@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.
- package/dist/cli.js.map +1 -1
- package/dist/dashboardClient.d.ts +19 -11
- package/dist/dashboardClient.d.ts.map +1 -1
- package/dist/dashboardClient.js +30 -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 +42 -0
- package/dist/dashboardInstrumentation.d.ts.map +1 -0
- package/dist/dashboardInstrumentation.js +116 -0
- package/dist/dashboardInstrumentation.js.map +1 -0
- package/dist/dashboardServer.d.ts +14 -8
- package/dist/dashboardServer.d.ts.map +1 -1
- package/dist/dashboardServer.js +54 -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 +96 -83
- package/src/dashboardHtml.ts +620 -100
- package/src/dashboardInstrumentation.ts +151 -0
- package/src/dashboardServer.ts +185 -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,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
|
+
}
|
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,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(
|
|
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
|
|
63
|
-
readonly
|
|
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({
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
this.
|
|
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 ?
|
|
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
|
-
|
|
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
|
}
|