@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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nwire/telemetry-otel",
3
+ "version": "0.7.0",
4
+ "private": false,
5
+ "description": "OpenTelemetry bridge for the Nwire canonical telemetry stream. Translates every Telemetry record into OTLP spans + events. Plug in any OTEL exporter (Datadog, Honeycomb, Tempo, Vector → GreptimeDB).",
6
+ "files": [
7
+ "dist",
8
+ "src"
9
+ ],
10
+ "type": "module",
11
+ "main": "./dist/telemetry-otel.js",
12
+ "types": "./dist/telemetry-otel.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/telemetry-otel.js",
16
+ "types": "./dist/telemetry-otel.d.ts"
17
+ }
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@nwire/forge": "0.7.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5.6.0",
27
+ "vitest": "^4.0.18"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "typecheck": "tsc --noEmit"
32
+ }
33
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * `@nwire/telemetry-otel` — exporter end-to-end against a fake tracer.
3
+ *
4
+ * Exercises real runtime dispatch + external call paths, then asserts the
5
+ * spans + events the bridge produced. The fake tracer records every
6
+ * startSpan / span method call so tests can inspect them.
7
+ */
8
+
9
+ import { describe, it, expect } from "vitest";
10
+ import { z } from "zod";
11
+ import * as forge from "@nwire/forge";
12
+ import { attachOtelExporter } from "../telemetry-otel";
13
+ import type { OtelTracer, OtelSpan } from "../telemetry-otel";
14
+
15
+ interface RecordedSpan {
16
+ name: string;
17
+ attributes: Record<string, unknown>;
18
+ events: Array<{ name: string; attrs?: Record<string, unknown> }>;
19
+ status: { code: number; message?: string } | null;
20
+ ended: boolean;
21
+ endTime?: number | Date;
22
+ startTime?: number | Date;
23
+ }
24
+
25
+ function makeFakeTracer(): { tracer: OtelTracer; spans: RecordedSpan[] } {
26
+ const spans: RecordedSpan[] = [];
27
+ const tracer: OtelTracer = {
28
+ startSpan(name, options) {
29
+ const rec: RecordedSpan = {
30
+ name,
31
+ attributes: { ...(options?.attributes ?? {}) },
32
+ events: [],
33
+ status: null,
34
+ ended: false,
35
+ startTime: options?.startTime,
36
+ };
37
+ spans.push(rec);
38
+ const span: OtelSpan = {
39
+ setAttribute(k, v) {
40
+ rec.attributes[k] = v;
41
+ },
42
+ setAttributes(attrs) {
43
+ Object.assign(rec.attributes, attrs);
44
+ },
45
+ addEvent(eventName, attrs) {
46
+ rec.events.push({ name: eventName, attrs });
47
+ },
48
+ recordException() {
49
+ /* noop */
50
+ },
51
+ setStatus(s) {
52
+ rec.status = { ...s };
53
+ },
54
+ end(endTime) {
55
+ rec.ended = true;
56
+ rec.endTime = endTime;
57
+ },
58
+ };
59
+ return span;
60
+ },
61
+ };
62
+ return { tracer, spans };
63
+ }
64
+
65
+ const SubmitInput = z.object({ who: z.string() });
66
+
67
+ const submit = forge.defineAction({
68
+ name: "demo.submit",
69
+ schema: SubmitInput,
70
+ emits: [],
71
+ handler: async () => ({ eventName: "demo.submitted", payload: { who: "Avi" } }),
72
+ });
73
+
74
+ const demoModule = forge.defineModule("demo", { actions: [submit] });
75
+ const demoApp = forge.defineApp("demo-app", { modules: [demoModule] });
76
+
77
+ describe("attachOtelExporter", () => {
78
+ it("opens + closes a span for a successful dispatch", async () => {
79
+ const app = demoApp.create();
80
+ await app.start();
81
+ const { tracer, spans } = makeFakeTracer();
82
+ const detach = attachOtelExporter(app.runtime, { tracer });
83
+
84
+ await app.runtime.dispatch(submit, { who: "Avi" });
85
+ detach();
86
+
87
+ const action = spans.find((s) => s.name.includes("action demo.submit"));
88
+ expect(action).toBeDefined();
89
+ expect(action!.ended).toBe(true);
90
+ expect(action!.status?.code).toBe(1); // OK
91
+ expect(action!.attributes["nwire.action"]).toBe("demo.submit");
92
+ expect(typeof action!.attributes["nwire.message_id"]).toBe("string");
93
+ expect(action!.attributes["nwire.action.emitted_events"]).toBe("demo.submitted");
94
+ // event.published should appear as a span event on the action span (default mode).
95
+ expect(action!.events.some((e) => e.name === "event.published")).toBe(true);
96
+ });
97
+
98
+ it("attaches event.published as a span event when eventsAsSpans=false", async () => {
99
+ const app = demoApp.create();
100
+ await app.start();
101
+ const { tracer, spans } = makeFakeTracer();
102
+ attachOtelExporter(app.runtime, { tracer });
103
+ await app.runtime.dispatch(submit, { who: "Avi" });
104
+
105
+ const actionSpans = spans.filter((s) => s.name.includes("action demo.submit"));
106
+ expect(actionSpans).toHaveLength(1);
107
+ // No standalone event span.
108
+ expect(spans.some((s) => s.name.includes("event demo.submitted"))).toBe(false);
109
+ });
110
+
111
+ it("opens a standalone span per event when eventsAsSpans=true", async () => {
112
+ const app = demoApp.create();
113
+ await app.start();
114
+ const { tracer, spans } = makeFakeTracer();
115
+ attachOtelExporter(app.runtime, { tracer, eventsAsSpans: true });
116
+ await app.runtime.dispatch(submit, { who: "Avi" });
117
+
118
+ expect(spans.some((s) => s.name.includes("event demo.submitted"))).toBe(true);
119
+ });
120
+
121
+ it("filters by kind when `kinds` is set", async () => {
122
+ const app = demoApp.create();
123
+ await app.start();
124
+ const { tracer, spans } = makeFakeTracer();
125
+ attachOtelExporter(app.runtime, {
126
+ tracer,
127
+ kinds: ["action.dispatched", "action.completed"],
128
+ });
129
+ await app.runtime.dispatch(submit, { who: "Avi" });
130
+
131
+ // No event-published span events should appear since we filtered them out.
132
+ const actionSpan = spans.find((s) => s.name.includes("action demo.submit"))!;
133
+ expect(actionSpan.events.some((e) => e.name === "event.published")).toBe(false);
134
+ });
135
+
136
+ it("emits ad-hoc spans for query.executed", async () => {
137
+ const SkillState = z.object({ items: z.array(z.string()).default([]) });
138
+ const Skills = forge.defineProjection<{ items: string[] }>("skills", {
139
+ listens: [],
140
+ initial: () => ({ items: [] }),
141
+ on: {},
142
+ });
143
+ const listSkills = forge.defineQuery(Skills, {
144
+ name: "skills.list",
145
+ schema: z.object({}),
146
+ execute: (state) => state.items,
147
+ });
148
+ void SkillState;
149
+ const queryModule = forge.defineModule("queries", {
150
+ projections: [Skills],
151
+ queries: [listSkills],
152
+ });
153
+ const queryApp = forge.defineApp("query-app", { modules: [queryModule] });
154
+ const app = queryApp.create();
155
+ await app.start();
156
+
157
+ const { tracer, spans } = makeFakeTracer();
158
+ attachOtelExporter(app.runtime, { tracer });
159
+ await app.runtime.query("skills.list", {}, "");
160
+
161
+ const querySpan = spans.find((s) => s.name.includes("query skills.list"));
162
+ expect(querySpan).toBeDefined();
163
+ expect(querySpan!.attributes["nwire.query.name"]).toBe("skills.list");
164
+ expect(typeof querySpan!.attributes["nwire.query.duration_ms"]).toBe("number");
165
+ });
166
+
167
+ it("traces external calls as child spans", async () => {
168
+ const Notify = forge.defineExternalCall({
169
+ name: "notify",
170
+ target: { provider: "x", endpoint: "/ping" },
171
+ request: z.object({ to: z.string() }),
172
+ response: z.object({ ok: z.boolean() }),
173
+ });
174
+ const send = forge.defineAction({
175
+ name: "ops.send",
176
+ schema: z.object({ to: z.string() }),
177
+ handler: async (input, ctx) => {
178
+ await ctx.externalCall(Notify, { to: input.to });
179
+ return undefined;
180
+ },
181
+ });
182
+ const opsModule = forge.defineModule("ops", {
183
+ actions: [send],
184
+ externalCalls: [Notify],
185
+ });
186
+ const opsApp = forge.defineApp("ops-app", { modules: [opsModule] });
187
+ const app = opsApp.create();
188
+ await app.start();
189
+ app.runtime.registerExternalCallExecutor(Notify, async () => ({ ok: true }));
190
+
191
+ const { tracer, spans } = makeFakeTracer();
192
+ attachOtelExporter(app.runtime, { tracer });
193
+
194
+ await app.runtime.dispatch(send, { to: "avi" });
195
+
196
+ const ext = spans.find((s) => s.name.includes("external notify"));
197
+ expect(ext).toBeDefined();
198
+ expect(ext!.ended).toBe(true);
199
+ expect(ext!.status?.code).toBe(1); // OK
200
+ expect(ext!.attributes["nwire.external.call"]).toBe("notify");
201
+ expect(ext!.attributes["nwire.external.target"]).toBe("x//ping");
202
+ });
203
+
204
+ it("closes external-call span with ERROR on failure", async () => {
205
+ const Notify = forge.defineExternalCall({
206
+ name: "flaky",
207
+ target: { provider: "x", endpoint: "/boom" },
208
+ request: z.object({}),
209
+ });
210
+ const send = forge.defineAction({
211
+ name: "ops.boom",
212
+ schema: z.object({}),
213
+ handler: async (_input, ctx) => {
214
+ try {
215
+ await ctx.externalCall(Notify, {});
216
+ } catch {
217
+ /* expected */
218
+ }
219
+ return undefined;
220
+ },
221
+ });
222
+ const opsModule = forge.defineModule("ops2", {
223
+ actions: [send],
224
+ externalCalls: [Notify],
225
+ });
226
+ const opsApp = forge.defineApp("ops2-app", { modules: [opsModule] });
227
+ const app = opsApp.create();
228
+ await app.start();
229
+ app.runtime.registerExternalCallExecutor(Notify, async () => {
230
+ throw new Error("nope");
231
+ });
232
+
233
+ const { tracer, spans } = makeFakeTracer();
234
+ attachOtelExporter(app.runtime, { tracer });
235
+ await app.runtime.dispatch(send, {});
236
+
237
+ const ext = spans.find((s) => s.name.includes("external flaky"));
238
+ expect(ext).toBeDefined();
239
+ expect(ext!.status?.code).toBe(2); // ERROR
240
+ expect(ext!.attributes["nwire.error.message"]).toBe("nope");
241
+ });
242
+
243
+ it("detach() unsubscribes and closes still-open spans", async () => {
244
+ const app = demoApp.create();
245
+ await app.start();
246
+ const { tracer, spans } = makeFakeTracer();
247
+ const detach = attachOtelExporter(app.runtime, { tracer });
248
+
249
+ // Detach mid-flight; the exporter should still flush.
250
+ detach();
251
+ // After detach, new events should NOT produce more spans.
252
+ const before = spans.length;
253
+ await app.runtime.dispatch(submit, { who: "Dina" });
254
+ expect(spans.length).toBe(before);
255
+ });
256
+ });