@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
package/src/sdk/otel.ts
ADDED
|
@@ -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;
|