@decocms/start 4.5.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/.cursor/skills/deco-edge-caching/SKILL.md +14 -10
- 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/workerEntry.ts +39 -21
- package/src/vite/plugin.js +55 -0
- package/src/vite/plugin.test.js +60 -1
- package/vitest.config.ts +1 -1
- package/src/sdk/sampler.test.ts +0 -165
- package/src/sdk/sampler.ts +0 -213
|
@@ -2,15 +2,30 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Cloudflare-native observability codemod
|
|
4
4
|
*
|
|
5
|
-
* Rewrites a migrated site's `wrangler.jsonc` so Cloudflare
|
|
6
|
-
* `console.*` logs and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Rewrites a migrated site's `wrangler.jsonc` so the Cloudflare runtime
|
|
6
|
+
* captures `console.*` logs and auto-instrumented traces directly into
|
|
7
|
+
* the per-Worker observability dashboard. No in-Worker exporter, no
|
|
8
|
+
* external destination — the CF dashboard is the destination.
|
|
9
|
+
*
|
|
10
|
+
* The canonical block this script writes:
|
|
11
|
+
*
|
|
12
|
+
* "observability": {
|
|
13
|
+
* "enabled": true,
|
|
14
|
+
* "logs": { "enabled": true, "invocation_logs": true,
|
|
15
|
+
* "head_sampling_rate": 1, "persist": true },
|
|
16
|
+
* "traces": { "enabled": true,
|
|
17
|
+
* "head_sampling_rate": 0.1, "persist": true }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* `enabled: true` at the top level is the master switch — without it
|
|
21
|
+
* Cloudflare captures nothing, regardless of the sub-block flags.
|
|
22
|
+
* `persist: true` keeps the data queryable in the CF dashboard
|
|
23
|
+
* (Workers Logs view + Traces view). Discovered the hard way during the
|
|
24
|
+
* lebiscuit canary cutover.
|
|
9
25
|
*
|
|
10
26
|
* Behavior:
|
|
11
|
-
* - dry-run by default — prints
|
|
12
|
-
*
|
|
13
|
-
* unattended in CI.
|
|
27
|
+
* - dry-run by default — prints a unified diff against the existing
|
|
28
|
+
* observability block. Safe in CI.
|
|
14
29
|
* - `--write` performs the in-place edit. The script:
|
|
15
30
|
* 1. locates the existing `"observability": { ... }` block
|
|
16
31
|
* (matching balanced braces, JSONC-comment-aware),
|
|
@@ -19,26 +34,35 @@
|
|
|
19
34
|
* observability key exists yet,
|
|
20
35
|
* 4. validates the result parses as JSON (after stripping
|
|
21
36
|
* comments) before writing.
|
|
22
|
-
* - Idempotent:
|
|
37
|
+
* - Idempotent: a wrangler.jsonc already on the canonical block is a
|
|
38
|
+
* no-op. A wrangler.jsonc with stale HyperDX-style destinations is
|
|
39
|
+
* rewritten to drop them.
|
|
40
|
+
* - Forwarding to an external destination (an OTel collector for
|
|
41
|
+
* ClickHouse, a third-party SaaS, etc.) is opt-in via
|
|
42
|
+
* `--destination-logs` / `--destination-traces`. The destination
|
|
43
|
+
* itself must be provisioned out-of-band in the CF dashboard.
|
|
23
44
|
*
|
|
24
45
|
* Usage (from a migrated site directory):
|
|
25
46
|
* npx -p @decocms/start deco-cf-observability # dry-run
|
|
26
47
|
* npx -p @decocms/start deco-cf-observability --write # apply
|
|
27
|
-
*
|
|
48
|
+
*
|
|
49
|
+
* # Opt-in: also forward to an account-level destination:
|
|
50
|
+
* npx -p @decocms/start deco-cf-observability --write \
|
|
51
|
+
* --destination-logs my-logs-dest --destination-traces my-traces-dest
|
|
28
52
|
*
|
|
29
53
|
* Options:
|
|
30
|
-
* --source <dir>
|
|
31
|
-
* --write
|
|
32
|
-
* --logs <
|
|
33
|
-
* --traces <
|
|
34
|
-
* --traces-rate <r>
|
|
35
|
-
* --logs-rate <r>
|
|
36
|
-
* --no-persist
|
|
37
|
-
* --persist
|
|
38
|
-
* --help, -h
|
|
54
|
+
* --source <dir> Site directory containing wrangler.jsonc (default: cwd)
|
|
55
|
+
* --write Apply the change. Otherwise prints diff and exits 1.
|
|
56
|
+
* --destination-logs <n> Optional CF destination name to forward logs to.
|
|
57
|
+
* --destination-traces <n> Optional CF destination name to forward traces to.
|
|
58
|
+
* --traces-rate <r> head_sampling_rate for traces (default: 0.1)
|
|
59
|
+
* --logs-rate <r> head_sampling_rate for logs (default: 1.0)
|
|
60
|
+
* --no-persist Set persist:false (do not keep data in CF dashboard)
|
|
61
|
+
* --persist Set persist:true (default — required if no destination)
|
|
62
|
+
* --help, -h Show this help
|
|
39
63
|
*
|
|
40
64
|
* Exit codes:
|
|
41
|
-
* 0 — no change needed (already
|
|
65
|
+
* 0 — no change needed (already canonical), or dry-run completed cleanly
|
|
42
66
|
* 1 — change required and `--write` not passed (CI signal)
|
|
43
67
|
* 2 — file invalid / can't parse / can't safely edit
|
|
44
68
|
*/
|
|
@@ -49,7 +73,9 @@ import * as path from "node:path";
|
|
|
49
73
|
interface CliOpts {
|
|
50
74
|
source: string;
|
|
51
75
|
write: boolean;
|
|
76
|
+
/** Optional CF destination slug for logs. Empty = no forwarding. */
|
|
52
77
|
logsDest: string;
|
|
78
|
+
/** Optional CF destination slug for traces. Empty = no forwarding. */
|
|
53
79
|
tracesDest: string;
|
|
54
80
|
tracesRate: number;
|
|
55
81
|
logsRate: number;
|
|
@@ -61,11 +87,13 @@ function parseArgs(argv: string[]): CliOpts {
|
|
|
61
87
|
const opts: CliOpts = {
|
|
62
88
|
source: ".",
|
|
63
89
|
write: false,
|
|
64
|
-
logsDest: "
|
|
65
|
-
tracesDest: "
|
|
90
|
+
logsDest: "",
|
|
91
|
+
tracesDest: "",
|
|
66
92
|
tracesRate: 0.1,
|
|
67
93
|
logsRate: 1.0,
|
|
68
|
-
persist:
|
|
94
|
+
// CF dashboard persistence on by default — without either persist:true
|
|
95
|
+
// OR a destination, observability data is captured and discarded.
|
|
96
|
+
persist: true,
|
|
69
97
|
help: false,
|
|
70
98
|
};
|
|
71
99
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -77,11 +105,11 @@ function parseArgs(argv: string[]): CliOpts {
|
|
|
77
105
|
case "--write":
|
|
78
106
|
opts.write = true;
|
|
79
107
|
break;
|
|
80
|
-
case "--logs":
|
|
81
|
-
opts.logsDest = argv[++i] ??
|
|
108
|
+
case "--destination-logs":
|
|
109
|
+
opts.logsDest = argv[++i] ?? "";
|
|
82
110
|
break;
|
|
83
|
-
case "--traces":
|
|
84
|
-
opts.tracesDest = argv[++i] ??
|
|
111
|
+
case "--destination-traces":
|
|
112
|
+
opts.tracesDest = argv[++i] ?? "";
|
|
85
113
|
break;
|
|
86
114
|
case "--traces-rate":
|
|
87
115
|
opts.tracesRate = Number(argv[++i] ?? opts.tracesRate);
|
|
@@ -108,32 +136,34 @@ function showHelp(): void {
|
|
|
108
136
|
console.log(`
|
|
109
137
|
@decocms/start — Cloudflare-native observability codemod
|
|
110
138
|
|
|
111
|
-
Rewrites wrangler.jsonc
|
|
112
|
-
|
|
113
|
-
|
|
139
|
+
Rewrites wrangler.jsonc so Cloudflare captures \`console.*\` logs and
|
|
140
|
+
auto-instrumented traces directly into the per-Worker dashboard. No
|
|
141
|
+
in-Worker exporter SDK, no external destination required.
|
|
114
142
|
|
|
115
143
|
Usage:
|
|
116
144
|
npx -p @decocms/start deco-cf-observability [options]
|
|
117
145
|
|
|
118
146
|
Options:
|
|
119
|
-
--source <dir>
|
|
120
|
-
--write
|
|
121
|
-
--logs <
|
|
122
|
-
--traces <
|
|
123
|
-
--traces-rate <r>
|
|
124
|
-
--logs-rate <r>
|
|
125
|
-
--persist
|
|
126
|
-
--
|
|
147
|
+
--source <dir> Site directory (default: .)
|
|
148
|
+
--write Apply the edit. Without it, prints diff and exits 1.
|
|
149
|
+
--destination-logs <n> Optional CF destination slug to also forward logs to.
|
|
150
|
+
--destination-traces <n> Optional CF destination slug to also forward traces to.
|
|
151
|
+
--traces-rate <r> head_sampling_rate for traces (default: 0.1)
|
|
152
|
+
--logs-rate <r> head_sampling_rate for logs (default: 1.0)
|
|
153
|
+
--persist Keep the dashboard storage tier (default)
|
|
154
|
+
--no-persist Drop the dashboard tier (only sane when forwarding)
|
|
155
|
+
--help, -h This message
|
|
127
156
|
|
|
128
157
|
After running with --write you must:
|
|
129
|
-
1.
|
|
130
|
-
2.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
1. Deploy the Worker (\`wrangler deploy\`).
|
|
159
|
+
2. Verify the CF dashboard shows logs + traces within ~5 min:
|
|
160
|
+
Workers & Pages → <site> → Observability
|
|
161
|
+
3. If migrating from an older app-side OTLP setup, delete the
|
|
162
|
+
now-orphaned secrets:
|
|
163
|
+
wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
|
|
164
|
+
OTEL_EXPORTER_OTLP_HEADERS \\
|
|
165
|
+
OTEL_SAMPLING_CONFIG \\
|
|
166
|
+
OTEL_LOG_MIN_SEVERITY
|
|
137
167
|
`);
|
|
138
168
|
}
|
|
139
169
|
|
|
@@ -406,43 +436,93 @@ function findTopLevelObjectEnd(src: string): number | null {
|
|
|
406
436
|
|
|
407
437
|
function renderObservabilityBlock(opts: CliOpts, indent = " "): string {
|
|
408
438
|
const persist = opts.persist;
|
|
409
|
-
|
|
439
|
+
const lines: string[] = [
|
|
410
440
|
`"observability": {`,
|
|
411
|
-
`${indent}//
|
|
412
|
-
`${indent}//
|
|
413
|
-
`${indent}
|
|
441
|
+
`${indent}// Master switch — without enabled:true at the top level CF`,
|
|
442
|
+
`${indent}// captures nothing, regardless of the sub-block flags.`,
|
|
443
|
+
`${indent}"enabled": true,`,
|
|
444
|
+
`${indent}// Cloudflare captures every console.* call from the Worker`,
|
|
445
|
+
`${indent}// (structured JSON via @decocms/start's logger lands here too).`,
|
|
446
|
+
`${indent}// persist:true keeps them queryable in the CF dashboard.`,
|
|
414
447
|
`${indent}"logs": {`,
|
|
415
448
|
`${indent}${indent}"enabled": true,`,
|
|
416
449
|
`${indent}${indent}"invocation_logs": true,`,
|
|
417
450
|
`${indent}${indent}"head_sampling_rate": ${opts.logsRate},`,
|
|
418
|
-
`${indent}${indent}"persist": ${persist}
|
|
419
|
-
|
|
451
|
+
`${indent}${indent}"persist": ${persist}`,
|
|
452
|
+
];
|
|
453
|
+
if (opts.logsDest) {
|
|
454
|
+
// Replace the trailing line with a comma'd version, then append the
|
|
455
|
+
// destinations array. Keeps the block valid JSON either way.
|
|
456
|
+
lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`;
|
|
457
|
+
lines.push(`${indent}${indent}"destinations": ["${opts.logsDest}"]`);
|
|
458
|
+
}
|
|
459
|
+
lines.push(
|
|
420
460
|
`${indent}},`,
|
|
421
|
-
`${indent}//
|
|
422
|
-
`${indent}// global
|
|
423
|
-
`${indent}//
|
|
424
|
-
`${indent}// requires opting back into the URLBasedSampler escape hatch.`,
|
|
461
|
+
`${indent}// Cloudflare auto-instruments fetch/KV/R2/DO subrequests and`,
|
|
462
|
+
`${indent}// also picks up @opentelemetry/api global-tracer spans the`,
|
|
463
|
+
`${indent}// framework's withTracing() helper emits.`,
|
|
425
464
|
`${indent}"traces": {`,
|
|
426
465
|
`${indent}${indent}"enabled": true,`,
|
|
427
466
|
`${indent}${indent}"head_sampling_rate": ${opts.tracesRate},`,
|
|
428
|
-
`${indent}${indent}"persist": ${persist}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
467
|
+
`${indent}${indent}"persist": ${persist}`,
|
|
468
|
+
);
|
|
469
|
+
if (opts.tracesDest) {
|
|
470
|
+
lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`;
|
|
471
|
+
lines.push(`${indent}${indent}"destinations": ["${opts.tracesDest}"]`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(`${indent}}`, `}`);
|
|
474
|
+
return lines.join("\n");
|
|
433
475
|
}
|
|
434
476
|
|
|
435
477
|
// ---------------------------------------------------------------------------
|
|
436
|
-
// Detect "already
|
|
478
|
+
// Detect "already canonical"
|
|
437
479
|
// ---------------------------------------------------------------------------
|
|
438
480
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
481
|
+
/**
|
|
482
|
+
* A wrangler.jsonc is considered canonical when it has the master
|
|
483
|
+
* `enabled: true` switch under `observability`, both `logs` and
|
|
484
|
+
* `traces` sub-blocks present, AND the `destinations` arrays match
|
|
485
|
+
* what `--destination-logs` / `--destination-traces` requested (which
|
|
486
|
+
* is "absent" by default). This means a stale HyperDX-style
|
|
487
|
+
* destination array always triggers a rewrite even if the rest of the
|
|
488
|
+
* shape happens to match.
|
|
489
|
+
*/
|
|
490
|
+
function isAlreadyCanonical(src: string, opts: CliOpts): boolean {
|
|
491
|
+
// Fast structural checks — full JSONC parse only if they pass.
|
|
492
|
+
if (!src.includes(`"observability"`)) return false;
|
|
493
|
+
if (!src.includes(`"enabled": true`) && !src.includes(`"enabled":true`)) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let parsed: unknown;
|
|
498
|
+
try {
|
|
499
|
+
parsed = JSON.parse(stripJsoncComments(src));
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
504
|
+
const obs = (parsed as Record<string, unknown>).observability;
|
|
505
|
+
if (!obs || typeof obs !== "object") return false;
|
|
506
|
+
const obsObj = obs as Record<string, unknown>;
|
|
507
|
+
if (obsObj.enabled !== true) return false;
|
|
508
|
+
|
|
509
|
+
const checkLeaf = (leaf: unknown, expectedDest: string): boolean => {
|
|
510
|
+
if (!leaf || typeof leaf !== "object") return false;
|
|
511
|
+
const l = leaf as Record<string, unknown>;
|
|
512
|
+
if (l.enabled !== true) return false;
|
|
513
|
+
const dests = l.destinations;
|
|
514
|
+
if (expectedDest === "") {
|
|
515
|
+
// Caller wants no destinations. Tolerate either absent or an
|
|
516
|
+
// empty array; reject any non-empty array.
|
|
517
|
+
if (dests === undefined) return true;
|
|
518
|
+
return Array.isArray(dests) && dests.length === 0;
|
|
519
|
+
}
|
|
520
|
+
if (!Array.isArray(dests) || dests.length !== 1) return false;
|
|
521
|
+
return dests[0] === expectedDest;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
if (!checkLeaf(obsObj.logs, opts.logsDest)) return false;
|
|
525
|
+
if (!checkLeaf(obsObj.traces, opts.tracesDest)) return false;
|
|
446
526
|
return true;
|
|
447
527
|
}
|
|
448
528
|
|
|
@@ -534,8 +614,11 @@ function applyEdit(src: string, opts: CliOpts): string {
|
|
|
534
614
|
if (end == null) {
|
|
535
615
|
throw new Error("wrangler.jsonc: could not locate top-level closing `}`");
|
|
536
616
|
}
|
|
537
|
-
//
|
|
538
|
-
|
|
617
|
+
// Walk back from the closing `}` over whitespace to find the last
|
|
618
|
+
// non-whitespace character. We splice in two pieces:
|
|
619
|
+
// - the comma (if needed) goes immediately AFTER that char so it
|
|
620
|
+
// sits on the same line as the prior key, not on a line of its own
|
|
621
|
+
// - the new key + value goes right before the closing `}`
|
|
539
622
|
let scan = end - 1;
|
|
540
623
|
while (scan >= 0 && /\s/.test(src[scan])) scan--;
|
|
541
624
|
const prevChar = scan >= 0 ? src[scan] : "";
|
|
@@ -545,8 +628,16 @@ function applyEdit(src: string, opts: CliOpts): string {
|
|
|
545
628
|
.split("\n")
|
|
546
629
|
.map((l) => baseIndent + l)
|
|
547
630
|
.join("\n");
|
|
548
|
-
|
|
549
|
-
|
|
631
|
+
if (needsComma) {
|
|
632
|
+
const commaInsertAt = scan + 1;
|
|
633
|
+
const before = `${src.slice(0, commaInsertAt)},`;
|
|
634
|
+
// Preserve any whitespace/newlines that were between the prior key
|
|
635
|
+
// and the closing `}` so the new block lines up under existing
|
|
636
|
+
// indentation conventions.
|
|
637
|
+
const between = src.slice(commaInsertAt, end);
|
|
638
|
+
return `${before}${between}${indented}\n${src.slice(end)}`;
|
|
639
|
+
}
|
|
640
|
+
return `${src.slice(0, end)}${indented}\n${src.slice(end)}`;
|
|
550
641
|
}
|
|
551
642
|
|
|
552
643
|
function main(): void {
|
|
@@ -563,8 +654,8 @@ function main(): void {
|
|
|
563
654
|
|
|
564
655
|
const before = fs.readFileSync(wranglerPath, "utf8");
|
|
565
656
|
|
|
566
|
-
if (
|
|
567
|
-
console.log(
|
|
657
|
+
if (isAlreadyCanonical(before, opts)) {
|
|
658
|
+
console.log(`${wranglerPath} already on the canonical CF observability block — no change.`);
|
|
568
659
|
process.exit(0);
|
|
569
660
|
}
|
|
570
661
|
|
|
@@ -590,21 +681,18 @@ function main(): void {
|
|
|
590
681
|
}
|
|
591
682
|
|
|
592
683
|
fs.writeFileSync(wranglerPath, after, "utf8");
|
|
593
|
-
console.log(
|
|
684
|
+
console.log(`wrote ${wranglerPath}`);
|
|
594
685
|
console.log(`
|
|
595
686
|
Next steps:
|
|
596
|
-
1.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
OTEL_EXPORTER_OTLP_HEADERS \\
|
|
606
|
-
OTEL_SAMPLING_CONFIG \\
|
|
607
|
-
OTEL_LOG_MIN_SEVERITY
|
|
687
|
+
1. wrangler deploy
|
|
688
|
+
2. Verify CF dashboard captures logs + traces (~5 min):
|
|
689
|
+
Workers & Pages → <site> → Observability
|
|
690
|
+
3. If migrating from an older app-side OTLP setup, delete the
|
|
691
|
+
now-orphaned secrets:
|
|
692
|
+
wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
|
|
693
|
+
OTEL_EXPORTER_OTLP_HEADERS \\
|
|
694
|
+
OTEL_SAMPLING_CONFIG \\
|
|
695
|
+
OTEL_LOG_MIN_SEVERITY
|
|
608
696
|
`);
|
|
609
697
|
}
|
|
610
698
|
|
package/src/sdk/observability.ts
CHANGED
|
@@ -16,9 +16,19 @@
|
|
|
16
16
|
* } from "@decocms/start/sdk/observability";
|
|
17
17
|
* ```
|
|
18
18
|
*
|
|
19
|
-
* The granular modules (`@decocms/start/sdk/logger`, `.../otelAdapters
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* The granular modules (`@decocms/start/sdk/logger`, `.../otelAdapters`)
|
|
20
|
+
* remain importable for advanced use cases (custom adapters, tests, etc.)
|
|
21
|
+
* but the common path stays here.
|
|
22
|
+
*
|
|
23
|
+
* **5.0.0 surface change.** The OTLP exporters (`createOtelLoggerAdapter`,
|
|
24
|
+
* `createOtelMeterAdapter`), the flush registry (`flushOtelProviders`,
|
|
25
|
+
* `registerOtelFlushHandler`), and the URL-based sampler
|
|
26
|
+
* (`URLBasedSampler`, `decodeSamplingConfig`, `createUrlBasedHeadSampler`,
|
|
27
|
+
* `SamplingConfig`, `SamplingRule`) were removed. They will be reintroduced
|
|
28
|
+
* via `./otelAdapters/clickhouseCollector.ts` when the platform-side OTel
|
|
29
|
+
* collector ships. The `instrumentWorker` / `withTracing` / `logger` /
|
|
30
|
+
* `recordRequestMetric` / `recordCacheMetric` surface is unchanged — only
|
|
31
|
+
* the transport layer was stripped.
|
|
22
32
|
*/
|
|
23
33
|
|
|
24
34
|
// Tracer / meter / request log primitives (re-exported from the middleware)
|
|
@@ -38,7 +48,8 @@ export {
|
|
|
38
48
|
type TracerAdapter,
|
|
39
49
|
withTracing,
|
|
40
50
|
} from "../middleware/observability";
|
|
41
|
-
// Composite helpers (for advanced multi-backend wiring
|
|
51
|
+
// Composite helpers (for advanced multi-backend wiring — e.g. AE + future
|
|
52
|
+
// ClickHouse-collector meter, or default-console + future-collector logger)
|
|
42
53
|
export { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
43
54
|
// Logger surface
|
|
44
55
|
export {
|
|
@@ -50,28 +61,25 @@ export {
|
|
|
50
61
|
type LoggerAdapter,
|
|
51
62
|
type LogLevel,
|
|
52
63
|
logger,
|
|
64
|
+
type SerializedError,
|
|
65
|
+
serializeError,
|
|
66
|
+
setLoggerAttributeFloor,
|
|
53
67
|
setLogLevel,
|
|
54
68
|
} from "./logger";
|
|
55
69
|
// Worker-entry wrapper + adapter wiring
|
|
56
70
|
export { instrumentWorker, type OtelOptions } from "./otel";
|
|
57
|
-
//
|
|
71
|
+
// AE meter adapter + runtime env helpers (for tests / custom wiring)
|
|
58
72
|
export {
|
|
73
|
+
type AnalyticsEngineDataset,
|
|
59
74
|
type AnalyticsEngineMeterAdapterOptions,
|
|
60
75
|
createAnalyticsEngineMeterAdapter,
|
|
61
|
-
createOtelLoggerAdapter,
|
|
62
|
-
createOtelMeterAdapter,
|
|
63
|
-
flushOtelProviders,
|
|
64
76
|
getRuntimeEnv,
|
|
65
|
-
type OtelLoggerAdapterOptions,
|
|
66
|
-
type OtelMeterAdapterOptions,
|
|
67
|
-
registerOtelFlushHandler,
|
|
68
77
|
setRuntimeEnv,
|
|
69
78
|
} from "./otelAdapters";
|
|
70
|
-
//
|
|
79
|
+
// ClickHouse collector adapter — stub today, real exporter when the
|
|
80
|
+
// collector lands. Re-exported from here so site code can import the
|
|
81
|
+
// future-target symbol via the canonical observability barrel.
|
|
71
82
|
export {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
type SamplingRule,
|
|
76
|
-
URLBasedSampler,
|
|
77
|
-
} from "./sampler";
|
|
83
|
+
type ClickhouseCollectorOptions,
|
|
84
|
+
createClickhouseCollectorAdapter,
|
|
85
|
+
} from "./otelAdapters/clickhouseCollector";
|
package/src/sdk/otel.test.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Coverage for
|
|
2
|
+
* Coverage for the public observability surface and `instrumentWorker`.
|
|
3
3
|
*
|
|
4
|
-
* As of
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* As of 5.0.0 the framework no longer bundles an in-Worker OTLP exporter
|
|
5
|
+
* for logs/metrics. Tests below verify:
|
|
6
|
+
* - public exports stay stable (logger, composite helpers, AE adapter,
|
|
7
|
+
* observability primitives)
|
|
8
|
+
* - `instrumentWorker` is a thin wrapper: bridge tracer + boot logger/meter,
|
|
9
|
+
* forward `fetch` to the wrapped handler, no flush registry, no
|
|
10
|
+
* `ctx.waitUntil` for telemetry plumbing.
|
|
8
11
|
*/
|
|
9
12
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
13
|
import * as observability from "../middleware/observability";
|
|
@@ -12,12 +15,10 @@ import * as composite from "./composite";
|
|
|
12
15
|
import * as logger from "./logger";
|
|
13
16
|
import { _resetBootStateForTests, instrumentWorker } from "./otel";
|
|
14
17
|
import * as adapters from "./otelAdapters";
|
|
15
|
-
import
|
|
18
|
+
import { createClickhouseCollectorAdapter } from "./otelAdapters/clickhouseCollector";
|
|
16
19
|
|
|
17
20
|
interface TestEnv extends Record<string, unknown> {
|
|
18
21
|
DECO_SITE_NAME?: string;
|
|
19
|
-
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
|
20
|
-
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
|
21
22
|
DECO_METRICS?: { writeDataPoint: () => void };
|
|
22
23
|
CF_VERSION_METADATA?: { id: string };
|
|
23
24
|
}
|
|
@@ -39,6 +40,8 @@ describe("observability granular modules", () => {
|
|
|
39
40
|
expect(typeof logger.configureLogger).toBe("function");
|
|
40
41
|
expect(typeof logger.setLogLevel).toBe("function");
|
|
41
42
|
expect(typeof logger.defaultLoggerAdapter.log).toBe("function");
|
|
43
|
+
expect(typeof logger.serializeError).toBe("function");
|
|
44
|
+
expect(typeof logger.setLoggerAttributeFloor).toBe("function");
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
it("exports composite helpers", () => {
|
|
@@ -46,20 +49,15 @@ describe("observability granular modules", () => {
|
|
|
46
49
|
expect(typeof composite.createCompositeMeter).toBe("function");
|
|
47
50
|
});
|
|
48
51
|
|
|
49
|
-
it("exports
|
|
50
|
-
expect(typeof adapters.createOtelLoggerAdapter).toBe("function");
|
|
51
|
-
expect(typeof adapters.createOtelMeterAdapter).toBe("function");
|
|
52
|
+
it("exports only the AE adapter factory + runtime env helpers", () => {
|
|
52
53
|
expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
|
|
53
54
|
expect(typeof adapters.setRuntimeEnv).toBe("function");
|
|
54
55
|
expect(typeof adapters.getRuntimeEnv).toBe("function");
|
|
55
|
-
|
|
56
|
-
expect(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(typeof sampler.URLBasedSampler).toBe("function");
|
|
61
|
-
expect(typeof sampler.decodeSamplingConfig).toBe("function");
|
|
62
|
-
expect(typeof sampler.createUrlBasedHeadSampler).toBe("function");
|
|
56
|
+
// OTLP factories + flush registry were removed in 5.0.0.
|
|
57
|
+
expect("createOtelLoggerAdapter" in adapters).toBe(false);
|
|
58
|
+
expect("createOtelMeterAdapter" in adapters).toBe(false);
|
|
59
|
+
expect("flushOtelProviders" in adapters).toBe(false);
|
|
60
|
+
expect("registerOtelFlushHandler" in adapters).toBe(false);
|
|
63
61
|
});
|
|
64
62
|
|
|
65
63
|
it("exports observability primitives from middleware/observability", () => {
|
|
@@ -69,65 +67,39 @@ describe("observability granular modules", () => {
|
|
|
69
67
|
expect(observability.MetricNames.HTTP_REQUEST_DURATION_MS).toBe("http_request_duration_ms");
|
|
70
68
|
expect(observability.MetricNames.RESOLVE_DURATION_MS).toBe("resolve_duration_ms");
|
|
71
69
|
});
|
|
70
|
+
|
|
71
|
+
it("ClickHouse collector adapter is a documented stub that throws", () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
createClickhouseCollectorAdapter({ endpoint: "https://otel-collector.internal" }),
|
|
74
|
+
).toThrow(/not implemented/i);
|
|
75
|
+
});
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
describe("instrumentWorker — CF-native default boot", () => {
|
|
75
79
|
beforeEach(() => {
|
|
76
80
|
_resetBootStateForTests();
|
|
77
|
-
adapters._resetFlushHandlersForTests();
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
afterEach(() => {
|
|
81
84
|
_resetBootStateForTests();
|
|
82
|
-
adapters._resetFlushHandlersForTests();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("default mode (no opts, only OTLP endpoint set): wires meter flush only, NOT logger flush", async () => {
|
|
86
|
-
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
87
|
-
const wrapped = instrumentWorker(handler);
|
|
88
|
-
|
|
89
|
-
const env: TestEnv = {
|
|
90
|
-
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
91
|
-
OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
|
|
92
|
-
};
|
|
93
|
-
const ctx = fakeCtx();
|
|
94
|
-
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
95
|
-
|
|
96
|
-
// Exactly one provider registered: the OTLP meter. The OTLP logger is
|
|
97
|
-
// gated behind `enableAppSideOtlpLogs` opt-in and CF handles log export.
|
|
98
|
-
expect(adapters._getFlushHandlerCountForTests()).toBe(1);
|
|
99
|
-
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
100
|
-
expect(ctx.waited).toHaveLength(1);
|
|
101
85
|
});
|
|
102
86
|
|
|
103
|
-
it("
|
|
87
|
+
it("forwards fetch to the wrapped handler", async () => {
|
|
104
88
|
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
105
89
|
const wrapped = instrumentWorker(handler);
|
|
106
90
|
|
|
107
91
|
const env: TestEnv = {};
|
|
108
92
|
const ctx = fakeCtx();
|
|
109
|
-
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
110
|
-
|
|
111
|
-
expect(adapters._getFlushHandlerCountForTests()).toBe(0);
|
|
112
|
-
// ctx.waitUntil still called with the no-op flush, but the handler array is empty.
|
|
113
|
-
expect(ctx.waited).toHaveLength(1);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("opt-in mode (enableAppSideOtlpLogs: true): wires BOTH logger and meter flush", async () => {
|
|
117
|
-
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
118
|
-
const wrapped = instrumentWorker(handler, { enableAppSideOtlpLogs: true });
|
|
119
|
-
|
|
120
|
-
const env: TestEnv = {
|
|
121
|
-
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
122
|
-
OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
|
|
123
|
-
};
|
|
124
|
-
const ctx = fakeCtx();
|
|
125
|
-
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
93
|
+
const res = await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
126
94
|
|
|
127
|
-
expect(
|
|
95
|
+
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
96
|
+
expect(await res.text()).toBe("ok");
|
|
97
|
+
// No flush registry anymore — instrumentWorker should NOT push anything
|
|
98
|
+
// into ctx.waitUntil for telemetry plumbing.
|
|
99
|
+
expect(ctx.waited).toHaveLength(0);
|
|
128
100
|
});
|
|
129
101
|
|
|
130
|
-
it("
|
|
102
|
+
it("does not call ctx.waitUntil even when the handler throws", async () => {
|
|
131
103
|
const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
|
|
132
104
|
const wrapped = instrumentWorker(handler);
|
|
133
105
|
|
|
@@ -136,23 +108,18 @@ describe("instrumentWorker — CF-native default boot", () => {
|
|
|
136
108
|
await expect(wrapped.fetch(new Request("https://example.test/"), env, ctx)).rejects.toThrow(
|
|
137
109
|
"boom",
|
|
138
110
|
);
|
|
139
|
-
|
|
140
|
-
expect(ctx.waited).toHaveLength(1);
|
|
111
|
+
expect(ctx.waited).toHaveLength(0);
|
|
141
112
|
});
|
|
142
113
|
|
|
143
|
-
it("boot is idempotent across requests
|
|
114
|
+
it("boot is idempotent across requests", async () => {
|
|
144
115
|
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
145
|
-
const wrapped = instrumentWorker(handler);
|
|
146
|
-
|
|
147
|
-
const env: TestEnv = {
|
|
148
|
-
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
149
|
-
};
|
|
116
|
+
const wrapped = instrumentWorker(handler, { decoRuntimeVersion: "5.0.0-test" });
|
|
150
117
|
|
|
118
|
+
const env: TestEnv = {};
|
|
151
119
|
for (let i = 0; i < 3; i++) {
|
|
152
120
|
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
153
121
|
}
|
|
154
122
|
|
|
155
|
-
expect(adapters._getFlushHandlerCountForTests()).toBe(1);
|
|
156
123
|
expect(handler.fetch).toHaveBeenCalledTimes(3);
|
|
157
124
|
});
|
|
158
125
|
|
|
@@ -165,8 +132,20 @@ describe("instrumentWorker — CF-native default boot", () => {
|
|
|
165
132
|
const ctx = fakeCtx();
|
|
166
133
|
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
167
134
|
|
|
168
|
-
// AE adapter
|
|
169
|
-
// so
|
|
170
|
-
expect(
|
|
135
|
+
// AE adapter does not register flush handlers (writeDataPoint is
|
|
136
|
+
// fire-and-forget), so ctx.waitUntil stays empty even with AE wired.
|
|
137
|
+
expect(ctx.waited).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("accepts a function for options so site code can read env at boot time", async () => {
|
|
141
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
142
|
+
const wrapped = instrumentWorker(handler, (env) => ({
|
|
143
|
+
serviceName: (env.DECO_SITE_NAME as string) ?? "fallback",
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const env: TestEnv = { DECO_SITE_NAME: "my-store-test" };
|
|
147
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
148
|
+
|
|
149
|
+
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
171
150
|
});
|
|
172
151
|
});
|