@decocms/start 4.2.0 → 4.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -1391,6 +1391,26 @@ async function resolveDecoPageImpl(
1391
1391
  try {
1392
1392
  const deferred = resolveSectionShallow(section, ctx);
1393
1393
  if (deferred) {
1394
+ // Skip sections whose scheduling window has already closed (or
1395
+ // hasn't opened yet). Without this check a LoadingFallback
1396
+ // skeleton is rendered and immediately replaced by nothing once
1397
+ // the component's own scheduling guard returns null — producing
1398
+ // the "skeleton flashes then disappears" effect.
1399
+ const sched = deferred.rawProps?.scheduling as
1400
+ | { start?: string; end?: string }
1401
+ | undefined;
1402
+ if (sched) {
1403
+ const now = Date.now();
1404
+ if (sched.end && now > new Date(sched.end).getTime()) {
1405
+ flatIndex++;
1406
+ continue;
1407
+ }
1408
+ if (sched.start && now < new Date(sched.start).getTime()) {
1409
+ flatIndex++;
1410
+ continue;
1411
+ }
1412
+ }
1413
+
1394
1414
  deferred.index = currentFlatIndex;
1395
1415
 
1396
1416
  // Cache rawProps server-side and strip from the deferred object
@@ -60,9 +60,11 @@ export {
60
60
  createAnalyticsEngineMeterAdapter,
61
61
  createOtelLoggerAdapter,
62
62
  createOtelMeterAdapter,
63
+ flushOtelProviders,
63
64
  getRuntimeEnv,
64
65
  type OtelLoggerAdapterOptions,
65
66
  type OtelMeterAdapterOptions,
67
+ registerOtelFlushHandler,
66
68
  setRuntimeEnv,
67
69
  } from "./otelAdapters";
68
70
  // Sampler
package/src/sdk/otel.ts CHANGED
@@ -42,16 +42,25 @@ import { type Resource, resourceFromAttributes } from "@opentelemetry/resources"
42
42
 
43
43
  import { configureMeter, configureTracer } from "../middleware/observability";
44
44
  import { createCompositeLogger, createCompositeMeter } from "./composite";
45
- import { configureLogger, defaultLoggerAdapter, logger } from "./logger";
45
+ import { configureLogger, defaultLoggerAdapter, type LogLevel, logger } from "./logger";
46
46
  import {
47
47
  createAnalyticsEngineMeterAdapter,
48
48
  createOtelLoggerAdapter,
49
49
  createOtelMeterAdapter,
50
+ flushOtelProviders,
50
51
  setRuntimeEnv,
51
52
  } from "./otelAdapters";
52
53
  import { RequestContext } from "./requestContext";
53
54
  import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
54
55
 
56
+ const VALID_LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
57
+
58
+ function parseLogLevel(value: unknown): LogLevel | undefined {
59
+ if (typeof value !== "string") return undefined;
60
+ const lc = value.toLowerCase();
61
+ return VALID_LOG_LEVELS.find((l) => l === lc);
62
+ }
63
+
55
64
  // ---------------------------------------------------------------------------
56
65
  // Types
57
66
  // ---------------------------------------------------------------------------
@@ -69,6 +78,15 @@ export interface OtelOptions {
69
78
  analyticsEngineEnabled?: boolean;
70
79
  /** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
71
80
  metricsExportIntervalMillis?: number;
81
+ /**
82
+ * Minimum severity to forward to OTLP logs (HyperDX). Below the floor
83
+ * the framework still writes a structured JSON line to `console.*`
84
+ * (Cloudflare Workers Logs), so nothing is silently lost.
85
+ *
86
+ * Defaults to `"warn"`. Falls back to env `OTEL_LOG_MIN_SEVERITY` when
87
+ * unset. Set to `"debug"` to forward everything.
88
+ */
89
+ otlpMinSeverity?: LogLevel;
72
90
  /**
73
91
  * Version of `@decocms/start` to advertise as `deco.runtime.version`.
74
92
  * Falls back to a build-time constant; override only for tests.
@@ -179,12 +197,27 @@ export function instrumentWorker(
179
197
  return handler.fetch(request, env, ctx);
180
198
  };
181
199
 
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();
200
+ try {
201
+ // RequestContext may already be active (createDecoWorkerEntry sets it
202
+ // up). If so, run inline; otherwise wrap. Cheap to detect via current.
203
+ if (RequestContext.current) {
204
+ return await wrap();
205
+ }
206
+ return await RequestContext.run(request, wrap);
207
+ } finally {
208
+ // Drain OTLP logger + meter batches inside the post-response window
209
+ // the platform guarantees via `waitUntil`. Without this hook,
210
+ // BatchLogRecordProcessor (5s flush) and PeriodicExportingMetricReader
211
+ // (60s flush) batches usually die with the isolate before the timer
212
+ // fires and never reach HyperDX. `flushOtelProviders` is a no-op when
213
+ // OTLP isn't configured, so this is safe in every code path.
214
+ try {
215
+ ctx.waitUntil(flushOtelProviders());
216
+ } catch {
217
+ // `waitUntil` only throws if `ctx` isn't a real ExecutionContext
218
+ // (e.g. test stubs). Telemetry flush failures are not request-fatal.
219
+ }
186
220
  }
187
- return RequestContext.run(request, wrap);
188
221
  },
189
222
  };
190
223
 
@@ -216,6 +249,9 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
216
249
  });
217
250
 
218
251
  // ---- Logger ----------------------------------------------------------
252
+ const otlpMinSeverity =
253
+ opts.otlpMinSeverity ?? parseLogLevel(env.OTEL_LOG_MIN_SEVERITY) ?? "warn";
254
+
219
255
  const otelLogger =
220
256
  otlpEndpoint != null
221
257
  ? createOtelLoggerAdapter({
@@ -223,6 +259,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
223
259
  headers: otlpHeaders,
224
260
  resource,
225
261
  name: serviceName,
262
+ minSeverity: otlpMinSeverity,
226
263
  })
227
264
  : null;
228
265
 
@@ -262,6 +299,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
262
299
  logger.info("observability booted", {
263
300
  service: serviceName,
264
301
  otlp: Boolean(otlpEndpoint),
302
+ otlpMinSeverity: otlpEndpoint != null ? otlpMinSeverity : null,
265
303
  analyticsEngine: aeEnabled,
266
304
  sampling: Boolean(env.OTEL_SAMPLING_CONFIG),
267
305
  runtimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
@@ -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
 
@@ -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);