codexmeter 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/ingest.js CHANGED
@@ -4,22 +4,19 @@ import {
4
4
  normalizeCwd, deriveRepoKey, deriveRepoLabel,
5
5
  classifyAgentFamily, isSubagent, normalizeModelName,
6
6
  } from './normalize.js';
7
- import { initPricing, priceSession } from './cost-catalog.js';
7
+ import { calculateCostFromUsage, initPricing, priceSession } from './cost-catalog.js';
8
8
  import { buildAggregates, buildSessionView } from './aggregator.js';
9
9
  import { createDayKeyFormatter } from './day-key.js';
10
10
  import { createLiveAggregateState, createEmptyLivePatch, applySessionToLiveState, buildLiveBootstrap, buildLivePatch } from './live-state.js';
11
11
  import { createRolloutWorkerPool } from './rollout-worker-pool.js';
12
+ import { beginReplayCapture, createReplayCaptureState, failReplayCapture, getReplaySnapshot, recordReplayEvent, resetReplayCapture } from './export-replay.js';
13
+ import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
12
14
 
13
15
  const LIVE_FRAME_INTERVAL_MS = 50;
14
16
  const LIVE_DAYS_PER_SECOND = 6;
15
- const LIVE_DAY_CADENCE_MS = Math.round(1000 / LIVE_DAYS_PER_SECOND);
17
+ const LIVE_OVERVIEW_CADENCE_MS = Math.round(1000 / 10);
16
18
  const LIVE_DAY_KEYS_PER_EMIT = 1;
17
- const LIVE_SURFACE_CADENCE_MS = {
18
- overview: LIVE_FRAME_INTERVAL_MS * 2,
19
- rankings: LIVE_FRAME_INTERVAL_MS * 3,
20
- daily: LIVE_DAY_CADENCE_MS,
21
- heatmap: LIVE_DAY_CADENCE_MS,
22
- };
19
+ const LIVE_SESSION_REORDER_BUFFER = 160;
23
20
 
24
21
  export function createIngestState() {
25
22
  return {
@@ -43,9 +40,11 @@ export function createIngestState() {
43
40
  live_subscribers: new Set(),
44
41
  live_pump_timer: null,
45
42
  live_pending_patch: createEmptyLivePatch(),
43
+ live_session_buffer: [],
46
44
  live_progress_dirty: false,
47
45
  live_last_emit_at: 0,
48
- live_last_surface_emit_at: { overview: 0, rankings: 0, daily: 0, heatmap: 0 },
46
+ live_last_overview_emit_at: 0,
47
+ replay_capture: createReplayCaptureState(),
49
48
  };
50
49
  }
51
50
 
@@ -60,6 +59,12 @@ export async function runIngest(codexHome, state, opts = {}) {
60
59
  state.live_state = createLiveAggregateState(tz);
61
60
  state.phase = 'inventory';
62
61
  state.percent = 0;
62
+ beginReplayCapture(state.replay_capture, state.ingest_id, {
63
+ ingest_id: state.ingest_id,
64
+ seq: 0,
65
+ progress: progressPayload(state),
66
+ data: buildLiveBootstrap(state.live_state),
67
+ });
63
68
  queueLiveProgress(state);
64
69
  broadcastBootstrap(state);
65
70
 
@@ -98,6 +103,9 @@ export async function runIngest(codexHome, state, opts = {}) {
98
103
  model_name: null,
99
104
  reasoning_effort: null,
100
105
  usage_total: null,
106
+ usage_by_day: null,
107
+ has_usage_by_day: false,
108
+ live_sort_ts: null,
101
109
  active_by_day: null,
102
110
  agent_role: t.agent_role,
103
111
  agent_nickname: t.agent_nickname,
@@ -132,7 +140,7 @@ export async function runIngest(codexHome, state, opts = {}) {
132
140
 
133
141
  state.needs_enrichment = candidates.length;
134
142
  state.percent = candidates.length > 0 ? 0.08 : 0.90;
135
- const BATCH_SIZE = opts.batchSize || 100;
143
+ const BATCH_SIZE = opts.batchSize || 40;
136
144
  const ROOT_REFRESH_EVERY = opts.rootRefreshEvery || 1000;
137
145
  let lastRootRefreshCount = 0;
138
146
 
@@ -160,12 +168,20 @@ export async function runIngest(codexHome, state, opts = {}) {
160
168
  if (data.reasoning_effort) s.reasoning_effort = data.reasoning_effort;
161
169
  if (data.parent_thread_id) s.parent_thread_id = data.parent_thread_id;
162
170
  if (data.usage_total) s.usage_total = data.usage_total;
171
+ if (data.usage_by_day) {
172
+ s.usage_by_day = buildUsageByDayMetrics(s.model_name, data.usage_by_day);
173
+ s.has_usage_by_day = s.usage_by_day.length > 0;
174
+ }
175
+ if (data.first_usage_timestamp) {
176
+ s.live_sort_ts = Math.floor(data.first_usage_timestamp / 1000);
177
+ }
163
178
  if (data.active_seconds && data.active_seconds > 0) {
164
179
  s.elapsed_seconds = data.active_seconds;
165
180
  s.active_by_day = data.active_by_day || null;
166
181
  }
167
182
  }
168
183
  finalizeSessionMetrics(s, toDayKey);
184
+ s.live_sort_day = deriveLiveSortDay(s, toDayKey);
169
185
  s.materialized = true;
170
186
  }
171
187
 
@@ -177,8 +193,14 @@ export async function runIngest(codexHome, state, opts = {}) {
177
193
  assignRootThreadIds(sessions);
178
194
  lastRootRefreshCount = state.enriched;
179
195
  }
196
+ const liveReady = bufferLiveSessionsForPresentation(state, batch);
197
+ if (state.live_session_buffer[0]?.live_sort_day) {
198
+ state.current_date_bucket = state.live_session_buffer[0].live_sort_day;
199
+ } else if (liveReady[0]?.live_sort_day) {
200
+ state.current_date_bucket = liveReady[0].live_sort_day;
201
+ }
180
202
  const livePatch = createEmptyLivePatch();
181
- for (const session of batch) {
203
+ for (const session of liveReady) {
182
204
  applySessionToLiveState(state.live_state, session, livePatch);
183
205
  }
184
206
  queueLivePatch(state, livePatch);
@@ -197,6 +219,11 @@ export async function runIngest(codexHome, state, opts = {}) {
197
219
  }
198
220
 
199
221
  assignRootThreadIds(sessions);
222
+ const finalLivePatch = createEmptyLivePatch();
223
+ for (const session of flushBufferedLiveSessions(state)) {
224
+ applySessionToLiveState(state.live_state, session, finalLivePatch);
225
+ }
226
+ queueLivePatch(state, finalLivePatch);
200
227
 
201
228
  state.phase = 'aggregation';
202
229
  state.percent = state.needs_enrichment > 0 ? 0.95 : 0.90;
@@ -216,6 +243,7 @@ export async function runIngest(codexHome, state, opts = {}) {
216
243
  if (!isCurrentRun()) return;
217
244
  state.error = err.message;
218
245
  state.phase = 'error';
246
+ failReplayCapture(state.replay_capture);
219
247
  console.error('Ingest error:', err);
220
248
  flushLive(state, 'ingest-error');
221
249
  } finally {
@@ -247,9 +275,11 @@ export function restartIngest(codexHome, state, opts = {}) {
247
275
  state.live_state = null;
248
276
  state.live_seq = 0;
249
277
  state.live_pending_patch = createEmptyLivePatch();
278
+ state.live_session_buffer = [];
250
279
  state.live_progress_dirty = false;
251
280
  state.live_last_emit_at = 0;
252
- state.live_last_surface_emit_at = { overview: 0, rankings: 0, daily: 0, heatmap: 0 };
281
+ state.live_last_overview_emit_at = 0;
282
+ resetReplayCapture(state.replay_capture);
253
283
 
254
284
  return runIngest(codexHome, state, opts);
255
285
  }
@@ -352,6 +382,64 @@ function finalizeSessionMetrics(session, toDayKey) {
352
382
  }
353
383
  }
354
384
 
385
+ function buildUsageByDayMetrics(modelName, usageByDay) {
386
+ const entries = [];
387
+ for (const [dayKey, usage] of Object.entries(usageByDay || {})) {
388
+ const tokens = (usage?.input_tokens || 0) + (usage?.output_tokens || 0);
389
+ const cost = calculateCostFromUsage(modelName, usage);
390
+ entries.push({
391
+ day: dayKey,
392
+ tokens,
393
+ cost,
394
+ });
395
+ }
396
+ entries.sort((a, b) => a.day.localeCompare(b.day));
397
+ return entries;
398
+ }
399
+
400
+ function deriveLiveSortDay(session, toDayKey) {
401
+ if (session.has_usage_by_day && session.usage_by_day?.length) {
402
+ let best = session.usage_by_day[0];
403
+ for (const entry of session.usage_by_day) {
404
+ if ((entry.tokens || 0) > (best.tokens || 0)) {
405
+ best = entry;
406
+ continue;
407
+ }
408
+ if ((entry.tokens || 0) === (best.tokens || 0) && String(entry.day) > String(best.day)) {
409
+ best = entry;
410
+ }
411
+ }
412
+ return best?.day || null;
413
+ }
414
+ if (session.started_at) {
415
+ return toDayKey(session.started_at * 1000);
416
+ }
417
+ return null;
418
+ }
419
+
420
+ function compareLiveSessionOrder(a, b) {
421
+ const dayCompare = String(a.live_sort_day || '9999-12-31').localeCompare(String(b.live_sort_day || '9999-12-31'));
422
+ if (dayCompare !== 0) return dayCompare;
423
+ const liveTsCompare = (a.live_sort_ts || a.started_at || 0) - (b.live_sort_ts || b.started_at || 0);
424
+ if (liveTsCompare !== 0) return liveTsCompare;
425
+ return String(a.thread_id || '').localeCompare(String(b.thread_id || ''));
426
+ }
427
+
428
+ function bufferLiveSessionsForPresentation(state, sessions) {
429
+ if (!sessions.length) return [];
430
+ state.live_session_buffer.push(...sessions);
431
+ state.live_session_buffer.sort(compareLiveSessionOrder);
432
+ const flushCount = Math.max(0, state.live_session_buffer.length - LIVE_SESSION_REORDER_BUFFER);
433
+ if (flushCount <= 0) return [];
434
+ return state.live_session_buffer.splice(0, flushCount);
435
+ }
436
+
437
+ function flushBufferedLiveSessions(state) {
438
+ if (!state.live_session_buffer.length) return [];
439
+ state.live_session_buffer.sort(compareLiveSessionOrder);
440
+ return state.live_session_buffer.splice(0, state.live_session_buffer.length);
441
+ }
442
+
355
443
  function queueLiveProgress(state) {
356
444
  state.live_progress_dirty = true;
357
445
  ensureLivePump(state);
@@ -364,13 +452,14 @@ function queueLivePatch(state, patch) {
364
452
  }
365
453
 
366
454
  function ensureLivePump(state) {
367
- if (state.live_pump_timer || !state.live_subscribers.size) return;
455
+ if (state.live_pump_timer) return;
456
+ if (!state.live_subscribers.size && !state.replay_capture.active) return;
368
457
  state.live_pump_timer = setInterval(() => flushLive(state), LIVE_FRAME_INTERVAL_MS);
369
458
  }
370
459
 
371
460
  function stopLivePumpIfIdle(state) {
372
461
  if (!state.live_pump_timer) return;
373
- if (state.live_subscribers.size) return;
462
+ if (state.live_subscribers.size || state.replay_capture.active) return;
374
463
  clearInterval(state.live_pump_timer);
375
464
  state.live_pump_timer = null;
376
465
  finalizeWithoutSubscribers(state);
@@ -378,6 +467,7 @@ function stopLivePumpIfIdle(state) {
378
467
 
379
468
  function finalizeWithoutSubscribers(state) {
380
469
  if (state.live_subscribers.size) return;
470
+ if (state.replay_capture.active) return;
381
471
  if (!state.presentation_complete_pending) return;
382
472
  state.phase = 'complete';
383
473
  state.percent = 1;
@@ -388,7 +478,7 @@ function finalizeWithoutSubscribers(state) {
388
478
  }
389
479
 
390
480
  function flushLive(state, forcedEvent = null) {
391
- if (!state.live_subscribers.size) {
481
+ if (!state.live_subscribers.size && !state.replay_capture.active) {
392
482
  state.live_pending_patch = createEmptyLivePatch();
393
483
  state.live_progress_dirty = false;
394
484
  stopLivePumpIfIdle(state);
@@ -428,12 +518,16 @@ function flushLive(state, forcedEvent = null) {
428
518
  payload.data = buildLiveBootstrap(state.live_state);
429
519
  }
430
520
 
431
- broadcastLive(state, event, payload);
521
+ recordReplayEvent(state.replay_capture, event, payload);
522
+ if (state.live_subscribers.size) {
523
+ broadcastLive(state, event, payload);
524
+ }
432
525
  state.live_last_emit_at = Date.now();
433
526
  state.live_progress_dirty = shouldEmitComplete ? false : (event === 'progress' ? false : state.live_progress_dirty);
434
527
  if (forcedEvent) {
435
528
  state.live_pending_patch = createEmptyLivePatch();
436
529
  }
530
+ stopLivePumpIfIdle(state);
437
531
  }
438
532
 
439
533
  function broadcastLive(state, event, payload) {
@@ -475,41 +569,48 @@ function takeFlushablePatch(state) {
475
569
  const now = Date.now();
476
570
  const sent = createEmptyLivePatch();
477
571
 
478
- if (state.live_pending_patch.overview.size > 0 && readyForSurface(state, 'overview', now)) {
479
- moveSet(state.live_pending_patch.overview, sent.overview);
480
- state.live_last_surface_emit_at.overview = now;
481
- }
482
-
483
- const rankingsDirty =
572
+ const overviewDirty =
573
+ state.live_pending_patch.overview.size > 0 ||
484
574
  state.live_pending_patch.repos.total.size > 0 || state.live_pending_patch.repos.d7.size > 0 || state.live_pending_patch.repos.d30.size > 0 ||
485
575
  state.live_pending_patch.models.total.size > 0 || state.live_pending_patch.models.d7.size > 0 || state.live_pending_patch.models.d30.size > 0 ||
486
- state.live_pending_patch.families.total.size > 0 || state.live_pending_patch.families.d7.size > 0 || state.live_pending_patch.families.d30.size > 0;
576
+ state.live_pending_patch.families.total.size > 0 || state.live_pending_patch.families.d7.size > 0 || state.live_pending_patch.families.d30.size > 0 ||
577
+ state.live_pending_patch.daily.size > 0 || state.live_pending_patch.heatmap.size > 0;
487
578
 
488
- if (rankingsDirty && readyForSurface(state, 'rankings', now)) {
489
- moveRangeSets(state.live_pending_patch.repos, sent.repos);
490
- moveRangeSets(state.live_pending_patch.models, sent.models);
491
- moveRangeSets(state.live_pending_patch.families, sent.families);
492
- state.live_last_surface_emit_at.rankings = now;
579
+ if (!overviewDirty || !readyForOverview(state, now)) {
580
+ return sent;
493
581
  }
494
582
 
495
- const dayDirty = state.live_pending_patch.daily.size > 0 || state.live_pending_patch.heatmap.size > 0;
496
- if (dayDirty && readyForSurface(state, 'daily', now)) {
497
- const nextDayKeys = takeNextChronologicalDayKeys(
498
- state.live_pending_patch.daily,
499
- state.live_pending_patch.heatmap,
500
- LIVE_DAY_KEYS_PER_EMIT
501
- );
502
- moveSpecificKeys(state.live_pending_patch.daily, sent.daily, nextDayKeys);
503
- moveSpecificKeys(state.live_pending_patch.heatmap, sent.heatmap, nextDayKeys);
504
- state.live_last_surface_emit_at.daily = now;
505
- state.live_last_surface_emit_at.heatmap = now;
506
- }
583
+ moveSet(state.live_pending_patch.overview, sent.overview);
584
+ moveRangeSets(state.live_pending_patch.repos, sent.repos);
585
+ moveRangeSets(state.live_pending_patch.models, sent.models);
586
+ moveRangeSets(state.live_pending_patch.families, sent.families);
587
+
588
+ const nextDayKeys = takeNextChronologicalDayKeys(
589
+ state.live_pending_patch.daily,
590
+ state.live_pending_patch.heatmap,
591
+ LIVE_DAY_KEYS_PER_EMIT
592
+ );
593
+ moveSpecificKeys(state.live_pending_patch.daily, sent.daily, nextDayKeys);
594
+ moveSpecificKeys(state.live_pending_patch.heatmap, sent.heatmap, nextDayKeys);
595
+ state.live_last_overview_emit_at = now;
507
596
 
508
597
  return sent;
509
598
  }
510
599
 
511
- function readyForSurface(state, surfaceKey, now) {
512
- return (now - state.live_last_surface_emit_at[surfaceKey]) >= LIVE_SURFACE_CADENCE_MS[surfaceKey];
600
+ function readyForOverview(state, now) {
601
+ return (now - state.live_last_overview_emit_at) >= getOverviewCadenceMs(state);
602
+ }
603
+
604
+ function getOverviewCadenceMs(state) {
605
+ const tail = OVERVIEW_INGEST_ANIMATION.tail;
606
+ const tailStartPercent = Math.min(Math.max(tail?.startPercent ?? 0.95, 0), 0.999);
607
+ const tailHz = Number.isFinite(tail?.overviewHz) && tail.overviewHz > 0 ? tail.overviewHz : 5;
608
+ const tailCadenceMs = Math.round(1000 / tailHz);
609
+
610
+ if (tail?.enabled && (state.presentation_complete_pending || (state.percent || 0) >= tailStartPercent)) {
611
+ return tailCadenceMs;
612
+ }
613
+ return LIVE_OVERVIEW_CADENCE_MS;
513
614
  }
514
615
 
515
616
  function moveSet(from, to) {
@@ -581,6 +682,10 @@ export function detachLiveSubscriber(state, res) {
581
682
  stopLivePumpIfIdle(state);
582
683
  }
583
684
 
685
+ export function getLatestReplay(state) {
686
+ return getReplaySnapshot(state.replay_capture);
687
+ }
688
+
584
689
  function broadcastBootstrap(state) {
585
690
  if (!state.live_subscribers.size) return;
586
691
  const payload = {
@@ -217,7 +217,10 @@ function applyDailyBucket(dayMap, session, toDayKey, dirtySet) {
217
217
  const startDay = toDayKey(startMs);
218
218
  const endDay = toDayKey(endMs - 1);
219
219
 
220
- if (startDay === endDay) {
220
+ if (session.has_usage_by_day) {
221
+ addSessionPresence(dayMap, startDay, endDay, startMs, endMs, totalDur, toDayKey, session, dirtySet);
222
+ addUsageByDay(dayMap, session, dirtySet);
223
+ } else if (startDay === endDay) {
221
224
  addToDay(dayMap, startDay, session, 1.0);
222
225
  dirtySet.add(startDay);
223
226
  } else {
@@ -247,7 +250,21 @@ function applyDailyBucket(dayMap, session, toDayKey, dirtySet) {
247
250
  }
248
251
 
249
252
  function applyHeatmapBucket(dayMap, session, toDayKey, dirtySet) {
250
- if (session.started_at) {
253
+ if (session.has_usage_by_day) {
254
+ for (const usageDay of session.usage_by_day || []) {
255
+ const dayKey = usageDay.day;
256
+ const day = ensureHeatmapDay(dayMap, dayKey);
257
+ day.tokens += usageDay.tokens || 0;
258
+ if (usageDay.cost !== null) day.cost += usageDay.cost;
259
+ dirtySet.add(dayKey);
260
+ }
261
+ if (session.started_at) {
262
+ const startDay = toDayKey(session.started_at * 1000);
263
+ const day = ensureHeatmapDay(dayMap, startDay);
264
+ day.sessions += 1;
265
+ dirtySet.add(startDay);
266
+ }
267
+ } else if (session.started_at) {
251
268
  const dayKey = toDayKey(session.started_at * 1000);
252
269
  const day = ensureHeatmapDay(dayMap, dayKey);
253
270
  day.tokens += session.tokens_used || 0;
@@ -265,6 +282,48 @@ function applyHeatmapBucket(dayMap, session, toDayKey, dirtySet) {
265
282
  }
266
283
  }
267
284
 
285
+ function addSessionPresence(dayMap, startDay, endDay, startMs, endMs, totalDur, toDayKey, session, dirtySet) {
286
+ if (startDay === endDay) {
287
+ addPresenceToDay(dayMap, startDay, session, 1.0);
288
+ dirtySet.add(startDay);
289
+ return;
290
+ }
291
+
292
+ let cursor = dayStartMs(startDay);
293
+ while (cursor < endMs) {
294
+ const nextDay = cursor + 86400000;
295
+ const overlapStart = Math.max(cursor, startMs);
296
+ const overlapEnd = Math.min(nextDay, endMs);
297
+ const fraction = (overlapEnd - overlapStart) / totalDur;
298
+ if (fraction > 0) {
299
+ const dayKey = toDayKey(cursor);
300
+ addPresenceToDay(dayMap, dayKey, session, fraction);
301
+ dirtySet.add(dayKey);
302
+ }
303
+ cursor = nextDay;
304
+ }
305
+ }
306
+
307
+ function addPresenceToDay(dayMap, dayKey, session, fraction) {
308
+ const day = ensureDay(dayMap, dayKey);
309
+ if (fraction > 0.001) day.sessions += 1;
310
+ }
311
+
312
+ function addUsageByDay(dayMap, session, dirtySet) {
313
+ for (const usageDay of session.usage_by_day || []) {
314
+ const dayKey = usageDay.day;
315
+ const day = ensureDay(dayMap, dayKey);
316
+ day.tokens += usageDay.tokens || 0;
317
+ if (usageDay.cost !== null) day.cost += usageDay.cost;
318
+
319
+ const modelKey = session.model_name || 'unknown';
320
+ if (!day.by_model[modelKey]) day.by_model[modelKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
321
+ day.by_model[modelKey].tokens += usageDay.tokens || 0;
322
+ if (usageDay.cost !== null) day.by_model[modelKey].cost += usageDay.cost;
323
+ dirtySet.add(dayKey);
324
+ }
325
+ }
326
+
268
327
  function addToDay(dayMap, dayKey, session, fraction) {
269
328
  const day = ensureDay(dayMap, dayKey);
270
329
  day.tokens += (session.tokens_used || 0) * fraction;
@@ -15,9 +15,11 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
15
15
  model_name: null,
16
16
  reasoning_effort: null,
17
17
  first_timestamp: null,
18
+ first_usage_timestamp: null,
18
19
  last_timestamp: null,
19
20
  active_seconds: null,
20
21
  active_by_day: null,
22
+ usage_by_day: null,
21
23
  parent_thread_id: null,
22
24
  usage_total: null,
23
25
  };
@@ -28,6 +30,11 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
28
30
  let prevTimestamp = null;
29
31
  let activeMs = 0;
30
32
  const activeByDay = new Map();
33
+ const usageByDay = new Map();
34
+ let lastInputTokens = 0;
35
+ let lastCachedInputTokens = 0;
36
+ let lastOutputTokens = 0;
37
+ let hasSeenUsage = false;
31
38
  for (const line of content.split(/\r?\n/)) {
32
39
  if (!line.trim()) continue;
33
40
 
@@ -58,13 +65,46 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
58
65
  if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
59
66
  const usage = obj.payload.info?.total_token_usage;
60
67
  if (usage) {
68
+ const inputTokens = usage.input_tokens || 0;
69
+ const cachedInputTokens = usage.cached_input_tokens || 0;
70
+ const outputTokens = usage.output_tokens || 0;
71
+ const reasoningOutputTokens = usage.reasoning_output_tokens || 0;
72
+ const totalTokens = usage.total_tokens || 0;
73
+
61
74
  result.usage_total = {
62
- input_tokens: usage.input_tokens || 0,
63
- cached_input_tokens: usage.cached_input_tokens || 0,
64
- output_tokens: usage.output_tokens || 0,
65
- reasoning_output_tokens: usage.reasoning_output_tokens || 0,
66
- total_tokens: usage.total_tokens || 0,
75
+ input_tokens: inputTokens,
76
+ cached_input_tokens: cachedInputTokens,
77
+ output_tokens: outputTokens,
78
+ reasoning_output_tokens: reasoningOutputTokens,
79
+ total_tokens: totalTokens,
67
80
  };
81
+
82
+ const usageDelta = hasSeenUsage
83
+ ? {
84
+ input_tokens: Math.max(inputTokens - lastInputTokens, 0),
85
+ cached_input_tokens: Math.max(cachedInputTokens - lastCachedInputTokens, 0),
86
+ output_tokens: Math.max(outputTokens - lastOutputTokens, 0),
87
+ }
88
+ : {
89
+ input_tokens: inputTokens,
90
+ cached_input_tokens: cachedInputTokens,
91
+ output_tokens: outputTokens,
92
+ };
93
+
94
+ hasSeenUsage = true;
95
+ lastInputTokens = inputTokens;
96
+ lastCachedInputTokens = cachedInputTokens;
97
+ lastOutputTokens = outputTokens;
98
+ if (obj.timestamp && hasUsage(usageDelta)) {
99
+ const ts = new Date(obj.timestamp).getTime();
100
+ if (!isNaN(ts)) {
101
+ if (!result.first_usage_timestamp || ts < result.first_usage_timestamp) {
102
+ result.first_usage_timestamp = ts;
103
+ }
104
+ const dayKey = toDayKey(ts);
105
+ mergeUsageTotals(usageByDay, dayKey, usageDelta);
106
+ }
107
+ }
68
108
  }
69
109
  }
70
110
 
@@ -100,9 +140,51 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
100
140
  result.active_by_day = activeByDaySeconds;
101
141
  result.active_seconds = Object.values(activeByDaySeconds).reduce((sum, seconds) => sum + seconds, 0);
102
142
  }
143
+ if (usageByDay.size > 0) {
144
+ result.usage_by_day = Object.fromEntries(usageByDay);
145
+ }
103
146
  } catch {
104
147
  return null;
105
148
  }
106
149
 
107
150
  return result;
108
151
  }
152
+
153
+ function normalizeUsageTotals(usage) {
154
+ return {
155
+ input_tokens: usage.input_tokens || 0,
156
+ cached_input_tokens: usage.cached_input_tokens || 0,
157
+ output_tokens: usage.output_tokens || 0,
158
+ reasoning_output_tokens: usage.reasoning_output_tokens || 0,
159
+ total_tokens: usage.total_tokens || 0,
160
+ };
161
+ }
162
+
163
+ function subtractUsageTotals(current, previous) {
164
+ return {
165
+ input_tokens: Math.max((current.input_tokens || 0) - (previous.input_tokens || 0), 0),
166
+ cached_input_tokens: Math.max((current.cached_input_tokens || 0) - (previous.cached_input_tokens || 0), 0),
167
+ output_tokens: Math.max((current.output_tokens || 0) - (previous.output_tokens || 0), 0),
168
+ reasoning_output_tokens: Math.max((current.reasoning_output_tokens || 0) - (previous.reasoning_output_tokens || 0), 0),
169
+ total_tokens: Math.max((current.total_tokens || 0) - (previous.total_tokens || 0), 0),
170
+ };
171
+ }
172
+
173
+ function hasUsage(usage) {
174
+ return !!usage && (
175
+ usage.input_tokens > 0 ||
176
+ usage.cached_input_tokens > 0 ||
177
+ usage.output_tokens > 0
178
+ );
179
+ }
180
+
181
+ function mergeUsageTotals(target, dayKey, usage) {
182
+ let previous = target.get(dayKey);
183
+ if (!previous) {
184
+ previous = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 };
185
+ target.set(dayKey, previous);
186
+ }
187
+ previous.input_tokens += usage.input_tokens || 0;
188
+ previous.cached_input_tokens += usage.cached_input_tokens || 0;
189
+ previous.output_tokens += usage.output_tokens || 0;
190
+ }