@decocms/blocks 7.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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * Single observability entry point for `@decocms/start` on Cloudflare Workers.
3
+ *
4
+ * `instrumentWorker(handler, options)` wraps a Worker handler with:
5
+ * - structured JSON logger (stdout → Cloudflare Workers Logs) — always
6
+ * - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
7
+ * - OTLP/HTTP metrics exporter, direct POST to `deco-otel-ingest`
8
+ * `/v1/metrics` — when `env.DECO_OTEL_METRICS_ENDPOINT` is set. Buffered
9
+ * per-isolate, flushed via `ctx.waitUntil` at the end of every request.
10
+ * See `otelHttpMeter.ts` for the aggregation + flush model.
11
+ * - OTLP/HTTP error-log channel, direct POST to `deco-otel-ingest`
12
+ * `/v1/logs` — when `env.DECO_OTEL_LOGS_ENDPOINT` is set. Carries
13
+ * `logger.error(...)` calls at 100% capture (rate-limited per
14
+ * isolate) so head-sampled CF Destinations don't drop them.
15
+ * See `otelHttpLog.ts` for the rate limiter + flush model.
16
+ * - Bridges framework-internal `withTracing()` calls onto the global
17
+ * `@opentelemetry/api` tracer, stamping `deco.*` attributes on every span
18
+ * so they survive Cloudflare's platform-managed trace export
19
+ *
20
+ * **Transport split.** Logs (info/warn) and traces flow through Cloudflare
21
+ * Destinations (configured in `wrangler.jsonc`
22
+ * `observability.{logs,traces}.destinations`). Metrics are NOT supported
23
+ * by Destinations today (CF only exports OTLP for logs and traces), so the
24
+ * framework POSTs them directly. Errors travel BOTH paths — via
25
+ * `console.error` (sampled by CF Destinations) and direct POST (100%
26
+ * capture). Same OTLP/HTTP JSON wire format, same ingest Worker.
27
+ *
28
+ * Required `wrangler.jsonc` block (run `scripts/migrate-to-cf-observability.ts`
29
+ * to inject this automatically). Sampling defaults follow the fleet-scale cost
30
+ * model documented in `docs/observability.md`:
31
+ * ```jsonc
32
+ * "observability": {
33
+ * "enabled": true,
34
+ * "logs": { "enabled": true, "invocation_logs": true,
35
+ * "head_sampling_rate": 1, "persist": true },
36
+ * "traces": { "enabled": true,
37
+ * "head_sampling_rate": 0.01, "persist": true }
38
+ * },
39
+ * "version_metadata": { "binding": "CF_VERSION_METADATA" },
40
+ * "analytics_engine_datasets": [{ "binding": "DECO_METRICS",
41
+ * "dataset": "deco_metrics_my_site" }]
42
+ * ```
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * // worker-entry.ts
47
+ * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
48
+ * import { instrumentWorker } from "@decocms/start/sdk/otel";
49
+ *
50
+ * const handler = createDecoWorkerEntry(serverEntry, options);
51
+ * export default instrumentWorker(handler, { serviceName: "my-store" });
52
+ * ```
53
+ *
54
+ * **Future ClickHouse path.** When a co-deployed OTel collector lands, an
55
+ * exporter that pushes spans + logs + metrics to that collector will live in
56
+ * `./otelAdapters/clickhouseCollector.ts` (today: documented stub that throws).
57
+ * The `withTracing` / `recordRequestMetric` / `logger` instrumentation surface
58
+ * does not change — only the transport layer wires up.
59
+ */
60
+
61
+ import { SpanStatusCode, trace } from "@opentelemetry/api";
62
+ // OTel community SemConv constants — official attribute names from the OTel
63
+ // specification. Importing from the package (rather than typing strings)
64
+ // guards against typos, surfaces deprecations via TypeScript, and tracks
65
+ // upstream spec changes via the dep version.
66
+ // https://opentelemetry.io/docs/specs/semconv/resource/
67
+ // https://www.npmjs.com/package/@opentelemetry/semantic-conventions
68
+ //
69
+ // Stable attributes (graduated in upstream SemConv) come from the default
70
+ // entry point. Incubating attributes (still experimental upstream) come from
71
+ // the `/incubating` subpath — using them is a deliberate trade-off: stable
72
+ // attribute names + a stability disclaimer documented in the conventions
73
+ // guide (see context/04_engineering/o11y/02-conventions.md §4.2).
74
+ import {
75
+ ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
76
+ ATTR_SERVICE_INSTANCE_ID,
77
+ ATTR_SERVICE_NAME,
78
+ ATTR_SERVICE_VERSION,
79
+ } from "@opentelemetry/semantic-conventions";
80
+ import {
81
+ ATTR_CLOUD_PLATFORM,
82
+ ATTR_CLOUD_PROVIDER,
83
+ } from "@opentelemetry/semantic-conventions/incubating";
84
+ import { createCompositeLogger, createCompositeMeter } from "./composite";
85
+ import { configureLogger, defaultLoggerAdapter, logger, setLogLevel, setLoggerAttributeFloor, type LogLevel } from "./logger";
86
+ import { METRIC_METADATA } from "../middleware/observability";
87
+ import { configureMeter, configureTracer, getActiveSpan } from "./observability";
88
+ import { createAnalyticsEngineMeterAdapter } from "./otelAdapters";
89
+ import { createOtlpHttpLogAdapter, type OtlpHttpLog } from "./otelHttpLog";
90
+ import { createOtlpHttpMeterAdapter, type OtlpHttpMeter } from "./otelHttpMeter";
91
+ import {
92
+ createOtlpHttpTracerAdapter,
93
+ type OtlpHttpTracer,
94
+ type TraceContext,
95
+ } from "./otelHttpTracer";
96
+ import { RequestContext } from "./requestContext";
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Types
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export interface OtelOptions {
103
+ /** Logical service name. Falls back to `env.DECO_SITE_NAME`, then "deco-site". */
104
+ serviceName?: string;
105
+ /** Env var name holding the AE binding. Defaults to `"DECO_METRICS"`. */
106
+ analyticsEngineBindingName?: string;
107
+ /** Set to `false` to disable AE even when the binding is present. */
108
+ analyticsEngineEnabled?: boolean;
109
+ /**
110
+ * Env var name holding the OTLP/HTTP metrics endpoint. Defaults to
111
+ * `"DECO_OTEL_METRICS_ENDPOINT"`. When the env var is set (and
112
+ * `otlpMetricsEnabled !== false`), `instrumentWorker` wires a direct-POST
113
+ * metrics exporter and flushes the buffer via `ctx.waitUntil` at the
114
+ * end of every request. Cooldown + buffer cap are controlled by
115
+ * `OtlpHttpMeterOptions`.
116
+ */
117
+ otlpMetricsEndpointEnvVar?: string;
118
+ /** Set to `false` to disable the OTLP/HTTP metrics exporter explicitly. */
119
+ otlpMetricsEnabled?: boolean;
120
+ /**
121
+ * Env var name holding the OTLP/HTTP logs endpoint — the primary log
122
+ * transport. Defaults to `"DECO_OTEL_LOGS_ENDPOINT"`. When set (and
123
+ * `otlpLogsEnabled !== false`), `instrumentWorker` patches `console.*`
124
+ * at boot so all application and third-party log calls route through
125
+ * the framework logger → direct-POST to this endpoint.
126
+ */
127
+ otlpLogsEndpointEnvVar?: string;
128
+ /** Set to `false` to disable the OTLP/HTTP logs exporter explicitly. */
129
+ otlpLogsEnabled?: boolean;
130
+ /**
131
+ * Minimum log level forwarded via direct-POST. Defaults to `"info"` per
132
+ * the OpenTelemetry specification. Set to `"warn"` to forward only errors
133
+ * and warnings, or `"debug"` to capture everything (can be high volume).
134
+ *
135
+ * Precedence: env var (`otlpLogsMinLevelEnvVar`, default
136
+ * `DECO_OTEL_LOGS_MIN_LEVEL`) > this option > `"info"`. Invalid env
137
+ * values fall through silently.
138
+ */
139
+ otlpLogsMinLevel?: LogLevel;
140
+ /**
141
+ * Env var name to read the minimum log level from. Defaults to
142
+ * `"DECO_OTEL_LOGS_MIN_LEVEL"`. Value MUST be one of
143
+ * `"debug"`, `"info"`, `"warn"`, `"error"`.
144
+ */
145
+ otlpLogsMinLevelEnvVar?: string;
146
+ /**
147
+ * Extra HTTP headers sent on every OTLP POST (logs, traces, metrics).
148
+ * Merged with the adapter default (`Content-Type: application/json`).
149
+ *
150
+ * Resolved from `DECO_OTEL_HEADERS` env var (format: `key=value,key2=value2`)
151
+ * when not set programmatically. Useful for collector auth:
152
+ *
153
+ * ```
154
+ * DECO_OTEL_HEADERS=Authorization=Bearer <token>
155
+ * ```
156
+ */
157
+ otlpHeaders?: Record<string, string>;
158
+ /**
159
+ * Env var name to read extra OTLP headers from. Defaults to `"DECO_OTEL_HEADERS"`.
160
+ * Format: `key=value,key2=value2`.
161
+ */
162
+ otlpHeadersEnvVar?: string;
163
+ /**
164
+ * Authorization header value sent on every OTLP POST.
165
+ * Equivalent to `otlpHeaders: { authorization: "<value>" }` but intended
166
+ * for secrets: store via `wrangler secret put DECO_OTEL_AUTH_TOKEN` so the
167
+ * token is never committed to the repo. Non-secret headers go in
168
+ * `DECO_OTEL_HEADERS` (plaintext in `wrangler.jsonc`).
169
+ *
170
+ * Example: `DECO_OTEL_AUTH_TOKEN=Bearer eyJhbGci...`
171
+ *
172
+ * Precedence (lowest → highest): auth token env var → `DECO_OTEL_HEADERS`
173
+ * env var → `otlpHeaders` option. An explicit `authorization` key in
174
+ * `DECO_OTEL_HEADERS` or `otlpHeaders` overrides this value.
175
+ */
176
+ otlpAuthToken?: string;
177
+ /**
178
+ * Env var name to read the auth token from. Defaults to `"DECO_OTEL_AUTH_TOKEN"`.
179
+ */
180
+ otlpAuthTokenEnvVar?: string;
181
+ /**
182
+ * Env var name holding the OTLP/HTTP traces endpoint used by the
183
+ * direct-POST span exporter. Defaults to `"DECO_OTEL_TRACES_ENDPOINT"`.
184
+ * When set (and `otlpTracesEnabled !== false`), framework `deco.*`
185
+ * spans created via `withTracing` are captured, sampled, and POSTed
186
+ * directly to this endpoint. Flushed alongside metrics via
187
+ * `ctx.waitUntil`.
188
+ *
189
+ * Without this endpoint configured, `withTracing` falls back to the
190
+ * `@opentelemetry/api` global tracer (the legacy CF auto-instrumentation
191
+ * path). The framework registers BOTH adapters when a traces endpoint
192
+ * is set so CF auto-spans stay intact AND framework spans get
193
+ * direct-POSTed to ClickHouse. See Phase 3 in
194
+ * `MIGRATION_TOOLING_PLAN.md`.
195
+ */
196
+ otlpTracesEndpointEnvVar?: string;
197
+ /** Set to `false` to disable the OTLP/HTTP traces exporter explicitly. */
198
+ otlpTracesEnabled?: boolean;
199
+ /**
200
+ * Head sampling rate for framework spans direct-POSTed via the OTLP
201
+ * traces endpoint. Default `0.01` matches the CF Destinations
202
+ * `traces.head_sampling_rate` recommendation. Decisions are consistent
203
+ * per trace (hash of `trace_id`), so child spans (`deco.cache.lookup`,
204
+ * `deco.cms.resolvePage`, ...) are kept iff their root
205
+ * `deco.http.request` span is kept. Set to `1` to capture every trace
206
+ * (preview / debug only — production cost grows linearly).
207
+ *
208
+ * Precedence: env var (`otlpTracesSamplingRateEnvVar`, default
209
+ * `DECO_OTEL_TRACES_SAMPLING_RATE`) > this option > `0.01`. Reading from
210
+ * env lets local-dev opt into 100% sampling via `.dev.vars` without
211
+ * changing the worker entry.
212
+ */
213
+ otlpTracesSamplingRate?: number;
214
+ /**
215
+ * Env var name to read the head sampling rate from when set on `env`.
216
+ * Defaults to `DECO_OTEL_TRACES_SAMPLING_RATE`. Value MUST be a finite
217
+ * number in `[0, 1]`. Invalid values are ignored (falls back to
218
+ * `otlpTracesSamplingRate` then `0.01`).
219
+ */
220
+ otlpTracesSamplingRateEnvVar?: string;
221
+ /**
222
+ * When `true` (or when `DECO_OTEL_ERROR_PROMOTION=true` is set), any
223
+ * `logger.error()` call with an active trace context marks that trace for
224
+ * export even if head sampling did not select it. Useful for ensuring
225
+ * errors always have a trace in ClickHouse without raising the global
226
+ * sampling rate.
227
+ *
228
+ * Disabled by default — enable via env var or this option once validated
229
+ * in production. Precedence: env var (`otlpTracesErrorPromotionEnvVar`,
230
+ * default `DECO_OTEL_ERROR_PROMOTION`) > this option > `false`.
231
+ */
232
+ otlpTracesErrorPromotion?: boolean;
233
+ /**
234
+ * Env var name to read the error promotion flag from. Defaults to
235
+ * `"DECO_OTEL_ERROR_PROMOTION"`. Value must be `"true"` to enable.
236
+ */
237
+ otlpTracesErrorPromotionEnvVar?: string;
238
+ /**
239
+ * Sampling rate applied to error-promoted traces, 0.0..1.0. Default `0.1`
240
+ * (promote 10% of error traces). Lower values cap ClickHouse volume when
241
+ * errors are frequent. Uses the same FNV-1a hash as head sampling.
242
+ *
243
+ * Precedence: env var (`otlpTracesErrorPromotionRateEnvVar`, default
244
+ * `DECO_OTEL_ERROR_PROMOTION_RATE`) > this option > `0.1`.
245
+ */
246
+ otlpTracesErrorPromotionRate?: number;
247
+ /**
248
+ * Env var name to read the error promotion rate from. Defaults to
249
+ * `"DECO_OTEL_ERROR_PROMOTION_RATE"`. Value must be a finite number in
250
+ * `[0, 1]`. Invalid values are ignored.
251
+ */
252
+ otlpTracesErrorPromotionRateEnvVar?: string;
253
+ /** Test seam — replace the global `fetch` used by the traces exporter. */
254
+ otlpTracesFetchImpl?: typeof fetch;
255
+ /**
256
+ * Version of `@decocms/start` to advertise as `deco.runtime.version`
257
+ * on every span and every log line. Falls back to a build-time constant;
258
+ * override only for tests.
259
+ */
260
+ decoRuntimeVersion?: string;
261
+ /** Optional `@decocms/apps` version, stamped as `deco.apps.version`. */
262
+ decoAppsVersion?: string;
263
+ /**
264
+ * Test seam — replace the global `fetch` used by the OTLP metrics
265
+ * exporter without touching the worker's outbound fetch.
266
+ */
267
+ otlpMetricsFetchImpl?: typeof fetch;
268
+ /** Test seam — replace the global `fetch` used by the logs exporter. */
269
+ otlpLogsFetchImpl?: typeof fetch;
270
+ }
271
+
272
+ interface WorkerExecutionContext {
273
+ waitUntil(promise: Promise<unknown>): void;
274
+ passThroughOnException(): void;
275
+ }
276
+
277
+ interface WorkerHandler {
278
+ fetch(
279
+ request: Request,
280
+ env: Record<string, unknown>,
281
+ ctx: WorkerExecutionContext,
282
+ ): Promise<Response>;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Boot state — pinned to globalThis via Symbol.for so multiple bundled
287
+ // copies of this module converge on the SAME state.
288
+ //
289
+ // Why: Vite (and any bundler chunking by entry) can produce more than one
290
+ // inlined copy of this file when multiple package entries — e.g.
291
+ // `@decocms/start/sdk/otel` and `@decocms/start/sdk/workerEntry` — both
292
+ // pull it transitively. With plain module-scoped `let` variables, the
293
+ // auto-wrap path inside `createDecoWorkerEntry` ends up writing the meter
294
+ // into Copy A's `otlpMeter` while the per-request `recordRequestMetric`
295
+ // closure reads from Copy B's empty `otlpMeter`. Net effect in prod:
296
+ // `bootObservability` runs (we observed the very first `POST /v1/metrics`
297
+ // from miess-tanstack), but every subsequent request's `flush()` finds an
298
+ // empty buffer because the meter the framework writes into is a different
299
+ // instance from the one the exporter drains.
300
+ //
301
+ // `observability.ts` already uses this pattern (see the `STATE_KEY` block
302
+ // there); the comment there flags this exact failure mode. The reason it
303
+ // took so long to surface here is that until PR #232 added the auto-wrap
304
+ // inside `createDecoWorkerEntry`, sites always called `instrumentWorker`
305
+ // once from their own `worker-entry.ts` — a single bundle entry — so
306
+ // nobody hit the duplication path.
307
+ //
308
+ // CF Workers guarantee one `globalThis` per isolate, so there's no risk
309
+ // of cross-isolate bleed. Symbol.for keeps the registry stable across
310
+ // hot reloads in `wrangler dev` too.
311
+ // ---------------------------------------------------------------------------
312
+
313
+ type OrigConsole = Pick<typeof console, "log" | "info" | "warn" | "error" | "debug">;
314
+
315
+ interface BootState {
316
+ booted: boolean;
317
+ otlpMeter: OtlpHttpMeter | null;
318
+ otlpLog: OtlpHttpLog | null;
319
+ otlpTracer: OtlpHttpTracer | null;
320
+ spanAttributeFloor: Record<string, string>;
321
+ origConsole: OrigConsole | null;
322
+ }
323
+
324
+ const BOOT_STATE_KEY = Symbol.for("@decocms/start/sdk/otel/boot.v1");
325
+
326
+ function getBootState(): BootState {
327
+ const g = globalThis as Record<symbol, unknown>;
328
+ if (!g[BOOT_STATE_KEY]) {
329
+ g[BOOT_STATE_KEY] = {
330
+ booted: false,
331
+ otlpMeter: null,
332
+ otlpLog: null,
333
+ otlpTracer: null,
334
+ spanAttributeFloor: {},
335
+ origConsole: null,
336
+ } satisfies BootState;
337
+ }
338
+ return g[BOOT_STATE_KEY] as BootState;
339
+ }
340
+
341
+ /**
342
+ * Per-request inbound W3C trace context, parsed from the `traceparent`
343
+ * header at request entry. Read by the OTLP trace exporter when it
344
+ * creates a root span so we honor remote parents and the `sampled`
345
+ * flag. Stored on a request-scoped slot (via `RequestContext.bag`) so
346
+ * concurrent requests in the same isolate don't trample each other.
347
+ */
348
+ const TRACE_CTX_BAG_KEY = "deco.observability.traceContext.v1";
349
+ const DEBUG_SAMPLED_BAG_KEY = "deco.observability.debugSampled.v1";
350
+
351
+ function getRequestTraceContext(): TraceContext | null {
352
+ return RequestContext.getBag<TraceContext>(TRACE_CTX_BAG_KEY) ?? null;
353
+ }
354
+
355
+ function getDebugSampled(): boolean {
356
+ return RequestContext.getBag<boolean>(DEBUG_SAMPLED_BAG_KEY) ?? false;
357
+ }
358
+
359
+ /**
360
+ * Public entry point used by `workerEntry.ts` to stash the parsed
361
+ * traceparent for the OTLP tracer to consume. Exported (not just
362
+ * module-local) because the parser lives in `otelHttpTracer.ts` and
363
+ * the call site is `workerEntry.ts`.
364
+ */
365
+ export function _setRequestTraceContext(ctx: TraceContext | null): void {
366
+ if (ctx) RequestContext.setBag(TRACE_CTX_BAG_KEY, ctx);
367
+ }
368
+
369
+ /**
370
+ * Called by `workerEntry.ts` when the inbound URL contains `?__d=<any>`.
371
+ * Forces trace sampling for the current request regardless of `headSamplingRate`.
372
+ * Useful for debugging individual requests in production without changing
373
+ * the global sampling rate.
374
+ */
375
+ export function _setDebugSampled(): void {
376
+ RequestContext.setBag(DEBUG_SAMPLED_BAG_KEY, true);
377
+ }
378
+
379
+ /**
380
+ * Per-span attribute floor — stamped on every span we create via
381
+ * `configureTracer().startSpan(...)`. Lives on the shared `BootState`
382
+ * (see above) so framework code paths that read it through different
383
+ * bundled copies of this module all see the floor `bootObservability`
384
+ * just installed.
385
+ */
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // instrumentWorker
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Wraps a Cloudflare Worker handler with the @decocms/start observability
393
+ * stack:
394
+ * - structured JSON logger to console.* (CF captures via observability.logs)
395
+ * - AE meter (when `DECO_METRICS` binding present)
396
+ * - bridge from framework-internal `withTracing()` to `@opentelemetry/api`
397
+ * global tracer (CF observability.traces ingests via auto-instrumentation
398
+ * + the global-tracer hook)
399
+ *
400
+ * No external destinations, no OTLP transport. Forwarding to a future
401
+ * OTel collector for ClickHouse will live behind a separate adapter
402
+ * (see `./otelAdapters/clickhouseCollector.ts`).
403
+ */
404
+ export function instrumentWorker(
405
+ handler: WorkerHandler,
406
+ options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
407
+ ): WorkerHandler {
408
+ // Default tracer bridge — delegates to `@opentelemetry/api` global. When
409
+ // `bootObservability` discovers `DECO_OTEL_TRACES_ENDPOINT`, it composes
410
+ // this bridge with the direct-POST OTLP tracer so framework spans flow to
411
+ // BOTH the CF dashboard AND ClickHouse (the bridge stays a no-op when CF
412
+ // tracing isn't configured, which is the common case today). See
413
+ // `configureTracerStack` below.
414
+ configureTracer(buildOtelApiTracer());
415
+
416
+ return {
417
+ async fetch(request, env, ctx) {
418
+ const opts =
419
+ typeof options === "function" ? options(env as Record<string, unknown>) : options;
420
+ bootObservability(opts, env as Record<string, unknown>);
421
+ // RequestContext.run + setRuntimeEnv(env) is handled inside
422
+ // workerEntry.ts on the inner handler — instrumentWorker does
423
+ // NOT re-wrap so we don't double-enter AsyncLocalStorage.
424
+ try {
425
+ return await handler.fetch(request, env, ctx);
426
+ } finally {
427
+ // Drain the OTLP metrics + error-log + traces buffers via
428
+ // ctx.waitUntil so no POST blocks the response. Each exporter
429
+ // throttles itself per isolate — calling on every request is
430
+ // cheap; the network only fires when the cooldown elapses or
431
+ // the buffer fills.
432
+ const state = getBootState();
433
+ if (state.otlpMeter) {
434
+ try {
435
+ ctx.waitUntil(state.otlpMeter.flush());
436
+ } catch {
437
+ /* ctx.waitUntil throwing is benign — never block the response */
438
+ }
439
+ }
440
+ if (state.otlpLog) {
441
+ try {
442
+ ctx.waitUntil(state.otlpLog.flush());
443
+ } catch {
444
+ /* swallow */
445
+ }
446
+ }
447
+ if (state.otlpTracer) {
448
+ try {
449
+ ctx.waitUntil(state.otlpTracer.flush());
450
+ } catch {
451
+ /* swallow */
452
+ }
453
+ }
454
+ }
455
+ },
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Build the legacy `@opentelemetry/api` global-tracer bridge. Stays a
461
+ * no-op when no global TracerProvider is registered — same outcome as
462
+ * the historical configuration.
463
+ */
464
+ function buildOtelApiTracer(): import("../middleware/observability").TracerAdapter {
465
+ return {
466
+ startSpan: (name, attrs) => {
467
+ const merged = { ...getBootState().spanAttributeFloor, ...(attrs ?? {}) };
468
+ const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
469
+ return {
470
+ end: () => span.end(),
471
+ setError: (error) => {
472
+ const message = error instanceof Error ? error.message : String(error);
473
+ if (error instanceof Error) span.recordException(error);
474
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
475
+ },
476
+ setAttribute: (k, v) => {
477
+ span.setAttribute(k, v);
478
+ if (k === "http.status_code" && typeof v === "number" && v >= 400) {
479
+ span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${v}` });
480
+ }
481
+ },
482
+ spanContext: () => {
483
+ const ctx = span.spanContext();
484
+ return {
485
+ traceId: ctx.traceId,
486
+ spanId: ctx.spanId,
487
+ traceFlags: ctx.traceFlags,
488
+ };
489
+ },
490
+ };
491
+ },
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Wire the framework tracer. When the OTLP traces endpoint is configured,
497
+ * compose the direct-POST tracer ALONGSIDE the `@opentelemetry/api` bridge
498
+ * via a fanout adapter so:
499
+ * 1. `withTracing` calls feed BOTH adapters.
500
+ * 2. A child span's `spanContext()` reports the direct-POST span's IDs
501
+ * (the bridge is best-effort — if CF tracing isn't installed those
502
+ * IDs are zeros anyway).
503
+ *
504
+ * When no traces endpoint is configured, fall back to the bridge alone —
505
+ * preserves the legacy behavior for sites that haven't bumped wrangler.
506
+ */
507
+ function configureTracerStack(otlpAdapter: OtlpHttpTracer | null): void {
508
+ const bridge = buildOtelApiTracer();
509
+ if (!otlpAdapter) {
510
+ configureTracer(bridge);
511
+ return;
512
+ }
513
+ // Compose. The OTLP adapter is the "primary" (its IDs win for
514
+ // `spanContext()` because callers downstream use them for trace
515
+ // propagation). The bridge is best-effort — fed the same name/attrs
516
+ // so CF Workers Observability still sees the spans if that channel
517
+ // is enabled in wrangler.jsonc.
518
+ configureTracer({
519
+ startSpan(name, attrs) {
520
+ const merged = { ...getBootState().spanAttributeFloor, ...(attrs ?? {}) };
521
+ const primary = otlpAdapter.startSpan(name, merged);
522
+ const secondary = bridge.startSpan(name, merged);
523
+ return {
524
+ end(): void {
525
+ try {
526
+ primary.end();
527
+ } finally {
528
+ try {
529
+ secondary.end();
530
+ } catch {
531
+ /* swallow */
532
+ }
533
+ }
534
+ },
535
+ setError(error: unknown): void {
536
+ try {
537
+ primary.setError?.(error);
538
+ } finally {
539
+ try {
540
+ secondary.setError?.(error);
541
+ } catch {
542
+ /* swallow */
543
+ }
544
+ }
545
+ },
546
+ setAttribute(key: string, value: string | number | boolean): void {
547
+ primary.setAttribute?.(key, value);
548
+ try {
549
+ secondary.setAttribute?.(key, value);
550
+ } catch {
551
+ /* swallow */
552
+ }
553
+ },
554
+ spanContext() {
555
+ // The OTLP adapter owns the canonical IDs — those are the IDs
556
+ // we propagate downstream via `traceparent` headers.
557
+ return primary.spanContext?.() ?? secondary.spanContext?.() ?? {
558
+ traceId: "",
559
+ spanId: "",
560
+ traceFlags: 0,
561
+ };
562
+ },
563
+ };
564
+ },
565
+ });
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Boot — wires the loggers/meters once (per worker isolate)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ /**
573
+ * Replaces `console.*` with thin shims that route every call through the
574
+ * framework `logger`, which forwards to the configured adapter (direct-POST
575
+ * OTLP when active). The original functions are saved on `BootState` so
576
+ * `_resetBootStateForTests()` can restore them between test runs.
577
+ *
578
+ * A re-entrancy guard (`busy`) prevents the loop that would occur if the
579
+ * active logger adapter ever calls back into `console.*` (e.g. the
580
+ * `defaultLoggerAdapter` in dev mode). Without the guard:
581
+ * console.error → logger.error → defaultLoggerAdapter → console.error → …
582
+ *
583
+ * Only called when the OTLP logs endpoint is configured. In dev (no OTLP),
584
+ * `console.*` is left untouched so `wrangler dev` / `wrangler tail` keep
585
+ * showing output normally.
586
+ */
587
+ function patchConsole(state: BootState): void {
588
+ if (state.origConsole) return; // already patched this isolate
589
+
590
+ const orig: OrigConsole = {
591
+ log: console.log.bind(console),
592
+ info: console.info.bind(console),
593
+ warn: console.warn.bind(console),
594
+ error: console.error.bind(console),
595
+ debug: console.debug.bind(console),
596
+ };
597
+ state.origConsole = orig;
598
+
599
+ let busy = false;
600
+
601
+ const forward = (level: LogLevel, args: unknown[]): void => {
602
+ if (busy) return;
603
+ busy = true;
604
+ try {
605
+ const msg = args
606
+ .map((a) => {
607
+ if (typeof a === "string") return a;
608
+ try { return JSON.stringify(a); } catch { return String(a); }
609
+ })
610
+ .join(" ");
611
+ logger[level](msg);
612
+ } finally {
613
+ busy = false;
614
+ }
615
+ };
616
+
617
+ console.log = (...args: unknown[]) => forward("info", args);
618
+ console.info = (...args: unknown[]) => forward("info", args);
619
+ console.warn = (...args: unknown[]) => forward("warn", args);
620
+ console.error = (...args: unknown[]) => forward("error", args);
621
+ console.debug = (...args: unknown[]) => forward("debug", args);
622
+ }
623
+
624
+ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
625
+ const state = getBootState();
626
+ if (state.booted) return;
627
+
628
+ // Capture the original console.warn BEFORE patchConsole() runs. The onError
629
+ // callbacks below need a direct channel to console that bypasses the logger
630
+ // to avoid routing exporter-level warnings back through the OTLP adapter
631
+ // that is currently failing.
632
+ const warnDirect = console.warn.bind(console);
633
+
634
+ const serviceName = opts.serviceName ?? (env.DECO_SITE_NAME as string | undefined) ?? "deco-site";
635
+ const decoRuntimeVersion = opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION;
636
+ const deploymentEnvironment = (env.DECO_ENV_NAME as string | undefined) ?? "production";
637
+ const serviceVersion = (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id;
638
+
639
+ // service.name and service.version are OTel resource conventions. CF's
640
+ // managed export already stamps service.name at the resource level (from
641
+ // the Worker name in wrangler.jsonc) but framework-created spans don't
642
+ // inherit resource attrs, so we stamp them per-span/per-log defensively.
643
+ // service.version comes from the CF_VERSION_METADATA binding which is
644
+ // unique per deployment — needed to correlate regressions with releases.
645
+ //
646
+ // service.instance.id (OTel SemConv required for distributed services) is a
647
+ // per-isolate UUID generated at boot. Distinguishes parallel isolates on the
648
+ // same deploy — required so the System Health Agent can attribute behavior
649
+ // (memory creep, slow cold starts) to a specific instance.
650
+ // https://opentelemetry.io/docs/specs/semconv/resource/#service
651
+ //
652
+ // cloud.provider / cloud.platform are stamped statically here because every
653
+ // isolate runs on CF Workers. cloud.region (the CF colo) is per-request, not
654
+ // per-isolate, so it's NOT in the resource floor — it MUST be attached at
655
+ // the span level by the request handler.
656
+ // https://opentelemetry.io/docs/specs/semconv/resource/cloud/
657
+ //
658
+ // Note: `deco.runtime.version` and `deco.apps.version` are Deco extensions
659
+ // (not in OTel SemConv), so they remain as string literals.
660
+ const floor: Record<string, string> = {
661
+ [ATTR_SERVICE_NAME]: serviceName,
662
+ [ATTR_SERVICE_INSTANCE_ID]: crypto.randomUUID(),
663
+ [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: deploymentEnvironment,
664
+ [ATTR_CLOUD_PROVIDER]: "cloudflare",
665
+ [ATTR_CLOUD_PLATFORM]: "cloudflare_workers",
666
+ "deco.runtime.version": decoRuntimeVersion,
667
+ };
668
+ if (serviceVersion) floor[ATTR_SERVICE_VERSION] = serviceVersion;
669
+ if (opts.decoAppsVersion) floor["deco.apps.version"] = opts.decoAppsVersion;
670
+
671
+ // Stamp on every span we create. CF-managed trace export emits its own
672
+ // resource attribute set, so legacy resource attrs don't survive.
673
+ // Stamping per-span preserves the dimensions dashboards / saved searches
674
+ // filter on.
675
+ state.spanAttributeFloor = floor;
676
+
677
+ // Stamp on every log record. CF Workers Logs ships the JSON body
678
+ // verbatim — without this floor, panels grouping logs by these
679
+ // dimensions return empty. Caller-supplied `attrs` still win on
680
+ // key collision (see logger.ts).
681
+ setLoggerAttributeFloor(floor);
682
+
683
+ // Logger — two paths composed:
684
+ //
685
+ // - `defaultLoggerAdapter`: structured JSON to `console.*`. CF
686
+ // Workers Logs captures this and CF Destinations forwards a
687
+ // `logs.head_sampling_rate` fraction to `deco-otel-ingest/v1/logs`.
688
+ // Carries debug / info / warn / error.
689
+ // - `otlpLog.adapter`: direct POST to `/v1/logs` for level=error
690
+ // only, rate-limited (default 100/min, burst 20), buffered, flushed
691
+ // via `ctx.waitUntil` at request end. Guarantees ≥99% error capture
692
+ // regardless of the CF Destinations sampling rate. See
693
+ // `otelHttpLog.ts` for the aggregation + rate-limit details.
694
+ //
695
+ // The two paths land in the SAME `default.otel_logs` table, so the
696
+ // ingestor's existing PII redaction applies uniformly and dashboards
697
+ // need no changes. Records from the direct-POST path are
698
+ // distinguishable from CF-Destinations records by `ScopeName =
699
+ // "@decocms/start"` if needed (CF stamps its own scope).
700
+ const otlpLogsEnvVar = opts.otlpLogsEndpointEnvVar ?? "DECO_OTEL_LOGS_ENDPOINT";
701
+ const otlpLogsEndpoint = (env[otlpLogsEnvVar] as string | undefined) ?? "";
702
+ const otlpLogsEnabled =
703
+ opts.otlpLogsEnabled !== false && otlpLogsEndpoint.length > 0;
704
+ // Minimum log level precedence: env var > options > "info" default (OTel spec).
705
+ // Invalid env values fall through silently.
706
+ const otlpLogsMinLevelEnvVar =
707
+ opts.otlpLogsMinLevelEnvVar ?? "DECO_OTEL_LOGS_MIN_LEVEL";
708
+ const otlpLogsMinLevelFromEnv = (
709
+ (env[otlpLogsMinLevelEnvVar] as string | undefined) ?? ""
710
+ ).toLowerCase();
711
+ const validLogLevels = ["debug", "info", "warn", "error"] as const;
712
+ const otlpLogsMinLevel: LogLevel =
713
+ (validLogLevels as readonly string[]).includes(otlpLogsMinLevelFromEnv)
714
+ ? (otlpLogsMinLevelFromEnv as LogLevel)
715
+ : opts.otlpLogsMinLevel ?? "info";
716
+ // Sync the logger gate so logger.debug() calls are not silently dropped
717
+ // before reaching the OTLP adapter when minLevel is "debug" or "info".
718
+ setLogLevel(otlpLogsMinLevel);
719
+ const otlpHeadersEnvVar = opts.otlpHeadersEnvVar ?? "DECO_OTEL_HEADERS";
720
+ const otlpHeadersFromEnv = (env[otlpHeadersEnvVar] as string | undefined) ?? "";
721
+ const parsedEnvHeaders: Record<string, string> = {};
722
+ for (const pair of otlpHeadersFromEnv.split(",")) {
723
+ const eqIdx = pair.indexOf("=");
724
+ if (eqIdx > 0) {
725
+ parsedEnvHeaders[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
726
+ }
727
+ }
728
+ const otlpAuthTokenEnvVar = opts.otlpAuthTokenEnvVar ?? "DECO_OTEL_AUTH_TOKEN";
729
+ const otlpAuthToken = (env[otlpAuthTokenEnvVar] as string | undefined) ?? opts.otlpAuthToken ?? "";
730
+ const authHeader: Record<string, string> = otlpAuthToken ? { authorization: otlpAuthToken } : {};
731
+ // Priority: auth token (lowest) → DECO_OTEL_HEADERS env → otlpHeaders option (highest).
732
+ const otlpHeaders: Record<string, string> = { ...authHeader, ...parsedEnvHeaders, ...(opts.otlpHeaders ?? {}) };
733
+
734
+ const errorPromotionEnvVar =
735
+ opts.otlpTracesErrorPromotionEnvVar ?? "DECO_OTEL_ERROR_PROMOTION";
736
+ const errorPromotionEnabled =
737
+ (env[errorPromotionEnvVar] as string | undefined) === "true" ||
738
+ (opts.otlpTracesErrorPromotion ?? false);
739
+
740
+ if (otlpLogsEnabled) {
741
+ state.otlpLog = createOtlpHttpLogAdapter({
742
+ endpoint: otlpLogsEndpoint,
743
+ resourceAttributes: floor,
744
+ scopeVersion: decoRuntimeVersion,
745
+ minLevel: otlpLogsMinLevel,
746
+ headers: otlpHeaders,
747
+ fetchImpl: opts.otlpLogsFetchImpl,
748
+ promoteTrace: errorPromotionEnabled
749
+ ? (traceId) => state.otlpTracer?.promoteTrace(traceId)
750
+ : undefined,
751
+ onError: (kind, err) => {
752
+ // Use warnDirect (pre-patch console.warn) to avoid routing this
753
+ // warning back through the OTLP adapter that is currently failing.
754
+ try {
755
+ warnDirect(
756
+ JSON.stringify({
757
+ level: "warn",
758
+ msg: "otlp error-log exporter",
759
+ kind,
760
+ error: err instanceof Error ? err.message : String(err),
761
+ timestamp: new Date().toISOString(),
762
+ }),
763
+ );
764
+ } catch {
765
+ /* swallow */
766
+ }
767
+ },
768
+ });
769
+ } else {
770
+ state.otlpLog = null;
771
+ }
772
+
773
+ if (otlpLogsEnabled) {
774
+ // OTLP active: direct-POST is the sole log transport.
775
+ // console.* will be intercepted by patchConsole() below so all
776
+ // application and third-party console calls route here too.
777
+ // defaultLoggerAdapter is excluded to prevent workerd from capturing
778
+ // framework logs (which would make them visible to the tail worker a
779
+ // second time — the tail worker only needs to see what the worker
780
+ // itself cannot capture: exceededCpu / exceededMemory / isolate crashes).
781
+ configureLogger(createCompositeLogger([state.otlpLog!.adapter]));
782
+ } else {
783
+ // No OTLP endpoint — dev mode. Keep writing to console so wrangler dev /
784
+ // wrangler tail show output normally. No monkey-patch applied.
785
+ configureLogger(createCompositeLogger([defaultLoggerAdapter]));
786
+ }
787
+
788
+ // Meter — fan out to two backends when both are configured:
789
+ //
790
+ // - AE (binding `DECO_METRICS`): high-cardinality drill-down, queryable
791
+ // via the per-site dataset.
792
+ // - OTLP/HTTP (env `DECO_OTEL_METRICS_ENDPOINT`): SRE-grade rollups
793
+ // landed in ClickHouse `otel_metrics_{sum,gauge,histogram}` via the
794
+ // `deco-otel-ingest` Worker. Cumulative temporality, per-isolate
795
+ // buffer flushed by `ctx.waitUntil` in `instrumentWorker`.
796
+ //
797
+ // The two emitters are NOT redundant — AE keeps per-request per-path
798
+ // dimensions; OTLP carries the same metric names at coarser cardinality
799
+ // and survives outside the CF dashboard. Cost model in
800
+ // `docs/observability.md` accounts for both.
801
+ const aeBindingName = opts.analyticsEngineBindingName ?? "DECO_METRICS";
802
+ const aeEnabled = opts.analyticsEngineEnabled !== false && Boolean(env[aeBindingName]);
803
+ const aeAdapter = aeEnabled
804
+ ? createAnalyticsEngineMeterAdapter({ bindingName: aeBindingName })
805
+ : null;
806
+
807
+ const otlpEnvVar = opts.otlpMetricsEndpointEnvVar ?? "DECO_OTEL_METRICS_ENDPOINT";
808
+ const otlpEndpoint = (env[otlpEnvVar] as string | undefined) ?? "";
809
+ const otlpEnabled = opts.otlpMetricsEnabled !== false && otlpEndpoint.length > 0;
810
+ if (otlpEnabled) {
811
+ state.otlpMeter = createOtlpHttpMeterAdapter({
812
+ endpoint: otlpEndpoint,
813
+ resourceAttributes: floor,
814
+ scopeVersion: decoRuntimeVersion,
815
+ headers: otlpHeaders,
816
+ fetchImpl: opts.otlpMetricsFetchImpl,
817
+ metricMetadata: METRIC_METADATA,
818
+ onError: (kind, err) => {
819
+ try {
820
+ warnDirect(
821
+ JSON.stringify({
822
+ level: "warn",
823
+ msg: "otlp metrics exporter",
824
+ kind,
825
+ error: err instanceof Error ? err.message : String(err),
826
+ timestamp: new Date().toISOString(),
827
+ }),
828
+ );
829
+ } catch {
830
+ /* swallow */
831
+ }
832
+ },
833
+ });
834
+ } else {
835
+ state.otlpMeter = null;
836
+ }
837
+
838
+ const composedMeter = createCompositeMeter([aeAdapter, state.otlpMeter]);
839
+ // Composite meter is always installed — when both backends are absent the
840
+ // composite becomes a 0-element no-op via createCompositeMeter's filter.
841
+ configureMeter(composedMeter);
842
+
843
+ // Traces — direct-POST exporter for framework `deco.*` spans. Without
844
+ // this, `withTracing` delegates to the no-op `@opentelemetry/api`
845
+ // global tracer and every framework span silently disappears (the
846
+ // Phase 3 gap documented in `MIGRATION_TOOLING_PLAN.md`). Same
847
+ // transport pattern as metrics/error-logs — buffered, sampled by
848
+ // trace-id hash, flushed via `ctx.waitUntil`.
849
+ const otlpTracesEnvVar = opts.otlpTracesEndpointEnvVar ?? "DECO_OTEL_TRACES_ENDPOINT";
850
+ const otlpTracesEndpoint = (env[otlpTracesEnvVar] as string | undefined) ?? "";
851
+ const otlpTracesEnabled =
852
+ opts.otlpTracesEnabled !== false && otlpTracesEndpoint.length > 0;
853
+ if (otlpTracesEnabled) {
854
+ // Sampling rate precedence: env var > options > 0.01 default.
855
+ // Invalid env values (NaN, < 0, > 1) fall through silently.
856
+ const otlpTracesSamplingRateEnvVar =
857
+ opts.otlpTracesSamplingRateEnvVar ?? "DECO_OTEL_TRACES_SAMPLING_RATE";
858
+ const samplingRateFromEnv = Number.parseFloat(
859
+ (env[otlpTracesSamplingRateEnvVar] as string | undefined) ?? "",
860
+ );
861
+ const samplingRateOverride =
862
+ Number.isFinite(samplingRateFromEnv) &&
863
+ samplingRateFromEnv >= 0 &&
864
+ samplingRateFromEnv <= 1
865
+ ? samplingRateFromEnv
866
+ : undefined;
867
+ const errorPromotionRateEnvVar =
868
+ opts.otlpTracesErrorPromotionRateEnvVar ?? "DECO_OTEL_ERROR_PROMOTION_RATE";
869
+ const errorPromotionRateFromEnv = Number.parseFloat(
870
+ (env[errorPromotionRateEnvVar] as string | undefined) ?? "",
871
+ );
872
+ const errorPromotionRate =
873
+ Number.isFinite(errorPromotionRateFromEnv) &&
874
+ errorPromotionRateFromEnv >= 0 &&
875
+ errorPromotionRateFromEnv <= 1
876
+ ? errorPromotionRateFromEnv
877
+ : (opts.otlpTracesErrorPromotionRate ?? 0.1);
878
+
879
+ state.otlpTracer = createOtlpHttpTracerAdapter({
880
+ endpoint: otlpTracesEndpoint,
881
+ resourceAttributes: floor,
882
+ scopeVersion: decoRuntimeVersion,
883
+ headSamplingRate: samplingRateOverride ?? opts.otlpTracesSamplingRate ?? 0.01,
884
+ errorPromotionRate,
885
+ headers: otlpHeaders,
886
+ fetchImpl: opts.otlpTracesFetchImpl,
887
+ getActiveSpanForParent: () => getActiveSpan(),
888
+ getRequestTraceContext,
889
+ getForceSampled: getDebugSampled,
890
+ onError: (kind, err) => {
891
+ try {
892
+ warnDirect(
893
+ JSON.stringify({
894
+ level: "warn",
895
+ msg: "otlp traces exporter",
896
+ kind,
897
+ error: err instanceof Error ? err.message : String(err),
898
+ timestamp: new Date().toISOString(),
899
+ }),
900
+ );
901
+ } catch {
902
+ /* swallow */
903
+ }
904
+ },
905
+ });
906
+ } else {
907
+ state.otlpTracer = null;
908
+ }
909
+
910
+ // Wire the tracer stack — composes the OTLP direct-POST adapter (when
911
+ // configured) with the @opentelemetry/api bridge. See
912
+ // `configureTracerStack` for the fanout semantics.
913
+ configureTracerStack(state.otlpTracer);
914
+
915
+ state.booted = true;
916
+
917
+ // Intercept console.* only when OTLP is active (production). In dev mode
918
+ // (no OTLP endpoint) console is left untouched so wrangler dev / wrangler
919
+ // tail keep showing output normally.
920
+ if (otlpLogsEnabled) {
921
+ patchConsole(state);
922
+ }
923
+
924
+ // Boot breadcrumb — infra telemetry, not application telemetry. Goes to the
925
+ // original console (pre-patch) so it shows in wrangler dev / wrangler tail
926
+ // without polluting the OTLP buffer (where it would interfere with trace-
927
+ // based sampling — there is no active span at boot time).
928
+ try {
929
+ warnDirect(
930
+ JSON.stringify({
931
+ level: "info",
932
+ msg: "observability booted",
933
+ service: serviceName,
934
+ analyticsEngine: aeEnabled,
935
+ otlpMetrics: otlpEnabled,
936
+ otlpLogs: otlpLogsEnabled,
937
+ otlpLogsMinLevel: otlpLogsMinLevel,
938
+ otlpTraces: otlpTracesEnabled,
939
+ consolePatch: otlpLogsEnabled,
940
+ runtimeVersion: decoRuntimeVersion,
941
+ deploymentEnvironment,
942
+ ...(serviceVersion ? { serviceVersion } : {}),
943
+ }),
944
+ );
945
+ } catch {
946
+ /* swallow */
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Test-only: clear boot state so successive tests can re-boot
952
+ * `instrumentWorker` with different options. Do not call from app code.
953
+ */
954
+ export function _resetBootStateForTests(): void {
955
+ const state = getBootState();
956
+ state.booted = false;
957
+ state.spanAttributeFloor = {};
958
+ state.otlpMeter = null;
959
+ state.otlpLog = null;
960
+ state.otlpTracer = null;
961
+ if (state.origConsole) {
962
+ console.log = state.origConsole.log;
963
+ console.info = state.origConsole.info;
964
+ console.warn = state.origConsole.warn;
965
+ console.error = state.origConsole.error;
966
+ console.debug = state.origConsole.debug;
967
+ state.origConsole = null;
968
+ }
969
+ setLoggerAttributeFloor({});
970
+ }
971
+
972
+ // ---------------------------------------------------------------------------
973
+ // Helpers
974
+ // ---------------------------------------------------------------------------
975
+
976
+ /**
977
+ * @decocms/start version, read from package.json so it stays in sync
978
+ * automatically with every semantic-release cut.
979
+ */
980
+ import pkg from "../../package.json";
981
+ const DECO_RUNTIME_VERSION = pkg.version;