@decocms/blocks 7.0.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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the public observability surface and `instrumentWorker`.
|
|
3
|
+
*
|
|
4
|
+
* As of 5.0.0 the framework no longer bundles an in-Worker OTLP exporter
|
|
5
|
+
* for logs/metrics. Tests below verify:
|
|
6
|
+
* - public exports stay stable (logger, composite helpers, AE adapter,
|
|
7
|
+
* observability primitives)
|
|
8
|
+
* - `instrumentWorker` is a thin wrapper: bridge tracer + boot logger/meter,
|
|
9
|
+
* forward `fetch` to the wrapped handler, no flush registry, no
|
|
10
|
+
* `ctx.waitUntil` for telemetry plumbing.
|
|
11
|
+
*/
|
|
12
|
+
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import * as composite from "./composite";
|
|
15
|
+
import * as logger from "./logger";
|
|
16
|
+
import * as observability from "./observability";
|
|
17
|
+
import { _resetBootStateForTests, instrumentWorker } from "./otel";
|
|
18
|
+
import * as adapters from "./otelAdapters";
|
|
19
|
+
import { createClickhouseCollectorAdapter } from "./otelAdapters/clickhouseCollector";
|
|
20
|
+
|
|
21
|
+
interface TestEnv extends Record<string, unknown> {
|
|
22
|
+
DECO_SITE_NAME?: string;
|
|
23
|
+
DECO_METRICS?: { writeDataPoint: () => void };
|
|
24
|
+
CF_VERSION_METADATA?: { id: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeCtx() {
|
|
28
|
+
const waited: Array<Promise<unknown>> = [];
|
|
29
|
+
return {
|
|
30
|
+
waitUntil: (p: Promise<unknown>) => {
|
|
31
|
+
waited.push(p);
|
|
32
|
+
},
|
|
33
|
+
passThroughOnException: () => {},
|
|
34
|
+
waited,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("observability granular modules", () => {
|
|
39
|
+
it("exports the logger surface", () => {
|
|
40
|
+
expect(typeof logger.logger.info).toBe("function");
|
|
41
|
+
expect(typeof logger.configureLogger).toBe("function");
|
|
42
|
+
expect(typeof logger.setLogLevel).toBe("function");
|
|
43
|
+
expect(typeof logger.defaultLoggerAdapter.log).toBe("function");
|
|
44
|
+
expect(typeof logger.serializeError).toBe("function");
|
|
45
|
+
expect(typeof logger.setLoggerAttributeFloor).toBe("function");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exports composite helpers", () => {
|
|
49
|
+
expect(typeof composite.createCompositeLogger).toBe("function");
|
|
50
|
+
expect(typeof composite.createCompositeMeter).toBe("function");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("exports only the AE adapter factory + runtime env helpers", () => {
|
|
54
|
+
expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
|
|
55
|
+
expect(typeof adapters.setRuntimeEnv).toBe("function");
|
|
56
|
+
expect(typeof adapters.getRuntimeEnv).toBe("function");
|
|
57
|
+
// OTLP factories + flush registry were removed in 5.0.0.
|
|
58
|
+
expect("createOtelLoggerAdapter" in adapters).toBe(false);
|
|
59
|
+
expect("createOtelMeterAdapter" in adapters).toBe(false);
|
|
60
|
+
expect("flushOtelProviders" in adapters).toBe(false);
|
|
61
|
+
expect("registerOtelFlushHandler" in adapters).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("exports observability primitives from middleware/observability", () => {
|
|
65
|
+
expect(typeof observability.withTracing).toBe("function");
|
|
66
|
+
expect(typeof observability.recordRequestMetric).toBe("function");
|
|
67
|
+
expect(typeof observability.recordCacheMetric).toBe("function");
|
|
68
|
+
expect(observability.MetricNames.HTTP_SERVER_REQUEST_DURATION).toBe(
|
|
69
|
+
"http.server.request.duration",
|
|
70
|
+
);
|
|
71
|
+
expect(observability.MetricNames.RESOLVE_DURATION).toBe("deco.cms.resolve.duration");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ClickHouse collector adapter is a documented stub that throws", () => {
|
|
75
|
+
expect(() =>
|
|
76
|
+
createClickhouseCollectorAdapter({ endpoint: "https://otel-collector.internal" }),
|
|
77
|
+
).toThrow(/not implemented/i);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("instrumentWorker — CF-native default boot", () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
_resetBootStateForTests();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
_resetBootStateForTests();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("forwards fetch to the wrapped handler", async () => {
|
|
91
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
92
|
+
const wrapped = instrumentWorker(handler);
|
|
93
|
+
|
|
94
|
+
const env: TestEnv = {};
|
|
95
|
+
const ctx = fakeCtx();
|
|
96
|
+
const res = await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
97
|
+
|
|
98
|
+
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
99
|
+
expect(await res.text()).toBe("ok");
|
|
100
|
+
// No flush registry anymore — instrumentWorker should NOT push anything
|
|
101
|
+
// into ctx.waitUntil for telemetry plumbing.
|
|
102
|
+
expect(ctx.waited).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not call ctx.waitUntil even when the handler throws", async () => {
|
|
106
|
+
const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
|
|
107
|
+
const wrapped = instrumentWorker(handler);
|
|
108
|
+
|
|
109
|
+
const env: TestEnv = {};
|
|
110
|
+
const ctx = fakeCtx();
|
|
111
|
+
await expect(wrapped.fetch(new Request("https://example.test/"), env, ctx)).rejects.toThrow(
|
|
112
|
+
"boom",
|
|
113
|
+
);
|
|
114
|
+
expect(ctx.waited).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("boot is idempotent across requests", async () => {
|
|
118
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
119
|
+
const wrapped = instrumentWorker(handler, { decoRuntimeVersion: "5.0.0-test" });
|
|
120
|
+
|
|
121
|
+
const env: TestEnv = {};
|
|
122
|
+
for (let i = 0; i < 3; i++) {
|
|
123
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(handler.fetch).toHaveBeenCalledTimes(3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("AE meter is wired without OTLP when DECO_METRICS binding is present", async () => {
|
|
130
|
+
const writeDataPoint = vi.fn();
|
|
131
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
132
|
+
const wrapped = instrumentWorker(handler);
|
|
133
|
+
|
|
134
|
+
const env: TestEnv = { DECO_METRICS: { writeDataPoint } };
|
|
135
|
+
const ctx = fakeCtx();
|
|
136
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
137
|
+
|
|
138
|
+
// AE adapter does not register flush handlers (writeDataPoint is
|
|
139
|
+
// fire-and-forget), so ctx.waitUntil stays empty even with AE wired.
|
|
140
|
+
expect(ctx.waited).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("accepts a function for options so site code can read env at boot time", async () => {
|
|
144
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
145
|
+
const wrapped = instrumentWorker(handler, (env) => ({
|
|
146
|
+
serviceName: (env.DECO_SITE_NAME as string) ?? "fallback",
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const env: TestEnv = { DECO_SITE_NAME: "my-store-test" };
|
|
150
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
151
|
+
|
|
152
|
+
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("instrumentWorker — identity floor", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
_resetBootStateForTests();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
_resetBootStateForTests();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("stamps service.name and service.version on the logger floor", async () => {
|
|
166
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
167
|
+
const wrapped = instrumentWorker(handler, { serviceName: "casaevideo-tanstack" });
|
|
168
|
+
|
|
169
|
+
const env: TestEnv = { CF_VERSION_METADATA: { id: "abc123" } };
|
|
170
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
171
|
+
|
|
172
|
+
const floor = logger._getLoggerAttributeFloorForTests();
|
|
173
|
+
expect(floor["service.name"]).toBe("casaevideo-tanstack");
|
|
174
|
+
expect(floor["service.version"]).toBe("abc123");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("falls back to DECO_SITE_NAME for service.name", async () => {
|
|
178
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
179
|
+
const wrapped = instrumentWorker(handler);
|
|
180
|
+
|
|
181
|
+
const env: TestEnv = { DECO_SITE_NAME: "fallback-site" };
|
|
182
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
183
|
+
|
|
184
|
+
const floor = logger._getLoggerAttributeFloorForTests();
|
|
185
|
+
expect(floor["service.name"]).toBe("fallback-site");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("omits service.version when CF_VERSION_METADATA is absent", async () => {
|
|
189
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
190
|
+
const wrapped = instrumentWorker(handler, { serviceName: "no-version-site" });
|
|
191
|
+
|
|
192
|
+
const env: TestEnv = {};
|
|
193
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
194
|
+
|
|
195
|
+
const floor = logger._getLoggerAttributeFloorForTests();
|
|
196
|
+
expect("service.version" in floor).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("instrumentWorker — tracer bridge", () => {
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
_resetBootStateForTests();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
_resetBootStateForTests();
|
|
207
|
+
observability.configureTracer({
|
|
208
|
+
startSpan: () => ({ end: () => {} }),
|
|
209
|
+
});
|
|
210
|
+
// installBridgeWithFakeOtelSpan() below uses vi.spyOn(trace, "getTracer").
|
|
211
|
+
// Without restoring, that spy leaks into any later test that touches the
|
|
212
|
+
// real OTel API.
|
|
213
|
+
vi.restoreAllMocks();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function installBridgeWithFakeOtelSpan() {
|
|
217
|
+
const span = {
|
|
218
|
+
end: vi.fn(),
|
|
219
|
+
recordException: vi.fn(),
|
|
220
|
+
setStatus: vi.fn(),
|
|
221
|
+
setAttribute: vi.fn(),
|
|
222
|
+
spanContext: vi.fn(() => ({
|
|
223
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
224
|
+
spanId: "fedcba9876543210",
|
|
225
|
+
traceFlags: 1,
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
const getTracer = vi.spyOn(trace, "getTracer").mockReturnValue({
|
|
229
|
+
startSpan: () => span,
|
|
230
|
+
startActiveSpan: () => undefined,
|
|
231
|
+
} as unknown as ReturnType<typeof trace.getTracer>);
|
|
232
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
233
|
+
const wrapped = instrumentWorker(handler);
|
|
234
|
+
return { span, wrapped, getTracer };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it("setError sets SpanStatusCode.ERROR and records exception", async () => {
|
|
238
|
+
const { span, wrapped } = installBridgeWithFakeOtelSpan();
|
|
239
|
+
await wrapped.fetch(new Request("https://example.test/"), {}, fakeCtx());
|
|
240
|
+
|
|
241
|
+
// Drive a span through the bridge via withTracing.
|
|
242
|
+
await expect(
|
|
243
|
+
observability.withTracing("t", async () => {
|
|
244
|
+
throw new Error("boom");
|
|
245
|
+
}),
|
|
246
|
+
).rejects.toThrow("boom");
|
|
247
|
+
|
|
248
|
+
expect(span.recordException).toHaveBeenCalledOnce();
|
|
249
|
+
expect(span.setStatus).toHaveBeenCalledWith({
|
|
250
|
+
code: SpanStatusCode.ERROR,
|
|
251
|
+
message: "boom",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("setError sets ERROR status for non-Error throws too", async () => {
|
|
256
|
+
const { span, wrapped } = installBridgeWithFakeOtelSpan();
|
|
257
|
+
await wrapped.fetch(new Request("https://example.test/"), {}, fakeCtx());
|
|
258
|
+
|
|
259
|
+
await expect(
|
|
260
|
+
observability.withTracing("t", async () => {
|
|
261
|
+
throw "string-error";
|
|
262
|
+
}),
|
|
263
|
+
).rejects.toBe("string-error");
|
|
264
|
+
|
|
265
|
+
expect(span.recordException).not.toHaveBeenCalled();
|
|
266
|
+
expect(span.setStatus).toHaveBeenCalledWith({
|
|
267
|
+
code: SpanStatusCode.ERROR,
|
|
268
|
+
message: "string-error",
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("spanContext round-trips traceId/spanId/traceFlags", async () => {
|
|
273
|
+
const { wrapped } = installBridgeWithFakeOtelSpan();
|
|
274
|
+
await wrapped.fetch(new Request("https://example.test/"), {}, fakeCtx());
|
|
275
|
+
|
|
276
|
+
const tracer = observability.getTracer();
|
|
277
|
+
const span = tracer?.startSpan("t");
|
|
278
|
+
expect(span?.spanContext?.()).toEqual({
|
|
279
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
280
|
+
spanId: "fedcba9876543210",
|
|
281
|
+
traceFlags: 1,
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("instrumentWorker — OTLP/HTTP metrics exporter wiring", () => {
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
_resetBootStateForTests();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
_resetBootStateForTests();
|
|
293
|
+
vi.restoreAllMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
function makeFetchSpy() {
|
|
297
|
+
const calls: Array<{ url: string; body: string }> = [];
|
|
298
|
+
const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
299
|
+
calls.push({ url: String(input), body: String(init?.body ?? "") });
|
|
300
|
+
return new Response("{}", { status: 200 });
|
|
301
|
+
});
|
|
302
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
it("wires the OTLP meter only when DECO_OTEL_METRICS_ENDPOINT is set on env", async () => {
|
|
306
|
+
const { impl } = makeFetchSpy();
|
|
307
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
308
|
+
const wrapped = instrumentWorker(handler, {
|
|
309
|
+
serviceName: "smoke-site",
|
|
310
|
+
otlpMetricsFetchImpl: impl,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Without the env var, no flush is enqueued via ctx.waitUntil.
|
|
314
|
+
const ctxNoEndpoint = fakeCtx();
|
|
315
|
+
await wrapped.fetch(new Request("https://example.test/"), {}, ctxNoEndpoint);
|
|
316
|
+
expect(ctxNoEndpoint.waited).toHaveLength(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("records metrics + flushes via ctx.waitUntil at request end", async () => {
|
|
320
|
+
const { impl, calls } = makeFetchSpy();
|
|
321
|
+
const handler = {
|
|
322
|
+
fetch: vi.fn(async () => {
|
|
323
|
+
observability.recordRequestMetric("GET", "/p/123", 200, 42);
|
|
324
|
+
return new Response("ok");
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
const wrapped = instrumentWorker(handler, {
|
|
328
|
+
serviceName: "smoke-site",
|
|
329
|
+
otlpMetricsFetchImpl: impl,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const env: TestEnv = {
|
|
333
|
+
DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics",
|
|
334
|
+
} as TestEnv & { DECO_OTEL_METRICS_ENDPOINT: string };
|
|
335
|
+
const ctx = fakeCtx();
|
|
336
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
337
|
+
|
|
338
|
+
// ctx.waitUntil was used to drain the buffer.
|
|
339
|
+
expect(ctx.waited).toHaveLength(1);
|
|
340
|
+
await Promise.all(ctx.waited);
|
|
341
|
+
|
|
342
|
+
// Exactly one POST to the configured endpoint.
|
|
343
|
+
expect(calls).toHaveLength(1);
|
|
344
|
+
expect(calls[0].url).toBe("https://ingest.test/v1/metrics");
|
|
345
|
+
|
|
346
|
+
// Payload carries the resource floor and at least the canonical
|
|
347
|
+
// `http.server.request.duration` histogram that recordRequestMetric emits.
|
|
348
|
+
const payload = JSON.parse(calls[0].body) as {
|
|
349
|
+
resourceMetrics: Array<{
|
|
350
|
+
resource: { attributes: Array<{ key: string; value: { stringValue: string } }> };
|
|
351
|
+
scopeMetrics: Array<{ metrics: Array<{ name: string }> }>;
|
|
352
|
+
}>;
|
|
353
|
+
};
|
|
354
|
+
const attrs = payload.resourceMetrics[0].resource.attributes;
|
|
355
|
+
expect(attrs).toContainEqual({
|
|
356
|
+
key: "service.name",
|
|
357
|
+
value: { stringValue: "smoke-site" },
|
|
358
|
+
});
|
|
359
|
+
const names = payload.resourceMetrics[0].scopeMetrics[0].metrics.map((m) => m.name);
|
|
360
|
+
expect(names).toContain("http.server.request.duration");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("otlpMetricsEnabled=false disables the OTLP meter even when env is set", async () => {
|
|
364
|
+
const { impl, calls } = makeFetchSpy();
|
|
365
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
366
|
+
const wrapped = instrumentWorker(handler, {
|
|
367
|
+
otlpMetricsEnabled: false,
|
|
368
|
+
otlpMetricsFetchImpl: impl,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const env: TestEnv = {
|
|
372
|
+
DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics",
|
|
373
|
+
} as TestEnv & { DECO_OTEL_METRICS_ENDPOINT: string };
|
|
374
|
+
const ctx = fakeCtx();
|
|
375
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
376
|
+
|
|
377
|
+
expect(ctx.waited).toHaveLength(0);
|
|
378
|
+
expect(calls).toHaveLength(0);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("falling back to handler.fetch failure still triggers a flush", async () => {
|
|
382
|
+
const { impl } = makeFetchSpy();
|
|
383
|
+
const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
|
|
384
|
+
const wrapped = instrumentWorker(handler, {
|
|
385
|
+
otlpMetricsFetchImpl: impl,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const env: TestEnv = {
|
|
389
|
+
DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics",
|
|
390
|
+
} as TestEnv & { DECO_OTEL_METRICS_ENDPOINT: string };
|
|
391
|
+
const ctx = fakeCtx();
|
|
392
|
+
await expect(
|
|
393
|
+
wrapped.fetch(new Request("https://example.test/"), env, ctx),
|
|
394
|
+
).rejects.toThrow("boom");
|
|
395
|
+
|
|
396
|
+
expect(ctx.waited).toHaveLength(1);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("instrumentWorker — OTLP/HTTP error-log channel wiring", () => {
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
_resetBootStateForTests();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
_resetBootStateForTests();
|
|
407
|
+
vi.restoreAllMocks();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
function makeFetchSpy() {
|
|
411
|
+
const calls: Array<{ url: string; body: string }> = [];
|
|
412
|
+
const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
413
|
+
calls.push({ url: String(input), body: String(init?.body ?? "") });
|
|
414
|
+
return new Response("{}", { status: 200 });
|
|
415
|
+
});
|
|
416
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
it("routes logger.error through the direct-POST channel when env is set", async () => {
|
|
420
|
+
const { impl, calls } = makeFetchSpy();
|
|
421
|
+
const handler = {
|
|
422
|
+
fetch: vi.fn(async () => {
|
|
423
|
+
logger.logger.error("payment failed", { stage: "checkout", reason: "declined" });
|
|
424
|
+
return new Response("ok");
|
|
425
|
+
}),
|
|
426
|
+
};
|
|
427
|
+
const wrapped = instrumentWorker(handler, {
|
|
428
|
+
serviceName: "smoke-site",
|
|
429
|
+
otlpLogsFetchImpl: impl,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const env: TestEnv = {
|
|
433
|
+
DECO_OTEL_LOGS_ENDPOINT: "https://ingest.test/v1/logs",
|
|
434
|
+
} as TestEnv & { DECO_OTEL_LOGS_ENDPOINT: string };
|
|
435
|
+
const ctx = fakeCtx();
|
|
436
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
437
|
+
|
|
438
|
+
// ctx.waitUntil(flushErrors) was queued.
|
|
439
|
+
expect(ctx.waited.length).toBeGreaterThanOrEqual(1);
|
|
440
|
+
await Promise.all(ctx.waited);
|
|
441
|
+
|
|
442
|
+
expect(calls).toHaveLength(1);
|
|
443
|
+
expect(calls[0].url).toBe("https://ingest.test/v1/logs");
|
|
444
|
+
const payload = JSON.parse(calls[0].body) as {
|
|
445
|
+
resourceLogs: Array<{
|
|
446
|
+
scopeLogs: Array<{
|
|
447
|
+
logRecords: Array<{ severityText: string; body: { stringValue: string } }>;
|
|
448
|
+
}>;
|
|
449
|
+
}>;
|
|
450
|
+
};
|
|
451
|
+
const rec = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
452
|
+
expect(rec.severityText).toBe("error");
|
|
453
|
+
expect(rec.body.stringValue).toBe("payment failed");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("debug calls do NOT trigger a direct POST (below default info minLevel)", async () => {
|
|
457
|
+
const { impl, calls } = makeFetchSpy();
|
|
458
|
+
const handler = {
|
|
459
|
+
fetch: vi.fn(async () => {
|
|
460
|
+
logger.logger.debug("debug msg");
|
|
461
|
+
return new Response("ok");
|
|
462
|
+
}),
|
|
463
|
+
};
|
|
464
|
+
const wrapped = instrumentWorker(handler, {
|
|
465
|
+
otlpLogsFetchImpl: impl,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const env: TestEnv = {
|
|
469
|
+
DECO_OTEL_LOGS_ENDPOINT: "https://ingest.test/v1/logs",
|
|
470
|
+
} as TestEnv & { DECO_OTEL_LOGS_ENDPOINT: string };
|
|
471
|
+
const ctx = fakeCtx();
|
|
472
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
473
|
+
await Promise.all(ctx.waited);
|
|
474
|
+
|
|
475
|
+
expect(calls).toHaveLength(0);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("info calls DO trigger a direct POST (at default info minLevel)", async () => {
|
|
479
|
+
const { impl, calls } = makeFetchSpy();
|
|
480
|
+
const handler = {
|
|
481
|
+
fetch: vi.fn(async () => {
|
|
482
|
+
logger.logger.info("section loaded");
|
|
483
|
+
return new Response("ok");
|
|
484
|
+
}),
|
|
485
|
+
};
|
|
486
|
+
const wrapped = instrumentWorker(handler, {
|
|
487
|
+
otlpLogsFetchImpl: impl,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const env: TestEnv = {
|
|
491
|
+
DECO_OTEL_LOGS_ENDPOINT: "https://ingest.test/v1/logs",
|
|
492
|
+
} as TestEnv & { DECO_OTEL_LOGS_ENDPOINT: string };
|
|
493
|
+
const ctx = fakeCtx();
|
|
494
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
495
|
+
await Promise.all(ctx.waited);
|
|
496
|
+
|
|
497
|
+
expect(calls).toHaveLength(1);
|
|
498
|
+
const payload = JSON.parse(calls[0].body) as {
|
|
499
|
+
resourceLogs: Array<{ scopeLogs: Array<{ logRecords: Array<{ severityText: string }> }> }>;
|
|
500
|
+
};
|
|
501
|
+
expect(payload.resourceLogs[0].scopeLogs[0].logRecords[0].severityText).toBe("info");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("otlpLogsEnabled=false disables the channel even when env is set", async () => {
|
|
505
|
+
const { impl, calls } = makeFetchSpy();
|
|
506
|
+
const handler = {
|
|
507
|
+
fetch: vi.fn(async () => {
|
|
508
|
+
logger.logger.error("boom");
|
|
509
|
+
return new Response("ok");
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
const wrapped = instrumentWorker(handler, {
|
|
513
|
+
otlpLogsEnabled: false,
|
|
514
|
+
otlpLogsFetchImpl: impl,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const env: TestEnv = {
|
|
518
|
+
DECO_OTEL_LOGS_ENDPOINT: "https://ingest.test/v1/logs",
|
|
519
|
+
} as TestEnv & { DECO_OTEL_LOGS_ENDPOINT: string };
|
|
520
|
+
const ctx = fakeCtx();
|
|
521
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
522
|
+
await Promise.all(ctx.waited);
|
|
523
|
+
|
|
524
|
+
expect(calls).toHaveLength(0);
|
|
525
|
+
});
|
|
526
|
+
});
|