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