@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.
Files changed (41) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +50 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/dashboardClient.d.ts +67 -0
  6. package/dist/dashboardClient.d.ts.map +1 -0
  7. package/dist/dashboardClient.js +168 -0
  8. package/dist/dashboardClient.js.map +1 -0
  9. package/dist/dashboardEventListener.d.ts +32 -0
  10. package/dist/dashboardEventListener.d.ts.map +1 -0
  11. package/dist/dashboardEventListener.js +88 -0
  12. package/dist/dashboardEventListener.js.map +1 -0
  13. package/dist/dashboardHtml.d.ts +11 -0
  14. package/dist/dashboardHtml.d.ts.map +1 -0
  15. package/dist/dashboardHtml.js +662 -0
  16. package/dist/dashboardHtml.js.map +1 -0
  17. package/dist/dashboardServer.d.ts +99 -0
  18. package/dist/dashboardServer.d.ts.map +1 -0
  19. package/dist/dashboardServer.js +360 -0
  20. package/dist/dashboardServer.js.map +1 -0
  21. package/dist/events.d.ts +80 -0
  22. package/dist/events.d.ts.map +1 -0
  23. package/dist/events.js +7 -0
  24. package/dist/events.js.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/moduleGraph.d.ts +13 -0
  30. package/dist/moduleGraph.d.ts.map +1 -0
  31. package/dist/moduleGraph.js +25 -0
  32. package/dist/moduleGraph.js.map +1 -0
  33. package/package.json +47 -0
  34. package/src/cli.ts +42 -0
  35. package/src/dashboardClient.ts +189 -0
  36. package/src/dashboardEventListener.ts +104 -0
  37. package/src/dashboardHtml.ts +659 -0
  38. package/src/dashboardServer.ts +391 -0
  39. package/src/events.ts +94 -0
  40. package/src/index.ts +5 -0
  41. 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
+ }