@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/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 / Logpush) — always
6
- * - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
7
- * - OTLP/HTTP metrics exporter (HyperDX) — same condition
5
+ * - structured JSON logger (stdout → Cloudflare Workers Logs) — always
8
6
  * - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
9
- * - OTel traces via `@microlabs/otel-cf-workers` — when OTLP endpoint is set,
10
- * or always-on for the framework's internal `withTracing()` calls.
11
- * - URL-based head sampler from `OTEL_SAMPLING_CONFIG`
12
- * - OTel `Resource` with service.* / cloud.* / deployment.environment /
13
- * deco.runtime.version attributes
7
+ * - OTLP/HTTP metrics exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT`
8
+ * is set (CF doesn't support OTLP metrics export yet, so this stays app-side)
9
+ * - Per-request `ctx.waitUntil(forceFlush)` for any registered OTel batch
10
+ * processors so log/metric batches don't die with the isolate
11
+ * - Bridges framework-internal `withTracing()` calls onto the global
12
+ * `@opentelemetry/api` tracer, stamping `deco.*` attributes on every span
13
+ * so they survive Cloudflare's platform-managed trace export
14
14
  *
15
- * Removing HyperDX = unsetting `OTEL_EXPORTER_OTLP_ENDPOINT`. The console-JSON
16
- * logger and AE metrics keep flowing — this is the "no vendor lock-in" guarantee.
15
+ * **Logs and traces export to HyperDX is now handled by Cloudflare** via the
16
+ * `observability.{logs,traces}.destinations` block in `wrangler.jsonc`. CF
17
+ * captures `console.*` output and `@opentelemetry/api` global tracer spans
18
+ * out-of-band and ships them OTLP-encoded to whatever destination is
19
+ * configured. This eliminates the in-Worker exporter SDK, the per-request
20
+ * subrequest cost of pushing OTLP, and the entire class of bug PR #153 fixed
21
+ * (batch processors that never flush before isolate recycling).
17
22
  *
18
23
  * @example
19
24
  * ```ts
25
+ * // worker-entry.ts
20
26
  * import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
21
27
  * import { instrumentWorker } from "@decocms/start/sdk/otel";
22
28
  *
23
29
  * const handler = createDecoWorkerEntry(serverEntry, options);
24
- *
25
30
  * export default instrumentWorker(handler, { serviceName: "my-store" });
26
31
  * ```
27
32
  *
28
- * Wrangler bindings to add when enabling OTLP + AE:
33
+ * Companion `wrangler.jsonc` block (run `scripts/migrate-to-cf-observability.ts`
34
+ * to inject this automatically):
29
35
  * ```jsonc
30
- * "version_metadata": { "binding": "CF_VERSION_METADATA" },
31
- * "analytics_engine_datasets": [{ "binding": "DECO_METRICS", "dataset": "deco_metrics_my_site" }]
36
+ * "observability": {
37
+ * "logs": { "enabled": true, "destinations": ["hyperdx-logs"],
38
+ * "head_sampling_rate": 1.0, "persist": false },
39
+ * "traces": { "enabled": true, "destinations": ["hyperdx-traces"],
40
+ * "head_sampling_rate": 0.1, "persist": false }
41
+ * },
42
+ * "version_metadata": { "binding": "CF_VERSION_METADATA" },
43
+ * "analytics_engine_datasets": [{ "binding": "DECO_METRICS",
44
+ * "dataset": "deco_metrics_my_site" }]
32
45
  * ```
33
46
  *
34
- * Required Worker secrets when OTLP is enabled:
35
- * wrangler secret put OTEL_EXPORTER_OTLP_ENDPOINT # https://in-otel.hyperdx.io
36
- * wrangler secret put OTEL_EXPORTER_OTLP_HEADERS # authorization=<token>
47
+ * **Back-compat seam.** Sites that need to keep app-side OTLP log export
48
+ * (custom destination not covered by CF, custom batching, etc.) can opt back
49
+ * in with `enableAppSideOtlpLogs: true` and the existing `OTEL_EXPORTER_OTLP_*`
50
+ * secrets. Slated for removal in 5.0.0.
37
51
  */
38
52
 
39
- import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
40
53
  import { trace } from "@opentelemetry/api";
41
54
  import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
42
55
 
43
56
  import { configureMeter, configureTracer } from "../middleware/observability";
44
57
  import { createCompositeLogger, createCompositeMeter } from "./composite";
45
- import { configureLogger, defaultLoggerAdapter, logger } from "./logger";
58
+ import {
59
+ configureLogger,
60
+ defaultLoggerAdapter,
61
+ type LogLevel,
62
+ logger,
63
+ setLoggerAttributeFloor,
64
+ } from "./logger";
46
65
  import {
47
66
  createAnalyticsEngineMeterAdapter,
48
67
  createOtelLoggerAdapter,
49
68
  createOtelMeterAdapter,
69
+ flushOtelProviders,
50
70
  setRuntimeEnv,
51
71
  } from "./otelAdapters";
52
72
  import { RequestContext } from "./requestContext";
53
- import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
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
- * Version of `@decocms/start` to advertise as `deco.runtime.version`.
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, advertised as `deco.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 full @decocms/start
115
- * observability stack. Idempotent — calling twice on the same handler
116
- * is a no-op (returns the already-instrumented handler).
183
+ * Wraps a Cloudflare Worker handler with the @decocms/start observability
184
+ * stack:
185
+ * - structured JSON logger (always)
186
+ * - AE meter (when `DECO_METRICS` binding present)
187
+ * - optional app-side OTLP meter (when `OTEL_EXPORTER_OTLP_ENDPOINT` set)
188
+ * - optional app-side OTLP logger (when `enableAppSideOtlpLogs: true`)
189
+ * - per-request `ctx.waitUntil(forceFlush)` for any registered batch processors
190
+ * - bridge from framework-internal `withTracing()` to `@opentelemetry/api`
191
+ * global tracer, with `deco.*` attributes stamped on every span
192
+ *
193
+ * Logs and traces export to HyperDX (or any OTLP destination) is handled
194
+ * by Cloudflare via `observability.{logs,traces}.destinations` in
195
+ * `wrangler.jsonc`. This wrapper does NOT call `@microlabs/otel-cf-workers`
196
+ * `instrument()` — CF's platform-managed export captures `console.*` output
197
+ * and global-tracer spans out-of-band.
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 so
123
- // framework-internal `withTracing()` calls produce real OTel spans
124
- // for whatever exporter is configured (OTLP, console, etc.).
203
+ // Bridge our pluggable TracerAdapter onto @opentelemetry/api. Framework
204
+ // code calls `withTracing("name", fn, { attr: val })`; that delegates here
205
+ // and lands on `trace.getTracer("@decocms/start").startSpan(...)`.
206
+ //
207
+ // CF Workers Tracing (when `observability.traces.enabled = true` in
208
+ // wrangler) installs its own TracerProvider into the @opentelemetry/api
209
+ // global, so these spans flow through to whatever OTLP destination is
210
+ // configured. Without CF tracing the global tracer is a no-op proxy and
211
+ // the spans simply drop — same outcome as before, no error.
125
212
  configureTracer({
126
213
  startSpan: (name, attrs) => {
127
- const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: attrs });
214
+ const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
215
+ const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
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
- const resolveConfig: ResolveConfigFn = (env, _trigger) => {
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
- // Stash env so request-scoped adapters (AE) can resolve their bindings.
174
- // Done inside RequestContext.run wrapping in workerEntry.ts as well, but
175
- // for instrumentWorker we re-stash in case this handler is wrapped over
176
- // the top of a Worker that doesn't go through createDecoWorkerEntry.
228
+ const opts =
229
+ typeof options === "function" ? options(env as Record<string, unknown>) : options;
230
+ bootObservability(opts, env as Record<string, unknown>);
231
+
232
+ // Stash env so request-scoped adapters (AE) can resolve their
233
+ // bindings. Done inside RequestContext.run wrapping in workerEntry.ts
234
+ // too, but we re-stash here in case `instrumentWorker` is wrapped
235
+ // over a handler that doesn't go through `createDecoWorkerEntry`.
177
236
  const wrap = async () => {
178
237
  setRuntimeEnv(env);
179
238
  return handler.fetch(request, env, ctx);
180
239
  };
181
240
 
182
- // RequestContext may already be active (createDecoWorkerEntry sets it
183
- // up). If so, run inline; otherwise wrap. Cheap to detect via current.
184
- if (RequestContext.current) {
185
- return wrap();
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: (env.DECO_ENV_NAME as string | undefined) ?? "production",
214
- decoRuntimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
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
- otlp: Boolean(otlpEndpoint),
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
- sampling: Boolean(env.OTEL_SAMPLING_CONFIG),
267
- runtimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
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 = "2.28.2";
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 { afterEach, describe, expect, it, vi } from "vitest";
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
- const log = createOtelLoggerAdapter({ endpoint: "https://otel.example.invalid" });
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