@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/exporter.d.ts +66 -0
- package/dist/exporter.d.ts.map +1 -0
- package/dist/exporter.js +352 -0
- package/dist/exporter.js.map +1 -0
- package/dist/otel-types.d.ts +35 -0
- package/dist/otel-types.d.ts.map +1 -0
- package/dist/otel-types.js +11 -0
- package/dist/otel-types.js.map +1 -0
- package/dist/telemetry-otel.d.ts +18 -0
- package/dist/telemetry-otel.d.ts.map +1 -0
- package/dist/telemetry-otel.js +17 -0
- package/dist/telemetry-otel.js.map +1 -0
- package/package.json +33 -0
- package/src/__tests__/exporter.test.ts +256 -0
- package/src/exporter.ts +416 -0
- package/src/otel-types.ts +42 -0
- package/src/telemetry-otel.ts +18 -0
package/src/exporter.ts
ADDED
|
@@ -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";
|