@decocms/start 2.28.2 → 2.29.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,135 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createAnalyticsEngineMeterAdapter,
4
+ createOtelLoggerAdapter,
5
+ createOtelMeterAdapter,
6
+ setRuntimeEnv,
7
+ } from "./otelAdapters";
8
+ import { RequestContext } from "./requestContext";
9
+
10
+ describe("createOtelLoggerAdapter / createOtelMeterAdapter", () => {
11
+ afterEach(() => vi.restoreAllMocks());
12
+
13
+ it("returns null when no endpoint is provided (no-op safe)", () => {
14
+ expect(createOtelLoggerAdapter(null)).toBeNull();
15
+ expect(createOtelLoggerAdapter({ endpoint: "" })).toBeNull();
16
+ expect(createOtelMeterAdapter(null)).toBeNull();
17
+ });
18
+
19
+ it("constructs without throwing when an endpoint is provided", () => {
20
+ // We don't want this test to actually open a network connection.
21
+ // The exporters lazy-initialize their HTTP client and only POST on
22
+ // batch flush, so simply constructing must not throw.
23
+ const log = createOtelLoggerAdapter({
24
+ endpoint: "https://otel.example.invalid",
25
+ headers: { authorization: "test" },
26
+ name: "test-svc",
27
+ });
28
+ expect(log).not.toBeNull();
29
+ expect(typeof log!.log).toBe("function");
30
+
31
+ const meter = createOtelMeterAdapter({
32
+ endpoint: "https://otel.example.invalid",
33
+ headers: { authorization: "test" },
34
+ exportIntervalMillis: 60_000,
35
+ name: "test-svc",
36
+ });
37
+ expect(meter).not.toBeNull();
38
+ expect(typeof meter!.counterInc).toBe("function");
39
+ expect(typeof meter!.histogramRecord).toBe("function");
40
+ });
41
+
42
+ it("logs without throwing for a variety of attribute shapes", () => {
43
+ const log = createOtelLoggerAdapter({ endpoint: "https://otel.example.invalid" });
44
+ expect(log).not.toBeNull();
45
+ expect(() =>
46
+ log!.log("info", "ok", {
47
+ s: "x",
48
+ n: 1,
49
+ b: true,
50
+ arr: [1, "two", true],
51
+ nested: { a: 1, b: { c: "d" } },
52
+ nullV: null,
53
+ undef: undefined,
54
+ fn: () => {},
55
+ sym: Symbol("s") as unknown as string,
56
+ }),
57
+ ).not.toThrow();
58
+ });
59
+ });
60
+
61
+ describe("createAnalyticsEngineMeterAdapter", () => {
62
+ afterEach(() => vi.restoreAllMocks());
63
+
64
+ it("no-ops when binding is missing (does not throw)", () => {
65
+ const meter = createAnalyticsEngineMeterAdapter({ bindingName: "MISSING" });
66
+ expect(() => meter.counterInc("foo", 1)).not.toThrow();
67
+ expect(() => meter.gaugeSet?.("bar", 5)).not.toThrow();
68
+ expect(() => meter.histogramRecord?.("baz", 100)).not.toThrow();
69
+ });
70
+
71
+ it("writes one data point per call to the resolved binding", () => {
72
+ const writeDataPoint = vi.fn();
73
+ const binding = { writeDataPoint };
74
+
75
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
76
+
77
+ meter.counterInc("http_requests_total", 1, { method: "GET", path: "/", status: 200 });
78
+ meter.histogramRecord?.("http_request_duration_ms", 150, {
79
+ method: "GET",
80
+ path: "/",
81
+ status: 200,
82
+ });
83
+ meter.gaugeSet?.("custom_gauge", 7, { region: "gru" });
84
+
85
+ expect(writeDataPoint).toHaveBeenCalledTimes(3);
86
+
87
+ // For HTTP request metrics, indexes[0] must be the path.
88
+ expect(writeDataPoint.mock.calls[0]?.[0].indexes).toEqual(["/"]);
89
+ expect(writeDataPoint.mock.calls[0]?.[0].doubles).toEqual([1]);
90
+ expect(writeDataPoint.mock.calls[0]?.[0].blobs?.[0]).toBe("http_requests_total");
91
+
92
+ expect(writeDataPoint.mock.calls[1]?.[0].indexes).toEqual(["/"]);
93
+ expect(writeDataPoint.mock.calls[1]?.[0].doubles).toEqual([150]);
94
+
95
+ // Non-HTTP metrics fall back to the metric name as the index.
96
+ expect(writeDataPoint.mock.calls[2]?.[0].indexes).toEqual(["custom_gauge"]);
97
+ expect(writeDataPoint.mock.calls[2]?.[0].doubles).toEqual([7]);
98
+ });
99
+
100
+ it("resolves binding via RequestContext when not provided directly", () => {
101
+ const writeDataPoint = vi.fn();
102
+ const env = { DECO_METRICS: { writeDataPoint } };
103
+
104
+ RequestContext.run(new Request("https://x.example/"), () => {
105
+ setRuntimeEnv(env);
106
+ const meter = createAnalyticsEngineMeterAdapter();
107
+ // Use a known HTTP metric name so path is promoted to index[0].
108
+ meter.counterInc("http_requests_total", 1, { path: "/x" });
109
+ expect(writeDataPoint).toHaveBeenCalledOnce();
110
+ expect(writeDataPoint.mock.calls[0]?.[0].indexes).toEqual(["/x"]);
111
+ });
112
+ });
113
+
114
+ it("never throws when binding throws", () => {
115
+ const binding = {
116
+ writeDataPoint: () => {
117
+ throw new Error("AE down");
118
+ },
119
+ };
120
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
121
+ expect(() => meter.counterInc("x", 1)).not.toThrow();
122
+ });
123
+
124
+ it("orders blob columns deterministically by sorted label keys", () => {
125
+ const writeDataPoint = vi.fn();
126
+ const binding = { writeDataPoint };
127
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
128
+
129
+ meter.counterInc("metric", 1, { z: "last", a: "first", m: "mid" });
130
+
131
+ const blobs = writeDataPoint.mock.calls[0]?.[0].blobs as string[];
132
+ // blobs[0] is metric name; remaining must follow sorted label-key order: a, m, z
133
+ expect(blobs).toEqual(["metric", "first", "mid", "last"]);
134
+ });
135
+ });
@@ -0,0 +1,401 @@
1
+ /**
2
+ * OpenTelemetry adapter implementations for `@decocms/start`.
3
+ *
4
+ * These adapters plug into the framework's existing pluggable interfaces:
5
+ * - `LoggerAdapter` (from `./logger`) ← OTLP logs
6
+ * - `MeterAdapter` (from `../middleware/observability`) ← OTLP metrics
7
+ * - `MeterAdapter` ← Cloudflare Workers Analytics Engine
8
+ *
9
+ * Mirrors the metrics views and TTLs from `deco-cx/deco`'s
10
+ * `observability/otel/metrics.ts` so dashboards built against the Fresh
11
+ * stack keep working after the migration.
12
+ *
13
+ * All three adapters are no-op safe: missing env vars / missing AE binding
14
+ * means the adapter does nothing rather than throwing. The fan-out wrapper
15
+ * in `./composite` provides additional try/catch isolation.
16
+ */
17
+
18
+ import {
19
+ type Counter,
20
+ type Histogram,
21
+ metrics as metricsApi,
22
+ type ObservableGauge,
23
+ } from "@opentelemetry/api";
24
+ import { type AnyValue, logs as logsApi, SeverityNumber } from "@opentelemetry/api-logs";
25
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
26
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
27
+ import type { Resource } from "@opentelemetry/resources";
28
+ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
29
+ import {
30
+ type AggregationOption,
31
+ AggregationType,
32
+ InstrumentType,
33
+ MeterProvider,
34
+ PeriodicExportingMetricReader,
35
+ type ViewOptions,
36
+ } from "@opentelemetry/sdk-metrics";
37
+ import type { MeterAdapter } from "../middleware/observability";
38
+ import { MetricNames } from "../middleware/observability";
39
+ import type { LoggerAdapter, LogLevel } from "./logger";
40
+ import { RequestContext } from "./requestContext";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Env / binding access
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Per-request access to the Cloudflare Worker `env` bag. Stashed by
48
+ * `instrumentWorker()` at the top of every request via
49
+ * `RequestContext.setBag("__deco_env", env)`.
50
+ *
51
+ * Adapters use this to look up the AE binding (or any future binding-driven
52
+ * destination) without forcing site code to thread `env` through every
53
+ * call site.
54
+ */
55
+ const ENV_BAG_KEY = "__deco_env";
56
+
57
+ export function setRuntimeEnv(env: Record<string, unknown>): void {
58
+ RequestContext.setBag(ENV_BAG_KEY, env);
59
+ }
60
+
61
+ export function getRuntimeEnv(): Record<string, unknown> | undefined {
62
+ return RequestContext.getBag<Record<string, unknown>>(ENV_BAG_KEY);
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // OtelLoggerAdapter
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const SEVERITY: Record<LogLevel, { number: SeverityNumber; text: string }> = {
70
+ debug: { number: SeverityNumber.DEBUG, text: "DEBUG" },
71
+ info: { number: SeverityNumber.INFO, text: "INFO" },
72
+ warn: { number: SeverityNumber.WARN, text: "WARN" },
73
+ error: { number: SeverityNumber.ERROR, text: "ERROR" },
74
+ };
75
+
76
+ export interface OtelLoggerAdapterOptions {
77
+ endpoint: string;
78
+ headers?: Record<string, string>;
79
+ resource?: Resource;
80
+ /** OTel logger name. Defaults to "@decocms/start". */
81
+ name?: string;
82
+ }
83
+
84
+ /**
85
+ * Streams `logger.*` calls to an OTLP/HTTP logs endpoint (e.g. HyperDX).
86
+ *
87
+ * Returns `null` when no endpoint is configured — `instrumentWorker()`
88
+ * uses that signal to skip registering this adapter.
89
+ */
90
+ export function createOtelLoggerAdapter(
91
+ options: OtelLoggerAdapterOptions | null,
92
+ ): LoggerAdapter | null {
93
+ if (!options || !options.endpoint) return null;
94
+
95
+ const exporter = new OTLPLogExporter({
96
+ url: joinPath(options.endpoint, "/v1/logs"),
97
+ headers: options.headers,
98
+ });
99
+
100
+ const provider = new LoggerProvider({
101
+ resource: options.resource,
102
+ });
103
+ provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));
104
+
105
+ // Register globally so `@opentelemetry/api-logs` consumers (if any)
106
+ // also pick it up. Idempotent — safe across multiple worker reloads.
107
+ try {
108
+ logsApi.setGlobalLoggerProvider(provider);
109
+ } catch {
110
+ /* already set */
111
+ }
112
+
113
+ const otelLogger = provider.getLogger(options.name ?? "@decocms/start");
114
+
115
+ return {
116
+ log(level, msg, attrs) {
117
+ const sev = SEVERITY[level];
118
+ otelLogger.emit({
119
+ severityNumber: sev.number,
120
+ severityText: sev.text,
121
+ body: msg,
122
+ attributes: attrs ? sanitizeAttributes(attrs) : undefined,
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Coerce an arbitrary `Record<string, unknown>` into the strict `AnyValueMap`
130
+ * the OTel logs API expects. Drops anything that can't be safely
131
+ * represented (functions, symbols, circular structures via stringify guard).
132
+ */
133
+ function sanitizeAttributes(attrs: Record<string, unknown>): Record<string, AnyValue> {
134
+ const out: Record<string, AnyValue> = {};
135
+ for (const [k, v] of Object.entries(attrs)) {
136
+ out[k] = toAnyValue(v);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function toAnyValue(v: unknown): AnyValue {
142
+ if (v === null || v === undefined) return undefined;
143
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
144
+ if (v instanceof Uint8Array) return v;
145
+ if (Array.isArray(v)) return v.map(toAnyValue);
146
+ if (typeof v === "object") {
147
+ const out: Record<string, AnyValue> = {};
148
+ for (const [k, vv] of Object.entries(v as Record<string, unknown>)) {
149
+ out[k] = toAnyValue(vv);
150
+ }
151
+ return out;
152
+ }
153
+ // Functions, symbols, bigint — stringify so the operator still sees something.
154
+ try {
155
+ return String(v);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // OtelMeterAdapter
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Histogram bucket boundaries for millisecond timings.
167
+ * Mirrors `deco-cx/deco/observability/otel/metrics.ts` so HyperDX
168
+ * panels built off the Fresh stack keep working unchanged.
169
+ */
170
+ const MS_BOUNDARIES = [10, 100, 500, 1000, 5000, 10000, 15000];
171
+ /** Histogram bucket boundaries for second timings. */
172
+ const SECONDS_BOUNDARIES = [1, 5, 10, 50];
173
+
174
+ export interface OtelMeterAdapterOptions {
175
+ endpoint: string;
176
+ headers?: Record<string, string>;
177
+ resource?: Resource;
178
+ /** Push interval in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
179
+ exportIntervalMillis?: number;
180
+ /** OTel meter name. Defaults to "@decocms/start". */
181
+ name?: string;
182
+ }
183
+
184
+ /**
185
+ * Streams metric writes to an OTLP/HTTP metrics endpoint with a
186
+ * `PeriodicExportingMetricReader`.
187
+ *
188
+ * Two histogram views are pre-registered:
189
+ * - any metric ending with `_ms` → millisecond bucket boundaries
190
+ * - any metric ending with `_s` → second bucket boundaries
191
+ *
192
+ * Returns `null` when no endpoint is configured.
193
+ */
194
+ export function createOtelMeterAdapter(
195
+ options: OtelMeterAdapterOptions | null,
196
+ ): MeterAdapter | null {
197
+ if (!options || !options.endpoint) return null;
198
+
199
+ const exporter = new OTLPMetricExporter({
200
+ url: joinPath(options.endpoint, "/v1/metrics"),
201
+ headers: options.headers,
202
+ });
203
+
204
+ const reader = new PeriodicExportingMetricReader({
205
+ exporter,
206
+ exportIntervalMillis: options.exportIntervalMillis ?? 60_000,
207
+ });
208
+
209
+ const histogramAggregation = (boundaries: number[]): AggregationOption => ({
210
+ type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
211
+ options: { boundaries, recordMinMax: true },
212
+ });
213
+
214
+ const views: ViewOptions[] = [
215
+ {
216
+ instrumentName: "*_ms",
217
+ instrumentType: InstrumentType.HISTOGRAM,
218
+ aggregation: histogramAggregation(MS_BOUNDARIES),
219
+ },
220
+ {
221
+ instrumentName: "*_s",
222
+ instrumentType: InstrumentType.HISTOGRAM,
223
+ aggregation: histogramAggregation(SECONDS_BOUNDARIES),
224
+ },
225
+ ];
226
+
227
+ const provider = new MeterProvider({
228
+ resource: options.resource,
229
+ readers: [reader],
230
+ views,
231
+ });
232
+
233
+ try {
234
+ metricsApi.setGlobalMeterProvider(provider);
235
+ } catch {
236
+ /* already set */
237
+ }
238
+
239
+ const meter = provider.getMeter(options.name ?? "@decocms/start");
240
+
241
+ // Lazy-create instruments by name so we don't pay the create cost on
242
+ // every recordRequestMetric / recordCacheMetric invocation.
243
+ const counters = new Map<string, Counter>();
244
+ const histograms = new Map<string, Histogram>();
245
+ const gauges = new Map<string, ObservableGauge>();
246
+ const gaugeValues = new Map<string, { value: number; labels?: Record<string, unknown> }>();
247
+
248
+ function getCounter(name: string): Counter {
249
+ let c = counters.get(name);
250
+ if (!c) {
251
+ c = meter.createCounter(name);
252
+ counters.set(name, c);
253
+ }
254
+ return c;
255
+ }
256
+ function getHistogram(name: string): Histogram {
257
+ let h = histograms.get(name);
258
+ if (!h) {
259
+ h = meter.createHistogram(name);
260
+ histograms.set(name, h);
261
+ }
262
+ return h;
263
+ }
264
+ function ensureGauge(name: string): void {
265
+ if (gauges.has(name)) return;
266
+ const g = meter.createObservableGauge(name);
267
+ g.addCallback((result) => {
268
+ const last = gaugeValues.get(name);
269
+ if (last)
270
+ result.observe(last.value, last.labels as Record<string, string | number | boolean>);
271
+ });
272
+ gauges.set(name, g);
273
+ }
274
+
275
+ return {
276
+ counterInc(name, value, labels) {
277
+ getCounter(name).add(value ?? 1, labels);
278
+ },
279
+ histogramRecord(name, value, labels) {
280
+ getHistogram(name).record(value, labels);
281
+ },
282
+ gaugeSet(name, value, labels) {
283
+ ensureGauge(name);
284
+ gaugeValues.set(name, { value, labels });
285
+ },
286
+ };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // AnalyticsEngineMeterAdapter
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Workers Analytics Engine binding shape. Defined here so we don't need
295
+ * `@cloudflare/workers-types` as a dep.
296
+ */
297
+ interface AnalyticsEngineDataset {
298
+ writeDataPoint(point: { indexes?: string[]; blobs?: string[]; doubles?: number[] }): void;
299
+ }
300
+
301
+ export interface AnalyticsEngineMeterAdapterOptions {
302
+ /** Env var name holding the AE binding. Defaults to "DECO_METRICS". */
303
+ bindingName?: string;
304
+ /** Pre-resolved binding (mainly for tests). Bypasses RequestContext lookup. */
305
+ binding?: AnalyticsEngineDataset;
306
+ }
307
+
308
+ /**
309
+ * Writes one Analytics Engine data point per metric call.
310
+ *
311
+ * Schema (must stay stable — dashboards and SQL queries depend on it):
312
+ * - `indexes[0]`: a low-cardinality dimension. For request metrics that's
313
+ * the normalized URL path; for cache/resolve metrics that's the metric
314
+ * name itself.
315
+ * - `blobs[0]`: metric name (so a single dataset can hold many metrics).
316
+ * - `blobs[1..]`: stringified label values, in stable label-name order.
317
+ * - `doubles[0]`: the metric value (count, ms, gauge value).
318
+ *
319
+ * AE truncates / charges per data point, so this adapter is intentionally
320
+ * coarse: one point per `counterInc` / `histogramRecord` / `gaugeSet`.
321
+ */
322
+ export function createAnalyticsEngineMeterAdapter(
323
+ options: AnalyticsEngineMeterAdapterOptions = {},
324
+ ): MeterAdapter {
325
+ const bindingName = options.bindingName ?? "DECO_METRICS";
326
+
327
+ function resolveBinding(): AnalyticsEngineDataset | null {
328
+ if (options.binding) return options.binding;
329
+ const env = getRuntimeEnv();
330
+ const b = env?.[bindingName] as AnalyticsEngineDataset | undefined;
331
+ return b ?? null;
332
+ }
333
+
334
+ function write(name: string, value: number, labels?: Record<string, unknown>): void {
335
+ const binding = resolveBinding();
336
+ if (!binding) return; // No-op when binding is missing — never throw.
337
+
338
+ const indexValue = pickIndex(name, labels);
339
+ const blobs: string[] = [name];
340
+ if (labels) {
341
+ // Stable order — sort keys so the same blob index always means the
342
+ // same label across requests. AE has no schema; we enforce one here.
343
+ const keys = Object.keys(labels).sort();
344
+ for (const k of keys) {
345
+ const v = labels[k];
346
+ if (v !== undefined && v !== null) blobs.push(String(v));
347
+ }
348
+ }
349
+
350
+ try {
351
+ binding.writeDataPoint({
352
+ indexes: indexValue ? [indexValue] : undefined,
353
+ blobs,
354
+ doubles: [value],
355
+ });
356
+ } catch {
357
+ /* AE write failed — never fail the request */
358
+ }
359
+ }
360
+
361
+ return {
362
+ counterInc(name, value, labels) {
363
+ write(name, value ?? 1, labels);
364
+ },
365
+ histogramRecord(name, value, labels) {
366
+ write(name, value, labels);
367
+ },
368
+ gaugeSet(name, value, labels) {
369
+ write(name, value, labels);
370
+ },
371
+ };
372
+ }
373
+
374
+ function pickIndex(metricName: string, labels?: Record<string, unknown>): string | undefined {
375
+ if (!labels) return metricName;
376
+ // For HTTP request metrics the natural index is the path (matches the
377
+ // plan: `indexes[0]=path`). For other metrics, fall back to the metric
378
+ // name so the dataset is always queryable by index.
379
+ if (
380
+ metricName === MetricNames.HTTP_REQUESTS_TOTAL ||
381
+ metricName === MetricNames.HTTP_REQUEST_DURATION_MS ||
382
+ metricName === MetricNames.HTTP_REQUEST_ERRORS
383
+ ) {
384
+ const p = labels.path;
385
+ if (typeof p === "string" && p.length > 0) return p;
386
+ }
387
+ return metricName;
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // helpers
392
+ // ---------------------------------------------------------------------------
393
+
394
+ function joinPath(base: string, path: string): string {
395
+ if (!base) return path;
396
+ if (base.endsWith("/")) base = base.slice(0, -1);
397
+ if (!path.startsWith("/")) path = "/" + path;
398
+ // Already includes the path? Don't double-append.
399
+ if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
400
+ return base + path;
401
+ }
@@ -0,0 +1,127 @@
1
+ import { ROOT_CONTEXT, SpanKind } from "@opentelemetry/api";
2
+ import {
3
+ AlwaysOnSampler,
4
+ ParentBasedSampler,
5
+ SamplingDecision,
6
+ } from "@opentelemetry/sdk-trace-base";
7
+ import { describe, expect, it } from "vitest";
8
+ import { createUrlBasedHeadSampler, decodeSamplingConfig, URLBasedSampler } from "./sampler";
9
+
10
+ const TRACE_ID = "0000000000000000ffffffffffffffff";
11
+
12
+ function decide(sampler: URLBasedSampler, path: string) {
13
+ return sampler.shouldSample(
14
+ ROOT_CONTEXT,
15
+ TRACE_ID,
16
+ "span-name",
17
+ SpanKind.SERVER,
18
+ { "url.path": path },
19
+ [],
20
+ );
21
+ }
22
+
23
+ describe("URLBasedSampler", () => {
24
+ it("falls back to default ratio when no rule matches", () => {
25
+ const s = new URLBasedSampler({ default: 1.0 });
26
+ expect(decide(s, "/anything").decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
27
+ });
28
+
29
+ it("first matching rule wins", () => {
30
+ const s = new URLBasedSampler({
31
+ default: 1.0,
32
+ rules: [
33
+ { pattern: "^/api/health", ratio: 0.0 },
34
+ { pattern: "^/api/", ratio: 1.0 },
35
+ ],
36
+ });
37
+ expect(decide(s, "/api/health").decision).toBe(SamplingDecision.NOT_RECORD);
38
+ expect(decide(s, "/api/orders").decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
39
+ });
40
+
41
+ it("falls back to default when no path attribute is present", () => {
42
+ const s = new URLBasedSampler({ default: 1.0 });
43
+ const result = s.shouldSample(ROOT_CONTEXT, TRACE_ID, "noop", SpanKind.INTERNAL, {}, []);
44
+ expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
45
+ });
46
+
47
+ it("supports url.path, http.target, and http.url", () => {
48
+ const s = new URLBasedSampler({
49
+ default: 0.0,
50
+ rules: [{ pattern: "^/wanted", ratio: 1.0 }],
51
+ });
52
+ const ok = (attrs: Record<string, string>) =>
53
+ s.shouldSample(ROOT_CONTEXT, TRACE_ID, "n", SpanKind.SERVER, attrs, []);
54
+
55
+ expect(ok({ "url.path": "/wanted/x" }).decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
56
+ expect(ok({ "http.target": "/wanted/y" }).decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
57
+ expect(ok({ "http.url": "https://h.example/wanted/z?q=1" }).decision).toBe(
58
+ SamplingDecision.RECORD_AND_SAMPLED,
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("decodeSamplingConfig", () => {
64
+ it("returns null on missing input", () => {
65
+ expect(decodeSamplingConfig(undefined)).toBeNull();
66
+ expect(decodeSamplingConfig("")).toBeNull();
67
+ });
68
+
69
+ it("decodes valid base64 JSON", () => {
70
+ const cfg = { default: 0.5, rules: [{ pattern: "^/x", ratio: 1.0 }] };
71
+ const enc = btoa(JSON.stringify(cfg));
72
+ const decoded = decodeSamplingConfig(enc);
73
+ expect(decoded).toEqual(cfg);
74
+ });
75
+
76
+ it("drops invalid rules but keeps the rest", () => {
77
+ const enc = btoa(
78
+ JSON.stringify({
79
+ default: 0.1,
80
+ rules: [
81
+ { pattern: "^/ok", ratio: 1.0 },
82
+ { pattern: "^[", ratio: 1.0 }, // invalid regex
83
+ { pattern: "^/yes", ratio: 0.5 },
84
+ { pattern: 7, ratio: 0.5 }, // wrong type
85
+ ],
86
+ }),
87
+ );
88
+ const decoded = decodeSamplingConfig(enc);
89
+ expect(decoded?.rules).toEqual([
90
+ { pattern: "^/ok", ratio: 1.0 },
91
+ { pattern: "^/yes", ratio: 0.5 },
92
+ ]);
93
+ });
94
+
95
+ it("returns null for non-JSON input", () => {
96
+ expect(decodeSamplingConfig("not-base64-not-json!!")).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("createUrlBasedHeadSampler", () => {
101
+ it("wraps the URL-based sampler in ParentBasedSampler", () => {
102
+ const sampler = createUrlBasedHeadSampler(null);
103
+ expect(sampler).toBeInstanceOf(ParentBasedSampler);
104
+ });
105
+
106
+ it("applies the default-ratio = 1.0 when config is null", () => {
107
+ // Smoke test only — sampler internals are validated above.
108
+ const sampler = createUrlBasedHeadSampler(null);
109
+ const result = sampler.shouldSample(
110
+ ROOT_CONTEXT,
111
+ TRACE_ID,
112
+ "n",
113
+ SpanKind.SERVER,
114
+ { "url.path": "/" },
115
+ [],
116
+ );
117
+ expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
118
+ });
119
+ });
120
+
121
+ describe("regression: AlwaysOnSampler still works", () => {
122
+ it("guards against accidental import-rename breakage", () => {
123
+ // If sdk-trace-base ever renames AlwaysOnSampler we want a loud failure.
124
+ const s = new AlwaysOnSampler();
125
+ expect(s.shouldSample().decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
126
+ });
127
+ });