@decocms/start 4.6.0 → 5.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.
@@ -1,165 +0,0 @@
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 {
9
- createUrlBasedHeadSampler,
10
- DEFAULT_SAMPLE_RATIO,
11
- decodeSamplingConfig,
12
- URLBasedSampler,
13
- } from "./sampler";
14
-
15
- // Two trace IDs at opposite ends of the TraceIdRatioBased accumulator.
16
- // `accumulate(traceId)` xors the trace ID in 8-hex-char chunks; threshold at
17
- // ratio R is `floor(R * 0xffffffff)`. See
18
- // `@opentelemetry/sdk-trace-base/src/sampler/TraceIdRatioBasedSampler`.
19
- //
20
- // LOW: 0x00000000 ^ 0x00000000 ^ 0xffffffff ^ 0xffffffff = 0
21
- // → 0 < threshold for any ratio > 0 → SAMPLED at any ratio > 0
22
- // HIGH: 0xffffffff ^ 0x00000000 ^ 0x00000000 ^ 0x00000000 = 0xffffffff
23
- // → never below threshold for ratio < 1.0 → DROPPED at any ratio < 1
24
- const LOW_TRACE_ID = "0000000000000000ffffffffffffffff";
25
- const HIGH_TRACE_ID = "ffffffff000000000000000000000000";
26
-
27
- function decide(sampler: URLBasedSampler, path: string, traceId = LOW_TRACE_ID) {
28
- return sampler.shouldSample(
29
- ROOT_CONTEXT,
30
- traceId,
31
- "span-name",
32
- SpanKind.SERVER,
33
- { "url.path": path },
34
- [],
35
- );
36
- }
37
-
38
- describe("URLBasedSampler", () => {
39
- it("exposes 0.1 as the framework-wide default sample ratio", () => {
40
- expect(DEFAULT_SAMPLE_RATIO).toBe(0.1);
41
- });
42
-
43
- it("defaults to DEFAULT_SAMPLE_RATIO (0.1) when config omits `default`", () => {
44
- const s = new URLBasedSampler();
45
- // LOW_TRACE_ID accumulates to 0 — sampled at any ratio > 0, including 0.1.
46
- expect(decide(s, "/anything").decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
47
- // HIGH_TRACE_ID accumulates to ~uint32 max — dropped at 0.1, would be
48
- // kept only at ratio ~= 1. This is the assertion that catches an
49
- // accidental revert to the old `?? 1.0` fallback.
50
- expect(decide(s, "/anything", HIGH_TRACE_ID).decision).toBe(SamplingDecision.NOT_RECORD);
51
- });
52
-
53
- it("explicit `default: 1` opts in to AlwaysOn (records every trace)", () => {
54
- const s = new URLBasedSampler({ default: 1 });
55
- expect(decide(s, "/anything", HIGH_TRACE_ID).decision).toBe(
56
- SamplingDecision.RECORD_AND_SAMPLED,
57
- );
58
- });
59
-
60
- it("falls back to provided default ratio when no rule matches", () => {
61
- const s = new URLBasedSampler({ default: 1.0 });
62
- expect(decide(s, "/anything").decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
63
- });
64
-
65
- it("first matching rule wins", () => {
66
- const s = new URLBasedSampler({
67
- default: 1.0,
68
- rules: [
69
- { pattern: "^/api/health", ratio: 0.0 },
70
- { pattern: "^/api/", ratio: 1.0 },
71
- ],
72
- });
73
- expect(decide(s, "/api/health").decision).toBe(SamplingDecision.NOT_RECORD);
74
- expect(decide(s, "/api/orders").decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
75
- });
76
-
77
- it("falls back to default when no path attribute is present", () => {
78
- const s = new URLBasedSampler({ default: 1.0 });
79
- const result = s.shouldSample(ROOT_CONTEXT, LOW_TRACE_ID, "noop", SpanKind.INTERNAL, {}, []);
80
- expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
81
- });
82
-
83
- it("supports url.path, http.target, and http.url", () => {
84
- const s = new URLBasedSampler({
85
- default: 0.0,
86
- rules: [{ pattern: "^/wanted", ratio: 1.0 }],
87
- });
88
- const ok = (attrs: Record<string, string>) =>
89
- s.shouldSample(ROOT_CONTEXT, LOW_TRACE_ID, "n", SpanKind.SERVER, attrs, []);
90
-
91
- expect(ok({ "url.path": "/wanted/x" }).decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
92
- expect(ok({ "http.target": "/wanted/y" }).decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
93
- expect(ok({ "http.url": "https://h.example/wanted/z?q=1" }).decision).toBe(
94
- SamplingDecision.RECORD_AND_SAMPLED,
95
- );
96
- });
97
- });
98
-
99
- describe("decodeSamplingConfig", () => {
100
- it("returns null on missing input", () => {
101
- expect(decodeSamplingConfig(undefined)).toBeNull();
102
- expect(decodeSamplingConfig("")).toBeNull();
103
- });
104
-
105
- it("decodes valid base64 JSON", () => {
106
- const cfg = { default: 0.5, rules: [{ pattern: "^/x", ratio: 1.0 }] };
107
- const enc = btoa(JSON.stringify(cfg));
108
- const decoded = decodeSamplingConfig(enc);
109
- expect(decoded).toEqual(cfg);
110
- });
111
-
112
- it("drops invalid rules but keeps the rest", () => {
113
- const enc = btoa(
114
- JSON.stringify({
115
- default: 0.1,
116
- rules: [
117
- { pattern: "^/ok", ratio: 1.0 },
118
- { pattern: "^[", ratio: 1.0 }, // invalid regex
119
- { pattern: "^/yes", ratio: 0.5 },
120
- { pattern: 7, ratio: 0.5 }, // wrong type
121
- ],
122
- }),
123
- );
124
- const decoded = decodeSamplingConfig(enc);
125
- expect(decoded?.rules).toEqual([
126
- { pattern: "^/ok", ratio: 1.0 },
127
- { pattern: "^/yes", ratio: 0.5 },
128
- ]);
129
- });
130
-
131
- it("returns null for non-JSON input", () => {
132
- expect(decodeSamplingConfig("not-base64-not-json!!")).toBeNull();
133
- });
134
- });
135
-
136
- describe("createUrlBasedHeadSampler", () => {
137
- it("wraps the URL-based sampler in ParentBasedSampler", () => {
138
- const sampler = createUrlBasedHeadSampler(null);
139
- expect(sampler).toBeInstanceOf(ParentBasedSampler);
140
- });
141
-
142
- it("applies DEFAULT_SAMPLE_RATIO when config is null", () => {
143
- const sampler = createUrlBasedHeadSampler(null);
144
- // High-accumulating trace ID is dropped at 0.1 — proves the ParentBased
145
- // wrapper inherits the URLBasedSampler default and isn't accidentally
146
- // forcing AlwaysOn.
147
- const result = sampler.shouldSample(
148
- ROOT_CONTEXT,
149
- HIGH_TRACE_ID,
150
- "n",
151
- SpanKind.SERVER,
152
- { "url.path": "/" },
153
- [],
154
- );
155
- expect(result.decision).toBe(SamplingDecision.NOT_RECORD);
156
- });
157
- });
158
-
159
- describe("regression: AlwaysOnSampler still works", () => {
160
- it("guards against accidental import-rename breakage", () => {
161
- // If sdk-trace-base ever renames AlwaysOnSampler we want a loud failure.
162
- const s = new AlwaysOnSampler();
163
- expect(s.shouldSample().decision).toBe(SamplingDecision.RECORD_AND_SAMPLED);
164
- });
165
- });
@@ -1,213 +0,0 @@
1
- /**
2
- * URL-based head sampler — port of `deco-cx/deco/observability/otel/samplers/urlBased.ts`.
3
- *
4
- * **No longer wired into `instrumentWorker` by default.** As of 4.4.0 the
5
- * recommended path for trace sampling is Cloudflare's wrangler-level
6
- * `observability.traces.head_sampling_rate`, which is one global rate per
7
- * Worker. This module stays as an opt-in escape hatch for sites that need
8
- * URL-pattern-aware sampling (e.g. always trace `/checkout`, sample
9
- * homepages at 1%) — those sites must wire OTel themselves outside the
10
- * default `instrumentWorker` flow.
11
- *
12
- * The sampler reads `OTEL_SAMPLING_CONFIG` (base64-encoded JSON) and
13
- * decides each trace's sample rate based on the matching pattern.
14
- *
15
- * Wrapped in `ParentBasedSampler` so a span inherits its parent's sampling
16
- * decision when one exists (i.e. distributed traces stay consistent end
17
- * to end).
18
- *
19
- * **Default ratio.** When no `default` is provided in the config (or the env
20
- * var is unset entirely), the sampler keeps **10%** of traces. Production
21
- * storefront traffic at full sampling will burn HyperDX ingest quotas
22
- * quickly — for an unconfigured site we'd rather drop 90% than overspend by
23
- * default. Sites that genuinely want every trace recorded must opt-in
24
- * explicitly with `OTEL_SAMPLING_CONFIG` setting `default: 1`.
25
- *
26
- * @example
27
- * ```jsonc
28
- * // base64-encode this and set as OTEL_SAMPLING_CONFIG:
29
- * {
30
- * "default": 0.05,
31
- * "rules": [
32
- * { "pattern": "^/checkout", "ratio": 1.0 },
33
- * { "pattern": "^/api/health", "ratio": 0.0 },
34
- * { "pattern": "/p$", "ratio": 0.1 }
35
- * ]
36
- * }
37
- * ```
38
- *
39
- * @deprecated Slated for removal in 5.0.0 unless a site declares an active
40
- * need. Use Cloudflare's `head_sampling_rate` first.
41
- */
42
-
43
- import { type Attributes, type Context, type Link, type SpanKind, trace } from "@opentelemetry/api";
44
- import {
45
- AlwaysOffSampler,
46
- AlwaysOnSampler,
47
- ParentBasedSampler,
48
- type Sampler,
49
- type SamplingResult,
50
- TraceIdRatioBasedSampler,
51
- } from "@opentelemetry/sdk-trace-base";
52
-
53
- export interface SamplingRule {
54
- /** ECMA RegExp pattern matched against the URL path. */
55
- pattern: string;
56
- /** Ratio in [0, 1]. */
57
- ratio: number;
58
- }
59
-
60
- export interface SamplingConfig {
61
- /**
62
- * Default sample ratio applied when no rule matches. Defaults to **0.1**
63
- * (10% sampled) when omitted. Set to `1` to record every trace
64
- * (only do this when you need a full debug stream and accept the cost).
65
- */
66
- default?: number;
67
- /** Ordered list of rules. First match wins. */
68
- rules?: SamplingRule[];
69
- }
70
-
71
- /**
72
- * Default ratio applied by `URLBasedSampler` when neither the config nor a
73
- * matching rule specifies one. Centralised so tests + docs share a single
74
- * source of truth.
75
- */
76
- export const DEFAULT_SAMPLE_RATIO = 0.1;
77
-
78
- interface CompiledRule {
79
- re: RegExp;
80
- sampler: Sampler;
81
- }
82
-
83
- /**
84
- * URL-pattern-driven head sampler. Implements the OTel `Sampler` interface
85
- * directly so it can be plugged into `ParentBasedSampler`'s `root` slot.
86
- */
87
- export class URLBasedSampler implements Sampler {
88
- private readonly defaultSampler: Sampler;
89
- private readonly rules: CompiledRule[];
90
-
91
- constructor(config: SamplingConfig = {}) {
92
- this.defaultSampler = ratioToSampler(config.default ?? DEFAULT_SAMPLE_RATIO);
93
- this.rules = (config.rules ?? []).map((rule) => ({
94
- re: new RegExp(rule.pattern),
95
- sampler: ratioToSampler(rule.ratio),
96
- }));
97
- }
98
-
99
- shouldSample(
100
- context: Context,
101
- traceId: string,
102
- spanName: string,
103
- spanKind: SpanKind,
104
- attributes: Attributes,
105
- links: Link[],
106
- ): SamplingResult {
107
- const path = extractPath(attributes);
108
- if (path) {
109
- for (const rule of this.rules) {
110
- if (rule.re.test(path)) {
111
- return rule.sampler.shouldSample(context, traceId, spanName, spanKind, attributes, links);
112
- }
113
- }
114
- }
115
- return this.defaultSampler.shouldSample(
116
- context,
117
- traceId,
118
- spanName,
119
- spanKind,
120
- attributes,
121
- links,
122
- );
123
- }
124
-
125
- toString(): string {
126
- return `URLBasedSampler(${this.rules.length} rules)`;
127
- }
128
- }
129
-
130
- function ratioToSampler(ratio: number): Sampler {
131
- if (ratio >= 1) return new AlwaysOnSampler();
132
- if (ratio <= 0) return new AlwaysOffSampler();
133
- return new TraceIdRatioBasedSampler(ratio);
134
- }
135
-
136
- function extractPath(attrs: Attributes): string | null {
137
- // Prefer the OTel-standard `url.path` (semconv >= 1.21), fall back to
138
- // legacy `http.target` and `http.url`.
139
- const direct = attrs["url.path"] ?? attrs["http.target"];
140
- if (typeof direct === "string") return direct;
141
-
142
- const httpUrl = attrs["http.url"];
143
- if (typeof httpUrl === "string") {
144
- try {
145
- return new URL(httpUrl).pathname;
146
- } catch {
147
- return null;
148
- }
149
- }
150
- return null;
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // Boot helpers
155
- // ---------------------------------------------------------------------------
156
-
157
- /**
158
- * Decode a base64-encoded `OTEL_SAMPLING_CONFIG` value into a `SamplingConfig`.
159
- * Returns `null` (caller falls back to `DEFAULT_SAMPLE_RATIO`, currently 0.1) on:
160
- * - missing / empty input
161
- * - invalid base64
162
- * - JSON parse failure
163
- * - schema-mismatched payload
164
- *
165
- * Logs a warning to console when the env var is set but unparseable so the
166
- * mistake is visible in CF Logs without crashing the worker boot.
167
- */
168
- export function decodeSamplingConfig(raw: string | undefined): SamplingConfig | null {
169
- if (!raw) return null;
170
- try {
171
- const json = atob(raw);
172
- const parsed = JSON.parse(json) as unknown;
173
- if (!parsed || typeof parsed !== "object") return null;
174
- const obj = parsed as { default?: unknown; rules?: unknown };
175
-
176
- const defaultRatio = typeof obj.default === "number" ? obj.default : undefined;
177
- const rawRules = Array.isArray(obj.rules) ? obj.rules : [];
178
- const rules: SamplingRule[] = [];
179
- for (const r of rawRules) {
180
- if (!r || typeof r !== "object") continue;
181
- const rec = r as { pattern?: unknown; ratio?: unknown };
182
- if (typeof rec.pattern !== "string" || typeof rec.ratio !== "number") continue;
183
- try {
184
- // Eagerly validate the regex so a bad pattern fails at boot, not
185
- // on the first matching request.
186
- new RegExp(rec.pattern);
187
- rules.push({ pattern: rec.pattern, ratio: rec.ratio });
188
- } catch {
189
- console.warn(`[sampler] dropping invalid pattern: ${rec.pattern}`);
190
- }
191
- }
192
-
193
- return { default: defaultRatio, rules };
194
- } catch (err) {
195
- console.warn(`[sampler] failed to decode OTEL_SAMPLING_CONFIG`, String(err));
196
- return null;
197
- }
198
- }
199
-
200
- /**
201
- * Build a `ParentBasedSampler` rooted at our URL-based sampler.
202
- * Wire as the `headSampler` for any custom OTel SDK setup (e.g. a site
203
- * that opts back into `@microlabs/otel-cf-workers` outside the default
204
- * `instrumentWorker` flow).
205
- */
206
- export function createUrlBasedHeadSampler(config: SamplingConfig | null): Sampler {
207
- const root = new URLBasedSampler(config ?? {});
208
- return new ParentBasedSampler({ root });
209
- }
210
-
211
- // Re-export OTel API helper so callers can read `traceId` / build tags off
212
- // the active span without importing @opentelemetry/api directly.
213
- export { trace as _otelTrace };