@decocms/start 2.28.2 → 2.30.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 (49) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
  2. package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
  3. package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
  4. package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
  5. package/.cursor/rules/migration-tooling-policy.mdc +22 -2
  6. package/.github/workflows/deploy.yml +115 -0
  7. package/.github/workflows/preview.yml +143 -0
  8. package/.github/workflows/regen-blocks.yml +56 -0
  9. package/.github/workflows/release.yml +26 -0
  10. package/.github/workflows/sync-secrets.yml +173 -0
  11. package/CODEOWNERS +16 -0
  12. package/MIGRATION_TOOLING_PLAN.md +16 -4
  13. package/README.md +178 -79
  14. package/deploy/README.md +85 -0
  15. package/deploy/sites/als-tanstack.jsonc +7 -0
  16. package/deploy/sites/americanas-tanstack.jsonc +4 -0
  17. package/deploy/sites/baggagio-tanstack.jsonc +4 -0
  18. package/deploy/sites/casaevideo-storefront.jsonc +11 -0
  19. package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
  20. package/deploy/sites/miess-01-tanstack.jsonc +8 -0
  21. package/deploy/wrangler-template.jsonc +28 -0
  22. package/package.json +18 -15
  23. package/scripts/deploy/build-wrangler-config.mjs +49 -0
  24. package/scripts/deploy/jsonc.mjs +76 -0
  25. package/scripts/deploy/resolve-site.mjs +58 -0
  26. package/scripts/deploy/site-registry.mjs +142 -0
  27. package/scripts/deploy/wrangler-wrapper.mjs +126 -0
  28. package/scripts/migrate/phase-scaffold.ts +13 -3
  29. package/scripts/migrate/phase-verify.ts +6 -1
  30. package/scripts/migrate/templates/github-workflows.ts +98 -0
  31. package/scripts/migrate/templates/package-json.ts +9 -2
  32. package/src/cms/resolve.ts +81 -63
  33. package/src/cms/sectionLoaders.ts +11 -0
  34. package/src/index.ts +3 -0
  35. package/src/sdk/cachedLoader.ts +36 -13
  36. package/src/sdk/composite.test.ts +121 -0
  37. package/src/sdk/composite.ts +114 -0
  38. package/src/sdk/instrumentedFetch.ts +56 -0
  39. package/src/sdk/logger.test.ts +135 -0
  40. package/src/sdk/logger.ts +166 -0
  41. package/src/sdk/observability.ts +75 -0
  42. package/src/sdk/otel.test.ts +59 -0
  43. package/src/sdk/otel.ts +270 -29
  44. package/src/sdk/otelAdapters.test.ts +135 -0
  45. package/src/sdk/otelAdapters.ts +401 -0
  46. package/src/sdk/sampler.test.ts +127 -0
  47. package/src/sdk/sampler.ts +183 -0
  48. package/src/sdk/workerEntry.ts +541 -476
  49. package/scripts/migrate/templates/wrangler.ts +0 -30
@@ -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
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * URL-based head sampler — port of `deco-cx/deco/observability/otel/samplers/urlBased.ts`.
3
+ *
4
+ * Lets ops dial sampling rates per URL pattern without redeploying. Reads
5
+ * `OTEL_SAMPLING_CONFIG` (base64-encoded JSON) at boot and decides each
6
+ * trace's sample rate based on the matching pattern.
7
+ *
8
+ * Wrapped in `ParentBasedSampler` so a span inherits its parent's sampling
9
+ * decision when one exists (i.e. distributed traces are kept consistent end
10
+ * to end).
11
+ *
12
+ * @example
13
+ * ```jsonc
14
+ * // base64-encode this and set as OTEL_SAMPLING_CONFIG:
15
+ * {
16
+ * "default": 0.05,
17
+ * "rules": [
18
+ * { "pattern": "^/checkout", "ratio": 1.0 },
19
+ * { "pattern": "^/api/health", "ratio": 0.0 },
20
+ * { "pattern": "/p$", "ratio": 0.1 }
21
+ * ]
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ import { type Attributes, type Context, type Link, type SpanKind, trace } from "@opentelemetry/api";
27
+ import {
28
+ AlwaysOffSampler,
29
+ AlwaysOnSampler,
30
+ ParentBasedSampler,
31
+ type Sampler,
32
+ type SamplingResult,
33
+ TraceIdRatioBasedSampler,
34
+ } from "@opentelemetry/sdk-trace-base";
35
+
36
+ export interface SamplingRule {
37
+ /** ECMA RegExp pattern matched against the URL path. */
38
+ pattern: string;
39
+ /** Ratio in [0, 1]. */
40
+ ratio: number;
41
+ }
42
+
43
+ export interface SamplingConfig {
44
+ /** Default sample ratio applied when no rule matches. Defaults to 1.0 (always sample). */
45
+ default?: number;
46
+ /** Ordered list of rules. First match wins. */
47
+ rules?: SamplingRule[];
48
+ }
49
+
50
+ interface CompiledRule {
51
+ re: RegExp;
52
+ sampler: Sampler;
53
+ }
54
+
55
+ /**
56
+ * URL-pattern-driven head sampler. Implements the OTel `Sampler` interface
57
+ * directly so it can be plugged into `ParentBasedSampler`'s `root` slot.
58
+ */
59
+ export class URLBasedSampler implements Sampler {
60
+ private readonly defaultSampler: Sampler;
61
+ private readonly rules: CompiledRule[];
62
+
63
+ constructor(config: SamplingConfig = {}) {
64
+ this.defaultSampler = ratioToSampler(config.default ?? 1.0);
65
+ this.rules = (config.rules ?? []).map((rule) => ({
66
+ re: new RegExp(rule.pattern),
67
+ sampler: ratioToSampler(rule.ratio),
68
+ }));
69
+ }
70
+
71
+ shouldSample(
72
+ context: Context,
73
+ traceId: string,
74
+ spanName: string,
75
+ spanKind: SpanKind,
76
+ attributes: Attributes,
77
+ links: Link[],
78
+ ): SamplingResult {
79
+ const path = extractPath(attributes);
80
+ if (path) {
81
+ for (const rule of this.rules) {
82
+ if (rule.re.test(path)) {
83
+ return rule.sampler.shouldSample(context, traceId, spanName, spanKind, attributes, links);
84
+ }
85
+ }
86
+ }
87
+ return this.defaultSampler.shouldSample(
88
+ context,
89
+ traceId,
90
+ spanName,
91
+ spanKind,
92
+ attributes,
93
+ links,
94
+ );
95
+ }
96
+
97
+ toString(): string {
98
+ return `URLBasedSampler(${this.rules.length} rules)`;
99
+ }
100
+ }
101
+
102
+ function ratioToSampler(ratio: number): Sampler {
103
+ if (ratio >= 1) return new AlwaysOnSampler();
104
+ if (ratio <= 0) return new AlwaysOffSampler();
105
+ return new TraceIdRatioBasedSampler(ratio);
106
+ }
107
+
108
+ function extractPath(attrs: Attributes): string | null {
109
+ // Prefer the OTel-standard `url.path` (semconv >= 1.21), fall back to
110
+ // legacy `http.target` and `http.url`.
111
+ const direct = attrs["url.path"] ?? attrs["http.target"];
112
+ if (typeof direct === "string") return direct;
113
+
114
+ const httpUrl = attrs["http.url"];
115
+ if (typeof httpUrl === "string") {
116
+ try {
117
+ return new URL(httpUrl).pathname;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Boot helpers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Decode a base64-encoded `OTEL_SAMPLING_CONFIG` value into a `SamplingConfig`.
131
+ * Returns `null` (caller falls back to default ratio 1.0) on:
132
+ * - missing / empty input
133
+ * - invalid base64
134
+ * - JSON parse failure
135
+ * - schema-mismatched payload
136
+ *
137
+ * Logs a warning to console when the env var is set but unparseable so the
138
+ * mistake is visible in CF Logs without crashing the worker boot.
139
+ */
140
+ export function decodeSamplingConfig(raw: string | undefined): SamplingConfig | null {
141
+ if (!raw) return null;
142
+ try {
143
+ const json = atob(raw);
144
+ const parsed = JSON.parse(json) as unknown;
145
+ if (!parsed || typeof parsed !== "object") return null;
146
+ const obj = parsed as { default?: unknown; rules?: unknown };
147
+
148
+ const defaultRatio = typeof obj.default === "number" ? obj.default : undefined;
149
+ const rawRules = Array.isArray(obj.rules) ? obj.rules : [];
150
+ const rules: SamplingRule[] = [];
151
+ for (const r of rawRules) {
152
+ if (!r || typeof r !== "object") continue;
153
+ const rec = r as { pattern?: unknown; ratio?: unknown };
154
+ if (typeof rec.pattern !== "string" || typeof rec.ratio !== "number") continue;
155
+ try {
156
+ // Eagerly validate the regex so a bad pattern fails at boot, not
157
+ // on the first matching request.
158
+ new RegExp(rec.pattern);
159
+ rules.push({ pattern: rec.pattern, ratio: rec.ratio });
160
+ } catch {
161
+ console.warn(`[sampler] dropping invalid pattern: ${rec.pattern}`);
162
+ }
163
+ }
164
+
165
+ return { default: defaultRatio, rules };
166
+ } catch (err) {
167
+ console.warn(`[sampler] failed to decode OTEL_SAMPLING_CONFIG`, String(err));
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Build a `ParentBasedSampler` rooted at our URL-based sampler.
174
+ * Use as the `headSampler` for `@microlabs/otel-cf-workers`.
175
+ */
176
+ export function createUrlBasedHeadSampler(config: SamplingConfig | null): Sampler {
177
+ const root = new URLBasedSampler(config ?? {});
178
+ return new ParentBasedSampler({ root });
179
+ }
180
+
181
+ // Re-export OTel API helper so callers can read `traceId` / build tags off
182
+ // the active span without importing @opentelemetry/api directly.
183
+ export { trace as _otelTrace };