@decocms/start 4.3.0 → 4.4.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/src/sdk/logger.ts CHANGED
@@ -88,6 +88,20 @@ export const defaultLoggerAdapter: LoggerAdapter = {
88
88
  let activeAdapter: LoggerAdapter = defaultLoggerAdapter;
89
89
  let minLevel: LogLevel = "info";
90
90
 
91
+ /**
92
+ * Per-record attribute floor — merged into every log line BEFORE the
93
+ * caller's `attrs` (caller wins). Used to stamp `deco.runtime.version`,
94
+ * `deco.apps.version`, `deployment.environment` on every log so HyperDX
95
+ * panels filtering on these dimensions keep working under
96
+ * Cloudflare-managed log export (which otherwise strips our resource
97
+ * attributes — only the JSON record body survives).
98
+ *
99
+ * Set via `setLoggerAttributeFloor(...)` at boot from
100
+ * `instrumentWorker()`. Default empty so the logger is a no-op for sites
101
+ * that don't wire `instrumentWorker`.
102
+ */
103
+ let attributeFloor: Record<string, unknown> = {};
104
+
91
105
  /**
92
106
  * Replace the active logger adapter.
93
107
  * Call once at worker boot from `instrumentWorker()`.
@@ -96,6 +110,22 @@ export function configureLogger(adapter: LoggerAdapter): void {
96
110
  activeAdapter = adapter;
97
111
  }
98
112
 
113
+ /**
114
+ * Replace the per-record attribute floor — keys here will be added to
115
+ * every log line UNLESS the caller passes the same key in their `attrs`
116
+ * (caller wins). Set once at worker boot from `instrumentWorker()`.
117
+ */
118
+ export function setLoggerAttributeFloor(attrs: Record<string, unknown>): void {
119
+ attributeFloor = { ...attrs };
120
+ }
121
+
122
+ /**
123
+ * Test-only: read the current attribute floor. Do not call from app code.
124
+ */
125
+ export function _getLoggerAttributeFloorForTests(): Record<string, unknown> {
126
+ return { ...attributeFloor };
127
+ }
128
+
99
129
  /**
100
130
  * Get the current active logger adapter (for tests / advanced wiring).
101
131
  */
@@ -197,13 +227,21 @@ export function serializeError(err: unknown): SerializedError {
197
227
 
198
228
  function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void {
199
229
  if (!shouldLog(level)) return;
230
+ // Merge floor → caller attrs so caller can override any floor key.
231
+ // Skipped entirely when the floor is empty so the no-op path stays cheap.
232
+ const merged: Record<string, unknown> | undefined =
233
+ Object.keys(attributeFloor).length === 0
234
+ ? attrs
235
+ : attrs
236
+ ? { ...attributeFloor, ...attrs }
237
+ : { ...attributeFloor };
200
238
  try {
201
- activeAdapter.log(level, msg, attrs);
239
+ activeAdapter.log(level, msg, merged);
202
240
  } catch {
203
241
  // Adapter blew up. Fall back to default so we don't lose the line.
204
242
  if (activeAdapter !== defaultLoggerAdapter) {
205
243
  try {
206
- defaultLoggerAdapter.log(level, msg, attrs);
244
+ defaultLoggerAdapter.log(level, msg, merged);
207
245
  } catch {
208
246
  /* swallow */
209
247
  }
@@ -1,27 +1,38 @@
1
1
  /**
2
- * NOTE: full `instrumentWorker(...)` integration tests aren't feasible in
3
- * plain vitest because `@microlabs/otel-cf-workers` imports `cloudflare:workers`,
4
- * which only exists in the Workers runtime.
2
+ * Coverage for `instrumentWorker` and the public observability surface.
5
3
  *
6
- * The wiring is validated end-to-end on the lebiscuit canary (Section 2 of
7
- * the otel-hyperdx-parity plan): hit the deployed preview, confirm log
8
- * lines via `wrangler tail`, traces via the HyperDX UI, and AE data points
9
- * via `wrangler analytics-engine sql`. If we ever migrate the framework
10
- * test suite to `@cloudflare/vitest-pool-workers`, restore the in-process
11
- * smoke test here.
12
- *
13
- * Until then this file just guards the public API shape of the granular
14
- * modules so refactors that break the export surface fail loudly in CI.
15
- * (We deliberately don't import `./observability` here — it transitively
16
- * pulls in `@microlabs/otel-cf-workers` and therefore `cloudflare:workers`.)
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.
17
8
  */
18
- import { describe, expect, it } from "vitest";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
10
  import * as observability from "../middleware/observability";
20
11
  import * as composite from "./composite";
21
12
  import * as logger from "./logger";
13
+ import { _resetBootStateForTests, instrumentWorker } from "./otel";
22
14
  import * as adapters from "./otelAdapters";
23
15
  import * as sampler from "./sampler";
24
16
 
17
+ interface TestEnv extends Record<string, unknown> {
18
+ DECO_SITE_NAME?: string;
19
+ OTEL_EXPORTER_OTLP_ENDPOINT?: string;
20
+ OTEL_EXPORTER_OTLP_HEADERS?: string;
21
+ DECO_METRICS?: { writeDataPoint: () => void };
22
+ CF_VERSION_METADATA?: { id: string };
23
+ }
24
+
25
+ function fakeCtx() {
26
+ const waited: Array<Promise<unknown>> = [];
27
+ return {
28
+ waitUntil: (p: Promise<unknown>) => {
29
+ waited.push(p);
30
+ },
31
+ passThroughOnException: () => {},
32
+ waited,
33
+ };
34
+ }
35
+
25
36
  describe("observability granular modules", () => {
26
37
  it("exports the logger surface", () => {
27
38
  expect(typeof logger.logger.info).toBe("function");
@@ -41,6 +52,8 @@ describe("observability granular modules", () => {
41
52
  expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
42
53
  expect(typeof adapters.setRuntimeEnv).toBe("function");
43
54
  expect(typeof adapters.getRuntimeEnv).toBe("function");
55
+ expect(typeof adapters.flushOtelProviders).toBe("function");
56
+ expect(typeof adapters.registerOtelFlushHandler).toBe("function");
44
57
  });
45
58
 
46
59
  it("exports sampler API", () => {
@@ -57,3 +70,103 @@ describe("observability granular modules", () => {
57
70
  expect(observability.MetricNames.RESOLVE_DURATION_MS).toBe("resolve_duration_ms");
58
71
  });
59
72
  });
73
+
74
+ describe("instrumentWorker — CF-native default boot", () => {
75
+ beforeEach(() => {
76
+ _resetBootStateForTests();
77
+ adapters._resetFlushHandlersForTests();
78
+ });
79
+
80
+ afterEach(() => {
81
+ _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
+ });
102
+
103
+ it("default mode without OTLP endpoint: no flush handlers registered (pure CF-native)", async () => {
104
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
105
+ const wrapped = instrumentWorker(handler);
106
+
107
+ const env: TestEnv = {};
108
+ 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);
126
+
127
+ expect(adapters._getFlushHandlerCountForTests()).toBe(2);
128
+ });
129
+
130
+ it("flush is awaited via ctx.waitUntil even when fetch throws", async () => {
131
+ const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
132
+ const wrapped = instrumentWorker(handler);
133
+
134
+ const env: TestEnv = {};
135
+ const ctx = fakeCtx();
136
+ await expect(wrapped.fetch(new Request("https://example.test/"), env, ctx)).rejects.toThrow(
137
+ "boom",
138
+ );
139
+
140
+ expect(ctx.waited).toHaveLength(1);
141
+ });
142
+
143
+ it("boot is idempotent across requests: flush handler count stable", async () => {
144
+ 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
+ };
150
+
151
+ for (let i = 0; i < 3; i++) {
152
+ await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
153
+ }
154
+
155
+ expect(adapters._getFlushHandlerCountForTests()).toBe(1);
156
+ expect(handler.fetch).toHaveBeenCalledTimes(3);
157
+ });
158
+
159
+ it("AE meter is wired without OTLP when DECO_METRICS binding is present", async () => {
160
+ const writeDataPoint = vi.fn();
161
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
162
+ const wrapped = instrumentWorker(handler);
163
+
164
+ const env: TestEnv = { DECO_METRICS: { writeDataPoint } };
165
+ const ctx = fakeCtx();
166
+ await wrapped.fetch(new Request("https://example.test/"), env, ctx);
167
+
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);
171
+ });
172
+ });
package/src/sdk/otel.ts CHANGED
@@ -2,47 +2,66 @@
2
2
  * Single observability entry point for `@decocms/start` on Cloudflare Workers.
3
3
  *
4
4
  * `instrumentWorker(handler, options)` wraps a Worker handler with:
5
- * - structured JSON logger (stdout → Cloudflare Logs / Logpush) — always
6
- * - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
7
- * - OTLP/HTTP metrics exporter (HyperDX) — same condition
5
+ * - structured JSON logger (stdout → Cloudflare Workers Logs) — always
8
6
  * - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
9
- * - OTel traces via `@microlabs/otel-cf-workers` — when OTLP endpoint is set,
10
- * or always-on for the framework's internal `withTracing()` calls.
11
- * - URL-based head sampler from `OTEL_SAMPLING_CONFIG`
12
- * - OTel `Resource` with service.* / cloud.* / deployment.environment /
13
- * deco.runtime.version attributes
7
+ * - OTLP/HTTP metrics exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT`
8
+ * is set (CF doesn't support OTLP metrics export yet, so this stays app-side)
9
+ * - Per-request `ctx.waitUntil(forceFlush)` for any registered OTel batch
10
+ * processors so log/metric batches don't die with the isolate
11
+ * - Bridges framework-internal `withTracing()` calls onto the global
12
+ * `@opentelemetry/api` tracer, stamping `deco.*` attributes on every span
13
+ * so they survive Cloudflare's platform-managed trace export
14
14
  *
15
- * Removing HyperDX = unsetting `OTEL_EXPORTER_OTLP_ENDPOINT`. The console-JSON
16
- * logger and AE metrics keep flowing — this is the "no vendor lock-in" guarantee.
15
+ * **Logs and traces export to HyperDX is now handled by Cloudflare** via the
16
+ * `observability.{logs,traces}.destinations` block in `wrangler.jsonc`. CF
17
+ * captures `console.*` output and `@opentelemetry/api` global tracer spans
18
+ * out-of-band and ships them OTLP-encoded to whatever destination is
19
+ * configured. This eliminates the in-Worker exporter SDK, the per-request
20
+ * subrequest cost of pushing OTLP, and the entire class of bug PR #153 fixed
21
+ * (batch processors that never flush before isolate recycling).
17
22
  *
18
23
  * @example
19
24
  * ```ts
25
+ * // worker-entry.ts
20
26
  * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
21
27
  * import { instrumentWorker } from "@decocms/start/sdk/otel";
22
28
  *
23
29
  * const handler = createDecoWorkerEntry(serverEntry, options);
24
- *
25
30
  * export default instrumentWorker(handler, { serviceName: "my-store" });
26
31
  * ```
27
32
  *
28
- * Wrangler bindings to add when enabling OTLP + AE:
33
+ * Companion `wrangler.jsonc` block (run `scripts/migrate-to-cf-observability.ts`
34
+ * to inject this automatically):
29
35
  * ```jsonc
30
- * "version_metadata": { "binding": "CF_VERSION_METADATA" },
31
- * "analytics_engine_datasets": [{ "binding": "DECO_METRICS", "dataset": "deco_metrics_my_site" }]
36
+ * "observability": {
37
+ * "logs": { "enabled": true, "destinations": ["hyperdx-logs"],
38
+ * "head_sampling_rate": 1.0, "persist": false },
39
+ * "traces": { "enabled": true, "destinations": ["hyperdx-traces"],
40
+ * "head_sampling_rate": 0.1, "persist": false }
41
+ * },
42
+ * "version_metadata": { "binding": "CF_VERSION_METADATA" },
43
+ * "analytics_engine_datasets": [{ "binding": "DECO_METRICS",
44
+ * "dataset": "deco_metrics_my_site" }]
32
45
  * ```
33
46
  *
34
- * Required Worker secrets when OTLP is enabled:
35
- * wrangler secret put OTEL_EXPORTER_OTLP_ENDPOINT # https://in-otel.hyperdx.io
36
- * wrangler secret put OTEL_EXPORTER_OTLP_HEADERS # authorization=<token>
47
+ * **Back-compat seam.** Sites that need to keep app-side OTLP log export
48
+ * (custom destination not covered by CF, custom batching, etc.) can opt back
49
+ * in with `enableAppSideOtlpLogs: true` and the existing `OTEL_EXPORTER_OTLP_*`
50
+ * secrets. Slated for removal in 5.0.0.
37
51
  */
38
52
 
39
- import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
40
53
  import { trace } from "@opentelemetry/api";
41
54
  import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
42
55
 
43
56
  import { configureMeter, configureTracer } from "../middleware/observability";
44
57
  import { createCompositeLogger, createCompositeMeter } from "./composite";
45
- import { configureLogger, defaultLoggerAdapter, type LogLevel, logger } from "./logger";
58
+ import {
59
+ configureLogger,
60
+ defaultLoggerAdapter,
61
+ type LogLevel,
62
+ logger,
63
+ setLoggerAttributeFloor,
64
+ } from "./logger";
46
65
  import {
47
66
  createAnalyticsEngineMeterAdapter,
48
67
  createOtelLoggerAdapter,
@@ -51,7 +70,6 @@ import {
51
70
  setRuntimeEnv,
52
71
  } from "./otelAdapters";
53
72
  import { RequestContext } from "./requestContext";
54
- import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
55
73
 
56
74
  const VALID_LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
57
75
 
@@ -79,20 +97,38 @@ export interface OtelOptions {
79
97
  /** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
80
98
  metricsExportIntervalMillis?: number;
81
99
  /**
82
- * Minimum severity to forward to OTLP logs (HyperDX). Below the floor
83
- * the framework still writes a structured JSON line to `console.*`
84
- * (Cloudflare Workers Logs), so nothing is silently lost.
100
+ * Minimum severity to forward to the **app-side OTLP** logger (only
101
+ * relevant when `enableAppSideOtlpLogs: true`). The default `console.*`
102
+ * adapter is unaffected and continues to capture every level for
103
+ * Cloudflare Workers Logs / CF-side OTLP export.
85
104
  *
86
105
  * Defaults to `"warn"`. Falls back to env `OTEL_LOG_MIN_SEVERITY` when
87
106
  * unset. Set to `"debug"` to forward everything.
88
107
  */
89
108
  otlpMinSeverity?: LogLevel;
90
109
  /**
91
- * Version of `@decocms/start` to advertise as `deco.runtime.version`.
110
+ * Opt-in: also wire an in-Worker OTLP logger that pushes log records to
111
+ * `OTEL_EXPORTER_OTLP_ENDPOINT`. Defaults to `false` — sites should
112
+ * prefer the platform-managed CF-side path
113
+ * (`observability.logs.destinations` in `wrangler.jsonc`), which is
114
+ * cheaper, has no flush-bug class, and consumes zero subrequest budget.
115
+ *
116
+ * Use this only when CF's OTLP logs export doesn't meet a specific need
117
+ * (e.g. shipping to a destination CF doesn't support, custom batching,
118
+ * staging-only debugging). Requires `OTEL_EXPORTER_OTLP_ENDPOINT` and
119
+ * `OTEL_EXPORTER_OTLP_HEADERS` to be set.
120
+ *
121
+ * Slated for removal in 5.0.0.
122
+ */
123
+ enableAppSideOtlpLogs?: boolean;
124
+ /**
125
+ * Version of `@decocms/start` to advertise as `deco.runtime.version`
126
+ * on every span (CF doesn't preserve it as a resource attribute since
127
+ * we no longer ship our own resource — we stamp it per-span instead).
92
128
  * Falls back to a build-time constant; override only for tests.
93
129
  */
94
130
  decoRuntimeVersion?: string;
95
- /** Optional `@decocms/apps` version, advertised as `deco.apps.version`. */
131
+ /** Optional `@decocms/apps` version, stamped as `deco.apps.version` on every span. */
96
132
  decoAppsVersion?: string;
97
133
  }
98
134
 
@@ -124,25 +160,59 @@ interface BootState {
124
160
 
125
161
  let bootState: BootState | null = null;
126
162
 
163
+ /**
164
+ * Per-span attribute floor — stamped on every span we create via
165
+ * `configureTracer().startSpan(...)`. These match what the legacy resource
166
+ * attributes used to carry (when `@microlabs/otel-cf-workers` shipped its
167
+ * own OTel `Resource`); we now stamp them on each span so HyperDX panels
168
+ * filtering on `deco.runtime.version`, `deco.apps.version`, or
169
+ * `deployment.environment` keep working with CF-managed export, which only
170
+ * preserves CF's own resource attribute set (`service.name`, `faas.name`,
171
+ * `cloudflare.script_version.id`, etc.).
172
+ *
173
+ * Populated by `bootObservability` before any span is created. Stays an
174
+ * empty object until then so early span creation is a no-op stamp.
175
+ */
176
+ let spanAttributeFloor: Record<string, string> = {};
177
+
127
178
  // ---------------------------------------------------------------------------
128
179
  // instrumentWorker
129
180
  // ---------------------------------------------------------------------------
130
181
 
131
182
  /**
132
- * Wraps a Cloudflare Worker handler with the full @decocms/start
133
- * observability stack. Idempotent — calling twice on the same handler
134
- * is a no-op (returns the already-instrumented handler).
183
+ * Wraps a Cloudflare Worker handler with the @decocms/start observability
184
+ * stack:
185
+ * - structured JSON logger (always)
186
+ * - AE meter (when `DECO_METRICS` binding present)
187
+ * - optional app-side OTLP meter (when `OTEL_EXPORTER_OTLP_ENDPOINT` set)
188
+ * - optional app-side OTLP logger (when `enableAppSideOtlpLogs: true`)
189
+ * - per-request `ctx.waitUntil(forceFlush)` for any registered batch processors
190
+ * - bridge from framework-internal `withTracing()` to `@opentelemetry/api`
191
+ * global tracer, with `deco.*` attributes stamped on every span
192
+ *
193
+ * Logs and traces export to HyperDX (or any OTLP destination) is handled
194
+ * by Cloudflare via `observability.{logs,traces}.destinations` in
195
+ * `wrangler.jsonc`. This wrapper does NOT call `@microlabs/otel-cf-workers`
196
+ * `instrument()` — CF's platform-managed export captures `console.*` output
197
+ * and global-tracer spans out-of-band.
135
198
  */
136
199
  export function instrumentWorker(
137
200
  handler: WorkerHandler,
138
201
  options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
139
202
  ): WorkerHandler {
140
- // Bridge our pluggable TracerAdapter onto @opentelemetry/api so
141
- // framework-internal `withTracing()` calls produce real OTel spans
142
- // for whatever exporter is configured (OTLP, console, etc.).
203
+ // Bridge our pluggable TracerAdapter onto @opentelemetry/api. Framework
204
+ // code calls `withTracing("name", fn, { attr: val })`; that delegates here
205
+ // and lands on `trace.getTracer("@decocms/start").startSpan(...)`.
206
+ //
207
+ // CF Workers Tracing (when `observability.traces.enabled = true` in
208
+ // wrangler) installs its own TracerProvider into the @opentelemetry/api
209
+ // global, so these spans flow through to whatever OTLP destination is
210
+ // configured. Without CF tracing the global tracer is a no-op proxy and
211
+ // the spans simply drop — same outcome as before, no error.
143
212
  configureTracer({
144
213
  startSpan: (name, attrs) => {
145
- const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: attrs });
214
+ const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
215
+ const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
146
216
  return {
147
217
  end: () => span.end(),
148
218
  setError: (error) => {
@@ -153,64 +223,32 @@ export function instrumentWorker(
153
223
  },
154
224
  });
155
225
 
156
- const resolveConfig: ResolveConfigFn = (env, _trigger) => {
157
- const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
158
- bootObservability(opts, env as Record<string, unknown>);
159
-
160
- const state = bootState!;
161
-
162
- // Sampling — base64 JSON via OTEL_SAMPLING_CONFIG, see sdk/sampler.ts
163
- const samplingConfig = decodeSamplingConfig(env.OTEL_SAMPLING_CONFIG as string | undefined);
164
- const headSampler = createUrlBasedHeadSampler(samplingConfig);
165
-
166
- // microlabs requires an exporter even when we only want internal
167
- // tracing. When OTLP isn't configured, we still set up a no-op
168
- // collector — the URL we'd never reach so spans simply drop.
169
- const exporterUrl = state.otlpEndpoint ?? "http://127.0.0.1:0/v1/traces";
170
-
171
- return {
172
- exporter: {
173
- url: joinPath(exporterUrl, "/v1/traces"),
174
- headers: state.otlpHeaders,
175
- },
176
- service: {
177
- name: state.serviceName,
178
- version: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
179
- },
180
- sampling: { headSampler },
181
- // microlabs auto-instruments globalThis.fetch + KV + waitUntil.
182
- instrumentation: {
183
- instrumentGlobalFetch: true,
184
- instrumentGlobalCache: true,
185
- },
186
- };
187
- };
188
-
189
- const innerHandler: WorkerHandler = {
226
+ return {
190
227
  async fetch(request, env, ctx) {
191
- // Stash env so request-scoped adapters (AE) can resolve their bindings.
192
- // Done inside RequestContext.run wrapping in workerEntry.ts as well, but
193
- // for instrumentWorker we re-stash in case this handler is wrapped over
194
- // the top of a Worker that doesn't go through createDecoWorkerEntry.
228
+ const opts =
229
+ typeof options === "function" ? options(env as Record<string, unknown>) : options;
230
+ bootObservability(opts, env as Record<string, unknown>);
231
+
232
+ // Stash env so request-scoped adapters (AE) can resolve their
233
+ // bindings. Done inside RequestContext.run wrapping in workerEntry.ts
234
+ // too, but we re-stash here in case `instrumentWorker` is wrapped
235
+ // over a handler that doesn't go through `createDecoWorkerEntry`.
195
236
  const wrap = async () => {
196
237
  setRuntimeEnv(env);
197
238
  return handler.fetch(request, env, ctx);
198
239
  };
199
240
 
200
241
  try {
201
- // RequestContext may already be active (createDecoWorkerEntry sets it
202
- // up). If so, run inline; otherwise wrap. Cheap to detect via current.
203
242
  if (RequestContext.current) {
204
243
  return await wrap();
205
244
  }
206
245
  return await RequestContext.run(request, wrap);
207
246
  } finally {
208
- // Drain OTLP logger + meter batches inside the post-response window
209
- // the platform guarantees via `waitUntil`. Without this hook,
210
- // BatchLogRecordProcessor (5s flush) and PeriodicExportingMetricReader
211
- // (60s flush) batches usually die with the isolate before the timer
212
- // fires and never reach HyperDX. `flushOtelProviders` is a no-op when
213
- // OTLP isn't configured, so this is safe in every code path.
247
+ // Drain OTLP meter (and OTLP logger, if `enableAppSideOtlpLogs`)
248
+ // batches inside the post-response window `waitUntil` guarantees.
249
+ // Without this hook, `PeriodicExportingMetricReader` (60s flush)
250
+ // batches usually die with the isolate before the timer fires.
251
+ // No-op when no batch processors are registered.
214
252
  try {
215
253
  ctx.waitUntil(flushOtelProviders());
216
254
  } catch {
@@ -220,9 +258,6 @@ export function instrumentWorker(
220
258
  }
221
259
  },
222
260
  };
223
-
224
- // deno-lint-ignore no-explicit-any
225
- return instrument(innerHandler as any, resolveConfig) as unknown as WorkerHandler;
226
261
  }
227
262
 
228
263
  // ---------------------------------------------------------------------------
@@ -239,21 +274,57 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
239
274
  const otlpHeaders =
240
275
  opts.headers ?? parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
241
276
 
277
+ const decoRuntimeVersion = opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION;
278
+ const deploymentEnvironment = (env.DECO_ENV_NAME as string | undefined) ?? "production";
279
+
242
280
  const resource = buildResource({
243
281
  serviceName,
244
282
  serviceVersion: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
245
283
  serviceInstanceId: cryptoRandomId(),
246
- deploymentEnvironment: (env.DECO_ENV_NAME as string | undefined) ?? "production",
247
- decoRuntimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
284
+ deploymentEnvironment,
285
+ decoRuntimeVersion,
248
286
  decoAppsVersion: opts.decoAppsVersion,
249
287
  });
250
288
 
289
+ // Stamp deco.* attributes on every span we create. CF-managed trace
290
+ // export emits its own resource attribute set (service.name=Worker name,
291
+ // faas.name, cloudflare.script_version.id, faas.version, etc.) so the
292
+ // legacy resource attrs from `buildResource` don't survive on the
293
+ // CF-side path. Stamping them per-span preserves the dimensions
294
+ // existing HyperDX dashboards filter on.
295
+ spanAttributeFloor = {
296
+ "deco.runtime.version": decoRuntimeVersion,
297
+ "deployment.environment": deploymentEnvironment,
298
+ ...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
299
+ };
300
+
301
+ // Same set, stamped on every log record. CF Workers Logs ships the JSON
302
+ // body verbatim (resource attrs from `buildResource` are NOT applied to
303
+ // logs in default mode), so HyperDX panels grouping by these dimensions
304
+ // would otherwise return empty. Caller-supplied `attrs` still win on key
305
+ // collision.
306
+ setLoggerAttributeFloor({
307
+ "deco.runtime.version": decoRuntimeVersion,
308
+ "deployment.environment": deploymentEnvironment,
309
+ ...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
310
+ });
311
+
251
312
  // ---- Logger ----------------------------------------------------------
313
+ // Default mode: console JSON only. Cloudflare Workers Logs captures the
314
+ // output and ships it via `observability.logs.destinations` to whichever
315
+ // OTLP destination is configured in `wrangler.jsonc`. This is the
316
+ // recommended path: zero in-Worker exporter, no flush bug, no subrequest
317
+ // cost per emit.
318
+ //
319
+ // Opt-in mode (`enableAppSideOtlpLogs: true`): also wire the OTLP logger
320
+ // adapter for sites with destinations CF doesn't support. Requires
321
+ // `OTEL_EXPORTER_OTLP_ENDPOINT` to be set.
252
322
  const otlpMinSeverity =
253
323
  opts.otlpMinSeverity ?? parseLogLevel(env.OTEL_LOG_MIN_SEVERITY) ?? "warn";
254
324
 
325
+ const wantAppSideLogs = opts.enableAppSideOtlpLogs === true;
255
326
  const otelLogger =
256
- otlpEndpoint != null
327
+ wantAppSideLogs && otlpEndpoint != null
257
328
  ? createOtelLoggerAdapter({
258
329
  endpoint: otlpEndpoint,
259
330
  headers: otlpHeaders,
@@ -266,6 +337,9 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
266
337
  configureLogger(createCompositeLogger([defaultLoggerAdapter, otelLogger]));
267
338
 
268
339
  // ---- Meter -----------------------------------------------------------
340
+ // OTLP meter stays default-on when an endpoint is configured: CF doesn't
341
+ // support OTLP metrics export yet, so this is the only path to
342
+ // HyperDX-compatible metrics. Drop this branch when CF ships metrics.
269
343
  const otelMeter =
270
344
  otlpEndpoint != null
271
345
  ? createOtelMeterAdapter({
@@ -295,17 +369,32 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
295
369
  booted = true;
296
370
 
297
371
  // Single boot-time breadcrumb so operators can confirm the wiring at a
298
- // glance from CF Logs without enabling debug.
372
+ // glance from CF Logs without enabling debug. Surfaces which export
373
+ // mode is active (CF-native vs app-side) so misconfigured sites are
374
+ // obvious from the first request.
299
375
  logger.info("observability booted", {
300
376
  service: serviceName,
301
- otlp: Boolean(otlpEndpoint),
302
- otlpMinSeverity: otlpEndpoint != null ? otlpMinSeverity : null,
377
+ mode: wantAppSideLogs ? "hybrid (app-side OTLP logs + CF traces)" : "cf-native",
378
+ otlpMeter: Boolean(otlpEndpoint),
379
+ otlpLogger: wantAppSideLogs && otlpEndpoint != null,
380
+ otlpMinSeverity: wantAppSideLogs && otlpEndpoint != null ? otlpMinSeverity : null,
303
381
  analyticsEngine: aeEnabled,
304
- sampling: Boolean(env.OTEL_SAMPLING_CONFIG),
305
- runtimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
382
+ runtimeVersion: decoRuntimeVersion,
383
+ deploymentEnvironment,
306
384
  });
307
385
  }
308
386
 
387
+ /**
388
+ * Test-only: clear boot state so successive tests can re-boot
389
+ * `instrumentWorker` with different options. Do not call from app code.
390
+ */
391
+ export function _resetBootStateForTests(): void {
392
+ booted = false;
393
+ bootState = null;
394
+ spanAttributeFloor = {};
395
+ setLoggerAttributeFloor({});
396
+ }
397
+
309
398
  // ---------------------------------------------------------------------------
310
399
  // Resource attributes
311
400
  // ---------------------------------------------------------------------------
@@ -345,7 +434,7 @@ function buildResource(input: ResourceInput): Resource {
345
434
  * Drift is acceptable — this attribute is for operator triage, not for
346
435
  * billing / SLOs.
347
436
  */
348
- const DECO_RUNTIME_VERSION = "2.28.2";
437
+ const DECO_RUNTIME_VERSION = "4.4.0";
349
438
 
350
439
  function parseHeaders(str?: string): Record<string, string> {
351
440
  if (!str) return {};
@@ -365,14 +454,6 @@ function numericEnv(value: unknown, fallback: number): number {
365
454
  return Number.isFinite(n) && n > 0 ? n : fallback;
366
455
  }
367
456
 
368
- function joinPath(base: string, path: string): string {
369
- if (!base) return path;
370
- if (base.endsWith("/")) base = base.slice(0, -1);
371
- if (!path.startsWith("/")) path = "/" + path;
372
- if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
373
- return base + path;
374
- }
375
-
376
457
  function cryptoRandomId(): string {
377
458
  // crypto.randomUUID is universally available in CF Workers + Node 19+.
378
459
  try {