@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.
@@ -39,6 +39,56 @@ import { MetricNames } from "../middleware/observability";
39
39
  import type { LoggerAdapter, LogLevel } from "./logger";
40
40
  import { RequestContext } from "./requestContext";
41
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Flush registry — per-request `ctx.waitUntil` hook target
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Cloudflare Workers isolates are recycled aggressively (often before the
48
+ * next 5s `BatchLogRecordProcessor` tick or 60s `PeriodicExportingMetricReader`
49
+ * tick fires). Without a per-request flush, in-memory log/metric batches die
50
+ * with the isolate and never reach OTLP — observable as "spans show up in
51
+ * HyperDX, logs and metrics don't".
52
+ *
53
+ * Each OTLP adapter registers a `forceFlush` handler here at construction
54
+ * time. `instrumentWorker` calls `flushOtelProviders()` from
55
+ * `ctx.waitUntil(...)` after every response so the batch is drained inside
56
+ * the post-response window the platform guarantees.
57
+ *
58
+ * The registry is module-scoped (one entry per provider, not per request)
59
+ * and survives worker reloads — the boot guard in `bootObservability`
60
+ * prevents duplicate registration in steady state. Tests must call
61
+ * `_resetFlushHandlersForTests()` between fixtures to avoid leakage.
62
+ */
63
+ const flushHandlers: Array<() => Promise<unknown>> = [];
64
+
65
+ /** Register a `forceFlush()`-style handler. Idempotency is the caller's problem. */
66
+ export function registerOtelFlushHandler(fn: () => Promise<unknown>): void {
67
+ flushHandlers.push(fn);
68
+ }
69
+
70
+ /**
71
+ * Drain every registered OTLP provider in parallel. Resolves once they
72
+ * all settle — never rejects, because flush failures are telemetry
73
+ * incidents, not request incidents.
74
+ *
75
+ * Safe to call when no adapters are registered (returns immediately).
76
+ */
77
+ export async function flushOtelProviders(): Promise<void> {
78
+ if (flushHandlers.length === 0) return;
79
+ await Promise.allSettled(flushHandlers.map((fn) => fn()));
80
+ }
81
+
82
+ /** Test-only: clear the flush registry between fixtures. Do not call from app code. */
83
+ export function _resetFlushHandlersForTests(): void {
84
+ flushHandlers.length = 0;
85
+ }
86
+
87
+ /** Test-only: introspect the registry size. Do not call from app code. */
88
+ export function _getFlushHandlerCountForTests(): number {
89
+ return flushHandlers.length;
90
+ }
91
+
42
92
  // ---------------------------------------------------------------------------
43
93
  // Env / binding access
44
94
  // ---------------------------------------------------------------------------
@@ -79,13 +129,37 @@ export interface OtelLoggerAdapterOptions {
79
129
  resource?: Resource;
80
130
  /** OTel logger name. Defaults to "@decocms/start". */
81
131
  name?: string;
132
+ /**
133
+ * Minimum severity to forward to OTLP. Calls below this floor are dropped
134
+ * by this adapter (and therefore don't ship to HyperDX or any other OTLP
135
+ * sink), but the framework's default console adapter still sees them, so
136
+ * they remain visible in Cloudflare Workers Logs.
137
+ *
138
+ * Defaults to `"warn"`. The reasoning: in Cloudflare Workers every OTLP
139
+ * emit eventually becomes an outbound subrequest (after batching). Routine
140
+ * `info` chatter compounded over many isolates can exhaust either the
141
+ * subrequest budget (if a hot loop logs) or the HyperDX log-ingest quota.
142
+ * Warn+ keeps the trace ↔ log correlation that matters during incidents
143
+ * without ingesting noise that's already captured by Cloudflare Logs.
144
+ *
145
+ * Override per-site via `OtelOptions.otlpMinSeverity` or the
146
+ * `OTEL_LOG_MIN_SEVERITY` env var. Set to `"debug"` to forward everything.
147
+ */
148
+ minSeverity?: LogLevel;
82
149
  }
83
150
 
84
151
  /**
85
- * Streams `logger.*` calls to an OTLP/HTTP logs endpoint (e.g. HyperDX).
152
+ * Streams `logger.*` calls (at or above `minSeverity`) to an OTLP/HTTP logs
153
+ * endpoint (e.g. HyperDX).
86
154
  *
87
155
  * Returns `null` when no endpoint is configured — `instrumentWorker()`
88
156
  * uses that signal to skip registering this adapter.
157
+ *
158
+ * Side effect: registers the underlying `LoggerProvider`'s `forceFlush()`
159
+ * with `registerOtelFlushHandler` so per-request hooks in `instrumentWorker`
160
+ * can drain the batch before the Workers isolate is recycled. Without that
161
+ * registration, `BatchLogRecordProcessor`'s 5s timer rarely fires before
162
+ * GC and log records are silently dropped.
89
163
  */
90
164
  export function createOtelLoggerAdapter(
91
165
  options: OtelLoggerAdapterOptions | null,
@@ -101,6 +175,7 @@ export function createOtelLoggerAdapter(
101
175
  resource: options.resource,
102
176
  });
103
177
  provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));
178
+ registerOtelFlushHandler(() => provider.forceFlush());
104
179
 
105
180
  // Register globally so `@opentelemetry/api-logs` consumers (if any)
106
181
  // also pick it up. Idempotent — safe across multiple worker reloads.
@@ -111,10 +186,12 @@ export function createOtelLoggerAdapter(
111
186
  }
112
187
 
113
188
  const otelLogger = provider.getLogger(options.name ?? "@decocms/start");
189
+ const minSev = SEVERITY[options.minSeverity ?? "warn"].number;
114
190
 
115
191
  return {
116
192
  log(level, msg, attrs) {
117
193
  const sev = SEVERITY[level];
194
+ if (sev.number < minSev) return;
118
195
  otelLogger.emit({
119
196
  severityNumber: sev.number,
120
197
  severityText: sev.text,
@@ -229,6 +306,11 @@ export function createOtelMeterAdapter(
229
306
  readers: [reader],
230
307
  views,
231
308
  });
309
+ // See `flushHandlers` registry comment — `PeriodicExportingMetricReader`
310
+ // ticks every `exportIntervalMillis` (default 60s); without per-request
311
+ // forceFlush the metric batch typically dies with the Workers isolate
312
+ // before its first scheduled tick.
313
+ registerOtelFlushHandler(() => provider.forceFlush());
232
314
 
233
315
  try {
234
316
  metricsApi.setGlobalMeterProvider(provider);
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * URL-based head sampler — port of `deco-cx/deco/observability/otel/samplers/urlBased.ts`.
3
3
  *
4
- * Lets ops dial sampling rates per URL pattern without redeploying. Reads
5
- * `OTEL_SAMPLING_CONFIG` (base64-encoded JSON) at boot and decides each
6
- * trace's sample rate based on the matching pattern.
4
+ * **No longer wired into `instrumentWorker` by default.** As of 4.4.0 the
5
+ * recommended path for trace sampling is Cloudflare's wrangler-level
6
+ * `observability.traces.head_sampling_rate`, which is one global rate per
7
+ * Worker. This module stays as an opt-in escape hatch for sites that need
8
+ * URL-pattern-aware sampling (e.g. always trace `/checkout`, sample
9
+ * homepages at 1%) — those sites must wire OTel themselves outside the
10
+ * default `instrumentWorker` flow.
11
+ *
12
+ * The sampler reads `OTEL_SAMPLING_CONFIG` (base64-encoded JSON) and
13
+ * decides each trace's sample rate based on the matching pattern.
7
14
  *
8
15
  * Wrapped in `ParentBasedSampler` so a span inherits its parent's sampling
9
- * decision when one exists (i.e. distributed traces are kept consistent end
16
+ * decision when one exists (i.e. distributed traces stay consistent end
10
17
  * to end).
11
18
  *
12
19
  * **Default ratio.** When no `default` is provided in the config (or the env
@@ -28,6 +35,9 @@
28
35
  * ]
29
36
  * }
30
37
  * ```
38
+ *
39
+ * @deprecated Slated for removal in 5.0.0 unless a site declares an active
40
+ * need. Use Cloudflare's `head_sampling_rate` first.
31
41
  */
32
42
 
33
43
  import { type Attributes, type Context, type Link, type SpanKind, trace } from "@opentelemetry/api";
@@ -189,7 +199,9 @@ export function decodeSamplingConfig(raw: string | undefined): SamplingConfig |
189
199
 
190
200
  /**
191
201
  * Build a `ParentBasedSampler` rooted at our URL-based sampler.
192
- * Use as the `headSampler` for `@microlabs/otel-cf-workers`.
202
+ * Wire as the `headSampler` for any custom OTel SDK setup (e.g. a site
203
+ * that opts back into `@microlabs/otel-cf-workers` outside the default
204
+ * `instrumentWorker` flow).
193
205
  */
194
206
  export function createUrlBasedHeadSampler(config: SamplingConfig | null): Sampler {
195
207
  const root = new URLBasedSampler(config ?? {});
@@ -949,10 +949,13 @@ export function createDecoWorkerEntry(
949
949
  // via getRuntimeEnv() in sdk/otelAdapters.ts.
950
950
  setRuntimeEnv(env);
951
951
 
952
- // Wrap inner handler in a single root span. `@microlabs/otel-cf-workers`
953
- // already creates an outer span via its `instrument()` wrapper; this
954
- // adds a nested span carrying our normalized path/status attributes
955
- // that microlabs doesn't capture (it uses url.path verbatim).
952
+ // Wrap inner handler in a single root span carrying our normalized
953
+ // path/method attributes. With Cloudflare-managed trace export
954
+ // (`observability.traces.destinations` in wrangler.jsonc), this
955
+ // span and any `withTracing` spans nested below it flow to
956
+ // HyperDX via CF's platform-managed OTLP push, since the bridge
957
+ // in `instrumentWorker` configures the `@opentelemetry/api`
958
+ // global tracer for us.
956
959
  return withTracing(
957
960
  "deco.http.request",
958
961
  async () => {