@composed-di/observability 0.5.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +50 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashboardClient.d.ts +67 -0
- package/dist/dashboardClient.d.ts.map +1 -0
- package/dist/dashboardClient.js +168 -0
- package/dist/dashboardClient.js.map +1 -0
- package/dist/dashboardEventListener.d.ts +32 -0
- package/dist/dashboardEventListener.d.ts.map +1 -0
- package/dist/dashboardEventListener.js +88 -0
- package/dist/dashboardEventListener.js.map +1 -0
- package/dist/dashboardHtml.d.ts +11 -0
- package/dist/dashboardHtml.d.ts.map +1 -0
- package/dist/dashboardHtml.js +662 -0
- package/dist/dashboardHtml.js.map +1 -0
- package/dist/dashboardServer.d.ts +99 -0
- package/dist/dashboardServer.d.ts.map +1 -0
- package/dist/dashboardServer.js +360 -0
- package/dist/dashboardServer.js.map +1 -0
- package/dist/events.d.ts +80 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +7 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/moduleGraph.d.ts +13 -0
- package/dist/moduleGraph.d.ts.map +1 -0
- package/dist/moduleGraph.js +25 -0
- package/dist/moduleGraph.js.map +1 -0
- package/package.json +47 -0
- package/src/cli.ts +42 -0
- package/src/dashboardClient.ts +189 -0
- package/src/dashboardEventListener.ts +104 -0
- package/src/dashboardHtml.ts +659 -0
- package/src/dashboardServer.ts +391 -0
- package/src/events.ts +94 -0
- package/src/index.ts +5 -0
- package/src/moduleGraph.ts +37 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"moduleGraph.js","sourceRoot":"","sources":["../src/moduleGraph.ts"],"names":[],"mappings":";;AAiBA,kCAmBC;AApCD,4CAI2B;AAQ3B;;;;GAIG;AACH,SAAgB,WAAW,CAAC,MAAqB;IAC/C,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAC/C,IAAI,EAAE,OAAO,CAAC,QAAQ,CAAC,IAAI;KAC5B,CAAC,CAAC,CAAC;IAEJ,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CACjD,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,UAA+B,EAAE,EAAE;QAC5D,MAAM,OAAO,GACX,UAAU,YAAY,yBAAkB;YACtC,CAAC,CAAC,UAAU,CAAC,MAAM;YACnB,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;QACnB,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9B,IAAI,EAAE,OAAO,CAAC,QAAQ,CAAC,IAAI;YAC3B,EAAE,EAAE,MAAM,CAAC,IAAI;SAChB,CAAC,CAAC,CAAC;IACN,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC1B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@composed-di/observability",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.5.0-alpha",
|
|
5
|
+
"author": "Juan Herrera juanhr454@gmail.com",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"require": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"composed-di-dashboard": "./dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dependency injection",
|
|
26
|
+
"di",
|
|
27
|
+
"observability",
|
|
28
|
+
"dashboard",
|
|
29
|
+
"tracing"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/imherrera/composed-di.git",
|
|
35
|
+
"directory": "packages/observability"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@composed-di/core": "0.5.0-alpha"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc --build",
|
|
45
|
+
"clean": "tsc --build --clean"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone dashboard server:
|
|
4
|
+
*
|
|
5
|
+
* composed-di-dashboard [--port 4321] [--host 127.0.0.1]
|
|
6
|
+
*
|
|
7
|
+
* Applications export their service events to it with DashboardClient.
|
|
8
|
+
*/
|
|
9
|
+
import { ServiceDashboard } from './dashboardServer';
|
|
10
|
+
|
|
11
|
+
function argValue(flag: string): string | undefined {
|
|
12
|
+
const index = process.argv.indexOf(flag);
|
|
13
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const port = Number(argValue('--port') ?? process.env.PORT ?? 4321);
|
|
18
|
+
const host = argValue('--host') ?? '127.0.0.1';
|
|
19
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
20
|
+
console.error(`Invalid port: ${argValue('--port') ?? process.env.PORT}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dashboard = new ServiceDashboard();
|
|
25
|
+
const url = await dashboard.listen(port, host);
|
|
26
|
+
console.log(`composed-di dashboard listening at ${url}`);
|
|
27
|
+
console.log(
|
|
28
|
+
'Waiting for an application to connect (see DashboardClient in @composed-di/observability).',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const shutdown = async () => {
|
|
32
|
+
await dashboard.close();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
};
|
|
35
|
+
process.on('SIGINT', shutdown);
|
|
36
|
+
process.on('SIGTERM', shutdown);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main().catch((error) => {
|
|
40
|
+
console.error(error);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ServiceModule } from '@composed-di/core';
|
|
2
|
+
import { DashboardEventListener } from './dashboardEventListener';
|
|
3
|
+
import { SpanEvent } from './events';
|
|
4
|
+
import { ModuleGraph, moduleGraph } from './moduleGraph';
|
|
5
|
+
|
|
6
|
+
export interface DashboardClientOptions {
|
|
7
|
+
/** Base URL of the standalone dashboard server, e.g. "http://localhost:4321". */
|
|
8
|
+
url: string;
|
|
9
|
+
/** How long to buffer events before exporting a batch. Default 250ms. */
|
|
10
|
+
flushIntervalMs?: number;
|
|
11
|
+
/** Export immediately once this many events are buffered. Default 64. */
|
|
12
|
+
maxBatchSize?: number;
|
|
13
|
+
/** Events kept while the server is unreachable; oldest are dropped. Default 5000. */
|
|
14
|
+
maxQueueSize?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Called when an export fails. Defaults to a single console.warn per
|
|
17
|
+
* failure streak; exports never throw into application code.
|
|
18
|
+
*/
|
|
19
|
+
onError?: (error: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Exports service observability to a standalone dashboard server, the way
|
|
24
|
+
* an OpenTelemetry SDK exports spans to a collector.
|
|
25
|
+
*
|
|
26
|
+
* The application owns only this client: pass `client.listener` to
|
|
27
|
+
* `ServiceModule.from`, then `attach` the module to register its dependency
|
|
28
|
+
* graph with the server. Span events are buffered and shipped in batches;
|
|
29
|
+
* export failures are retried on the next flush and never affect the
|
|
30
|
+
* application.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const client = new DashboardClient({ url: 'http://localhost:4321' });
|
|
35
|
+
* const module = ServiceModule.from(factories, client.listener);
|
|
36
|
+
* client.attach(module);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export class DashboardClient {
|
|
40
|
+
/** Pass this as the second argument to ServiceModule.from. */
|
|
41
|
+
readonly listener = new DashboardEventListener();
|
|
42
|
+
|
|
43
|
+
private readonly url: string;
|
|
44
|
+
private readonly flushIntervalMs: number;
|
|
45
|
+
private readonly maxBatchSize: number;
|
|
46
|
+
private readonly maxQueueSize: number;
|
|
47
|
+
private readonly onError: (error: Error) => void;
|
|
48
|
+
|
|
49
|
+
private queue: SpanEvent[] = [];
|
|
50
|
+
private graph: ModuleGraph | null = null;
|
|
51
|
+
private graphSent = false;
|
|
52
|
+
private timer: NodeJS.Timeout | null = null;
|
|
53
|
+
/** Serializes exports so events reach the server in order. */
|
|
54
|
+
private exporting: Promise<void> = Promise.resolve();
|
|
55
|
+
private warned = false;
|
|
56
|
+
private closed = false;
|
|
57
|
+
|
|
58
|
+
constructor({
|
|
59
|
+
url,
|
|
60
|
+
flushIntervalMs = 250,
|
|
61
|
+
maxBatchSize = 64,
|
|
62
|
+
maxQueueSize = 5000,
|
|
63
|
+
onError,
|
|
64
|
+
}: DashboardClientOptions) {
|
|
65
|
+
this.url = url.replace(/\/$/, '');
|
|
66
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
67
|
+
this.maxBatchSize = maxBatchSize;
|
|
68
|
+
this.maxQueueSize = maxQueueSize;
|
|
69
|
+
this.onError =
|
|
70
|
+
onError ??
|
|
71
|
+
((error) => {
|
|
72
|
+
if (this.warned) return;
|
|
73
|
+
this.warned = true;
|
|
74
|
+
console.warn(
|
|
75
|
+
`[composed-di] dashboard export to ${this.url} failed (will keep retrying): ${error.message}`,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
this.listener.subscribe((event) => this.enqueue(event));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Registers the module's dependency graph with the dashboard server.
|
|
83
|
+
*/
|
|
84
|
+
attach(module: ServiceModule): this {
|
|
85
|
+
this.graph = moduleGraph(module);
|
|
86
|
+
this.graphSent = false;
|
|
87
|
+
void this.flush();
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Exports everything buffered so far. Resolves once the attempt finishes. */
|
|
92
|
+
flush(): Promise<void> {
|
|
93
|
+
this.exporting = this.exporting.then(() => this.export());
|
|
94
|
+
return this.exporting;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Flushes remaining events and stops the export timer. */
|
|
98
|
+
async close(): Promise<void> {
|
|
99
|
+
this.closed = true;
|
|
100
|
+
if (this.timer) {
|
|
101
|
+
clearTimeout(this.timer);
|
|
102
|
+
this.timer = null;
|
|
103
|
+
}
|
|
104
|
+
await this.flush();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private enqueue(event: SpanEvent): void {
|
|
108
|
+
if (this.closed) return;
|
|
109
|
+
this.queue.push(event);
|
|
110
|
+
if (this.queue.length > this.maxQueueSize) {
|
|
111
|
+
this.queue.splice(0, this.queue.length - this.maxQueueSize);
|
|
112
|
+
}
|
|
113
|
+
if (this.queue.length >= this.maxBatchSize) {
|
|
114
|
+
void this.flush();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.scheduleFlush(this.flushIntervalMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private scheduleFlush(delayMs: number): void {
|
|
121
|
+
if (this.timer || this.closed) return;
|
|
122
|
+
this.timer = setTimeout(() => {
|
|
123
|
+
this.timer = null;
|
|
124
|
+
void this.flush();
|
|
125
|
+
}, delayMs);
|
|
126
|
+
this.timer.unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async export(): Promise<void> {
|
|
130
|
+
// The graph must reach the server before any events that reference it.
|
|
131
|
+
if (this.graph && !this.graphSent) {
|
|
132
|
+
if ((await this.post('/v1/graph', this.graph)) !== 'ok') {
|
|
133
|
+
this.retryLater();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.graphSent = true;
|
|
137
|
+
}
|
|
138
|
+
let reRegistrations = 0;
|
|
139
|
+
while (this.queue.length > 0) {
|
|
140
|
+
const batch = this.queue.slice(0, this.maxBatchSize);
|
|
141
|
+
const result = await this.post('/v1/events', { events: batch });
|
|
142
|
+
// A restarted server lost the graph — re-register and resend the batch.
|
|
143
|
+
if (result === 'graph-required' && this.graph && reRegistrations < 3) {
|
|
144
|
+
reRegistrations += 1;
|
|
145
|
+
this.graphSent = false;
|
|
146
|
+
if ((await this.post('/v1/graph', this.graph)) !== 'ok') {
|
|
147
|
+
this.retryLater();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.graphSent = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (result !== 'ok') {
|
|
154
|
+
this.retryLater();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.queue.splice(0, batch.length);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** After a failed export, retry even if no new events arrive. */
|
|
162
|
+
private retryLater(): void {
|
|
163
|
+
if (this.queue.length > 0 || (this.graph && !this.graphSent)) {
|
|
164
|
+
this.scheduleFlush(Math.max(this.flushIntervalMs, 2000));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async post(
|
|
169
|
+
path: string,
|
|
170
|
+
body: unknown,
|
|
171
|
+
): Promise<'ok' | 'graph-required' | 'failed'> {
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(this.url + path, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
});
|
|
178
|
+
if (response.status === 409) return 'graph-required';
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
throw new Error(`server responded ${response.status}`);
|
|
181
|
+
}
|
|
182
|
+
this.warned = false;
|
|
183
|
+
return 'ok';
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.onError(error instanceof Error ? error : new Error(String(error)));
|
|
186
|
+
return 'failed';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
ServiceEventListener,
|
|
9
|
+
} from '@composed-di/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
|
+
/**
|
|
18
|
+
* A ServiceEventListener that turns service events into structured
|
|
19
|
+
* start/end span events for the realtime dashboard.
|
|
20
|
+
*
|
|
21
|
+
* - Uses AsyncLocalStorage to link spans to the span that was active when
|
|
22
|
+
* they started, which lets the dashboard draw cross-service call edges
|
|
23
|
+
* (e.g. UserService.getUser -> Database.query). Because a listener does
|
|
24
|
+
* not control the invocation of the operation it observes, the context
|
|
25
|
+
* is entered with `enterWith` and can outlive the span within the same
|
|
26
|
+
* synchronous frame; the dashboard tolerates this, as parents are only
|
|
27
|
+
* resolved among still-open spans.
|
|
28
|
+
* - Emits events synchronously to subscribers; it never buffers.
|
|
29
|
+
*/
|
|
30
|
+
export class DashboardEventListener implements ServiceEventListener {
|
|
31
|
+
private readonly context = new AsyncLocalStorage<SpanContext>();
|
|
32
|
+
private readonly listeners = new Set<(event: SpanEvent) => void>();
|
|
33
|
+
private nextId = 1;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribes to span events.
|
|
37
|
+
*
|
|
38
|
+
* @returns A function that removes the subscription.
|
|
39
|
+
*/
|
|
40
|
+
subscribe(listener: (event: SpanEvent) => void): () => void {
|
|
41
|
+
this.listeners.add(listener);
|
|
42
|
+
return () => this.listeners.delete(listener);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onInitialize({ key }: InitializeContext): EventSpan {
|
|
46
|
+
return this.startSpan(key.name, 'initialize', 'initialize');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onDispose({ key }: DisposeContext): EventSpan {
|
|
50
|
+
return this.startSpan(key.name, 'dispose', 'dispose');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onMethodCall({ key, methodName }: MethodCallContext): EventSpan {
|
|
54
|
+
return this.startSpan(key.name, methodName, 'call');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private startSpan(service: string, method: string, kind: SpanKind): EventSpan {
|
|
58
|
+
const id = this.nextId++;
|
|
59
|
+
const parent = this.context.getStore();
|
|
60
|
+
|
|
61
|
+
this.emit({
|
|
62
|
+
type: 'start',
|
|
63
|
+
id,
|
|
64
|
+
parentId: parent?.id ?? null,
|
|
65
|
+
name: `${service}.${method}`,
|
|
66
|
+
service,
|
|
67
|
+
method,
|
|
68
|
+
kind,
|
|
69
|
+
time: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const startedAt = performance.now();
|
|
73
|
+
let ended = false;
|
|
74
|
+
const end = (error: string | null) => {
|
|
75
|
+
if (ended) return;
|
|
76
|
+
ended = true;
|
|
77
|
+
this.emit({
|
|
78
|
+
type: 'end',
|
|
79
|
+
id,
|
|
80
|
+
time: Date.now(),
|
|
81
|
+
durationMs: performance.now() - startedAt,
|
|
82
|
+
error,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The observed operation runs right after this hook returns, in the
|
|
87
|
+
// same synchronous frame, so entering the context here makes this span
|
|
88
|
+
// the parent of any spans started inside the operation.
|
|
89
|
+
this.context.enterWith({ id });
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
end: () => end(null),
|
|
93
|
+
error: (error) => end(errorMessage(error)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private emit(event: SpanEvent): void {
|
|
98
|
+
this.listeners.forEach((listener) => listener(event));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function errorMessage(error: unknown): string {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|