@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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Typed invoke proxies for client-side RPC to deco loaders/actions.
3
+ *
4
+ * Two flavors:
5
+ *
6
+ * 1. `createInvokeProxy<T>()` — flat keys (e.g. `invoke["vtex/loaders/productList.ts"](props)`)
7
+ * 2. `createAppInvoke<T>()` — nested keys (e.g. `invoke.vtex.actions.checkout.addItemsToCart(props)`)
8
+ *
9
+ * Both POST to `/deco/invoke/{key}` under the hood.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // Flat proxy (legacy):
14
+ * const invoke = createInvokeProxy<Loaders>();
15
+ * await invoke["vtex/loaders/productList.ts"]({ query: "shoes" });
16
+ *
17
+ * // Nested proxy (recommended):
18
+ * const invoke = createAppInvoke();
19
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
20
+ * ```
21
+ */
22
+
23
+ export type InvokeProxy<TLoaders extends Record<string, (props: any) => Promise<any>>> = {
24
+ [K in keyof TLoaders]: TLoaders[K] extends (props: infer P) => Promise<infer R>
25
+ ? (props: P) => Promise<R>
26
+ : never;
27
+ };
28
+
29
+ /**
30
+ * Creates a proxy that turns loader key access into fetch calls to `/deco/invoke/:key`.
31
+ */
32
+ export function createInvokeProxy<TLoaders extends Record<string, (props: any) => Promise<any>>>(
33
+ basePath = "/deco/invoke",
34
+ ): InvokeProxy<TLoaders> {
35
+ return new Proxy({} as InvokeProxy<TLoaders>, {
36
+ get(_target, prop: string) {
37
+ return async (props: unknown) => {
38
+ const url = `${basePath}/${encodeURIComponent(prop)}`;
39
+ const response = await fetch(url, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify(props ?? {}),
43
+ });
44
+
45
+ if (!response.ok) {
46
+ const error = await response.json().catch(() => ({ error: response.statusText }));
47
+ throw new Error(
48
+ `Invoke ${prop} failed (${response.status}): ${(error as any).error || response.statusText}`,
49
+ );
50
+ }
51
+
52
+ return response.json();
53
+ };
54
+ },
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Batch invoke multiple loaders in a single request.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const results = await batchInvoke("/deco/invoke", {
64
+ * products: { __resolveType: "vtex/loaders/productList.ts", query: "shoes" },
65
+ * details: { __resolveType: "vtex/loaders/productDetailsPage.ts", slug: "shoe-1" },
66
+ * });
67
+ * ```
68
+ */
69
+ export async function batchInvoke<T extends Record<string, unknown>>(
70
+ basePath: string,
71
+ payloads: T,
72
+ ): Promise<{ [K in keyof T]: unknown }> {
73
+ const response = await fetch(basePath, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify(payloads),
77
+ });
78
+
79
+ if (!response.ok) {
80
+ throw new Error(`Batch invoke failed (${response.status})`);
81
+ }
82
+
83
+ return response.json();
84
+ }
85
+
86
+ /**
87
+ * Helper to create TanStack Query `queryOptions` for an invoke call.
88
+ * The storefront must have `@tanstack/react-query` installed.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { useQuery } from "@tanstack/react-query";
93
+ * import { invokeQueryOptions } from "@decocms/start/sdk/invoke";
94
+ *
95
+ * const options = invokeQueryOptions(
96
+ * "vtex/loaders/productList.ts",
97
+ * { query: "shoes" },
98
+ * { staleTime: 60_000 }
99
+ * );
100
+ *
101
+ * // In a component:
102
+ * const { data } = useQuery(options);
103
+ *
104
+ * // In a route loader:
105
+ * queryClient.ensureQueryData(options);
106
+ * ```
107
+ */
108
+ export function invokeQueryOptions<TResult = unknown>(
109
+ key: string,
110
+ props: unknown,
111
+ options?: { staleTime?: number; gcTime?: number; basePath?: string },
112
+ ) {
113
+ const basePath = options?.basePath ?? "/deco/invoke";
114
+
115
+ return {
116
+ queryKey: ["deco-invoke", key, props] as const,
117
+ queryFn: async (): Promise<TResult> => {
118
+ const url = `${basePath}/${encodeURIComponent(key)}`;
119
+ const response = await fetch(url, {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify(props ?? {}),
123
+ });
124
+
125
+ if (!response.ok) {
126
+ throw new Error(`Invoke ${key} failed (${response.status})`);
127
+ }
128
+
129
+ return response.json();
130
+ },
131
+ staleTime: options?.staleTime,
132
+ gcTime: options?.gcTime,
133
+ };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Nested invoke proxy — createAppInvoke
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Converts flat slash-separated keys into a nested object type.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * type Map = {
146
+ * "vtex/actions/checkout/addItemsToCart": (props: CartInput) => Promise<OrderForm>;
147
+ * "vtex/loaders/catalog/getProduct": (props: { slug: string }) => Promise<Product>;
148
+ * };
149
+ * type Nested = NestedFromFlat<Map>;
150
+ * // { vtex: { actions: { checkout: { addItemsToCart: (props: CartInput) => Promise<OrderForm> } } } }
151
+ * ```
152
+ */
153
+ type SplitFirst<S extends string> = S extends `${infer Head}/${infer Tail}`
154
+ ? [Head, Tail]
155
+ : [S, never];
156
+
157
+ type BuildNested<Key extends string, Value> =
158
+ SplitFirst<Key> extends [infer H extends string, infer T]
159
+ ? T extends string
160
+ ? { [K in H]: BuildNested<T, Value> }
161
+ : { [K in H]: Value }
162
+ : never;
163
+
164
+ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
165
+ ? I
166
+ : never;
167
+
168
+ type DeepMerge<T> = T extends object ? { [K in keyof T]: DeepMerge<T[K]> } : T;
169
+
170
+ export type NestedFromFlat<T extends Record<string, any>> = DeepMerge<
171
+ UnionToIntersection<{ [K in keyof T & string]: BuildNested<K, T[K]> }[keyof T & string]>
172
+ >;
173
+
174
+ /**
175
+ * Creates a typed nested invoke proxy.
176
+ *
177
+ * Each property access accumulates path segments. When called as a function,
178
+ * the segments are joined with "/" and POSTed to `/deco/invoke/{key}`.
179
+ * If the primary key returns 404, a `.ts` suffix variant is tried.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * import { createAppInvoke } from "@decocms/start/sdk/invoke";
184
+ *
185
+ * // Untyped (any):
186
+ * const invoke = createAppInvoke();
187
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
188
+ *
189
+ * // Typed (with handler map):
190
+ * type Handlers = {
191
+ * "vtex/actions/checkout/addItemsToCart": (props: CartInput) => Promise<OrderForm>;
192
+ * };
193
+ * const invoke = createAppInvoke<Handlers>();
194
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
195
+ * ```
196
+ */
197
+ export function createAppInvoke(basePath?: string): any;
198
+ export function createAppInvoke<T extends Record<string, any>>(
199
+ basePath?: string,
200
+ ): NestedFromFlat<T>;
201
+ export function createAppInvoke(basePath = "/deco/invoke"): any {
202
+ function buildProxy(path: string[]): any {
203
+ return new Proxy(
204
+ Object.assign(async (props: any) => {
205
+ const key = path.join("/");
206
+ for (const k of [key, `${key}.ts`]) {
207
+ const response = await fetch(`${basePath}/${k}`, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify(props ?? {}),
211
+ });
212
+ if (response.status === 404) {
213
+ await response.body?.cancel();
214
+ continue;
215
+ }
216
+ if (!response.ok) {
217
+ const error = await response.json().catch(() => ({ error: response.statusText }));
218
+ throw new Error(
219
+ `invoke(${k}) failed (${response.status}): ${(error as any).error || response.statusText}`,
220
+ );
221
+ }
222
+ return response.json();
223
+ }
224
+ throw new Error(`invoke(${key}) failed: handler not found`);
225
+ }, {}),
226
+ {
227
+ get(_target: any, prop: string | symbol) {
228
+ if (typeof prop === "symbol") return undefined;
229
+ if (prop === "then" || prop === "catch" || prop === "finally") {
230
+ return undefined;
231
+ }
232
+ return buildProxy([...path, prop]);
233
+ },
234
+ },
235
+ );
236
+ }
237
+
238
+ return buildProxy([]);
239
+ }
240
+
241
+ /**
242
+ * Default nested invoke proxy bound to `/deco/invoke`.
243
+ *
244
+ * Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
245
+ * call. Importing this singleton means no per-site boilerplate.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { invoke } from "@decocms/start/sdk/invoke";
250
+ *
251
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
252
+ * await invoke.site.loaders.Wishlist.getWishlist({});
253
+ * ```
254
+ *
255
+ * For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
256
+ * ```ts
257
+ * const invoke = createAppInvoke<Handlers>("/my/invoke");
258
+ * ```
259
+ */
260
+ export const invoke = createAppInvoke();
@@ -0,0 +1,432 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ _getLoggerAttributeFloorForTests,
4
+ configureLogger,
5
+ defaultLoggerAdapter,
6
+ getLoggerAdapter,
7
+ getLogLevel,
8
+ type LoggerAdapter,
9
+ logger,
10
+ serializeError,
11
+ setLoggerAttributeFloor,
12
+ setLogLevel,
13
+ } from "./logger";
14
+
15
+ describe("defaultLoggerAdapter", () => {
16
+ let logSpy: ReturnType<typeof vi.spyOn>;
17
+ let warnSpy: ReturnType<typeof vi.spyOn>;
18
+ let errorSpy: ReturnType<typeof vi.spyOn>;
19
+ let debugSpy: ReturnType<typeof vi.spyOn>;
20
+
21
+ beforeEach(() => {
22
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
23
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
24
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
25
+ debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ configureLogger(defaultLoggerAdapter);
31
+ setLogLevel("debug"); // permissive default for the rest of the suite
32
+ });
33
+
34
+ it("emits one JSON line per call", () => {
35
+ defaultLoggerAdapter.log("info", "hello", { foo: 1 });
36
+ expect(logSpy).toHaveBeenCalledOnce();
37
+ const arg = logSpy.mock.calls[0]?.[0] as string;
38
+ const parsed = JSON.parse(arg);
39
+ expect(parsed).toMatchObject({ level: "info", msg: "hello", foo: 1 });
40
+ expect(typeof parsed.timestamp).toBe("string");
41
+ });
42
+
43
+ it("routes by level so CF Logs colorises correctly", () => {
44
+ defaultLoggerAdapter.log("error", "boom");
45
+ defaultLoggerAdapter.log("warn", "careful");
46
+ defaultLoggerAdapter.log("debug", "details");
47
+ expect(errorSpy).toHaveBeenCalledOnce();
48
+ expect(warnSpy).toHaveBeenCalledOnce();
49
+ expect(debugSpy).toHaveBeenCalledOnce();
50
+ });
51
+
52
+ it("never throws on circular refs (last-resort fallback)", () => {
53
+ const circ: any = { name: "x" };
54
+ circ.self = circ;
55
+ expect(() => defaultLoggerAdapter.log("info", "circ", { circ })).not.toThrow();
56
+ expect(logSpy).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("does not let attrs override level/msg/timestamp keys", () => {
60
+ defaultLoggerAdapter.log("info", "real-msg", {
61
+ level: "tampered",
62
+ msg: "tampered",
63
+ timestamp: "tampered",
64
+ });
65
+ const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string);
66
+ expect(parsed.level).toBe("info");
67
+ expect(parsed.msg).toBe("real-msg");
68
+ expect(parsed.timestamp).not.toBe("tampered");
69
+ });
70
+ });
71
+
72
+ describe("level filtering", () => {
73
+ let logSpy: ReturnType<typeof vi.spyOn>;
74
+ let warnSpy: ReturnType<typeof vi.spyOn>;
75
+ let errorSpy: ReturnType<typeof vi.spyOn>;
76
+ let debugSpy: ReturnType<typeof vi.spyOn>;
77
+
78
+ beforeEach(() => {
79
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
80
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
81
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
82
+ debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
83
+ });
84
+
85
+ afterEach(() => {
86
+ vi.restoreAllMocks();
87
+ configureLogger(defaultLoggerAdapter);
88
+ setLogLevel("info");
89
+ });
90
+
91
+ it("drops calls below the active min level", () => {
92
+ setLogLevel("warn");
93
+ expect(getLogLevel()).toBe("warn");
94
+ logger.debug("d");
95
+ logger.info("i");
96
+ logger.warn("w");
97
+ logger.error("e");
98
+ expect(debugSpy).not.toHaveBeenCalled();
99
+ expect(logSpy).not.toHaveBeenCalled(); // info routes to console.log
100
+ expect(warnSpy).toHaveBeenCalledOnce();
101
+ expect(errorSpy).toHaveBeenCalledOnce();
102
+ });
103
+ });
104
+
105
+ describe("configureLogger", () => {
106
+ afterEach(() => {
107
+ configureLogger(defaultLoggerAdapter);
108
+ setLogLevel("info");
109
+ });
110
+
111
+ it("dispatches to the configured adapter", () => {
112
+ const calls: Array<[string, string, unknown]> = [];
113
+ const test: LoggerAdapter = {
114
+ log(level, msg, attrs) {
115
+ calls.push([level, msg, attrs]);
116
+ },
117
+ };
118
+ configureLogger(test);
119
+ expect(getLoggerAdapter()).toBe(test);
120
+
121
+ logger.info("hello", { x: 1 });
122
+ expect(calls).toEqual([["info", "hello", { x: 1 }]]);
123
+ });
124
+
125
+ it("falls back to default adapter if active adapter throws", () => {
126
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
127
+ const broken: LoggerAdapter = {
128
+ log() {
129
+ throw new Error("nope");
130
+ },
131
+ };
132
+ configureLogger(broken);
133
+ expect(() => logger.info("survives", { x: 1 })).not.toThrow();
134
+ // Default adapter was invoked as the fallback
135
+ expect(logSpy).toHaveBeenCalledOnce();
136
+ logSpy.mockRestore();
137
+ });
138
+ });
139
+
140
+ describe("setLoggerAttributeFloor", () => {
141
+ afterEach(() => {
142
+ setLoggerAttributeFloor({});
143
+ configureLogger(defaultLoggerAdapter);
144
+ setLogLevel("info");
145
+ });
146
+
147
+ it("starts empty so the floor is a no-op out of the box", () => {
148
+ expect(_getLoggerAttributeFloorForTests()).toEqual({});
149
+ });
150
+
151
+ it("merges floor attrs into every log call", () => {
152
+ setLoggerAttributeFloor({
153
+ "deco.runtime.version": "4.4.0",
154
+ "deployment.environment": "production",
155
+ });
156
+ const calls: Array<Record<string, unknown> | undefined> = [];
157
+ configureLogger({
158
+ log(_level, _msg, attrs) {
159
+ calls.push(attrs);
160
+ },
161
+ });
162
+
163
+ logger.info("ping", { route: "/" });
164
+
165
+ expect(calls).toHaveLength(1);
166
+ expect(calls[0]).toEqual({
167
+ "deco.runtime.version": "4.4.0",
168
+ "deployment.environment": "production",
169
+ route: "/",
170
+ });
171
+ });
172
+
173
+ it("lets caller attrs override the floor on key collision", () => {
174
+ setLoggerAttributeFloor({ "deployment.environment": "production" });
175
+ const calls: Array<Record<string, unknown> | undefined> = [];
176
+ configureLogger({
177
+ log(_level, _msg, attrs) {
178
+ calls.push(attrs);
179
+ },
180
+ });
181
+
182
+ logger.warn("override", { "deployment.environment": "staging" });
183
+
184
+ expect(calls[0]?.["deployment.environment"]).toBe("staging");
185
+ });
186
+
187
+ it("applies the floor even when the caller passes no attrs", () => {
188
+ setLoggerAttributeFloor({ tenant: "lebiscuit" });
189
+ const calls: Array<Record<string, unknown> | undefined> = [];
190
+ configureLogger({
191
+ log(_level, _msg, attrs) {
192
+ calls.push(attrs);
193
+ },
194
+ });
195
+
196
+ logger.info("no-attrs");
197
+
198
+ expect(calls[0]).toEqual({ tenant: "lebiscuit" });
199
+ });
200
+
201
+ it("clearing the floor restores the no-op fast path", () => {
202
+ setLoggerAttributeFloor({ tenant: "lebiscuit" });
203
+ setLoggerAttributeFloor({});
204
+ const calls: Array<Record<string, unknown> | undefined> = [];
205
+ configureLogger({
206
+ log(_level, _msg, attrs) {
207
+ calls.push(attrs);
208
+ },
209
+ });
210
+
211
+ logger.info("clean", { x: 1 });
212
+
213
+ expect(calls[0]).toEqual({ x: 1 });
214
+ });
215
+ });
216
+
217
+ describe("serializeError", () => {
218
+ it("flattens an Error into a JSON-safe shape with stack", () => {
219
+ const err = new Error("boom");
220
+ const out = serializeError(err);
221
+ expect(out).toEqual({ name: "Error", message: "boom", stack: err.stack });
222
+ });
223
+
224
+ it("preserves subclass name (TypeError, RangeError, custom)", () => {
225
+ expect(serializeError(new TypeError("bad type")).name).toBe("TypeError");
226
+ class MyErr extends Error {
227
+ override name = "MyErr";
228
+ }
229
+ expect(serializeError(new MyErr("custom")).name).toBe("MyErr");
230
+ });
231
+
232
+ it("survives JSON.stringify round-trip", () => {
233
+ const err = new Error("round-trip");
234
+ const out = serializeError(err);
235
+ expect(() => JSON.parse(JSON.stringify(out))).not.toThrow();
236
+ expect(JSON.parse(JSON.stringify(out)).message).toBe("round-trip");
237
+ });
238
+
239
+ it("captures plain objects as JSON in message, marking them as NonError", () => {
240
+ const out = serializeError({ code: 500, body: "vtex down" });
241
+ expect(out.name).toBe("NonError");
242
+ expect(out.message).toBe('{"code":500,"body":"vtex down"}');
243
+ expect(out.stack).toBeUndefined();
244
+ });
245
+
246
+ it("falls back to String() when an object has circular refs", () => {
247
+ const circ: Record<string, unknown> = { a: 1 };
248
+ circ.self = circ;
249
+ const out = serializeError(circ);
250
+ expect(out.name).toBe("NonError");
251
+ expect(typeof out.message).toBe("string");
252
+ expect(out.stack).toBeUndefined();
253
+ });
254
+
255
+ it("handles primitives (string, number, null, undefined)", () => {
256
+ expect(serializeError("just a string")).toEqual({
257
+ name: "NonError",
258
+ message: "just a string",
259
+ });
260
+ expect(serializeError(42)).toEqual({ name: "NonError", message: "42" });
261
+ expect(serializeError(null)).toEqual({ name: "NonError", message: "null" });
262
+ expect(serializeError(undefined)).toEqual({
263
+ name: "NonError",
264
+ message: "undefined",
265
+ });
266
+ });
267
+ });
268
+
269
+ describe("request.id stamping (Phase 1, D-9)", () => {
270
+ afterEach(() => {
271
+ configureLogger(defaultLoggerAdapter);
272
+ setLogLevel("info");
273
+ });
274
+
275
+ it("stamps request.id from RequestContext on every log line emitted inside a request scope", async () => {
276
+ const { RequestContext } = await import("./requestContext");
277
+
278
+ const seen: Array<Record<string, unknown> | undefined> = [];
279
+ configureLogger({
280
+ log(_l, _m, attrs) {
281
+ seen.push(attrs);
282
+ },
283
+ });
284
+
285
+ const reqWithId = new Request("https://example.com/", {
286
+ headers: { "x-request-id": "client-supplied-uuid" },
287
+ });
288
+ await RequestContext.run(reqWithId, async () => {
289
+ logger.info("inside-scope", { custom: "yes" });
290
+ });
291
+ // Outside the scope, no request.id is stamped — the fast path stays
292
+ // fast (no allocation, no key) when RequestContext.requestId is null.
293
+ logger.info("outside-scope", { custom: "no" });
294
+
295
+ expect(seen[0]).toMatchObject({
296
+ "request.id": "client-supplied-uuid",
297
+ custom: "yes",
298
+ });
299
+ expect(seen[1]).toEqual({ custom: "no" });
300
+ expect(seen[1]).not.toHaveProperty("request.id");
301
+ });
302
+
303
+ it("prefers caller-supplied request.id over the auto-generated one", async () => {
304
+ const { RequestContext } = await import("./requestContext");
305
+
306
+ const seen: Array<Record<string, unknown> | undefined> = [];
307
+ configureLogger({
308
+ log(_l, _m, attrs) {
309
+ seen.push(attrs);
310
+ },
311
+ });
312
+
313
+ const req = new Request("https://example.com/", {
314
+ headers: { "x-request-id": "from-headers" },
315
+ });
316
+
317
+ await RequestContext.run(req, async () => {
318
+ // Caller can still override by passing the key directly in attrs.
319
+ logger.info("override", { "request.id": "explicit-from-caller" });
320
+ });
321
+
322
+ expect(seen[0]?.["request.id"]).toBe("explicit-from-caller");
323
+ });
324
+
325
+ it("falls back to cf-ray when x-request-id is absent", async () => {
326
+ const { RequestContext } = await import("./requestContext");
327
+
328
+ const seen: Array<Record<string, unknown> | undefined> = [];
329
+ configureLogger({
330
+ log(_l, _m, attrs) {
331
+ seen.push(attrs);
332
+ },
333
+ });
334
+
335
+ const req = new Request("https://example.com/", {
336
+ headers: { "cf-ray": "8a1b2c3d4e5f6a7b" },
337
+ });
338
+
339
+ await RequestContext.run(req, async () => {
340
+ logger.info("cf-ray-stamped");
341
+ });
342
+
343
+ expect(seen[0]?.["request.id"]).toBe("8a1b2c3d4e5f6a7b");
344
+ });
345
+
346
+ it("generates a fresh UUID when neither header is set", async () => {
347
+ const { RequestContext } = await import("./requestContext");
348
+
349
+ const seen: Array<Record<string, unknown> | undefined> = [];
350
+ configureLogger({
351
+ log(_l, _m, attrs) {
352
+ seen.push(attrs);
353
+ },
354
+ });
355
+
356
+ const req = new Request("https://example.com/");
357
+
358
+ await RequestContext.run(req, async () => {
359
+ logger.info("uuid-stamped");
360
+ });
361
+
362
+ const stamped = seen[0]?.["request.id"];
363
+ expect(typeof stamped).toBe("string");
364
+ expect((stamped as string).length).toBeGreaterThan(8);
365
+ });
366
+ });
367
+
368
+ describe("trace correlation", () => {
369
+ afterEach(() => {
370
+ configureLogger(defaultLoggerAdapter);
371
+ setLogLevel("info");
372
+ });
373
+
374
+ it("includes trace_id/span_id when emitted inside withTracing", async () => {
375
+ const { configureTracer, setObservabilitySpanStore, withTracing } = await import(
376
+ "./observability"
377
+ );
378
+
379
+ let current: unknown = null;
380
+ setObservabilitySpanStore({
381
+ get: () => current as never,
382
+ run<R>(value: never, fn: () => R): R {
383
+ const prev = current;
384
+ current = value;
385
+ try {
386
+ const result = fn();
387
+ if (result instanceof Promise) {
388
+ return result.finally(() => {
389
+ current = prev;
390
+ }) as unknown as R;
391
+ }
392
+ current = prev;
393
+ return result;
394
+ } catch (err) {
395
+ current = prev;
396
+ throw err;
397
+ }
398
+ },
399
+ });
400
+ configureTracer({
401
+ startSpan: () => ({
402
+ end: () => {},
403
+ spanContext: () => ({
404
+ traceId: "deadbeefdeadbeefdeadbeefdeadbeef",
405
+ spanId: "1234567890abcdef",
406
+ traceFlags: 1,
407
+ }),
408
+ }),
409
+ });
410
+
411
+ const seen: Array<Record<string, unknown> | undefined> = [];
412
+ configureLogger({
413
+ log(_l, _m, attrs) {
414
+ seen.push(attrs);
415
+ },
416
+ });
417
+
418
+ await withTracing("t", async () => {
419
+ logger.info("inside-span", { custom: "yes" });
420
+ });
421
+ logger.info("outside-span", { custom: "no" });
422
+
423
+ expect(seen[0]).toMatchObject({
424
+ trace_id: "deadbeefdeadbeefdeadbeefdeadbeef",
425
+ span_id: "1234567890abcdef",
426
+ custom: "yes",
427
+ });
428
+ expect(seen[1]).toEqual({ custom: "no" });
429
+
430
+ setObservabilitySpanStore(undefined);
431
+ });
432
+ });