codexmeter 1.0.1 → 1.0.3

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/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import express from 'express';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { attachLiveSubscriber, createIngestState, detachLiveSubscriber, restartIngest, runIngest } from './ingest.js';
4
+ import { attachLiveSubscriber, createIngestState, detachLiveSubscriber, getLatestReplay, restartIngest, runIngest } from './ingest.js';
5
+ import { createJobSummary, createVideoExportManager, getActiveVideoExportJob, getVideoExportJob, getVideoExportSupport, startOverviewVideoExport } from './export-video.js';
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
 
8
9
  export function createServer(codexHome, opts = {}) {
9
10
  const app = express();
11
+ app.use(express.json());
10
12
  const state = createIngestState();
13
+ const exportManager = createVideoExportManager();
11
14
  const distDir = path.join(__dirname, '..', 'dist');
12
15
  const apiOnly = opts.devApiOnly === true;
13
16
  const ingestOpts = { ...opts };
@@ -77,6 +80,85 @@ export function createServer(codexHome, opts = {}) {
77
80
  app.get('/api/heatmap', wrap('heatmap'));
78
81
  app.get('/api/families', wrap('families'));
79
82
 
83
+ app.post('/api/export/overview-video', async (req, res) => {
84
+ const activeJob = getActiveVideoExportJob(exportManager);
85
+ if (activeJob) {
86
+ res.status(409).json({ error: 'Another export job is already running.' });
87
+ return;
88
+ }
89
+ const replay = getLatestReplay(state);
90
+ if (!replay) {
91
+ res.status(409).json({ error: 'No completed ingest replay is available yet.' });
92
+ return;
93
+ }
94
+
95
+ const appBaseUrl = req.get('x-codexmeter-client-base') || opts.frontendBaseUrl || `${req.protocol}://${req.get('host')}`;
96
+ const settledEnvelope = state.aggregates ? {
97
+ overview: { data: state.aggregates.overview },
98
+ repos: { data: state.aggregates.repos },
99
+ models: { data: state.aggregates.models },
100
+ families: { data: state.aggregates.families },
101
+ daily: { data: state.aggregates.daily },
102
+ heatmap: { data: state.aggregates.heatmap },
103
+ } : null;
104
+ try {
105
+ const job = await startOverviewVideoExport(exportManager, {
106
+ replay,
107
+ settledEnvelope,
108
+ appBaseUrl,
109
+ installPortableBrowser: Boolean(req.body?.install_portable_browser),
110
+ });
111
+ res.status(202).json(createJobSummary(job, `${req.protocol}://${req.get('host')}`));
112
+ } catch (err) {
113
+ res.status(err.statusCode || 500).json({ error: err.message || String(err) });
114
+ }
115
+ });
116
+
117
+ app.get('/api/export/active', (req, res) => {
118
+ const job = getActiveVideoExportJob(exportManager);
119
+ if (!job) {
120
+ res.json({ job: null });
121
+ return;
122
+ }
123
+ res.json({ job: createJobSummary(job, `${req.protocol}://${req.get('host')}`) });
124
+ });
125
+
126
+ app.get('/api/export/support', async (_req, res) => {
127
+ res.json(await getVideoExportSupport());
128
+ });
129
+
130
+ app.get('/api/export/:jobId/status', (req, res) => {
131
+ const job = getVideoExportJob(exportManager, req.params.jobId);
132
+ if (!job) {
133
+ res.status(404).json({ error: 'Export job not found.' });
134
+ return;
135
+ }
136
+ res.json(createJobSummary(job, `${req.protocol}://${req.get('host')}`));
137
+ });
138
+
139
+ app.get('/api/export/:jobId/render-data', (req, res) => {
140
+ const job = getVideoExportJob(exportManager, req.params.jobId);
141
+ if (!job) {
142
+ res.status(404).json({ error: 'Export job not found.' });
143
+ return;
144
+ }
145
+ const payload = exportManager.getRenderPayload(req.params.jobId);
146
+ if (!payload) {
147
+ res.status(404).json({ error: 'Export render data not found.' });
148
+ return;
149
+ }
150
+ res.json(payload);
151
+ });
152
+
153
+ app.get('/api/export/:jobId/file', (req, res) => {
154
+ const job = getVideoExportJob(exportManager, req.params.jobId);
155
+ if (!job || job.status !== 'complete' || !job.output_path) {
156
+ res.status(404).json({ error: 'Export file not ready.' });
157
+ return;
158
+ }
159
+ res.download(job.output_path, job.file_name || `codexmeter-overview-${job.id}.mp4`);
160
+ });
161
+
80
162
  app.get('/api/sessions', (req, res) => {
81
163
  const q = (req.query.q || '').toLowerCase();
82
164
  let sessions = state.sessions || [];
package/server/ingest.js CHANGED
@@ -9,17 +9,14 @@ 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
 
@@ -100,6 +105,7 @@ export async function runIngest(codexHome, state, opts = {}) {
100
105
  usage_total: null,
101
106
  usage_by_day: null,
102
107
  has_usage_by_day: false,
108
+ live_sort_ts: null,
103
109
  active_by_day: null,
104
110
  agent_role: t.agent_role,
105
111
  agent_nickname: t.agent_nickname,
@@ -134,7 +140,7 @@ export async function runIngest(codexHome, state, opts = {}) {
134
140
 
135
141
  state.needs_enrichment = candidates.length;
136
142
  state.percent = candidates.length > 0 ? 0.08 : 0.90;
137
- const BATCH_SIZE = opts.batchSize || 100;
143
+ const BATCH_SIZE = opts.batchSize || 40;
138
144
  const ROOT_REFRESH_EVERY = opts.rootRefreshEvery || 1000;
139
145
  let lastRootRefreshCount = 0;
140
146
 
@@ -166,12 +172,16 @@ export async function runIngest(codexHome, state, opts = {}) {
166
172
  s.usage_by_day = buildUsageByDayMetrics(s.model_name, data.usage_by_day);
167
173
  s.has_usage_by_day = s.usage_by_day.length > 0;
168
174
  }
175
+ if (data.first_usage_timestamp) {
176
+ s.live_sort_ts = Math.floor(data.first_usage_timestamp / 1000);
177
+ }
169
178
  if (data.active_seconds && data.active_seconds > 0) {
170
179
  s.elapsed_seconds = data.active_seconds;
171
180
  s.active_by_day = data.active_by_day || null;
172
181
  }
173
182
  }
174
183
  finalizeSessionMetrics(s, toDayKey);
184
+ s.live_sort_day = deriveLiveSortDay(s, toDayKey);
175
185
  s.materialized = true;
176
186
  }
177
187
 
@@ -183,8 +193,14 @@ export async function runIngest(codexHome, state, opts = {}) {
183
193
  assignRootThreadIds(sessions);
184
194
  lastRootRefreshCount = state.enriched;
185
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
+ }
186
202
  const livePatch = createEmptyLivePatch();
187
- for (const session of batch) {
203
+ for (const session of liveReady) {
188
204
  applySessionToLiveState(state.live_state, session, livePatch);
189
205
  }
190
206
  queueLivePatch(state, livePatch);
@@ -203,6 +219,11 @@ export async function runIngest(codexHome, state, opts = {}) {
203
219
  }
204
220
 
205
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);
206
227
 
207
228
  state.phase = 'aggregation';
208
229
  state.percent = state.needs_enrichment > 0 ? 0.95 : 0.90;
@@ -222,6 +243,7 @@ export async function runIngest(codexHome, state, opts = {}) {
222
243
  if (!isCurrentRun()) return;
223
244
  state.error = err.message;
224
245
  state.phase = 'error';
246
+ failReplayCapture(state.replay_capture);
225
247
  console.error('Ingest error:', err);
226
248
  flushLive(state, 'ingest-error');
227
249
  } finally {
@@ -253,9 +275,11 @@ export function restartIngest(codexHome, state, opts = {}) {
253
275
  state.live_state = null;
254
276
  state.live_seq = 0;
255
277
  state.live_pending_patch = createEmptyLivePatch();
278
+ state.live_session_buffer = [];
256
279
  state.live_progress_dirty = false;
257
280
  state.live_last_emit_at = 0;
258
- 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);
259
283
 
260
284
  return runIngest(codexHome, state, opts);
261
285
  }
@@ -373,6 +397,49 @@ function buildUsageByDayMetrics(modelName, usageByDay) {
373
397
  return entries;
374
398
  }
375
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
+
376
443
  function queueLiveProgress(state) {
377
444
  state.live_progress_dirty = true;
378
445
  ensureLivePump(state);
@@ -385,13 +452,14 @@ function queueLivePatch(state, patch) {
385
452
  }
386
453
 
387
454
  function ensureLivePump(state) {
388
- 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;
389
457
  state.live_pump_timer = setInterval(() => flushLive(state), LIVE_FRAME_INTERVAL_MS);
390
458
  }
391
459
 
392
460
  function stopLivePumpIfIdle(state) {
393
461
  if (!state.live_pump_timer) return;
394
- if (state.live_subscribers.size) return;
462
+ if (state.live_subscribers.size || state.replay_capture.active) return;
395
463
  clearInterval(state.live_pump_timer);
396
464
  state.live_pump_timer = null;
397
465
  finalizeWithoutSubscribers(state);
@@ -399,6 +467,7 @@ function stopLivePumpIfIdle(state) {
399
467
 
400
468
  function finalizeWithoutSubscribers(state) {
401
469
  if (state.live_subscribers.size) return;
470
+ if (state.replay_capture.active) return;
402
471
  if (!state.presentation_complete_pending) return;
403
472
  state.phase = 'complete';
404
473
  state.percent = 1;
@@ -409,7 +478,7 @@ function finalizeWithoutSubscribers(state) {
409
478
  }
410
479
 
411
480
  function flushLive(state, forcedEvent = null) {
412
- if (!state.live_subscribers.size) {
481
+ if (!state.live_subscribers.size && !state.replay_capture.active) {
413
482
  state.live_pending_patch = createEmptyLivePatch();
414
483
  state.live_progress_dirty = false;
415
484
  stopLivePumpIfIdle(state);
@@ -449,12 +518,16 @@ function flushLive(state, forcedEvent = null) {
449
518
  payload.data = buildLiveBootstrap(state.live_state);
450
519
  }
451
520
 
452
- broadcastLive(state, event, payload);
521
+ recordReplayEvent(state.replay_capture, event, payload);
522
+ if (state.live_subscribers.size) {
523
+ broadcastLive(state, event, payload);
524
+ }
453
525
  state.live_last_emit_at = Date.now();
454
526
  state.live_progress_dirty = shouldEmitComplete ? false : (event === 'progress' ? false : state.live_progress_dirty);
455
527
  if (forcedEvent) {
456
528
  state.live_pending_patch = createEmptyLivePatch();
457
529
  }
530
+ stopLivePumpIfIdle(state);
458
531
  }
459
532
 
460
533
  function broadcastLive(state, event, payload) {
@@ -496,41 +569,48 @@ function takeFlushablePatch(state) {
496
569
  const now = Date.now();
497
570
  const sent = createEmptyLivePatch();
498
571
 
499
- if (state.live_pending_patch.overview.size > 0 && readyForSurface(state, 'overview', now)) {
500
- moveSet(state.live_pending_patch.overview, sent.overview);
501
- state.live_last_surface_emit_at.overview = now;
502
- }
503
-
504
- const rankingsDirty =
572
+ const overviewDirty =
573
+ state.live_pending_patch.overview.size > 0 ||
505
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 ||
506
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 ||
507
- 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;
508
578
 
509
- if (rankingsDirty && readyForSurface(state, 'rankings', now)) {
510
- moveRangeSets(state.live_pending_patch.repos, sent.repos);
511
- moveRangeSets(state.live_pending_patch.models, sent.models);
512
- moveRangeSets(state.live_pending_patch.families, sent.families);
513
- state.live_last_surface_emit_at.rankings = now;
579
+ if (!overviewDirty || !readyForOverview(state, now)) {
580
+ return sent;
514
581
  }
515
582
 
516
- const dayDirty = state.live_pending_patch.daily.size > 0 || state.live_pending_patch.heatmap.size > 0;
517
- if (dayDirty && readyForSurface(state, 'daily', now)) {
518
- const nextDayKeys = takeNextChronologicalDayKeys(
519
- state.live_pending_patch.daily,
520
- state.live_pending_patch.heatmap,
521
- LIVE_DAY_KEYS_PER_EMIT
522
- );
523
- moveSpecificKeys(state.live_pending_patch.daily, sent.daily, nextDayKeys);
524
- moveSpecificKeys(state.live_pending_patch.heatmap, sent.heatmap, nextDayKeys);
525
- state.live_last_surface_emit_at.daily = now;
526
- state.live_last_surface_emit_at.heatmap = now;
527
- }
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;
528
596
 
529
597
  return sent;
530
598
  }
531
599
 
532
- function readyForSurface(state, surfaceKey, now) {
533
- 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;
534
614
  }
535
615
 
536
616
  function moveSet(from, to) {
@@ -602,6 +682,10 @@ export function detachLiveSubscriber(state, res) {
602
682
  stopLivePumpIfIdle(state);
603
683
  }
604
684
 
685
+ export function getLatestReplay(state) {
686
+ return getReplaySnapshot(state.replay_capture);
687
+ }
688
+
605
689
  function broadcastBootstrap(state) {
606
690
  if (!state.live_subscribers.size) return;
607
691
  const payload = {
@@ -82,7 +82,7 @@ export function buildLiveBootstrap(live) {
82
82
  export function buildLivePatch(live, patch) {
83
83
  return {
84
84
  overview: Object.fromEntries(
85
- [...patch.overview].map((rangeKey) => [rangeKey, serializeOverviewBucket(live.overview[rangeKey])])
85
+ [...patch.overview].map((rangeKey) => [rangeKey, serializeOverviewBucket(live.overview[rangeKey], live.lowerBounds[rangeKey])])
86
86
  ),
87
87
  repos: serializePatchedTopRanges(live.repos, live.repoTopKeys, patch.repos, serializeRepoSummary),
88
88
  models: serializePatchedTopRanges(live.models, live.modelTopKeys, patch.models, serializeModelSummary),
@@ -352,14 +352,17 @@ function ensureHeatmapDay(dayMap, dayKey) {
352
352
 
353
353
  function serializeOverview(live) {
354
354
  return {
355
- total: serializeOverviewBucket(live.overview.total),
356
- d7: serializeOverviewBucket(live.overview.d7),
357
- d30: serializeOverviewBucket(live.overview.d30),
355
+ total: serializeOverviewBucket(live.overview.total, live.lowerBounds.total),
356
+ d7: serializeOverviewBucket(live.overview.d7, live.lowerBounds.d7),
357
+ d30: serializeOverviewBucket(live.overview.d30, live.lowerBounds.d30),
358
358
  cost_assumptions: CACHE_ASSUMPTIONS,
359
359
  };
360
360
  }
361
361
 
362
- function serializeOverviewBucket(bucket) {
362
+ function serializeOverviewBucket(bucket, lowerBound = 0) {
363
+ const boundedFrom = bucket.earliest === Infinity
364
+ ? null
365
+ : Math.max(bucket.earliest, lowerBound || 0);
363
366
  return {
364
367
  total_tokens: bucket.total_tokens,
365
368
  total_cost: bucket.total_cost,
@@ -368,7 +371,7 @@ function serializeOverviewBucket(bucket) {
368
371
  active_models: bucket.modelSet.size,
369
372
  total_elapsed_seconds: bucket.total_elapsed_seconds,
370
373
  date_range: {
371
- from: bucket.earliest === Infinity ? null : bucket.earliest,
374
+ from: boundedFrom,
372
375
  to: bucket.latest === -Infinity ? null : bucket.latest,
373
376
  },
374
377
  coverage: {
@@ -15,6 +15,7 @@ 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,
@@ -97,6 +98,9 @@ export async function enrichFromRollout(rolloutPath, opts = {}) {
97
98
  if (obj.timestamp && hasUsage(usageDelta)) {
98
99
  const ts = new Date(obj.timestamp).getTime();
99
100
  if (!isNaN(ts)) {
101
+ if (!result.first_usage_timestamp || ts < result.first_usage_timestamp) {
102
+ result.first_usage_timestamp = ts;
103
+ }
100
104
  const dayKey = toDayKey(ts);
101
105
  mergeUsageTotals(usageByDay, dayKey, usageDelta);
102
106
  }