src/dashboard/stats.js
Rama master · réplica de lectura del repositorio original.
/**
* Request statistics collector with debounced JSON persistence.
*/
import { readFileSync, existsSync } from 'fs';
import { AsyncLocalStorage } from 'node:async_hooks';
import { writeJsonAtomic } from '../fs-atomic.js';
import { join } from 'path';
import { config } from '../config.js';
import { getModelInfo, getCloudSelectorPricing, resolveModel } from '../models.js';
import { resolveConnectSelector } from '../devin-connect-models.js';
import { accountRef } from '../log-safety.js';
const STATS_FILE = join(config.dataDir, 'stats.json');
// v2.0.9x — cardinality bound for per-model stats. modelCounts is keyed by
// the client-controlled model string; without a cap, case-permuted names or
// free-tier passthrough names grow RAM + stats.json + the per-poll sort
// unbounded. Cap the number of *real* model keys (LRU-evict the
// least-recently-updated one into a shared '(other)' bucket so totals still
// reconcile). Secure default is bounded; set STATS_MAX_MODELS=0 to opt back
// into the old unbounded behavior.
const OTHER_MODEL_KEY = '(other)';
const MAX_MODELS = (() => {
const raw = parseInt(process.env.STATS_MAX_MODELS ?? '', 10);
return Number.isFinite(raw) && raw >= 0 ? raw : 500;
})();
// Monotonic recency counter for LRU eviction. Wall-clock time ties within a
// single millisecond (many requests can land there), which would make
// eviction fall back to insertion order and starve hot-but-old keys; a
// strictly-increasing seq gives an unambiguous least-recently-updated pick.
let _touchSeq = 0;
// audit S8: cache of the current-hour hourlyBuckets entry so recordRequest
// avoids an O(n) .find per request. Reset (null) means "re-resolve on next
// record" — safe after restart or hour rollover.
let _curBucket = null;
// Every caller-keyed map is stored on a null prototype so a model literally
// named "__proto__" is an ordinary own key instead of Object.prototype. The
// public getStats()/exportStats() views convert these back to plain objects so
// JSON consumers keep the historical shape.
const MAP_KEYS = ['modelCounts', 'accountCounts', 'creditsByHour', 'creditsByDay', 'creditsByModel', 'tokensByModel'];
const UNSAFE_MODEL_KEYS = new Set(['__proto__', 'constructor', 'toString', 'prototype']);
/** A dictionary with no inherited keys. */
function emptyMap() {
return Object.create(null);
}
/**
* True for model names that would collide with a prototype/function property.
* `Object.hasOwn(Object.prototype, key)` catches the wider inherited surface
* (`valueOf`, `hasOwnProperty`, …) while the explicit set documents the three
* names this fix is contracted to reject.
*/
function isUnsafeModelKey(key) {
return typeof key !== 'string' || key === '' || UNSAFE_MODEL_KEYS.has(key)
|| Object.hasOwn(Object.prototype, key);
}
/** Shallow plain-object copy of an internal null-prototype map. */
function plainMap(source) {
return Object.fromEntries(Object.entries(source && typeof source === 'object' ? source : {}));
}
/** Ensure a state map is null-prototype; migrate a plain object in place. */
function ensureNullProtoMap(name) {
const current = _state[name];
if (!current || typeof current !== 'object' || Array.isArray(current)
|| Object.getPrototypeOf(current) !== null) {
if (name === 'modelCounts') _state[name] = adoptModelCounts(current);
else if (name === 'tokensByModel') _state[name] = adoptTokensByModel(current);
else if (name === 'creditsByModel') _state[name] = adoptCreditsByModel(current);
else _state[name] = adoptMap(current);
}
return _state[name];
}
/** Count of tracked model keys excluding the shared overflow bucket. */
function realModelKeyCount(modelCounts = _state.modelCounts) {
let n = 0;
for (const k of Object.keys(modelCounts)) {
if (k !== OTHER_MODEL_KEY) n++;
}
return n;
}
/** Fold an evicted model's counts into the shared '(other)' bucket. */
function foldIntoOther(src, modelCounts = _state.modelCounts) {
let dst = Object.hasOwn(modelCounts, OTHER_MODEL_KEY) ? modelCounts[OTHER_MODEL_KEY] : null;
if (!dst) {
dst = { requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0 };
modelCounts[OTHER_MODEL_KEY] = dst;
}
dst.requests += src.requests || 0;
dst.success += src.success || 0;
dst.errors += src.errors || 0;
dst.totalMs += src.totalMs || 0;
if (!dst.recentMs) dst.recentMs = [];
if (Array.isArray(src.recentMs) && src.recentMs.length) {
for (const v of src.recentMs) dst.recentMs.push(v);
if (dst.recentMs.length > 200) dst.recentMs = dst.recentMs.slice(-200);
}
dst.lastTs = modelCounts === _state.modelCounts ? ++_touchSeq : Math.max(dst.lastTs || 0, src.lastTs || 0);
}
/**
* The modelCounts key a request belongs to, applying the cardinality cap.
*
* Shared by recordRequest and recordTokenUsage so the two maps cannot disagree
* about which bucket a model landed in — a token row under a key the request
* table never created would render as a model with tokens and zero requests.
*/
function boundedModelKey(model) {
let key = typeof model === 'string' && model ? model : OTHER_MODEL_KEY;
// Reject prototype-shaped names before any map lookup: "__proto__" would
// otherwise resolve to Object.prototype, and "constructor"/"toString" to a
// shared function object. They cannot be real model ids, so account for the
// request in the shared overflow bucket instead.
if (key !== OTHER_MODEL_KEY && isUnsafeModelKey(key)) key = OTHER_MODEL_KEY;
if (key !== OTHER_MODEL_KEY && !Object.hasOwn(_state.modelCounts, key)) {
enforceModelCap();
if (MAX_MODELS > 0 && realModelKeyCount() >= MAX_MODELS) key = OTHER_MODEL_KEY;
}
return key;
}
/**
* Ensure there is room for one more real model key, LRU-evicting the
* least-recently-updated real model into '(other)' while over the cap.
* No-op when MAX_MODELS is 0 (unbounded).
*/
function enforceModelCap(reserveSlot = true, modelCounts = _state.modelCounts) {
const evicted = [];
if (MAX_MODELS <= 0) return evicted;
while (realModelKeyCount(modelCounts) > MAX_MODELS - (reserveSlot ? 1 : 0)) {
let coldestKey = null;
let coldestTs = Infinity;
for (const [k, s] of Object.entries(modelCounts)) {
if (k === OTHER_MODEL_KEY) continue;
const ts = s.lastTs || 0;
if (ts < coldestTs) { coldestTs = ts; coldestKey = k; }
}
if (coldestKey == null) break;
foldIntoOther(modelCounts[coldestKey], modelCounts);
delete modelCounts[coldestKey];
evicted.push(coldestKey);
}
// Only the live state owns the live token map, and only the keys THIS call
// evicted may be folded: a token row can legitimately exist before its request
// row (recordTokenUsage runs first on several chat paths), so sweeping every
// key absent from modelCounts would misfile a live model under '(other)'.
if (modelCounts === _state.modelCounts && evicted.length) {
pruneTokensToModelCounts(evicted, modelCounts);
}
return evicted;
}
/**
* Fold the given models' token rows into '(other)'.
*
* Used for keys the request table actually lost (LRU eviction, or an imported
* snapshot whose cap dropped them). The dashboard joins the two maps by key, and
* an orphan would render a model that "moved 2M tokens" while the request table
* says it never ran.
*/
function pruneTokensToModelCounts(keys, modelCounts = _state.modelCounts, tokens = _state.tokensByModel) {
if (!tokens || typeof tokens !== 'object') return;
for (const key of keys || []) {
if (key === OTHER_MODEL_KEY) continue;
if (Object.hasOwn(modelCounts, key)) continue;
if (!Object.hasOwn(tokens, key)) continue;
const dst = tokens[OTHER_MODEL_KEY] || {
fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0,
};
tokens[OTHER_MODEL_KEY] = dst;
for (const field of Object.keys(dst)) dst[field] += tokens[key][field] || 0;
delete tokens[key];
}
}
const STATS_DEFAULTS = {
startedAt: 0,
totalRequests: 0,
successCount: 0,
errorCount: 0,
modelCounts: {}, // { "gpt-4o-mini": { requests, success, errors, totalMs } }
accountCounts: {}, // { "acct-<8 hex>": { requests, success, errors } } — opaque ref, never a key/email
hourlyBuckets: [], // [{ hour: "2026-04-09T07:00:00Z", requests, errors }]
// v2.0.69 (#118 wnfilm) — bucket-level token totals so the dashboard
// can show fresh_input / cache_read / cache_write / output without
// having to recompute from the per-request usage stream. Keyed by
// bucket so summing across the proxy lifetime is just `totals[k]`.
tokenTotals: {
fresh_input: 0,
cache_read: 0,
cache_write: 0,
output: 0,
total: 0,
requests_with_usage: 0,
},
// v2.0.91 — track upstream rejection/cooldown events
policyBlockedCount: 0,
// v3.4.x — content-policy-block observability ring. The upstream content
// policy is non-deterministic (same prompt blocks then passes), so we
// capture a small system-prompt-only sample at block time for later A/B.
// Bounded ring, newest last; cap via POLICY_BLOCK_RING (default 50).
recentPolicyBlocks: [],
rateLimitedCount: 0,
// v2.0.148 — Credits spend dimension. creditsByHour/Day are keyed maps
// { "<iso-hour|day>": creditsFloat } so the dashboard can chart spend over
// time. Cost per request = MODELS[model].credit (rate card), summed here so we
// never have to thread cost through the 10 recordRequest call sites. Only
// successful attempts are charged; failed attempts keep their request row
// with credit 0, so the log and this card share one number.
creditsTotal: 0,
creditsByHour: {}, // { "2026-07-09T01:00:00Z": 12.5 }
creditsByDay: {}, // { "2026-07-09": 340.0 }
creditsByModel: {}, // { "claude-opus-4-8-medium": 88.0 }
// v3.10.x — per-model token totals. tokenTotals answers "how many tokens did
// this deployment move"; this answers "which model moved them", which is the
// question a bill asks. Keyed by the same bounded model key modelCounts uses,
// so a client-permuted name cannot grow this map without bound.
tokensByModel: {}, // { "swe-2-medium": { fresh_input, cache_read, cache_write, output, total, requests_with_usage } }
// v2.0.148 — per-request detail ring buffer (bounded, persisted) so long runs
// keep a rolling window of recent calls for audit/export instead of losing them.
recentRequests: [], // [{ ts, model, success, ms, account, credit }] newest last, cap 500; account is an opaque acct-<hash> ref
};
function createStatsState() {
const state = structuredClone(STATS_DEFAULTS);
for (const key of MAP_KEYS) state[key] = emptyMap();
return { ...state, startedAt: Date.now() };
}
const _state = createStatsState();
const RECENT_REQ_CAP = 500;
const ACCOUNT_REF_RE = /^acct-[0-9a-f]{8}$/;
/**
* Canonical account reference for stats persistence and display.
*
* The request log used to key this dimension by `String(accountId).slice(0, 8)`.
* Chat handlers passed the credential the attempt presented, and every
* `devin-session-token$…` key starts with `devin-se`, so all connect accounts
* collapsed into one bucket and stats.json stored a credential prefix. Callers
* now pass the immutable account id; this boundary turns any value into an
* opaque `acct-<8 hex>` ref (the same shape the DevIn panel uses). A value that
* already looks like a ref passes through, so re-reading a persisted row or
* importing an exported snapshot is idempotent.
*/
function normalizeAccountRef(value) {
if (value == null) return null;
const raw = typeof value === 'string' ? value : String(value);
if (!raw) return null;
return ACCOUNT_REF_RE.test(raw) ? raw : accountRef(raw);
}
/** Re-key a persisted account map by normalized ref, merging old duplicates. */
function normalizeAccountCounts(map) {
const out = emptyMap();
const source = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
for (const [key, counts] of Object.entries(source)) {
const ref = normalizeAccountRef(key);
if (!ref) continue;
const current = out[ref] || { requests: 0, success: 0, errors: 0 };
current.requests += Number(counts?.requests) || 0;
current.success += Number(counts?.success) || 0;
current.errors += Number(counts?.errors) || 0;
out[ref] = current;
}
return out;
}
/** Scrub the credential-derived `account` values older versions persisted. */
function normalizeRecentRequests(rows) {
if (!Array.isArray(rows)) return [];
for (const row of rows) {
if (row && typeof row === 'object') row.account = normalizeAccountRef(row.account);
}
return rows;
}
/**
* Copy a persisted/generic map onto a null-prototype dictionary. Only own
* enumerable keys are read, and the three prototype-shaped names are dropped.
* Model-keyed maps use the adopt* helpers below, which fold unsafe names into
* the shared '(other)' bucket instead of silently losing their totals.
*/
function adoptMap(source) {
const out = emptyMap();
if (!source || typeof source !== 'object' || Array.isArray(source)) return out;
for (const [key, value] of Object.entries(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
out[key] = value;
}
return out;
}
/** Adopt persisted model rows, folding prototype-shaped keys into '(other)'. */
function adoptModelCounts(source) {
const out = emptyMap();
if (!source || typeof source !== 'object' || Array.isArray(source)) return out;
for (const [key, value] of Object.entries(source)) {
if (!isUnsafeModelKey(key)) { out[key] = value; continue; }
if (!value || typeof value !== 'object') continue;
const dst = out[OTHER_MODEL_KEY] || (out[OTHER_MODEL_KEY] = {
requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0,
});
for (const field of ['requests', 'success', 'errors', 'totalMs']) {
dst[field] = (Number(dst[field]) || 0) + (Number(value[field]) || 0);
}
if (!Array.isArray(dst.recentMs)) dst.recentMs = [];
if (Array.isArray(value.recentMs)) {
for (const sample of value.recentMs) {
if (Number.isFinite(sample)) dst.recentMs.push(sample);
}
if (dst.recentMs.length > 200) dst.recentMs = dst.recentMs.slice(-200);
}
dst.lastTs = Math.max(Number(dst.lastTs) || 0, Number(value.lastTs) || 0);
}
return out;
}
const TOKEN_FIELDS = ['fresh_input', 'cache_read', 'cache_write', 'output', 'total', 'requests_with_usage'];
/** Adopt persisted token rows, folding prototype-shaped keys into '(other)'. */
function adoptTokensByModel(source) {
const out = emptyMap();
if (!source || typeof source !== 'object' || Array.isArray(source)) return out;
for (const [key, value] of Object.entries(source)) {
if (!isUnsafeModelKey(key)) { out[key] = value; continue; }
if (!value || typeof value !== 'object') continue;
let dst = out[OTHER_MODEL_KEY];
if (!dst) {
dst = emptyMap();
out[OTHER_MODEL_KEY] = dst;
}
for (const field of TOKEN_FIELDS) {
dst[field] = (Number(dst[field]) || 0) + (Number(value[field]) || 0);
}
}
return out;
}
/** Adopt persisted per-model credits, folding prototype-shaped keys into '(other)'. */
function adoptCreditsByModel(source) {
const out = emptyMap();
if (!source || typeof source !== 'object' || Array.isArray(source)) return out;
for (const [key, value] of Object.entries(source)) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const target = isUnsafeModelKey(key) ? OTHER_MODEL_KEY : key;
out[target] = (Object.hasOwn(out, target) ? out[target] : 0) + value;
}
return out;
}
// ─── Per-HTTP-request accounting scope ─────────────────────────────────
// recordRequest (the attempt row) and recordTokenUsage (the usage block) are
// called from different depths of a chat handler and, depending on the route,
// in either order. A module-level "last row" pointer would attribute tokens to
// whichever concurrent request happened to finish last, so the two calls are
// joined through the async context the HTTP server establishes per request.
// Outside such a scope (unit tests, direct handler calls) both functions keep
// their pre-existing aggregate-only behaviour and the log row gets the
// success-derived status.
const _requestScope = new AsyncLocalStorage();
/**
* Run `fn` inside a per-request accounting scope. Returns fn's own result.
* Re-entrant: an existing scope is reused, so wrapping a handler twice never
* splits one request across two scopes.
*/
export function withStatsScope(fn) {
const existing = _requestScope.getStore();
if (existing) return fn(existing);
const scope = { rows: [], pendingUsage: null, status: null };
return _requestScope.run(scope, () => fn(scope));
}
/**
* Close a scope. The last attempt row is the one the client actually received,
* so it gets the real HTTP status; earlier failover attempts keep the
* success-derived 200/500 they were recorded with. Rows are the same objects
* held in recentRequests, so this patch persists with the next scheduled save.
*/
export function finalizeStatsScope(scope) {
if (!scope) return;
const row = scope.rows[scope.rows.length - 1];
if (row && Number.isFinite(scope.status)) row.status = scope.status;
}
/** Non-negative finite number or 0 — rows come from persisted JSON too. */
function nonNeg(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : 0;
}
/**
* Normalize an OpenAI-shaped usage block into the four counters the request
* log stores. `tokensIn` is the full billable input (fresh + cache read +
* cache write), matching what tokenTotals.total counts, so a row's
* tokensIn + tokensOut always reconciles with the lifetime token card.
*/
function usageTokenCounts(usage) {
if (!usage || typeof usage !== 'object') return null;
const bd = usage.cascade_breakdown || null;
const prompt = nonNeg(usage.prompt_tokens);
const cacheRead = nonNeg(bd?.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens);
const cacheWrite = nonNeg(bd?.cache_write_tokens ?? usage.cache_creation_input_tokens);
// Connect usage carries the fresh bucket explicitly because its prompt_tokens
// includes cache_write (O2); `prompt - cached` would count the write as fresh.
const fresh = nonNeg(bd?.fresh_input_tokens ?? usage.fresh_input_tokens ?? Math.max(0, prompt - cacheRead));
const tokensOut = nonNeg(bd?.output_tokens ?? usage.completion_tokens ?? usage.output_tokens);
if (!fresh && !cacheRead && !cacheWrite && !tokensOut) return null;
return { tokensIn: fresh + cacheRead + cacheWrite, tokensOut, cacheRead, cacheWrite };
}
function applyUsageToRow(row, usage) {
const counts = usageTokenCounts(usage);
if (!counts) return;
row.tokensIn = counts.tokensIn;
row.tokensOut = counts.tokensOut;
row.cacheRead = counts.cacheRead;
row.cacheWrite = counts.cacheWrite;
}
// Load persisted stats. The reviver drops prototype-shaped keys at every
// depth (JSON.parse creates an own "__proto__" property, and Object.assign
// would otherwise invoke its setter on _state). Each keyed map is then rebuilt
// on a null prototype so a legacy stats.json written by the vulnerable version
// cannot reintroduce a prototype key.
try {
if (existsSync(STATS_FILE)) {
const saved = JSON.parse(readFileSync(STATS_FILE, 'utf-8'), (key, value) =>
key === '__proto__' || key === 'constructor' || key === 'prototype' ? undefined : value);
Object.assign(_state, saved);
_state.modelCounts = adoptModelCounts(_state.modelCounts);
_state.tokensByModel = adoptTokensByModel(_state.tokensByModel);
_state.creditsByModel = adoptCreditsByModel(_state.creditsByModel);
_state.creditsByHour = adoptMap(_state.creditsByHour);
_state.creditsByDay = adoptMap(_state.creditsByDay);
// Older versions persisted the credential prefix (or a raw id) as the
// account key/value. Normalize on load so stats.json and the dashboard stop
// displaying it without waiting for the 500-row ring to rotate.
_state.accountCounts = normalizeAccountCounts(_state.accountCounts);
_state.recentRequests = normalizeRecentRequests(_state.recentRequests);
// Reseed the recency counter above any persisted lastTs so LRU ordering
// survives a restart without an early wrap-collision.
for (const s of Object.values(_state.modelCounts || {})) {
if (s && s.lastTs > _touchSeq) _touchSeq = s.lastTs;
}
}
} catch {}
// Debounced save
let _saveTimer = null;
function scheduleSave() {
clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
try {
writeJsonAtomic(STATS_FILE, _state);
} catch {}
}, 5000);
}
function getHourKey() {
const d = new Date();
d.setMinutes(0, 0, 0);
return d.toISOString();
}
/**
* Credit cost of one request, from live billing data when the sync knows it.
*
* The static rate card is a snapshot of one moment and knows nothing about
* promotions: swe-2-medium's card says 6 credits while the upstream marks it
* FREE until 2026-11-01. Metering the card would report spend the account never
* incurred, and the free badge on the model row would then contradict the very
* number next to it. Precedence: live free → 0; live credit rate → that rate;
* nothing live → the static card (the pre-existing behaviour).
*/
function requestCreditCost(model) {
if (!model) return 0;
const key = resolveModel(model) || model;
const resolution = resolveConnectSelector(key, { warnOnFallback: false });
if (resolution?.mapped) {
const live = getCloudSelectorPricing(resolution.selector);
if (live.freeNow === true) return 0;
if (typeof live.rate === 'number') return Math.max(0, live.rate);
}
const card = Number(getModelInfo(key)?.credit);
return Number.isFinite(card) && card > 0 ? card : 0;
}
/**
* Record a completed request.
*
* `extra` is optional and additive: `{ status, usage }`. `status` is the real
* HTTP status when the caller knows it; otherwise the row carries the
* success-derived 200/500. `usage` lets a caller that already holds the usage
* block attach it directly; normally recordTokenUsage joins it via the scope.
*/
export function recordRequest(model, success, durationMs, accountId, extra = {}) {
_state.totalRequests++;
if (success) _state.successCount++;
else _state.errorCount++;
// A cache-served turn is real client traffic but ZERO upstream consumption:
// no request reached the backend, so it must not charge the model's credit
// card. Before this flag the Connect replay arms called recordRequest like any
// completed turn, so every cache hit re-billed the answer it replayed. The row
// keeps the marker so the log can tell the two apart.
const cacheServed = extra?.cacheServed === true;
// Per-model stats (includes a small ring buffer for p50/p95 latency).
// Cap real-model cardinality: a brand-new key that would exceed the limit
// gets folded into the shared '(other)' bucket instead (LRU eviction of the
// coldest existing key). Known keys and '(other)' itself always update.
// The map is null-prototype and every read is an own-key check, so a model
// string can never resolve to an inherited object/function.
const modelCounts = ensureNullProtoMap('modelCounts');
const key = boundedModelKey(model);
if (!Object.hasOwn(modelCounts, key)) {
modelCounts[key] = { requests: 0, success: 0, errors: 0, totalMs: 0, recentMs: [], lastTs: 0 };
}
const mc = modelCounts[key];
mc.requests++;
if (success) mc.success++;
else mc.errors++;
mc.totalMs += durationMs;
mc.lastTs = ++_touchSeq;
if (!mc.recentMs) mc.recentMs = [];
if (durationMs > 0) {
mc.recentMs.push(durationMs);
if (mc.recentMs.length > 200) mc.recentMs.shift();
}
// Per-account stats, keyed by the same opaque ref the log row carries.
const account = normalizeAccountRef(accountId);
if (account) {
const accountCounts = ensureNullProtoMap('accountCounts');
if (!Object.hasOwn(accountCounts, account)) {
accountCounts[account] = { requests: 0, success: 0, errors: 0 };
}
const ac = accountCounts[account];
ac.requests++;
if (success) ac.success++;
else ac.errors++;
}
// Hourly bucket. audit S8: the current-hour bucket is (almost) always the
// last element, so a per-request O(n) linear .find over up to 720 buckets was
// wasted work. Cache the current hour's bucket ref and only re-resolve when
// the hour rolls over (or after a restart, where _curBucket starts null and
// we fall back to the tail/find once).
const hourKey = getHourKey();
let bucket = _curBucket && _curBucket.hour === hourKey ? _curBucket : null;
if (!bucket) {
const tail = _state.hourlyBuckets[_state.hourlyBuckets.length - 1];
bucket = tail && tail.hour === hourKey ? tail : _state.hourlyBuckets.find(b => b.hour === hourKey);
}
if (!bucket) {
bucket = { hour: hourKey, requests: 0, errors: 0 };
_state.hourlyBuckets.push(bucket);
// Keep last 30 days of hourly data (720 buckets)
if (_state.hourlyBuckets.length > 720) _state.hourlyBuckets.shift();
}
_curBucket = bucket;
bucket.requests++;
if (!success) bucket.errors++;
// v2.0.148 — Credits spend + per-request detail. Cost comes from the live
// billing sync when it knows the model (free promo → 0, catalog/rate-table
// multiplier otherwise) and only falls back to the static rate card when the
// upstream has said nothing. Persisted maps stay JSON-safe.
//
// A failed attempt is not spend: the credits card has always charged
// successes only, while the row used to carry the model's price anyway, so
// the log's cost column/summary over-reported by every failover, 429 and 500.
// Store the charged amount (0 on failure); `success` is the explicit failed
// flag, so the row and the card share one number.
let credit = 0;
if (success && !cacheServed) {
// Never let a prototype-shaped model name reach the catalog lookup either;
// it cannot name a real price card.
const costModel = typeof model === 'string' && !isUnsafeModelKey(model) ? model : null;
try { credit = requestCreditCost(costModel); } catch { credit = 0; }
}
if (credit > 0) {
const dayKey = hourKey.slice(0, 10);
const creditsByHour = ensureNullProtoMap('creditsByHour');
const creditsByDay = ensureNullProtoMap('creditsByDay');
const creditsByModel = ensureNullProtoMap('creditsByModel');
_state.creditsTotal = (_state.creditsTotal || 0) + credit;
creditsByHour[hourKey] = (Object.hasOwn(creditsByHour, hourKey) ? creditsByHour[hourKey] : 0) + credit;
creditsByDay[dayKey] = (Object.hasOwn(creditsByDay, dayKey) ? creditsByDay[dayKey] : 0) + credit;
// Key the credit row by the same bounded key as modelCounts so the
// dashboard join stays intact and a dangerous model name is normalized.
creditsByModel[key] = (Object.hasOwn(creditsByModel, key) ? creditsByModel[key] : 0) + credit;
// Prune credit hour map to ~30 days (720 keys), day map to ~90 days.
pruneKeyed(creditsByHour, 720);
pruneKeyed(creditsByDay, 90);
}
if (!Array.isArray(_state.recentRequests)) _state.recentRequests = [];
const scope = _requestScope.getStore();
const row = {
ts: Date.now(), model, success: !!success, ms: durationMs || 0,
account,
credit,
status: Number.isFinite(extra?.status) ? extra.status : (success ? 200 : 500),
tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0,
...(cacheServed ? { cacheServed: true } : {}),
};
// Usage can legitimately arrive before its row (the DEVIN_CONNECT non-stream
// path bills inside the attempt, then finalizes the account). The scope holds
// it until the row exists; a usage block handed in directly wins over it.
const usage = extra.usage || scope?.pendingUsage || null;
if (usage) applyUsageToRow(row, usage);
if (scope) {
scope.rows.push(row);
scope.pendingUsage = null;
}
_state.recentRequests.push(row);
if (_state.recentRequests.length > RECENT_REQ_CAP) {
_state.recentRequests.splice(0, _state.recentRequests.length - RECENT_REQ_CAP);
}
scheduleSave();
}
// Keep a keyed map bounded to the most recent `max` keys (lexicographic ISO
// order == chronological). Drops the oldest keys when over cap.
function pruneKeyed(map, max) {
const keys = Object.keys(map);
if (keys.length <= max) return;
keys.sort();
for (const k of keys.slice(0, keys.length - max)) delete map[k];
}
function percentile(sortedArr, p) {
if (!sortedArr.length) return 0;
const idx = Math.min(sortedArr.length - 1, Math.floor(sortedArr.length * p));
return sortedArr[idx];
}
/**
* Split the lifetime request total into rows that carry an account id and rows
* that cannot be attributed to any account. Global/unauthenticated paths call
* recordRequest with `accountId == null`, so they increment totalRequests but
* have no accountCounts key — the account dimension silently sums short of the
* page total. Derived at read time so pre-fix persisted snapshots reconcile
* without a migration.
*/
function accountAttribution() {
let attributed = 0;
for (const s of Object.values(_state.accountCounts || {})) {
const n = Number(s?.requests);
if (Number.isFinite(n) && n > 0) attributed += n;
}
const total = Number(_state.totalRequests) || 0;
return { total, attributed, unattributed: Math.max(0, total - attributed) };
}
/** Get all stats, with computed latency percentiles per model. */
export function getStats() {
const out = { ..._state };
out.accountAttribution = accountAttribution();
// Rebuild every keyed map as a plain object: the public response keeps its
// historical Object.prototype shape, while Object.fromEntries creates own
// data properties (never the __proto__ setter) for every key.
out.modelCounts = Object.fromEntries(Object.entries(_state.modelCounts).map(([m, s]) => {
const sorted = (s.recentMs || []).slice().sort((a, b) => a - b);
return [m, {
requests: s.requests,
success: s.success,
errors: s.errors,
totalMs: s.totalMs,
avgMs: s.requests > 0 ? Math.round(s.totalMs / s.requests) : 0,
p50Ms: Math.round(percentile(sorted, 0.5)),
p95Ms: Math.round(percentile(sorted, 0.95)),
}];
}));
out.accountCounts = plainMap(_state.accountCounts);
out.creditsByHour = plainMap(_state.creditsByHour);
out.creditsByDay = plainMap(_state.creditsByDay);
out.creditsByModel = plainMap(_state.creditsByModel);
out.tokensByModel = plainMap(_state.tokensByModel);
return out;
}
// v2.0.148 — Export the full stats state as a JSON-serializable snapshot (for
// backup / migration / offline analysis). Includes credits + recentRequests.
export function exportStats() {
const out = { ..._state };
for (const key of MAP_KEYS) out[key] = plainMap(_state[key]);
return {
_exportedAt: new Date().toISOString(),
_schema: 'windsurfapi-stats-v2',
...out,
};
}
function normalizeStatsSnapshot(src) {
const object = value => value && typeof value === 'object' && !Array.isArray(value);
const number = (value = 0) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error('invalid number');
return value;
};
const counts = (value, keys) => {
if (!object(value)) throw new Error('invalid counters');
// Own-key reads only: a polluted Object.prototype must never supply a
// counter that the imported snapshot did not actually contain.
return Object.fromEntries(keys.map(key =>
[key, number(Object.hasOwn(value, key) ? value[key] : 0)]));
};
const out = createStatsState();
if (!object(src) || !Object.keys(out).some(key => Object.hasOwn(src, key))) throw new Error('empty snapshot');
if (src._schema != null && !['windsurfapi-stats-v1', 'windsurfapi-stats-v2'].includes(src._schema)) {
throw new Error('unsupported schema');
}
for (const [key, value] of Object.entries(out)) {
if (typeof value === 'number' && Object.hasOwn(src, key)) out[key] = number(src[key]);
}
for (const key of ['modelCounts', 'accountCounts', 'creditsByHour', 'creditsByDay', 'creditsByModel']) {
if (!Object.hasOwn(src, key)) continue;
if (!object(src[key])) throw new Error('invalid map');
for (const [name, value] of Object.entries(src[key])) {
if (key.startsWith('credits')) { out[key][name] = number(value); continue; }
const entry = counts(value, key === 'modelCounts'
? ['requests', 'success', 'errors', 'totalMs', 'lastTs'] : ['requests', 'success', 'errors']);
if (key === 'modelCounts') {
if (value.recentMs !== undefined && !Array.isArray(value.recentMs)) throw new Error('invalid latency samples');
entry.recentMs = (value.recentMs || []).map(v => number(v)).slice(-200);
}
out[key][name] = entry;
}
}
// Snapshots written before the id fix carry credential prefixes in both the
// account map keys and the request rows; scrub them on import too.
out.accountCounts = normalizeAccountCounts(out.accountCounts);
if (Object.hasOwn(src, 'tokenTotals')) out.tokenTotals = counts(src.tokenTotals, Object.keys(out.tokenTotals));
if (Object.hasOwn(src, 'tokensByModel')) {
if (!object(src.tokensByModel)) throw new Error('invalid token map');
for (const [name, value] of Object.entries(src.tokensByModel)) {
out.tokensByModel[name] = counts(value,
['fresh_input', 'cache_read', 'cache_write', 'output', 'total', 'requests_with_usage']);
}
}
for (const key of ['hourlyBuckets', 'recentRequests', 'recentPolicyBlocks']) {
if (!Object.hasOwn(src, key)) continue;
if (!Array.isArray(src[key])) throw new Error('invalid history');
out[key] = src[key].map(entry => {
if (!object(entry)) throw new Error('invalid history entry');
if (key === 'hourlyBuckets') {
if (typeof entry.hour !== 'string' || !Number.isFinite(Date.parse(entry.hour))) throw new Error('invalid hour');
return { hour: new Date(entry.hour).toISOString(), ...counts(entry, ['requests', 'errors']) };
}
number(entry.ts);
if (!Object.hasOwn(entry, 'ts')) throw new Error('missing timestamp');
if (key === 'recentRequests') {
if (typeof entry.model !== 'string') throw new Error('invalid model');
if (entry.success !== undefined && typeof entry.success !== 'boolean') throw new Error('invalid success flag');
if (entry.account != null && typeof entry.account !== 'string') throw new Error('invalid account');
for (const field of ['ms', 'credit']) if (Object.hasOwn(entry, field)) number(entry[field]);
} else if (typeof entry.promptHash !== 'string') throw new Error('invalid prompt hash');
return entry;
});
}
out.recentRequests = normalizeRecentRequests(out.recentRequests);
return out;
}
function mergeStatsHistory(existing, incoming, identity, cap) {
const entries = new Map(existing.map(entry => [identity(entry), entry]));
for (const entry of incoming) {
const key = identity(entry);
if (!entries.has(key)) entries.set(key, entry);
}
return [...entries.values()].sort((a, b) => a.ts - b.ts).slice(-cap);
}
// v2.0.148 — Import a previously exported snapshot. MERGE mode (default) adds
// counts onto current; REPLACE overwrites. Numeric fields are summed, keyed
// maps merged, recentRequests concatenated + de-duped by ts+model then capped.
export function importStats(snap, { mode = 'merge' } = {}) {
if (!['merge', 'replace'].includes(mode)) return { ok: false, error: 'invalid mode' };
let next;
try {
// Clone and validate before touching live state; prototype keys are stripped at every depth.
const src = normalizeStatsSnapshot(JSON.parse(JSON.stringify(snap), (key, value) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
return value;
}));
next = mode === 'replace' ? src : normalizeStatsSnapshot(JSON.parse(JSON.stringify(_state)));
if (mode === 'merge') {
for (const [key, value] of Object.entries(src)) {
if (typeof value === 'number' && key !== 'startedAt') next[key] += value;
}
for (const key of ['creditsByHour', 'creditsByDay', 'creditsByModel', 'tokenTotals']) {
for (const [name, value] of Object.entries(src[key])) {
next[key][name] = (Object.hasOwn(next[key], name) ? next[key][name] : 0) + value;
}
}
for (const [name, incoming] of Object.entries(src.tokensByModel)) {
const current = Object.hasOwn(next.tokensByModel, name) ? next.tokensByModel[name] : null;
if (!current) { next.tokensByModel[name] = { ...incoming }; continue; }
for (const field of Object.keys(current)) current[field] += incoming[field] || 0;
}
for (const key of ['modelCounts', 'accountCounts']) {
for (const [name, incoming] of Object.entries(src[key])) {
const current = Object.hasOwn(next[key], name) ? next[key][name] : null;
if (!current) { next[key][name] = incoming; continue; }
for (const field of ['requests', 'success', 'errors']) current[field] += incoming[field];
if (key === 'modelCounts') {
current.totalMs += incoming.totalMs;
current.lastTs = Math.max(current.lastTs, incoming.lastTs);
current.recentMs = [...current.recentMs, ...incoming.recentMs].slice(-200);
}
}
}
}
const hours = new Map();
for (const bucket of [...(mode === 'merge' ? src.hourlyBuckets : []), ...next.hourlyBuckets]) {
const current = hours.get(bucket.hour);
if (current) { current.requests += bucket.requests; current.errors += bucket.errors; }
else hours.set(bucket.hour, { ...bucket });
}
next.hourlyBuckets = [...hours.values()].sort((a, b) => a.hour.localeCompare(b.hour)).slice(-720);
next.recentRequests = mergeStatsHistory(next.recentRequests, mode === 'merge' ? src.recentRequests : [],
r => `${r.ts}|${r.model}`, RECENT_REQ_CAP);
next.recentPolicyBlocks = mergeStatsHistory(next.recentPolicyBlocks, mode === 'merge' ? src.recentPolicyBlocks : [],
r => `${r.ts}|${r.promptHash}`, Number(process.env.POLICY_BLOCK_RING) || 50);
pruneKeyed(next.creditsByHour, 720);
pruneKeyed(next.creditsByDay, 90);
enforceModelCap(false, next.modelCounts);
pruneTokensToModelCounts(Object.keys(next.tokensByModel || {}), next.modelCounts, next.tokensByModel);
next = normalizeStatsSnapshot(next); // Also reject numeric overflow from merging.
} catch {
return { ok: false, error: 'invalid snapshot' };
}
Object.assign(_state, next);
for (const entry of Object.values(_state.modelCounts)) _touchSeq = Math.max(_touchSeq, entry.lastTs);
_curBucket = null;
scheduleSave();
return { ok: true, mode };
}
/** Reset all stats. */
export function resetStats() {
_state.totalRequests = 0;
_state.successCount = 0;
_state.errorCount = 0;
_state.modelCounts = emptyMap();
_state.accountCounts = emptyMap();
_state.hourlyBuckets = [];
_curBucket = null; // invalidate cached bucket ref (S8) — buckets array replaced
_state.tokenTotals = {
fresh_input: 0, cache_read: 0, cache_write: 0,
output: 0, total: 0, requests_with_usage: 0,
};
_state.creditsTotal = 0;
_state.creditsByHour = emptyMap();
_state.creditsByDay = emptyMap();
_state.creditsByModel = emptyMap();
_state.tokensByModel = emptyMap();
_state.recentRequests = [];
_state.recentPolicyBlocks = [];
_state.startedAt = Date.now();
scheduleSave();
}
/**
* v2.0.69 (#118): record per-request token bucket totals so the dashboard
* can show real fresh-input vs cache-read vs cache-write breakdown
* instead of the conflated prompt_tokens number.
*
* Accepts the OpenAI-shaped usage object that buildUsageBody returns —
* cascade_breakdown is the authoritative source when present, otherwise
* fall back to standard fields. Connect usage (O2) has no cascade_breakdown but
* carries `fresh_input_tokens` at the top level, because there prompt_tokens
* includes cache_write and `prompt - cached` would book the write as fresh.
*/
export function recordTokenUsage(usage, model = null) {
if (!usage || typeof usage !== 'object') return;
const bd = usage.cascade_breakdown || null;
const fresh = bd?.fresh_input_tokens ?? usage.fresh_input_tokens ?? Math.max(0, (usage.prompt_tokens || 0) - (usage.prompt_tokens_details?.cached_tokens || usage.cache_read_input_tokens || 0));
const cacheR = bd?.cache_read_tokens ?? (usage.prompt_tokens_details?.cached_tokens || usage.cache_read_input_tokens || 0);
const cacheW = bd?.cache_write_tokens ?? (usage.cache_creation_input_tokens || 0);
const output = bd?.output_tokens ?? (usage.completion_tokens || usage.output_tokens || 0);
if (!fresh && !cacheR && !cacheW && !output) return;
if (!_state.tokenTotals) {
_state.tokenTotals = { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0 };
}
_state.tokenTotals.fresh_input += fresh;
_state.tokenTotals.cache_read += cacheR;
_state.tokenTotals.cache_write += cacheW;
_state.tokenTotals.output += output;
_state.tokenTotals.total += fresh + cacheR + cacheW + output;
_state.tokenTotals.requests_with_usage += 1;
// Per-model attribution. Only when the caller KNOWS the model: filing an
// unknown request under '(other)' would invent a model row that no client
// ever named, and the global totals above already count it. Prototype-shaped
// names are unknown by the same rule — rejecting them here also prevents
// `tokensByModel['__proto__']` from resolving to Object.prototype.
const requested = typeof model === 'string' && model.trim() ? model : null;
const key = requested && !isUnsafeModelKey(requested) ? boundedModelKey(requested) : null;
if (key) {
const tokensByModel = ensureNullProtoMap('tokensByModel');
if (!Object.hasOwn(tokensByModel, key)) {
tokensByModel[key] = {
fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0,
};
}
const tm = tokensByModel[key];
tm.fresh_input += fresh;
tm.cache_read += cacheR;
tm.cache_write += cacheW;
tm.output += output;
tm.total += fresh + cacheR + cacheW + output;
tm.requests_with_usage += 1;
}
// Per-request attribution. The scope is the only safe join: a concurrent
// request for the same model must never inherit these counts. When the usage
// arrives before the attempt row (connect non-stream), hold it until
// recordRequest creates that row.
const scope = _requestScope.getStore();
if (scope) {
const row = scope.rows[scope.rows.length - 1];
if (row) applyUsageToRow(row, usage);
else scope.pendingUsage = usage;
}
scheduleSave();
}
export function recordPolicyBlocked(sample = null) {
_state.policyBlockedCount = (_state.policyBlockedCount || 0) + 1;
if (sample) {
if (!Array.isArray(_state.recentPolicyBlocks)) _state.recentPolicyBlocks = [];
_state.recentPolicyBlocks.push(sample);
const CAP = Number(process.env.POLICY_BLOCK_RING) || 50;
if (_state.recentPolicyBlocks.length > CAP) {
_state.recentPolicyBlocks.splice(0, _state.recentPolicyBlocks.length - CAP);
}
}
scheduleSave();
}
export function recordRateLimited() {
_state.rateLimitedCount = (_state.rateLimitedCount || 0) + 1;
scheduleSave();
}
// ─── Request log query ─────────────────────────────────────────────────
// Read-only view over the persisted per-request ring (recentRequests). Every
// number is computed from the rows that pass the filters, so the table and the
// aggregates below it always share one denominator. Retention is bounded and
// stated in the response (retention.stored/capacity); the panel labels the
// window instead of implying full history.
const LOG_PAGE_MAX = 200;
function logRowView(row) {
const ts = Number(row?.ts);
const rawStatus = row?.status;
const derived = row?.success ? 200 : 500;
const status = rawStatus == null || rawStatus === ''
? derived
: (Number.isFinite(Number(rawStatus)) ? Number(rawStatus) : derived);
return {
ts: Number.isFinite(ts) ? ts : 0,
model: typeof row?.model === 'string' ? row.model : '',
account: normalizeAccountRef(row?.account),
status,
success: !!row?.success,
ms: nonNeg(row?.ms),
tokensIn: nonNeg(row?.tokensIn),
tokensOut: nonNeg(row?.tokensOut),
cacheRead: nonNeg(row?.cacheRead),
cacheWrite: nonNeg(row?.cacheWrite),
credit: nonNeg(row?.credit),
};
}
function emptyAggregate(extra) {
return {
...extra,
requests: 0, success: 0, errors: 0,
tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, credits: 0, totalMs: 0,
_latencies: [],
};
}
function addToAggregate(agg, row) {
agg.requests += 1;
if (row.success) agg.success += 1;
else agg.errors += 1;
agg.tokensIn += row.tokensIn;
agg.tokensOut += row.tokensOut;
agg.cacheRead += row.cacheRead;
agg.cacheWrite += row.cacheWrite;
agg.credits += row.credit;
agg.totalMs += row.ms;
if (row.ms > 0) agg._latencies.push(row.ms);
}
function finishAggregate(agg) {
const latencies = agg._latencies || [];
agg.avgMs = agg.requests > 0 ? Math.round(agg.totalMs / agg.requests) : 0;
agg.p50Ms = percentileOf(latencies, 0.5);
agg.p95Ms = percentileOf(latencies, 0.95);
delete agg.totalMs;
delete agg._latencies;
return agg;
}
/** Nearest-rank percentile over an unsorted sample; 0 when empty. */
function percentileOf(values, p) {
if (!values.length) return 0;
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
}
/**
* Newest-first page of request-log rows plus aggregates over the whole
* filtered window (not just the page). Filters: `from`/`to` epoch ms, exact
* `model`/`account`, numeric `status`.
*/
export function getRequestLog({
from = null, to = null, model = null, account = null, status = null,
limit = 50, offset = 0,
} = {}) {
const rows = Array.isArray(_state.recentRequests) ? _state.recentRequests : [];
const fromNum = from == null || from === '' ? null : Number(from);
const toNum = to == null || to === '' ? null : Number(to);
const fromMs = Number.isFinite(fromNum) ? fromNum : null;
const toMs = Number.isFinite(toNum) ? toNum : null;
const statusNum = status == null || status === '' ? null : Number(status);
const modelFilter = typeof model === 'string' && model ? model : null;
const accountFilter = typeof account === 'string' && account ? account : null;
const filtered = [];
for (const raw of rows) {
if (!raw || typeof raw !== 'object') continue;
const row = logRowView(raw);
if (fromMs != null && row.ts < fromMs) continue;
if (toMs != null && row.ts > toMs) continue;
if (modelFilter && row.model !== modelFilter) continue;
if (accountFilter && row.account !== accountFilter) continue;
if (statusNum != null && (!Number.isFinite(statusNum) || row.status !== statusNum)) continue;
filtered.push(row);
}
const n = Math.max(1, Math.min(LOG_PAGE_MAX, Number(limit) || 50));
const off = Math.max(0, Math.floor(Number(offset) || 0));
const newestFirst = filtered.slice().reverse();
const items = newestFirst.slice(off, off + n);
const summary = emptyAggregate({});
const byModel = new Map();
const byAccount = new Map();
const byDay = new Map();
const unattributed = emptyAggregate({ account: null });
for (const row of filtered) {
addToAggregate(summary, row);
if (row.model) {
const agg = byModel.get(row.model) || emptyAggregate({ model: row.model });
addToAggregate(agg, row);
byModel.set(row.model, agg);
}
if (row.account) {
const agg = byAccount.get(row.account) || emptyAggregate({ account: row.account });
addToAggregate(agg, row);
byAccount.set(row.account, agg);
} else {
addToAggregate(unattributed, row);
}
const day = new Date(row.ts).toISOString().slice(0, 10);
const agg = byDay.get(day) || emptyAggregate({ day });
addToAggregate(agg, row);
byDay.set(day, agg);
}
finishAggregate(summary);
finishAggregate(unattributed);
const models = [...byModel.values()]
.map(finishAggregate)
.sort((a, b) => b.requests - a.requests || a.model.localeCompare(b.model));
const accounts = [...byAccount.values()]
.map(finishAggregate)
.sort((a, b) => b.requests - a.requests || String(a.account).localeCompare(String(b.account)));
const days = [...byDay.values()]
.map(finishAggregate)
.sort((a, b) => a.day.localeCompare(b.day));
const all = rows.map(logRowView);
return {
items,
total: filtered.length,
limit: n,
offset: off,
hasMore: off + items.length < filtered.length,
retention: { stored: all.length, capacity: RECENT_REQ_CAP },
filters: {
models: [...new Set(all.map(r => r.model).filter(Boolean))].sort(),
accounts: [...new Set(all.map(r => r.account).filter(Boolean))].sort(),
statuses: [...new Set(all.map(r => r.status).filter(Number.isFinite))].sort((a, b) => a - b),
},
summary,
byModel: models,
byAccount: accounts,
// Rows with no account id: byAccount + unattributed = summary, so the
// account tab can state the remainder instead of silently summing short.
unattributed,
byDay: days,
};
}