@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.
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
- package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
- package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
- package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
- package/.cursor/rules/migration-tooling-policy.mdc +22 -2
- package/.github/workflows/deploy.yml +115 -0
- package/.github/workflows/preview.yml +143 -0
- package/.github/workflows/regen-blocks.yml +56 -0
- package/.github/workflows/release.yml +26 -0
- package/.github/workflows/sync-secrets.yml +173 -0
- package/CODEOWNERS +16 -0
- package/MIGRATION_TOOLING_PLAN.md +16 -4
- package/README.md +178 -79
- package/deploy/README.md +85 -0
- package/deploy/sites/als-tanstack.jsonc +7 -0
- package/deploy/sites/americanas-tanstack.jsonc +4 -0
- package/deploy/sites/baggagio-tanstack.jsonc +4 -0
- package/deploy/sites/casaevideo-storefront.jsonc +11 -0
- package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
- package/deploy/sites/miess-01-tanstack.jsonc +8 -0
- package/deploy/wrangler-template.jsonc +28 -0
- package/package.json +18 -15
- package/scripts/deploy/build-wrangler-config.mjs +49 -0
- package/scripts/deploy/jsonc.mjs +76 -0
- package/scripts/deploy/resolve-site.mjs +58 -0
- package/scripts/deploy/site-registry.mjs +142 -0
- package/scripts/deploy/wrangler-wrapper.mjs +126 -0
- package/scripts/migrate/phase-scaffold.ts +13 -3
- package/scripts/migrate/phase-verify.ts +6 -1
- package/scripts/migrate/templates/github-workflows.ts +98 -0
- package/scripts/migrate/templates/package-json.ts +9 -2
- package/src/cms/resolve.ts +81 -63
- package/src/cms/sectionLoaders.ts +11 -0
- package/src/index.ts +3 -0
- package/src/sdk/cachedLoader.ts +36 -13
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/instrumentedFetch.ts +56 -0
- package/src/sdk/logger.test.ts +135 -0
- package/src/sdk/logger.ts +166 -0
- package/src/sdk/observability.ts +75 -0
- package/src/sdk/otel.test.ts +59 -0
- package/src/sdk/otel.ts +270 -29
- package/src/sdk/otelAdapters.test.ts +135 -0
- package/src/sdk/otelAdapters.ts +401 -0
- package/src/sdk/sampler.test.ts +127 -0
- package/src/sdk/sampler.ts +183 -0
- package/src/sdk/workerEntry.ts +541 -476
- 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 };
|