@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,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
|
+
});
|