@typokit/otel 0.1.4

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,425 @@
1
+ import { describe, it, expect } from "@rstest/core";
2
+ import {
3
+ Tracer,
4
+ Span,
5
+ ConsoleSpanExporter,
6
+ OtlpSpanExporter,
7
+ NoopSpanExporter,
8
+ createRequestTracer,
9
+ resolveTracingConfig,
10
+ createExporter,
11
+ generateTraceId,
12
+ generateSpanId,
13
+ } from "./tracing.js";
14
+ import type { SpanData, SpanExporter } from "./types.js";
15
+
16
+ /** Test exporter that captures exported spans */
17
+ class TestSpanExporter implements SpanExporter {
18
+ exported: SpanData[][] = [];
19
+ export(spans: SpanData[]): void {
20
+ this.exported.push(spans);
21
+ }
22
+ }
23
+
24
+ describe("generateTraceId", () => {
25
+ it("should generate a 32-character hex string", () => {
26
+ const id = generateTraceId();
27
+ expect(id).toHaveLength(32);
28
+ expect(/^[0-9a-f]{32}$/.test(id)).toBe(true);
29
+ });
30
+
31
+ it("should generate unique IDs", () => {
32
+ const ids = new Set(Array.from({ length: 10 }, () => generateTraceId()));
33
+ expect(ids.size).toBe(10);
34
+ });
35
+ });
36
+
37
+ describe("generateSpanId", () => {
38
+ it("should generate a 16-character hex string", () => {
39
+ const id = generateSpanId();
40
+ expect(id).toHaveLength(16);
41
+ expect(/^[0-9a-f]{16}$/.test(id)).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("Span", () => {
46
+ it("should create a span with traceId, name, and kind", () => {
47
+ const span = new Span({
48
+ traceId: "abc123",
49
+ name: "test-span",
50
+ kind: "server",
51
+ });
52
+
53
+ expect(span.traceId).toBe("abc123");
54
+ expect(span.name).toBe("test-span");
55
+ expect(span.kind).toBe("server");
56
+ expect(span.startTime).toBeDefined();
57
+ expect(span.ended).toBe(false);
58
+ expect(span.status).toBe("unset");
59
+ });
60
+
61
+ it("should set attributes", () => {
62
+ const span = new Span({ traceId: "t1", name: "s1", kind: "internal" });
63
+ span.setAttribute("http.method", "GET");
64
+ span.setAttribute("http.status_code", 200);
65
+ span.setAttribute("http.ok", true);
66
+
67
+ expect(span.attributes["http.method"]).toBe("GET");
68
+ expect(span.attributes["http.status_code"]).toBe(200);
69
+ expect(span.attributes["http.ok"]).toBe(true);
70
+ });
71
+
72
+ it("should track status: ok", () => {
73
+ const span = new Span({ traceId: "t1", name: "s1", kind: "internal" });
74
+ span.setOk();
75
+ expect(span.status).toBe("ok");
76
+ });
77
+
78
+ it("should track status: error with message", () => {
79
+ const span = new Span({ traceId: "t1", name: "s1", kind: "internal" });
80
+ span.setError("something failed");
81
+ expect(span.status).toBe("error");
82
+ expect(span.attributes["error.message"]).toBe("something failed");
83
+ });
84
+
85
+ it("should end and record duration", () => {
86
+ const span = new Span({ traceId: "t1", name: "s1", kind: "internal" });
87
+ expect(span.ended).toBe(false);
88
+ span.end();
89
+ expect(span.ended).toBe(true);
90
+
91
+ const data = span.toData();
92
+ expect(data.endTime).toBeDefined();
93
+ expect(typeof data.durationMs).toBe("number");
94
+ expect(data.durationMs).toBeGreaterThanOrEqual(0);
95
+ });
96
+
97
+ it("should convert to SpanData without optional fields when absent", () => {
98
+ const span = new Span({ traceId: "t1", name: "s1", kind: "server" });
99
+ const data = span.toData();
100
+
101
+ expect(data.traceId).toBe("t1");
102
+ expect(data.name).toBe("s1");
103
+ expect(data.kind).toBe("server");
104
+ expect(data.status).toBe("unset");
105
+ expect("parentSpanId" in data).toBe(false);
106
+ expect("endTime" in data).toBe(false);
107
+ expect("durationMs" in data).toBe(false);
108
+ });
109
+
110
+ it("should include parentSpanId when provided", () => {
111
+ const span = new Span({
112
+ traceId: "t1",
113
+ parentSpanId: "parent-1",
114
+ name: "child",
115
+ kind: "internal",
116
+ });
117
+ const data = span.toData();
118
+ expect(data.parentSpanId).toBe("parent-1");
119
+ });
120
+ });
121
+
122
+ describe("Tracer", () => {
123
+ it("should create a tracer with a unique traceId", () => {
124
+ const tracer = new Tracer();
125
+ expect(tracer.traceId).toBeDefined();
126
+ expect(tracer.traceId.length).toBe(32);
127
+ });
128
+
129
+ it("should use provided traceId", () => {
130
+ const tracer = new Tracer({ traceId: "custom-trace-id" });
131
+ expect(tracer.traceId).toBe("custom-trace-id");
132
+ });
133
+
134
+ it("should start a root span for a request", () => {
135
+ const exporter = new TestSpanExporter();
136
+ const tracer = new Tracer({ exporter, enabled: true });
137
+
138
+ const rootSpan = tracer.startRootSpan("POST /users", {
139
+ "http.method": "POST",
140
+ "http.target": "/users",
141
+ });
142
+
143
+ expect(rootSpan.name).toBe("POST /users");
144
+ expect(rootSpan.kind).toBe("server");
145
+ expect(rootSpan.attributes["http.method"]).toBe("POST");
146
+ expect(rootSpan.traceId).toBe(tracer.traceId);
147
+ });
148
+
149
+ it("should create child spans under the root span", () => {
150
+ const exporter = new TestSpanExporter();
151
+ const tracer = new Tracer({ exporter, enabled: true });
152
+
153
+ const root = tracer.startRootSpan("GET /items");
154
+ const child = tracer.startSpan("middleware:auth");
155
+
156
+ expect(child.traceId).toBe(tracer.traceId);
157
+ expect(child.kind).toBe("internal");
158
+
159
+ const childData = child.toData();
160
+ expect(childData.parentSpanId).toBe(root.spanId);
161
+ });
162
+
163
+ it("should create span hierarchy: root → middleware → validation → handler → serialization", () => {
164
+ const exporter = new TestSpanExporter();
165
+ const tracer = new Tracer({ exporter, enabled: true });
166
+
167
+ const root = tracer.startRootSpan("POST /users");
168
+ const mw = tracer.startSpan("middleware:logging");
169
+ mw.setOk();
170
+ mw.end();
171
+ const auth = tracer.startSpan("middleware:auth");
172
+ auth.setOk();
173
+ auth.end();
174
+ const validation = tracer.startSpan("validation:body");
175
+ validation.setAttribute("validation.result", "pass");
176
+ validation.setOk();
177
+ validation.end();
178
+ const handler = tracer.startSpan("handler");
179
+ handler.setOk();
180
+ handler.end();
181
+ const serialization = tracer.startSpan("serialization");
182
+ serialization.setOk();
183
+ serialization.end();
184
+ root.setOk();
185
+ root.setAttribute("http.status_code", 200);
186
+ root.end();
187
+
188
+ const spans = tracer.getSpans();
189
+ expect(spans).toHaveLength(6);
190
+
191
+ // Root span
192
+ expect(spans[0].name).toBe("POST /users");
193
+ expect(spans[0].kind).toBe("server");
194
+
195
+ // All children reference root
196
+ for (let i = 1; i < spans.length; i++) {
197
+ expect(spans[i].parentSpanId).toBe(spans[0].spanId);
198
+ expect(spans[i].kind).toBe("internal");
199
+ }
200
+
201
+ // Verify span names
202
+ expect(spans.map((s) => s.name)).toEqual([
203
+ "POST /users",
204
+ "middleware:logging",
205
+ "middleware:auth",
206
+ "validation:body",
207
+ "handler",
208
+ "serialization",
209
+ ]);
210
+ });
211
+
212
+ it("should propagate traceId across all spans", () => {
213
+ const tracer = new Tracer({ enabled: true });
214
+ tracer.startRootSpan("request");
215
+ const s1 = tracer.startSpan("phase1");
216
+ const s2 = tracer.startSpan("phase2");
217
+ s1.end();
218
+ s2.end();
219
+
220
+ const spans = tracer.getSpans();
221
+ for (const span of spans) {
222
+ expect(span.traceId).toBe(tracer.traceId);
223
+ }
224
+ });
225
+
226
+ it("should flush and export all spans", () => {
227
+ const exporter = new TestSpanExporter();
228
+ const tracer = new Tracer({ exporter, enabled: true });
229
+
230
+ tracer.startRootSpan("request");
231
+ tracer.startSpan("child1");
232
+ tracer.startSpan("child2");
233
+
234
+ tracer.flush();
235
+
236
+ expect(exporter.exported).toHaveLength(1);
237
+ expect(exporter.exported[0]).toHaveLength(3);
238
+
239
+ // All spans should be ended after flush
240
+ for (const span of exporter.exported[0]) {
241
+ expect(span.endTime).toBeDefined();
242
+ }
243
+ });
244
+
245
+ it("should not export when disabled", () => {
246
+ const exporter = new TestSpanExporter();
247
+ const tracer = new Tracer({ exporter, enabled: false });
248
+
249
+ tracer.startRootSpan("request");
250
+ tracer.startSpan("child");
251
+ tracer.flush();
252
+
253
+ expect(exporter.exported).toHaveLength(0);
254
+ });
255
+
256
+ it("should include service.name attribute on root span", () => {
257
+ const exporter = new TestSpanExporter();
258
+ const tracer = new Tracer({
259
+ exporter,
260
+ serviceName: "my-api",
261
+ enabled: true,
262
+ });
263
+
264
+ tracer.startRootSpan("request");
265
+ tracer.flush();
266
+
267
+ const rootSpan = exporter.exported[0][0];
268
+ expect(rootSpan.attributes["service.name"]).toBe("my-api");
269
+ });
270
+
271
+ it("should default serviceName to 'typokit'", () => {
272
+ const exporter = new TestSpanExporter();
273
+ const tracer = new Tracer({ exporter, enabled: true });
274
+
275
+ tracer.startRootSpan("request");
276
+ tracer.flush();
277
+
278
+ expect(exporter.exported[0][0].attributes["service.name"]).toBe("typokit");
279
+ });
280
+ });
281
+
282
+ describe("resolveTracingConfig", () => {
283
+ it("should default to enabled with console exporter when no config", () => {
284
+ const config = resolveTracingConfig();
285
+ expect(config.enabled).toBe(true);
286
+ expect(config.exporter).toBe("console");
287
+ });
288
+
289
+ it("should handle tracing: true in telemetry config", () => {
290
+ const config = resolveTracingConfig({
291
+ tracing: true,
292
+ exporter: "otlp",
293
+ endpoint: "http://collector:4318",
294
+ });
295
+ expect(config.enabled).toBe(true);
296
+ expect(config.exporter).toBe("otlp");
297
+ expect(config.endpoint).toBe("http://collector:4318");
298
+ });
299
+
300
+ it("should handle tracing: false", () => {
301
+ const config = resolveTracingConfig({ tracing: false });
302
+ expect(config.enabled).toBe(false);
303
+ });
304
+
305
+ it("should handle tracing as object config", () => {
306
+ const config = resolveTracingConfig({
307
+ tracing: {
308
+ enabled: true,
309
+ exporter: "otlp",
310
+ endpoint: "http://custom:4318",
311
+ serviceName: "my-svc",
312
+ },
313
+ });
314
+ expect(config.enabled).toBe(true);
315
+ expect(config.exporter).toBe("otlp");
316
+ expect(config.endpoint).toBe("http://custom:4318");
317
+ expect(config.serviceName).toBe("my-svc");
318
+ });
319
+
320
+ it("should inherit top-level exporter/endpoint when tracing is boolean", () => {
321
+ const config = resolveTracingConfig({
322
+ tracing: true,
323
+ exporter: "otlp",
324
+ endpoint: "http://otel:4318",
325
+ serviceName: "top-svc",
326
+ });
327
+ expect(config.exporter).toBe("otlp");
328
+ expect(config.endpoint).toBe("http://otel:4318");
329
+ expect(config.serviceName).toBe("top-svc");
330
+ });
331
+ });
332
+
333
+ describe("createExporter", () => {
334
+ it("should create NoopSpanExporter when disabled", () => {
335
+ const exp = createExporter({ enabled: false });
336
+ expect(exp).toBeInstanceOf(NoopSpanExporter);
337
+ });
338
+
339
+ it("should create ConsoleSpanExporter for console exporter", () => {
340
+ const exp = createExporter({ enabled: true, exporter: "console" });
341
+ expect(exp).toBeInstanceOf(ConsoleSpanExporter);
342
+ });
343
+
344
+ it("should create OtlpSpanExporter for otlp exporter", () => {
345
+ const exp = createExporter({
346
+ enabled: true,
347
+ exporter: "otlp",
348
+ endpoint: "http://collector:4318",
349
+ });
350
+ expect(exp).toBeInstanceOf(OtlpSpanExporter);
351
+ });
352
+ });
353
+
354
+ describe("createRequestTracer", () => {
355
+ it("should create a tracer with default config", () => {
356
+ const tracer = createRequestTracer();
357
+ expect(tracer.traceId).toBeDefined();
358
+ expect(tracer.traceId.length).toBe(32);
359
+ });
360
+
361
+ it("should create a tracer with telemetry config", () => {
362
+ const tracer = createRequestTracer({
363
+ tracing: true,
364
+ serviceName: "test-api",
365
+ });
366
+ expect(tracer.traceId).toBeDefined();
367
+ });
368
+
369
+ it("should accept exporter override", () => {
370
+ const exporter = new TestSpanExporter();
371
+ const tracer = createRequestTracer({ tracing: true }, exporter);
372
+
373
+ tracer.startRootSpan("request");
374
+ tracer.flush();
375
+
376
+ expect(exporter.exported).toHaveLength(1);
377
+ });
378
+
379
+ it("should disable tracing when config says false", () => {
380
+ const exporter = new TestSpanExporter();
381
+ const tracer = createRequestTracer({ tracing: false }, exporter);
382
+
383
+ tracer.startRootSpan("request");
384
+ tracer.flush();
385
+
386
+ expect(exporter.exported).toHaveLength(0);
387
+ });
388
+ });
389
+
390
+ describe("ConsoleSpanExporter", () => {
391
+ it("should be constructable", () => {
392
+ const exp = new ConsoleSpanExporter();
393
+ expect(typeof exp.export).toBe("function");
394
+ });
395
+ });
396
+
397
+ describe("OtlpSpanExporter", () => {
398
+ it("should be constructable with default endpoint", () => {
399
+ const exp = new OtlpSpanExporter();
400
+ expect(typeof exp.export).toBe("function");
401
+ });
402
+
403
+ it("should be constructable with custom endpoint", () => {
404
+ const exp = new OtlpSpanExporter("http://custom:4318");
405
+ expect(typeof exp.export).toBe("function");
406
+ });
407
+ });
408
+
409
+ describe("NoopSpanExporter", () => {
410
+ it("should silently discard spans", () => {
411
+ const exp = new NoopSpanExporter();
412
+ exp.export([
413
+ {
414
+ traceId: "t1",
415
+ spanId: "s1",
416
+ name: "test",
417
+ kind: "server",
418
+ startTime: new Date().toISOString(),
419
+ status: "ok",
420
+ attributes: {},
421
+ },
422
+ ]);
423
+ // No error thrown
424
+ });
425
+ });