dashboard-blipburst 0.1.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/LICENSE +21 -0
- package/README.md +106 -0
- package/bin/cli.js +38 -0
- package/index.d.ts +66 -0
- package/index.js +3 -0
- package/package.json +42 -0
- package/public/index.html +305 -0
- package/src/adapter.js +171 -0
- package/src/port.js +45 -0
- package/src/server.js +168 -0
- package/src/store.js +263 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajmal Nasumudeen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# dashboard-blipburst
|
|
2
|
+
|
|
3
|
+
A local, zero-dependency live dashboard for [BlipBurst](https://github.com/stormdotcom/BlipBurst) — see fault injection happen in real time, browse a history of past experiment runs, spot which endpoints get chaos-tested (and which never do), and track MTTR per fault type.
|
|
4
|
+
|
|
5
|
+
It's a standalone package: installing or running it never touches your `blipburst` install, and `blipburst` has no dependency on it either. Wire the two together with a couple of lines in your own app config.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g dashboard-blipburst
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Run it
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
blipburst-dashboard
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- Binds to port `4477` by default. If that's taken, it auto-increments (`4478`, `4479`, …) and prints the port it actually bound.
|
|
20
|
+
- Writes the resolved port to `.blipburst/port` in the current directory, so the SDK-side adapter (below) can find it automatically without you hardcoding a port anywhere.
|
|
21
|
+
- Open the printed URL in a browser for the live dashboard.
|
|
22
|
+
|
|
23
|
+
Override the preferred port with `--port` / `-p`, or the `BLIPBURST_DASHBOARD_PORT` env var:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
blipburst-dashboard --port 5000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Wire it up to BlipBurst
|
|
30
|
+
|
|
31
|
+
No changes to `blipburst` itself are needed — everything below plugs into config options `blipburst` already supports.
|
|
32
|
+
|
|
33
|
+
### Option A — logger transport (recommended, covers everything)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { BlipBurst } from 'blipburst';
|
|
37
|
+
import { toDashboardTransport } from 'dashboard-blipburst';
|
|
38
|
+
|
|
39
|
+
const sim = new BlipBurst({
|
|
40
|
+
profile: 'flaky',
|
|
41
|
+
logger: { transport: toDashboardTransport() },
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If the dashboard isn't running when an event fires, `toDashboardTransport` buffers it to `.blipburst/log.jsonl` instead of throwing — nothing is lost, and the backlog flushes automatically the next time a transport is created (e.g. your app restarts with the dashboard now up), or on demand:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { flushBuffer } from 'dashboard-blipburst';
|
|
49
|
+
await flushBuffer();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Option B — webhook (uses BlipBurst's existing webhook emitter)
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { BlipBurst } from 'blipburst';
|
|
56
|
+
import { toDashboardWebhookUrl } from 'dashboard-blipburst';
|
|
57
|
+
|
|
58
|
+
const sim = new BlipBurst({
|
|
59
|
+
profile: 'cascade',
|
|
60
|
+
webhook: { url: toDashboardWebhookUrl() },
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Option C — full request coverage (for the "untested endpoints" heatmap)
|
|
65
|
+
|
|
66
|
+
BlipBurst only logs when a fault actually fires, so log/webhook wiring alone can't tell "endpoint gets hit constantly but never chaos-tested" apart from "endpoint isn't hit at all." `wrapForDashboard` wraps `makeRequest()` so *every* call is reported, faulted or not:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { BlipBurst } from 'blipburst';
|
|
70
|
+
import { wrapForDashboard, toDashboardTransport } from 'dashboard-blipburst';
|
|
71
|
+
|
|
72
|
+
const sim = wrapForDashboard(
|
|
73
|
+
new BlipBurst({ profile: 'flaky', logger: { transport: toDashboardTransport() } })
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await sim.makeRequest(); // now tracked whether or not a fault fired
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
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
|
+
|
|
81
|
+
## What the dashboard shows
|
|
82
|
+
|
|
83
|
+
- **Live fault feed** — streamed over SSE as `fault.injected` / `request.failed` events arrive.
|
|
84
|
+
- **Chaos coverage heatmap** — per endpoint: total requests and how many were faulted. Rows tagged `untested` were exercised but never fault-injected (only available when using `wrapForDashboard`).
|
|
85
|
+
- **MTTR per fault type** — mean time from a fault firing to recovery, per `Fault['kind']`.
|
|
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
|
+
|
|
88
|
+
History persists to `.blipburst/events.jsonl` in the dashboard's working directory across restarts.
|
|
89
|
+
|
|
90
|
+
## HTTP API
|
|
91
|
+
|
|
92
|
+
| Endpoint | Method | Purpose |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `/ingest/log` | POST | Used by `toDashboardTransport()` |
|
|
95
|
+
| `/ingest/webhook` | POST | Used by `toDashboardWebhookUrl()` |
|
|
96
|
+
| `/ingest/request` | POST | Used by `wrapForDashboard()` |
|
|
97
|
+
| `/events/stream` | GET (SSE) | Live event stream |
|
|
98
|
+
| `/api/events` | GET | Recent events (`?limit=`, `?since=`) |
|
|
99
|
+
| `/api/runs` | GET | Grouped run history |
|
|
100
|
+
| `/api/heatmap` | GET | Endpoint x fault-kind matrix |
|
|
101
|
+
| `/api/mttr` | GET | MTTR per fault kind |
|
|
102
|
+
| `/api/health` | GET | Liveness check |
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT © Ajmal N
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startDashboardServer } from '../src/server.js';
|
|
3
|
+
import { DEFAULT_PORT } from '../src/port.js';
|
|
4
|
+
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const out = { port: undefined };
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const arg = argv[i];
|
|
9
|
+
if (arg === '--port' || arg === '-p') {
|
|
10
|
+
out.port = parseInt(argv[++i], 10);
|
|
11
|
+
} else if (arg.startsWith('--port=')) {
|
|
12
|
+
out.port = parseInt(arg.split('=')[1], 10);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const { port } = parseArgs(process.argv.slice(2));
|
|
20
|
+
const preferredPort = Number.isFinite(port) ? port : DEFAULT_PORT;
|
|
21
|
+
|
|
22
|
+
const { port: resolvedPort } = await startDashboardServer({ port: preferredPort });
|
|
23
|
+
|
|
24
|
+
console.log(`BlipBurst dashboard running at http://localhost:${resolvedPort}`);
|
|
25
|
+
if (resolvedPort !== preferredPort) {
|
|
26
|
+
console.log(`(port ${preferredPort} was in use — auto-selected ${resolvedPort})`);
|
|
27
|
+
}
|
|
28
|
+
console.log(`Port written to .blipburst/port — point BlipBurst's logger/webhook adapter at it automatically.`);
|
|
29
|
+
|
|
30
|
+
const shutdown = () => process.exit(0);
|
|
31
|
+
process.on('SIGINT', shutdown);
|
|
32
|
+
process.on('SIGTERM', shutdown);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
main().catch((err) => {
|
|
36
|
+
console.error('Failed to start BlipBurst dashboard:', err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Structural copies of the BlipBurst shapes these adapters plug into.
|
|
2
|
+
// Kept local (rather than imported from `blipburst`) so this package stays
|
|
3
|
+
// dependency-free and independently publishable; the shapes must match
|
|
4
|
+
// `LogEntry` / `LogTransport` exported by `blipburst/src/types.ts`.
|
|
5
|
+
|
|
6
|
+
export type BlipBurstLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
7
|
+
|
|
8
|
+
export interface BlipBurstLogEntry {
|
|
9
|
+
timestamp: string;
|
|
10
|
+
level: BlipBurstLogLevel;
|
|
11
|
+
event: string;
|
|
12
|
+
data?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type BlipBurstLogTransport = (entry: BlipBurstLogEntry) => void;
|
|
16
|
+
|
|
17
|
+
/** Minimal shape of a `blipburst` instance that `wrapForDashboard` needs. */
|
|
18
|
+
export interface BlipBurstLike {
|
|
19
|
+
makeRequest(overrideUrl?: string): Promise<unknown>;
|
|
20
|
+
getMetrics(): { faultStats: Record<string, number>; [key: string]: unknown };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DashboardAdapterOptions {
|
|
24
|
+
/** Group events from this call under a specific run id instead of a freshly generated one. */
|
|
25
|
+
runId?: string;
|
|
26
|
+
/** Dashboard port override; otherwise read from `.blipburst/port` or defaults to 4477. */
|
|
27
|
+
port?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds a `LogTransport` for `new BlipBurst({ logger: { transport } })`
|
|
32
|
+
* that forwards fault/log events to a running dashboard-blipburst
|
|
33
|
+
* instance, buffering to `.blipburst/log.jsonl` when it isn't reachable.
|
|
34
|
+
*/
|
|
35
|
+
export function toDashboardTransport(port?: number, options?: DashboardAdapterOptions): BlipBurstLogTransport;
|
|
36
|
+
|
|
37
|
+
/** Builds the URL to pass as `webhook.url` so BlipBurst's own WebhookEmitter posts straight at the dashboard. */
|
|
38
|
+
export function toDashboardWebhookUrl(port?: number): string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wraps a BlipBurst instance so every `makeRequest()` call — faulted or
|
|
42
|
+
* not — is reported to the dashboard, enabling accurate chaos-coverage
|
|
43
|
+
* heatmaps (which endpoints were exercised but never fault-tested).
|
|
44
|
+
*/
|
|
45
|
+
export function wrapForDashboard<T extends BlipBurstLike>(sim: T, options?: DashboardAdapterOptions): T;
|
|
46
|
+
|
|
47
|
+
/** Replays any events buffered in `.blipburst/log.jsonl` while the dashboard was unreachable. */
|
|
48
|
+
export function flushBuffer(port?: number): Promise<{ flushed: number; remaining?: number }>;
|
|
49
|
+
|
|
50
|
+
export interface StartDashboardServerOptions {
|
|
51
|
+
/** Preferred port to bind; auto-increments on EADDRINUSE. Defaults to 4477. */
|
|
52
|
+
port?: number;
|
|
53
|
+
/** How many ports above `port` to try before giving up. Defaults to 20. */
|
|
54
|
+
maxPortAttempts?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StartDashboardServerResult {
|
|
58
|
+
server: import('node:http').Server;
|
|
59
|
+
port: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Starts the dashboard HTTP+SSE server. Used by the `blipburst-dashboard` bin; importable for embedding/tests. */
|
|
63
|
+
export function startDashboardServer(options?: StartDashboardServerOptions): Promise<StartDashboardServerResult>;
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_PORT: number;
|
|
66
|
+
export function resolvePort(explicitPort?: number): number;
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dashboard-blipburst",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"blipburst-dashboard": "./bin/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"public",
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node ./bin/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"blipburst",
|
|
28
|
+
"chaos-engineering",
|
|
29
|
+
"dashboard",
|
|
30
|
+
"observability",
|
|
31
|
+
"fault-injection",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"author": "Ajmal N",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/stormdotcom/BlipBurst.git",
|
|
39
|
+
"directory": "dashboard-blipburst"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/stormdotcom/BlipBurst/tree/main/dashboard-blipburst#readme"
|
|
42
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>BlipBurst Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0b0e14;
|
|
10
|
+
--panel: #12161f;
|
|
11
|
+
--panel-border: #1f2530;
|
|
12
|
+
--text: #e6e9ef;
|
|
13
|
+
--text-dim: #8b93a3;
|
|
14
|
+
--accent: #7c9dff;
|
|
15
|
+
--ok: #4ade80;
|
|
16
|
+
--warn: #fbbf24;
|
|
17
|
+
--danger: #f87171;
|
|
18
|
+
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body {
|
|
22
|
+
margin: 0;
|
|
23
|
+
background: var(--bg);
|
|
24
|
+
color: var(--text);
|
|
25
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
26
|
+
font-size: 14px;
|
|
27
|
+
}
|
|
28
|
+
header {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
padding: 14px 20px;
|
|
33
|
+
border-bottom: 1px solid var(--panel-border);
|
|
34
|
+
}
|
|
35
|
+
header h1 {
|
|
36
|
+
font-size: 16px;
|
|
37
|
+
margin: 0;
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
letter-spacing: 0.2px;
|
|
40
|
+
}
|
|
41
|
+
header .dot {
|
|
42
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
43
|
+
background: var(--danger);
|
|
44
|
+
box-shadow: 0 0 0 3px rgba(248,113,113,0.15);
|
|
45
|
+
}
|
|
46
|
+
header .dot.live { background: var(--ok); box-shadow: 0 0 0 3px rgba(74,222,128,0.15); }
|
|
47
|
+
header .status { font-size: 12px; color: var(--text-dim); }
|
|
48
|
+
header .spacer { flex: 1; }
|
|
49
|
+
header code { font-family: var(--mono); color: var(--text-dim); font-size: 12px; }
|
|
50
|
+
|
|
51
|
+
main {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: 1.1fr 1fr;
|
|
54
|
+
gap: 16px;
|
|
55
|
+
padding: 16px 20px;
|
|
56
|
+
max-width: 1400px;
|
|
57
|
+
margin: 0 auto;
|
|
58
|
+
}
|
|
59
|
+
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
|
|
60
|
+
|
|
61
|
+
.panel {
|
|
62
|
+
background: var(--panel);
|
|
63
|
+
border: 1px solid var(--panel-border);
|
|
64
|
+
border-radius: 10px;
|
|
65
|
+
padding: 14px 16px;
|
|
66
|
+
min-width: 0;
|
|
67
|
+
}
|
|
68
|
+
.panel h2 {
|
|
69
|
+
font-size: 13px;
|
|
70
|
+
text-transform: uppercase;
|
|
71
|
+
letter-spacing: 0.6px;
|
|
72
|
+
color: var(--text-dim);
|
|
73
|
+
margin: 0 0 10px 0;
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 8px;
|
|
77
|
+
}
|
|
78
|
+
.panel h2 .hint { text-transform: none; letter-spacing: 0; font-size: 11px; color: var(--text-dim); font-weight: 400; }
|
|
79
|
+
|
|
80
|
+
.feed { max-height: 360px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
|
81
|
+
.feed-row {
|
|
82
|
+
display: grid;
|
|
83
|
+
grid-template-columns: 74px auto 1fr;
|
|
84
|
+
gap: 8px;
|
|
85
|
+
align-items: baseline;
|
|
86
|
+
padding: 6px 8px;
|
|
87
|
+
border-radius: 6px;
|
|
88
|
+
background: rgba(255,255,255,0.02);
|
|
89
|
+
font-family: var(--mono);
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
}
|
|
92
|
+
.feed-row .t { color: var(--text-dim); }
|
|
93
|
+
.feed-row .kind { padding: 1px 6px; border-radius: 4px; font-weight: 600; white-space: nowrap; }
|
|
94
|
+
.feed-row .kind.latency { background: rgba(124,157,255,0.15); color: var(--accent); }
|
|
95
|
+
.feed-row .kind.httpError { background: rgba(248,113,113,0.15); color: var(--danger); }
|
|
96
|
+
.feed-row .kind.reset { background: rgba(248,113,113,0.15); color: var(--danger); }
|
|
97
|
+
.feed-row .kind.timeout { background: rgba(251,191,36,0.15); color: var(--warn); }
|
|
98
|
+
.feed-row .kind.corruption { background: rgba(251,191,36,0.15); color: var(--warn); }
|
|
99
|
+
.feed-row .kind.none { background: rgba(74,222,128,0.12); color: var(--ok); }
|
|
100
|
+
.feed-row .url { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
101
|
+
.empty { color: var(--text-dim); font-size: 12px; padding: 8px 0; }
|
|
102
|
+
|
|
103
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
104
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--panel-border); }
|
|
105
|
+
th { color: var(--text-dim); font-weight: 500; }
|
|
106
|
+
td.num { text-align: right; font-family: var(--mono); }
|
|
107
|
+
tr.untested td.url::after {
|
|
108
|
+
content: 'untested';
|
|
109
|
+
margin-left: 8px;
|
|
110
|
+
font-size: 10px;
|
|
111
|
+
padding: 1px 6px;
|
|
112
|
+
border-radius: 4px;
|
|
113
|
+
background: rgba(251,191,36,0.15);
|
|
114
|
+
color: var(--warn);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.runs-list { display: flex; flex-direction: column; gap: 8px; max-height: 360px; overflow-y: auto; }
|
|
118
|
+
.run { border: 1px solid var(--panel-border); border-radius: 8px; padding: 8px 10px; }
|
|
119
|
+
.run .row1 { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
|
120
|
+
.run .row2 { margin-top: 4px; font-size: 12px; }
|
|
121
|
+
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; margin-right: 6px; font-size: 11px; }
|
|
122
|
+
.badge.fail { background: rgba(248,113,113,0.15); color: var(--danger); }
|
|
123
|
+
.badge.ok { background: rgba(74,222,128,0.15); color: var(--ok); }
|
|
124
|
+
|
|
125
|
+
.mttr-bars { display: flex; flex-direction: column; gap: 10px; }
|
|
126
|
+
.mttr-row .label { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
|
|
127
|
+
.mttr-row .track { background: rgba(255,255,255,0.05); border-radius: 4px; height: 8px; overflow: hidden; }
|
|
128
|
+
.mttr-row .fill { height: 100%; background: var(--accent); }
|
|
129
|
+
.mttr-source { font-size: 11px; color: var(--text-dim); margin-top: 6px; }
|
|
130
|
+
|
|
131
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
132
|
+
::-webkit-scrollbar-thumb { background: var(--panel-border); border-radius: 4px; }
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<header>
|
|
137
|
+
<div class="dot" id="conn-dot"></div>
|
|
138
|
+
<h1>BlipBurst Dashboard</h1>
|
|
139
|
+
<span class="status" id="conn-status">connecting…</span>
|
|
140
|
+
<span class="spacer"></span>
|
|
141
|
+
<code id="port-hint"></code>
|
|
142
|
+
</header>
|
|
143
|
+
|
|
144
|
+
<main>
|
|
145
|
+
<section class="panel" style="grid-column: 1 / -1;">
|
|
146
|
+
<h2>Live fault feed <span class="hint">fault.injected / request.failed events as they arrive</span></h2>
|
|
147
|
+
<div class="feed" id="feed"><div class="empty">Waiting for events — point BlipBurst's <code>logger.transport</code> or <code>webhook.url</code> at this dashboard.</div></div>
|
|
148
|
+
</section>
|
|
149
|
+
|
|
150
|
+
<section class="panel">
|
|
151
|
+
<h2>Chaos coverage heatmap <span class="hint">by endpoint / url</span></h2>
|
|
152
|
+
<table>
|
|
153
|
+
<thead><tr><th>Endpoint</th><th class="num">Requests</th><th class="num">Faulted</th></tr></thead>
|
|
154
|
+
<tbody id="heatmap-body"><tr><td colspan="3" class="empty">No data yet</td></tr></tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</section>
|
|
157
|
+
|
|
158
|
+
<section class="panel">
|
|
159
|
+
<h2>MTTR by fault type <span class="hint" id="mttr-hint"></span></h2>
|
|
160
|
+
<div class="mttr-bars" id="mttr-bars"><div class="empty">No data yet</div></div>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
<section class="panel" style="grid-column: 1 / -1;">
|
|
164
|
+
<h2>Experiment run history <span class="hint">grouped by process / adapter instance</span></h2>
|
|
165
|
+
<div class="runs-list" id="runs-list"><div class="empty">No runs recorded yet</div></div>
|
|
166
|
+
</section>
|
|
167
|
+
</main>
|
|
168
|
+
|
|
169
|
+
<script>
|
|
170
|
+
(function () {
|
|
171
|
+
const feedEl = document.getElementById('feed');
|
|
172
|
+
const heatmapBody = document.getElementById('heatmap-body');
|
|
173
|
+
const mttrBars = document.getElementById('mttr-bars');
|
|
174
|
+
const mttrHint = document.getElementById('mttr-hint');
|
|
175
|
+
const runsList = document.getElementById('runs-list');
|
|
176
|
+
const connDot = document.getElementById('conn-dot');
|
|
177
|
+
const connStatus = document.getElementById('conn-status');
|
|
178
|
+
document.getElementById('port-hint').textContent = location.host;
|
|
179
|
+
|
|
180
|
+
const MAX_FEED_ROWS = 150;
|
|
181
|
+
let feedRows = [];
|
|
182
|
+
|
|
183
|
+
function fmtTime(iso) {
|
|
184
|
+
try { return new Date(iso).toLocaleTimeString(); } catch { return iso; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeHtml(s) {
|
|
188
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderFeed() {
|
|
192
|
+
if (feedRows.length === 0) {
|
|
193
|
+
feedEl.innerHTML = '<div class="empty">Waiting for events — point BlipBurst\'s <code>logger.transport</code> or <code>webhook.url</code> at this dashboard.</div>';
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
feedEl.innerHTML = feedRows.slice(0, MAX_FEED_ROWS).map((e) => {
|
|
197
|
+
const kind = e.faultKind || (e.success === false ? 'httpError' : 'none');
|
|
198
|
+
const label = e.faultKind || (e.success === false ? 'failed' : 'ok');
|
|
199
|
+
return '<div class="feed-row">' +
|
|
200
|
+
'<span class="t">' + fmtTime(e.timestamp) + '</span>' +
|
|
201
|
+
'<span class="kind ' + escapeHtml(kind) + '">' + escapeHtml(label) + '</span>' +
|
|
202
|
+
'<span class="url">' + escapeHtml(e.url || '—') + '</span>' +
|
|
203
|
+
'</div>';
|
|
204
|
+
}).join('');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function addEvent(e) {
|
|
208
|
+
feedRows.unshift(e);
|
|
209
|
+
if (feedRows.length > MAX_FEED_ROWS) feedRows.length = MAX_FEED_ROWS;
|
|
210
|
+
renderFeed();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function refreshHeatmap() {
|
|
214
|
+
const res = await fetch('/api/heatmap');
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
if (!data.rows || data.rows.length === 0) {
|
|
217
|
+
heatmapBody.innerHTML = '<tr><td colspan="3" class="empty">No data yet</td></tr>';
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
heatmapBody.innerHTML = data.rows.map((row) => {
|
|
221
|
+
const requests = data.sawRequestEvents ? row.totalRequests : '—';
|
|
222
|
+
return '<tr class="' + (row.untested ? 'untested' : '') + '">' +
|
|
223
|
+
'<td class="url">' + escapeHtml(row.url) + '</td>' +
|
|
224
|
+
'<td class="num">' + requests + '</td>' +
|
|
225
|
+
'<td class="num">' + row.faultedCount + '</td>' +
|
|
226
|
+
'</tr>';
|
|
227
|
+
}).join('');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function refreshMttr() {
|
|
231
|
+
const res = await fetch('/api/mttr');
|
|
232
|
+
const data = await res.json();
|
|
233
|
+
const entries = Object.entries(data.mttrMsByKind || {});
|
|
234
|
+
mttrHint.textContent = data.source === 'request'
|
|
235
|
+
? '(measured: time-to-next-success)'
|
|
236
|
+
: '(estimated from fault-injection log bursts — wrap with wrapForDashboard() for exact MTTR)';
|
|
237
|
+
if (entries.length === 0) {
|
|
238
|
+
mttrBars.innerHTML = '<div class="empty">No data yet</div>';
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const max = Math.max(...entries.map(([, ms]) => ms), 1);
|
|
242
|
+
mttrBars.innerHTML = entries.map(([kind, ms]) => {
|
|
243
|
+
const pct = Math.max(4, Math.round((ms / max) * 100));
|
|
244
|
+
return '<div class="mttr-row">' +
|
|
245
|
+
'<div class="label"><span>' + escapeHtml(kind) + '</span><span>' + ms + ' ms</span></div>' +
|
|
246
|
+
'<div class="track"><div class="fill" style="width:' + pct + '%"></div></div>' +
|
|
247
|
+
'</div>';
|
|
248
|
+
}).join('');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function refreshRuns() {
|
|
252
|
+
const res = await fetch('/api/runs');
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
if (!data.runs || data.runs.length === 0) {
|
|
255
|
+
runsList.innerHTML = '<div class="empty">No runs recorded yet</div>';
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
runsList.innerHTML = data.runs.map((r) => {
|
|
259
|
+
const kinds = Object.entries(r.faultKinds || {}).map(([k, n]) => k + ':' + n).join(' ');
|
|
260
|
+
return '<div class="run">' +
|
|
261
|
+
'<div class="row1"><span>' + escapeHtml(r.runId) + '</span><span>' + fmtTime(r.startedAt) + ' → ' + fmtTime(r.endedAt) + '</span></div>' +
|
|
262
|
+
'<div class="row2">' +
|
|
263
|
+
'<span class="badge ok">' + r.eventCount + ' events</span>' +
|
|
264
|
+
(r.failureCount ? '<span class="badge fail">' + r.failureCount + ' failed</span>' : '') +
|
|
265
|
+
'<span>' + escapeHtml(kinds) + '</span>' +
|
|
266
|
+
'</div>' +
|
|
267
|
+
'</div>';
|
|
268
|
+
}).join('');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function refreshDerived() {
|
|
272
|
+
refreshHeatmap();
|
|
273
|
+
refreshMttr();
|
|
274
|
+
refreshRuns();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function loadInitial() {
|
|
278
|
+
const res = await fetch('/api/events?limit=150');
|
|
279
|
+
const data = await res.json();
|
|
280
|
+
feedRows = (data.events || []).slice().reverse();
|
|
281
|
+
renderFeed();
|
|
282
|
+
refreshDerived();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function connectStream() {
|
|
286
|
+
const es = new EventSource('/events/stream');
|
|
287
|
+
es.onopen = () => { connDot.classList.add('live'); connStatus.textContent = 'live'; };
|
|
288
|
+
es.onerror = () => { connDot.classList.remove('live'); connStatus.textContent = 'reconnecting…'; };
|
|
289
|
+
let refreshTimer = null;
|
|
290
|
+
es.onmessage = (msg) => {
|
|
291
|
+
try {
|
|
292
|
+
const record = JSON.parse(msg.data);
|
|
293
|
+
addEvent(record);
|
|
294
|
+
clearTimeout(refreshTimer);
|
|
295
|
+
refreshTimer = setTimeout(refreshDerived, 500);
|
|
296
|
+
} catch { /* ignore malformed frame */ }
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
loadInitial();
|
|
301
|
+
connectStream();
|
|
302
|
+
})();
|
|
303
|
+
</script>
|
|
304
|
+
</body>
|
|
305
|
+
</html>
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolvePort, ensureBlipDir, BUFFER_FILE } from './port.js';
|
|
4
|
+
|
|
5
|
+
function bufferEnvelope(envelope) {
|
|
6
|
+
ensureBlipDir();
|
|
7
|
+
try {
|
|
8
|
+
appendFileSync(BUFFER_FILE, JSON.stringify(envelope) + '\n');
|
|
9
|
+
} catch {
|
|
10
|
+
/* if we can't even buffer, drop the event rather than throw */
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function postJson(port, path, payload) {
|
|
15
|
+
const res = await fetch(`http://localhost:${port}${path}`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify(payload),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) throw new Error(`dashboard responded ${res.status}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Best-effort send: on any failure (dashboard not running, network error) buffer to disk instead of throwing. */
|
|
24
|
+
async function sendOrBuffer(port, path, payload) {
|
|
25
|
+
try {
|
|
26
|
+
await postJson(port, path, payload);
|
|
27
|
+
} catch {
|
|
28
|
+
bufferEnvelope({ path, payload });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replays any events buffered while the dashboard was down. Safe to call
|
|
34
|
+
* repeatedly (e.g. before every send) — it's a no-op once the buffer is
|
|
35
|
+
* drained. Failures leave the buffer file untouched so nothing is lost.
|
|
36
|
+
*/
|
|
37
|
+
export async function flushBuffer(port) {
|
|
38
|
+
const resolvedPort = resolvePort(port);
|
|
39
|
+
if (!existsSync(BUFFER_FILE)) return { flushed: 0 };
|
|
40
|
+
|
|
41
|
+
let lines;
|
|
42
|
+
try {
|
|
43
|
+
lines = readFileSync(BUFFER_FILE, 'utf8').split('\n').filter(Boolean);
|
|
44
|
+
} catch {
|
|
45
|
+
return { flushed: 0 };
|
|
46
|
+
}
|
|
47
|
+
if (lines.length === 0) return { flushed: 0 };
|
|
48
|
+
|
|
49
|
+
const remaining = [];
|
|
50
|
+
let flushed = 0;
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
let envelope;
|
|
53
|
+
try {
|
|
54
|
+
envelope = JSON.parse(line);
|
|
55
|
+
} catch {
|
|
56
|
+
continue; // drop corrupt line
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await postJson(resolvedPort, envelope.path, envelope.payload);
|
|
60
|
+
flushed++;
|
|
61
|
+
} catch {
|
|
62
|
+
remaining.push(line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(BUFFER_FILE, remaining.length ? remaining.join('\n') + '\n' : '');
|
|
68
|
+
} catch {
|
|
69
|
+
/* best-effort */
|
|
70
|
+
}
|
|
71
|
+
return { flushed, remaining: remaining.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds a `LogTransport` (the shape BlipBurst's `LoggerOptions.transport`
|
|
76
|
+
* expects: `(entry: LogEntry) => void`) that forwards every log entry to a
|
|
77
|
+
* running dashboard-blipburst instance. If the dashboard isn't reachable,
|
|
78
|
+
* events are appended to `.blipburst/log.jsonl` instead of being lost, and
|
|
79
|
+
* are flushed automatically the next time this transport is created (i.e.
|
|
80
|
+
* the next process start) or on demand via `flushBuffer()`.
|
|
81
|
+
*
|
|
82
|
+
* Usage:
|
|
83
|
+
* import { toDashboardTransport } from 'dashboard-blipburst';
|
|
84
|
+
* const sim = new BlipBurst({ logger: { transport: toDashboardTransport() } });
|
|
85
|
+
*/
|
|
86
|
+
export function toDashboardTransport(port, options = {}) {
|
|
87
|
+
const resolvedPort = resolvePort(port);
|
|
88
|
+
const runId = options.runId ?? randomUUID();
|
|
89
|
+
|
|
90
|
+
// Try to drain anything buffered from a previous run without blocking
|
|
91
|
+
// transport creation.
|
|
92
|
+
flushBuffer(resolvedPort).catch(() => {});
|
|
93
|
+
|
|
94
|
+
return function dashboardTransport(entry) {
|
|
95
|
+
// LogTransport is synchronous/fire-and-forget by contract — never await
|
|
96
|
+
// or throw here, BlipBurst calls this inline on the request path.
|
|
97
|
+
sendOrBuffer(resolvedPort, '/ingest/log', { runId, entry, source: 'logger' }).catch(() => {});
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns the URL to hand to BlipBurst's `webhook.url` option so its
|
|
103
|
+
* built-in WebhookEmitter posts fault/circuit events straight at the
|
|
104
|
+
* dashboard — no adapter function needed on that path since BlipBurst's
|
|
105
|
+
* webhook is already URL-based.
|
|
106
|
+
*
|
|
107
|
+
* Usage:
|
|
108
|
+
* const sim = new BlipBurst({ webhook: { url: toDashboardWebhookUrl() } });
|
|
109
|
+
*/
|
|
110
|
+
export function toDashboardWebhookUrl(port) {
|
|
111
|
+
const resolvedPort = resolvePort(port);
|
|
112
|
+
return `http://localhost:${resolvedPort}/ingest/webhook`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function faultKindDiff(before, after) {
|
|
116
|
+
for (const kind of Object.keys(after)) {
|
|
117
|
+
if ((after[kind] ?? 0) > (before[kind] ?? 0)) return kind;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Wraps a `BlipBurst` instance so *every* `makeRequest()` call — faulted or
|
|
124
|
+
* not — is reported to the dashboard. This is what makes the "chaos
|
|
125
|
+
* coverage" heatmap able to tell the difference between an endpoint that
|
|
126
|
+
* is merely faulted often and one that is called constantly but never
|
|
127
|
+
* chaos-tested: logger/webhook events alone only fire when a fault
|
|
128
|
+
* actually happens.
|
|
129
|
+
*
|
|
130
|
+
* Does not modify BlipBurst itself — it wraps the public `makeRequest` and
|
|
131
|
+
* `getMetrics` methods on the instance you already constructed.
|
|
132
|
+
*
|
|
133
|
+
* Usage:
|
|
134
|
+
* const sim = wrapForDashboard(new BlipBurst({ profile: 'flaky' }));
|
|
135
|
+
* await sim.makeRequest();
|
|
136
|
+
*/
|
|
137
|
+
export function wrapForDashboard(sim, options = {}) {
|
|
138
|
+
const resolvedPort = resolvePort(options.port);
|
|
139
|
+
const runId = options.runId ?? randomUUID();
|
|
140
|
+
const original = sim.makeRequest.bind(sim);
|
|
141
|
+
|
|
142
|
+
sim.makeRequest = async function wrappedMakeRequest(overrideUrl) {
|
|
143
|
+
const before = sim.getMetrics().faultStats;
|
|
144
|
+
// `url` is a private TS field on BlipBurst at compile time, but private
|
|
145
|
+
// fields are a type-checker construct only — the instance property is
|
|
146
|
+
// readable like any other at runtime, which is what lets us log the
|
|
147
|
+
// endpoint for calls that don't pass an explicit override.
|
|
148
|
+
const url = overrideUrl ?? sim.url ?? 'unknown';
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
let success = true;
|
|
151
|
+
try {
|
|
152
|
+
return await original(overrideUrl);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
success = false;
|
|
155
|
+
throw err;
|
|
156
|
+
} finally {
|
|
157
|
+
const after = sim.getMetrics().faultStats;
|
|
158
|
+
const faultKind = faultKindDiff(before, after);
|
|
159
|
+
sendOrBuffer(resolvedPort, '/ingest/request', {
|
|
160
|
+
runId,
|
|
161
|
+
url,
|
|
162
|
+
faultKind,
|
|
163
|
+
success,
|
|
164
|
+
durationMs: Date.now() - startedAt,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
}).catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return sim;
|
|
171
|
+
}
|
package/src/port.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PORT = 4477;
|
|
5
|
+
export const BLIP_DIR = '.blipburst';
|
|
6
|
+
export const PORT_FILE = join(BLIP_DIR, 'port');
|
|
7
|
+
export const BUFFER_FILE = join(BLIP_DIR, 'log.jsonl');
|
|
8
|
+
export const EVENTS_FILE = join(BLIP_DIR, 'events.jsonl');
|
|
9
|
+
|
|
10
|
+
export function ensureBlipDir() {
|
|
11
|
+
try {
|
|
12
|
+
mkdirSync(BLIP_DIR, { recursive: true });
|
|
13
|
+
} catch {
|
|
14
|
+
/* best-effort */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read the port the dashboard server last advertised in this cwd, if any. */
|
|
19
|
+
export function readPortFile() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = readFileSync(PORT_FILE, 'utf8').trim();
|
|
22
|
+
const parsed = parseInt(raw, 10);
|
|
23
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
24
|
+
} catch {
|
|
25
|
+
/* no port file yet */
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writePortFile(port) {
|
|
31
|
+
ensureBlipDir();
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(PORT_FILE, String(port), 'utf8');
|
|
34
|
+
} catch {
|
|
35
|
+
/* best-effort */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve the port the dashboard is (or should be) reachable on. */
|
|
40
|
+
export function resolvePort(explicitPort) {
|
|
41
|
+
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;
|
|
45
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { EventStore } from './store.js';
|
|
6
|
+
import { writePortFile } from './port.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const INDEX_HTML_PATH = join(__dirname, '..', 'public', 'index.html');
|
|
10
|
+
|
|
11
|
+
function readJsonBody(req) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let raw = '';
|
|
14
|
+
req.on('data', (chunk) => {
|
|
15
|
+
raw += chunk;
|
|
16
|
+
if (raw.length > 5_000_000) req.destroy(new Error('payload too large'));
|
|
17
|
+
});
|
|
18
|
+
req.on('end', () => {
|
|
19
|
+
if (!raw) return resolve({});
|
|
20
|
+
try {
|
|
21
|
+
resolve(JSON.parse(raw));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
reject(err);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
req.on('error', reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sendJson(res, status, payload) {
|
|
31
|
+
const body = JSON.stringify(payload);
|
|
32
|
+
res.writeHead(status, {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'Access-Control-Allow-Origin': '*',
|
|
35
|
+
});
|
|
36
|
+
res.end(body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createRequestHandler(store) {
|
|
40
|
+
const sseClients = new Set();
|
|
41
|
+
|
|
42
|
+
return async function handler(req, res) {
|
|
43
|
+
const url = new URL(req.url, `http://localhost`);
|
|
44
|
+
const { pathname } = url;
|
|
45
|
+
|
|
46
|
+
if (req.method === 'OPTIONS') {
|
|
47
|
+
res.writeHead(204, {
|
|
48
|
+
'Access-Control-Allow-Origin': '*',
|
|
49
|
+
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
50
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
51
|
+
});
|
|
52
|
+
return res.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
56
|
+
try {
|
|
57
|
+
const html = readFileSync(INDEX_HTML_PATH, 'utf8');
|
|
58
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
59
|
+
return res.end(html);
|
|
60
|
+
} catch {
|
|
61
|
+
res.writeHead(500);
|
|
62
|
+
return res.end('dashboard UI missing');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (req.method === 'GET' && pathname === '/api/health') {
|
|
67
|
+
return sendJson(res, 200, { ok: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === 'GET' && pathname === '/api/events') {
|
|
71
|
+
const since = url.searchParams.get('since') ?? undefined;
|
|
72
|
+
const limit = parseInt(url.searchParams.get('limit') ?? '500', 10);
|
|
73
|
+
return sendJson(res, 200, { events: store.recent(limit, since) });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (req.method === 'GET' && pathname === '/api/runs') {
|
|
77
|
+
return sendJson(res, 200, { runs: store.runs() });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (req.method === 'GET' && pathname === '/api/heatmap') {
|
|
81
|
+
return sendJson(res, 200, store.heatmap());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (req.method === 'GET' && pathname === '/api/mttr') {
|
|
85
|
+
return sendJson(res, 200, store.mttr());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.method === 'GET' && pathname === '/events/stream') {
|
|
89
|
+
res.writeHead(200, {
|
|
90
|
+
'Content-Type': 'text/event-stream',
|
|
91
|
+
'Cache-Control': 'no-cache',
|
|
92
|
+
Connection: 'keep-alive',
|
|
93
|
+
'Access-Control-Allow-Origin': '*',
|
|
94
|
+
});
|
|
95
|
+
res.write(': connected\n\n');
|
|
96
|
+
const send = (record) => res.write(`data: ${JSON.stringify(record)}\n\n`);
|
|
97
|
+
sseClients.add(send);
|
|
98
|
+
const unsubscribe = store.subscribe(send);
|
|
99
|
+
const keepAlive = setInterval(() => res.write(': ping\n\n'), 25_000);
|
|
100
|
+
req.on('close', () => {
|
|
101
|
+
clearInterval(keepAlive);
|
|
102
|
+
sseClients.delete(send);
|
|
103
|
+
unsubscribe();
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (req.method === 'POST' && pathname === '/ingest/log') {
|
|
109
|
+
try {
|
|
110
|
+
const body = await readJsonBody(req);
|
|
111
|
+
const record = store.ingest('log', body);
|
|
112
|
+
return sendJson(res, 202, { ok: true, id: record.id });
|
|
113
|
+
} catch {
|
|
114
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (req.method === 'POST' && pathname === '/ingest/webhook') {
|
|
119
|
+
try {
|
|
120
|
+
const body = await readJsonBody(req);
|
|
121
|
+
const record = store.ingest('webhook', body);
|
|
122
|
+
return sendJson(res, 202, { ok: true, id: record.id });
|
|
123
|
+
} catch {
|
|
124
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (req.method === 'POST' && pathname === '/ingest/request') {
|
|
129
|
+
try {
|
|
130
|
+
const body = await readJsonBody(req);
|
|
131
|
+
const record = store.ingest('request', body);
|
|
132
|
+
return sendJson(res, 202, { ok: true, id: record.id });
|
|
133
|
+
} catch {
|
|
134
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
139
|
+
res.end('not found');
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function listenWithAutoIncrement(server, port, attemptsLeft) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
server.once('error', (err) => {
|
|
146
|
+
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
147
|
+
resolve(listenWithAutoIncrement(server, port + 1, attemptsLeft - 1));
|
|
148
|
+
} else {
|
|
149
|
+
reject(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
server.listen(port, () => resolve(port));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Starts the dashboard HTTP+SSE server. Tries `preferredPort` first and
|
|
158
|
+
* auto-increments on EADDRINUSE, writing the port that was actually bound
|
|
159
|
+
* to `.blipburst/port` in the current working directory so the BlipBurst
|
|
160
|
+
* SDK adapter can discover it.
|
|
161
|
+
*/
|
|
162
|
+
export async function startDashboardServer({ port: preferredPort = 4477, maxPortAttempts = 20 } = {}) {
|
|
163
|
+
const store = new EventStore();
|
|
164
|
+
const server = createServer(createRequestHandler(store));
|
|
165
|
+
const port = await listenWithAutoIncrement(server, preferredPort, maxPortAttempts);
|
|
166
|
+
writePortFile(port);
|
|
167
|
+
return { server, store, port };
|
|
168
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { EVENTS_FILE, ensureBlipDir } from './port.js';
|
|
4
|
+
|
|
5
|
+
const MAX_IN_MEMORY = 5000;
|
|
6
|
+
// Consecutive fault.injected log lines of the same kind+url within this gap
|
|
7
|
+
// are treated as one "incident" for the log-only MTTR fallback.
|
|
8
|
+
const INCIDENT_GAP_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes the three ingest shapes (log-transport entries, webhook
|
|
12
|
+
* payloads, and the richer /ingest/request events from wrapForDashboard)
|
|
13
|
+
* into one flat event record the dashboard UI and derived views consume.
|
|
14
|
+
*/
|
|
15
|
+
function normalize(kind, body) {
|
|
16
|
+
const receivedAt = new Date().toISOString();
|
|
17
|
+
const id = randomUUID();
|
|
18
|
+
|
|
19
|
+
if (kind === 'request') {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
kind,
|
|
23
|
+
receivedAt,
|
|
24
|
+
runId: body.runId ?? 'unknown',
|
|
25
|
+
timestamp: body.timestamp ?? receivedAt,
|
|
26
|
+
url: body.url ?? 'unknown',
|
|
27
|
+
faultKind: body.faultKind ?? null,
|
|
28
|
+
success: body.success !== false,
|
|
29
|
+
durationMs: typeof body.durationMs === 'number' ? body.durationMs : null,
|
|
30
|
+
event: body.faultKind ? 'fault.injected' : 'request.ok',
|
|
31
|
+
level: body.success === false ? 'warn' : 'info',
|
|
32
|
+
raw: body,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 'log' (logger.transport) and 'webhook' both carry a BlipBurst LogEntry
|
|
37
|
+
// shape by default: { timestamp, level, event, data? }. A custom
|
|
38
|
+
// webhook.transform can change that shape entirely, so fall back
|
|
39
|
+
// gracefully when the expected fields are missing.
|
|
40
|
+
const entry = kind === 'log' ? body.entry ?? body : body;
|
|
41
|
+
const data = entry && typeof entry === 'object' ? entry.data ?? {} : {};
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
kind,
|
|
45
|
+
receivedAt,
|
|
46
|
+
runId: body.runId ?? 'unknown',
|
|
47
|
+
timestamp: entry?.timestamp ?? receivedAt,
|
|
48
|
+
url: data.url ?? null,
|
|
49
|
+
faultKind: entry?.event === 'fault.injected' ? data.kind ?? null : null,
|
|
50
|
+
success: entry?.event !== 'request.failed',
|
|
51
|
+
durationMs: null,
|
|
52
|
+
event: entry?.event ?? 'unknown',
|
|
53
|
+
level: entry?.level ?? 'info',
|
|
54
|
+
raw: body,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class EventStore {
|
|
59
|
+
constructor() {
|
|
60
|
+
this.events = [];
|
|
61
|
+
this.subscribers = new Set();
|
|
62
|
+
this._loadFromDisk();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_loadFromDisk() {
|
|
66
|
+
ensureBlipDir();
|
|
67
|
+
if (!existsSync(EVENTS_FILE)) return;
|
|
68
|
+
try {
|
|
69
|
+
const lines = readFileSync(EVENTS_FILE, 'utf8').split('\n').filter(Boolean);
|
|
70
|
+
for (const line of lines.slice(-MAX_IN_MEMORY)) {
|
|
71
|
+
try {
|
|
72
|
+
this.events.push(JSON.parse(line));
|
|
73
|
+
} catch {
|
|
74
|
+
/* skip corrupt line */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort load */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
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();
|
|
86
|
+
try {
|
|
87
|
+
ensureBlipDir();
|
|
88
|
+
appendFileSync(EVENTS_FILE, JSON.stringify(record) + '\n');
|
|
89
|
+
} catch {
|
|
90
|
+
/* best-effort persistence */
|
|
91
|
+
}
|
|
92
|
+
for (const send of this.subscribers) send(record);
|
|
93
|
+
return record;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
subscribe(send) {
|
|
97
|
+
this.subscribers.add(send);
|
|
98
|
+
return () => this.subscribers.delete(send);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
recent(limit = 500, since) {
|
|
102
|
+
let list = this.events;
|
|
103
|
+
if (since) list = list.filter((e) => e.receivedAt > since);
|
|
104
|
+
return list.slice(-limit);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Group events by runId into a chronological experiment-run timeline. */
|
|
108
|
+
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()]
|
|
135
|
+
.map((r) => ({ ...r, urls: [...r.urls] }))
|
|
136
|
+
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
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`.
|
|
145
|
+
*/
|
|
146
|
+
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) => {
|
|
163
|
+
const faulted = Object.values(row.faultCounts).reduce((a, b) => a + b, 0);
|
|
164
|
+
return {
|
|
165
|
+
...row,
|
|
166
|
+
faultedCount: faulted,
|
|
167
|
+
untested: row.coverageKnown && row.totalRequests > 0 && faulted === 0,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
rows.sort((a, b) => b.faultedCount - a.faultedCount);
|
|
171
|
+
return { sawRequestEvents, rows };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* MTTR per fault kind.
|
|
176
|
+
*
|
|
177
|
+
* Preferred method (`source: 'request'`): wrapForDashboard reports every
|
|
178
|
+
* 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.
|
|
180
|
+
*
|
|
181
|
+
* Fallback (`source: 'log'`): plain logger/webhook wiring only sees
|
|
182
|
+
* fault.injected lines, so we approximate recovery time as the duration
|
|
183
|
+
* of each "incident" — a run of same-kind faults on the same url with no
|
|
184
|
+
* gap larger than INCIDENT_GAP_MS between them.
|
|
185
|
+
*/
|
|
186
|
+
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
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { source: 'request', mttrMsByKind: average(durationsByKind) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_mttrFromLogs() {
|
|
222
|
+
const faultEvents = this.events
|
|
223
|
+
.filter((e) => e.faultKind)
|
|
224
|
+
.slice()
|
|
225
|
+
.sort((a, b) => (a.timestamp < b.timestamp ? -1 : 1));
|
|
226
|
+
|
|
227
|
+
const byUrlKind = new Map();
|
|
228
|
+
for (const e of faultEvents) {
|
|
229
|
+
const key = `${e.url ?? 'unknown'}::${e.faultKind}`;
|
|
230
|
+
const list = byUrlKind.get(key) ?? [];
|
|
231
|
+
list.push(e);
|
|
232
|
+
byUrlKind.set(key, list);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const durationsByKind = new Map();
|
|
236
|
+
for (const [key, list] of byUrlKind) {
|
|
237
|
+
const kind = key.split('::')[1];
|
|
238
|
+
let incidentStart = list[0].timestamp;
|
|
239
|
+
let prev = list[0].timestamp;
|
|
240
|
+
for (let i = 1; i <= list.length; i++) {
|
|
241
|
+
const cur = list[i]?.timestamp;
|
|
242
|
+
const gap = cur ? Date.parse(cur) - Date.parse(prev) : Infinity;
|
|
243
|
+
if (gap > INCIDENT_GAP_MS || !cur) {
|
|
244
|
+
const duration = Date.parse(prev) - Date.parse(incidentStart);
|
|
245
|
+
const arr = durationsByKind.get(kind) ?? [];
|
|
246
|
+
arr.push(duration);
|
|
247
|
+
durationsByKind.set(kind, arr);
|
|
248
|
+
incidentStart = cur;
|
|
249
|
+
}
|
|
250
|
+
prev = cur ?? prev;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { source: 'log', mttrMsByKind: average(durationsByKind) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function average(mapOfArrays) {
|
|
258
|
+
const out = {};
|
|
259
|
+
for (const [kind, arr] of mapOfArrays) {
|
|
260
|
+
out[kind] = arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|