@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.
- package/dist/cli.js.map +1 -1
- package/dist/dashboardClient.d.ts +17 -11
- package/dist/dashboardClient.d.ts.map +1 -1
- package/dist/dashboardClient.js +28 -11
- package/dist/dashboardClient.js.map +1 -1
- package/dist/dashboardEventListener.d.ts +27 -8
- package/dist/dashboardEventListener.d.ts.map +1 -1
- package/dist/dashboardEventListener.js +38 -16
- package/dist/dashboardEventListener.js.map +1 -1
- package/dist/dashboardHtml.d.ts +6 -0
- package/dist/dashboardHtml.d.ts.map +1 -1
- package/dist/dashboardHtml.js +618 -98
- package/dist/dashboardHtml.js.map +1 -1
- package/dist/dashboardInstrumentation.d.ts +44 -0
- package/dist/dashboardInstrumentation.d.ts.map +1 -0
- package/dist/dashboardInstrumentation.js +119 -0
- package/dist/dashboardInstrumentation.js.map +1 -0
- package/dist/dashboardServer.d.ts +12 -8
- package/dist/dashboardServer.d.ts.map +1 -1
- package/dist/dashboardServer.js +52 -14
- package/dist/dashboardServer.js.map +1 -1
- package/dist/events.d.ts +20 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/moduleGraph.d.ts.map +1 -1
- package/dist/moduleGraph.js.map +1 -1
- package/package.json +24 -23
- package/src/cli.ts +19 -19
- package/src/dashboardClient.ts +94 -83
- package/src/dashboardHtml.ts +620 -100
- package/src/dashboardInstrumentation.ts +153 -0
- package/src/dashboardServer.ts +183 -148
- package/src/events.ts +55 -35
- package/src/index.ts +5 -5
- package/src/moduleGraph.ts +9 -9
- 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
|
+
}
|
package/src/dashboardServer.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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(
|
|
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
|
-
/**
|
|
63
|
-
readonly
|
|
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({
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
this.
|
|
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 ?
|
|
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
|
-
|
|
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
|
}
|