@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/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,
|
|
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,
|
|
244
|
+
defaultLoggerAdapter.log(level, msg, merged);
|
|
207
245
|
} catch {
|
|
208
246
|
/* swallow */
|
|
209
247
|
}
|
package/src/sdk/observability.ts
CHANGED
|
@@ -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.test.ts
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
+
});
|