dashboard-blipburst 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -85,7 +85,14 @@ This also unlocks exact MTTR (measured time from a failed call to the next succe
85
85
  - **MTTR per fault type** — mean time from a fault firing to recovery, per `Fault['kind']`.
86
86
  - **Experiment run history** — events grouped by run (one per adapter instance / process), with start/end time, event and failure counts, and a fault-kind breakdown.
87
87
 
88
- History persists to `.blipburst/events.jsonl` in the dashboard's working directory across restarts.
88
+ ## Data retention
89
+
90
+ Two different things are kept, on purpose, with different lifetimes:
91
+
92
+ - **Raw event log** (`.blipburst/events.jsonl`) — the last 5,000 events, in memory and on disk. This backs the live feed / `/api/events`. It's a rolling window: once it fills up, older raw events are dropped to keep disk and memory use bounded, whether the dashboard runs for an hour or a month.
93
+ - **Aggregates** (`.blipburst/aggregates.json`) — per-endpoint heatmap totals, per-fault-kind MTTR running averages, and per-run summaries. These are updated incrementally on every event and never pruned, so the heatmap, MTTR, and run history stay accurate for the dashboard's entire lifetime even after the raw log has rolled over many times. This file stays small (a few KB) regardless of how much raw traffic has passed through.
94
+
95
+ In the browser, the dashboard also caches recent events in IndexedDB (capped at 2,000 rows, oldest pruned first) so a page reload repaints instantly from local cache and only delta-fetches what's new from the server, instead of re-downloading the full recent-events window every time.
89
96
 
90
97
  ## HTTP API
91
98
 
package/bin/cli.js CHANGED
@@ -19,7 +19,7 @@ async function main() {
19
19
  const { port } = parseArgs(process.argv.slice(2));
20
20
  const preferredPort = Number.isFinite(port) ? port : DEFAULT_PORT;
21
21
 
22
- const { port: resolvedPort } = await startDashboardServer({ port: preferredPort });
22
+ const { port: resolvedPort, store } = await startDashboardServer({ port: preferredPort });
23
23
 
24
24
  console.log(`BlipBurst dashboard running at http://localhost:${resolvedPort}`);
25
25
  if (resolvedPort !== preferredPort) {
@@ -27,7 +27,10 @@ async function main() {
27
27
  }
28
28
  console.log(`Port written to .blipburst/port — point BlipBurst's logger/webhook adapter at it automatically.`);
29
29
 
30
- const shutdown = () => process.exit(0);
30
+ const shutdown = () => {
31
+ store.flushNow(); // persist the last few seconds of heatmap/MTTR/run aggregates before exiting
32
+ process.exit(0);
33
+ };
31
34
  process.on('SIGINT', shutdown);
32
35
  process.on('SIGTERM', shutdown);
33
36
  }
package/index.d.ts CHANGED
@@ -54,9 +54,15 @@ export interface StartDashboardServerOptions {
54
54
  maxPortAttempts?: number;
55
55
  }
56
56
 
57
+ export interface DashboardEventStore {
58
+ /** Force an immediate flush of the persistent heatmap/MTTR/run aggregates — call before process exit. */
59
+ flushNow(): void;
60
+ }
61
+
57
62
  export interface StartDashboardServerResult {
58
63
  server: import('node:http').Server;
59
64
  port: number;
65
+ store: DashboardEventStore;
60
66
  }
61
67
 
62
68
  /** Starts the dashboard HTTP+SSE server. Used by the `blipburst-dashboard` bin; importable for embedding/tests. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dashboard-blipburst",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local live dashboard for BlipBurst — fault feed, experiment run history, endpoint/tenant chaos heatmap, and MTTR per fault type. Install globally, point BlipBurst's logger/webhook at it.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/public/index.html CHANGED
@@ -178,7 +178,66 @@
178
178
  document.getElementById('port-hint').textContent = location.host;
179
179
 
180
180
  const MAX_FEED_ROWS = 150;
181
+ const IDB_NAME = 'blipburst-dashboard';
182
+ const IDB_STORE = 'events';
183
+ const IDB_CAP = 2000; // client-local retention cap — separate from the server's own MAX_EVENTS
181
184
  let feedRows = [];
185
+ let idb = null;
186
+
187
+ // IndexedDB caches recent events per-browser so a reload doesn't need to
188
+ // re-fetch full history from the server, and so the feed keeps some
189
+ // scrollback beyond what the server's own bounded window still holds.
190
+ // Degrades to network-only (no local cache) if IndexedDB is unavailable.
191
+ function openIdb() {
192
+ return new Promise((resolve) => {
193
+ if (!('indexedDB' in window)) return resolve(null);
194
+ const req = indexedDB.open(IDB_NAME, 1);
195
+ req.onupgradeneeded = () => {
196
+ const db = req.result;
197
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
198
+ const store = db.createObjectStore(IDB_STORE, { keyPath: 'id' });
199
+ store.createIndex('receivedAt', 'receivedAt');
200
+ }
201
+ };
202
+ req.onsuccess = () => resolve(req.result);
203
+ req.onerror = () => resolve(null);
204
+ });
205
+ }
206
+
207
+ function idbGetAll(db) {
208
+ return new Promise((resolve) => {
209
+ if (!db) return resolve([]);
210
+ const req = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).getAll();
211
+ req.onsuccess = () => resolve(req.result || []);
212
+ req.onerror = () => resolve([]);
213
+ });
214
+ }
215
+
216
+ function idbPutMany(db, records) {
217
+ if (!db || records.length === 0) return;
218
+ const store = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE);
219
+ for (const r of records) store.put(r);
220
+ }
221
+
222
+ /** Deletes the oldest rows (by receivedAt) beyond `cap` — the client-side "invalidate older data" policy. */
223
+ function idbPruneOldest(db, cap) {
224
+ if (!db) return;
225
+ const store = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE);
226
+ const countReq = store.count();
227
+ countReq.onsuccess = () => {
228
+ const excess = countReq.result - cap;
229
+ if (excess <= 0) return;
230
+ let deleted = 0;
231
+ const cursorReq = store.index('receivedAt').openCursor(); // ascending = oldest first
232
+ cursorReq.onsuccess = (ev) => {
233
+ const cursor = ev.target.result;
234
+ if (!cursor || deleted >= excess) return;
235
+ cursor.delete();
236
+ deleted++;
237
+ cursor.continue();
238
+ };
239
+ };
240
+ }
182
241
 
183
242
  function fmtTime(iso) {
184
243
  try { return new Date(iso).toLocaleTimeString(); } catch { return iso; }
@@ -208,6 +267,10 @@
208
267
  feedRows.unshift(e);
209
268
  if (feedRows.length > MAX_FEED_ROWS) feedRows.length = MAX_FEED_ROWS;
210
269
  renderFeed();
270
+ if (idb) {
271
+ idbPutMany(idb, [e]);
272
+ idbPruneOldest(idb, IDB_CAP);
273
+ }
211
274
  }
212
275
 
213
276
  async function refreshHeatmap() {
@@ -275,9 +338,31 @@
275
338
  }
276
339
 
277
340
  async function loadInitial() {
278
- const res = await fetch('/api/events?limit=150');
341
+ idb = await openIdb();
342
+
343
+ // Paint whatever's cached locally first — instant, no network wait —
344
+ // then delta-fetch only what's new since the newest cached event
345
+ // instead of re-downloading the full recent-events window every load.
346
+ const cached = await idbGetAll(idb);
347
+ cached.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1));
348
+ if (cached.length) {
349
+ feedRows = cached.slice(0, MAX_FEED_ROWS);
350
+ renderFeed();
351
+ }
352
+
353
+ const since = cached.length ? cached[0].receivedAt : undefined;
354
+ const url = '/api/events?limit=500' + (since ? '&since=' + encodeURIComponent(since) : '');
355
+ const res = await fetch(url);
279
356
  const data = await res.json();
280
- feedRows = (data.events || []).slice().reverse();
357
+ const fresh = data.events || [];
358
+
359
+ if (fresh.length) {
360
+ idbPutMany(idb, fresh);
361
+ idbPruneOldest(idb, IDB_CAP);
362
+ const seen = new Set();
363
+ const merged = [...fresh].reverse().concat(feedRows);
364
+ feedRows = merged.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true))).slice(0, MAX_FEED_ROWS);
365
+ }
281
366
  renderFeed();
282
367
  refreshDerived();
283
368
  }
package/src/store.js CHANGED
@@ -1,8 +1,21 @@
1
- import { appendFileSync, existsSync, readFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { EVENTS_FILE, ensureBlipDir } from './port.js';
3
+ import { join } from 'node:path';
4
+ import { EVENTS_FILE, BLIP_DIR, ensureBlipDir } from './port.js';
5
+
6
+ const AGGREGATES_FILE = join(BLIP_DIR, 'aggregates.json');
7
+
8
+ // Bounds on the *raw* per-event log (both in-memory and on disk). This is
9
+ // the "very recent events / live feed" window — it's expected to roll over.
10
+ // Derived "major event" data (heatmap totals, MTTR running averages, run
11
+ // summaries) lives in AGGREGATES_FILE instead, which is never pruned, so
12
+ // long-term signal survives raw-log rollover.
13
+ const MAX_EVENTS = 5000;
14
+ // Once the on-disk log grows this far past the cap, rewrite it down to size
15
+ // rather than compacting on every single ingest (which would be O(n) per event).
16
+ const COMPACT_THRESHOLD = MAX_EVENTS * 1.5;
17
+ const AGGREGATE_FLUSH_INTERVAL_MS = 3000;
4
18
 
5
- const MAX_IN_MEMORY = 5000;
6
19
  // Consecutive fault.injected log lines of the same kind+url within this gap
7
20
  // are treated as one "incident" for the log-only MTTR fallback.
8
21
  const INCIDENT_GAP_MS = 10_000;
@@ -59,7 +72,21 @@ export class EventStore {
59
72
  constructor() {
60
73
  this.events = [];
61
74
  this.subscribers = new Set();
75
+ this._diskLineCount = 0;
76
+
77
+ // Persistent, never-pruned aggregates ("major events").
78
+ this.heatmapAgg = new Map(); // url -> { totalRequests, faultCounts, coverageKnown }
79
+ this.mttrAgg = new Map(); // faultKind -> { sum, count }
80
+ this.runsAgg = new Map(); // runId -> { startedAt, endedAt, eventCount, faultCount, failureCount, faultKinds, urls: Set }
81
+ this._openFaultByUrl = new Map(); // in-memory only — resets on restart, at most loses one open incident
82
+ this._sawRequestEvents = false;
83
+ this._dirty = false;
84
+
62
85
  this._loadFromDisk();
86
+ this._loadAggregates();
87
+
88
+ this._flushTimer = setInterval(() => this._persistAggregatesIfDirty(), AGGREGATE_FLUSH_INTERVAL_MS);
89
+ this._flushTimer.unref?.();
63
90
  }
64
91
 
65
92
  _loadFromDisk() {
@@ -67,28 +94,152 @@ export class EventStore {
67
94
  if (!existsSync(EVENTS_FILE)) return;
68
95
  try {
69
96
  const lines = readFileSync(EVENTS_FILE, 'utf8').split('\n').filter(Boolean);
70
- for (const line of lines.slice(-MAX_IN_MEMORY)) {
97
+ this._diskLineCount = lines.length;
98
+ for (const line of lines.slice(-MAX_EVENTS)) {
71
99
  try {
72
100
  this.events.push(JSON.parse(line));
73
101
  } catch {
74
102
  /* skip corrupt line */
75
103
  }
76
104
  }
105
+ // A pre-existing unbounded log (e.g. from before this retention policy
106
+ // existed) gets shrunk to the cap immediately rather than waiting for
107
+ // enough new events to cross COMPACT_THRESHOLD.
108
+ if (this._diskLineCount > MAX_EVENTS) this._compactDiskLog();
77
109
  } catch {
78
110
  /* best-effort load */
79
111
  }
80
112
  }
81
113
 
82
- ingest(kind, body) {
83
- const record = normalize(kind, body);
84
- this.events.push(record);
85
- if (this.events.length > MAX_IN_MEMORY) this.events.shift();
114
+ _loadAggregates() {
115
+ if (!existsSync(AGGREGATES_FILE)) return;
116
+ try {
117
+ const data = JSON.parse(readFileSync(AGGREGATES_FILE, 'utf8'));
118
+ for (const [url, row] of Object.entries(data.heatmap ?? {})) {
119
+ this.heatmapAgg.set(url, row);
120
+ if (row.coverageKnown) this._sawRequestEvents = true;
121
+ }
122
+ for (const [kind, agg] of Object.entries(data.mttr ?? {})) this.mttrAgg.set(kind, agg);
123
+ for (const [runId, run] of Object.entries(data.runs ?? {})) {
124
+ this.runsAgg.set(runId, { ...run, urls: new Set(run.urls ?? []) });
125
+ }
126
+ } catch {
127
+ /* corrupt or missing — start fresh */
128
+ }
129
+ }
130
+
131
+ _persistAggregatesIfDirty() {
132
+ if (!this._dirty) return;
133
+ this._dirty = false;
134
+ try {
135
+ ensureBlipDir();
136
+ const heatmap = Object.fromEntries(this.heatmapAgg);
137
+ const mttr = Object.fromEntries(this.mttrAgg);
138
+ const runs = Object.fromEntries(
139
+ [...this.runsAgg.entries()].map(([id, r]) => [id, { ...r, urls: [...r.urls] }])
140
+ );
141
+ writeFileSync(AGGREGATES_FILE, JSON.stringify({ heatmap, mttr, runs }));
142
+ } catch {
143
+ /* best-effort */
144
+ }
145
+ }
146
+
147
+ /** Force an immediate aggregate flush — call on graceful shutdown so the last few seconds aren't lost. */
148
+ flushNow() {
149
+ this._dirty = true;
150
+ this._persistAggregatesIfDirty();
151
+ }
152
+
153
+ _appendRaw(record) {
86
154
  try {
87
155
  ensureBlipDir();
88
156
  appendFileSync(EVENTS_FILE, JSON.stringify(record) + '\n');
157
+ this._diskLineCount++;
158
+ if (this._diskLineCount > COMPACT_THRESHOLD) this._compactDiskLog();
89
159
  } catch {
90
160
  /* best-effort persistence */
91
161
  }
162
+ }
163
+
164
+ /** Rewrites the raw event log down to the in-memory window instead of letting it grow unbounded. */
165
+ _compactDiskLog() {
166
+ try {
167
+ const lines = this.events.map((e) => JSON.stringify(e));
168
+ writeFileSync(EVENTS_FILE, lines.length ? lines.join('\n') + '\n' : '');
169
+ this._diskLineCount = lines.length;
170
+ } catch {
171
+ /* best-effort */
172
+ }
173
+ }
174
+
175
+ _updateAggregates(record) {
176
+ // Run summaries — one row per runId, kept forever regardless of raw-log rollover.
177
+ if (!this.runsAgg.has(record.runId)) {
178
+ this.runsAgg.set(record.runId, {
179
+ runId: record.runId,
180
+ startedAt: record.timestamp,
181
+ endedAt: record.timestamp,
182
+ eventCount: 0,
183
+ faultCount: 0,
184
+ failureCount: 0,
185
+ faultKinds: {},
186
+ urls: new Set(),
187
+ });
188
+ }
189
+ const run = this.runsAgg.get(record.runId);
190
+ run.eventCount++;
191
+ if (record.timestamp < run.startedAt) run.startedAt = record.timestamp;
192
+ if (record.timestamp > run.endedAt) run.endedAt = record.timestamp;
193
+ if (record.faultKind) {
194
+ run.faultCount++;
195
+ run.faultKinds[record.faultKind] = (run.faultKinds[record.faultKind] ?? 0) + 1;
196
+ }
197
+ if (record.success === false) run.failureCount++;
198
+ if (record.url) run.urls.add(record.url);
199
+
200
+ // Heatmap — per-url totals, kept forever.
201
+ if (record.url) {
202
+ if (!this.heatmapAgg.has(record.url)) {
203
+ this.heatmapAgg.set(record.url, { totalRequests: 0, faultCounts: {}, coverageKnown: false });
204
+ }
205
+ const row = this.heatmapAgg.get(record.url);
206
+ if (record.kind === 'request') {
207
+ row.totalRequests++;
208
+ row.coverageKnown = true;
209
+ this._sawRequestEvents = true;
210
+ }
211
+ if (record.faultKind) row.faultCounts[record.faultKind] = (row.faultCounts[record.faultKind] ?? 0) + 1;
212
+ }
213
+
214
+ // MTTR (request-source) — running sum/count per fault kind, kept forever.
215
+ // The "is a fault currently open" tracker itself is in-memory only, so a
216
+ // server restart mid-incident loses at most that one in-flight measurement.
217
+ if (record.kind === 'request') {
218
+ const openFault = this._openFaultByUrl.get(record.url);
219
+ if (!record.success && record.faultKind) {
220
+ if (!openFault) this._openFaultByUrl.set(record.url, { kind: record.faultKind, since: record.timestamp });
221
+ } else if (record.success && openFault) {
222
+ const ms = Date.parse(record.timestamp) - Date.parse(openFault.since);
223
+ if (Number.isFinite(ms) && ms >= 0) {
224
+ const agg = this.mttrAgg.get(openFault.kind) ?? { sum: 0, count: 0 };
225
+ agg.sum += ms;
226
+ agg.count++;
227
+ this.mttrAgg.set(openFault.kind, agg);
228
+ }
229
+ this._openFaultByUrl.delete(record.url);
230
+ }
231
+ }
232
+ }
233
+
234
+ ingest(kind, body) {
235
+ const record = normalize(kind, body);
236
+ this.events.push(record);
237
+ if (this.events.length > MAX_EVENTS) this.events.shift();
238
+
239
+ this._appendRaw(record);
240
+ this._updateAggregates(record);
241
+ this._dirty = true;
242
+
92
243
  for (const send of this.subscribers) send(record);
93
244
  return record;
94
245
  }
@@ -98,77 +249,40 @@ export class EventStore {
98
249
  return () => this.subscribers.delete(send);
99
250
  }
100
251
 
252
+ /** Recent raw events — the bounded live-feed window, not the full history. */
101
253
  recent(limit = 500, since) {
102
254
  let list = this.events;
103
255
  if (since) list = list.filter((e) => e.receivedAt > since);
104
256
  return list.slice(-limit);
105
257
  }
106
258
 
107
- /** Group events by runId into a chronological experiment-run timeline. */
259
+ /** Chronological experiment-run timeline, from the persistent per-run aggregate (survives raw-log rollover). */
108
260
  runs() {
109
- const byRun = new Map();
110
- for (const e of this.events) {
111
- if (!byRun.has(e.runId)) {
112
- byRun.set(e.runId, {
113
- runId: e.runId,
114
- startedAt: e.timestamp,
115
- endedAt: e.timestamp,
116
- eventCount: 0,
117
- faultCount: 0,
118
- failureCount: 0,
119
- faultKinds: {},
120
- urls: new Set(),
121
- });
122
- }
123
- const run = byRun.get(e.runId);
124
- run.eventCount++;
125
- if (e.timestamp < run.startedAt) run.startedAt = e.timestamp;
126
- if (e.timestamp > run.endedAt) run.endedAt = e.timestamp;
127
- if (e.faultKind) {
128
- run.faultCount++;
129
- run.faultKinds[e.faultKind] = (run.faultKinds[e.faultKind] ?? 0) + 1;
130
- }
131
- if (e.success === false) run.failureCount++;
132
- if (e.url) run.urls.add(e.url);
133
- }
134
- return [...byRun.values()]
261
+ return [...this.runsAgg.values()]
135
262
  .map((r) => ({ ...r, urls: [...r.urls] }))
136
263
  .sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
137
264
  }
138
265
 
139
266
  /**
140
- * Endpoint x fault-kind matrix. `requestEvents` (from wrapForDashboard)
141
- * tell us every call made, faulted or not, so we can surface endpoints
142
- * that were exercised but never chaos-tested. Log/webhook-only setups
143
- * only ever see faulted calls, so "untested" can't be computed for them
144
- * — those rows are marked `coverageKnown: false`.
267
+ * Endpoint x fault-kind matrix, from the persistent per-url aggregate.
268
+ * `requestEvents` (from wrapForDashboard) tell us every call made, faulted
269
+ * or not, so we can surface endpoints that were exercised but never
270
+ * chaos-tested. Log/webhook-only setups only ever see faulted calls, so
271
+ * "untested" can't be computed for them — those rows are marked
272
+ * `coverageKnown: false`.
145
273
  */
146
274
  heatmap() {
147
- const byUrl = new Map();
148
- let sawRequestEvents = false;
149
-
150
- for (const e of this.events) {
151
- if (!e.url) continue;
152
- if (e.kind === 'request') sawRequestEvents = true;
153
- if (!byUrl.has(e.url)) byUrl.set(e.url, { url: e.url, totalRequests: 0, faultCounts: {}, coverageKnown: false });
154
- const row = byUrl.get(e.url);
155
- if (e.kind === 'request') {
156
- row.totalRequests++;
157
- row.coverageKnown = true;
158
- }
159
- if (e.faultKind) row.faultCounts[e.faultKind] = (row.faultCounts[e.faultKind] ?? 0) + 1;
160
- }
161
-
162
- const rows = [...byUrl.values()].map((row) => {
275
+ const rows = [...this.heatmapAgg.entries()].map(([url, row]) => {
163
276
  const faulted = Object.values(row.faultCounts).reduce((a, b) => a + b, 0);
164
277
  return {
278
+ url,
165
279
  ...row,
166
280
  faultedCount: faulted,
167
281
  untested: row.coverageKnown && row.totalRequests > 0 && faulted === 0,
168
282
  };
169
283
  });
170
284
  rows.sort((a, b) => b.faultedCount - a.faultedCount);
171
- return { sawRequestEvents, rows };
285
+ return { sawRequestEvents: this._sawRequestEvents, rows };
172
286
  }
173
287
 
174
288
  /**
@@ -176,46 +290,23 @@ export class EventStore {
176
290
  *
177
291
  * Preferred method (`source: 'request'`): wrapForDashboard reports every
178
292
  * call's success/failure, so MTTR is the mean time between a failed call
179
- * attributed to a fault kind and the next successful call on that url.
293
+ * attributed to a fault kind and the next successful call on that url
294
+ * computed from the persistent running sum/count, so it stays accurate
295
+ * even after the raw log rolls over.
180
296
  *
181
297
  * Fallback (`source: 'log'`): plain logger/webhook wiring only sees
182
298
  * fault.injected lines, so we approximate recovery time as the duration
183
299
  * of each "incident" — a run of same-kind faults on the same url with no
184
- * gap larger than INCIDENT_GAP_MS between them.
300
+ * gap larger than INCIDENT_GAP_MS between them. This one's computed from
301
+ * the bounded raw-event window since it's already just a rough estimate.
185
302
  */
186
303
  mttr() {
187
- const requestEvents = this.events.filter((e) => e.kind === 'request');
188
- if (requestEvents.length > 0) return this._mttrFromRequests(requestEvents);
189
- return this._mttrFromLogs();
190
- }
191
-
192
- _mttrFromRequests(requestEvents) {
193
- const byUrl = new Map();
194
- for (const e of requestEvents) {
195
- const list = byUrl.get(e.url) ?? [];
196
- list.push(e);
197
- byUrl.set(e.url, list);
198
- }
199
-
200
- const durationsByKind = new Map();
201
- for (const list of byUrl.values()) {
202
- list.sort((a, b) => (a.timestamp < b.timestamp ? -1 : 1));
203
- let openFault = null;
204
- for (const e of list) {
205
- if (!e.success && e.faultKind) {
206
- if (!openFault) openFault = { kind: e.faultKind, since: e.timestamp };
207
- } else if (e.success && openFault) {
208
- const ms = Date.parse(e.timestamp) - Date.parse(openFault.since);
209
- if (Number.isFinite(ms) && ms >= 0) {
210
- const arr = durationsByKind.get(openFault.kind) ?? [];
211
- arr.push(ms);
212
- durationsByKind.set(openFault.kind, arr);
213
- }
214
- openFault = null;
215
- }
216
- }
304
+ if (this._sawRequestEvents) {
305
+ const out = {};
306
+ for (const [kind, { sum, count }] of this.mttrAgg) out[kind] = count ? Math.round(sum / count) : 0;
307
+ return { source: 'request', mttrMsByKind: out };
217
308
  }
218
- return { source: 'request', mttrMsByKind: average(durationsByKind) };
309
+ return this._mttrFromLogs();
219
310
  }
220
311
 
221
312
  _mttrFromLogs() {