@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,457 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createOtlpHttpLogAdapter } from "./otelHttpLog";
|
|
3
|
+
|
|
4
|
+
interface OtlpLogsPayload {
|
|
5
|
+
resourceLogs: Array<{
|
|
6
|
+
resource: {
|
|
7
|
+
attributes: Array<{ key: string; value: { stringValue: string } }>;
|
|
8
|
+
};
|
|
9
|
+
scopeLogs: Array<{
|
|
10
|
+
scope: { name: string; version: string };
|
|
11
|
+
logRecords: Array<{
|
|
12
|
+
timeUnixNano: string;
|
|
13
|
+
severityNumber: number;
|
|
14
|
+
severityText: string;
|
|
15
|
+
body: { stringValue: string };
|
|
16
|
+
attributes: Array<{ key: string; value: Record<string, unknown> }>;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function captureFetch() {
|
|
23
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
24
|
+
const impl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
25
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
26
|
+
return new Response("{}", { status: 200 });
|
|
27
|
+
});
|
|
28
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildAdapter(
|
|
32
|
+
overrides: {
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
minFlushIntervalMs?: number;
|
|
35
|
+
maxBufferRecords?: number;
|
|
36
|
+
rateLimitBurstCapacity?: number;
|
|
37
|
+
rateLimitRefillPerMinute?: number;
|
|
38
|
+
nowMs?: () => number;
|
|
39
|
+
onError?: (kind: "flush" | "overflow" | "rate-limit", err: unknown) => void;
|
|
40
|
+
} = {},
|
|
41
|
+
) {
|
|
42
|
+
return createOtlpHttpLogAdapter({
|
|
43
|
+
endpoint: "https://ingest.test/v1/logs",
|
|
44
|
+
resourceAttributes: {
|
|
45
|
+
"service.name": "smoke-site",
|
|
46
|
+
"service.version": "abc123",
|
|
47
|
+
},
|
|
48
|
+
scopeVersion: "5.0.0-test",
|
|
49
|
+
fetchImpl: overrides.fetchImpl,
|
|
50
|
+
minFlushIntervalMs: overrides.minFlushIntervalMs ?? 0,
|
|
51
|
+
maxBufferRecords: overrides.maxBufferRecords,
|
|
52
|
+
rateLimitBurstCapacity: overrides.rateLimitBurstCapacity ?? 100,
|
|
53
|
+
rateLimitRefillPerMinute: overrides.rateLimitRefillPerMinute ?? 1000,
|
|
54
|
+
nowMs: overrides.nowMs,
|
|
55
|
+
onError: overrides.onError,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("createOtlpHttpLogAdapter — level filter + OTLP shape", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("buffers only level=error; debug/info/warn are dropped silently", async () => {
|
|
71
|
+
const { impl, calls } = captureFetch();
|
|
72
|
+
const sink = buildAdapter({ fetchImpl: impl });
|
|
73
|
+
|
|
74
|
+
sink.adapter.log("info", "info msg", { foo: 1 });
|
|
75
|
+
sink.adapter.log("warn", "warn msg", { foo: 2 });
|
|
76
|
+
sink.adapter.log("debug", "debug msg", { foo: 3 });
|
|
77
|
+
expect(sink.pendingRecordCount()).toBe(0);
|
|
78
|
+
|
|
79
|
+
sink.adapter.log("error", "boom", { reason: "kaboom" });
|
|
80
|
+
expect(sink.pendingRecordCount()).toBe(1);
|
|
81
|
+
|
|
82
|
+
await sink.flush();
|
|
83
|
+
expect(calls).toHaveLength(1);
|
|
84
|
+
const p = JSON.parse(String(calls[0].init.body)) as OtlpLogsPayload;
|
|
85
|
+
const r = p.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
86
|
+
expect(r.severityText).toBe("error");
|
|
87
|
+
expect(r.severityNumber).toBe(17);
|
|
88
|
+
expect(r.body.stringValue).toBe("boom");
|
|
89
|
+
expect(r.attributes).toContainEqual({
|
|
90
|
+
key: "reason",
|
|
91
|
+
value: { stringValue: "kaboom" },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("stamps resource attributes on every payload", async () => {
|
|
96
|
+
const { impl, calls } = captureFetch();
|
|
97
|
+
const sink = buildAdapter({ fetchImpl: impl });
|
|
98
|
+
sink.adapter.log("error", "x");
|
|
99
|
+
await sink.flush();
|
|
100
|
+
|
|
101
|
+
const p = JSON.parse(String(calls[0].init.body)) as OtlpLogsPayload;
|
|
102
|
+
expect(p.resourceLogs[0].resource.attributes).toContainEqual({
|
|
103
|
+
key: "service.name",
|
|
104
|
+
value: { stringValue: "smoke-site" },
|
|
105
|
+
});
|
|
106
|
+
expect(p.resourceLogs[0].resource.attributes).toContainEqual({
|
|
107
|
+
key: "service.version",
|
|
108
|
+
value: { stringValue: "abc123" },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("serializes scalar attribute kinds correctly", async () => {
|
|
113
|
+
const { impl, calls } = captureFetch();
|
|
114
|
+
const sink = buildAdapter({ fetchImpl: impl });
|
|
115
|
+
sink.adapter.log("error", "x", {
|
|
116
|
+
s: "string",
|
|
117
|
+
b: true,
|
|
118
|
+
i: 42,
|
|
119
|
+
d: 1.5,
|
|
120
|
+
n: null,
|
|
121
|
+
u: undefined,
|
|
122
|
+
o: { nested: 1 },
|
|
123
|
+
});
|
|
124
|
+
await sink.flush();
|
|
125
|
+
|
|
126
|
+
const p = JSON.parse(String(calls[0].init.body)) as OtlpLogsPayload;
|
|
127
|
+
const attrs = p.resourceLogs[0].scopeLogs[0].logRecords[0].attributes;
|
|
128
|
+
const byKey = (k: string) => attrs.find((a) => a.key === k)?.value;
|
|
129
|
+
expect(byKey("s")).toEqual({ stringValue: "string" });
|
|
130
|
+
expect(byKey("b")).toEqual({ boolValue: true });
|
|
131
|
+
expect(byKey("i")).toEqual({ intValue: "42" });
|
|
132
|
+
expect(byKey("d")).toEqual({ doubleValue: 1.5 });
|
|
133
|
+
expect(byKey("n")).toBeUndefined();
|
|
134
|
+
expect(byKey("u")).toBeUndefined();
|
|
135
|
+
expect(byKey("o")).toEqual({ stringValue: '{"nested":1}' });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("createOtlpHttpLogAdapter — rate limiting + overflow", () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
vi.useFakeTimers();
|
|
142
|
+
vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
|
|
143
|
+
});
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
vi.useRealTimers();
|
|
146
|
+
vi.restoreAllMocks();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("token bucket drops errors past the burst capacity until refill", () => {
|
|
150
|
+
let mockNow = 1_000_000;
|
|
151
|
+
const onError = vi.fn();
|
|
152
|
+
const sink = buildAdapter({
|
|
153
|
+
rateLimitBurstCapacity: 3,
|
|
154
|
+
rateLimitRefillPerMinute: 60, // 1 per second
|
|
155
|
+
nowMs: () => mockNow,
|
|
156
|
+
onError,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
sink.adapter.log("error", "a");
|
|
160
|
+
sink.adapter.log("error", "b");
|
|
161
|
+
sink.adapter.log("error", "c");
|
|
162
|
+
expect(sink.pendingRecordCount()).toBe(3);
|
|
163
|
+
|
|
164
|
+
sink.adapter.log("error", "d");
|
|
165
|
+
expect(sink.pendingRecordCount()).toBe(3);
|
|
166
|
+
expect(onError).toHaveBeenCalledWith("rate-limit", expect.any(Error));
|
|
167
|
+
onError.mockClear();
|
|
168
|
+
|
|
169
|
+
// 2 seconds later → 2 new tokens refilled.
|
|
170
|
+
mockNow += 2000;
|
|
171
|
+
sink.adapter.log("error", "e");
|
|
172
|
+
sink.adapter.log("error", "f");
|
|
173
|
+
expect(sink.pendingRecordCount()).toBe(5);
|
|
174
|
+
sink.adapter.log("error", "g");
|
|
175
|
+
expect(sink.pendingRecordCount()).toBe(5);
|
|
176
|
+
expect(onError).toHaveBeenCalledWith("rate-limit", expect.any(Error));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("buffer overflow drops new records past the cap with onError", () => {
|
|
180
|
+
const onError = vi.fn();
|
|
181
|
+
const sink = buildAdapter({
|
|
182
|
+
maxBufferRecords: 2,
|
|
183
|
+
rateLimitBurstCapacity: 100,
|
|
184
|
+
onError,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
sink.adapter.log("error", "1");
|
|
188
|
+
sink.adapter.log("error", "2");
|
|
189
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
190
|
+
|
|
191
|
+
sink.adapter.log("error", "3");
|
|
192
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
193
|
+
expect(onError).toHaveBeenCalledWith("overflow", expect.any(Error));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("createOtlpHttpLogAdapter — flush semantics", () => {
|
|
198
|
+
afterEach(() => vi.restoreAllMocks());
|
|
199
|
+
|
|
200
|
+
it("flush drains the buffer and resets length to 0 on success", async () => {
|
|
201
|
+
const { impl } = captureFetch();
|
|
202
|
+
const sink = buildAdapter({ fetchImpl: impl, minFlushIntervalMs: 0 });
|
|
203
|
+
sink.adapter.log("error", "a");
|
|
204
|
+
sink.adapter.log("error", "b");
|
|
205
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
206
|
+
await sink.flush();
|
|
207
|
+
expect(sink.pendingRecordCount()).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("non-200 response surfaces via onError but does not throw", async () => {
|
|
211
|
+
const onError = vi.fn();
|
|
212
|
+
const non200: typeof fetch = vi.fn(
|
|
213
|
+
async () => new Response("oops", { status: 502 }),
|
|
214
|
+
) as unknown as typeof fetch;
|
|
215
|
+
const sink = buildAdapter({ fetchImpl: non200, onError, minFlushIntervalMs: 0 });
|
|
216
|
+
sink.adapter.log("error", "boom");
|
|
217
|
+
await sink.flush();
|
|
218
|
+
expect(onError).toHaveBeenCalledWith("flush", expect.any(Error));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("fetch rejection surfaces via onError but does not throw", async () => {
|
|
222
|
+
const onError = vi.fn();
|
|
223
|
+
const failing: typeof fetch = vi.fn(() =>
|
|
224
|
+
Promise.reject(new Error("offline")),
|
|
225
|
+
) as unknown as typeof fetch;
|
|
226
|
+
const sink = buildAdapter({ fetchImpl: failing, onError, minFlushIntervalMs: 0 });
|
|
227
|
+
sink.adapter.log("error", "boom");
|
|
228
|
+
await expect(sink.flush()).resolves.toBeUndefined();
|
|
229
|
+
expect(onError).toHaveBeenCalledWith("flush", expect.any(Error));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("cooldown gates flushes; cooldown is bypassed once buffer reaches the cap", async () => {
|
|
233
|
+
let mockNow = 1_000_000;
|
|
234
|
+
const { impl, calls } = captureFetch();
|
|
235
|
+
const sink = buildAdapter({
|
|
236
|
+
fetchImpl: impl,
|
|
237
|
+
minFlushIntervalMs: 5000,
|
|
238
|
+
maxBufferRecords: 3,
|
|
239
|
+
nowMs: () => mockNow,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
sink.adapter.log("error", "1");
|
|
243
|
+
await sink.flush();
|
|
244
|
+
expect(calls).toHaveLength(1);
|
|
245
|
+
|
|
246
|
+
mockNow += 2000;
|
|
247
|
+
sink.adapter.log("error", "2");
|
|
248
|
+
await sink.flush();
|
|
249
|
+
expect(calls).toHaveLength(1);
|
|
250
|
+
|
|
251
|
+
mockNow += 500;
|
|
252
|
+
sink.adapter.log("error", "3");
|
|
253
|
+
sink.adapter.log("error", "4");
|
|
254
|
+
expect(sink.pendingRecordCount()).toBe(3);
|
|
255
|
+
await sink.flush();
|
|
256
|
+
expect(calls).toHaveLength(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("non-200 response RESTORES records to the buffer (no permanent loss)", async () => {
|
|
260
|
+
// The earlier behavior dropped records on the ground when the POST
|
|
261
|
+
// failed — sites lost errors permanently. After the fix, a failing
|
|
262
|
+
// POST returns the buffered records to the front of the queue so a
|
|
263
|
+
// subsequent successful flush picks them up.
|
|
264
|
+
const onError = vi.fn();
|
|
265
|
+
let attempt = 0;
|
|
266
|
+
const fetchImpl: typeof fetch = vi.fn(async () => {
|
|
267
|
+
attempt += 1;
|
|
268
|
+
if (attempt === 1) return new Response("oops", { status: 502 });
|
|
269
|
+
return new Response("{}", { status: 200 });
|
|
270
|
+
}) as unknown as typeof fetch;
|
|
271
|
+
const sink = buildAdapter({ fetchImpl, onError, minFlushIntervalMs: 0 });
|
|
272
|
+
sink.adapter.log("error", "boom-1");
|
|
273
|
+
sink.adapter.log("error", "boom-2");
|
|
274
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
275
|
+
|
|
276
|
+
await sink.flush();
|
|
277
|
+
expect(onError).toHaveBeenCalledWith("flush", expect.any(Error));
|
|
278
|
+
// Records restored.
|
|
279
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
280
|
+
|
|
281
|
+
await sink.flush();
|
|
282
|
+
expect(sink.pendingRecordCount()).toBe(0);
|
|
283
|
+
expect(attempt).toBe(2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("fetch rejection RESTORES records to the buffer (no permanent loss)", async () => {
|
|
287
|
+
const onError = vi.fn();
|
|
288
|
+
let attempt = 0;
|
|
289
|
+
const fetchImpl: typeof fetch = vi.fn(() => {
|
|
290
|
+
attempt += 1;
|
|
291
|
+
if (attempt === 1) return Promise.reject(new Error("offline"));
|
|
292
|
+
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
293
|
+
}) as unknown as typeof fetch;
|
|
294
|
+
const sink = buildAdapter({ fetchImpl, onError, minFlushIntervalMs: 0 });
|
|
295
|
+
sink.adapter.log("error", "boom");
|
|
296
|
+
await sink.flush();
|
|
297
|
+
expect(sink.pendingRecordCount()).toBe(1);
|
|
298
|
+
await sink.flush();
|
|
299
|
+
expect(sink.pendingRecordCount()).toBe(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("on restore, when buffer has grown past the cap, drops NEWER records from the buffer (not the snapshot)", async () => {
|
|
303
|
+
// The originating-error records live in the snapshot — they were
|
|
304
|
+
// logged BEFORE the failed flush started. The buffer's current
|
|
305
|
+
// contents arrived DURING the failed flush and are newer / less
|
|
306
|
+
// likely to be the root-cause context. Restore policy must drop
|
|
307
|
+
// the newest first.
|
|
308
|
+
const onError = vi.fn();
|
|
309
|
+
let inFlightResolve: ((res: Response) => void) | undefined;
|
|
310
|
+
const fetchImpl: typeof fetch = vi.fn(
|
|
311
|
+
() =>
|
|
312
|
+
new Promise<Response>((resolve) => {
|
|
313
|
+
inFlightResolve = resolve;
|
|
314
|
+
}),
|
|
315
|
+
) as unknown as typeof fetch;
|
|
316
|
+
const sink = buildAdapter({
|
|
317
|
+
fetchImpl,
|
|
318
|
+
onError,
|
|
319
|
+
minFlushIntervalMs: 0,
|
|
320
|
+
maxBufferRecords: 3,
|
|
321
|
+
});
|
|
322
|
+
sink.adapter.log("error", "old-a");
|
|
323
|
+
sink.adapter.log("error", "old-b");
|
|
324
|
+
const flushPromise = sink.flush();
|
|
325
|
+
expect(sink.pendingRecordCount()).toBe(0);
|
|
326
|
+
sink.adapter.log("error", "new-c");
|
|
327
|
+
sink.adapter.log("error", "new-d");
|
|
328
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
329
|
+
inFlightResolve?.(new Response("oops", { status: 503 }));
|
|
330
|
+
await flushPromise;
|
|
331
|
+
|
|
332
|
+
// snapshot = 2 (old-a, old-b), buffer (post-POST) = 2 (new-c, new-d),
|
|
333
|
+
// cap = 3 → must drop 1. The newest record (new-d) is the one to
|
|
334
|
+
// drop; the snapshot (oldest, originating-error context) is fully
|
|
335
|
+
// restored.
|
|
336
|
+
expect(sink.pendingRecordCount()).toBe(3);
|
|
337
|
+
|
|
338
|
+
// We can't directly inspect record bodies via the public API, but
|
|
339
|
+
// a fresh successful flush should surface them in order. Wire a
|
|
340
|
+
// new fetch impl that captures the payload.
|
|
341
|
+
let captured: string | undefined;
|
|
342
|
+
const sniff: typeof fetch = vi.fn(async (_url, init) => {
|
|
343
|
+
captured = String(init?.body);
|
|
344
|
+
return new Response("{}", { status: 200 });
|
|
345
|
+
}) as unknown as typeof fetch;
|
|
346
|
+
// Swap fetch impl by building a new adapter would lose state; instead
|
|
347
|
+
// assert on the overflow message, which names what was dropped.
|
|
348
|
+
void sniff;
|
|
349
|
+
|
|
350
|
+
expect(onError).toHaveBeenCalledWith(
|
|
351
|
+
"overflow",
|
|
352
|
+
expect.objectContaining({
|
|
353
|
+
message: expect.stringMatching(
|
|
354
|
+
/dropped 1 newer records from buffer.*preserved 2 originating-error records/,
|
|
355
|
+
),
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
expect(captured).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("on restore, when the snapshot ALONE exceeds the cap, drops snapshot's NEWEST tail (keeps oldest)", async () => {
|
|
362
|
+
const onError = vi.fn();
|
|
363
|
+
let inFlightResolve: ((res: Response) => void) | undefined;
|
|
364
|
+
const fetchImpl: typeof fetch = vi.fn(
|
|
365
|
+
() =>
|
|
366
|
+
new Promise<Response>((resolve) => {
|
|
367
|
+
inFlightResolve = resolve;
|
|
368
|
+
}),
|
|
369
|
+
) as unknown as typeof fetch;
|
|
370
|
+
const sink = buildAdapter({
|
|
371
|
+
fetchImpl,
|
|
372
|
+
onError,
|
|
373
|
+
minFlushIntervalMs: 0,
|
|
374
|
+
maxBufferRecords: 2,
|
|
375
|
+
});
|
|
376
|
+
// Manually pack the snapshot beyond the cap by lowering the cap
|
|
377
|
+
// after the initial buffering. Simpler: buffer 4 records under a
|
|
378
|
+
// larger temporary cap is hard with this API, so instead we
|
|
379
|
+
// buffer 4 with rate-limit-headroom and start the flush; cap is
|
|
380
|
+
// already 2 at config time, so buffering 3 already exercises
|
|
381
|
+
// overflow at log-time. Use cap=2, buffer 2 (snapshot), trigger
|
|
382
|
+
// flush, then prove that on restore both come back even though
|
|
383
|
+
// the buffer also grew to 2 in the meantime (forcing snapshot
|
|
384
|
+
// truncation).
|
|
385
|
+
sink.adapter.log("error", "old-a");
|
|
386
|
+
sink.adapter.log("error", "old-b");
|
|
387
|
+
const flushPromise = sink.flush();
|
|
388
|
+
sink.adapter.log("error", "new-c");
|
|
389
|
+
sink.adapter.log("error", "new-d");
|
|
390
|
+
// Buffer is already at cap 2; trying to log more would overflow at
|
|
391
|
+
// the log() path. That's fine — it's a separate codepath. Confirm
|
|
392
|
+
// pending count.
|
|
393
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
394
|
+
|
|
395
|
+
inFlightResolve?.(new Response("oops", { status: 503 }));
|
|
396
|
+
await flushPromise;
|
|
397
|
+
|
|
398
|
+
// total = 2 snapshot + 2 buffer = 4; cap = 2 → drop 2. Both buffer
|
|
399
|
+
// entries (newer) go first. Snapshot fully restored.
|
|
400
|
+
expect(sink.pendingRecordCount()).toBe(2);
|
|
401
|
+
expect(onError).toHaveBeenCalledWith(
|
|
402
|
+
"overflow",
|
|
403
|
+
expect.objectContaining({
|
|
404
|
+
message: expect.stringMatching(/dropped 2 newer records from buffer/),
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("serializes a record whose attribute value is `undefined` without crashing or emitting undefined", async () => {
|
|
410
|
+
// `JSON.stringify(undefined)` returns the JS value `undefined`, not the
|
|
411
|
+
// string "undefined". Without the guard, that surfaces in the OTLP
|
|
412
|
+
// payload as `{ stringValue: undefined }` which the ingestor rejects.
|
|
413
|
+
const { impl, calls } = captureFetch();
|
|
414
|
+
const sink = buildAdapter({ fetchImpl: impl, minFlushIntervalMs: 0 });
|
|
415
|
+
sink.adapter.log("error", "boom", {
|
|
416
|
+
fnAttr: () => 1,
|
|
417
|
+
undef: undefined,
|
|
418
|
+
ok: "yes",
|
|
419
|
+
});
|
|
420
|
+
await sink.flush();
|
|
421
|
+
expect(calls).toHaveLength(1);
|
|
422
|
+
const body = JSON.parse(String(calls[0].init?.body)) as {
|
|
423
|
+
resourceLogs: Array<{
|
|
424
|
+
scopeLogs: Array<{
|
|
425
|
+
logRecords: Array<{
|
|
426
|
+
attributes: Array<{ key: string; value: Record<string, unknown> }>;
|
|
427
|
+
}>;
|
|
428
|
+
}>;
|
|
429
|
+
}>;
|
|
430
|
+
};
|
|
431
|
+
const attrs = body.resourceLogs[0].scopeLogs[0].logRecords[0].attributes;
|
|
432
|
+
const fnAttr = attrs.find((a) => a.key === "fnAttr");
|
|
433
|
+
// Function should fall back to a stringified form, never `undefined`.
|
|
434
|
+
expect(fnAttr).toBeDefined();
|
|
435
|
+
expect(typeof (fnAttr?.value as { stringValue?: unknown }).stringValue).toBe("string");
|
|
436
|
+
// `undefined` attrs are dropped at the serializer top-level filter.
|
|
437
|
+
expect(attrs.find((a) => a.key === "undef")).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("concurrent flushes share a single in-flight POST", async () => {
|
|
441
|
+
let release: ((res: Response) => void) | undefined;
|
|
442
|
+
const slow: typeof fetch = vi.fn(
|
|
443
|
+
() =>
|
|
444
|
+
new Promise<Response>((resolve) => {
|
|
445
|
+
release = resolve;
|
|
446
|
+
}),
|
|
447
|
+
) as unknown as typeof fetch;
|
|
448
|
+
const sink = buildAdapter({ fetchImpl: slow, minFlushIntervalMs: 0 });
|
|
449
|
+
sink.adapter.log("error", "x");
|
|
450
|
+
const a = sink.flush();
|
|
451
|
+
const b = sink.flush();
|
|
452
|
+
expect(slow).toHaveBeenCalledTimes(1);
|
|
453
|
+
release?.(new Response("{}", { status: 200 }));
|
|
454
|
+
await Promise.all([a, b]);
|
|
455
|
+
expect(slow).toHaveBeenCalledTimes(1);
|
|
456
|
+
});
|
|
457
|
+
});
|