@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.
@@ -2,15 +2,30 @@
2
2
  /**
3
3
  * Cloudflare-native observability codemod
4
4
  *
5
- * Rewrites a migrated site's `wrangler.jsonc` so Cloudflare ships
6
- * `console.*` logs and OTel traces directly to HyperDX (or any other
7
- * OTLP destination provisioned in the CF dashboard) — replacing the
8
- * in-Worker OTLP exporter that `@decocms/start` 4.3.x bundled.
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 the proposed `observability` block
12
- * plus a unified diff against the existing one. Safe to run
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: running twice produces the same file.
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
- * npx -p @decocms/start deco-cf-observability --logs hyperdx-logs --traces hyperdx-traces --write
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> Site directory containing wrangler.jsonc (default: cwd)
31
- * --write Apply the change. Otherwise prints diff and exits.
32
- * --logs <name> Logs destination name (default: "hyperdx-logs")
33
- * --traces <name> Traces destination name (default: "hyperdx-traces")
34
- * --traces-rate <r> head_sampling_rate for traces (default: 0.1)
35
- * --logs-rate <r> head_sampling_rate for logs (default: 1.0)
36
- * --no-persist Set persist:false (default saves CF dashboard storage cost)
37
- * --persist Set persist:true (keep traces/logs in the CF dashboard)
38
- * --help, -h Show this help
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 CF-native), or dry-run completed
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: "hyperdx-logs",
65
- tracesDest: "hyperdx-traces",
90
+ logsDest: "",
91
+ tracesDest: "",
66
92
  tracesRate: 0.1,
67
93
  logsRate: 1.0,
68
- persist: false,
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] ?? opts.logsDest;
108
+ case "--destination-logs":
109
+ opts.logsDest = argv[++i] ?? "";
82
110
  break;
83
- case "--traces":
84
- opts.tracesDest = argv[++i] ?? opts.tracesDest;
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 to ship logs and traces via Cloudflare's
112
- platform-managed OTLP export (observability.{logs,traces}.destinations)
113
- instead of the in-Worker exporter SDK.
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> Site directory (default: .)
120
- --write Apply the edit. Without it, prints diff and exits 1.
121
- --logs <name> Logs destination (default: hyperdx-logs)
122
- --traces <name> Traces destination (default: hyperdx-traces)
123
- --traces-rate <r> head_sampling_rate for traces (default: 0.1)
124
- --logs-rate <r> head_sampling_rate for logs (default: 1.0)
125
- --persist Keep the dashboard storage tier (default: --no-persist)
126
- --help, -h This message
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. Provision the destinations in the CF dashboard (one-time per account)
130
- 2. Deploy the Worker
131
- 3. Validate signals are landing in HyperDX
132
- 4. Delete the now-orphaned secrets:
133
- wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
134
- OTEL_EXPORTER_OTLP_HEADERS \\
135
- OTEL_SAMPLING_CONFIG \\
136
- OTEL_LOG_MIN_SEVERITY
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
- return [
439
+ const lines: string[] = [
410
440
  `"observability": {`,
411
- `${indent}// Cloudflare ships console.* output OTLP-encoded to the`,
412
- `${indent}// HyperDX destination provisioned at the account level. No`,
413
- `${indent}// in-Worker exporter, no flush bug, no subrequest cost.`,
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
- `${indent}${indent}"destinations": ["${opts.logsDest}"]`,
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}// Auto-instruments fetch/KV/R2/DO + picks up @opentelemetry/api`,
422
- `${indent}// global tracer spans (the bridge instrumentWorker installs).`,
423
- `${indent}// Sampling is one global rate per Worker; URL-pattern sampling`,
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
- `${indent}${indent}"destinations": ["${opts.tracesDest}"]`,
430
- `${indent}}`,
431
- `}`,
432
- ].join("\n");
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 CF-native"
478
+ // Detect "already canonical"
437
479
  // ---------------------------------------------------------------------------
438
480
 
439
- function isAlreadyCfNative(src: string, opts: CliOpts): boolean {
440
- // Cheap heuristic: the file mentions both destinations (under either
441
- // logs or traces) and a `head_sampling_rate`. A more thorough parse
442
- // is overkill for an idempotency check.
443
- if (!src.includes(`"destinations"`)) return false;
444
- if (!src.includes(opts.logsDest) && !src.includes(opts.tracesDest)) return false;
445
- if (!src.includes("head_sampling_rate")) return false;
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
- // Determine if we need a leading comma on the new key.
538
- const insertAt = end;
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
- const insertion = `${needsComma ? "," : ""}\n${indented}\n`;
549
- return src.slice(0, insertAt) + insertion + src.slice(insertAt);
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 (isAlreadyCfNative(before, opts)) {
567
- console.log(`✓ ${wranglerPath} already on CF-native observability — no change.`);
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(`✓ wrote ${wranglerPath}`);
684
+ console.log(`wrote ${wranglerPath}`);
594
685
  console.log(`
595
686
  Next steps:
596
- 1. (one-time per CF account) provision destinations in the dashboard:
597
- Logs: ${opts.logsDest} → HyperDX OTLP /v1/logs + Authorization header
598
- Traces: ${opts.tracesDest}HyperDX OTLP /v1/traces + Authorization header
599
- 2. wrangler deploy
600
- 3. Verify in HyperDX:
601
- service:<your-site-name> AND SeverityNumber:* → log records arriving
602
- service:<your-site-name> AND duration:* → spans arriving
603
- 4. Delete now-orphaned secrets:
604
- wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
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
 
@@ -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
- * `.../sampler`) remain importable for advanced use cases (custom adapters,
21
- * tests, etc.) but the common path stays here.
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
- // Adapters (for tests / custom wiring)
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
- // Sampler
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
- createUrlBasedHeadSampler,
73
- decodeSamplingConfig,
74
- type SamplingConfig,
75
- type SamplingRule,
76
- URLBasedSampler,
77
- } from "./sampler";
83
+ type ClickhouseCollectorOptions,
84
+ createClickhouseCollectorAdapter,
85
+ } from "./otelAdapters/clickhouseCollector";
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Coverage for `instrumentWorker` and the public observability surface.
2
+ * Coverage for the public observability surface and `instrumentWorker`.
3
3
  *
4
- * As of 4.4.0 the framework no longer wraps with `@microlabs/otel-cf-workers`
5
- * (`cloudflare:workers`-only), so importing `./otel` and `./observability`
6
- * works in plain vitest. Earlier versions of this file documented that
7
- * constraint — it's gone.
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 * as sampler from "./sampler";
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 OTel adapter factories", () => {
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
- expect(typeof adapters.flushOtelProviders).toBe("function");
56
- expect(typeof adapters.registerOtelFlushHandler).toBe("function");
57
- });
58
-
59
- it("exports sampler API", () => {
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("default mode without OTLP endpoint: no flush handlers registered (pure CF-native)", async () => {
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(adapters._getFlushHandlerCountForTests()).toBe(2);
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("flush is awaited via ctx.waitUntil even when fetch throws", async () => {
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: flush handler count stable", async () => {
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 doesn't register a flush (writeDataPoint is fire-and-forget),
169
- // so no providers should be registered when only AE is wired.
170
- expect(adapters._getFlushHandlerCountForTests()).toBe(0);
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
  });