@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- 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
|
+
};
|