@nwire/telemetry-otel 0.7.0

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * `attachOtelExporter(runtime, options)` — translates the canonical
3
+ * Nwire telemetry stream into OpenTelemetry spans + events.
4
+ *
5
+ * import { trace } from "@opentelemetry/api";
6
+ * import { attachOtelExporter } from "@nwire/telemetry-otel";
7
+ *
8
+ * const tracer = trace.getTracer("amit");
9
+ * const detach = attachOtelExporter(app.runtime, { tracer });
10
+ *
11
+ * Every Telemetry kind gets a sensible OTel mapping:
12
+ *
13
+ * action.dispatched → open span "action {name}" (parent for the dispatch)
14
+ * action.completed → close that span with ok status
15
+ * action.failed → addEvent "action.failed" (attempt + error)
16
+ * dlq.recorded → close span with error status + dlq event
17
+ * event.published → addEvent "event.published" on the open action span
18
+ * OR ad-hoc span if no open parent
19
+ * actor.transitioned → addEvent "actor.transitioned"
20
+ * projection.folded → addEvent "projection.folded"
21
+ * reaction.fired → child span "reaction {sourceEvent}"
22
+ * reaction.failed → addEvent on the parent + recordException
23
+ * query.executed → ad-hoc span "query {name}"
24
+ * timer.scheduled / .fired → addEvent
25
+ * external.call.started → open child span "external {call}"
26
+ * external.call.completed → close it with ok
27
+ * external.call.failed → close it with error + addEvent per attempt
28
+ * inbound.webhook.received → ad-hoc span "webhook {name}"
29
+ * outbox.flushed → addEvent (could become a metric later)
30
+ * inbox.dedup.hit → addEvent
31
+ * queue.job.{enqueued,started,completed} → ad-hoc span "queue {queue}/{jobId}"
32
+ * cron.fired → ad-hoc span "cron {name}"
33
+ *
34
+ * Spans are correlated by `envelope.messageId` (when present) — open spans
35
+ * are tracked in a Map so completion records can look them up. If a
36
+ * close-record arrives with no matching open span (e.g. listener attached
37
+ * mid-flight), we emit an orphan span with `{ status: ERROR, orphan: true }`.
38
+ *
39
+ * Returns a detach function — calling it unsubscribes and closes any
40
+ * still-open spans with an `unsubscribed` event.
41
+ */
42
+
43
+ import type { Runtime, Telemetry, SerializedError } from "@nwire/forge";
44
+ import type { OtelTracer, OtelSpan, SpanStatusCode } from "./otel-types";
45
+
46
+ export interface AttachOtelExporterOptions {
47
+ /** OTel Tracer instance from `trace.getTracer(name, version?)`. */
48
+ readonly tracer: OtelTracer;
49
+ /**
50
+ * Prefix to apply to span names. Default `'nwire.'` so traces in OTLP
51
+ * backends stand out. Pass empty string to disable.
52
+ */
53
+ readonly spanNamePrefix?: string;
54
+ /**
55
+ * Custom event-published handling. By default, we attach published
56
+ * events as span events on the open action span. If you prefer each
57
+ * event as its own span, set `eventsAsSpans: true`.
58
+ */
59
+ readonly eventsAsSpans?: boolean;
60
+ /**
61
+ * Filter which telemetry kinds to forward. Default: all. Pass an array
62
+ * to opt in to specific kinds (e.g. for high-volume production traffic
63
+ * you might keep only action.* + external.call.*).
64
+ */
65
+ readonly kinds?: readonly Telemetry["kind"][];
66
+ }
67
+
68
+ const OK: SpanStatusCode = 1;
69
+ const ERROR: SpanStatusCode = 2;
70
+
71
+ interface OpenSpan {
72
+ readonly span: OtelSpan;
73
+ readonly startedAt: number;
74
+ }
75
+
76
+ export function attachOtelExporter(
77
+ runtime: Runtime,
78
+ options: AttachOtelExporterOptions,
79
+ ): () => void {
80
+ const tracer = options.tracer;
81
+ const prefix = options.spanNamePrefix ?? "nwire.";
82
+ const eventsAsSpans = options.eventsAsSpans ?? false;
83
+ const allowed: ReadonlySet<string> | null = options.kinds ? new Set(options.kinds) : null;
84
+
85
+ // Open spans, keyed by envelope.messageId (action) or call+messageId
86
+ // pair (external call). When the matching completion arrives we close.
87
+ const actionSpans = new Map<string, OpenSpan>();
88
+ const externalCallSpans = new Map<string, OpenSpan>();
89
+
90
+ const unsubscribe = runtime.onTelemetry((rec) => {
91
+ if (allowed && !allowed.has(rec.kind)) return;
92
+ try {
93
+ route(rec);
94
+ } catch (err) {
95
+ // Don't let exporter errors break the runtime — the framework's
96
+ // emit() already guards listener throws, but we add explicit
97
+ // logging here too.
98
+ // eslint-disable-next-line no-console
99
+ console.error("[telemetry-otel] export failed:", err);
100
+ }
101
+ });
102
+
103
+ function route(rec: Telemetry): void {
104
+ switch (rec.kind) {
105
+ case "action.dispatched":
106
+ openActionSpan(rec);
107
+ return;
108
+ case "action.completed":
109
+ closeActionSpan(rec, OK, undefined, {
110
+ "nwire.action.duration_ms": rec.durationMs,
111
+ "nwire.action.emitted_events": rec.emittedEvents.join(","),
112
+ });
113
+ return;
114
+ case "action.failed":
115
+ addEventToActionSpan(rec.envelope.messageId, "action.failed", {
116
+ "nwire.action.attempt": rec.attempt,
117
+ "nwire.action.max_attempts": rec.maxAttempts,
118
+ "nwire.action.will_retry": rec.willRetry,
119
+ ...errorAttrs(rec.error),
120
+ });
121
+ return;
122
+ case "dlq.recorded":
123
+ closeActionSpan(rec, ERROR, rec.error.message, {
124
+ "nwire.dlq.attempts": rec.attempts,
125
+ ...errorAttrs(rec.error),
126
+ });
127
+ return;
128
+ case "event.published":
129
+ if (eventsAsSpans) {
130
+ adhocSpan(`event ${rec.event.eventName}`, rec.ts, {
131
+ "nwire.event.name": rec.event.eventName,
132
+ "nwire.event.source": rec.source,
133
+ ...envelopeAttrs(rec.envelope),
134
+ });
135
+ } else {
136
+ // Event records carry a DERIVED envelope: messageId is the
137
+ // event's own, causationId is the action that emitted it. So
138
+ // we look up the action span by causationId.
139
+ addEventToActionSpan(rec.envelope.causationId, "event.published", {
140
+ "nwire.event.name": rec.event.eventName,
141
+ "nwire.event.source": rec.source,
142
+ });
143
+ }
144
+ return;
145
+ case "actor.transitioned":
146
+ addEventToActionSpan(rec.envelope.causationId, "actor.transitioned", {
147
+ "nwire.actor": rec.actor,
148
+ "nwire.actor.key": rec.key,
149
+ "nwire.actor.from": rec.from,
150
+ "nwire.actor.to": rec.to,
151
+ "nwire.actor.event": rec.triggeringEvent,
152
+ });
153
+ return;
154
+ case "projection.folded":
155
+ addEventToActionSpan(rec.envelope.causationId, "projection.folded", {
156
+ "nwire.projection": rec.projection,
157
+ "nwire.event.name": rec.event,
158
+ "nwire.projection.duration_ms": rec.durationMs,
159
+ });
160
+ return;
161
+ case "reaction.fired":
162
+ adhocSpan(`reaction ${rec.sourceEvent}`, rec.ts, {
163
+ "nwire.reaction.source_event": rec.sourceEvent,
164
+ "nwire.reaction.duration_ms": rec.durationMs,
165
+ ...envelopeAttrs(rec.envelope),
166
+ });
167
+ return;
168
+ case "reaction.failed":
169
+ addEventToActionSpan(rec.envelope.causationId, "reaction.failed", {
170
+ "nwire.reaction.source_event": rec.sourceEvent,
171
+ ...errorAttrs(rec.error),
172
+ });
173
+ return;
174
+ case "query.executed":
175
+ adhocSpan(`query ${rec.query}`, rec.ts, {
176
+ "nwire.query.name": rec.query,
177
+ "nwire.query.duration_ms": rec.durationMs,
178
+ "nwire.tenant": rec.tenant,
179
+ });
180
+ return;
181
+ case "timer.scheduled":
182
+ case "timer.fired":
183
+ adhocSpan(rec.kind, rec.ts, {
184
+ "nwire.actor": rec.actor,
185
+ "nwire.actor.key": rec.key,
186
+ "nwire.timer.name": rec.timer,
187
+ "nwire.timer.action": rec.action,
188
+ "nwire.tenant": rec.tenant,
189
+ ...(rec.kind === "timer.fired"
190
+ ? { "nwire.timer.late_by_ms": rec.lateByMs }
191
+ : { "nwire.timer.fire_at": rec.fireAt }),
192
+ });
193
+ return;
194
+ case "external.call.started":
195
+ openExternalCallSpan(rec);
196
+ return;
197
+ case "external.call.completed":
198
+ closeExternalCallSpan(rec, OK, undefined, {
199
+ "nwire.external.duration_ms": rec.durationMs,
200
+ ...(rec.status !== undefined ? { "nwire.external.status": rec.status } : {}),
201
+ });
202
+ return;
203
+ case "external.call.failed":
204
+ closeExternalCallSpan(rec, ERROR, rec.error.message, {
205
+ "nwire.external.attempt": rec.attempt,
206
+ "nwire.external.will_retry": rec.willRetry,
207
+ ...errorAttrs(rec.error),
208
+ });
209
+ return;
210
+ case "inbound.webhook.received":
211
+ adhocSpan(`webhook ${rec.webhook}`, rec.ts, {
212
+ "nwire.webhook.name": rec.webhook,
213
+ "nwire.webhook.source": rec.source,
214
+ "nwire.webhook.signature_valid": rec.signatureValid,
215
+ "nwire.webhook.dedup_hit": rec.dedupHit,
216
+ ...(rec.routedTo ? { "nwire.webhook.routed_to": rec.routedTo } : {}),
217
+ });
218
+ return;
219
+ case "outbox.flushed":
220
+ adhocSpan(`outbox ${rec.outbox} flush`, rec.ts, {
221
+ "nwire.outbox.name": rec.outbox,
222
+ "nwire.outbox.events": rec.events,
223
+ "nwire.outbox.failed": rec.failed,
224
+ "nwire.outbox.duration_ms": rec.durationMs,
225
+ });
226
+ return;
227
+ case "inbox.dedup.hit":
228
+ adhocSpan(`inbox ${rec.inbox} dedup`, rec.ts, {
229
+ "nwire.inbox.name": rec.inbox,
230
+ "nwire.inbox.message_id": rec.messageId,
231
+ "nwire.inbox.first_seen_at": rec.firstSeenAt,
232
+ });
233
+ return;
234
+ case "queue.job.enqueued":
235
+ case "queue.job.started":
236
+ case "queue.job.completed":
237
+ adhocSpan(`queue ${rec.queue}/${rec.kind.replace("queue.job.", "")}`, rec.ts, {
238
+ "nwire.queue.name": rec.queue,
239
+ "nwire.queue.job_id": rec.jobId,
240
+ ...(rec.kind === "queue.job.enqueued" && rec.delay !== undefined
241
+ ? { "nwire.queue.delay": rec.delay }
242
+ : {}),
243
+ ...(rec.kind === "queue.job.started" ? { "nwire.queue.waited_ms": rec.waitedMs } : {}),
244
+ ...(rec.kind === "queue.job.completed"
245
+ ? { "nwire.queue.duration_ms": rec.durationMs, "nwire.queue.ok": rec.ok }
246
+ : {}),
247
+ });
248
+ return;
249
+ case "cron.fired":
250
+ adhocSpan(`cron ${rec.cronName}`, rec.ts, {
251
+ "nwire.cron.name": rec.cronName,
252
+ "nwire.cron.schedule": rec.schedule,
253
+ "nwire.cron.expected": rec.expected,
254
+ "nwire.cron.actual": rec.actual,
255
+ "nwire.cron.late_by_ms": rec.lateByMs,
256
+ });
257
+ return;
258
+ }
259
+ }
260
+
261
+ function openActionSpan(rec: Extract<Telemetry, { kind: "action.dispatched" }>): void {
262
+ const span = tracer.startSpan(`${prefix}action ${rec.action}`, {
263
+ startTime: new Date(rec.ts),
264
+ attributes: {
265
+ "nwire.action": rec.action,
266
+ ...envelopeAttrs(rec.envelope),
267
+ "nwire.app": rec.appName,
268
+ },
269
+ });
270
+ actionSpans.set(rec.envelope.messageId, { span, startedAt: Date.now() });
271
+ }
272
+
273
+ function closeActionSpan(
274
+ rec: { ts: string; envelope: { messageId: string }; appName: string },
275
+ status: SpanStatusCode,
276
+ msg: string | undefined,
277
+ attrs: Record<string, unknown>,
278
+ ): void {
279
+ const open = actionSpans.get(rec.envelope.messageId);
280
+ if (!open) {
281
+ // Orphan — close-record without an open span. Emit an ad-hoc record
282
+ // so we don't silently drop data.
283
+ adhocSpan(`${prefix}action.orphan`, rec.ts, {
284
+ "nwire.orphan": true,
285
+ "nwire.app": rec.appName,
286
+ ...attrs,
287
+ });
288
+ return;
289
+ }
290
+ actionSpans.delete(rec.envelope.messageId);
291
+ setAttrs(open.span, attrs);
292
+ open.span.setStatus({ code: status, message: msg });
293
+ open.span.end(new Date(rec.ts));
294
+ }
295
+
296
+ function addEventToActionSpan(
297
+ messageId: string,
298
+ name: string,
299
+ attrs: Record<string, unknown>,
300
+ ): void {
301
+ const open = actionSpans.get(messageId);
302
+ if (open) {
303
+ open.span.addEvent(name, attrs);
304
+ return;
305
+ }
306
+ // No parent — emit standalone ad-hoc.
307
+ adhocSpan(name, new Date().toISOString(), attrs);
308
+ }
309
+
310
+ function openExternalCallSpan(rec: Extract<Telemetry, { kind: "external.call.started" }>): void {
311
+ const span = tracer.startSpan(`${prefix}external ${rec.call}`, {
312
+ startTime: new Date(rec.ts),
313
+ attributes: {
314
+ "nwire.external.call": rec.call,
315
+ "nwire.external.target": rec.target,
316
+ ...(rec.idempotencyKey ? { "nwire.external.idempotency_key": rec.idempotencyKey } : {}),
317
+ ...(rec.envelope ? envelopeAttrs(rec.envelope) : {}),
318
+ "nwire.app": rec.appName,
319
+ },
320
+ });
321
+ // Key by call + correlation; multiple concurrent calls with same name
322
+ // would collide, so include messageId if available.
323
+ const key = externalCallKey(rec.call, rec.envelope?.messageId);
324
+ externalCallSpans.set(key, { span, startedAt: Date.now() });
325
+ }
326
+
327
+ function closeExternalCallSpan(
328
+ rec:
329
+ | Extract<Telemetry, { kind: "external.call.completed" }>
330
+ | Extract<Telemetry, { kind: "external.call.failed" }>,
331
+ status: SpanStatusCode,
332
+ msg: string | undefined,
333
+ attrs: Record<string, unknown>,
334
+ ): void {
335
+ const key = externalCallKey(rec.call, rec.envelope?.messageId);
336
+ const open = externalCallSpans.get(key);
337
+ if (!open) {
338
+ adhocSpan(`${prefix}external.orphan ${rec.call}`, rec.ts, {
339
+ "nwire.orphan": true,
340
+ "nwire.external.call": rec.call,
341
+ "nwire.external.target": rec.target,
342
+ ...attrs,
343
+ });
344
+ return;
345
+ }
346
+ externalCallSpans.delete(key);
347
+ setAttrs(open.span, attrs);
348
+ open.span.setStatus({ code: status, message: msg });
349
+ open.span.end(new Date(rec.ts));
350
+ }
351
+
352
+ function externalCallKey(call: string, messageId: string | undefined): string {
353
+ return `${call}::${messageId ?? "no-envelope"}`;
354
+ }
355
+
356
+ function adhocSpan(name: string, ts: string, attrs: Record<string, unknown>): void {
357
+ const startTime = new Date(ts);
358
+ const span = tracer.startSpan(name.startsWith(prefix) ? name : `${prefix}${name}`, {
359
+ startTime,
360
+ attributes: attrs,
361
+ });
362
+ span.end(startTime);
363
+ }
364
+
365
+ return () => {
366
+ unsubscribe();
367
+ // Close any still-open spans so the exporter flushes them.
368
+ for (const { span } of actionSpans.values()) {
369
+ span.addEvent("nwire.unsubscribed");
370
+ span.end();
371
+ }
372
+ for (const { span } of externalCallSpans.values()) {
373
+ span.addEvent("nwire.unsubscribed");
374
+ span.end();
375
+ }
376
+ actionSpans.clear();
377
+ externalCallSpans.clear();
378
+ };
379
+ }
380
+
381
+ function envelopeAttrs(envelope: {
382
+ messageId: string;
383
+ correlationId: string;
384
+ causationId: string;
385
+ tenant?: string;
386
+ userId?: string;
387
+ timestamp: string;
388
+ }): Record<string, unknown> {
389
+ return {
390
+ "nwire.message_id": envelope.messageId,
391
+ "nwire.correlation_id": envelope.correlationId,
392
+ "nwire.causation_id": envelope.causationId,
393
+ ...(envelope.tenant ? { "nwire.tenant": envelope.tenant } : {}),
394
+ ...(envelope.userId ? { "nwire.user_id": envelope.userId } : {}),
395
+ };
396
+ }
397
+
398
+ function errorAttrs(err: SerializedError): Record<string, unknown> {
399
+ return {
400
+ "nwire.error.name": err.name,
401
+ "nwire.error.message": err.message,
402
+ ...(err.stack ? { "nwire.error.stack": err.stack } : {}),
403
+ };
404
+ }
405
+
406
+ function setAttrs(span: OtelSpan, attrs: Record<string, unknown>): void {
407
+ if (span.setAttributes) {
408
+ span.setAttributes(attrs);
409
+ return;
410
+ }
411
+ for (const [k, v] of Object.entries(attrs)) {
412
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
413
+ span.setAttribute(k, v);
414
+ }
415
+ }
416
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Minimal duck-typed surface of `@opentelemetry/api`'s Tracer + Span. We
3
+ * never import the package — consumers bring their own version and pass a
4
+ * `Tracer` in. This keeps `@nwire/telemetry-otel` zero-dep at runtime
5
+ * (only `@nwire/forge` for the Telemetry type).
6
+ *
7
+ * The fields are the subset we actually use. If a consumer passes a real
8
+ * `@opentelemetry/api` Tracer, structural typing makes it compatible.
9
+ */
10
+
11
+ export type SpanKind = 0 | 1 | 2 | 3 | 4 | 5;
12
+ export type SpanStatusCode = 0 | 1 | 2;
13
+
14
+ export interface SpanContext {
15
+ traceId: string;
16
+ spanId: string;
17
+ }
18
+
19
+ export interface OtelSpan {
20
+ spanContext?(): SpanContext;
21
+ setAttribute(key: string, value: string | number | boolean): OtelSpan | void;
22
+ setAttributes?(attrs: Record<string, unknown>): OtelSpan | void;
23
+ addEvent(
24
+ name: string,
25
+ attrs?: Record<string, unknown>,
26
+ timestamp?: number | Date,
27
+ ): OtelSpan | void;
28
+ recordException(err: unknown): OtelSpan | void;
29
+ setStatus(status: { code: SpanStatusCode; message?: string }): OtelSpan | void;
30
+ end(endTime?: number | Date): void;
31
+ }
32
+
33
+ export interface OtelTracer {
34
+ startSpan(
35
+ name: string,
36
+ options?: {
37
+ attributes?: Record<string, unknown>;
38
+ kind?: SpanKind;
39
+ startTime?: number | Date;
40
+ },
41
+ ): OtelSpan;
42
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@nwire/telemetry-otel` — OpenTelemetry bridge for the canonical Nwire
3
+ * telemetry stream. Subscribes to `runtime.onTelemetry` and translates
4
+ * every record into OTel spans + events.
5
+ *
6
+ * import { trace } from "@opentelemetry/api";
7
+ * import { attachOtelExporter } from "@nwire/telemetry-otel";
8
+ *
9
+ * const detach = attachOtelExporter(app.runtime, {
10
+ * tracer: trace.getTracer("amit"),
11
+ * });
12
+ *
13
+ * Pairs cleanly with Vector → GreptimeDB (and any other OTLP backend —
14
+ * Datadog, Honeycomb, Tempo, Jaeger).
15
+ */
16
+
17
+ export { attachOtelExporter, type AttachOtelExporterOptions } from "./exporter";
18
+ export type { OtelTracer, OtelSpan, SpanContext, SpanKind, SpanStatusCode } from "./otel-types";