dashboard-blipburst 0.1.0 → 0.2.4
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 +29 -1
- package/bin/cli.js +17 -5
- package/index.d.ts +28 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/public/index.html +87 -2
- package/src/adapter.js +32 -17
- package/src/port.js +28 -3
- package/src/server.js +6 -0
- package/src/store.js +181 -90
package/README.md
CHANGED
|
@@ -26,6 +26,23 @@ Override the preferred port with `--port` / `-p`, or the `BLIPBURST_DASHBOARD_PO
|
|
|
26
26
|
blipburst-dashboard --port 5000
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Hosted environments
|
|
30
|
+
|
|
31
|
+
Locally, the SDK-side adapter finds the dashboard automatically via the `.blipburst/port` file written to the current directory — that only works because both processes share a filesystem. In a hosted setup (the dashboard deployed somewhere, the BlipBurst-instrumented app deployed somewhere else), there's no shared file, so use env vars on both sides instead:
|
|
32
|
+
|
|
33
|
+
**On the dashboard's deployment**, set `PORT` (the convention Render/Railway/Fly/Heroku-style platforms already inject) or `BLIPBURST_DASHBOARD_PORT`. Either one makes the server bind exactly there and **fail loudly instead of silently drifting to another port** on conflict — unlike a bare local run, which auto-increments for dev convenience, a hosted deployment can only be reached on the port its platform is routing to, so drifting silently would just make it unreachable:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# most hosting platforms set PORT for you automatically — nothing else needed
|
|
37
|
+
blipburst-dashboard
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**On the app reporting events**, set `BLIPBURST_DASHBOARD_URL` to the dashboard's public URL — every adapter function (`toDashboardTransport`, `toDashboardWebhookUrl`, `wrapForDashboard`) picks it up automatically, no code change needed:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
BLIPBURST_DASHBOARD_URL=https://blipburst-dashboard.internal.example.com node server.js
|
|
44
|
+
```
|
|
45
|
+
|
|
29
46
|
## Wire it up to BlipBurst
|
|
30
47
|
|
|
31
48
|
No changes to `blipburst` itself are needed — everything below plugs into config options `blipburst` already supports.
|
|
@@ -78,6 +95,10 @@ await sim.makeRequest(); // now tracked whether or not a fault fired
|
|
|
78
95
|
|
|
79
96
|
This also unlocks exact MTTR (measured time from a failed call to the next success on that endpoint) instead of the log-only estimate (duration of consecutive fault bursts).
|
|
80
97
|
|
|
98
|
+
### A quiet dashboard in production is expected, not broken
|
|
99
|
+
|
|
100
|
+
BlipBurst's `enabled: false` option (or `BLIPBURST_ENABLED=false`) is a global kill switch — when it's off, no faults ever fire, so nothing reaches any of the ingest endpoints above regardless of how the transport/webhook/wrapper is wired. If you point the dashboard at a production deployment that correctly disables chaos there, an empty live feed and heatmap is the dashboard working correctly, not a wiring bug. Point it at a dev/staging deployment (where chaos is actually enabled) to see live data.
|
|
101
|
+
|
|
81
102
|
## What the dashboard shows
|
|
82
103
|
|
|
83
104
|
- **Live fault feed** — streamed over SSE as `fault.injected` / `request.failed` events arrive.
|
|
@@ -85,7 +106,14 @@ This also unlocks exact MTTR (measured time from a failed call to the next succe
|
|
|
85
106
|
- **MTTR per fault type** — mean time from a fault firing to recovery, per `Fault['kind']`.
|
|
86
107
|
- **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
108
|
|
|
88
|
-
|
|
109
|
+
## Data retention
|
|
110
|
+
|
|
111
|
+
Two different things are kept, on purpose, with different lifetimes:
|
|
112
|
+
|
|
113
|
+
- **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.
|
|
114
|
+
- **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.
|
|
115
|
+
|
|
116
|
+
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
117
|
|
|
90
118
|
## HTTP API
|
|
91
119
|
|
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startDashboardServer } from '../src/server.js';
|
|
3
|
-
import { DEFAULT_PORT } from '../src/port.js';
|
|
3
|
+
import { DEFAULT_PORT, explicitPortFromEnv } from '../src/port.js';
|
|
4
4
|
|
|
5
5
|
function parseArgs(argv) {
|
|
6
6
|
const out = { port: undefined };
|
|
@@ -16,18 +16,30 @@ function parseArgs(argv) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function main() {
|
|
19
|
-
const { port } = parseArgs(process.argv.slice(2));
|
|
20
|
-
|
|
19
|
+
const { port: cliPort } = parseArgs(process.argv.slice(2));
|
|
20
|
+
// --port wins, then BLIPBURST_DASHBOARD_PORT / the hosting-platform-standard
|
|
21
|
+
// PORT (Render/Railway/Fly/Heroku all inject this). Any of these being set
|
|
22
|
+
// means something upstream expects this exact port — a hosted deployment
|
|
23
|
+
// routes traffic there and nowhere else — so silently drifting to another
|
|
24
|
+
// port on conflict would make the deployment unreachable. Bare local runs
|
|
25
|
+
// with nothing set get the old convenience behavior: auto-increment.
|
|
26
|
+
const explicitPort = Number.isFinite(cliPort) ? cliPort : explicitPortFromEnv();
|
|
27
|
+
const preferredPort = explicitPort ?? DEFAULT_PORT;
|
|
28
|
+
const maxPortAttempts = explicitPort ? 0 : 20;
|
|
21
29
|
|
|
22
|
-
const { port: resolvedPort } = await startDashboardServer({ port: preferredPort });
|
|
30
|
+
const { port: resolvedPort, store } = await startDashboardServer({ port: preferredPort, maxPortAttempts });
|
|
23
31
|
|
|
24
32
|
console.log(`BlipBurst dashboard running at http://localhost:${resolvedPort}`);
|
|
25
33
|
if (resolvedPort !== preferredPort) {
|
|
26
34
|
console.log(`(port ${preferredPort} was in use — auto-selected ${resolvedPort})`);
|
|
27
35
|
}
|
|
28
36
|
console.log(`Port written to .blipburst/port — point BlipBurst's logger/webhook adapter at it automatically.`);
|
|
37
|
+
console.log(`Hosted setup: set BLIPBURST_DASHBOARD_URL on the reporting app's environment to point it at this dashboard's public URL instead of relying on .blipburst/port.`);
|
|
29
38
|
|
|
30
|
-
const shutdown = () =>
|
|
39
|
+
const shutdown = () => {
|
|
40
|
+
store.flushNow(); // persist the last few seconds of heatmap/MTTR/run aggregates before exiting
|
|
41
|
+
process.exit(0);
|
|
42
|
+
};
|
|
31
43
|
process.on('SIGINT', shutdown);
|
|
32
44
|
process.on('SIGTERM', shutdown);
|
|
33
45
|
}
|
package/index.d.ts
CHANGED
|
@@ -23,7 +23,11 @@ export interface BlipBurstLike {
|
|
|
23
23
|
export interface DashboardAdapterOptions {
|
|
24
24
|
/** Group events from this call under a specific run id instead of a freshly generated one. */
|
|
25
25
|
runId?: string;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Dashboard port override for the local `http://localhost:<port>` case.
|
|
28
|
+
* Ignored when BLIPBURST_DASHBOARD_URL is set — see resolveDashboardUrl.
|
|
29
|
+
* Otherwise resolved from BLIPBURST_DASHBOARD_PORT / PORT / `.blipburst/port` / 4477.
|
|
30
|
+
*/
|
|
27
31
|
port?: number;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -54,9 +58,15 @@ export interface StartDashboardServerOptions {
|
|
|
54
58
|
maxPortAttempts?: number;
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
export interface DashboardEventStore {
|
|
62
|
+
/** Force an immediate flush of the persistent heatmap/MTTR/run aggregates — call before process exit. */
|
|
63
|
+
flushNow(): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
export interface StartDashboardServerResult {
|
|
58
67
|
server: import('node:http').Server;
|
|
59
68
|
port: number;
|
|
69
|
+
store: DashboardEventStore;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Starts the dashboard HTTP+SSE server. Used by the `blipburst-dashboard` bin; importable for embedding/tests. */
|
|
@@ -64,3 +74,20 @@ export function startDashboardServer(options?: StartDashboardServerOptions): Pro
|
|
|
64
74
|
|
|
65
75
|
export const DEFAULT_PORT: number;
|
|
66
76
|
export function resolvePort(explicitPort?: number): number;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the full base URL to reach the dashboard at. Set
|
|
80
|
+
* BLIPBURST_DASHBOARD_URL (e.g. `https://dashboard.internal.example.com`)
|
|
81
|
+
* on the reporting app's environment to point every adapter function at a
|
|
82
|
+
* dashboard running on a different host — no code change needed. Falls
|
|
83
|
+
* back to `http://localhost:<resolvePort()>` otherwise.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveDashboardUrl(explicitPort?: number): string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A port from BLIPBURST_DASHBOARD_PORT or the hosting-platform-standard
|
|
89
|
+
* PORT (Render/Railway/Fly/Heroku), or `null` if neither is set. Used to
|
|
90
|
+
* decide whether binding should fail loudly on conflict instead of
|
|
91
|
+
* auto-incrementing — see `startDashboardServer`'s `maxPortAttempts`.
|
|
92
|
+
*/
|
|
93
|
+
export function explicitPortFromEnv(): number | null;
|
package/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { toDashboardTransport, toDashboardWebhookUrl, wrapForDashboard, flushBuffer } from './src/adapter.js';
|
|
2
2
|
export { startDashboardServer } from './src/server.js';
|
|
3
|
-
export { DEFAULT_PORT, resolvePort } from './src/port.js';
|
|
3
|
+
export { DEFAULT_PORT, resolvePort, resolveDashboardUrl, explicitPortFromEnv } from './src/port.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dashboard-blipburst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
-
|
|
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
|
-
|
|
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/adapter.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { resolveDashboardUrl, ensureBlipDir, BUFFER_FILE } from './port.js';
|
|
4
|
+
|
|
5
|
+
// toDashboardTransport() and wrapForDashboard() are commonly used together
|
|
6
|
+
// in the same process (the README's "full request coverage" combo) — they
|
|
7
|
+
// need to land in the same run, so default to one shared per-process runId
|
|
8
|
+
// generated lazily on first use, rather than each function minting its own.
|
|
9
|
+
// An explicit `options.runId` still overrides this per call.
|
|
10
|
+
let _defaultRunId = null;
|
|
11
|
+
function getDefaultRunId() {
|
|
12
|
+
return (_defaultRunId ??= randomUUID());
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
function bufferEnvelope(envelope) {
|
|
6
16
|
ensureBlipDir();
|
|
@@ -11,8 +21,8 @@ function bufferEnvelope(envelope) {
|
|
|
11
21
|
}
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
async function postJson(
|
|
15
|
-
const res = await fetch(
|
|
24
|
+
async function postJson(baseUrl, path, payload) {
|
|
25
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
16
26
|
method: 'POST',
|
|
17
27
|
headers: { 'Content-Type': 'application/json' },
|
|
18
28
|
body: JSON.stringify(payload),
|
|
@@ -21,9 +31,9 @@ async function postJson(port, path, payload) {
|
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/** Best-effort send: on any failure (dashboard not running, network error) buffer to disk instead of throwing. */
|
|
24
|
-
async function sendOrBuffer(
|
|
34
|
+
async function sendOrBuffer(baseUrl, path, payload) {
|
|
25
35
|
try {
|
|
26
|
-
await postJson(
|
|
36
|
+
await postJson(baseUrl, path, payload);
|
|
27
37
|
} catch {
|
|
28
38
|
bufferEnvelope({ path, payload });
|
|
29
39
|
}
|
|
@@ -35,7 +45,7 @@ async function sendOrBuffer(port, path, payload) {
|
|
|
35
45
|
* drained. Failures leave the buffer file untouched so nothing is lost.
|
|
36
46
|
*/
|
|
37
47
|
export async function flushBuffer(port) {
|
|
38
|
-
const
|
|
48
|
+
const baseUrl = resolveDashboardUrl(port);
|
|
39
49
|
if (!existsSync(BUFFER_FILE)) return { flushed: 0 };
|
|
40
50
|
|
|
41
51
|
let lines;
|
|
@@ -56,7 +66,7 @@ export async function flushBuffer(port) {
|
|
|
56
66
|
continue; // drop corrupt line
|
|
57
67
|
}
|
|
58
68
|
try {
|
|
59
|
-
await postJson(
|
|
69
|
+
await postJson(baseUrl, envelope.path, envelope.payload);
|
|
60
70
|
flushed++;
|
|
61
71
|
} catch {
|
|
62
72
|
remaining.push(line);
|
|
@@ -79,22 +89,27 @@ export async function flushBuffer(port) {
|
|
|
79
89
|
* are flushed automatically the next time this transport is created (i.e.
|
|
80
90
|
* the next process start) or on demand via `flushBuffer()`.
|
|
81
91
|
*
|
|
92
|
+
* Locally this discovers the dashboard via `.blipburst/port` automatically.
|
|
93
|
+
* In a hosted setup where the app and the dashboard run on different hosts,
|
|
94
|
+
* set BLIPBURST_DASHBOARD_URL (e.g. `https://dashboard.internal.example.com`)
|
|
95
|
+
* on the app's environment instead — no code change needed.
|
|
96
|
+
*
|
|
82
97
|
* Usage:
|
|
83
98
|
* import { toDashboardTransport } from 'dashboard-blipburst';
|
|
84
99
|
* const sim = new BlipBurst({ logger: { transport: toDashboardTransport() } });
|
|
85
100
|
*/
|
|
86
101
|
export function toDashboardTransport(port, options = {}) {
|
|
87
|
-
const
|
|
88
|
-
const runId = options.runId ??
|
|
102
|
+
const baseUrl = resolveDashboardUrl(port);
|
|
103
|
+
const runId = options.runId ?? getDefaultRunId();
|
|
89
104
|
|
|
90
105
|
// Try to drain anything buffered from a previous run without blocking
|
|
91
106
|
// transport creation.
|
|
92
|
-
flushBuffer(
|
|
107
|
+
flushBuffer(port).catch(() => {});
|
|
93
108
|
|
|
94
109
|
return function dashboardTransport(entry) {
|
|
95
110
|
// LogTransport is synchronous/fire-and-forget by contract — never await
|
|
96
111
|
// or throw here, BlipBurst calls this inline on the request path.
|
|
97
|
-
sendOrBuffer(
|
|
112
|
+
sendOrBuffer(baseUrl, '/ingest/log', { runId, entry, source: 'logger' }).catch(() => {});
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
@@ -102,14 +117,14 @@ export function toDashboardTransport(port, options = {}) {
|
|
|
102
117
|
* Returns the URL to hand to BlipBurst's `webhook.url` option so its
|
|
103
118
|
* built-in WebhookEmitter posts fault/circuit events straight at the
|
|
104
119
|
* dashboard — no adapter function needed on that path since BlipBurst's
|
|
105
|
-
* webhook is already URL-based.
|
|
120
|
+
* webhook is already URL-based. Respects BLIPBURST_DASHBOARD_URL the same
|
|
121
|
+
* way toDashboardTransport does.
|
|
106
122
|
*
|
|
107
123
|
* Usage:
|
|
108
124
|
* const sim = new BlipBurst({ webhook: { url: toDashboardWebhookUrl() } });
|
|
109
125
|
*/
|
|
110
126
|
export function toDashboardWebhookUrl(port) {
|
|
111
|
-
|
|
112
|
-
return `http://localhost:${resolvedPort}/ingest/webhook`;
|
|
127
|
+
return `${resolveDashboardUrl(port)}/ingest/webhook`;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
function faultKindDiff(before, after) {
|
|
@@ -135,8 +150,8 @@ function faultKindDiff(before, after) {
|
|
|
135
150
|
* await sim.makeRequest();
|
|
136
151
|
*/
|
|
137
152
|
export function wrapForDashboard(sim, options = {}) {
|
|
138
|
-
const
|
|
139
|
-
const runId = options.runId ??
|
|
153
|
+
const baseUrl = resolveDashboardUrl(options.port);
|
|
154
|
+
const runId = options.runId ?? getDefaultRunId();
|
|
140
155
|
const original = sim.makeRequest.bind(sim);
|
|
141
156
|
|
|
142
157
|
sim.makeRequest = async function wrappedMakeRequest(overrideUrl) {
|
|
@@ -156,7 +171,7 @@ export function wrapForDashboard(sim, options = {}) {
|
|
|
156
171
|
} finally {
|
|
157
172
|
const after = sim.getMetrics().faultStats;
|
|
158
173
|
const faultKind = faultKindDiff(before, after);
|
|
159
|
-
sendOrBuffer(
|
|
174
|
+
sendOrBuffer(baseUrl, '/ingest/request', {
|
|
160
175
|
runId,
|
|
161
176
|
url,
|
|
162
177
|
faultKind,
|
package/src/port.js
CHANGED
|
@@ -36,10 +36,35 @@ export function writePortFile(port) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A port set via BLIPBURST_DASHBOARD_PORT or the hosting-platform-standard
|
|
41
|
+
* PORT (Render/Railway/Fly/Heroku all inject this) means the deployment
|
|
42
|
+
* expects the process to bind exactly there — unlike the local port-file
|
|
43
|
+
* convenience, silently drifting to a different port would make it
|
|
44
|
+
* unreachable, so callers use this to decide whether auto-increment is safe.
|
|
45
|
+
*/
|
|
46
|
+
export function explicitPortFromEnv() {
|
|
47
|
+
const raw = process.env.BLIPBURST_DASHBOARD_PORT ?? process.env.PORT;
|
|
48
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
49
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/** Resolve the port the dashboard is (or should be) reachable on. */
|
|
40
53
|
export function resolvePort(explicitPort) {
|
|
41
54
|
if (explicitPort) return explicitPort;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
return explicitPortFromEnv() ?? readPortFile() ?? DEFAULT_PORT;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the full base URL to reach the dashboard at. In a hosted setup
|
|
60
|
+
* where the app reporting events and the dashboard aren't on the same
|
|
61
|
+
* machine (so there's no shared `.blipburst/port` file to read), set
|
|
62
|
+
* BLIPBURST_DASHBOARD_URL (e.g. `https://dashboard.internal.example.com`)
|
|
63
|
+
* on the app's side and every adapter function picks it up automatically.
|
|
64
|
+
* Falls back to the local `http://localhost:<port>` behavior otherwise.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveDashboardUrl(explicitPort) {
|
|
67
|
+
const fromEnv = process.env.BLIPBURST_DASHBOARD_URL;
|
|
68
|
+
if (fromEnv) return fromEnv.replace(/\/+$/, '');
|
|
69
|
+
return `http://localhost:${resolvePort(explicitPort)}`;
|
|
45
70
|
}
|
package/src/server.js
CHANGED
|
@@ -145,6 +145,12 @@ function listenWithAutoIncrement(server, port, attemptsLeft) {
|
|
|
145
145
|
server.once('error', (err) => {
|
|
146
146
|
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
147
147
|
resolve(listenWithAutoIncrement(server, port + 1, attemptsLeft - 1));
|
|
148
|
+
} else if (err.code === 'EADDRINUSE') {
|
|
149
|
+
reject(new Error(
|
|
150
|
+
`Port ${port} is already in use and auto-increment is disabled (maxPortAttempts: 0) — ` +
|
|
151
|
+
`this happens when the port came from --port, BLIPBURST_DASHBOARD_PORT, or PORT, since a ` +
|
|
152
|
+
`hosting platform expects the process to bind exactly there. Free the port or change the env var.`
|
|
153
|
+
));
|
|
148
154
|
} else {
|
|
149
155
|
reject(err);
|
|
150
156
|
}
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/**
|
|
259
|
+
/** Chronological experiment-run timeline, from the persistent per-run aggregate (survives raw-log rollover). */
|
|
108
260
|
runs() {
|
|
109
|
-
|
|
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
|
|
141
|
-
* tell us every call made, faulted
|
|
142
|
-
* that were exercised but never
|
|
143
|
-
* only ever see faulted calls, so
|
|
144
|
-
* — those rows are marked
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
309
|
+
return this._mttrFromLogs();
|
|
219
310
|
}
|
|
220
311
|
|
|
221
312
|
_mttrFromLogs() {
|