@decocms/blocks 7.2.3 → 7.3.1
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 +3 -1
- package/src/cms/resolve.ts +102 -9
- package/src/cms/schema.ts +2 -10
- package/src/cms/sectionMixins.ts +1 -1
- package/src/cms/stickyFlags.test.ts +117 -0
- package/src/hooks/Picture.tsx +18 -2
- package/src/middleware/observability.test.ts +26 -22
- package/src/middleware/observability.ts +76 -53
- package/src/sdk/detectDevice.ts +45 -0
- package/src/sdk/flags.test.ts +91 -0
- package/src/sdk/flags.ts +108 -0
- package/src/sdk/requestContext.ts +1 -1
- package/src/sdk/useDevice.ts +12 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework-agnostic core: CMS block resolution, section registry, matchers, portable SDK utilities",
|
|
6
6
|
"repository": {
|
|
@@ -46,9 +46,11 @@
|
|
|
46
46
|
"./sdk/cookie": "./src/sdk/cookie.ts",
|
|
47
47
|
"./sdk/crypto": "./src/sdk/crypto.ts",
|
|
48
48
|
"./sdk/csp": "./src/sdk/csp.ts",
|
|
49
|
+
"./sdk/detectDevice": "./src/sdk/detectDevice.ts",
|
|
49
50
|
"./sdk/djb2": "./src/sdk/djb2.ts",
|
|
50
51
|
"./sdk/encoding": "./src/sdk/encoding.ts",
|
|
51
52
|
"./sdk/env": "./src/sdk/env.ts",
|
|
53
|
+
"./sdk/flags": "./src/sdk/flags.ts",
|
|
52
54
|
"./sdk/http": "./src/sdk/http.ts",
|
|
53
55
|
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
|
|
54
56
|
"./sdk/invoke": "./src/sdk/invoke.ts",
|
package/src/cms/resolve.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
|
|
8
8
|
import { getMeter, MetricNames, withTracing } from "../middleware/observability";
|
|
9
9
|
import { djb2Hex } from "../sdk/djb2";
|
|
10
|
+
import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
|
|
10
11
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
11
12
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
12
13
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
@@ -380,6 +381,15 @@ export interface MatcherContext {
|
|
|
380
381
|
cookies?: Record<string, string>;
|
|
381
382
|
headers?: Record<string, string>;
|
|
382
383
|
request?: Request;
|
|
384
|
+
/**
|
|
385
|
+
* Sticky A/B flags recorded during resolution. The resolver appends a
|
|
386
|
+
* {@link StoredFlag} the first time it evaluates each sticky matcher (see
|
|
387
|
+
* `evaluateVariantRule`), reusing the decision for the rest of the request
|
|
388
|
+
* so every resolve pass picks the same variant. Read it after resolution to
|
|
389
|
+
* persist the `deco_flags` cookie and split the edge cache per cohort.
|
|
390
|
+
* Pass an array in to opt into recording; leave undefined to disable.
|
|
391
|
+
*/
|
|
392
|
+
flags?: StoredFlag[];
|
|
383
393
|
/**
|
|
384
394
|
* Client-side (SPA) navigation via TanStack `<Link>`. Disables section
|
|
385
395
|
* deferral: deferral is a streaming-SSR optimization, but a client nav
|
|
@@ -650,6 +660,85 @@ export function evaluateMatcher(
|
|
|
650
660
|
return false;
|
|
651
661
|
}
|
|
652
662
|
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Sticky matchers (A/B)
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Matcher types whose decision must stick per user instead of re-rolling every
|
|
669
|
+
* request. Mirrors the `sticky = "session"` export on the corresponding
|
|
670
|
+
* matchers in @decocms/apps (deco-start re-implements matchers inline, so it
|
|
671
|
+
* declares the set here rather than importing those exports).
|
|
672
|
+
*/
|
|
673
|
+
const STICKY_MATCHER_TYPES = new Set(["website/matchers/random.ts"]);
|
|
674
|
+
|
|
675
|
+
interface StickyMeta {
|
|
676
|
+
/** Cohort identity — the named matcher block (e.g. "TestHero"). */
|
|
677
|
+
name: string;
|
|
678
|
+
/** Traffic share for the `true` branch. */
|
|
679
|
+
traffic: number;
|
|
680
|
+
/** `round(traffic * 100)` — the re-roll fingerprint. */
|
|
681
|
+
pct: number;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* If `rule` points at a sticky matcher, return its cohort identity + traffic;
|
|
686
|
+
* otherwise `null` (evaluate normally). A rule is either a named matcher block
|
|
687
|
+
* (`{__resolveType: "TestHero"}`, dereferenced here to read its `traffic`) or
|
|
688
|
+
* an inline matcher; the block name is the stable cohort key when present.
|
|
689
|
+
*/
|
|
690
|
+
function stickyMatcherMeta(rule: Record<string, unknown> | undefined): StickyMeta | null {
|
|
691
|
+
const rt = rule?.__resolveType as string | undefined;
|
|
692
|
+
if (!rt) return null;
|
|
693
|
+
|
|
694
|
+
const blocks = loadBlocks();
|
|
695
|
+
const name = rt;
|
|
696
|
+
let resolved = rule as Record<string, unknown>;
|
|
697
|
+
if (blocks[rt]) {
|
|
698
|
+
resolved = blocks[rt] as Record<string, unknown>;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const type = resolved.__resolveType as string | undefined;
|
|
702
|
+
if (!type || !STICKY_MATCHER_TYPES.has(type)) return null;
|
|
703
|
+
|
|
704
|
+
const traffic = typeof resolved.traffic === "number" ? resolved.traffic : 0.5;
|
|
705
|
+
return { name, traffic, pct: trafficToPct(traffic) };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Evaluate a variant's rule, applying sticky persistence for A/B matchers.
|
|
710
|
+
*
|
|
711
|
+
* For a sticky matcher: reuse the decision already recorded this request; else
|
|
712
|
+
* honor the `deco_segment` cookie when its fingerprint still matches the current
|
|
713
|
+
* `traffic`; else roll fresh. Every decision is recorded on `ctx.flags` (when
|
|
714
|
+
* present) so the caller can persist the cookie and split the cache. Non-sticky
|
|
715
|
+
* rules fall through to the plain matcher.
|
|
716
|
+
*/
|
|
717
|
+
function evaluateVariantRule(
|
|
718
|
+
rule: Record<string, unknown> | undefined,
|
|
719
|
+
ctx: MatcherContext,
|
|
720
|
+
): boolean {
|
|
721
|
+
const meta = stickyMatcherMeta(rule);
|
|
722
|
+
// Admin preview forces variants via x-deco-matchers-override; defer to
|
|
723
|
+
// evaluateMatcher so a forced decision wins. Sticky persistence is a
|
|
724
|
+
// production concern (previews aren't cached), so skipping it here is fine.
|
|
725
|
+
if (!meta || hasMatchersOverride(ctx)) return evaluateMatcher(rule, ctx);
|
|
726
|
+
|
|
727
|
+
// Reuse a decision already made this request so every resolve pass (page,
|
|
728
|
+
// shallow section-key, deferred) selects the same variant.
|
|
729
|
+
const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
|
|
730
|
+
if (already) return already.value;
|
|
731
|
+
|
|
732
|
+
const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name);
|
|
733
|
+
// pct === -1 marks a classic-deco segment without a fingerprint — honor it
|
|
734
|
+
// (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
|
|
735
|
+
const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
|
|
736
|
+
const value = useStored ? stored.value : Math.random() < meta.traffic;
|
|
737
|
+
|
|
738
|
+
ctx.flags?.push({ name: meta.name, value, pct: meta.pct });
|
|
739
|
+
return value;
|
|
740
|
+
}
|
|
741
|
+
|
|
653
742
|
// ---------------------------------------------------------------------------
|
|
654
743
|
// Select (partial field picking)
|
|
655
744
|
// ---------------------------------------------------------------------------
|
|
@@ -742,7 +831,7 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
742
831
|
|
|
743
832
|
for (const variant of variants) {
|
|
744
833
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
745
|
-
if (
|
|
834
|
+
if (evaluateVariantRule(rule, rctx.matcherCtx)) {
|
|
746
835
|
return internalResolve(variant.value, childCtx);
|
|
747
836
|
}
|
|
748
837
|
}
|
|
@@ -1047,7 +1136,7 @@ export async function resolvePageSeoBlock(
|
|
|
1047
1136
|
let matched: unknown = null;
|
|
1048
1137
|
for (const variant of variants) {
|
|
1049
1138
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1050
|
-
if (
|
|
1139
|
+
if (evaluateVariantRule(rule, rctx.matcherCtx)) {
|
|
1051
1140
|
matched = variant.value;
|
|
1052
1141
|
break;
|
|
1053
1142
|
}
|
|
@@ -1228,7 +1317,7 @@ function resolveFinalSectionKey(section: unknown, matcherCtx?: MatcherContext):
|
|
|
1228
1317
|
let matched: unknown = null;
|
|
1229
1318
|
for (const variant of variants) {
|
|
1230
1319
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1231
|
-
if (
|
|
1320
|
+
if (evaluateVariantRule(rule, matcherCtx ?? {})) {
|
|
1232
1321
|
matched = variant.value;
|
|
1233
1322
|
break;
|
|
1234
1323
|
}
|
|
@@ -1281,7 +1370,7 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo
|
|
|
1281
1370
|
let matched: unknown = null;
|
|
1282
1371
|
for (const variant of variants) {
|
|
1283
1372
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1284
|
-
if (
|
|
1373
|
+
if (evaluateVariantRule(rule, matcherCtx ?? {})) {
|
|
1285
1374
|
matched = variant.value;
|
|
1286
1375
|
break;
|
|
1287
1376
|
}
|
|
@@ -1433,7 +1522,7 @@ function resolveSectionShallow(
|
|
|
1433
1522
|
let matched: unknown = null;
|
|
1434
1523
|
for (const variant of variants) {
|
|
1435
1524
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1436
|
-
if (
|
|
1525
|
+
if (evaluateVariantRule(rule, matcherCtx ?? {})) {
|
|
1437
1526
|
matched = variant.value;
|
|
1438
1527
|
break;
|
|
1439
1528
|
}
|
|
@@ -1492,7 +1581,7 @@ export async function resolveSectionsList(
|
|
|
1492
1581
|
const variants = obj.variants as Array<{ value: unknown; rule?: unknown }>;
|
|
1493
1582
|
for (const variant of variants) {
|
|
1494
1583
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1495
|
-
if (
|
|
1584
|
+
if (evaluateVariantRule(rule, rctx.matcherCtx)) {
|
|
1496
1585
|
return resolveSectionsList(variant.value, rctx, depth + 1);
|
|
1497
1586
|
}
|
|
1498
1587
|
}
|
|
@@ -1507,7 +1596,7 @@ export async function resolveSectionsList(
|
|
|
1507
1596
|
if (!variants?.length) return [];
|
|
1508
1597
|
for (const variant of variants) {
|
|
1509
1598
|
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1510
|
-
if (
|
|
1599
|
+
if (evaluateVariantRule(rule, rctx.matcherCtx)) {
|
|
1511
1600
|
return resolveSectionsList(variant.value, rctx, depth + 1);
|
|
1512
1601
|
}
|
|
1513
1602
|
}
|
|
@@ -1634,10 +1723,14 @@ export async function resolveDecoPage(
|
|
|
1634
1723
|
async () => {
|
|
1635
1724
|
const result = await resolveDecoPageImpl(targetPath, matcherCtx);
|
|
1636
1725
|
try {
|
|
1726
|
+
// No path label: resolve duration is a per-service latency signal, and
|
|
1727
|
+
// the request path is unbounded cardinality (one histogram series per
|
|
1728
|
+
// URL — product pages, search, facets) that made this the single
|
|
1729
|
+
// biggest series in ClickHouse. Per-route latency lives on the span
|
|
1730
|
+
// below (raw deco.route), which is sampled — the right place for it.
|
|
1637
1731
|
getMeter()?.histogramRecord?.(
|
|
1638
1732
|
MetricNames.RESOLVE_DURATION,
|
|
1639
|
-
performance.now() - startedAt,
|
|
1640
|
-
{ path: targetPath },
|
|
1733
|
+
(performance.now() - startedAt) / 1000,
|
|
1641
1734
|
);
|
|
1642
1735
|
} catch {
|
|
1643
1736
|
/* observability never fails the request */
|
package/src/cms/schema.ts
CHANGED
|
@@ -461,12 +461,7 @@ registerMatcherSchemas([
|
|
|
461
461
|
matchers: {
|
|
462
462
|
type: "array",
|
|
463
463
|
title: "Matchers",
|
|
464
|
-
items: {
|
|
465
|
-
type: "object",
|
|
466
|
-
required: ["__resolveType"],
|
|
467
|
-
properties: { __resolveType: { type: "string" } },
|
|
468
|
-
additionalProperties: true,
|
|
469
|
-
},
|
|
464
|
+
items: { $ref: "#/root/matchers" },
|
|
470
465
|
},
|
|
471
466
|
},
|
|
472
467
|
},
|
|
@@ -481,11 +476,8 @@ registerMatcherSchemas([
|
|
|
481
476
|
type: "object",
|
|
482
477
|
properties: {
|
|
483
478
|
matcher: {
|
|
484
|
-
|
|
479
|
+
$ref: "#/root/matchers",
|
|
485
480
|
title: "Matcher",
|
|
486
|
-
required: ["__resolveType"],
|
|
487
|
-
properties: { __resolveType: { type: "string" } },
|
|
488
|
-
additionalProperties: true,
|
|
489
481
|
},
|
|
490
482
|
},
|
|
491
483
|
},
|
package/src/cms/sectionMixins.ts
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./sectionLoaders", () => ({
|
|
4
|
+
isLayoutSection: () => false,
|
|
5
|
+
runSingleSectionLoader: vi.fn(async (section: any) => section),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("../sdk/normalizeUrls", () => ({
|
|
9
|
+
normalizeUrlsInObject: vi.fn(<T>(x: T) => x),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// A page whose A/B "TestHero" matcher block resolves to a 50/50 random matcher.
|
|
13
|
+
vi.mock("./loader", () => ({
|
|
14
|
+
findPageByPath: vi.fn(),
|
|
15
|
+
loadBlocks: vi.fn(() => ({
|
|
16
|
+
TestHero: { __resolveType: "website/matchers/random.ts", traffic: 0.5 },
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./registry", () => ({
|
|
21
|
+
getSection: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { DECO_MATCHERS_OVERRIDE_PARAM } from "../matchers/override";
|
|
25
|
+
import { type StoredFlag, serializeSegmentCookie } from "../sdk/flags";
|
|
26
|
+
import type { MatcherContext } from "./resolve";
|
|
27
|
+
import { resolveValue } from "./resolve";
|
|
28
|
+
|
|
29
|
+
const FLAG = {
|
|
30
|
+
__resolveType: "website/flags/multivariate.ts",
|
|
31
|
+
variants: [
|
|
32
|
+
{ rule: { __resolveType: "TestHero" }, value: "VARIANT_1" },
|
|
33
|
+
{ rule: { __resolveType: "website/matchers/always.ts" }, value: "VARIANT_2" },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function ctx(decision?: { value: boolean; pct: number }): MatcherContext & { flags: StoredFlag[] } {
|
|
38
|
+
const cookie = decision
|
|
39
|
+
? serializeSegmentCookie([{ name: "TestHero", value: decision.value, pct: decision.pct }])
|
|
40
|
+
: undefined;
|
|
41
|
+
return {
|
|
42
|
+
cookies: cookie ? { deco_segment: cookie } : {},
|
|
43
|
+
flags: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => vi.restoreAllMocks());
|
|
48
|
+
|
|
49
|
+
describe("sticky A/B multivariate resolution", () => {
|
|
50
|
+
it("honors a matching cookie without rolling (test cohort)", async () => {
|
|
51
|
+
const rand = vi.spyOn(Math, "random");
|
|
52
|
+
const c = ctx({ value: true, pct: 50 });
|
|
53
|
+
|
|
54
|
+
const result = await resolveValue(FLAG, undefined, c);
|
|
55
|
+
|
|
56
|
+
expect(result).toBe("VARIANT_1");
|
|
57
|
+
expect(rand).not.toHaveBeenCalled();
|
|
58
|
+
expect(c.flags).toEqual([{ name: "TestHero", value: true, pct: 50 }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("honors a matching cookie without rolling (control cohort)", async () => {
|
|
62
|
+
const rand = vi.spyOn(Math, "random");
|
|
63
|
+
const c = ctx({ value: false, pct: 50 });
|
|
64
|
+
|
|
65
|
+
const result = await resolveValue(FLAG, undefined, c);
|
|
66
|
+
|
|
67
|
+
expect(result).toBe("VARIANT_2");
|
|
68
|
+
expect(rand).not.toHaveBeenCalled();
|
|
69
|
+
expect(c.flags).toEqual([{ name: "TestHero", value: false, pct: 50 }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rolls and records when there is no cookie", async () => {
|
|
73
|
+
vi.spyOn(Math, "random").mockReturnValue(0.9); // >= 0.5 -> control
|
|
74
|
+
const c = ctx();
|
|
75
|
+
|
|
76
|
+
const result = await resolveValue(FLAG, undefined, c);
|
|
77
|
+
|
|
78
|
+
expect(result).toBe("VARIANT_2");
|
|
79
|
+
expect(c.flags).toEqual([{ name: "TestHero", value: false, pct: 50 }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("re-rolls when the cookie's traffic fingerprint is stale", async () => {
|
|
83
|
+
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.5 -> test
|
|
84
|
+
// Cookie says the user was in control at 70% traffic; current traffic is 50%.
|
|
85
|
+
const c = ctx({ value: false, pct: 70 });
|
|
86
|
+
|
|
87
|
+
const result = await resolveValue(FLAG, undefined, c);
|
|
88
|
+
|
|
89
|
+
expect(result).toBe("VARIANT_1");
|
|
90
|
+
expect(c.flags).toEqual([{ name: "TestHero", value: true, pct: 50 }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not record flags when recording is not opted into", async () => {
|
|
94
|
+
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
|
95
|
+
// No `flags` array on the context -> nothing to record, but still resolves.
|
|
96
|
+
const result = await resolveValue(FLAG, undefined, { cookies: {} });
|
|
97
|
+
expect(result).toBe("VARIANT_1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("honors the admin matchers-override instead of the cookie/roll", async () => {
|
|
101
|
+
const rand = vi.spyOn(Math, "random");
|
|
102
|
+
// Admin forces TestHero=0 for preview, but the cookie says test cohort.
|
|
103
|
+
const c: MatcherContext & { flags: StoredFlag[] } = {
|
|
104
|
+
cookies: {
|
|
105
|
+
deco_segment: serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]),
|
|
106
|
+
},
|
|
107
|
+
headers: { [DECO_MATCHERS_OVERRIDE_PARAM]: "TestHero=0" },
|
|
108
|
+
flags: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = await resolveValue(FLAG, undefined, c);
|
|
112
|
+
|
|
113
|
+
expect(result).toBe("VARIANT_2"); // override wins over the cookie
|
|
114
|
+
expect(rand).not.toHaveBeenCalled();
|
|
115
|
+
expect(c.flags).toEqual([]); // previews don't persist a cohort
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/hooks/Picture.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ComponentPropsWithoutRef,
|
|
3
|
+
type Context,
|
|
3
4
|
createContext,
|
|
4
5
|
forwardRef,
|
|
5
6
|
type ReactNode,
|
|
@@ -12,13 +13,27 @@ import { type FitOptions, getOptimizedMediaUrl, getSrcSet } from "./Image";
|
|
|
12
13
|
// Preload context — flows from <Picture preload> to child <Source> elements
|
|
13
14
|
// so each source can inject its own <link rel="preload"> with the correct
|
|
14
15
|
// media query for responsive art direction.
|
|
16
|
+
//
|
|
17
|
+
// Created LAZILY (first render), not at module scope: this file has no
|
|
18
|
+
// "use client" directive, so in a react-server graph (a Server Component
|
|
19
|
+
// page, or ANY Next.js route handler — which ignores "use client"
|
|
20
|
+
// entirely) the module evaluates against React's react-server build, where
|
|
21
|
+
// `createContext` does not exist. A module-scope call crashes the whole
|
|
22
|
+
// graph at import time ("createContext is not a function") even if
|
|
23
|
+
// Picture/Source never render. Deferring to render time is safe: renders
|
|
24
|
+
// only ever happen under a full React build (client or SSR), and both
|
|
25
|
+
// components resolve the same memoized context object.
|
|
15
26
|
// -------------------------------------------------------------------------
|
|
16
27
|
|
|
17
28
|
interface PreloadContextValue {
|
|
18
29
|
preload: boolean;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
let _preloadContext: Context<PreloadContextValue> | null = null;
|
|
33
|
+
function getPreloadContext(): Context<PreloadContextValue> {
|
|
34
|
+
_preloadContext ??= createContext<PreloadContextValue>({ preload: false });
|
|
35
|
+
return _preloadContext;
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
// -------------------------------------------------------------------------
|
|
24
39
|
// Source — composable <source> with automatic srcSet optimization and
|
|
@@ -41,7 +56,7 @@ export const Source = forwardRef<HTMLSourceElement, SourceProps>(function Source
|
|
|
41
56
|
{ src, width, height, fetchPriority, fit = "cover", ...rest },
|
|
42
57
|
ref,
|
|
43
58
|
) {
|
|
44
|
-
const { preload } = useContext(
|
|
59
|
+
const { preload } = useContext(getPreloadContext());
|
|
45
60
|
|
|
46
61
|
const optimizedSrc = getOptimizedMediaUrl({
|
|
47
62
|
originalSrc: src,
|
|
@@ -93,6 +108,7 @@ export const Picture = forwardRef<HTMLPictureElement, PictureProps>(function Pic
|
|
|
93
108
|
ref,
|
|
94
109
|
) {
|
|
95
110
|
const value = useMemo(() => ({ preload }), [preload]);
|
|
111
|
+
const PreloadContext = getPreloadContext();
|
|
96
112
|
|
|
97
113
|
return (
|
|
98
114
|
<PreloadContext.Provider value={value}>
|
|
@@ -81,13 +81,13 @@ describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
|
81
81
|
expect(counters).toHaveLength(0);
|
|
82
82
|
expect(histograms).toHaveLength(1);
|
|
83
83
|
expect(histograms[0]?.name).toBe(MetricNames.HTTP_SERVER_REQUEST_DURATION);
|
|
84
|
-
expect(histograms[0]?.value).toBe(
|
|
84
|
+
expect(histograms[0]?.value).toBe(0.042); // seconds (semconv)
|
|
85
85
|
expect(histograms[0]?.labels).toMatchObject({
|
|
86
|
-
method: "GET",
|
|
86
|
+
"http.request.method": "GET",
|
|
87
87
|
// Default normalization: dynamic segments collapsed.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
status_class: "2xx",
|
|
88
|
+
"http.route": "/products/:slug/p",
|
|
89
|
+
"http.response.status_code": 200,
|
|
90
|
+
"deco.http.status_class": "2xx",
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -99,7 +99,7 @@ describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
|
99
99
|
route_pattern: "/_products/$slug/p",
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
expect(histograms[0]?.labels?.
|
|
102
|
+
expect(histograms[0]?.labels?.["http.route"]).toBe("/_products/$slug/p");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it("tags 5xx requests with status_class=5xx for downstream error filtering", () => {
|
|
@@ -108,8 +108,8 @@ describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
|
108
108
|
|
|
109
109
|
recordRequestMetric("POST", "/checkout", 503, 120);
|
|
110
110
|
|
|
111
|
-
expect(histograms[0]?.labels?.status_class).toBe("5xx");
|
|
112
|
-
expect(histograms[0]?.labels?.
|
|
111
|
+
expect(histograms[0]?.labels?.["deco.http.status_class"]).toBe("5xx");
|
|
112
|
+
expect(histograms[0]?.labels?.["http.response.status_code"]).toBe(503);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => {
|
|
@@ -125,10 +125,10 @@ describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
expect(histograms[0]?.labels).toMatchObject({
|
|
128
|
-
outcome: "ok",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
region: "GRU",
|
|
128
|
+
"deco.http.outcome": "ok",
|
|
129
|
+
"deco.cache.decision": "STALE-HIT",
|
|
130
|
+
"deco.cache.layer": "edge",
|
|
131
|
+
"deco.http.region": "GRU",
|
|
132
132
|
ab_variant: "B",
|
|
133
133
|
});
|
|
134
134
|
});
|
|
@@ -154,21 +154,22 @@ describe("recordCacheMetric — cache_layer label", () => {
|
|
|
154
154
|
recordCacheMetric(true, "product", "HIT", "edge");
|
|
155
155
|
|
|
156
156
|
expect(counters).toHaveLength(1);
|
|
157
|
-
expect(counters[0]?.name).toBe(MetricNames.
|
|
157
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS);
|
|
158
158
|
expect(counters[0]?.labels).toMatchObject({
|
|
159
|
-
profile: "product",
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
"deco.cache.profile": "product",
|
|
160
|
+
"deco.cache.status": "HIT",
|
|
161
|
+
"deco.cache.layer": "edge",
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it("
|
|
165
|
+
it("records status=MISS when hit=false", () => {
|
|
166
166
|
const { adapter, counters } = captureMeter();
|
|
167
167
|
configureMeter(adapter);
|
|
168
168
|
|
|
169
169
|
recordCacheMetric(false, "search", "MISS", "edge");
|
|
170
170
|
|
|
171
|
-
expect(counters[0]?.name).toBe(MetricNames.
|
|
171
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_REQUESTS);
|
|
172
|
+
expect(counters[0]?.labels?.["deco.cache.status"]).toBe("MISS");
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
it("supports the legacy 3-arg signature for backward compat", () => {
|
|
@@ -177,7 +178,10 @@ describe("recordCacheMetric — cache_layer label", () => {
|
|
|
177
178
|
|
|
178
179
|
recordCacheMetric(true, "static");
|
|
179
180
|
|
|
180
|
-
expect(counters[0]?.labels).toEqual({
|
|
181
|
+
expect(counters[0]?.labels).toEqual({
|
|
182
|
+
"deco.cache.status": "HIT",
|
|
183
|
+
"deco.cache.profile": "static",
|
|
184
|
+
});
|
|
181
185
|
});
|
|
182
186
|
|
|
183
187
|
it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
|
|
@@ -187,8 +191,8 @@ describe("recordCacheMetric — cache_layer label", () => {
|
|
|
187
191
|
recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
|
|
188
192
|
recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
|
|
189
193
|
|
|
190
|
-
expect(counters[0]?.labels?.
|
|
191
|
-
expect(counters[1]?.labels?.
|
|
194
|
+
expect(counters[0]?.labels?.["deco.cache.layer"]).toBe("cachedLoader");
|
|
195
|
+
expect(counters[1]?.labels?.["deco.cache.layer"]).toBe("vtex-swr");
|
|
192
196
|
});
|
|
193
197
|
});
|
|
194
198
|
|
|
@@ -209,7 +213,7 @@ describe("recordCommerceMetric (D-11)", () => {
|
|
|
209
213
|
|
|
210
214
|
expect(histograms).toHaveLength(1);
|
|
211
215
|
expect(histograms[0]?.name).toBe(MetricNames.HTTP_CLIENT_REQUEST_DURATION);
|
|
212
|
-
expect(histograms[0]?.value).toBe(123);
|
|
216
|
+
expect(histograms[0]?.value).toBe(0.123); // seconds (semconv)
|
|
213
217
|
expect(histograms[0]?.labels).toMatchObject({
|
|
214
218
|
provider: "vtex",
|
|
215
219
|
operation: "intelligent-search.product_search",
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
|
|
35
35
|
import * as asyncHooks from "node:async_hooks";
|
|
36
36
|
import {
|
|
37
|
+
ATTR_HTTP_REQUEST_METHOD,
|
|
38
|
+
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
|
39
|
+
ATTR_HTTP_ROUTE,
|
|
37
40
|
METRIC_HTTP_CLIENT_REQUEST_DURATION,
|
|
38
41
|
METRIC_HTTP_SERVER_REQUEST_DURATION,
|
|
39
42
|
} from "@opentelemetry/semantic-conventions";
|
|
@@ -257,8 +260,10 @@ export const MetricNames = {
|
|
|
257
260
|
HTTP_SERVER_REQUEST_DURATION: METRIC_HTTP_SERVER_REQUEST_DURATION,
|
|
258
261
|
HTTP_CLIENT_REQUEST_DURATION: METRIC_HTTP_CLIENT_REQUEST_DURATION,
|
|
259
262
|
// Deco extensions — no canonical OTel metric exists for these concepts.
|
|
260
|
-
|
|
261
|
-
|
|
263
|
+
// Single cache counter dimensioned by `deco.cache.status` — follows the OTel
|
|
264
|
+
// semconv pattern (cf. nfs.server.repcache.requests + .status). deco-cx/deco
|
|
265
|
+
// uses the same name so both frameworks aggregate together.
|
|
266
|
+
CACHE_REQUESTS: "deco.cache.requests",
|
|
262
267
|
RESOLVE_DURATION: "deco.cms.resolve.duration",
|
|
263
268
|
LOADER_DURATION: "deco.loader.duration",
|
|
264
269
|
LOADER_ERRORS: "deco.loader.errors",
|
|
@@ -266,11 +271,9 @@ export const MetricNames = {
|
|
|
266
271
|
|
|
267
272
|
/**
|
|
268
273
|
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
269
|
-
* `unit` fields.
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* `http.server.request.duration`, conversion happens in the collector or
|
|
273
|
-
* the query layer, NOT in this framework.
|
|
274
|
+
* `unit` fields. Durations are normalized to **seconds at the source** (OTel
|
|
275
|
+
* semconv), matching the deco-cx/deco framework — NOT converted downstream in
|
|
276
|
+
* the collector. Callers still pass milliseconds; the record helpers divide.
|
|
274
277
|
*
|
|
275
278
|
* Keep keys aligned with `MetricNames` values so a missing entry is a
|
|
276
279
|
* type error at compile time when a new metric ships without metadata.
|
|
@@ -278,27 +281,23 @@ export const MetricNames = {
|
|
|
278
281
|
export const METRIC_METADATA: Record<string, { description: string; unit: string }> = {
|
|
279
282
|
[MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
|
|
280
283
|
description: "Duration of HTTP server requests handled at the Worker entry point.",
|
|
281
|
-
unit: "
|
|
284
|
+
unit: "s",
|
|
282
285
|
},
|
|
283
286
|
[MetricNames.HTTP_CLIENT_REQUEST_DURATION]: {
|
|
284
287
|
description: "Duration of outbound HTTP client requests (commerce, generic fetch).",
|
|
285
|
-
unit: "
|
|
288
|
+
unit: "s",
|
|
286
289
|
},
|
|
287
|
-
[MetricNames.
|
|
288
|
-
description: "Cache lookups
|
|
289
|
-
unit: "{request}",
|
|
290
|
-
},
|
|
291
|
-
[MetricNames.CACHE_MISS]: {
|
|
292
|
-
description: "Cache lookups resulting in MISS or BYPASS.",
|
|
290
|
+
[MetricNames.CACHE_REQUESTS]: {
|
|
291
|
+
description: "Cache lookups, dimensioned by deco.cache.status (hit/stale/miss/bypass).",
|
|
293
292
|
unit: "{request}",
|
|
294
293
|
},
|
|
295
294
|
[MetricNames.RESOLVE_DURATION]: {
|
|
296
295
|
description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
|
|
297
|
-
unit: "
|
|
296
|
+
unit: "s",
|
|
298
297
|
},
|
|
299
298
|
[MetricNames.LOADER_DURATION]: {
|
|
300
299
|
description: "Per-loader execution duration, emitted by cachedLoader.",
|
|
301
|
-
unit: "
|
|
300
|
+
unit: "s",
|
|
302
301
|
},
|
|
303
302
|
[MetricNames.LOADER_ERRORS]: {
|
|
304
303
|
description: "Per-loader error count.",
|
|
@@ -321,10 +320,11 @@ export function statusClassFor(status: number): string {
|
|
|
321
320
|
}
|
|
322
321
|
|
|
323
322
|
/**
|
|
324
|
-
* Optional dimensions stamped on `
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
323
|
+
* Optional dimensions stamped on `http.server.request.duration` (semconv).
|
|
324
|
+
* Request count and error rate are derived from the histogram's `count` and
|
|
325
|
+
* a `http.response.status_code` filter — the old separate `_total` /
|
|
326
|
+
* `_errors_total` counters were removed. All fields are optional — callers
|
|
327
|
+
* pass what they have, the framework fills in the rest from defaults.
|
|
328
328
|
*
|
|
329
329
|
* Cardinality discipline: every field here is bounded. `route_pattern`
|
|
330
330
|
* comes from the TanStack router (a closed set), `outcome` is the CF
|
|
@@ -403,16 +403,19 @@ export function recordRequestMetric(
|
|
|
403
403
|
// Total combinations are bounded — safe for unbounded series on
|
|
404
404
|
// ClickHouse but operators should still avoid grouping by `region`
|
|
405
405
|
// unless explicitly needed.
|
|
406
|
+
// semconv attribute keys for the core HTTP dimensions; deco.* for the
|
|
407
|
+
// proprietary extras. Unified with deco-cx/deco so both frameworks land on
|
|
408
|
+
// the same series/labels in ClickHouse.
|
|
406
409
|
const merged: Labels = {
|
|
407
|
-
method,
|
|
408
|
-
|
|
409
|
-
status,
|
|
410
|
-
status_class: statusClassFor(status),
|
|
410
|
+
[ATTR_HTTP_REQUEST_METHOD]: method,
|
|
411
|
+
[ATTR_HTTP_ROUTE]: labels?.route_pattern ?? normalizePath(path),
|
|
412
|
+
[ATTR_HTTP_RESPONSE_STATUS_CODE]: status,
|
|
413
|
+
"deco.http.status_class": statusClassFor(status),
|
|
411
414
|
};
|
|
412
|
-
if (labels?.outcome) merged.outcome = labels.outcome;
|
|
413
|
-
if (labels?.cache_decision) merged.
|
|
414
|
-
if (labels?.cache_layer) merged.
|
|
415
|
-
if (labels?.region) merged.region = labels.region;
|
|
415
|
+
if (labels?.outcome) merged["deco.http.outcome"] = labels.outcome;
|
|
416
|
+
if (labels?.cache_decision) merged["deco.cache.decision"] = labels.cache_decision;
|
|
417
|
+
if (labels?.cache_layer) merged["deco.cache.layer"] = labels.cache_layer;
|
|
418
|
+
if (labels?.region) merged["deco.http.region"] = labels.region;
|
|
416
419
|
if (labels?.extra) {
|
|
417
420
|
for (const [k, v] of Object.entries(labels.extra)) {
|
|
418
421
|
// Defense-in-depth — refuse to stamp known per-request identifiers
|
|
@@ -430,7 +433,12 @@ export function recordRequestMetric(
|
|
|
430
433
|
// filtering on `http.response.status_code`. Separate `_total` /
|
|
431
434
|
// `_errors_total` counters were removed because they duplicate
|
|
432
435
|
// histogram-derived signals and aren't part of the canonical spec.
|
|
433
|
-
|
|
436
|
+
// Record in seconds (semconv) — caller passes ms.
|
|
437
|
+
m.histogramRecord?.(
|
|
438
|
+
MetricNames.HTTP_SERVER_REQUEST_DURATION,
|
|
439
|
+
durationMs / 1000,
|
|
440
|
+
merged,
|
|
441
|
+
);
|
|
434
442
|
}
|
|
435
443
|
|
|
436
444
|
/**
|
|
@@ -480,28 +488,30 @@ export function recordCacheMetric(
|
|
|
480
488
|
// meter is a no-op (e.g. on tests, or in dev without DECO_METRICS).
|
|
481
489
|
const active = getActiveSpan();
|
|
482
490
|
if (active) {
|
|
483
|
-
if (decision) active.setAttribute?.("deco.cache.
|
|
491
|
+
if (decision) active.setAttribute?.("deco.cache.status", decision);
|
|
484
492
|
if (profile) active.setAttribute?.("deco.cache.profile", profile);
|
|
485
493
|
if (layer) active.setAttribute?.("deco.cache.layer", layer);
|
|
486
494
|
}
|
|
487
495
|
|
|
488
496
|
const m = getState().meter;
|
|
489
497
|
if (!m) return;
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
const labels: Labels = {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
498
|
+
// Single counter dimensioned by deco.cache.status — unified with deco-cx/deco
|
|
499
|
+
// (follows the semconv nfs.server.repcache.requests + .status pattern). status
|
|
500
|
+
// is the decision when known (HIT/STALE-HIT/STALE-ERROR/MISS/BYPASS), else the
|
|
501
|
+
// hit boolean. Same key on span + metric.
|
|
502
|
+
const labels: Labels = {
|
|
503
|
+
"deco.cache.status": decision ?? (hit ? "HIT" : "MISS"),
|
|
504
|
+
};
|
|
505
|
+
if (profile) labels["deco.cache.profile"] = profile;
|
|
506
|
+
if (layer) labels["deco.cache.layer"] = layer;
|
|
507
|
+
m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels);
|
|
499
508
|
}
|
|
500
509
|
|
|
501
510
|
/**
|
|
502
|
-
* Labels for
|
|
503
|
-
*
|
|
504
|
-
*
|
|
511
|
+
* Labels for the outbound commerce sample recorded on
|
|
512
|
+
* `http.client.request.duration`. Owned by the framework so apps-start (and
|
|
513
|
+
* any future provider package) can register operation strings without owning
|
|
514
|
+
* the histogram declaration. Phase 2 (D-11).
|
|
505
515
|
*/
|
|
506
516
|
export interface CommerceMetricLabels {
|
|
507
517
|
/** `vtex`, `shopify`, `wake`, ... — small closed set. */
|
|
@@ -515,10 +525,10 @@ export interface CommerceMetricLabels {
|
|
|
515
525
|
}
|
|
516
526
|
|
|
517
527
|
/**
|
|
518
|
-
* Record a commerce / outbound-fetch duration sample. No-op when no
|
|
519
|
-
*
|
|
520
|
-
* (
|
|
521
|
-
*
|
|
528
|
+
* Record a commerce / outbound-fetch duration sample. No-op when no meter is
|
|
529
|
+
* configured. Emitted on the canonical `http.client.request.duration` metric
|
|
530
|
+
* (semconv) — providers vary by the `provider`/`operation` labels, not by
|
|
531
|
+
* name, so dashboards aggregate cleanly across the fleet.
|
|
522
532
|
*/
|
|
523
533
|
export function recordCommerceMetric(
|
|
524
534
|
durationMs: number,
|
|
@@ -535,7 +545,12 @@ export function recordCommerceMetric(
|
|
|
535
545
|
// Canonical OTel HTTP client metric — outbound commerce calls share the
|
|
536
546
|
// same metric as any other outbound HTTP request; `peer.service` /
|
|
537
547
|
// `commerce.operation` attributes disambiguate consumer queries.
|
|
538
|
-
|
|
548
|
+
// Record in seconds (semconv) — caller passes ms.
|
|
549
|
+
m.histogramRecord?.(
|
|
550
|
+
MetricNames.HTTP_CLIENT_REQUEST_DURATION,
|
|
551
|
+
durationMs / 1000,
|
|
552
|
+
merged,
|
|
553
|
+
);
|
|
539
554
|
}
|
|
540
555
|
|
|
541
556
|
/**
|
|
@@ -551,9 +566,10 @@ export function recordLoaderMetric(
|
|
|
551
566
|
) {
|
|
552
567
|
const m = getState().meter;
|
|
553
568
|
if (!m) return;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
569
|
+
// Record in seconds (semconv) — caller passes ms.
|
|
570
|
+
m.histogramRecord?.(MetricNames.LOADER_DURATION, durationMs / 1000, {
|
|
571
|
+
"deco.loader.name": name,
|
|
572
|
+
"deco.cache.result": cacheStatus,
|
|
557
573
|
});
|
|
558
574
|
}
|
|
559
575
|
|
|
@@ -564,10 +580,17 @@ export function recordLoaderMetric(
|
|
|
564
580
|
export function recordLoaderError(name: string) {
|
|
565
581
|
const m = getState().meter;
|
|
566
582
|
if (!m) return;
|
|
567
|
-
m.counterInc(MetricNames.LOADER_ERRORS, 1, { loader: name });
|
|
583
|
+
m.counterInc(MetricNames.LOADER_ERRORS, 1, { "deco.loader.name": name });
|
|
568
584
|
}
|
|
569
585
|
|
|
570
|
-
|
|
586
|
+
/**
|
|
587
|
+
* Collapse dynamic path segments (ids, product slugs) into placeholders so a
|
|
588
|
+
* path can be used as a bounded metric label. Exported so every metric that
|
|
589
|
+
* labels by route (`http.server.request.duration`, `deco.cms.resolve.duration`)
|
|
590
|
+
* shares the exact same normalization — a raw path is unbounded cardinality
|
|
591
|
+
* (one histogram series per URL) and must never reach a metric attribute.
|
|
592
|
+
*/
|
|
593
|
+
export function normalizePath(path: string): string {
|
|
571
594
|
// Collapse dynamic segments to reduce cardinality
|
|
572
595
|
return path
|
|
573
596
|
.replace(/\/[0-9a-f]{8,}/gi, "/:id")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure User-Agent device detection — no React, no RequestContext, no
|
|
3
|
+
* side effects. Import from HERE (not `./useDevice`) in any module that can
|
|
4
|
+
* end up in a Next.js App Router **route handler** graph.
|
|
5
|
+
*
|
|
6
|
+
* Why this file exists: `useDevice.ts` re-exports the `"use client"`
|
|
7
|
+
* context half (`./useDeviceContext`, which calls `createContext` at module
|
|
8
|
+
* scope) for backward compatibility. That re-export is harmless in Server
|
|
9
|
+
* Components — Next honors the `"use client"` boundary there — but route
|
|
10
|
+
* handlers (App Router route.ts files) have NO client graph: the directive
|
|
11
|
+
* is ignored, the module executes against Next's vendored **RSC** React
|
|
12
|
+
* (`vendored/rsc/react.js`), and that build has no `createContext`, so the
|
|
13
|
+
* whole route module crashes at evaluation time
|
|
14
|
+
* ("...createContext is not a function"). `@decocms/nextjs`'s admin route
|
|
15
|
+
* handlers reach device detection via `cms/sectionMixins.ts`
|
|
16
|
+
* (`detectDevice`) and `sdk/requestContext.ts` (`isMobileUA`) — both now
|
|
17
|
+
* import this leaf so their graphs never touch the client context file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type Device = "mobile" | "tablet" | "desktop";
|
|
21
|
+
|
|
22
|
+
// Android phones include "Mobile" in their UA; Android tablets do not.
|
|
23
|
+
// Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
|
|
24
|
+
// the MOBILE_RE `android.*mobile` branch matches phones.
|
|
25
|
+
export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
|
|
26
|
+
export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Simple mobile-or-not check (mobile + tablet = true).
|
|
30
|
+
* Use this for cache key splitting or any context where you
|
|
31
|
+
* only need a mobile/desktop binary decision.
|
|
32
|
+
*/
|
|
33
|
+
export function isMobileUA(userAgent: string): boolean {
|
|
34
|
+
return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect device type from a User-Agent string.
|
|
39
|
+
* Pure function — no side effects, works anywhere.
|
|
40
|
+
*/
|
|
41
|
+
export function detectDevice(userAgent: string): Device {
|
|
42
|
+
if (TABLET_RE.test(userAgent)) return "tablet";
|
|
43
|
+
if (MOBILE_RE.test(userAgent)) return "mobile";
|
|
44
|
+
return "desktop";
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseSegmentCookie,
|
|
4
|
+
type StoredFlag,
|
|
5
|
+
segmentCacheToken,
|
|
6
|
+
serializeSegmentCookie,
|
|
7
|
+
trafficToPct,
|
|
8
|
+
} from "./flags";
|
|
9
|
+
|
|
10
|
+
describe("trafficToPct", () => {
|
|
11
|
+
it("maps a ratio to a 0-100 integer", () => {
|
|
12
|
+
expect(trafficToPct(0.5)).toBe(50);
|
|
13
|
+
expect(trafficToPct(0.234)).toBe(23);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("clamps out-of-range and non-finite values", () => {
|
|
17
|
+
expect(trafficToPct(0)).toBe(0);
|
|
18
|
+
expect(trafficToPct(-1)).toBe(0);
|
|
19
|
+
expect(trafficToPct(1)).toBe(100);
|
|
20
|
+
expect(trafficToPct(2)).toBe(100);
|
|
21
|
+
expect(trafficToPct(NaN)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("deco_segment round-trip", () => {
|
|
26
|
+
it("round-trips a single active flag", () => {
|
|
27
|
+
const flags: StoredFlag[] = [{ name: "TestHero", value: true, pct: 50 }];
|
|
28
|
+
expect(parseSegmentCookie(serializeSegmentCookie(flags))).toEqual(flags);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("round-trips active + inactive flags (sorted by name)", () => {
|
|
32
|
+
const flags: StoredFlag[] = [
|
|
33
|
+
{ name: "Zeta", value: false, pct: 20 },
|
|
34
|
+
{ name: "Alpha", value: true, pct: 70 },
|
|
35
|
+
];
|
|
36
|
+
expect(parseSegmentCookie(serializeSegmentCookie(flags))).toEqual([
|
|
37
|
+
{ name: "Alpha", value: true, pct: 70 },
|
|
38
|
+
{ name: "Zeta", value: false, pct: 20 },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("produces the classic deco_segment shape that OneDollarStats reads", () => {
|
|
43
|
+
const raw = serializeSegmentCookie([
|
|
44
|
+
{ name: "TestHero", value: true, pct: 50 },
|
|
45
|
+
{ name: "Promo", value: false, pct: 30 },
|
|
46
|
+
]);
|
|
47
|
+
// Mirrors @decocms/apps OneDollarStats readFlagsFromCookie decode.
|
|
48
|
+
const seg = JSON.parse(decodeURIComponent(atob(raw)));
|
|
49
|
+
expect(seg.active).toContain("TestHero");
|
|
50
|
+
expect(seg.inactiveDrawn).toContain("Promo");
|
|
51
|
+
expect(seg.pct).toEqual({ TestHero: 50, Promo: 30 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is raw base64 (no percent-escapes) so atob() works directly", () => {
|
|
55
|
+
const raw = serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]);
|
|
56
|
+
expect(raw).not.toMatch(/%/);
|
|
57
|
+
expect(() => atob(raw)).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("parseSegmentCookie robustness", () => {
|
|
62
|
+
it("returns [] for empty / nullish / malformed input", () => {
|
|
63
|
+
expect(parseSegmentCookie(undefined)).toEqual([]);
|
|
64
|
+
expect(parseSegmentCookie(null)).toEqual([]);
|
|
65
|
+
expect(parseSegmentCookie("")).toEqual([]);
|
|
66
|
+
expect(parseSegmentCookie("not-base64-!!!")).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("marks classic segments without a pct fingerprint as pct: -1", () => {
|
|
70
|
+
const raw = btoa(encodeURIComponent(JSON.stringify({ active: ["Legacy"], inactiveDrawn: [] })));
|
|
71
|
+
expect(parseSegmentCookie(raw)).toEqual([{ name: "Legacy", value: true, pct: -1 }]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("segmentCacheToken", () => {
|
|
76
|
+
it("is empty for no flags so non-A/B pages share a cache entry", () => {
|
|
77
|
+
expect(segmentCacheToken([])).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("includes the pct fingerprint so a traffic change re-buckets", () => {
|
|
81
|
+
const at50 = segmentCacheToken([{ name: "TestHero", value: true, pct: 50 }]);
|
|
82
|
+
const at70 = segmentCacheToken([{ name: "TestHero", value: true, pct: 70 }]);
|
|
83
|
+
expect(at50).not.toBe(at70);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("differs by decision so cohorts split", () => {
|
|
87
|
+
const on = segmentCacheToken([{ name: "TestHero", value: true, pct: 50 }]);
|
|
88
|
+
const off = segmentCacheToken([{ name: "TestHero", value: false, pct: 50 }]);
|
|
89
|
+
expect(on).not.toBe(off);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/sdk/flags.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sticky A/B flag persistence via the `deco_segment` cookie — shared by the
|
|
3
|
+
* resolver (SSR variant selection), the worker entry (cache-key cohort
|
|
4
|
+
* splitting), and consumed by analytics (@decocms/apps OneDollarStats reads
|
|
5
|
+
* the same cookie to enrich pageviews/events).
|
|
6
|
+
*
|
|
7
|
+
* ## Why
|
|
8
|
+
*
|
|
9
|
+
* `website/matchers/random.ts` is a stateless `Math.random() < traffic`, so a
|
|
10
|
+
* visitor's variant used to be re-rolled on every request AND frozen per
|
|
11
|
+
* edge-cache entry (the HTML is cached and the cache key never carried the
|
|
12
|
+
* variant). The variant a first visitor happened to roll then got served to
|
|
13
|
+
* everyone in that device/geo bucket, and analytics never saw the flag.
|
|
14
|
+
*
|
|
15
|
+
* ## Cookie format
|
|
16
|
+
*
|
|
17
|
+
* `deco_segment` is the classic deco segment cookie:
|
|
18
|
+
* `btoa(encodeURIComponent(JSON.stringify({ active, inactiveDrawn, pct })))`.
|
|
19
|
+
* - `active` — flag names the visitor was assigned to (`true` branch).
|
|
20
|
+
* - `inactiveDrawn` — flag names drawn but not matched (`false` branch).
|
|
21
|
+
* - `pct` — `{ name: round(traffic*100) }`, a deco-start extension (analytics
|
|
22
|
+
* ignores unknown fields) used as the re-roll fingerprint: when the operator
|
|
23
|
+
* changes `traffic` and redeploys, the fingerprint no longer matches and the
|
|
24
|
+
* visitor is re-rolled once, then re-sticks — same self-healing scheme as
|
|
25
|
+
* {@link ./abTesting.ts}.
|
|
26
|
+
*
|
|
27
|
+
* The cookie MUST be written un-encoded (raw base64) — OneDollarStats reads it
|
|
28
|
+
* with `atob()` directly. Base64's `+ / =` are all valid cookie-value octets
|
|
29
|
+
* (RFC 6265), so pass `encode: (v) => v` to the cookie setter.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Cookie carrying the visitor's sticky flag decisions (classic deco segment). */
|
|
33
|
+
export const SEGMENT_COOKIE = "deco_segment";
|
|
34
|
+
|
|
35
|
+
/** A recorded flag decision: which named flag, the branch taken, and the
|
|
36
|
+
* `traffic` fingerprint (0–100) it was decided under. */
|
|
37
|
+
export interface StoredFlag {
|
|
38
|
+
/** Matcher block name, e.g. "TestHero". Stable identity for the cohort. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** The branch the visitor was assigned to. */
|
|
41
|
+
value: boolean;
|
|
42
|
+
/** `round(traffic * 100)` at assignment time — the re-roll fingerprint. */
|
|
43
|
+
pct: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Wire shape of the `deco_segment` cookie payload. */
|
|
47
|
+
interface DecoSegment {
|
|
48
|
+
active?: string[];
|
|
49
|
+
inactiveDrawn?: string[];
|
|
50
|
+
/** deco-start extension: per-flag traffic fingerprint. Ignored by analytics. */
|
|
51
|
+
pct?: Record<string, number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Convert a 0–1 traffic ratio to the 0–100 integer fingerprint. */
|
|
55
|
+
export function trafficToPct(traffic: number): number {
|
|
56
|
+
if (!Number.isFinite(traffic) || traffic <= 0) return 0;
|
|
57
|
+
if (traffic >= 1) return 100;
|
|
58
|
+
return Math.round(traffic * 100);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the `deco_segment` cookie value into decisions. The value is raw
|
|
63
|
+
* base64 (no URL-decoding needed). Foreign cookies without the `pct` extension
|
|
64
|
+
* parse with `pct: -1`, which the resolver treats as "always matches" so a
|
|
65
|
+
* classic-deco segment stays sticky instead of re-rolling. Malformed cookies
|
|
66
|
+
* yield `[]`.
|
|
67
|
+
*/
|
|
68
|
+
export function parseSegmentCookie(raw: string | undefined | null): StoredFlag[] {
|
|
69
|
+
if (!raw) return [];
|
|
70
|
+
try {
|
|
71
|
+
const seg = JSON.parse(decodeURIComponent(atob(raw))) as DecoSegment;
|
|
72
|
+
const pct = seg.pct ?? {};
|
|
73
|
+
const out: StoredFlag[] = [];
|
|
74
|
+
for (const name of seg.active ?? []) out.push({ name, value: true, pct: pct[name] ?? -1 });
|
|
75
|
+
for (const name of seg.inactiveDrawn ?? []) {
|
|
76
|
+
out.push({ name, value: false, pct: pct[name] ?? -1 });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Serialize decisions into a raw-base64 `deco_segment` cookie value. */
|
|
85
|
+
export function serializeSegmentCookie(flags: StoredFlag[]): string {
|
|
86
|
+
const active: string[] = [];
|
|
87
|
+
const inactiveDrawn: string[] = [];
|
|
88
|
+
const pct: Record<string, number> = {};
|
|
89
|
+
for (const f of [...flags].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
90
|
+
(f.value ? active : inactiveDrawn).push(f.name);
|
|
91
|
+
pct[f.name] = f.pct;
|
|
92
|
+
}
|
|
93
|
+
const seg: DecoSegment = { active, inactiveDrawn, pct };
|
|
94
|
+
return btoa(encodeURIComponent(JSON.stringify(seg)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stable token for the cache key — includes the `pct` fingerprint so a
|
|
99
|
+
* `traffic` change lands cohorts in fresh buckets. Empty string when there are
|
|
100
|
+
* no flags, so non-A/B pages keep sharing one entry.
|
|
101
|
+
*/
|
|
102
|
+
export function segmentCacheToken(flags: StoredFlag[]): string {
|
|
103
|
+
if (!flags.length) return "";
|
|
104
|
+
return [...flags]
|
|
105
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
106
|
+
.map((f) => `${f.name}:${f.value ? "1" : "0"}:${f.pct}`)
|
|
107
|
+
.join(",");
|
|
108
|
+
}
|
|
@@ -87,7 +87,7 @@ export interface RequestContextData {
|
|
|
87
87
|
// Storage
|
|
88
88
|
// -------------------------------------------------------------------------
|
|
89
89
|
|
|
90
|
-
import { isMobileUA } from "./
|
|
90
|
+
import { isMobileUA } from "./detectDevice";
|
|
91
91
|
|
|
92
92
|
const BOT_RE =
|
|
93
93
|
/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegram|googlebot|yandex|baidu|duckduck/i;
|
package/src/sdk/useDevice.ts
CHANGED
|
@@ -21,52 +21,32 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { detectDevice } from "./detectDevice";
|
|
24
25
|
import { RequestContext } from "./requestContext";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
// The pure UA-parsing half (`Device`, `MOBILE_RE`, `TABLET_RE`,
|
|
28
|
+
// `isMobileUA`, `detectDevice`) lives in `./detectDevice` — a leaf with no
|
|
29
|
+
// React and no RequestContext — and is re-exported below for backward
|
|
30
|
+
// compatibility. Modules that can land in a Next.js route-handler graph
|
|
31
|
+
// (`cms/sectionMixins.ts`, `sdk/requestContext.ts`) MUST import that leaf
|
|
32
|
+
// directly, not this file: this file also re-exports the `"use client"`
|
|
33
|
+
// context half, which crashes route handlers (see ./detectDevice's doc
|
|
34
|
+
// comment for the vendored-RSC-React mechanics).
|
|
35
|
+
export { type Device, detectDevice, isMobileUA, MOBILE_RE, TABLET_RE } from "./detectDevice";
|
|
27
36
|
|
|
28
37
|
// `DeviceContext`, the `useDevice()` hook, and `DeviceProvider` live in
|
|
29
38
|
// `./useDeviceContext` (a `"use client"` file) and are re-exported below for
|
|
30
39
|
// full backward compatibility with this module's public API — see that
|
|
31
|
-
// file's doc comment for why the split exists.
|
|
32
|
-
// *this* file is a plain, hook-free function, safe to import from
|
|
33
|
-
// server-only code (e.g. `cms/sectionMixins.ts`'s `withDevice()`/
|
|
34
|
-
// `withMobile()` loader mixins) without dragging a client-only hook/context
|
|
35
|
-
// boundary into a Server Component's module graph.
|
|
40
|
+
// file's doc comment for why the split exists.
|
|
36
41
|
export { DeviceContext, useDevice, DeviceProvider } from "./useDeviceContext";
|
|
37
42
|
|
|
38
|
-
// Android phones include "Mobile" in their UA; Android tablets do not.
|
|
39
|
-
// Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
|
|
40
|
-
// the MOBILE_RE `android.*mobile` branch matches phones.
|
|
41
|
-
export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
|
|
42
|
-
export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Simple mobile-or-not check (mobile + tablet = true).
|
|
46
|
-
* Use this for cache key splitting or any context where you
|
|
47
|
-
* only need a mobile/desktop binary decision.
|
|
48
|
-
*/
|
|
49
|
-
export function isMobileUA(userAgent: string): boolean {
|
|
50
|
-
return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Detect device type from a User-Agent string.
|
|
55
|
-
* Pure function — no side effects, works anywhere.
|
|
56
|
-
*/
|
|
57
|
-
export function detectDevice(userAgent: string): Device {
|
|
58
|
-
if (TABLET_RE.test(userAgent)) return "tablet";
|
|
59
|
-
if (MOBILE_RE.test(userAgent)) return "mobile";
|
|
60
|
-
return "desktop";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
43
|
/**
|
|
64
44
|
* Resolve the current device from the ambient runtime (RequestContext on the
|
|
65
45
|
* server, `navigator.userAgent` on the client) — no React hooks involved.
|
|
66
46
|
* Used both by the plain `check*()` helpers below and by `useDevice()`/
|
|
67
47
|
* `DeviceProvider` in `./useDeviceContext`.
|
|
68
48
|
*/
|
|
69
|
-
export function resolveDeviceFromRuntime():
|
|
49
|
+
export function resolveDeviceFromRuntime(): ReturnType<typeof detectDevice> {
|
|
70
50
|
// Server: use RequestContext UA header
|
|
71
51
|
if (typeof document === "undefined") {
|
|
72
52
|
const ctx = RequestContext.current;
|