@decocms/blocks 7.0.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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,543 @@
1
+ /**
2
+ * OTLP/HTTP JSON trace exporter — direct POST from a Cloudflare Worker
3
+ * to `deco-otel-ingest` `/v1/traces`.
4
+ *
5
+ * Mirrors `otelHttpMeter.ts` in shape: per-isolate buffer, flush via
6
+ * `ctx.waitUntil` at request end, cooldown + buffer cap.
7
+ *
8
+ * **Why direct POST instead of CF Destinations + `@opentelemetry/api` bridge.**
9
+ * The bridge tracer in `otel.ts` delegates to `trace.getTracer(...)`. Without
10
+ * a registered `TracerProvider`, that's a no-op proxy and every framework
11
+ * `deco.*` span we create silently disappears. Empirical verification (May
12
+ * 2026) on prod sites confirmed: zero `deco.*` rows in `otel_traces` despite
13
+ * `withTracing` being called on every request. This adapter ships those
14
+ * spans direct-POST, same channel as metrics + error logs.
15
+ *
16
+ * **Sampling.** Consistent per-trace decision via FNV-1a hash of the
17
+ * trace-id. Caller passes `headSamplingRate` (default 0.01 = 1%) to match
18
+ * the CF Destinations `traces.head_sampling_rate` default and keep volume
19
+ * within budget. Parent-based override: if an incoming W3C `traceparent`
20
+ * header carried `flags=01` (sampled), every span in that trace is
21
+ * exported regardless of the rate.
22
+ *
23
+ * **Buffer.** Spans are buffered per-isolate until `flush()` ships them.
24
+ * Unlike metrics (CUMULATIVE temporality, lifelong buffer), traces are
25
+ * one-shot per span: buffer drains on flush and starts empty for the next
26
+ * window. Cap (`maxBufferSpans`) protects against runaway trace volume.
27
+ *
28
+ * **Parent linkage.** `startSpan` reads the active span from a caller-
29
+ * supplied accessor (`getActiveSpanForParent`) — the framework wires this
30
+ * to `getActiveSpan` from `middleware/observability.ts` so child spans
31
+ * inherit `trace_id` + record `parent_span_id` automatically. Root spans
32
+ * (no active parent) consult `getRequestTraceContext` to pick up the
33
+ * incoming W3C traceparent, or generate a fresh trace.
34
+ */
35
+
36
+ import type { Span, TracerAdapter } from "../middleware/observability";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // W3C traceparent parsing
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Trace context lifted from an inbound W3C `traceparent` header (RFC
44
+ * tracecontext, `version-traceId-parentId-flags`). `remoteParent: true`
45
+ * indicates the parent span lives in another service.
46
+ */
47
+ export interface TraceContext {
48
+ traceId: string;
49
+ parentSpanId: string;
50
+ /** Parent's `traceFlags & 0x01` — the W3C "sampled" bit. */
51
+ sampled: boolean;
52
+ /** True when the context came from an inbound header (remote parent). */
53
+ remoteParent: boolean;
54
+ }
55
+
56
+ /**
57
+ * Parse a W3C `traceparent` value. Returns `null` on any structural
58
+ * violation, including the well-known all-zero IDs (which OTel treats
59
+ * as invalid — see W3C tracecontext §3.2.2).
60
+ */
61
+ export function parseTraceparent(value: string | null | undefined): TraceContext | null {
62
+ if (!value) return null;
63
+ const parts = value.split("-");
64
+ if (parts.length !== 4) return null;
65
+ const [version, traceId, parentId, flags] = parts;
66
+ if (version !== "00") return null;
67
+ if (!/^[0-9a-f]{32}$/.test(traceId)) return null;
68
+ if (traceId === "0".repeat(32)) return null;
69
+ if (!/^[0-9a-f]{16}$/.test(parentId)) return null;
70
+ if (parentId === "0".repeat(16)) return null;
71
+ if (!/^[0-9a-f]{2}$/.test(flags)) return null;
72
+ const sampled = (Number.parseInt(flags, 16) & 0x01) === 0x01;
73
+ return { traceId, parentSpanId: parentId, sampled, remoteParent: true };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // ID generation + sampling
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function randomHex(bytes: number): string {
81
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
82
+ const buf = new Uint8Array(bytes);
83
+ crypto.getRandomValues(buf);
84
+ let out = "";
85
+ for (let i = 0; i < buf.length; i++) {
86
+ out += buf[i].toString(16).padStart(2, "0");
87
+ }
88
+ return out;
89
+ }
90
+ // Fallback for runtimes without crypto.getRandomValues (tests, some
91
+ // sandboxes). Lower-entropy but the function still satisfies the
92
+ // length contract — collisions in this branch are operational
93
+ // breadcrumbs, not a security threat.
94
+ let out = "";
95
+ while (out.length < bytes * 2) {
96
+ out += Math.floor(Math.random() * 0xffff)
97
+ .toString(16)
98
+ .padStart(4, "0");
99
+ }
100
+ return out.slice(0, bytes * 2);
101
+ }
102
+
103
+ export function newTraceId(): string {
104
+ return randomHex(16);
105
+ }
106
+
107
+ export function newSpanId(): string {
108
+ return randomHex(8);
109
+ }
110
+
111
+ /**
112
+ * FNV-1a 32-bit hash over the trace ID (hex string). Cheap, dependency-
113
+ * free, and produces a uniform distribution over 32-bit unsigned ints —
114
+ * good enough for consistent head sampling.
115
+ */
116
+ function hashTraceId(traceId: string): number {
117
+ let h = 2166136261;
118
+ for (let i = 0; i < traceId.length; i++) {
119
+ h ^= traceId.charCodeAt(i);
120
+ h = Math.imul(h, 16777619);
121
+ }
122
+ return h >>> 0;
123
+ }
124
+
125
+ /**
126
+ * Consistent head-sampling decision: every span in a trace gets the same
127
+ * answer because the input is `traceId`, not `spanId`. Caller is
128
+ * responsible for parent-based overrides (see `shouldExportSpan`).
129
+ */
130
+ export function shouldSampleTrace(traceId: string, rate: number): boolean {
131
+ if (!Number.isFinite(rate) || rate >= 1) return true;
132
+ if (rate <= 0) return false;
133
+ return hashTraceId(traceId) / 0xffffffff < rate;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Types
138
+ // ---------------------------------------------------------------------------
139
+
140
+ type Labels = Record<string, string | number | boolean>;
141
+
142
+ /** OTel `SpanKind` enum. We only ever emit INTERNAL spans. */
143
+ const SPAN_KIND_INTERNAL = 1;
144
+ /** OTel `StatusCode.OK` = 1, `ERROR` = 2 (and `UNSET` = 0). */
145
+ const STATUS_OK = 1;
146
+ const STATUS_ERROR = 2;
147
+
148
+ interface SpanEvent {
149
+ name: string;
150
+ timeUnixNano: string;
151
+ attributes: Labels;
152
+ }
153
+
154
+ interface SpanRecord {
155
+ name: string;
156
+ traceId: string;
157
+ spanId: string;
158
+ parentSpanId: string;
159
+ startTimeUnixNano: string;
160
+ endTimeUnixNano: string;
161
+ attributes: Labels;
162
+ events: SpanEvent[];
163
+ status: { code: number; message: string };
164
+ }
165
+
166
+ export interface OtlpHttpTracerOptions {
167
+ /** Full OTLP/HTTP JSON traces endpoint, e.g. `https://.../v1/traces`. */
168
+ endpoint: string;
169
+ /** Resource attributes stamped on every OTLP payload (service.name etc.). */
170
+ resourceAttributes: Record<string, string>;
171
+ /** Scope name advertised in `scopeSpans[].scope.name`. */
172
+ scopeName?: string;
173
+ /** Scope version. */
174
+ scopeVersion?: string;
175
+ /**
176
+ * Head sampling rate, 0.0..1.0. Default 0.01 to match the
177
+ * CF Destinations `traces.head_sampling_rate` recommendation. Set to
178
+ * 1 to capture every trace; set to 0 to disable export entirely.
179
+ *
180
+ * Decisions are consistent per trace (FNV-1a hash of the trace ID),
181
+ * so a `deco.cms.resolvePage` child span is sampled iff the
182
+ * `deco.http.request` root for the same trace is sampled.
183
+ */
184
+ headSamplingRate?: number;
185
+ /**
186
+ * Sampling rate for error-promoted traces, 0.0..1.0. Default `0.1`
187
+ * (promote 10% of error traces). Lower values reduce ClickHouse volume when
188
+ * errors are frequent — uses the same FNV-1a hash as head sampling so
189
+ * the decision is consistent per trace. Only applies when `promoteTrace`
190
+ * is called (i.e. `DECO_OTEL_ERROR_PROMOTION=true`).
191
+ */
192
+ errorPromotionRate?: number;
193
+ /** Hard cap on pending spans. Default: 2000. */
194
+ maxBufferSpans?: number;
195
+ /** Cooldown between successful flushes (ms). Default: 5000. */
196
+ minFlushIntervalMs?: number;
197
+ /** Per-flush HTTP timeout (ms). Default: 5000. */
198
+ flushTimeoutMs?: number;
199
+ /**
200
+ * Extra HTTP headers merged into every OTLP POST (e.g. `Authorization: Bearer …`).
201
+ * `Content-Type: application/json` is always set and cannot be overridden here.
202
+ */
203
+ headers?: Record<string, string>;
204
+ /**
205
+ * Test seam — override `fetch` for the flush path. Same role as in
206
+ * `otelHttpMeter.ts`.
207
+ */
208
+ fetchImpl?: typeof fetch;
209
+ /** Test seam — override `Date.now()` for deterministic timestamps. */
210
+ nowMs?: () => number;
211
+ /** Optional sink for transport errors. */
212
+ onError?: (kind: "flush" | "overflow", err: unknown) => void;
213
+ /**
214
+ * Accessor for the currently-active span at the moment `startSpan` is
215
+ * called. The framework wires this to `getActiveSpan()` from
216
+ * `middleware/observability.ts` so child spans inherit `trace_id` and
217
+ * record `parent_span_id`. Returns `null` for root spans.
218
+ */
219
+ getActiveSpanForParent: () => Span | null;
220
+ /**
221
+ * Accessor for the per-request trace context (parsed from inbound
222
+ * `traceparent`). Read at root-span creation time so we honor remote
223
+ * parents and the `sampled` flag.
224
+ */
225
+ getRequestTraceContext?: () => TraceContext | null;
226
+ /**
227
+ * When this returns `true`, the sampling decision is forced to sampled
228
+ * regardless of `headSamplingRate` or parent context. Used to implement
229
+ * debug sampling via the `?__d=` query parameter — lets an operator force
230
+ * a full trace on a specific production request without changing global rates.
231
+ */
232
+ getForceSampled?: () => boolean;
233
+ }
234
+
235
+ export interface OtlpHttpTracer extends TracerAdapter {
236
+ /** Drain the buffer (subject to cooldown). */
237
+ flush(): Promise<void>;
238
+ /** For tests + the audit channel. */
239
+ pendingSpanCount(): number;
240
+ /**
241
+ * Mark a trace for export even when it was not selected by head sampling.
242
+ * Called when an error is logged mid-request so the root span and any
243
+ * spans that end after the error are still shipped to ClickHouse.
244
+ */
245
+ promoteTrace(traceId: string): void;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Factory
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export function createOtlpHttpTracerAdapter(options: OtlpHttpTracerOptions): OtlpHttpTracer {
253
+ const endpoint = options.endpoint;
254
+ const resourceAttributes = options.resourceAttributes;
255
+ const scopeName = options.scopeName ?? "@decocms/start";
256
+ const scopeVersion = options.scopeVersion ?? "";
257
+ const headSamplingRate = options.headSamplingRate ?? 0.01;
258
+ const maxBuffer = options.maxBufferSpans ?? 2000;
259
+ const minFlushIntervalMs = options.minFlushIntervalMs ?? 5000;
260
+ const flushTimeoutMs = options.flushTimeoutMs ?? 5000;
261
+ const extraHeaders = options.headers ?? {};
262
+ const fetchImpl = options.fetchImpl ?? fetch;
263
+ const now = options.nowMs ?? (() => Date.now());
264
+ const onError = options.onError;
265
+ const getActiveSpanForParent = options.getActiveSpanForParent;
266
+ const getRequestTraceContext = options.getRequestTraceContext;
267
+ const getForceSampled = options.getForceSampled;
268
+
269
+ // Buffer of completed spans waiting to ship. Sampling decision is taken
270
+ // at span-end (not span-start) so attribute mutations during the span
271
+ // lifetime are captured in the record we drop or keep.
272
+ const spans: SpanRecord[] = [];
273
+ // Traces promoted by an error log — spans for these traces export even
274
+ // when head sampling did not select them.
275
+ const promotedTraces = new Set<string>();
276
+ const errorPromotionRate = options.errorPromotionRate ?? 0.1;
277
+ let lastFlushAt = 0;
278
+ let inflight: Promise<void> | null = null;
279
+
280
+ function pendingSpanCount(): number {
281
+ return spans.length;
282
+ }
283
+
284
+ function promoteTrace(traceId: string): void {
285
+ if (shouldSampleTrace(traceId, errorPromotionRate)) {
286
+ promotedTraces.add(traceId);
287
+ }
288
+ }
289
+
290
+ function startSpan(name: string, attributes?: Labels): Span {
291
+ const parent = getActiveSpanForParent();
292
+ const parentCtx = parent?.spanContext?.();
293
+
294
+ // Inherit trace ID from the parent (in-process or remote). Only root
295
+ // spans with no parent context generate a fresh trace ID.
296
+ const remoteCtx = parentCtx ? null : (getRequestTraceContext?.() ?? null);
297
+ const traceId = parentCtx?.traceId ?? remoteCtx?.traceId ?? newTraceId();
298
+ const parentSpanId = parentCtx?.spanId ?? remoteCtx?.parentSpanId ?? "";
299
+
300
+ // Sampling decision at startSpan() — OTel-spec-compliant. Setting
301
+ // traceFlags here (not at end()) means spanContext().traceFlags is
302
+ // correct during the entire span lifetime, which lets the log adapter
303
+ // use it for trace-based log sampling.
304
+ //
305
+ // Priority:
306
+ // 1. ?__d= debug param present → always sample (operator override)
307
+ // 2. Remote parent sampled=true → always sample (join external traces)
308
+ // 3. Remote parent sampled=false → don't sample (honor external decision)
309
+ // 4. In-process parent → inherit its traceFlags (consistent per-trace)
310
+ // 5. Root span, no parent → FNV-1a hash of traceId vs headSamplingRate
311
+ const sampled: boolean =
312
+ (getForceSampled?.() === true)
313
+ ? true
314
+ : remoteCtx !== null
315
+ ? remoteCtx.sampled
316
+ : parentCtx != null
317
+ ? (parentCtx.traceFlags & 0x01) === 0x01
318
+ : shouldSampleTrace(traceId, headSamplingRate);
319
+ const traceFlags = sampled ? 0x01 : 0x00;
320
+
321
+ const spanId = newSpanId();
322
+ const startTimeNs = msToNs(now());
323
+ const record: SpanRecord = {
324
+ name,
325
+ traceId,
326
+ spanId,
327
+ parentSpanId,
328
+ startTimeUnixNano: startTimeNs,
329
+ endTimeUnixNano: startTimeNs, // overwritten on end()
330
+ attributes: { ...(attributes ?? {}) },
331
+ events: [],
332
+ status: { code: 0, message: "" },
333
+ };
334
+
335
+ let ended = false;
336
+
337
+ return {
338
+ end(): void {
339
+ if (ended) return;
340
+ ended = true;
341
+ record.endTimeUnixNano = msToNs(now());
342
+
343
+ // Sampling decision was already made at startSpan() — traceFlags
344
+ // carries the result. Child spans inherit it from their parent so
345
+ // the entire trace is kept or dropped consistently.
346
+ // Exception: if an error was logged for this trace (promoteTrace was
347
+ // called), export anyway so the error has a trace in ClickHouse.
348
+ if (!sampled && !promotedTraces.has(traceId)) return;
349
+
350
+ if (spans.length >= maxBuffer) {
351
+ onError?.("overflow", new Error(`trace buffer at cap (${maxBuffer}) — dropping span`));
352
+ return;
353
+ }
354
+ spans.push(record);
355
+ },
356
+ setError(error: unknown): void {
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ record.status = { code: STATUS_ERROR, message };
359
+ record.attributes["exception.type"] =
360
+ error instanceof Error ? error.constructor.name : "unknown";
361
+ record.attributes["exception.message"] = message;
362
+ if (error instanceof Error && error.stack) {
363
+ record.attributes["exception.stacktrace"] = error.stack;
364
+ }
365
+ record.events.push({
366
+ name: "exception",
367
+ timeUnixNano: msToNs(now()),
368
+ attributes: {
369
+ "exception.type":
370
+ error instanceof Error ? error.constructor.name : "unknown",
371
+ "exception.message": message,
372
+ ...(error instanceof Error && error.stack
373
+ ? { "exception.stacktrace": error.stack }
374
+ : {}),
375
+ },
376
+ });
377
+ },
378
+ setAttribute(key: string, value: string | number | boolean): void {
379
+ record.attributes[key] = value;
380
+ // Status promotion driven by http.status_code:
381
+ // < 400 → OK (explicit success so dashboards don't show UNSET)
382
+ // ≥ 400 → ERROR (upstream returned an error; fetch() itself didn't
383
+ // throw, so the catch path never ran — this is the only
384
+ // place we can promote the status correctly)
385
+ if (key === "http.status_code" && typeof value === "number") {
386
+ if (value < 400 && record.status.code === 0) {
387
+ record.status = { code: STATUS_OK, message: "" };
388
+ } else if (value >= 400) {
389
+ record.status = { code: STATUS_ERROR, message: `HTTP ${value}` };
390
+ }
391
+ }
392
+ },
393
+ spanContext(): { traceId: string; spanId: string; traceFlags: number } {
394
+ return { traceId, spanId, traceFlags };
395
+ },
396
+ };
397
+ }
398
+
399
+ async function doFlush(): Promise<void> {
400
+ if (spans.length === 0) return;
401
+
402
+ // Snapshot + reset buffer before the network call so concurrent
403
+ // span ends during the POST land in the next window.
404
+ const batch = spans.splice(0, spans.length);
405
+
406
+ const payload = serializeOtlpTraces(batch, {
407
+ resourceAttributes,
408
+ scopeName,
409
+ scopeVersion,
410
+ });
411
+
412
+ const controller = new AbortController();
413
+ const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
414
+ try {
415
+ const res = await fetchImpl(endpoint, {
416
+ method: "POST",
417
+ headers: { ...extraHeaders, "Content-Type": "application/json" },
418
+ body: JSON.stringify(payload),
419
+ signal: controller.signal,
420
+ });
421
+ if (!res.ok) {
422
+ try {
423
+ await res.text();
424
+ } catch {
425
+ /* swallow */
426
+ }
427
+ onError?.("flush", new Error(`POST ${endpoint} → ${res.status}`));
428
+ }
429
+ } catch (err) {
430
+ onError?.("flush", err);
431
+ } finally {
432
+ clearTimeout(timer);
433
+ }
434
+ }
435
+
436
+ async function flush(): Promise<void> {
437
+ if (inflight) return inflight;
438
+ const elapsed = now() - lastFlushAt;
439
+ const overCap = spans.length >= maxBuffer;
440
+ if (!overCap && elapsed < minFlushIntervalMs) return;
441
+ inflight = doFlush().finally(() => {
442
+ lastFlushAt = now();
443
+ inflight = null;
444
+ });
445
+ return inflight;
446
+ }
447
+
448
+ return {
449
+ startSpan,
450
+ flush,
451
+ pendingSpanCount,
452
+ promoteTrace,
453
+ };
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // OTLP/HTTP JSON serialization for traces
458
+ // ---------------------------------------------------------------------------
459
+
460
+ function msToNs(ms: number): string {
461
+ return `${Math.floor(ms)}000000`;
462
+ }
463
+
464
+ function attrsToOtlp(attrs: Labels): Array<{
465
+ key: string;
466
+ value:
467
+ | { stringValue: string }
468
+ | { intValue: string }
469
+ | { doubleValue: number }
470
+ | { boolValue: boolean };
471
+ }> {
472
+ const out: ReturnType<typeof attrsToOtlp> = [];
473
+ for (const k of Object.keys(attrs).sort()) {
474
+ const v = attrs[k];
475
+ if (v === undefined || v === null) continue;
476
+ if (typeof v === "string") out.push({ key: k, value: { stringValue: v } });
477
+ else if (typeof v === "boolean") out.push({ key: k, value: { boolValue: v } });
478
+ else if (Number.isInteger(v)) out.push({ key: k, value: { intValue: String(v) } });
479
+ else out.push({ key: k, value: { doubleValue: v } });
480
+ }
481
+ return out;
482
+ }
483
+
484
+ interface SerializeOpts {
485
+ resourceAttributes: Record<string, string>;
486
+ scopeName: string;
487
+ scopeVersion: string;
488
+ }
489
+
490
+ function serializeOtlpTraces(
491
+ batch: SpanRecord[],
492
+ opts: SerializeOpts,
493
+ ): { resourceSpans: unknown[] } {
494
+ const otlpSpans = batch.map((s) => ({
495
+ traceId: s.traceId,
496
+ spanId: s.spanId,
497
+ parentSpanId: s.parentSpanId,
498
+ name: s.name,
499
+ kind: SPAN_KIND_INTERNAL,
500
+ startTimeUnixNano: s.startTimeUnixNano,
501
+ endTimeUnixNano: s.endTimeUnixNano,
502
+ attributes: attrsToOtlp(s.attributes),
503
+ status: {
504
+ code: s.status.code,
505
+ ...(s.status.message ? { message: s.status.message } : {}),
506
+ },
507
+ events: s.events.map((e) => ({
508
+ timeUnixNano: e.timeUnixNano,
509
+ name: e.name,
510
+ attributes: attrsToOtlp(e.attributes),
511
+ })),
512
+ }));
513
+
514
+ const resourceAttrs: Array<{ key: string; value: { stringValue: string } }> = [];
515
+ for (const k of Object.keys(opts.resourceAttributes).sort()) {
516
+ resourceAttrs.push({ key: k, value: { stringValue: opts.resourceAttributes[k] } });
517
+ }
518
+
519
+ return {
520
+ resourceSpans: [
521
+ {
522
+ resource: { attributes: resourceAttrs },
523
+ scopeSpans: [
524
+ {
525
+ scope: { name: opts.scopeName, version: opts.scopeVersion },
526
+ spans: otlpSpans,
527
+ },
528
+ ],
529
+ },
530
+ ],
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Test seam: internals exposed for unit tests only. Never use from app
536
+ * code — the surface is unstable and might change between minor releases.
537
+ */
538
+ export const _internals = {
539
+ parseTraceparent,
540
+ shouldSampleTrace,
541
+ newTraceId,
542
+ newSpanId,
543
+ };