@web-my-money/studio-consumer 1.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/README.md +19 -0
- package/package.json +27 -0
- package/src/analytics/bucketing.ts +101 -0
- package/src/analytics/collect-handler.ts +126 -0
- package/src/analytics/collector.ts +537 -0
- package/src/analytics/components.tsx +382 -0
- package/src/analytics/index.ts +37 -0
- package/src/analytics/proxy.ts +87 -0
- package/src/analytics/use-form-analytics.ts +256 -0
- package/src/attribution/crm.ts +20 -0
- package/src/attribution/index.ts +14 -0
- package/src/attribution/store.ts +225 -0
- package/src/content/dict-overrides.ts +101 -0
- package/src/content/headers.d.ts +11 -0
- package/src/content/headers.mjs +40 -0
- package/src/content/index.ts +27 -0
- package/src/content/manifest-handler.ts +34 -0
- package/src/content/payload.ts +414 -0
- package/src/content/preview.tsx +550 -0
- package/src/content/revalidate-handler.ts +103 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @web-my-money/studio-consumer
|
|
2
|
+
|
|
3
|
+
Consumer-side integration for WMM Studio: content, analytics, attribution.
|
|
4
|
+
|
|
5
|
+
This is a source-only workspace package — no build step, no `dist`. Consumers
|
|
6
|
+
are Next.js apps that already transpile workspace and scoped packages, so
|
|
7
|
+
publishing compiled output here would only add a compile-and-publish cycle
|
|
8
|
+
for no gain.
|
|
9
|
+
|
|
10
|
+
## Entry points
|
|
11
|
+
|
|
12
|
+
- `@web-my-money/studio-consumer/analytics`
|
|
13
|
+
- `@web-my-money/studio-consumer/content`
|
|
14
|
+
- `@web-my-money/studio-consumer/attribution`
|
|
15
|
+
|
|
16
|
+
This package is currently a skeleton: each entry point exports only a
|
|
17
|
+
`PACKAGE_VERSION` constant, used to prove at runtime that a consumer is
|
|
18
|
+
resolving this package rather than a stale copy. Content, analytics, and
|
|
19
|
+
attribution logic land in later tasks.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@web-my-money/studio-consumer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Web-My-Money/wmm-studio.git",
|
|
9
|
+
"directory": "packages/studio-consumer"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./analytics": "./src/analytics/index.ts",
|
|
18
|
+
"./content": "./src/content/index.ts",
|
|
19
|
+
"./attribution": "./src/attribution/index.ts",
|
|
20
|
+
"./headers": "./src/content/headers.mjs"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"next": ">=16",
|
|
24
|
+
"react": ">=19"
|
|
25
|
+
},
|
|
26
|
+
"files": ["src"]
|
|
27
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B assignment, as pure arithmetic (wmm-studio docs/analytics/06-ab-testing.md §2).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `lib/experiments.ts` — which keeps its `server-only` marker and
|
|
5
|
+
* re-exports all of this — because the analytics collector runs in the browser
|
|
6
|
+
* and has to stamp the visitor's arm on every event. Without that stamp
|
|
7
|
+
* `funnel_events.variant` is empty and the comparison in Studio has nothing to
|
|
8
|
+
* compare, which is exactly the state this file was written to fix.
|
|
9
|
+
*
|
|
10
|
+
* ## The client may RESOLVE an arm. It must never RENDER one.
|
|
11
|
+
*
|
|
12
|
+
* Reading the arm to label an event is safe: the label describes a decision the
|
|
13
|
+
* server already made. Choosing copy from it is not — a client-side choice
|
|
14
|
+
* flashes the wrong variant, and the flash itself changes behaviour, which is
|
|
15
|
+
* the measurement artefact this whole design exists to avoid. Rendering stays
|
|
16
|
+
* behind `lib/content.ts`, which is server-only and stays that way.
|
|
17
|
+
*
|
|
18
|
+
* Four rules, each because the alternative quietly corrupts the result:
|
|
19
|
+
*
|
|
20
|
+
* - **Sticky per visitor.** A visitor who sees both variants poisons both
|
|
21
|
+
* numbers, so the bucket is a pure function of their id and the experiment
|
|
22
|
+
* key — no randomness at request time, and nothing to store.
|
|
23
|
+
* - **Same answer on both sides.** Server and client run this same function
|
|
24
|
+
* over the same cookie and the same published experiment list, so the arm
|
|
25
|
+
* that was rendered and the arm that gets recorded cannot disagree.
|
|
26
|
+
* - **Default is control.** No experiment, unknown path, missing cookie, bad
|
|
27
|
+
* config — all serve control. A variant is never served by accident.
|
|
28
|
+
* - **One experiment per path**, enforced by a unique index in Studio's schema.
|
|
29
|
+
* Overlapping experiments make both results meaningless.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type RunningExperiment = {
|
|
33
|
+
key: string;
|
|
34
|
+
targetPath: string;
|
|
35
|
+
/** Percent of visitors sent to variant B, 1-99. */
|
|
36
|
+
split: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const CONTROL = "control";
|
|
40
|
+
export const VARIANT = "b";
|
|
41
|
+
/** The cookie the proxy sets and the collector reads. */
|
|
42
|
+
export const VISITOR_COOKIE = "wmm_vid";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* FNV-1a, 32-bit. A hash, not a PRNG: the same input must bucket the same way on
|
|
46
|
+
* every request, on every server, forever — including after a deploy. Chosen for
|
|
47
|
+
* being short enough to read and verify by hand; the distribution only has to be
|
|
48
|
+
* even, not cryptographic, and nothing here is a secret.
|
|
49
|
+
*/
|
|
50
|
+
function hash(input: string): number {
|
|
51
|
+
let h = 0x811c9dc5;
|
|
52
|
+
for (let i = 0; i < input.length; i++) {
|
|
53
|
+
h ^= input.charCodeAt(i);
|
|
54
|
+
// `Math.imul` keeps this a 32-bit multiply; `h * 16777619` loses precision
|
|
55
|
+
// past 2^53 and stops being the same function across inputs.
|
|
56
|
+
h = Math.imul(h, 0x01000193);
|
|
57
|
+
}
|
|
58
|
+
// `>>> 0` because the XOR above makes h signed.
|
|
59
|
+
return h >>> 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A stable 0-99 bucket for a visitor within one experiment. */
|
|
63
|
+
export function bucketFor(visitorId: string, experimentKey: string): number {
|
|
64
|
+
return hash(`${experimentKey}:${visitorId}`) % 100;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The variant to render for this visitor on this path.
|
|
69
|
+
*
|
|
70
|
+
* `experiments` comes from the published content payload, so this costs no
|
|
71
|
+
* database call. Returns `control` for anything unclear.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveVariant(
|
|
74
|
+
path: string,
|
|
75
|
+
visitorId: string | null | undefined,
|
|
76
|
+
experiments: RunningExperiment[] | null | undefined,
|
|
77
|
+
): { variant: string; experimentKey: string | null } {
|
|
78
|
+
if (!visitorId || !experiments?.length) {
|
|
79
|
+
return { variant: CONTROL, experimentKey: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const experiment = experiments.find((e) => e.targetPath === path);
|
|
83
|
+
if (!experiment) return { variant: CONTROL, experimentKey: null };
|
|
84
|
+
|
|
85
|
+
// Guard the config too: a split outside 1-99 is not an experiment, and
|
|
86
|
+
// trusting it would send everyone one way while still labelling the data as a
|
|
87
|
+
// test.
|
|
88
|
+
if (
|
|
89
|
+
!Number.isFinite(experiment.split) ||
|
|
90
|
+
experiment.split < 1 ||
|
|
91
|
+
experiment.split > 99
|
|
92
|
+
) {
|
|
93
|
+
return { variant: CONTROL, experimentKey: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const bucket = bucketFor(visitorId, experiment.key);
|
|
97
|
+
return {
|
|
98
|
+
variant: bucket < experiment.split ? VARIANT : CONTROL,
|
|
99
|
+
experimentKey: experiment.key,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* First-party analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.1).
|
|
5
|
+
*
|
|
6
|
+
* The browser posts same-origin here; this route stamps the site key and
|
|
7
|
+
* forwards the batch server-side to Studio's ingest endpoint with the bearer
|
|
8
|
+
* token. Two reasons it exists rather than the browser calling Studio directly:
|
|
9
|
+
* the token stays server-side, and a same-origin request is not what ad-blockers
|
|
10
|
+
* and third-party cookie policy are looking for.
|
|
11
|
+
*
|
|
12
|
+
* It ALWAYS returns 204, whatever happens. An analytics outage must never
|
|
13
|
+
* surface an error on a marketing page or interfere with a form — so failures
|
|
14
|
+
* are logged here and are invisible to the visitor.
|
|
15
|
+
*
|
|
16
|
+
* Inert with no `FUNNEL_INGEST_URL`: nothing is forwarded and the site behaves
|
|
17
|
+
* exactly as it did before this route existed. That is the correct state before
|
|
18
|
+
* rollout, which is what lets this merge ahead of being switched on.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Mirrors the ingest endpoint's cap. Refused here so we don't forward a 413. */
|
|
22
|
+
const MAX_BATCH = 50;
|
|
23
|
+
|
|
24
|
+
const noContent = () => new NextResponse(null, { status: 204 });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where events are forwarded.
|
|
28
|
+
*
|
|
29
|
+
* Defaulted rather than required, so onboarding a new consumer site is ONE
|
|
30
|
+
* secret instead of two settings — and so a typo in a per-project env var cannot
|
|
31
|
+
* silently send a client's events nowhere. Override it only for a non-production
|
|
32
|
+
* Studio.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_INGEST_URL = "https://studio.webmymoney.com/api/ingest/funnel-events";
|
|
35
|
+
|
|
36
|
+
/** The shape every per-site ingest key has. See wmm-studio `lib/analytics/ingest-auth.ts`. */
|
|
37
|
+
const KEY_PREFIX = "wmmk_";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Warn once, not per request.
|
|
41
|
+
*
|
|
42
|
+
* A key without the `wmmk_` prefix is the legacy GLOBAL token: Studio still
|
|
43
|
+
* accepts it, but it derives nothing from it, so events are filed under whatever
|
|
44
|
+
* `site_key` the payload claims — and the gate that deletes that path is next.
|
|
45
|
+
* The failure mode this catches is the whole point: everything stays green (204s,
|
|
46
|
+
* rows arriving, checklist passing) right up until the legacy path is removed and
|
|
47
|
+
* the site's analytics go dark. This is the collector's hot path, so the flag is
|
|
48
|
+
* module-level and the line is printed once per process, not once per batch.
|
|
49
|
+
*/
|
|
50
|
+
let warnedAboutKeyShape = false;
|
|
51
|
+
|
|
52
|
+
function warnOnceAboutKeyShape(key: string): void {
|
|
53
|
+
if (warnedAboutKeyShape || key.startsWith(KEY_PREFIX)) return;
|
|
54
|
+
warnedAboutKeyShape = true;
|
|
55
|
+
console.warn(
|
|
56
|
+
`[analytics] FUNNEL_INGEST_TOKEN does not look like a per-site ingest key ` +
|
|
57
|
+
`(expected a "${KEY_PREFIX}" prefix). This is the legacy global token: Studio ` +
|
|
58
|
+
`will file these events under the site key in the payload rather than deriving ` +
|
|
59
|
+
`it from the key, and that path is being removed. Mint this site's own key at ` +
|
|
60
|
+
`Studio -> /sites/new.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Test-only: forget that the one-time warning has already been printed. */
|
|
65
|
+
export function resetIngestKeyShapeWarning(): void {
|
|
66
|
+
warnedAboutKeyShape = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createCollectHandler(config: {
|
|
70
|
+
siteKey: string;
|
|
71
|
+
ingestUrl: string;
|
|
72
|
+
ingestKey: string;
|
|
73
|
+
}): (request: Request) => Promise<Response> {
|
|
74
|
+
return async function POST(request: Request): Promise<Response> {
|
|
75
|
+
const url = config.ingestUrl || DEFAULT_INGEST_URL;
|
|
76
|
+
const token = config.ingestKey;
|
|
77
|
+
// The token is the only thing that genuinely has to be configured per site.
|
|
78
|
+
if (!token) return noContent();
|
|
79
|
+
warnOnceAboutKeyShape(token);
|
|
80
|
+
|
|
81
|
+
let events: unknown[];
|
|
82
|
+
try {
|
|
83
|
+
const body = (await request.json()) as { events?: unknown };
|
|
84
|
+
if (!Array.isArray(body.events)) return noContent();
|
|
85
|
+
events = body.events;
|
|
86
|
+
} catch {
|
|
87
|
+
return noContent();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (events.length === 0) return noContent();
|
|
91
|
+
if (events.length > MAX_BATCH) {
|
|
92
|
+
console.warn(`[analytics] refusing oversized batch: ${events.length}`);
|
|
93
|
+
return noContent();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The site key is stamped here, never trusted from the client: a browser must
|
|
97
|
+
// not be able to write events into another site's namespace by editing a
|
|
98
|
+
// payload. Anything the client sent under that key is overwritten.
|
|
99
|
+
const stamped = events.map((e) => ({
|
|
100
|
+
...(e && typeof e === "object" ? e : {}),
|
|
101
|
+
siteKey: config.siteKey,
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
Authorization: `Bearer ${token}`,
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({ events: stamped }),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
// One line, no payload echo: a rejected batch is a code or config problem
|
|
115
|
+
// and the status is what identifies which.
|
|
116
|
+
console.warn(`[analytics] ingest rejected batch: ${res.status}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn(
|
|
120
|
+
`[analytics] ingest unreachable: ${err instanceof Error ? err.message : "unknown"}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return noContent();
|
|
125
|
+
};
|
|
126
|
+
}
|