@decocms/start 4.2.1 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +36 -110
- package/package.json +4 -3
- package/scripts/migrate/phase-report.ts +8 -0
- package/scripts/migrate/templates/server-entry.ts +39 -3
- package/scripts/migrate-to-cf-observability.test.ts +169 -0
- package/scripts/migrate-to-cf-observability.ts +611 -0
- package/src/sdk/logger.test.ts +79 -0
- package/src/sdk/logger.ts +40 -2
- package/src/sdk/observability.ts +2 -0
- package/src/sdk/otel.test.ts +128 -15
- package/src/sdk/otel.ts +210 -91
- package/src/sdk/otelAdapters.test.ts +138 -2
- package/src/sdk/otelAdapters.ts +83 -1
- package/src/sdk/sampler.ts +17 -5
- package/src/sdk/workerEntry.ts +7 -4
package/src/sdk/otel.ts
CHANGED
|
@@ -2,55 +2,82 @@
|
|
|
2
2
|
* Single observability entry point for `@decocms/start` on Cloudflare Workers.
|
|
3
3
|
*
|
|
4
4
|
* `instrumentWorker(handler, options)` wraps a Worker handler with:
|
|
5
|
-
* - structured JSON logger (stdout → Cloudflare Logs
|
|
6
|
-
* - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
|
|
7
|
-
* - OTLP/HTTP metrics exporter (HyperDX) — same condition
|
|
5
|
+
* - structured JSON logger (stdout → Cloudflare Workers Logs) — always
|
|
8
6
|
* - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* - OTLP/HTTP metrics exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
8
|
+
* is set (CF doesn't support OTLP metrics export yet, so this stays app-side)
|
|
9
|
+
* - Per-request `ctx.waitUntil(forceFlush)` for any registered OTel batch
|
|
10
|
+
* processors so log/metric batches don't die with the isolate
|
|
11
|
+
* - Bridges framework-internal `withTracing()` calls onto the global
|
|
12
|
+
* `@opentelemetry/api` tracer, stamping `deco.*` attributes on every span
|
|
13
|
+
* so they survive Cloudflare's platform-managed trace export
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* **Logs and traces export to HyperDX is now handled by Cloudflare** via the
|
|
16
|
+
* `observability.{logs,traces}.destinations` block in `wrangler.jsonc`. CF
|
|
17
|
+
* captures `console.*` output and `@opentelemetry/api` global tracer spans
|
|
18
|
+
* out-of-band and ships them OTLP-encoded to whatever destination is
|
|
19
|
+
* configured. This eliminates the in-Worker exporter SDK, the per-request
|
|
20
|
+
* subrequest cost of pushing OTLP, and the entire class of bug PR #153 fixed
|
|
21
|
+
* (batch processors that never flush before isolate recycling).
|
|
17
22
|
*
|
|
18
23
|
* @example
|
|
19
24
|
* ```ts
|
|
25
|
+
* // worker-entry.ts
|
|
20
26
|
* import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
21
27
|
* import { instrumentWorker } from "@decocms/start/sdk/otel";
|
|
22
28
|
*
|
|
23
29
|
* const handler = createDecoWorkerEntry(serverEntry, options);
|
|
24
|
-
*
|
|
25
30
|
* export default instrumentWorker(handler, { serviceName: "my-store" });
|
|
26
31
|
* ```
|
|
27
32
|
*
|
|
28
|
-
*
|
|
33
|
+
* Companion `wrangler.jsonc` block (run `scripts/migrate-to-cf-observability.ts`
|
|
34
|
+
* to inject this automatically):
|
|
29
35
|
* ```jsonc
|
|
30
|
-
* "
|
|
31
|
-
*
|
|
36
|
+
* "observability": {
|
|
37
|
+
* "logs": { "enabled": true, "destinations": ["hyperdx-logs"],
|
|
38
|
+
* "head_sampling_rate": 1.0, "persist": false },
|
|
39
|
+
* "traces": { "enabled": true, "destinations": ["hyperdx-traces"],
|
|
40
|
+
* "head_sampling_rate": 0.1, "persist": false }
|
|
41
|
+
* },
|
|
42
|
+
* "version_metadata": { "binding": "CF_VERSION_METADATA" },
|
|
43
|
+
* "analytics_engine_datasets": [{ "binding": "DECO_METRICS",
|
|
44
|
+
* "dataset": "deco_metrics_my_site" }]
|
|
32
45
|
* ```
|
|
33
46
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
47
|
+
* **Back-compat seam.** Sites that need to keep app-side OTLP log export
|
|
48
|
+
* (custom destination not covered by CF, custom batching, etc.) can opt back
|
|
49
|
+
* in with `enableAppSideOtlpLogs: true` and the existing `OTEL_EXPORTER_OTLP_*`
|
|
50
|
+
* secrets. Slated for removal in 5.0.0.
|
|
37
51
|
*/
|
|
38
52
|
|
|
39
|
-
import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
|
|
40
53
|
import { trace } from "@opentelemetry/api";
|
|
41
54
|
import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
|
|
42
55
|
|
|
43
56
|
import { configureMeter, configureTracer } from "../middleware/observability";
|
|
44
57
|
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
45
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
configureLogger,
|
|
60
|
+
defaultLoggerAdapter,
|
|
61
|
+
type LogLevel,
|
|
62
|
+
logger,
|
|
63
|
+
setLoggerAttributeFloor,
|
|
64
|
+
} from "./logger";
|
|
46
65
|
import {
|
|
47
66
|
createAnalyticsEngineMeterAdapter,
|
|
48
67
|
createOtelLoggerAdapter,
|
|
49
68
|
createOtelMeterAdapter,
|
|
69
|
+
flushOtelProviders,
|
|
50
70
|
setRuntimeEnv,
|
|
51
71
|
} from "./otelAdapters";
|
|
52
72
|
import { RequestContext } from "./requestContext";
|
|
53
|
-
|
|
73
|
+
|
|
74
|
+
const VALID_LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
|
|
75
|
+
|
|
76
|
+
function parseLogLevel(value: unknown): LogLevel | undefined {
|
|
77
|
+
if (typeof value !== "string") return undefined;
|
|
78
|
+
const lc = value.toLowerCase();
|
|
79
|
+
return VALID_LOG_LEVELS.find((l) => l === lc);
|
|
80
|
+
}
|
|
54
81
|
|
|
55
82
|
// ---------------------------------------------------------------------------
|
|
56
83
|
// Types
|
|
@@ -70,11 +97,38 @@ export interface OtelOptions {
|
|
|
70
97
|
/** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
|
|
71
98
|
metricsExportIntervalMillis?: number;
|
|
72
99
|
/**
|
|
73
|
-
*
|
|
100
|
+
* Minimum severity to forward to the **app-side OTLP** logger (only
|
|
101
|
+
* relevant when `enableAppSideOtlpLogs: true`). The default `console.*`
|
|
102
|
+
* adapter is unaffected and continues to capture every level for
|
|
103
|
+
* Cloudflare Workers Logs / CF-side OTLP export.
|
|
104
|
+
*
|
|
105
|
+
* Defaults to `"warn"`. Falls back to env `OTEL_LOG_MIN_SEVERITY` when
|
|
106
|
+
* unset. Set to `"debug"` to forward everything.
|
|
107
|
+
*/
|
|
108
|
+
otlpMinSeverity?: LogLevel;
|
|
109
|
+
/**
|
|
110
|
+
* Opt-in: also wire an in-Worker OTLP logger that pushes log records to
|
|
111
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT`. Defaults to `false` — sites should
|
|
112
|
+
* prefer the platform-managed CF-side path
|
|
113
|
+
* (`observability.logs.destinations` in `wrangler.jsonc`), which is
|
|
114
|
+
* cheaper, has no flush-bug class, and consumes zero subrequest budget.
|
|
115
|
+
*
|
|
116
|
+
* Use this only when CF's OTLP logs export doesn't meet a specific need
|
|
117
|
+
* (e.g. shipping to a destination CF doesn't support, custom batching,
|
|
118
|
+
* staging-only debugging). Requires `OTEL_EXPORTER_OTLP_ENDPOINT` and
|
|
119
|
+
* `OTEL_EXPORTER_OTLP_HEADERS` to be set.
|
|
120
|
+
*
|
|
121
|
+
* Slated for removal in 5.0.0.
|
|
122
|
+
*/
|
|
123
|
+
enableAppSideOtlpLogs?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Version of `@decocms/start` to advertise as `deco.runtime.version`
|
|
126
|
+
* on every span (CF doesn't preserve it as a resource attribute since
|
|
127
|
+
* we no longer ship our own resource — we stamp it per-span instead).
|
|
74
128
|
* Falls back to a build-time constant; override only for tests.
|
|
75
129
|
*/
|
|
76
130
|
decoRuntimeVersion?: string;
|
|
77
|
-
/** Optional `@decocms/apps` version,
|
|
131
|
+
/** Optional `@decocms/apps` version, stamped as `deco.apps.version` on every span. */
|
|
78
132
|
decoAppsVersion?: string;
|
|
79
133
|
}
|
|
80
134
|
|
|
@@ -106,25 +160,59 @@ interface BootState {
|
|
|
106
160
|
|
|
107
161
|
let bootState: BootState | null = null;
|
|
108
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Per-span attribute floor — stamped on every span we create via
|
|
165
|
+
* `configureTracer().startSpan(...)`. These match what the legacy resource
|
|
166
|
+
* attributes used to carry (when `@microlabs/otel-cf-workers` shipped its
|
|
167
|
+
* own OTel `Resource`); we now stamp them on each span so HyperDX panels
|
|
168
|
+
* filtering on `deco.runtime.version`, `deco.apps.version`, or
|
|
169
|
+
* `deployment.environment` keep working with CF-managed export, which only
|
|
170
|
+
* preserves CF's own resource attribute set (`service.name`, `faas.name`,
|
|
171
|
+
* `cloudflare.script_version.id`, etc.).
|
|
172
|
+
*
|
|
173
|
+
* Populated by `bootObservability` before any span is created. Stays an
|
|
174
|
+
* empty object until then so early span creation is a no-op stamp.
|
|
175
|
+
*/
|
|
176
|
+
let spanAttributeFloor: Record<string, string> = {};
|
|
177
|
+
|
|
109
178
|
// ---------------------------------------------------------------------------
|
|
110
179
|
// instrumentWorker
|
|
111
180
|
// ---------------------------------------------------------------------------
|
|
112
181
|
|
|
113
182
|
/**
|
|
114
|
-
* Wraps a Cloudflare Worker handler with the
|
|
115
|
-
*
|
|
116
|
-
*
|
|
183
|
+
* Wraps a Cloudflare Worker handler with the @decocms/start observability
|
|
184
|
+
* stack:
|
|
185
|
+
* - structured JSON logger (always)
|
|
186
|
+
* - AE meter (when `DECO_METRICS` binding present)
|
|
187
|
+
* - optional app-side OTLP meter (when `OTEL_EXPORTER_OTLP_ENDPOINT` set)
|
|
188
|
+
* - optional app-side OTLP logger (when `enableAppSideOtlpLogs: true`)
|
|
189
|
+
* - per-request `ctx.waitUntil(forceFlush)` for any registered batch processors
|
|
190
|
+
* - bridge from framework-internal `withTracing()` to `@opentelemetry/api`
|
|
191
|
+
* global tracer, with `deco.*` attributes stamped on every span
|
|
192
|
+
*
|
|
193
|
+
* Logs and traces export to HyperDX (or any OTLP destination) is handled
|
|
194
|
+
* by Cloudflare via `observability.{logs,traces}.destinations` in
|
|
195
|
+
* `wrangler.jsonc`. This wrapper does NOT call `@microlabs/otel-cf-workers`
|
|
196
|
+
* `instrument()` — CF's platform-managed export captures `console.*` output
|
|
197
|
+
* and global-tracer spans out-of-band.
|
|
117
198
|
*/
|
|
118
199
|
export function instrumentWorker(
|
|
119
200
|
handler: WorkerHandler,
|
|
120
201
|
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
|
|
121
202
|
): WorkerHandler {
|
|
122
|
-
// Bridge our pluggable TracerAdapter onto @opentelemetry/api
|
|
123
|
-
//
|
|
124
|
-
//
|
|
203
|
+
// Bridge our pluggable TracerAdapter onto @opentelemetry/api. Framework
|
|
204
|
+
// code calls `withTracing("name", fn, { attr: val })`; that delegates here
|
|
205
|
+
// and lands on `trace.getTracer("@decocms/start").startSpan(...)`.
|
|
206
|
+
//
|
|
207
|
+
// CF Workers Tracing (when `observability.traces.enabled = true` in
|
|
208
|
+
// wrangler) installs its own TracerProvider into the @opentelemetry/api
|
|
209
|
+
// global, so these spans flow through to whatever OTLP destination is
|
|
210
|
+
// configured. Without CF tracing the global tracer is a no-op proxy and
|
|
211
|
+
// the spans simply drop — same outcome as before, no error.
|
|
125
212
|
configureTracer({
|
|
126
213
|
startSpan: (name, attrs) => {
|
|
127
|
-
const
|
|
214
|
+
const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
|
|
215
|
+
const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
|
|
128
216
|
return {
|
|
129
217
|
end: () => span.end(),
|
|
130
218
|
setError: (error) => {
|
|
@@ -135,61 +223,41 @@ export function instrumentWorker(
|
|
|
135
223
|
},
|
|
136
224
|
});
|
|
137
225
|
|
|
138
|
-
|
|
139
|
-
const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
140
|
-
bootObservability(opts, env as Record<string, unknown>);
|
|
141
|
-
|
|
142
|
-
const state = bootState!;
|
|
143
|
-
|
|
144
|
-
// Sampling — base64 JSON via OTEL_SAMPLING_CONFIG, see sdk/sampler.ts
|
|
145
|
-
const samplingConfig = decodeSamplingConfig(env.OTEL_SAMPLING_CONFIG as string | undefined);
|
|
146
|
-
const headSampler = createUrlBasedHeadSampler(samplingConfig);
|
|
147
|
-
|
|
148
|
-
// microlabs requires an exporter even when we only want internal
|
|
149
|
-
// tracing. When OTLP isn't configured, we still set up a no-op
|
|
150
|
-
// collector — the URL we'd never reach so spans simply drop.
|
|
151
|
-
const exporterUrl = state.otlpEndpoint ?? "http://127.0.0.1:0/v1/traces";
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
exporter: {
|
|
155
|
-
url: joinPath(exporterUrl, "/v1/traces"),
|
|
156
|
-
headers: state.otlpHeaders,
|
|
157
|
-
},
|
|
158
|
-
service: {
|
|
159
|
-
name: state.serviceName,
|
|
160
|
-
version: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
161
|
-
},
|
|
162
|
-
sampling: { headSampler },
|
|
163
|
-
// microlabs auto-instruments globalThis.fetch + KV + waitUntil.
|
|
164
|
-
instrumentation: {
|
|
165
|
-
instrumentGlobalFetch: true,
|
|
166
|
-
instrumentGlobalCache: true,
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const innerHandler: WorkerHandler = {
|
|
226
|
+
return {
|
|
172
227
|
async fetch(request, env, ctx) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
228
|
+
const opts =
|
|
229
|
+
typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
230
|
+
bootObservability(opts, env as Record<string, unknown>);
|
|
231
|
+
|
|
232
|
+
// Stash env so request-scoped adapters (AE) can resolve their
|
|
233
|
+
// bindings. Done inside RequestContext.run wrapping in workerEntry.ts
|
|
234
|
+
// too, but we re-stash here in case `instrumentWorker` is wrapped
|
|
235
|
+
// over a handler that doesn't go through `createDecoWorkerEntry`.
|
|
177
236
|
const wrap = async () => {
|
|
178
237
|
setRuntimeEnv(env);
|
|
179
238
|
return handler.fetch(request, env, ctx);
|
|
180
239
|
};
|
|
181
240
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
241
|
+
try {
|
|
242
|
+
if (RequestContext.current) {
|
|
243
|
+
return await wrap();
|
|
244
|
+
}
|
|
245
|
+
return await RequestContext.run(request, wrap);
|
|
246
|
+
} finally {
|
|
247
|
+
// Drain OTLP meter (and OTLP logger, if `enableAppSideOtlpLogs`)
|
|
248
|
+
// batches inside the post-response window `waitUntil` guarantees.
|
|
249
|
+
// Without this hook, `PeriodicExportingMetricReader` (60s flush)
|
|
250
|
+
// batches usually die with the isolate before the timer fires.
|
|
251
|
+
// No-op when no batch processors are registered.
|
|
252
|
+
try {
|
|
253
|
+
ctx.waitUntil(flushOtelProviders());
|
|
254
|
+
} catch {
|
|
255
|
+
// `waitUntil` only throws if `ctx` isn't a real ExecutionContext
|
|
256
|
+
// (e.g. test stubs). Telemetry flush failures are not request-fatal.
|
|
257
|
+
}
|
|
186
258
|
}
|
|
187
|
-
return RequestContext.run(request, wrap);
|
|
188
259
|
},
|
|
189
260
|
};
|
|
190
|
-
|
|
191
|
-
// deno-lint-ignore no-explicit-any
|
|
192
|
-
return instrument(innerHandler as any, resolveConfig) as unknown as WorkerHandler;
|
|
193
261
|
}
|
|
194
262
|
|
|
195
263
|
// ---------------------------------------------------------------------------
|
|
@@ -206,29 +274,72 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
206
274
|
const otlpHeaders =
|
|
207
275
|
opts.headers ?? parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
|
|
208
276
|
|
|
277
|
+
const decoRuntimeVersion = opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION;
|
|
278
|
+
const deploymentEnvironment = (env.DECO_ENV_NAME as string | undefined) ?? "production";
|
|
279
|
+
|
|
209
280
|
const resource = buildResource({
|
|
210
281
|
serviceName,
|
|
211
282
|
serviceVersion: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
212
283
|
serviceInstanceId: cryptoRandomId(),
|
|
213
|
-
deploymentEnvironment
|
|
214
|
-
decoRuntimeVersion
|
|
284
|
+
deploymentEnvironment,
|
|
285
|
+
decoRuntimeVersion,
|
|
215
286
|
decoAppsVersion: opts.decoAppsVersion,
|
|
216
287
|
});
|
|
217
288
|
|
|
289
|
+
// Stamp deco.* attributes on every span we create. CF-managed trace
|
|
290
|
+
// export emits its own resource attribute set (service.name=Worker name,
|
|
291
|
+
// faas.name, cloudflare.script_version.id, faas.version, etc.) so the
|
|
292
|
+
// legacy resource attrs from `buildResource` don't survive on the
|
|
293
|
+
// CF-side path. Stamping them per-span preserves the dimensions
|
|
294
|
+
// existing HyperDX dashboards filter on.
|
|
295
|
+
spanAttributeFloor = {
|
|
296
|
+
"deco.runtime.version": decoRuntimeVersion,
|
|
297
|
+
"deployment.environment": deploymentEnvironment,
|
|
298
|
+
...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Same set, stamped on every log record. CF Workers Logs ships the JSON
|
|
302
|
+
// body verbatim (resource attrs from `buildResource` are NOT applied to
|
|
303
|
+
// logs in default mode), so HyperDX panels grouping by these dimensions
|
|
304
|
+
// would otherwise return empty. Caller-supplied `attrs` still win on key
|
|
305
|
+
// collision.
|
|
306
|
+
setLoggerAttributeFloor({
|
|
307
|
+
"deco.runtime.version": decoRuntimeVersion,
|
|
308
|
+
"deployment.environment": deploymentEnvironment,
|
|
309
|
+
...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
|
|
310
|
+
});
|
|
311
|
+
|
|
218
312
|
// ---- Logger ----------------------------------------------------------
|
|
313
|
+
// Default mode: console JSON only. Cloudflare Workers Logs captures the
|
|
314
|
+
// output and ships it via `observability.logs.destinations` to whichever
|
|
315
|
+
// OTLP destination is configured in `wrangler.jsonc`. This is the
|
|
316
|
+
// recommended path: zero in-Worker exporter, no flush bug, no subrequest
|
|
317
|
+
// cost per emit.
|
|
318
|
+
//
|
|
319
|
+
// Opt-in mode (`enableAppSideOtlpLogs: true`): also wire the OTLP logger
|
|
320
|
+
// adapter for sites with destinations CF doesn't support. Requires
|
|
321
|
+
// `OTEL_EXPORTER_OTLP_ENDPOINT` to be set.
|
|
322
|
+
const otlpMinSeverity =
|
|
323
|
+
opts.otlpMinSeverity ?? parseLogLevel(env.OTEL_LOG_MIN_SEVERITY) ?? "warn";
|
|
324
|
+
|
|
325
|
+
const wantAppSideLogs = opts.enableAppSideOtlpLogs === true;
|
|
219
326
|
const otelLogger =
|
|
220
|
-
otlpEndpoint != null
|
|
327
|
+
wantAppSideLogs && otlpEndpoint != null
|
|
221
328
|
? createOtelLoggerAdapter({
|
|
222
329
|
endpoint: otlpEndpoint,
|
|
223
330
|
headers: otlpHeaders,
|
|
224
331
|
resource,
|
|
225
332
|
name: serviceName,
|
|
333
|
+
minSeverity: otlpMinSeverity,
|
|
226
334
|
})
|
|
227
335
|
: null;
|
|
228
336
|
|
|
229
337
|
configureLogger(createCompositeLogger([defaultLoggerAdapter, otelLogger]));
|
|
230
338
|
|
|
231
339
|
// ---- Meter -----------------------------------------------------------
|
|
340
|
+
// OTLP meter stays default-on when an endpoint is configured: CF doesn't
|
|
341
|
+
// support OTLP metrics export yet, so this is the only path to
|
|
342
|
+
// HyperDX-compatible metrics. Drop this branch when CF ships metrics.
|
|
232
343
|
const otelMeter =
|
|
233
344
|
otlpEndpoint != null
|
|
234
345
|
? createOtelMeterAdapter({
|
|
@@ -258,16 +369,32 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
258
369
|
booted = true;
|
|
259
370
|
|
|
260
371
|
// Single boot-time breadcrumb so operators can confirm the wiring at a
|
|
261
|
-
// glance from CF Logs without enabling debug.
|
|
372
|
+
// glance from CF Logs without enabling debug. Surfaces which export
|
|
373
|
+
// mode is active (CF-native vs app-side) so misconfigured sites are
|
|
374
|
+
// obvious from the first request.
|
|
262
375
|
logger.info("observability booted", {
|
|
263
376
|
service: serviceName,
|
|
264
|
-
|
|
377
|
+
mode: wantAppSideLogs ? "hybrid (app-side OTLP logs + CF traces)" : "cf-native",
|
|
378
|
+
otlpMeter: Boolean(otlpEndpoint),
|
|
379
|
+
otlpLogger: wantAppSideLogs && otlpEndpoint != null,
|
|
380
|
+
otlpMinSeverity: wantAppSideLogs && otlpEndpoint != null ? otlpMinSeverity : null,
|
|
265
381
|
analyticsEngine: aeEnabled,
|
|
266
|
-
|
|
267
|
-
|
|
382
|
+
runtimeVersion: decoRuntimeVersion,
|
|
383
|
+
deploymentEnvironment,
|
|
268
384
|
});
|
|
269
385
|
}
|
|
270
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Test-only: clear boot state so successive tests can re-boot
|
|
389
|
+
* `instrumentWorker` with different options. Do not call from app code.
|
|
390
|
+
*/
|
|
391
|
+
export function _resetBootStateForTests(): void {
|
|
392
|
+
booted = false;
|
|
393
|
+
bootState = null;
|
|
394
|
+
spanAttributeFloor = {};
|
|
395
|
+
setLoggerAttributeFloor({});
|
|
396
|
+
}
|
|
397
|
+
|
|
271
398
|
// ---------------------------------------------------------------------------
|
|
272
399
|
// Resource attributes
|
|
273
400
|
// ---------------------------------------------------------------------------
|
|
@@ -307,7 +434,7 @@ function buildResource(input: ResourceInput): Resource {
|
|
|
307
434
|
* Drift is acceptable — this attribute is for operator triage, not for
|
|
308
435
|
* billing / SLOs.
|
|
309
436
|
*/
|
|
310
|
-
const DECO_RUNTIME_VERSION = "
|
|
437
|
+
const DECO_RUNTIME_VERSION = "4.4.0";
|
|
311
438
|
|
|
312
439
|
function parseHeaders(str?: string): Record<string, string> {
|
|
313
440
|
if (!str) return {};
|
|
@@ -327,14 +454,6 @@ function numericEnv(value: unknown, fallback: number): number {
|
|
|
327
454
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
328
455
|
}
|
|
329
456
|
|
|
330
|
-
function joinPath(base: string, path: string): string {
|
|
331
|
-
if (!base) return path;
|
|
332
|
-
if (base.endsWith("/")) base = base.slice(0, -1);
|
|
333
|
-
if (!path.startsWith("/")) path = "/" + path;
|
|
334
|
-
if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
|
|
335
|
-
return base + path;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
457
|
function cryptoRandomId(): string {
|
|
339
458
|
// crypto.randomUUID is universally available in CF Workers + Node 19+.
|
|
340
459
|
try {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
4
|
+
_getFlushHandlerCountForTests,
|
|
5
|
+
_resetFlushHandlersForTests,
|
|
3
6
|
createAnalyticsEngineMeterAdapter,
|
|
4
7
|
createOtelLoggerAdapter,
|
|
5
8
|
createOtelMeterAdapter,
|
|
9
|
+
flushOtelProviders,
|
|
10
|
+
registerOtelFlushHandler,
|
|
6
11
|
setRuntimeEnv,
|
|
7
12
|
} from "./otelAdapters";
|
|
8
13
|
import { RequestContext } from "./requestContext";
|
|
@@ -40,7 +45,13 @@ describe("createOtelLoggerAdapter / createOtelMeterAdapter", () => {
|
|
|
40
45
|
});
|
|
41
46
|
|
|
42
47
|
it("logs without throwing for a variety of attribute shapes", () => {
|
|
43
|
-
|
|
48
|
+
// minSeverity:"debug" so the test exercises the actual emit path; the
|
|
49
|
+
// default "warn" floor would short-circuit before the OTel emit runs
|
|
50
|
+
// and the attribute-sanitization branch wouldn't be hit.
|
|
51
|
+
const log = createOtelLoggerAdapter({
|
|
52
|
+
endpoint: "https://otel.example.invalid",
|
|
53
|
+
minSeverity: "debug",
|
|
54
|
+
});
|
|
44
55
|
expect(log).not.toBeNull();
|
|
45
56
|
expect(() =>
|
|
46
57
|
log!.log("info", "ok", {
|
|
@@ -58,6 +69,131 @@ describe("createOtelLoggerAdapter / createOtelMeterAdapter", () => {
|
|
|
58
69
|
});
|
|
59
70
|
});
|
|
60
71
|
|
|
72
|
+
describe("createOtelLoggerAdapter — minSeverity floor", () => {
|
|
73
|
+
// Every call to `createOtelLoggerAdapter` constructs its own local
|
|
74
|
+
// `LoggerProvider`. The OTel global provider is set the first time
|
|
75
|
+
// (`setGlobalLoggerProvider` is "first wins"), so spying on the global
|
|
76
|
+
// doesn't observe later adapters' emits. Spying on the *prototype*
|
|
77
|
+
// sidesteps that — every provider instance, local or global, returns
|
|
78
|
+
// the same fake logger and we can count emits across all of them.
|
|
79
|
+
let emitSpy: ReturnType<typeof vi.fn>;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
_resetFlushHandlersForTests();
|
|
83
|
+
emitSpy = vi.fn();
|
|
84
|
+
vi.spyOn(LoggerProvider.prototype, "getLogger").mockReturnValue({
|
|
85
|
+
emit: emitSpy,
|
|
86
|
+
} as unknown as ReturnType<LoggerProvider["getLogger"]>);
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
_resetFlushHandlersForTests();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function makeAdapter(minSeverity?: "debug" | "info" | "warn" | "error") {
|
|
94
|
+
const adapter = createOtelLoggerAdapter({
|
|
95
|
+
endpoint: "https://otel.example.invalid",
|
|
96
|
+
headers: { authorization: "test" },
|
|
97
|
+
...(minSeverity ? { minSeverity } : {}),
|
|
98
|
+
});
|
|
99
|
+
expect(adapter).not.toBeNull();
|
|
100
|
+
return adapter!;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("defaults to 'warn' — drops debug + info, keeps warn + error", () => {
|
|
104
|
+
const adapter = makeAdapter(undefined);
|
|
105
|
+
adapter.log("debug", "d");
|
|
106
|
+
adapter.log("info", "i");
|
|
107
|
+
adapter.log("warn", "w");
|
|
108
|
+
adapter.log("error", "e");
|
|
109
|
+
expect(emitSpy).toHaveBeenCalledTimes(2);
|
|
110
|
+
expect(emitSpy.mock.calls[0]?.[0].severityText).toBe("WARN");
|
|
111
|
+
expect(emitSpy.mock.calls[1]?.[0].severityText).toBe("ERROR");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("explicit minSeverity='debug' lets every level through", () => {
|
|
115
|
+
const adapter = makeAdapter("debug");
|
|
116
|
+
adapter.log("debug", "d");
|
|
117
|
+
adapter.log("info", "i");
|
|
118
|
+
adapter.log("warn", "w");
|
|
119
|
+
adapter.log("error", "e");
|
|
120
|
+
expect(emitSpy).toHaveBeenCalledTimes(4);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("explicit minSeverity='error' only forwards error", () => {
|
|
124
|
+
const adapter = makeAdapter("error");
|
|
125
|
+
adapter.log("debug", "d");
|
|
126
|
+
adapter.log("info", "i");
|
|
127
|
+
adapter.log("warn", "w");
|
|
128
|
+
adapter.log("error", "e");
|
|
129
|
+
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(emitSpy.mock.calls[0]?.[0].severityText).toBe("ERROR");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("flushOtelProviders / registerOtelFlushHandler", () => {
|
|
135
|
+
beforeEach(() => _resetFlushHandlersForTests());
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
vi.restoreAllMocks();
|
|
138
|
+
_resetFlushHandlersForTests();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("resolves immediately when no handlers are registered", async () => {
|
|
142
|
+
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
143
|
+
await expect(flushOtelProviders()).resolves.toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("invokes every registered handler exactly once", async () => {
|
|
147
|
+
const a = vi.fn(() => Promise.resolve());
|
|
148
|
+
const b = vi.fn(() => Promise.resolve());
|
|
149
|
+
registerOtelFlushHandler(a);
|
|
150
|
+
registerOtelFlushHandler(b);
|
|
151
|
+
expect(_getFlushHandlerCountForTests()).toBe(2);
|
|
152
|
+
await flushOtelProviders();
|
|
153
|
+
expect(a).toHaveBeenCalledOnce();
|
|
154
|
+
expect(b).toHaveBeenCalledOnce();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("does NOT reject when a handler throws — uses Promise.allSettled", async () => {
|
|
158
|
+
const ok = vi.fn(() => Promise.resolve());
|
|
159
|
+
const fail = vi.fn(() => Promise.reject(new Error("OTLP 503")));
|
|
160
|
+
registerOtelFlushHandler(fail);
|
|
161
|
+
registerOtelFlushHandler(ok);
|
|
162
|
+
// Must resolve, not throw — flush failures are telemetry incidents,
|
|
163
|
+
// not request incidents. This is the surface contract callers rely on.
|
|
164
|
+
await expect(flushOtelProviders()).resolves.toBeUndefined();
|
|
165
|
+
expect(ok).toHaveBeenCalledOnce();
|
|
166
|
+
expect(fail).toHaveBeenCalledOnce();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("createOtelLoggerAdapter registers exactly one flush handler", () => {
|
|
170
|
+
const before = _getFlushHandlerCountForTests();
|
|
171
|
+
const adapter = createOtelLoggerAdapter({
|
|
172
|
+
endpoint: "https://otel.example.invalid",
|
|
173
|
+
});
|
|
174
|
+
expect(adapter).not.toBeNull();
|
|
175
|
+
expect(_getFlushHandlerCountForTests()).toBe(before + 1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("createOtelMeterAdapter registers exactly one flush handler", () => {
|
|
179
|
+
const before = _getFlushHandlerCountForTests();
|
|
180
|
+
const meter = createOtelMeterAdapter({
|
|
181
|
+
endpoint: "https://otel.example.invalid",
|
|
182
|
+
exportIntervalMillis: 60_000,
|
|
183
|
+
});
|
|
184
|
+
expect(meter).not.toBeNull();
|
|
185
|
+
expect(_getFlushHandlerCountForTests()).toBe(before + 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returning-null factories register no handlers", () => {
|
|
189
|
+
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
190
|
+
createOtelLoggerAdapter(null);
|
|
191
|
+
createOtelLoggerAdapter({ endpoint: "" });
|
|
192
|
+
createOtelMeterAdapter(null);
|
|
193
|
+
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
61
197
|
describe("createAnalyticsEngineMeterAdapter", () => {
|
|
62
198
|
afterEach(() => vi.restoreAllMocks());
|
|
63
199
|
|