@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,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildHydrationContext } from "./hydrationContext";
|
|
3
|
+
|
|
4
|
+
function makeRequest(headers: Record<string, string> = {}): Request {
|
|
5
|
+
return new Request("https://store.com/", { headers });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("buildHydrationContext", () => {
|
|
9
|
+
it("extracts locale and timezone from cookies", () => {
|
|
10
|
+
const req = makeRequest({ cookie: "locale=pt-BR; tz=America/Sao_Paulo" });
|
|
11
|
+
const ctx = buildHydrationContext(req);
|
|
12
|
+
expect(ctx.locale).toBe("pt-BR");
|
|
13
|
+
expect(ctx.timeZone).toBe("America/Sao_Paulo");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("falls back to Accept-Language when no locale cookie", () => {
|
|
17
|
+
const req = makeRequest({ "accept-language": "pt-BR,pt;q=0.9,en;q=0.8" });
|
|
18
|
+
const ctx = buildHydrationContext(req);
|
|
19
|
+
expect(ctx.locale).toBe("pt-BR");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults to en-US and UTC when no headers or cookies", () => {
|
|
23
|
+
const req = makeRequest();
|
|
24
|
+
const ctx = buildHydrationContext(req);
|
|
25
|
+
expect(ctx.locale).toBe("en-US");
|
|
26
|
+
expect(ctx.timeZone).toBe("UTC");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("extracts country from cf-ipcountry header", () => {
|
|
30
|
+
const req = makeRequest({ "cf-ipcountry": "BR" });
|
|
31
|
+
const ctx = buildHydrationContext(req);
|
|
32
|
+
expect(ctx.country).toBe("BR");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("extracts country from cookie when no cf header", () => {
|
|
36
|
+
const req = makeRequest({ cookie: "country=US" });
|
|
37
|
+
const ctx = buildHydrationContext(req);
|
|
38
|
+
expect(ctx.country).toBe("US");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("country is undefined when not available", () => {
|
|
42
|
+
const req = makeRequest();
|
|
43
|
+
const ctx = buildHydrationContext(req);
|
|
44
|
+
expect(ctx.country).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("cf-ipcountry takes precedence over cookie", () => {
|
|
48
|
+
const req = makeRequest({
|
|
49
|
+
"cf-ipcountry": "BR",
|
|
50
|
+
cookie: "country=US",
|
|
51
|
+
});
|
|
52
|
+
const ctx = buildHydrationContext(req);
|
|
53
|
+
expect(ctx.country).toBe("BR");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles cookies with = in values", () => {
|
|
57
|
+
const req = makeRequest({ cookie: "locale=en-US; token=abc=def=ghi" });
|
|
58
|
+
const ctx = buildHydrationContext(req);
|
|
59
|
+
expect(ctx.locale).toBe("en-US");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration context utilities for consistent SSR/client rendering.
|
|
3
|
+
*
|
|
4
|
+
* Provides patterns for extracting locale, timezone, and other
|
|
5
|
+
* request-specific data that must be consistent between server
|
|
6
|
+
* and client to avoid hydration mismatches.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // In your storefront's middleware.ts:
|
|
11
|
+
* import { createMiddleware } from "@tanstack/react-start";
|
|
12
|
+
* import { buildHydrationContext } from "@decocms/start/middleware/hydrationContext";
|
|
13
|
+
*
|
|
14
|
+
* export const hydrationMiddleware = createMiddleware().server(async ({ request, next }) => {
|
|
15
|
+
* const hydrationCtx = buildHydrationContext(request);
|
|
16
|
+
* return next({ context: { hydration: hydrationCtx } });
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Then in components:
|
|
21
|
+
* ```tsx
|
|
22
|
+
* // Use the cookie-based values for deterministic SSR rendering
|
|
23
|
+
* const locale = hydrationCtx.locale; // same on server and client
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface HydrationContext {
|
|
28
|
+
/** Locale from cookie or Accept-Language header. */
|
|
29
|
+
locale: string;
|
|
30
|
+
/** Timezone from cookie. Falls back to "UTC" for deterministic SSR. */
|
|
31
|
+
timeZone: string;
|
|
32
|
+
/** Country code from Cloudflare headers or cookie. */
|
|
33
|
+
country?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build hydration context from a request.
|
|
38
|
+
*
|
|
39
|
+
* Values are extracted from cookies first (set by the client on first visit),
|
|
40
|
+
* then from headers. Cookie-based values are deterministic because the same
|
|
41
|
+
* cookie is sent on both SSR and client-side navigations.
|
|
42
|
+
*
|
|
43
|
+
* Recommended: set these cookies on first client render:
|
|
44
|
+
* ```tsx
|
|
45
|
+
* useEffect(() => {
|
|
46
|
+
* const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
47
|
+
* document.cookie = `tz=${tz}; path=/; max-age=31536000; SameSite=Lax`;
|
|
48
|
+
* document.cookie = `locale=${navigator.language}; path=/; max-age=31536000; SameSite=Lax`;
|
|
49
|
+
* }, []);
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function buildHydrationContext(request: Request): HydrationContext {
|
|
53
|
+
const cookies = parseCookies(request.headers.get("cookie") ?? "");
|
|
54
|
+
|
|
55
|
+
// Locale: cookie → Accept-Language → fallback
|
|
56
|
+
const locale =
|
|
57
|
+
cookies.locale ||
|
|
58
|
+
request.headers.get("accept-language")?.split(",")[0]?.split(";")[0]?.trim() ||
|
|
59
|
+
"en-US";
|
|
60
|
+
|
|
61
|
+
// Timezone: cookie → UTC fallback (never guess — causes hydration mismatch)
|
|
62
|
+
const timeZone = cookies.tz || "UTC";
|
|
63
|
+
|
|
64
|
+
// Country: Cloudflare header → cookie
|
|
65
|
+
const country =
|
|
66
|
+
request.headers.get("cf-ipcountry") || cookies.country || undefined;
|
|
67
|
+
|
|
68
|
+
return { locale, timeZone, country };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
72
|
+
const cookies: Record<string, string> = {};
|
|
73
|
+
for (const part of cookieHeader.split(";")) {
|
|
74
|
+
const [key, ...rest] = part.split("=");
|
|
75
|
+
const k = key?.trim();
|
|
76
|
+
if (k) cookies[k] = rest.join("=").trim();
|
|
77
|
+
}
|
|
78
|
+
return cookies;
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deco middleware utilities for TanStack Start.
|
|
3
|
+
*
|
|
4
|
+
* These are NOT TanStack `createMiddleware()` instances because
|
|
5
|
+
* `@decocms/start` doesn't depend on `@tanstack/react-start`.
|
|
6
|
+
* Instead, they export composable handler functions that storefronts
|
|
7
|
+
* wire into their own `createMiddleware()` chain.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* // In your storefront's middleware.ts
|
|
12
|
+
* import { createMiddleware } from "@tanstack/react-start";
|
|
13
|
+
* import { buildDecoState, handleLiveness, applyServerTiming, applyCorsHeaders } from "@decocms/start/middleware";
|
|
14
|
+
*
|
|
15
|
+
* export const decoMiddleware = createMiddleware().server(async ({ request, next }) => {
|
|
16
|
+
* const liveness = handleLiveness(request);
|
|
17
|
+
* if (liveness) return { result: undefined, response: liveness };
|
|
18
|
+
*
|
|
19
|
+
* const state = buildDecoState(request, "my-site");
|
|
20
|
+
* const endTotal = state.timings.start("total");
|
|
21
|
+
*
|
|
22
|
+
* const result = await next();
|
|
23
|
+
*
|
|
24
|
+
* endTotal();
|
|
25
|
+
* applyServerTiming(result.response, state);
|
|
26
|
+
* if (state.isAdmin) applyCorsHeaders(result.response, request);
|
|
27
|
+
*
|
|
28
|
+
* return result;
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
type CacheDecision,
|
|
35
|
+
configureMeter,
|
|
36
|
+
configureTracer,
|
|
37
|
+
getActiveSpan,
|
|
38
|
+
getMeter,
|
|
39
|
+
getTracer,
|
|
40
|
+
injectTraceContext,
|
|
41
|
+
logRequest,
|
|
42
|
+
type MeterAdapter,
|
|
43
|
+
MetricNames,
|
|
44
|
+
recordCacheMetric,
|
|
45
|
+
recordRequestMetric,
|
|
46
|
+
type Span,
|
|
47
|
+
setSpanAttribute,
|
|
48
|
+
type TracerAdapter,
|
|
49
|
+
withTracing,
|
|
50
|
+
} from "../sdk/observability";
|
|
51
|
+
export { buildDecoState, type DecoState } from "./decoState";
|
|
52
|
+
export {
|
|
53
|
+
getHealthMetrics,
|
|
54
|
+
type HealthMetrics,
|
|
55
|
+
handleHealthCheck,
|
|
56
|
+
trackRequest,
|
|
57
|
+
} from "./healthMetrics";
|
|
58
|
+
export { buildHydrationContext, type HydrationContext } from "./hydrationContext";
|
|
59
|
+
export { handleLiveness } from "./liveness";
|
|
60
|
+
export {
|
|
61
|
+
createSectionValidator,
|
|
62
|
+
type DeferredSectionInput,
|
|
63
|
+
validateDeferredSectionInput,
|
|
64
|
+
} from "./validateSection";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Appends Server-Timing header to a response from the accumulated timings.
|
|
68
|
+
*/
|
|
69
|
+
export function applyServerTiming(response: Response, state: { timings: { toHeader(): string } }) {
|
|
70
|
+
const header = state.timings.toHeader();
|
|
71
|
+
if (header) {
|
|
72
|
+
response.headers.append("Server-Timing", header);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Applies CORS headers for deco admin origins to a response.
|
|
78
|
+
*/
|
|
79
|
+
export function applyCorsHeaders(response: Response, request: Request) {
|
|
80
|
+
const origin = request.headers.get("origin") || "*";
|
|
81
|
+
response.headers.set("Access-Control-Allow-Origin", origin);
|
|
82
|
+
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
83
|
+
response.headers.set(
|
|
84
|
+
"Access-Control-Allow-Headers",
|
|
85
|
+
"Content-Type, Authorization, If-None-Match",
|
|
86
|
+
);
|
|
87
|
+
response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness and readiness probe handlers.
|
|
3
|
+
*
|
|
4
|
+
* - `/_liveness` — simple 200 OK for load balancers (K8s, Cloudflare)
|
|
5
|
+
* - `/deco/_health` — detailed JSON health metrics (delegated to healthMetrics)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { handleHealthCheck } from "./healthMetrics";
|
|
9
|
+
|
|
10
|
+
export function handleLiveness(request: Request): Response | null {
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
|
|
13
|
+
if (url.pathname === "/_liveness" || url.pathname === "/deco/_liveness") {
|
|
14
|
+
return new Response("OK", {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return handleHealthCheck(request);
|
|
21
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 (D-11) coverage for the metric surface — canonical label set,
|
|
3
|
+
* cache_layer, commerce_request_duration_ms. The Phase 1 logger/trace
|
|
4
|
+
* tests live under `src/sdk/logger.test.ts` and `src/sdk/otel.test.ts`;
|
|
5
|
+
* this file focuses on the middleware-level helpers.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
configureMeter,
|
|
10
|
+
type MeterAdapter,
|
|
11
|
+
MetricNames,
|
|
12
|
+
recordCacheMetric,
|
|
13
|
+
recordCommerceMetric,
|
|
14
|
+
recordRequestMetric,
|
|
15
|
+
statusClassFor,
|
|
16
|
+
} from "./observability";
|
|
17
|
+
|
|
18
|
+
interface Counter {
|
|
19
|
+
name: string;
|
|
20
|
+
value: number;
|
|
21
|
+
labels?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
interface Histogram {
|
|
24
|
+
name: string;
|
|
25
|
+
value: number;
|
|
26
|
+
labels?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureMeter(): {
|
|
30
|
+
adapter: MeterAdapter;
|
|
31
|
+
counters: Counter[];
|
|
32
|
+
histograms: Histogram[];
|
|
33
|
+
} {
|
|
34
|
+
const counters: Counter[] = [];
|
|
35
|
+
const histograms: Histogram[] = [];
|
|
36
|
+
const adapter: MeterAdapter = {
|
|
37
|
+
counterInc(name, value, labels) {
|
|
38
|
+
counters.push({ name, value: value ?? 1, labels });
|
|
39
|
+
},
|
|
40
|
+
histogramRecord(name, value, labels) {
|
|
41
|
+
histograms.push({ name, value, labels });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { adapter, counters, histograms };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("statusClassFor", () => {
|
|
48
|
+
it("maps 2xx / 3xx / 4xx / 5xx to canonical class labels", () => {
|
|
49
|
+
expect(statusClassFor(200)).toBe("2xx");
|
|
50
|
+
expect(statusClassFor(204)).toBe("2xx");
|
|
51
|
+
expect(statusClassFor(301)).toBe("3xx");
|
|
52
|
+
expect(statusClassFor(404)).toBe("4xx");
|
|
53
|
+
expect(statusClassFor(500)).toBe("5xx");
|
|
54
|
+
expect(statusClassFor(503)).toBe("5xx");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns 'unknown' for out-of-range / NaN / non-numeric inputs", () => {
|
|
58
|
+
expect(statusClassFor(-1)).toBe("unknown");
|
|
59
|
+
expect(statusClassFor(99)).toBe("unknown");
|
|
60
|
+
expect(statusClassFor(600)).toBe("unknown");
|
|
61
|
+
expect(statusClassFor(Number.NaN)).toBe("unknown");
|
|
62
|
+
expect(statusClassFor(Infinity)).toBe("unknown");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
// Reset meter so other tests start clean.
|
|
69
|
+
configureMeter({ counterInc: () => {} });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stamps method + route_pattern + status + status_class by default", () => {
|
|
73
|
+
const { adapter, counters, histograms } = captureMeter();
|
|
74
|
+
configureMeter(adapter);
|
|
75
|
+
|
|
76
|
+
recordRequestMetric("GET", "/products/abc123/p", 200, 42);
|
|
77
|
+
|
|
78
|
+
// Canonical OTel HTTP server metric is histogram-only; the count
|
|
79
|
+
// dimension is derived from the histogram's bucket counts at query
|
|
80
|
+
// time, so we no longer emit a parallel `_total` counter.
|
|
81
|
+
expect(counters).toHaveLength(0);
|
|
82
|
+
expect(histograms).toHaveLength(1);
|
|
83
|
+
expect(histograms[0]?.name).toBe(MetricNames.HTTP_SERVER_REQUEST_DURATION);
|
|
84
|
+
expect(histograms[0]?.value).toBe(42);
|
|
85
|
+
expect(histograms[0]?.labels).toMatchObject({
|
|
86
|
+
method: "GET",
|
|
87
|
+
// Default normalization: dynamic segments collapsed.
|
|
88
|
+
route_pattern: "/products/:slug/p",
|
|
89
|
+
status: 200,
|
|
90
|
+
status_class: "2xx",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("prefers caller-supplied route_pattern over normalized path", () => {
|
|
95
|
+
const { adapter, histograms } = captureMeter();
|
|
96
|
+
configureMeter(adapter);
|
|
97
|
+
|
|
98
|
+
recordRequestMetric("GET", "/anything/random/123", 200, 5, {
|
|
99
|
+
route_pattern: "/_products/$slug/p",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(histograms[0]?.labels?.route_pattern).toBe("/_products/$slug/p");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("tags 5xx requests with status_class=5xx for downstream error filtering", () => {
|
|
106
|
+
const { adapter, histograms } = captureMeter();
|
|
107
|
+
configureMeter(adapter);
|
|
108
|
+
|
|
109
|
+
recordRequestMetric("POST", "/checkout", 503, 120);
|
|
110
|
+
|
|
111
|
+
expect(histograms[0]?.labels?.status_class).toBe("5xx");
|
|
112
|
+
expect(histograms[0]?.labels?.status).toBe(503);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => {
|
|
116
|
+
const { adapter, histograms } = captureMeter();
|
|
117
|
+
configureMeter(adapter);
|
|
118
|
+
|
|
119
|
+
recordRequestMetric("GET", "/", 200, 10, {
|
|
120
|
+
outcome: "ok",
|
|
121
|
+
cache_decision: "STALE-HIT",
|
|
122
|
+
cache_layer: "edge",
|
|
123
|
+
region: "GRU",
|
|
124
|
+
extra: { ab_variant: "B" },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(histograms[0]?.labels).toMatchObject({
|
|
128
|
+
outcome: "ok",
|
|
129
|
+
cache_decision: "STALE-HIT",
|
|
130
|
+
cache_layer: "edge",
|
|
131
|
+
region: "GRU",
|
|
132
|
+
ab_variant: "B",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("is a no-op when no meter is configured", () => {
|
|
137
|
+
// We can't easily prove a no-op other than verifying no throw —
|
|
138
|
+
// safer than calling configureMeter(null), which would mask real
|
|
139
|
+
// bugs. The previous test's `afterEach` reset already gives us a
|
|
140
|
+
// bare meter; this test confirms the call is benign.
|
|
141
|
+
expect(() => recordRequestMetric("GET", "/", 200, 1)).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("recordCacheMetric — cache_layer label", () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
configureMeter({ counterInc: () => {} });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("stamps profile + decision + layer when all are provided", () => {
|
|
151
|
+
const { adapter, counters } = captureMeter();
|
|
152
|
+
configureMeter(adapter);
|
|
153
|
+
|
|
154
|
+
recordCacheMetric(true, "product", "HIT", "edge");
|
|
155
|
+
|
|
156
|
+
expect(counters).toHaveLength(1);
|
|
157
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_HIT);
|
|
158
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
159
|
+
profile: "product",
|
|
160
|
+
cache_decision: "HIT",
|
|
161
|
+
cache_layer: "edge",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("emits cache_miss_total when hit=false", () => {
|
|
166
|
+
const { adapter, counters } = captureMeter();
|
|
167
|
+
configureMeter(adapter);
|
|
168
|
+
|
|
169
|
+
recordCacheMetric(false, "search", "MISS", "edge");
|
|
170
|
+
|
|
171
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_MISS);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("supports the legacy 3-arg signature for backward compat", () => {
|
|
175
|
+
const { adapter, counters } = captureMeter();
|
|
176
|
+
configureMeter(adapter);
|
|
177
|
+
|
|
178
|
+
recordCacheMetric(true, "static");
|
|
179
|
+
|
|
180
|
+
expect(counters[0]?.labels).toEqual({ profile: "static" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
|
|
184
|
+
const { adapter, counters } = captureMeter();
|
|
185
|
+
configureMeter(adapter);
|
|
186
|
+
|
|
187
|
+
recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
|
|
188
|
+
recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
|
|
189
|
+
|
|
190
|
+
expect(counters[0]?.labels?.cache_layer).toBe("cachedLoader");
|
|
191
|
+
expect(counters[1]?.labels?.cache_layer).toBe("vtex-swr");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("recordCommerceMetric (D-11)", () => {
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
configureMeter({ counterInc: () => {} });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("emits http.client.request.duration with provider + operation labels", () => {
|
|
201
|
+
const { adapter, histograms } = captureMeter();
|
|
202
|
+
configureMeter(adapter);
|
|
203
|
+
|
|
204
|
+
recordCommerceMetric(123, {
|
|
205
|
+
provider: "vtex",
|
|
206
|
+
operation: "intelligent-search.product_search",
|
|
207
|
+
status_class: "2xx",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(histograms).toHaveLength(1);
|
|
211
|
+
expect(histograms[0]?.name).toBe(MetricNames.HTTP_CLIENT_REQUEST_DURATION);
|
|
212
|
+
expect(histograms[0]?.value).toBe(123);
|
|
213
|
+
expect(histograms[0]?.labels).toMatchObject({
|
|
214
|
+
provider: "vtex",
|
|
215
|
+
operation: "intelligent-search.product_search",
|
|
216
|
+
status_class: "2xx",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("includes the cached boolean when provided", () => {
|
|
221
|
+
const { adapter, histograms } = captureMeter();
|
|
222
|
+
configureMeter(adapter);
|
|
223
|
+
|
|
224
|
+
recordCommerceMetric(5, {
|
|
225
|
+
provider: "shopify",
|
|
226
|
+
operation: "graphql.cart_query",
|
|
227
|
+
cached: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(histograms[0]?.labels?.cached).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("is a no-op when no meter is configured", () => {
|
|
234
|
+
expect(() =>
|
|
235
|
+
recordCommerceMetric(1, { provider: "vtex", operation: "test" }),
|
|
236
|
+
).not.toThrow();
|
|
237
|
+
});
|
|
238
|
+
});
|