@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/logger.ts CHANGED
@@ -88,6 +88,20 @@ export const defaultLoggerAdapter: LoggerAdapter = {
88
88
  let activeAdapter: LoggerAdapter = defaultLoggerAdapter;
89
89
  let minLevel: LogLevel = "info";
90
90
 
91
+ /**
92
+ * Per-record attribute floor — merged into every log line BEFORE the
93
+ * caller's `attrs` (caller wins). Used to stamp `deco.runtime.version`,
94
+ * `deco.apps.version`, `deployment.environment` on every log so HyperDX
95
+ * panels filtering on these dimensions keep working under
96
+ * Cloudflare-managed log export (which otherwise strips our resource
97
+ * attributes — only the JSON record body survives).
98
+ *
99
+ * Set via `setLoggerAttributeFloor(...)` at boot from
100
+ * `instrumentWorker()`. Default empty so the logger is a no-op for sites
101
+ * that don't wire `instrumentWorker`.
102
+ */
103
+ let attributeFloor: Record<string, unknown> = {};
104
+
91
105
  /**
92
106
  * Replace the active logger adapter.
93
107
  * Call once at worker boot from `instrumentWorker()`.
@@ -96,6 +110,22 @@ export function configureLogger(adapter: LoggerAdapter): void {
96
110
  activeAdapter = adapter;
97
111
  }
98
112
 
113
+ /**
114
+ * Replace the per-record attribute floor — keys here will be added to
115
+ * every log line UNLESS the caller passes the same key in their `attrs`
116
+ * (caller wins). Set once at worker boot from `instrumentWorker()`.
117
+ */
118
+ export function setLoggerAttributeFloor(attrs: Record<string, unknown>): void {
119
+ attributeFloor = { ...attrs };
120
+ }
121
+
122
+ /**
123
+ * Test-only: read the current attribute floor. Do not call from app code.
124
+ */
125
+ export function _getLoggerAttributeFloorForTests(): Record<string, unknown> {
126
+ return { ...attributeFloor };
127
+ }
128
+
99
129
  /**
100
130
  * Get the current active logger adapter (for tests / advanced wiring).
101
131
  */
@@ -197,13 +227,21 @@ export function serializeError(err: unknown): SerializedError {
197
227
 
198
228
  function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void {
199
229
  if (!shouldLog(level)) return;
230
+ // Merge floor → caller attrs so caller can override any floor key.
231
+ // Skipped entirely when the floor is empty so the no-op path stays cheap.
232
+ const merged: Record<string, unknown> | undefined =
233
+ Object.keys(attributeFloor).length === 0
234
+ ? attrs
235
+ : attrs
236
+ ? { ...attributeFloor, ...attrs }
237
+ : { ...attributeFloor };
200
238
  try {
201
- activeAdapter.log(level, msg, attrs);
239
+ activeAdapter.log(level, msg, merged);
202
240
  } catch {
203
241
  // Adapter blew up. Fall back to default so we don't lose the line.
204
242
  if (activeAdapter !== defaultLoggerAdapter) {
205
243
  try {
206
- defaultLoggerAdapter.log(level, msg, attrs);
244
+ defaultLoggerAdapter.log(level, msg, merged);
207
245
  } catch {
208
246
  /* swallow */
209
247
  }
@@ -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
@@ -1,27 +1,38 @@
1
1
  /**
2
- * NOTE: full `instrumentWorker(...)` integration tests aren't feasible in
3
- * plain vitest because `@microlabs/otel-cf-workers` imports `cloudflare:workers`,
4
- * which only exists in the Workers runtime.
2
+ * Coverage for `instrumentWorker` and the public observability surface.
5
3
  *
6
- * The wiring is validated end-to-end on the lebiscuit canary (Section 2 of
7
- * the otel-hyperdx-parity plan): hit the deployed preview, confirm log
8
- * lines via `wrangler tail`, traces via the HyperDX UI, and AE data points
9
- * via `wrangler analytics-engine sql`. If we ever migrate the framework
10
- * test suite to `@cloudflare/vitest-pool-workers`, restore the in-process
11
- * smoke test here.
12
- *
13
- * Until then this file just guards the public API shape of the granular
14
- * modules so refactors that break the export surface fail loudly in CI.
15
- * (We deliberately don't import `./observability` here — it transitively
16
- * pulls in `@microlabs/otel-cf-workers` and therefore `cloudflare:workers`.)
4
+ * As of 4.4.0 the framework no longer wraps with `@microlabs/otel-cf-workers`
5
+ * (`cloudflare:workers`-only), so importing `./otel` and `./observability`
6
+ * works in plain vitest. Earlier versions of this file documented that
7
+ * constraint it's gone.
17
8
  */
18
- import { describe, expect, it } from "vitest";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
10
  import * as observability from "../middleware/observability";
20
11
  import * as composite from "./composite";
21
12
  import * as logger from "./logger";
13
+ import { _resetBootStateForTests, instrumentWorker } from "./otel";
22
14
  import * as adapters from "./otelAdapters";
23
15
  import * as sampler from "./sampler";
24
16
 
17
+ interface TestEnv extends Record<string, unknown> {
18
+ DECO_SITE_NAME?: string;
19
+ OTEL_EXPORTER_OTLP_ENDPOINT?: string;
20
+ OTEL_EXPORTER_OTLP_HEADERS?: string;
21
+ DECO_METRICS?: { writeDataPoint: () => void };
22
+ CF_VERSION_METADATA?: { id: string };
23
+ }
24
+
25
+ function fakeCtx() {
26
+ const waited: Array<Promise<unknown>> = [];
27
+ return {
28
+ waitUntil: (p: Promise<unknown>) => {
29
+ waited.push(p);
30
+ },
31
+ passThroughOnException: () => {},
32
+ waited,
33
+ };
34
+ }
35
+
25
36
  describe("observability granular modules", () => {
26
37
  it("exports the logger surface", () => {
27
38
  expect(typeof logger.logger.info).toBe("function");
@@ -41,6 +52,8 @@ describe("observability granular modules", () => {
41
52
  expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
42
53
  expect(typeof adapters.setRuntimeEnv).toBe("function");
43
54
  expect(typeof adapters.getRuntimeEnv).toBe("function");
55
+ expect(typeof adapters.flushOtelProviders).toBe("function");
56
+ expect(typeof adapters.registerOtelFlushHandler).toBe("function");
44
57
  });
45
58
 
46
59
  it("exports sampler API", () => {
@@ -57,3 +70,103 @@ describe("observability granular modules", () => {
57
70
  expect(observability.MetricNames.RESOLVE_DURATION_MS).toBe("resolve_duration_ms");
58
71
  });
59
72
  });
73
+
74
+ describe("instrumentWorker — CF-native default boot", () => {
75
+ beforeEach(() => {
76
+ _resetBootStateForTests();
77
+ adapters._resetFlushHandlersForTests();
78
+ });
79
+
80
+ afterEach(() => {
81
+ _resetBootStateForTests();
82
+ adapters._resetFlushHandlersForTests();
83
+ });
84
+
85
+ it("default mode (no opts, only OTLP endpoint set): wires meter flush only, NOT logger flush", async () => {
86
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
87
+ const wrapped = instrumentWorker(handler);
88
+
89
+ const env: TestEnv = {
90
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
91
+ OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
92
+ };
93
+ const ctx = fakeCtx();
94
+ await wrapped.fetch(new Request("https://example.test/"), env, ctx);
95
+
96
+ // Exactly one provider registered: the OTLP meter. The OTLP logger is
97
+ // gated behind `enableAppSideOtlpLogs` opt-in and CF handles log export.
98
+ expect(adapters._getFlushHandlerCountForTests()).toBe(1);
99
+ expect(handler.fetch).toHaveBeenCalledOnce();
100
+ expect(ctx.waited).toHaveLength(1);
101
+ });
102
+
103
+ it("default mode without OTLP endpoint: no flush handlers registered (pure CF-native)", async () => {
104
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
105
+ const wrapped = instrumentWorker(handler);
106
+
107
+ const env: TestEnv = {};
108
+ const ctx = fakeCtx();
109
+ await wrapped.fetch(new Request("https://example.test/"), env, ctx);
110
+
111
+ expect(adapters._getFlushHandlerCountForTests()).toBe(0);
112
+ // ctx.waitUntil still called with the no-op flush, but the handler array is empty.
113
+ expect(ctx.waited).toHaveLength(1);
114
+ });
115
+
116
+ it("opt-in mode (enableAppSideOtlpLogs: true): wires BOTH logger and meter flush", async () => {
117
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
118
+ const wrapped = instrumentWorker(handler, { enableAppSideOtlpLogs: true });
119
+
120
+ const env: TestEnv = {
121
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
122
+ OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
123
+ };
124
+ const ctx = fakeCtx();
125
+ await wrapped.fetch(new Request("https://example.test/"), env, ctx);
126
+
127
+ expect(adapters._getFlushHandlerCountForTests()).toBe(2);
128
+ });
129
+
130
+ it("flush is awaited via ctx.waitUntil even when fetch throws", async () => {
131
+ const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
132
+ const wrapped = instrumentWorker(handler);
133
+
134
+ const env: TestEnv = {};
135
+ const ctx = fakeCtx();
136
+ await expect(wrapped.fetch(new Request("https://example.test/"), env, ctx)).rejects.toThrow(
137
+ "boom",
138
+ );
139
+
140
+ expect(ctx.waited).toHaveLength(1);
141
+ });
142
+
143
+ it("boot is idempotent across requests: flush handler count stable", async () => {
144
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
145
+ const wrapped = instrumentWorker(handler);
146
+
147
+ const env: TestEnv = {
148
+ OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
149
+ };
150
+
151
+ for (let i = 0; i < 3; i++) {
152
+ await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
153
+ }
154
+
155
+ expect(adapters._getFlushHandlerCountForTests()).toBe(1);
156
+ expect(handler.fetch).toHaveBeenCalledTimes(3);
157
+ });
158
+
159
+ it("AE meter is wired without OTLP when DECO_METRICS binding is present", async () => {
160
+ const writeDataPoint = vi.fn();
161
+ const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
162
+ const wrapped = instrumentWorker(handler);
163
+
164
+ const env: TestEnv = { DECO_METRICS: { writeDataPoint } };
165
+ const ctx = fakeCtx();
166
+ await wrapped.fetch(new Request("https://example.test/"), env, ctx);
167
+
168
+ // AE adapter doesn't register a flush (writeDataPoint is fire-and-forget),
169
+ // so no providers should be registered when only AE is wired.
170
+ expect(adapters._getFlushHandlerCountForTests()).toBe(0);
171
+ });
172
+ });