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 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
- History persists to `.blipburst/events.jsonl` in the dashboard's working directory across restarts.
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
- const preferredPort = Number.isFinite(port) ? port : DEFAULT_PORT;
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 = () => process.exit(0);
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
- /** Dashboard port override; otherwise read from `.blipburst/port` or defaults to 4477. */
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.1.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
- 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/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 { resolvePort, ensureBlipDir, BUFFER_FILE } from './port.js';
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(port, path, payload) {
15
- const res = await fetch(`http://localhost:${port}${path}`, {
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(port, path, payload) {
34
+ async function sendOrBuffer(baseUrl, path, payload) {
25
35
  try {
26
- await postJson(port, path, payload);
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 resolvedPort = resolvePort(port);
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(resolvedPort, envelope.path, envelope.payload);
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 resolvedPort = resolvePort(port);
88
- const runId = options.runId ?? randomUUID();
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(resolvedPort).catch(() => {});
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(resolvedPort, '/ingest/log', { runId, entry, source: 'logger' }).catch(() => {});
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
- const resolvedPort = resolvePort(port);
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 resolvedPort = resolvePort(options.port);
139
- const runId = options.runId ?? randomUUID();
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(resolvedPort, '/ingest/request', {
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
- const fromEnv = process.env.BLIPBURST_DASHBOARD_PORT;
43
- if (fromEnv && Number.isFinite(parseInt(fromEnv, 10))) return parseInt(fromEnv, 10);
44
- return readPortFile() ?? DEFAULT_PORT;
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 { 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() {