@decocms/start 6.0.1 → 6.2.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,422 @@
1
+ /**
2
+ * Phase 3 (D-12) coverage for the direct-POST OTLP trace exporter.
3
+ * Mirrors the test style of `otelHttpMeter.test.ts` — fake fetch,
4
+ * deterministic clock, deterministic IDs via injected accessors.
5
+ */
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import type { Span } from "../middleware/observability";
8
+ import {
9
+ createOtlpHttpTracerAdapter,
10
+ newSpanId,
11
+ newTraceId,
12
+ parseTraceparent,
13
+ shouldSampleTrace,
14
+ type TraceContext,
15
+ } from "./otelHttpTracer";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers — capture fetch & emulate active-span propagation
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function captureFetch(): {
22
+ fetchImpl: typeof fetch;
23
+ calls: Array<{ url: string; payload: Record<string, unknown> }>;
24
+ } {
25
+ const calls: Array<{ url: string; payload: Record<string, unknown> }> = [];
26
+ const fetchImpl: typeof fetch = async (input, init) => {
27
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28
+ const body = init?.body as string | undefined;
29
+ calls.push({ url, payload: body ? JSON.parse(body) : {} });
30
+ return new Response("", { status: 200 });
31
+ };
32
+ return { fetchImpl, calls };
33
+ }
34
+
35
+ /**
36
+ * Build a minimal active-span scope helper. Tests can push a parent
37
+ * span before calling `startSpan`, simulating what `withTracing` does
38
+ * via ALS.
39
+ */
40
+ function activeSpanStack(): {
41
+ push(span: Span): void;
42
+ pop(): void;
43
+ get(): Span | null;
44
+ } {
45
+ const stack: Span[] = [];
46
+ return {
47
+ push(span) {
48
+ stack.push(span);
49
+ },
50
+ pop() {
51
+ stack.pop();
52
+ },
53
+ get() {
54
+ return stack[stack.length - 1] ?? null;
55
+ },
56
+ };
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // parseTraceparent
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe("parseTraceparent (D-12)", () => {
64
+ it("parses a well-formed sampled header", () => {
65
+ const ctx = parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
66
+ expect(ctx).toEqual({
67
+ traceId: "0af7651916cd43dd8448eb211c80319c",
68
+ parentSpanId: "b7ad6b7169203331",
69
+ sampled: true,
70
+ remoteParent: true,
71
+ });
72
+ });
73
+
74
+ it("parses a well-formed unsampled header", () => {
75
+ const ctx = parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00");
76
+ expect(ctx?.sampled).toBe(false);
77
+ });
78
+
79
+ it("returns null on null / undefined / empty string", () => {
80
+ expect(parseTraceparent(null)).toBeNull();
81
+ expect(parseTraceparent(undefined)).toBeNull();
82
+ expect(parseTraceparent("")).toBeNull();
83
+ });
84
+
85
+ it("rejects unknown versions", () => {
86
+ expect(parseTraceparent("01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")).toBeNull();
87
+ });
88
+
89
+ it("rejects the well-known all-zero IDs (per W3C §3.2.2)", () => {
90
+ expect(
91
+ parseTraceparent("00-00000000000000000000000000000000-b7ad6b7169203331-01"),
92
+ ).toBeNull();
93
+ expect(
94
+ parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01"),
95
+ ).toBeNull();
96
+ });
97
+
98
+ it("rejects malformed lengths and non-hex characters", () => {
99
+ expect(parseTraceparent("00-tooshort-b7ad6b7169203331-01")).toBeNull();
100
+ expect(parseTraceparent("00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-b7ad6b7169203331-01")).toBeNull();
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // shouldSampleTrace — consistency + boundary cases
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe("shouldSampleTrace (D-12)", () => {
109
+ it("returns true for rate >= 1 (always sample)", () => {
110
+ expect(shouldSampleTrace("0".repeat(31) + "1", 1)).toBe(true);
111
+ expect(shouldSampleTrace("0".repeat(31) + "1", 5)).toBe(true);
112
+ expect(shouldSampleTrace("0".repeat(31) + "1", Infinity)).toBe(true);
113
+ });
114
+
115
+ it("returns false for rate <= 0 (never sample)", () => {
116
+ expect(shouldSampleTrace("0".repeat(31) + "1", 0)).toBe(false);
117
+ expect(shouldSampleTrace("0".repeat(31) + "1", -1)).toBe(false);
118
+ });
119
+
120
+ it("decisions are stable per trace ID (called twice → same answer)", () => {
121
+ const traceId = newTraceId();
122
+ const first = shouldSampleTrace(traceId, 0.1);
123
+ const second = shouldSampleTrace(traceId, 0.1);
124
+ expect(first).toBe(second);
125
+ });
126
+
127
+ it("approximates the configured rate over a large sample", () => {
128
+ // 5000 random trace IDs sampled at 10% should land within ±3% of
129
+ // the target — bounded statistical noise, deterministic seed not
130
+ // required because `Math.random` flakes here would already
131
+ // indicate a broken hash.
132
+ const trials = 5000;
133
+ let kept = 0;
134
+ for (let i = 0; i < trials; i++) {
135
+ if (shouldSampleTrace(newTraceId(), 0.1)) kept++;
136
+ }
137
+ const observed = kept / trials;
138
+ expect(observed).toBeGreaterThan(0.07);
139
+ expect(observed).toBeLessThan(0.13);
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // ID generation — shape only (entropy depends on crypto.getRandomValues)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("newTraceId / newSpanId (D-12)", () => {
148
+ it("returns 32-hex-char trace IDs and 16-hex-char span IDs", () => {
149
+ expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/);
150
+ expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/);
151
+ });
152
+ it("does not repeat over 100 calls", () => {
153
+ const set = new Set<string>();
154
+ for (let i = 0; i < 100; i++) set.add(newTraceId());
155
+ expect(set.size).toBe(100);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Adapter — startSpan / end / flush
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("createOtlpHttpTracerAdapter (D-12)", () => {
164
+ it("buffers a root span and POSTs OTLP payload on flush", async () => {
165
+ const { fetchImpl, calls } = captureFetch();
166
+ const stack = activeSpanStack();
167
+ let clock = 100;
168
+
169
+ const tracer = createOtlpHttpTracerAdapter({
170
+ endpoint: "https://collector.example/v1/traces",
171
+ resourceAttributes: { "service.name": "test-site" },
172
+ headSamplingRate: 1,
173
+ minFlushIntervalMs: 0,
174
+ fetchImpl,
175
+ nowMs: () => clock,
176
+ getActiveSpanForParent: () => stack.get(),
177
+ });
178
+
179
+ const span = tracer.startSpan("deco.http.request", { "http.method": "GET" });
180
+ clock = 250;
181
+ span.setAttribute?.("http.status_code", 200);
182
+ span.end();
183
+
184
+ expect(tracer.pendingSpanCount()).toBe(1);
185
+ await tracer.flush();
186
+ expect(tracer.pendingSpanCount()).toBe(0);
187
+ expect(calls).toHaveLength(1);
188
+
189
+ const resourceSpans = (calls[0].payload as { resourceSpans: unknown[] }).resourceSpans as Array<{
190
+ resource: { attributes: Array<{ key: string; value: { stringValue: string } }> };
191
+ scopeSpans: Array<{
192
+ spans: Array<{
193
+ name: string;
194
+ traceId: string;
195
+ spanId: string;
196
+ parentSpanId: string;
197
+ startTimeUnixNano: string;
198
+ endTimeUnixNano: string;
199
+ attributes: Array<{ key: string; value: unknown }>;
200
+ status: { code: number };
201
+ }>;
202
+ }>;
203
+ }>;
204
+ const rs = resourceSpans[0];
205
+ expect(rs.resource.attributes).toContainEqual({
206
+ key: "service.name",
207
+ value: { stringValue: "test-site" },
208
+ });
209
+ const spans = rs.scopeSpans[0].spans;
210
+ expect(spans).toHaveLength(1);
211
+ expect(spans[0].name).toBe("deco.http.request");
212
+ expect(spans[0].traceId).toMatch(/^[0-9a-f]{32}$/);
213
+ expect(spans[0].spanId).toMatch(/^[0-9a-f]{16}$/);
214
+ expect(spans[0].parentSpanId).toBe("");
215
+ // 200 status_code → status.code = OK (1)
216
+ expect(spans[0].status.code).toBe(1);
217
+ });
218
+
219
+ it("inherits trace ID from the active parent span", async () => {
220
+ const { fetchImpl, calls } = captureFetch();
221
+ const stack = activeSpanStack();
222
+ const tracer = createOtlpHttpTracerAdapter({
223
+ endpoint: "https://collector.example/v1/traces",
224
+ resourceAttributes: {},
225
+ headSamplingRate: 1,
226
+ minFlushIntervalMs: 0,
227
+ fetchImpl,
228
+ getActiveSpanForParent: () => stack.get(),
229
+ });
230
+
231
+ const parent = tracer.startSpan("deco.http.request");
232
+ stack.push(parent);
233
+ const child = tracer.startSpan("deco.cache.lookup");
234
+ stack.pop();
235
+
236
+ const parentCtx = parent.spanContext?.();
237
+ const childCtx = child.spanContext?.();
238
+ expect(childCtx?.traceId).toBe(parentCtx?.traceId);
239
+ expect(childCtx?.spanId).not.toBe(parentCtx?.spanId);
240
+
241
+ child.end();
242
+ parent.end();
243
+ await tracer.flush();
244
+
245
+ const payload = calls[0].payload as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{
246
+ name: string;
247
+ parentSpanId: string;
248
+ spanId: string;
249
+ }> }> }> };
250
+ const spans = payload.resourceSpans[0].scopeSpans[0].spans;
251
+ const childRow = spans.find((s) => s.name === "deco.cache.lookup");
252
+ const parentRow = spans.find((s) => s.name === "deco.http.request");
253
+ expect(childRow?.parentSpanId).toBe(parentRow?.spanId);
254
+ });
255
+
256
+ it("honors an inbound W3C traceparent for root spans", async () => {
257
+ const { fetchImpl } = captureFetch();
258
+ const stack = activeSpanStack();
259
+ const remote: TraceContext = {
260
+ traceId: "0af7651916cd43dd8448eb211c80319c",
261
+ parentSpanId: "b7ad6b7169203331",
262
+ sampled: true,
263
+ remoteParent: true,
264
+ };
265
+ const tracer = createOtlpHttpTracerAdapter({
266
+ endpoint: "https://collector.example/v1/traces",
267
+ resourceAttributes: {},
268
+ headSamplingRate: 0, // would normally drop everything
269
+ minFlushIntervalMs: 0,
270
+ fetchImpl,
271
+ getActiveSpanForParent: () => stack.get(),
272
+ getRequestTraceContext: () => remote,
273
+ });
274
+
275
+ const root = tracer.startSpan("deco.http.request");
276
+ const ctx = root.spanContext?.();
277
+ expect(ctx?.traceId).toBe(remote.traceId);
278
+ // parentSpanId from the remote should appear on the OTLP record
279
+ root.end();
280
+
281
+ // Even at samplingRate=0, the remote `sampled=true` overrides and
282
+ // keeps this span.
283
+ expect(tracer.pendingSpanCount()).toBe(1);
284
+ });
285
+
286
+ it("drops spans deterministically when samplingRate=0 and no remote sample bit", async () => {
287
+ const stack = activeSpanStack();
288
+ const tracer = createOtlpHttpTracerAdapter({
289
+ endpoint: "https://collector.example/v1/traces",
290
+ resourceAttributes: {},
291
+ headSamplingRate: 0,
292
+ minFlushIntervalMs: 0,
293
+ fetchImpl: vi.fn() as unknown as typeof fetch,
294
+ getActiveSpanForParent: () => stack.get(),
295
+ });
296
+ const span = tracer.startSpan("deco.http.request");
297
+ span.end();
298
+ expect(tracer.pendingSpanCount()).toBe(0);
299
+ });
300
+
301
+ it("captures setError as an OTLP exception event", async () => {
302
+ const { fetchImpl, calls } = captureFetch();
303
+ const stack = activeSpanStack();
304
+ const tracer = createOtlpHttpTracerAdapter({
305
+ endpoint: "https://collector.example/v1/traces",
306
+ resourceAttributes: {},
307
+ headSamplingRate: 1,
308
+ minFlushIntervalMs: 0,
309
+ fetchImpl,
310
+ getActiveSpanForParent: () => stack.get(),
311
+ });
312
+ const span = tracer.startSpan("deco.cache.lookup");
313
+ span.setError?.(new TypeError("boom"));
314
+ span.end();
315
+ await tracer.flush();
316
+
317
+ const payload = calls[0].payload as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{
318
+ status: { code: number; message?: string };
319
+ events: Array<{ name: string; attributes: Array<{ key: string }> }>;
320
+ attributes: Array<{ key: string; value: { stringValue: string } }>;
321
+ }> }> }> };
322
+ const span0 = payload.resourceSpans[0].scopeSpans[0].spans[0];
323
+ expect(span0.status.code).toBe(2);
324
+ expect(span0.status.message).toBe("boom");
325
+ expect(span0.events[0].name).toBe("exception");
326
+ expect(span0.attributes.find((a) => a.key === "exception.type")?.value.stringValue).toBe(
327
+ "TypeError",
328
+ );
329
+ });
330
+
331
+ it("flush is a no-op when the buffer is empty", async () => {
332
+ const fetchImpl = vi.fn() as unknown as typeof fetch;
333
+ const stack = activeSpanStack();
334
+ const tracer = createOtlpHttpTracerAdapter({
335
+ endpoint: "https://collector.example/v1/traces",
336
+ resourceAttributes: {},
337
+ headSamplingRate: 1,
338
+ minFlushIntervalMs: 0,
339
+ fetchImpl,
340
+ getActiveSpanForParent: () => stack.get(),
341
+ });
342
+ await tracer.flush();
343
+ expect(fetchImpl).not.toHaveBeenCalled();
344
+ });
345
+
346
+ it("respects the cooldown — second flush within window is skipped", async () => {
347
+ const { fetchImpl, calls } = captureFetch();
348
+ const stack = activeSpanStack();
349
+ // Use wall-clock-realistic values: `lastFlushAt` starts at 0, so the
350
+ // first flush's `elapsed` needs to exceed `minFlushIntervalMs` to
351
+ // get through. That's always true in production where `now() =
352
+ // Date.now() ~= 1.7e12`; we model that by starting the fake clock
353
+ // at the same scale.
354
+ let clock = 1_000_000_000_000;
355
+ const tracer = createOtlpHttpTracerAdapter({
356
+ endpoint: "https://collector.example/v1/traces",
357
+ resourceAttributes: {},
358
+ headSamplingRate: 1,
359
+ minFlushIntervalMs: 5000,
360
+ fetchImpl,
361
+ nowMs: () => clock,
362
+ getActiveSpanForParent: () => stack.get(),
363
+ });
364
+ const a = tracer.startSpan("a");
365
+ a.end();
366
+ clock += 100;
367
+ await tracer.flush();
368
+ expect(calls).toHaveLength(1);
369
+
370
+ const b = tracer.startSpan("b");
371
+ b.end();
372
+ clock += 100; // 200ms after first flush — still within 5s cooldown
373
+ await tracer.flush();
374
+ expect(calls).toHaveLength(1); // still one — cooldown blocked it
375
+ expect(tracer.pendingSpanCount()).toBe(1);
376
+
377
+ clock += 10_000; // past cooldown
378
+ await tracer.flush();
379
+ expect(calls).toHaveLength(2);
380
+ });
381
+
382
+ it("calls onError when the buffer overflows", async () => {
383
+ const stack = activeSpanStack();
384
+ const onError = vi.fn();
385
+ const tracer = createOtlpHttpTracerAdapter({
386
+ endpoint: "https://collector.example/v1/traces",
387
+ resourceAttributes: {},
388
+ headSamplingRate: 1,
389
+ minFlushIntervalMs: 0,
390
+ maxBufferSpans: 2,
391
+ fetchImpl: (async () => new Response("", { status: 200 })) as typeof fetch,
392
+ getActiveSpanForParent: () => stack.get(),
393
+ onError,
394
+ });
395
+ for (let i = 0; i < 5; i++) {
396
+ const s = tracer.startSpan(`s${i}`);
397
+ s.end();
398
+ }
399
+ expect(onError).toHaveBeenCalled();
400
+ expect(onError.mock.calls[0][0]).toBe("overflow");
401
+ });
402
+
403
+ it("calls onError on flush failure (non-2xx)", async () => {
404
+ const stack = activeSpanStack();
405
+ const onError = vi.fn();
406
+ const fetchImpl: typeof fetch = async () => new Response("server explosion", { status: 503 });
407
+ const tracer = createOtlpHttpTracerAdapter({
408
+ endpoint: "https://collector.example/v1/traces",
409
+ resourceAttributes: {},
410
+ headSamplingRate: 1,
411
+ minFlushIntervalMs: 0,
412
+ fetchImpl,
413
+ getActiveSpanForParent: () => stack.get(),
414
+ onError,
415
+ });
416
+ const s = tracer.startSpan("x");
417
+ s.end();
418
+ await tracer.flush();
419
+ expect(onError).toHaveBeenCalled();
420
+ expect(onError.mock.calls[0][0]).toBe("flush");
421
+ });
422
+ });