@composed-di/observability 0.5.0-alpha → 0.7.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 (40) hide show
  1. package/dist/cli.js.map +1 -1
  2. package/dist/dashboardClient.d.ts +17 -11
  3. package/dist/dashboardClient.d.ts.map +1 -1
  4. package/dist/dashboardClient.js +28 -11
  5. package/dist/dashboardClient.js.map +1 -1
  6. package/dist/dashboardEventListener.d.ts +27 -8
  7. package/dist/dashboardEventListener.d.ts.map +1 -1
  8. package/dist/dashboardEventListener.js +38 -16
  9. package/dist/dashboardEventListener.js.map +1 -1
  10. package/dist/dashboardHtml.d.ts +6 -0
  11. package/dist/dashboardHtml.d.ts.map +1 -1
  12. package/dist/dashboardHtml.js +618 -98
  13. package/dist/dashboardHtml.js.map +1 -1
  14. package/dist/dashboardInstrumentation.d.ts +44 -0
  15. package/dist/dashboardInstrumentation.d.ts.map +1 -0
  16. package/dist/dashboardInstrumentation.js +119 -0
  17. package/dist/dashboardInstrumentation.js.map +1 -0
  18. package/dist/dashboardServer.d.ts +12 -8
  19. package/dist/dashboardServer.d.ts.map +1 -1
  20. package/dist/dashboardServer.js +52 -14
  21. package/dist/dashboardServer.js.map +1 -1
  22. package/dist/events.d.ts +20 -1
  23. package/dist/events.d.ts.map +1 -1
  24. package/dist/events.js +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/moduleGraph.d.ts.map +1 -1
  30. package/dist/moduleGraph.js.map +1 -1
  31. package/package.json +24 -23
  32. package/src/cli.ts +19 -19
  33. package/src/dashboardClient.ts +94 -83
  34. package/src/dashboardHtml.ts +620 -100
  35. package/src/dashboardInstrumentation.ts +153 -0
  36. package/src/dashboardServer.ts +183 -148
  37. package/src/events.ts +55 -35
  38. package/src/index.ts +5 -5
  39. package/src/moduleGraph.ts +9 -9
  40. package/src/dashboardEventListener.ts +0 -104
@@ -0,0 +1,153 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { performance } from 'node:perf_hooks'
3
+ import { ServiceInstrumentation } from '@composed-di/instrumentation-core'
4
+ import type {
5
+ DisposeContext,
6
+ OperationSpan,
7
+ InitializeContext,
8
+ MethodCallContext,
9
+ } from '@composed-di/instrumentation-core'
10
+ import { SpanEvent, SpanKind } from './events'
11
+
12
+ /** The span context propagated across sync and async call boundaries. */
13
+ interface SpanContext {
14
+ id: number
15
+ }
16
+
17
+ export interface DashboardInstrumentationOptions {
18
+ /** Longest serialized value kept; longer ones are truncated. Default 200. */
19
+ maxValueLength?: number
20
+ }
21
+
22
+ /**
23
+ * A ServiceInstrumentation that turns service events into structured
24
+ * start/end span events for the realtime dashboard.
25
+ *
26
+ * - Uses AsyncLocalStorage to link spans to the span that was active when
27
+ * they started, which lets the dashboard draw cross-service call edges
28
+ * (e.g. UserService.getUser -> Database.query). The observed operation
29
+ * is invoked through the span's `run` wrapper, so the context is scoped
30
+ * to the operation and its async continuations.
31
+ * - Emits events synchronously to subscribers; it never buffers.
32
+ *
33
+ * Arguments and results are serialized onto spans exactly when
34
+ * `instrument()` (inherited from ServiceInstrumentation) delivers them —
35
+ * capture and redaction policy live in the InstrumentOptions, not here.
36
+ * Pass `capture: { arguments: true, results: true }` there to see values
37
+ * in the dashboard.
38
+ */
39
+ export class DashboardInstrumentation extends ServiceInstrumentation {
40
+ private readonly context = new AsyncLocalStorage<SpanContext>()
41
+ private readonly listeners = new Set<(event: SpanEvent) => void>()
42
+ private nextId = 1
43
+ private readonly maxValueLength: number
44
+
45
+ constructor({ maxValueLength = 200 }: DashboardInstrumentationOptions = {}) {
46
+ super()
47
+ this.maxValueLength = maxValueLength
48
+ }
49
+
50
+ /**
51
+ * Subscribes to span events.
52
+ *
53
+ * @returns A function that removes the subscription.
54
+ */
55
+ subscribe(listener: (event: SpanEvent) => void): () => void {
56
+ this.listeners.add(listener)
57
+ return () => this.listeners.delete(listener)
58
+ }
59
+
60
+ onInitialize({ key }: InitializeContext): OperationSpan {
61
+ return this.startSpan(key.name, 'initialize', 'initialize')
62
+ }
63
+
64
+ onDispose({ key }: DisposeContext): OperationSpan {
65
+ return this.startSpan(key.name, 'dispose', 'dispose')
66
+ }
67
+
68
+ onMethodCall({ key, methodName, args }: MethodCallContext): OperationSpan {
69
+ // Args are present exactly when argument capture is enabled in the
70
+ // InstrumentOptions; they arrive already redacted.
71
+ return this.startSpan(
72
+ key.name,
73
+ methodName,
74
+ 'call',
75
+ args ? this.serialize(args) : undefined,
76
+ )
77
+ }
78
+
79
+ private startSpan(
80
+ service: string,
81
+ method: string,
82
+ kind: SpanKind,
83
+ args?: string,
84
+ ): OperationSpan {
85
+ const id = this.nextId++
86
+ const parent = this.context.getStore()
87
+
88
+ this.emit({
89
+ type: 'start',
90
+ id,
91
+ parentId: parent?.id ?? null,
92
+ name: `${service}.${method}`,
93
+ service,
94
+ method,
95
+ kind,
96
+ time: Date.now(),
97
+ args,
98
+ })
99
+
100
+ const startedAt = performance.now()
101
+ let ended = false
102
+ const end = (error: string | null, result?: string) => {
103
+ if (ended) return
104
+ ended = true
105
+ this.emit({
106
+ type: 'end',
107
+ id,
108
+ time: Date.now(),
109
+ durationMs: performance.now() - startedAt,
110
+ error,
111
+ result,
112
+ })
113
+ }
114
+
115
+ return {
116
+ // Running the operation inside this span's context makes it the
117
+ // parent of any spans started within.
118
+ run: (fn) => this.context.run({ id }, fn),
119
+ end: (outcome) => {
120
+ if (outcome.type === 'failure') {
121
+ end(errorMessage(outcome.error))
122
+ } else {
123
+ // A value is present exactly when result capture is enabled in
124
+ // the InstrumentOptions; it arrives already redacted.
125
+ end(
126
+ null,
127
+ 'value' in outcome ? this.serialize(outcome.value) : undefined,
128
+ )
129
+ }
130
+ },
131
+ }
132
+ }
133
+
134
+ private serialize(value: unknown): string {
135
+ let text: string
136
+ try {
137
+ text = JSON.stringify(value) ?? String(value)
138
+ } catch {
139
+ text = '[unserializable]'
140
+ }
141
+ return text.length > this.maxValueLength
142
+ ? text.slice(0, this.maxValueLength) + '…'
143
+ : text
144
+ }
145
+
146
+ private emit(event: SpanEvent): void {
147
+ this.listeners.forEach((listener) => listener(event))
148
+ }
149
+ }
150
+
151
+ function errorMessage(error: unknown): string {
152
+ return error instanceof Error ? error.message : String(error)
153
+ }
@@ -3,12 +3,15 @@ import {
3
3
  IncomingMessage,
4
4
  Server,
5
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';
6
+ } from 'node:http'
7
+ import { AddressInfo } from 'node:net'
8
+ import { ServiceModule } from '@composed-di/core'
9
+ import {
10
+ DashboardInstrumentation,
11
+ DashboardInstrumentationOptions,
12
+ } from './dashboardInstrumentation'
13
+ import { ModuleGraph, moduleGraph } from './moduleGraph'
14
+ import { renderDashboardHtml } from './dashboardHtml'
12
15
  import {
13
16
  DashboardSnapshot,
14
17
  GraphEdge,
@@ -18,14 +21,14 @@ import {
18
21
  SpanKind,
19
22
  SpanStart,
20
23
  WireEvent,
21
- } from './events';
24
+ } from './events'
22
25
 
23
- export interface ServiceDashboardOptions {
26
+ export interface ServiceDashboardOptions extends DashboardInstrumentationOptions {
24
27
  /** How many recent events to keep for late-joining clients. Default 200. */
25
- recentEventLimit?: number;
28
+ recentEventLimit?: number
26
29
  }
27
30
 
28
- const HEARTBEAT_INTERVAL_MS = 15_000;
31
+ const HEARTBEAT_INTERVAL_MS = 15_000
29
32
 
30
33
  /**
31
34
  * A realtime dashboard server for observed ServiceModules.
@@ -43,7 +46,7 @@ const HEARTBEAT_INTERVAL_MS = 15_000;
43
46
  * accepts `POST /v1/graph` (the dependency graph) and `POST /v1/events`
44
47
  * (batched span events).
45
48
  *
46
- * **In-process:** create the module with `dashboard.listener`, call
49
+ * **In-process:** create the module with `dashboard.instrumentation`, call
47
50
  * `dashboard.attach(module)`, and `listen` from the same process.
48
51
  *
49
52
  * @example
@@ -54,40 +57,48 @@ const HEARTBEAT_INTERVAL_MS = 15_000;
54
57
  *
55
58
  * // application process
56
59
  * const client = new DashboardClient({ url: 'http://localhost:4321' });
57
- * const module = ServiceModule.from(factories, client.listener);
60
+ * const module = ServiceModule.from(
61
+ * client.instrumentation.instrument(factories, {
62
+ * capture: { arguments: true, results: true },
63
+ * }),
64
+ * );
58
65
  * client.attach(module);
59
66
  * ```
60
67
  */
61
68
  export class ServiceDashboard {
62
- /** Pass this as the second argument to ServiceModule.from. */
63
- readonly listener = new DashboardEventListener();
69
+ /** Call `.instrument()` on this to wrap the factories composing the module. */
70
+ readonly instrumentation: DashboardInstrumentation
64
71
 
65
- private nodes: GraphNode[] = [];
66
- private edges: GraphEdge[] = [];
67
- private services = new Map<string, ServiceStats>();
72
+ private nodes: GraphNode[] = []
73
+ private edges: GraphEdge[] = []
74
+ private services = new Map<string, ServiceStats>()
68
75
  /** 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;
76
+ private openSpans = new Map<number, SpanStart>()
77
+ private recent: WireEvent[] = []
78
+ private readonly recentEventLimit: number
72
79
  /** Whether any graph was registered; ingest is refused (409) before that. */
73
- private graphRegistered = false;
80
+ private graphRegistered = false
74
81
 
75
- private clients = new Set<ServerResponse>();
76
- private server: Server | null = null;
77
- private heartbeat: NodeJS.Timeout | null = null;
82
+ private clients = new Set<ServerResponse>()
83
+ private server: Server | null = null
84
+ private heartbeat: NodeJS.Timeout | null = null
78
85
 
79
- constructor({ recentEventLimit = 200 }: ServiceDashboardOptions = {}) {
80
- this.recentEventLimit = recentEventLimit;
81
- this.listener.subscribe((event) => this.onSpanEvent(event));
86
+ constructor({
87
+ recentEventLimit = 200,
88
+ ...instrumentationOptions
89
+ }: ServiceDashboardOptions = {}) {
90
+ this.instrumentation = new DashboardInstrumentation(instrumentationOptions)
91
+ this.recentEventLimit = recentEventLimit
92
+ this.instrumentation.subscribe((event) => this.onSpanEvent(event))
82
93
  }
83
94
 
84
95
  /**
85
96
  * 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.
97
+ * this with the module that was created with this dashboard's instrumentation.
87
98
  */
88
99
  attach(module: ServiceModule): this {
89
- this.registerGraph(moduleGraph(module));
90
- return this;
100
+ this.registerGraph(moduleGraph(module))
101
+ return this
91
102
  }
92
103
 
93
104
  /**
@@ -96,15 +107,13 @@ export class ServiceDashboard {
96
107
  * through `POST /v1/graph`.
97
108
  */
98
109
  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();
110
+ this.graphRegistered = true
111
+ this.nodes = graph.nodes
112
+ this.edges = graph.edges
113
+ this.services = new Map(this.nodes.map((node) => [node.name, freshStats()]))
114
+ this.openSpans.clear()
115
+ this.recent = []
116
+ this.broadcastSnapshot()
108
117
  }
109
118
 
110
119
  /**
@@ -119,11 +128,11 @@ export class ServiceDashboard {
119
128
  event.service !== null &&
120
129
  !this.services.has(event.service)
121
130
  ) {
122
- this.nodes.push({ name: event.service });
123
- this.services.set(event.service, freshStats());
124
- this.broadcastSnapshot();
131
+ this.nodes.push({ name: event.service })
132
+ this.services.set(event.service, freshStats())
133
+ this.broadcastSnapshot()
125
134
  }
126
- this.onSpanEvent(event);
135
+ this.onSpanEvent(event)
127
136
  }
128
137
  }
129
138
 
@@ -134,7 +143,7 @@ export class ServiceDashboard {
134
143
  edges: this.edges,
135
144
  services: Object.fromEntries(this.services),
136
145
  recent: this.recent,
137
- };
146
+ }
138
147
  }
139
148
 
140
149
  /**
@@ -146,43 +155,43 @@ export class ServiceDashboard {
146
155
  */
147
156
  listen(port = 4321, host = '127.0.0.1'): Promise<string> {
148
157
  if (this.server) {
149
- throw new Error('Dashboard server is already listening');
158
+ throw new Error('Dashboard server is already listening')
150
159
  }
151
160
  const server = createServer((request, response) => {
152
- void this.route(request, response);
153
- });
154
- this.server = server;
161
+ void this.route(request, response)
162
+ })
163
+ this.server = server
155
164
  this.heartbeat = setInterval(() => {
156
- this.clients.forEach((client) => client.write(': ping\n\n'));
157
- }, HEARTBEAT_INTERVAL_MS);
165
+ this.clients.forEach((client) => client.write(': ping\n\n'))
166
+ }, HEARTBEAT_INTERVAL_MS)
158
167
  // Don't let an idle dashboard keep the process alive on its own.
159
- this.heartbeat.unref();
168
+ this.heartbeat.unref()
160
169
 
161
170
  return new Promise((resolve, reject) => {
162
- server.once('error', reject);
171
+ server.once('error', reject)
163
172
  server.listen(port, host, () => {
164
- const address = server.address() as AddressInfo;
173
+ const address = server.address() as AddressInfo
165
174
  const shownHost =
166
- host === '0.0.0.0' || host === '::' ? 'localhost' : host;
167
- resolve(`http://${shownHost}:${address.port}`);
168
- });
169
- });
175
+ host === '0.0.0.0' || host === '::' ? 'localhost' : host
176
+ resolve(`http://${shownHost}:${address.port}`)
177
+ })
178
+ })
170
179
  }
171
180
 
172
181
  /** Stops the server and disconnects all clients. */
173
182
  async close(): Promise<void> {
174
183
  if (this.heartbeat) {
175
- clearInterval(this.heartbeat);
176
- this.heartbeat = null;
184
+ clearInterval(this.heartbeat)
185
+ this.heartbeat = null
177
186
  }
178
- this.clients.forEach((client) => client.end());
179
- this.clients.clear();
180
- const server = this.server;
181
- this.server = null;
187
+ this.clients.forEach((client) => client.end())
188
+ this.clients.clear()
189
+ const server = this.server
190
+ this.server = null
182
191
  if (server) {
183
192
  await new Promise<void>((resolve, reject) => {
184
- server.close((error) => (error ? reject(error) : resolve()));
185
- });
193
+ server.close((error) => (error ? reject(error) : resolve()))
194
+ })
186
195
  }
187
196
  }
188
197
 
@@ -190,60 +199,60 @@ export class ServiceDashboard {
190
199
  request: IncomingMessage,
191
200
  response: ServerResponse,
192
201
  ): Promise<void> {
193
- const path = request.url?.split('?')[0];
194
- const route = `${request.method} ${path}`;
202
+ const path = request.url?.split('?')[0]
203
+ const route = `${request.method} ${path}`
195
204
  try {
196
205
  switch (route) {
197
206
  case 'GET /':
198
207
  response.writeHead(200, {
199
208
  'content-type': 'text/html; charset=utf-8',
200
- });
201
- response.end(renderDashboardHtml());
202
- return;
209
+ })
210
+ response.end(renderDashboardHtml())
211
+ return
203
212
  case 'GET /events':
204
- this.acceptEventStream(response);
205
- return;
213
+ this.acceptEventStream(response)
214
+ return
206
215
  case 'GET /snapshot':
207
- response.writeHead(200, { 'content-type': 'application/json' });
208
- response.end(JSON.stringify(this.snapshot()));
209
- return;
216
+ response.writeHead(200, { 'content-type': 'application/json' })
217
+ response.end(JSON.stringify(this.snapshot()))
218
+ return
210
219
  case 'POST /v1/graph': {
211
- const graph = await readJsonBody(request);
220
+ const graph = await readJsonBody(request)
212
221
  if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges)) {
213
- throw new BadRequestError('expected { nodes: [], edges: [] }');
222
+ throw new BadRequestError('expected { nodes: [], edges: [] }')
214
223
  }
215
- this.registerGraph(graph);
216
- response.writeHead(204);
217
- response.end();
218
- return;
224
+ this.registerGraph(graph)
225
+ response.writeHead(204)
226
+ response.end()
227
+ return
219
228
  }
220
229
  case 'POST /v1/events': {
221
- const body = await readJsonBody(request);
230
+ const body = await readJsonBody(request)
222
231
  if (!Array.isArray(body?.events)) {
223
- throw new BadRequestError('expected { events: [] }');
232
+ throw new BadRequestError('expected { events: [] }')
224
233
  }
225
234
  // A restarted server has no graph; tell the client to re-register
226
235
  // before it sends events, so context isn't rendered nodeless.
227
236
  if (!this.graphRegistered) {
228
- response.writeHead(409, { 'content-type': 'text/plain' });
229
- response.end('no graph registered; POST /v1/graph first');
230
- return;
237
+ response.writeHead(409, { 'content-type': 'text/plain' })
238
+ response.end('no graph registered; POST /v1/graph first')
239
+ return
231
240
  }
232
- this.ingest(body.events);
233
- response.writeHead(204);
234
- response.end();
235
- return;
241
+ this.ingest(body.events)
242
+ response.writeHead(204)
243
+ response.end()
244
+ return
236
245
  }
237
246
  default:
238
- response.writeHead(404, { 'content-type': 'text/plain' });
239
- response.end('Not found');
247
+ response.writeHead(404, { 'content-type': 'text/plain' })
248
+ response.end('Not found')
240
249
  }
241
250
  } catch (error) {
242
- const badRequest = error instanceof BadRequestError;
251
+ const badRequest = error instanceof BadRequestError
243
252
  response.writeHead(badRequest ? 400 : 500, {
244
253
  'content-type': 'text/plain',
245
- });
246
- response.end(badRequest ? error.message : 'Internal error');
254
+ })
255
+ response.end(badRequest ? error.message : 'Internal error')
247
256
  }
248
257
  }
249
258
 
@@ -252,10 +261,10 @@ export class ServiceDashboard {
252
261
  'content-type': 'text/event-stream',
253
262
  'cache-control': 'no-cache',
254
263
  connection: 'keep-alive',
255
- });
256
- response.write(sseMessage('snapshot', this.snapshot()));
257
- this.clients.add(response);
258
- response.on('close', () => this.clients.delete(response));
264
+ })
265
+ response.write(sseMessage('snapshot', this.snapshot()))
266
+ this.clients.add(response)
267
+ response.on('close', () => this.clients.delete(response))
259
268
  }
260
269
 
261
270
  /**
@@ -263,88 +272,100 @@ export class ServiceDashboard {
263
272
  * broadcasts the enriched event to connected clients.
264
273
  */
265
274
  private onSpanEvent(event: SpanEvent): void {
266
- let wire: WireEvent;
275
+ let wire: WireEvent
267
276
 
268
277
  if (event.type === 'start') {
269
- this.openSpans.set(event.id, event);
270
- const stats = this.applyStart(event);
278
+ this.openSpans.set(event.id, event)
279
+ const stats = this.applyStart(event)
271
280
  wire = {
272
281
  span: event,
273
282
  service: event.service,
274
283
  method: event.method,
275
284
  kind: event.kind,
276
285
  parentService: this.parentServiceOf(event),
286
+ args: event.args ?? null,
277
287
  // Copy so the recent-events buffer keeps at-the-time values.
278
- stats: stats ? { ...stats } : null,
279
- };
288
+ stats: stats ? copyStats(stats) : null,
289
+ }
280
290
  } else {
281
- const start = this.openSpans.get(event.id);
282
- this.openSpans.delete(event.id);
283
- const stats = start ? this.applyEnd(start, event) : null;
291
+ const start = this.openSpans.get(event.id)
292
+ this.openSpans.delete(event.id)
293
+ const stats = start ? this.applyEnd(start, event) : null
284
294
  wire = {
285
295
  span: event,
286
296
  service: start?.service ?? null,
287
297
  method: start?.method ?? '?',
288
298
  kind: start?.kind ?? 'call',
289
299
  parentService: start ? this.parentServiceOf(start) : null,
290
- stats: stats ? { ...stats } : null,
291
- };
300
+ args: start?.args ?? null,
301
+ stats: stats ? copyStats(stats) : null,
302
+ }
292
303
  }
293
304
 
294
- this.recent.push(wire);
305
+ this.recent.push(wire)
295
306
  if (this.recent.length > this.recentEventLimit) {
296
- this.recent.splice(0, this.recent.length - this.recentEventLimit);
307
+ this.recent.splice(0, this.recent.length - this.recentEventLimit)
297
308
  }
298
- const message = sseMessage('span', wire);
299
- this.clients.forEach((client) => client.write(message));
309
+ const message = sseMessage('span', wire)
310
+ this.clients.forEach((client) => client.write(message))
300
311
  }
301
312
 
302
313
  private applyStart(event: SpanStart): ServiceStats | null {
303
- const stats = event.service ? this.services.get(event.service) : undefined;
304
- if (!stats) return null;
314
+ const stats = event.service ? this.services.get(event.service) : undefined
315
+ if (!stats) return null
305
316
  if (event.kind === 'initialize') {
306
- stats.status = 'initializing';
317
+ stats.status = 'initializing'
307
318
  }
308
- return stats;
319
+ return stats
309
320
  }
310
321
 
311
322
  private applyEnd(
312
323
  start: SpanStart,
313
324
  event: { durationMs: number; error: string | null },
314
325
  ): ServiceStats | null {
315
- const stats = start.service ? this.services.get(start.service) : undefined;
316
- if (!stats) return null;
326
+ const stats = start.service ? this.services.get(start.service) : undefined
327
+ if (!stats) return null
317
328
 
318
329
  if (event.error !== null) {
319
- stats.errors += 1;
330
+ stats.errors += 1
320
331
  }
321
332
  const transitions: Record<SpanKind, () => void> = {
322
333
  initialize: () => {
323
- stats.status = event.error !== null ? 'error' : 'ready';
324
- if (event.error === null) stats.initMs = event.durationMs;
334
+ stats.status = event.error !== null ? 'error' : 'ready'
335
+ if (event.error === null) stats.initMs = event.durationMs
325
336
  },
326
337
  dispose: () => {
327
- if (event.error === null) stats.status = 'disposed';
338
+ if (event.error === null) stats.status = 'disposed'
328
339
  },
329
340
  call: () => {
330
- stats.calls += 1;
331
- stats.totalCallMs += event.durationMs;
341
+ stats.calls += 1
342
+ stats.totalCallMs += event.durationMs
343
+ const method = (stats.methods[start.method] ??= {
344
+ calls: 0,
345
+ errors: 0,
346
+ totalMs: 0,
347
+ lastMs: 0,
348
+ })
349
+ method.calls += 1
350
+ method.totalMs += event.durationMs
351
+ method.lastMs = event.durationMs
352
+ if (event.error !== null) method.errors += 1
332
353
  },
333
- };
334
- transitions[start.kind]();
335
- return stats;
354
+ }
355
+ transitions[start.kind]()
356
+ return stats
336
357
  }
337
358
 
338
359
  /** Walks to the parent span to find which service triggered this one. */
339
360
  private parentServiceOf(event: SpanStart): string | null {
340
- if (event.parentId === null) return null;
341
- return this.openSpans.get(event.parentId)?.service ?? null;
361
+ if (event.parentId === null) return null
362
+ return this.openSpans.get(event.parentId)?.service ?? null
342
363
  }
343
364
 
344
365
  /** Pushes the full state to all connected clients, e.g. on graph changes. */
345
366
  private broadcastSnapshot(): void {
346
- const message = sseMessage('snapshot', this.snapshot());
347
- this.clients.forEach((client) => client.write(message));
367
+ const message = sseMessage('snapshot', this.snapshot())
368
+ this.clients.forEach((client) => client.write(message))
348
369
  }
349
370
  }
350
371
 
@@ -355,37 +376,51 @@ function freshStats(): ServiceStats {
355
376
  calls: 0,
356
377
  errors: 0,
357
378
  totalCallMs: 0,
358
- };
379
+ methods: {},
380
+ }
381
+ }
382
+
383
+ /** Deep enough that the recent-events buffer keeps at-the-time values. */
384
+ function copyStats(stats: ServiceStats): ServiceStats {
385
+ return {
386
+ ...stats,
387
+ methods: Object.fromEntries(
388
+ Object.entries(stats.methods).map(([name, method]) => [
389
+ name,
390
+ { ...method },
391
+ ]),
392
+ ),
393
+ }
359
394
  }
360
395
 
361
396
  class BadRequestError extends Error {}
362
397
 
363
- const MAX_BODY_BYTES = 5 * 1024 * 1024;
398
+ const MAX_BODY_BYTES = 5 * 1024 * 1024
364
399
 
365
400
  function readJsonBody(request: IncomingMessage): Promise<any> {
366
401
  return new Promise((resolve, reject) => {
367
- const chunks: Buffer[] = [];
368
- let size = 0;
402
+ const chunks: Buffer[] = []
403
+ let size = 0
369
404
  request.on('data', (chunk: Buffer) => {
370
- size += chunk.length;
405
+ size += chunk.length
371
406
  if (size > MAX_BODY_BYTES) {
372
- reject(new BadRequestError('request body too large'));
373
- request.destroy();
374
- return;
407
+ reject(new BadRequestError('request body too large'))
408
+ request.destroy()
409
+ return
375
410
  }
376
- chunks.push(chunk);
377
- });
411
+ chunks.push(chunk)
412
+ })
378
413
  request.on('end', () => {
379
414
  try {
380
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
415
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
381
416
  } catch {
382
- reject(new BadRequestError('invalid JSON body'));
417
+ reject(new BadRequestError('invalid JSON body'))
383
418
  }
384
- });
385
- request.on('error', reject);
386
- });
419
+ })
420
+ request.on('error', reject)
421
+ })
387
422
  }
388
423
 
389
424
  function sseMessage(event: string, data: unknown): string {
390
- return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
425
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
391
426
  }