@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.
- package/.github/workflows/release.yml +26 -2
- package/CHANGELOG.md +113 -0
- package/bun.lock +0 -67
- package/package.json +5 -10
- package/scripts/migrate-to-cf-observability.test.ts +63 -17
- package/scripts/migrate-to-cf-observability.ts +175 -87
- package/src/sdk/observability.ts +26 -18
- package/src/sdk/otel.test.ts +50 -71
- package/src/sdk/otel.ts +70 -295
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +11 -194
- package/src/sdk/otelAdapters.ts +18 -353
- package/src/sdk/sampler.test.ts +0 -165
- package/src/sdk/sampler.ts +0 -213
package/src/sdk/sampler.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/sdk/sampler.ts
DELETED
|
@@ -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 };
|