@composed-di/observability 0.5.0-alpha

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.
Files changed (41) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +50 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/dashboardClient.d.ts +67 -0
  6. package/dist/dashboardClient.d.ts.map +1 -0
  7. package/dist/dashboardClient.js +168 -0
  8. package/dist/dashboardClient.js.map +1 -0
  9. package/dist/dashboardEventListener.d.ts +32 -0
  10. package/dist/dashboardEventListener.d.ts.map +1 -0
  11. package/dist/dashboardEventListener.js +88 -0
  12. package/dist/dashboardEventListener.js.map +1 -0
  13. package/dist/dashboardHtml.d.ts +11 -0
  14. package/dist/dashboardHtml.d.ts.map +1 -0
  15. package/dist/dashboardHtml.js +662 -0
  16. package/dist/dashboardHtml.js.map +1 -0
  17. package/dist/dashboardServer.d.ts +99 -0
  18. package/dist/dashboardServer.d.ts.map +1 -0
  19. package/dist/dashboardServer.js +360 -0
  20. package/dist/dashboardServer.js.map +1 -0
  21. package/dist/events.d.ts +80 -0
  22. package/dist/events.d.ts.map +1 -0
  23. package/dist/events.js +7 -0
  24. package/dist/events.js.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/moduleGraph.d.ts +13 -0
  30. package/dist/moduleGraph.d.ts.map +1 -0
  31. package/dist/moduleGraph.js +25 -0
  32. package/dist/moduleGraph.js.map +1 -0
  33. package/package.json +47 -0
  34. package/src/cli.ts +42 -0
  35. package/src/dashboardClient.ts +189 -0
  36. package/src/dashboardEventListener.ts +104 -0
  37. package/src/dashboardHtml.ts +659 -0
  38. package/src/dashboardServer.ts +391 -0
  39. package/src/events.ts +94 -0
  40. package/src/index.ts +5 -0
  41. package/src/moduleGraph.ts +37 -0
@@ -0,0 +1,391 @@
1
+ import {
2
+ createServer,
3
+ IncomingMessage,
4
+ Server,
5
+ ServerResponse,
6
+ } from 'node:http';
7
+ import { AddressInfo } from 'node:net';
8
+ import { ServiceModule } from '@composed-di/core';
9
+ import { DashboardEventListener } from './dashboardEventListener';
10
+ import { ModuleGraph, moduleGraph } from './moduleGraph';
11
+ import { renderDashboardHtml } from './dashboardHtml';
12
+ import {
13
+ DashboardSnapshot,
14
+ GraphEdge,
15
+ GraphNode,
16
+ ServiceStats,
17
+ SpanEvent,
18
+ SpanKind,
19
+ SpanStart,
20
+ WireEvent,
21
+ } from './events';
22
+
23
+ export interface ServiceDashboardOptions {
24
+ /** How many recent events to keep for late-joining clients. Default 200. */
25
+ recentEventLimit?: number;
26
+ }
27
+
28
+ const HEARTBEAT_INTERVAL_MS = 15_000;
29
+
30
+ /**
31
+ * A realtime dashboard server for observed ServiceModules.
32
+ *
33
+ * Serves a self-contained HTML page that renders the dependency graph and
34
+ * streams service initialization, disposal, and method-call activity to it
35
+ * over Server-Sent Events. Uses only Node.js built-ins.
36
+ *
37
+ * Two ways to feed it, mirroring how OpenTelemetry separates SDK and
38
+ * collector:
39
+ *
40
+ * **Standalone (recommended):** run the server on its own — `npx
41
+ * composed-di-dashboard` or `new ServiceDashboard().listen(4321)` — and have
42
+ * the application export to it with a {@link DashboardClient}. The server
43
+ * accepts `POST /v1/graph` (the dependency graph) and `POST /v1/events`
44
+ * (batched span events).
45
+ *
46
+ * **In-process:** create the module with `dashboard.listener`, call
47
+ * `dashboard.attach(module)`, and `listen` from the same process.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * // dashboard process
52
+ * const dashboard = new ServiceDashboard();
53
+ * await dashboard.listen(4321);
54
+ *
55
+ * // application process
56
+ * const client = new DashboardClient({ url: 'http://localhost:4321' });
57
+ * const module = ServiceModule.from(factories, client.listener);
58
+ * client.attach(module);
59
+ * ```
60
+ */
61
+ export class ServiceDashboard {
62
+ /** Pass this as the second argument to ServiceModule.from. */
63
+ readonly listener = new DashboardEventListener();
64
+
65
+ private nodes: GraphNode[] = [];
66
+ private edges: GraphEdge[] = [];
67
+ private services = new Map<string, ServiceStats>();
68
+ /** Open spans, so end events can be resolved to their start metadata. */
69
+ private openSpans = new Map<number, SpanStart>();
70
+ private recent: WireEvent[] = [];
71
+ private readonly recentEventLimit: number;
72
+ /** Whether any graph was registered; ingest is refused (409) before that. */
73
+ private graphRegistered = false;
74
+
75
+ private clients = new Set<ServerResponse>();
76
+ private server: Server | null = null;
77
+ private heartbeat: NodeJS.Timeout | null = null;
78
+
79
+ constructor({ recentEventLimit = 200 }: ServiceDashboardOptions = {}) {
80
+ this.recentEventLimit = recentEventLimit;
81
+ this.listener.subscribe((event) => this.onSpanEvent(event));
82
+ }
83
+
84
+ /**
85
+ * In-process mode: reads the dependency graph out of the module. Call
86
+ * this with the module that was created with this dashboard's listener.
87
+ */
88
+ attach(module: ServiceModule): this {
89
+ this.registerGraph(moduleGraph(module));
90
+ return this;
91
+ }
92
+
93
+ /**
94
+ * Registers a dependency graph, resetting all aggregated state (a new
95
+ * registration means a new application run). Remote applications call this
96
+ * through `POST /v1/graph`.
97
+ */
98
+ registerGraph(graph: ModuleGraph): void {
99
+ this.graphRegistered = true;
100
+ this.nodes = graph.nodes;
101
+ this.edges = graph.edges;
102
+ this.services = new Map(
103
+ this.nodes.map((node) => [node.name, freshStats()]),
104
+ );
105
+ this.openSpans.clear();
106
+ this.recent = [];
107
+ this.broadcastSnapshot();
108
+ }
109
+
110
+ /**
111
+ * Applies span events, e.g. exported by a remote DashboardClient through
112
+ * `POST /v1/events`. Services not present in the registered graph are
113
+ * added as standalone nodes rather than dropped.
114
+ */
115
+ ingest(events: SpanEvent[]): void {
116
+ for (const event of events) {
117
+ if (
118
+ event.type === 'start' &&
119
+ event.service !== null &&
120
+ !this.services.has(event.service)
121
+ ) {
122
+ this.nodes.push({ name: event.service });
123
+ this.services.set(event.service, freshStats());
124
+ this.broadcastSnapshot();
125
+ }
126
+ this.onSpanEvent(event);
127
+ }
128
+ }
129
+
130
+ /** The current full dashboard state, as sent to newly connected clients. */
131
+ snapshot(): DashboardSnapshot {
132
+ return {
133
+ nodes: this.nodes,
134
+ edges: this.edges,
135
+ services: Object.fromEntries(this.services),
136
+ recent: this.recent,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Starts the HTTP server.
142
+ *
143
+ * @param port The port to listen on; 0 picks a free port. Default 4321.
144
+ * @param host The host to bind. Default 127.0.0.1.
145
+ * @returns The URL the dashboard is reachable at.
146
+ */
147
+ listen(port = 4321, host = '127.0.0.1'): Promise<string> {
148
+ if (this.server) {
149
+ throw new Error('Dashboard server is already listening');
150
+ }
151
+ const server = createServer((request, response) => {
152
+ void this.route(request, response);
153
+ });
154
+ this.server = server;
155
+ this.heartbeat = setInterval(() => {
156
+ this.clients.forEach((client) => client.write(': ping\n\n'));
157
+ }, HEARTBEAT_INTERVAL_MS);
158
+ // Don't let an idle dashboard keep the process alive on its own.
159
+ this.heartbeat.unref();
160
+
161
+ return new Promise((resolve, reject) => {
162
+ server.once('error', reject);
163
+ server.listen(port, host, () => {
164
+ const address = server.address() as AddressInfo;
165
+ const shownHost =
166
+ host === '0.0.0.0' || host === '::' ? 'localhost' : host;
167
+ resolve(`http://${shownHost}:${address.port}`);
168
+ });
169
+ });
170
+ }
171
+
172
+ /** Stops the server and disconnects all clients. */
173
+ async close(): Promise<void> {
174
+ if (this.heartbeat) {
175
+ clearInterval(this.heartbeat);
176
+ this.heartbeat = null;
177
+ }
178
+ this.clients.forEach((client) => client.end());
179
+ this.clients.clear();
180
+ const server = this.server;
181
+ this.server = null;
182
+ if (server) {
183
+ await new Promise<void>((resolve, reject) => {
184
+ server.close((error) => (error ? reject(error) : resolve()));
185
+ });
186
+ }
187
+ }
188
+
189
+ private async route(
190
+ request: IncomingMessage,
191
+ response: ServerResponse,
192
+ ): Promise<void> {
193
+ const path = request.url?.split('?')[0];
194
+ const route = `${request.method} ${path}`;
195
+ try {
196
+ switch (route) {
197
+ case 'GET /':
198
+ response.writeHead(200, {
199
+ 'content-type': 'text/html; charset=utf-8',
200
+ });
201
+ response.end(renderDashboardHtml());
202
+ return;
203
+ case 'GET /events':
204
+ this.acceptEventStream(response);
205
+ return;
206
+ case 'GET /snapshot':
207
+ response.writeHead(200, { 'content-type': 'application/json' });
208
+ response.end(JSON.stringify(this.snapshot()));
209
+ return;
210
+ case 'POST /v1/graph': {
211
+ const graph = await readJsonBody(request);
212
+ if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges)) {
213
+ throw new BadRequestError('expected { nodes: [], edges: [] }');
214
+ }
215
+ this.registerGraph(graph);
216
+ response.writeHead(204);
217
+ response.end();
218
+ return;
219
+ }
220
+ case 'POST /v1/events': {
221
+ const body = await readJsonBody(request);
222
+ if (!Array.isArray(body?.events)) {
223
+ throw new BadRequestError('expected { events: [] }');
224
+ }
225
+ // A restarted server has no graph; tell the client to re-register
226
+ // before it sends events, so context isn't rendered nodeless.
227
+ if (!this.graphRegistered) {
228
+ response.writeHead(409, { 'content-type': 'text/plain' });
229
+ response.end('no graph registered; POST /v1/graph first');
230
+ return;
231
+ }
232
+ this.ingest(body.events);
233
+ response.writeHead(204);
234
+ response.end();
235
+ return;
236
+ }
237
+ default:
238
+ response.writeHead(404, { 'content-type': 'text/plain' });
239
+ response.end('Not found');
240
+ }
241
+ } catch (error) {
242
+ const badRequest = error instanceof BadRequestError;
243
+ response.writeHead(badRequest ? 400 : 500, {
244
+ 'content-type': 'text/plain',
245
+ });
246
+ response.end(badRequest ? error.message : 'Internal error');
247
+ }
248
+ }
249
+
250
+ private acceptEventStream(response: ServerResponse): void {
251
+ response.writeHead(200, {
252
+ 'content-type': 'text/event-stream',
253
+ 'cache-control': 'no-cache',
254
+ connection: 'keep-alive',
255
+ });
256
+ response.write(sseMessage('snapshot', this.snapshot()));
257
+ this.clients.add(response);
258
+ response.on('close', () => this.clients.delete(response));
259
+ }
260
+
261
+ /**
262
+ * Applies a raw span event to the aggregated per-service state and
263
+ * broadcasts the enriched event to connected clients.
264
+ */
265
+ private onSpanEvent(event: SpanEvent): void {
266
+ let wire: WireEvent;
267
+
268
+ if (event.type === 'start') {
269
+ this.openSpans.set(event.id, event);
270
+ const stats = this.applyStart(event);
271
+ wire = {
272
+ span: event,
273
+ service: event.service,
274
+ method: event.method,
275
+ kind: event.kind,
276
+ parentService: this.parentServiceOf(event),
277
+ // Copy so the recent-events buffer keeps at-the-time values.
278
+ stats: stats ? { ...stats } : null,
279
+ };
280
+ } else {
281
+ const start = this.openSpans.get(event.id);
282
+ this.openSpans.delete(event.id);
283
+ const stats = start ? this.applyEnd(start, event) : null;
284
+ wire = {
285
+ span: event,
286
+ service: start?.service ?? null,
287
+ method: start?.method ?? '?',
288
+ kind: start?.kind ?? 'call',
289
+ parentService: start ? this.parentServiceOf(start) : null,
290
+ stats: stats ? { ...stats } : null,
291
+ };
292
+ }
293
+
294
+ this.recent.push(wire);
295
+ if (this.recent.length > this.recentEventLimit) {
296
+ this.recent.splice(0, this.recent.length - this.recentEventLimit);
297
+ }
298
+ const message = sseMessage('span', wire);
299
+ this.clients.forEach((client) => client.write(message));
300
+ }
301
+
302
+ private applyStart(event: SpanStart): ServiceStats | null {
303
+ const stats = event.service ? this.services.get(event.service) : undefined;
304
+ if (!stats) return null;
305
+ if (event.kind === 'initialize') {
306
+ stats.status = 'initializing';
307
+ }
308
+ return stats;
309
+ }
310
+
311
+ private applyEnd(
312
+ start: SpanStart,
313
+ event: { durationMs: number; error: string | null },
314
+ ): ServiceStats | null {
315
+ const stats = start.service ? this.services.get(start.service) : undefined;
316
+ if (!stats) return null;
317
+
318
+ if (event.error !== null) {
319
+ stats.errors += 1;
320
+ }
321
+ const transitions: Record<SpanKind, () => void> = {
322
+ initialize: () => {
323
+ stats.status = event.error !== null ? 'error' : 'ready';
324
+ if (event.error === null) stats.initMs = event.durationMs;
325
+ },
326
+ dispose: () => {
327
+ if (event.error === null) stats.status = 'disposed';
328
+ },
329
+ call: () => {
330
+ stats.calls += 1;
331
+ stats.totalCallMs += event.durationMs;
332
+ },
333
+ };
334
+ transitions[start.kind]();
335
+ return stats;
336
+ }
337
+
338
+ /** Walks to the parent span to find which service triggered this one. */
339
+ private parentServiceOf(event: SpanStart): string | null {
340
+ if (event.parentId === null) return null;
341
+ return this.openSpans.get(event.parentId)?.service ?? null;
342
+ }
343
+
344
+ /** Pushes the full state to all connected clients, e.g. on graph changes. */
345
+ private broadcastSnapshot(): void {
346
+ const message = sseMessage('snapshot', this.snapshot());
347
+ this.clients.forEach((client) => client.write(message));
348
+ }
349
+ }
350
+
351
+ function freshStats(): ServiceStats {
352
+ return {
353
+ status: 'pending',
354
+ initMs: null,
355
+ calls: 0,
356
+ errors: 0,
357
+ totalCallMs: 0,
358
+ };
359
+ }
360
+
361
+ class BadRequestError extends Error {}
362
+
363
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
364
+
365
+ function readJsonBody(request: IncomingMessage): Promise<any> {
366
+ return new Promise((resolve, reject) => {
367
+ const chunks: Buffer[] = [];
368
+ let size = 0;
369
+ request.on('data', (chunk: Buffer) => {
370
+ size += chunk.length;
371
+ if (size > MAX_BODY_BYTES) {
372
+ reject(new BadRequestError('request body too large'));
373
+ request.destroy();
374
+ return;
375
+ }
376
+ chunks.push(chunk);
377
+ });
378
+ request.on('end', () => {
379
+ try {
380
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
381
+ } catch {
382
+ reject(new BadRequestError('invalid JSON body'));
383
+ }
384
+ });
385
+ request.on('error', reject);
386
+ });
387
+ }
388
+
389
+ function sseMessage(event: string, data: unknown): string {
390
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
391
+ }
package/src/events.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Event and state types shared between the dashboard event listener, the
3
+ * dashboard server, and the browser client (over the SSE wire).
4
+ */
5
+
6
+ /** Classifies what a span represents in the service lifecycle. */
7
+ export type SpanKind = 'initialize' | 'dispose' | 'call';
8
+
9
+ /** Emitted when a traced function starts executing. */
10
+ export interface SpanStart {
11
+ type: 'start';
12
+ /** Monotonically increasing span id, unique per tracer. */
13
+ id: number;
14
+ /** Id of the span that was active when this one started, if any. */
15
+ parentId: number | null;
16
+ /** The qualified span name, e.g. "Database.query". */
17
+ name: string;
18
+ /** The service the span belongs to, or null if it could not be attributed. */
19
+ service: string | null;
20
+ /** The method or lifecycle step, e.g. "query" or "initialize". */
21
+ method: string;
22
+ kind: SpanKind;
23
+ /** Epoch milliseconds. */
24
+ time: number;
25
+ }
26
+
27
+ /** Emitted when a traced function finishes (or throws/rejects). */
28
+ export interface SpanEnd {
29
+ type: 'end';
30
+ /** Matches the id of the corresponding SpanStart. */
31
+ id: number;
32
+ /** Epoch milliseconds. */
33
+ time: number;
34
+ durationMs: number;
35
+ /** The error message when the traced function threw or rejected. */
36
+ error: string | null;
37
+ }
38
+
39
+ export type SpanEvent = SpanStart | SpanEnd;
40
+
41
+ export type ServiceStatus =
42
+ | 'pending'
43
+ | 'initializing'
44
+ | 'ready'
45
+ | 'error'
46
+ | 'disposed';
47
+
48
+ /** Aggregated per-service counters maintained by the dashboard. */
49
+ export interface ServiceStats {
50
+ status: ServiceStatus;
51
+ /** Duration of the last successful initialization, if any. */
52
+ initMs: number | null;
53
+ /** Number of completed method-call spans. */
54
+ calls: number;
55
+ /** Number of spans (of any kind) that ended with an error. */
56
+ errors: number;
57
+ /** Sum of completed method-call durations, for averaging. */
58
+ totalCallMs: number;
59
+ }
60
+
61
+ export interface GraphNode {
62
+ name: string;
63
+ }
64
+
65
+ /** A dependency edge: `from` depends on `to`. */
66
+ export interface GraphEdge {
67
+ from: string;
68
+ to: string;
69
+ }
70
+
71
+ /**
72
+ * The enriched event broadcast to dashboard clients. The server resolves
73
+ * cross-span context (parent service, span metadata on end events) and the
74
+ * updated per-service stats so the client needs no correlation logic.
75
+ */
76
+ export interface WireEvent {
77
+ span: SpanEvent;
78
+ /** Service of the span; on end events resolved from the matching start. */
79
+ service: string | null;
80
+ method: string;
81
+ kind: SpanKind;
82
+ /** Service of the parent span, when the span was started by another service. */
83
+ parentService: string | null;
84
+ /** Updated stats for `service`, present when the event changed them. */
85
+ stats: ServiceStats | null;
86
+ }
87
+
88
+ /** Full state sent to a client when it connects. */
89
+ export interface DashboardSnapshot {
90
+ nodes: GraphNode[];
91
+ edges: GraphEdge[];
92
+ services: Record<string, ServiceStats>;
93
+ recent: WireEvent[];
94
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './events';
2
+ export * from './dashboardEventListener';
3
+ export * from './dashboardClient';
4
+ export * from './dashboardServer';
5
+ export * from './moduleGraph';
@@ -0,0 +1,37 @@
1
+ import {
2
+ ServiceKey,
3
+ ServiceModule,
4
+ ServiceSelectorKey,
5
+ } from '@composed-di/core';
6
+ import { GraphEdge, GraphNode } from './events';
7
+
8
+ export interface ModuleGraph {
9
+ nodes: GraphNode[];
10
+ edges: GraphEdge[];
11
+ }
12
+
13
+ /**
14
+ * Extracts the dependency graph of a module: one node per factory, one edge
15
+ * per dependency. Selector dependencies expand to an edge per grouped key
16
+ * ("may resolve any of these").
17
+ */
18
+ export function moduleGraph(module: ServiceModule): ModuleGraph {
19
+ const nodes = module.factories.map((factory) => ({
20
+ name: factory.provides.name,
21
+ }));
22
+
23
+ const edges = module.factories.flatMap((factory) =>
24
+ factory.dependsOn.flatMap((dependency: ServiceKey<unknown>) => {
25
+ const targets =
26
+ dependency instanceof ServiceSelectorKey
27
+ ? dependency.values
28
+ : [dependency];
29
+ return targets.map((target) => ({
30
+ from: factory.provides.name,
31
+ to: target.name,
32
+ }));
33
+ }),
34
+ );
35
+
36
+ return { nodes, edges };
37
+ }