@nextcommerce/campaigns-os 1.33.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/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
// Analytics-parity capture + diff for the campaigns-os QA harness.
|
|
2
|
+
//
|
|
3
|
+
// This is the missing PARITY-QA analytics leg: migration doctrine forbids a
|
|
4
|
+
// cutover without a green parity diff, and the SDK-boundary parity zone is the
|
|
5
|
+
// live dataLayer event stream + GTM/pixel tag-fires. Runtime-injected GTM is
|
|
6
|
+
// invisible to repo scans (a live Meta Purchase tag once lived in a second GTM
|
|
7
|
+
// container injected at runtime; legacy `campaign.js` pushes its real events
|
|
8
|
+
// from a remote script) — so parity can only be asserted from a LIVE capture,
|
|
9
|
+
// baseline (legacy funnel) vs candidate (migrated preview), diffed here.
|
|
10
|
+
//
|
|
11
|
+
// Contract (see the campaignsjs→SDK-0.4.x migration doctrine, PARITY QA phase):
|
|
12
|
+
// - The canonical SDK `dl_*` commerce set is the BLOCKING gate.
|
|
13
|
+
// - Carried-over 3rd-party tags (GTM / Meta / Everflow / GA4 / …) present on
|
|
14
|
+
// the baseline but missing on the candidate are a WARN regression, not an
|
|
15
|
+
// auto-block (some are intentionally dropped — a human confirms).
|
|
16
|
+
// - Purchase `value`/`currency` are compared client-fired vs client-fired
|
|
17
|
+
// (the harness drives the SAME offer through both funnels). They are NEVER
|
|
18
|
+
// diffed against a backend order total: on one-step headless checkouts tax
|
|
19
|
+
// is computed backend at order-creation time and is NOT in the client value,
|
|
20
|
+
// so a value-vs-total diff would false-fail by the tax amount every order.
|
|
21
|
+
// - `transaction_id` differs legitimately (two different orders), so it is
|
|
22
|
+
// checked for PRESENCE/consistency, not equality.
|
|
23
|
+
//
|
|
24
|
+
// This module is pure + page-method-only (no direct playwright import), so the
|
|
25
|
+
// classification/diff logic is unit-testable and the capture attaches to any
|
|
26
|
+
// Playwright page the host harness already owns.
|
|
27
|
+
|
|
28
|
+
import { SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
29
|
+
import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
30
|
+
|
|
31
|
+
// Data layers the SDK and legacy funnels push through. The SDK's GTMAdapter
|
|
32
|
+
// mirrors every `dl_*` event to window.dataLayer AND window.ElevarDataLayer;
|
|
33
|
+
// the SDK's own dedicated layer is window.NextDataLayer. Legacy funnels push
|
|
34
|
+
// to window.dataLayer. We hook all of them and dedup downstream.
|
|
35
|
+
export const HOOKED_DATA_LAYERS = Object.freeze([
|
|
36
|
+
"dataLayer",
|
|
37
|
+
"NextDataLayer",
|
|
38
|
+
"ElevarDataLayer",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Default extra hosts treated as analytics tag-fires beyond the well-known ones.
|
|
42
|
+
// Everflow (affiliate) often fires from a merchant-custom tracking domain, so it
|
|
43
|
+
// is matched by substring and the host list is extensible via args.
|
|
44
|
+
const DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS = Object.freeze(["everflow"]);
|
|
45
|
+
|
|
46
|
+
// Float compare tolerance for money values (cents).
|
|
47
|
+
const VALUE_EPSILON = 0.005;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Capture (browser side) — operates on a passed-in Playwright page.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
// JS installed via page.addInitScript BEFORE any page script runs. It wraps the
|
|
54
|
+
// push() of each hooked data layer so every pushed event is recorded in order,
|
|
55
|
+
// surviving the common `window.dataLayer = window.dataLayer || []` idiom by
|
|
56
|
+
// trapping assignment with a getter/setter and re-wrapping push on replacement.
|
|
57
|
+
export function analyticsInitScript(layers = HOOKED_DATA_LAYERS) {
|
|
58
|
+
return `(() => {
|
|
59
|
+
const LAYERS = ${JSON.stringify(layers)};
|
|
60
|
+
const documentState = {
|
|
61
|
+
token: (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function")
|
|
62
|
+
? globalThis.crypto.randomUUID()
|
|
63
|
+
: String(Date.now()) + ":" + String(Math.random()),
|
|
64
|
+
url: String(location.href || ""),
|
|
65
|
+
};
|
|
66
|
+
const store = (window.__nextQaAnalytics = { events: [], document: documentState });
|
|
67
|
+
const emit = (payload) => {
|
|
68
|
+
try { window.__nextQaAnalyticsEmit && window.__nextQaAnalyticsEmit(payload); } catch (_) {}
|
|
69
|
+
};
|
|
70
|
+
emit({ type: "document", document: documentState });
|
|
71
|
+
const clone = (value) => {
|
|
72
|
+
try { return JSON.parse(JSON.stringify(value)); }
|
|
73
|
+
catch (_) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const k of Object.keys(value || {})) {
|
|
76
|
+
const v = value[k];
|
|
77
|
+
if (typeof v !== "function") out[k] = v;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const record = (layer, args) => {
|
|
83
|
+
for (const arg of args) {
|
|
84
|
+
if (arg && typeof arg === "object") {
|
|
85
|
+
const entry = { layer, data: clone(arg), document: documentState };
|
|
86
|
+
store.events.push(entry);
|
|
87
|
+
// Mirror to the Node side immediately (exposeBinding survives
|
|
88
|
+
// navigations; this in-page store does not). Fire-and-forget.
|
|
89
|
+
emit({ type: "event", entry });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const wrap = (layer, arr) => {
|
|
94
|
+
if (!Array.isArray(arr) || arr.__nextQaWrapped) return arr;
|
|
95
|
+
// Record anything already present at hook time.
|
|
96
|
+
record(layer, arr);
|
|
97
|
+
const nativePush = arr.push.bind(arr);
|
|
98
|
+
arr.push = (...args) => { record(layer, args); return nativePush(...args); };
|
|
99
|
+
Object.defineProperty(arr, "__nextQaWrapped", { value: true, enumerable: false });
|
|
100
|
+
return arr;
|
|
101
|
+
};
|
|
102
|
+
for (const name of LAYERS) {
|
|
103
|
+
let backing = wrap(name, window[name] || []);
|
|
104
|
+
Object.defineProperty(window, name, {
|
|
105
|
+
configurable: true,
|
|
106
|
+
get() { return backing; },
|
|
107
|
+
set(next) { backing = wrap(name, next || []); },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
})();`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Attach capture to a page: install the init script + listen for outbound tag
|
|
114
|
+
// fires to analytics hosts. Returns a handle whose collect() reads the recorded
|
|
115
|
+
// dataLayer events out of the page and normalizes them with the tag fires.
|
|
116
|
+
export async function attachAnalyticsCapture(page, options = {}) {
|
|
117
|
+
const extraHosts = Array.isArray(options.extraHosts) ? options.extraHosts : [];
|
|
118
|
+
const tagFires = [];
|
|
119
|
+
// Node-side event accumulator: a funnel traversal navigates through several
|
|
120
|
+
// documents (checkout → upsell → receipt) and each navigation discards the
|
|
121
|
+
// in-page store, so the init script mirrors every event out through this
|
|
122
|
+
// binding. Best-effort — pages without binding support fall back to the
|
|
123
|
+
// per-document store in collect().
|
|
124
|
+
const accumulatedEvents = [];
|
|
125
|
+
const documentsByToken = new Map();
|
|
126
|
+
let mainDocumentGeneration = 0;
|
|
127
|
+
let auxiliaryDocumentGeneration = 0;
|
|
128
|
+
let activeMainDocument = null;
|
|
129
|
+
let unknownMainDocument = null;
|
|
130
|
+
let unknownAuxiliaryDocument = null;
|
|
131
|
+
const safePageUrl = () => {
|
|
132
|
+
try { return typeof page.url === "function" ? page.url() : null; }
|
|
133
|
+
catch { return null; }
|
|
134
|
+
};
|
|
135
|
+
const isMainFrame = (frame) => {
|
|
136
|
+
if (!frame) return true;
|
|
137
|
+
try {
|
|
138
|
+
const mainFrame = typeof page.mainFrame === "function" ? page.mainFrame() : null;
|
|
139
|
+
if (mainFrame) return frame === mainFrame;
|
|
140
|
+
return typeof frame.parentFrame !== "function" || !frame.parentFrame();
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
// Descriptor lookup/creation is deliberately side-effect-free with respect
|
|
146
|
+
// to the active main document. Late events often arrive from an earlier
|
|
147
|
+
// execution context; observing their known token must never roll the receipt
|
|
148
|
+
// pointer backward.
|
|
149
|
+
const lookupDocument = (raw = {}, { fallbackUrl = null, mainFrame = true, provisional = false } = {}) => {
|
|
150
|
+
const route = redactUrlQuery(raw?.url ?? fallbackUrl);
|
|
151
|
+
const token = typeof raw?.token === "string" && raw.token ? raw.token : null;
|
|
152
|
+
if (token && documentsByToken.has(token)) return documentsByToken.get(token);
|
|
153
|
+
// A request can arrive just before the init-script binding callback. Fold
|
|
154
|
+
// the subsequent token into that provisional generation when its route is
|
|
155
|
+
// identical, rather than inventing two documents for one navigation.
|
|
156
|
+
if (token && mainFrame && activeMainDocument?.provisional === true && activeMainDocument.route === route) {
|
|
157
|
+
activeMainDocument.provisional = false;
|
|
158
|
+
documentsByToken.set(token, activeMainDocument);
|
|
159
|
+
return activeMainDocument;
|
|
160
|
+
}
|
|
161
|
+
const descriptor = {
|
|
162
|
+
route,
|
|
163
|
+
generation: mainFrame ? ++mainDocumentGeneration : ++auxiliaryDocumentGeneration,
|
|
164
|
+
mainFrame,
|
|
165
|
+
provisional: mainFrame && provisional && !token,
|
|
166
|
+
};
|
|
167
|
+
if (token) documentsByToken.set(token, descriptor);
|
|
168
|
+
return descriptor;
|
|
169
|
+
};
|
|
170
|
+
const activateMainDocument = (document) => {
|
|
171
|
+
if (!document?.mainFrame) return activeMainDocument;
|
|
172
|
+
if (!activeMainDocument || document.generation > activeMainDocument.generation) {
|
|
173
|
+
activeMainDocument = document;
|
|
174
|
+
}
|
|
175
|
+
return activeMainDocument;
|
|
176
|
+
};
|
|
177
|
+
const publicDocument = (document) => document
|
|
178
|
+
? { route: document.route || null, generation: document.generation }
|
|
179
|
+
: null;
|
|
180
|
+
const attributeEvent = (entry, { fallbackDocument = null, mainFrame = true, fallbackUrl = null } = {}) => {
|
|
181
|
+
if (!entry || typeof entry !== "object") return null;
|
|
182
|
+
const document = entry.document
|
|
183
|
+
? lookupDocument(entry.document, { mainFrame, fallbackUrl })
|
|
184
|
+
: fallbackDocument
|
|
185
|
+
|| (mainFrame ? activeMainDocument : null)
|
|
186
|
+
|| (mainFrame
|
|
187
|
+
? (unknownMainDocument ||= lookupDocument({}, { mainFrame: true, fallbackUrl }))
|
|
188
|
+
: (unknownAuxiliaryDocument ||= lookupDocument({}, { mainFrame: false, fallbackUrl })));
|
|
189
|
+
return { ...entry, document };
|
|
190
|
+
};
|
|
191
|
+
let bindingAttached = false;
|
|
192
|
+
if (typeof page.exposeBinding === "function") {
|
|
193
|
+
try {
|
|
194
|
+
await page.exposeBinding("__nextQaAnalyticsEmit", (source, payload) => {
|
|
195
|
+
const sourceFrame = source?.frame || null;
|
|
196
|
+
const mainFrame = isMainFrame(sourceFrame);
|
|
197
|
+
let sourceUrl = safePageUrl();
|
|
198
|
+
try {
|
|
199
|
+
if (sourceFrame && typeof sourceFrame.url === "function") sourceUrl = sourceFrame.url();
|
|
200
|
+
} catch { /* use page URL */ }
|
|
201
|
+
if (payload?.type === "document" && payload.document) {
|
|
202
|
+
const document = lookupDocument(payload.document, { fallbackUrl: sourceUrl, mainFrame });
|
|
203
|
+
const sourceRoute = redactUrlQuery(sourceUrl);
|
|
204
|
+
// Only a committed top-level document may advance receipt scope. A
|
|
205
|
+
// stale callback whose payload route no longer matches the main frame
|
|
206
|
+
// is registered for journey attribution but is not activated.
|
|
207
|
+
if (mainFrame && (!sourceRoute || !document.route || sourceRoute === document.route)) {
|
|
208
|
+
activateMainDocument(document);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const entry = payload?.type === "event" ? payload.entry : payload;
|
|
213
|
+
const attributed = attributeEvent(entry, { mainFrame, fallbackUrl: sourceUrl });
|
|
214
|
+
if (attributed) accumulatedEvents.push(attributed);
|
|
215
|
+
});
|
|
216
|
+
bindingAttached = true;
|
|
217
|
+
} catch (_) { /* binding may already exist on a reused page */ }
|
|
218
|
+
}
|
|
219
|
+
await page.addInitScript(analyticsInitScript());
|
|
220
|
+
const onRequest = (request) => {
|
|
221
|
+
const url = typeof request.url === "function" ? request.url() : request.url;
|
|
222
|
+
const fire = classifyTagFire(url, extraHosts);
|
|
223
|
+
if (!fire) return;
|
|
224
|
+
let documentUrl = safePageUrl();
|
|
225
|
+
try {
|
|
226
|
+
let frame = typeof request.frame === "function" ? request.frame() : null;
|
|
227
|
+
while (frame && typeof frame.parentFrame === "function" && frame.parentFrame()) frame = frame.parentFrame();
|
|
228
|
+
if (frame && typeof frame.url === "function") documentUrl = frame.url();
|
|
229
|
+
} catch { /* use the page URL fallback */ }
|
|
230
|
+
const route = redactUrlQuery(documentUrl);
|
|
231
|
+
const document = activeMainDocument?.route === route
|
|
232
|
+
? activeMainDocument
|
|
233
|
+
: lookupDocument({}, { fallbackUrl: documentUrl, mainFrame: true, provisional: true });
|
|
234
|
+
// A tag request can beat the binding notification immediately after a
|
|
235
|
+
// committed navigation. Reconcile that provisional document only when the
|
|
236
|
+
// top-frame route agrees with the page's current route.
|
|
237
|
+
if (route && route === redactUrlQuery(safePageUrl())) activateMainDocument(document);
|
|
238
|
+
tagFires.push({ ...fire, document });
|
|
239
|
+
};
|
|
240
|
+
page.on("request", onRequest);
|
|
241
|
+
const readScopes = async ({ strict = false } = {}) => {
|
|
242
|
+
let snapshot = { events: [], document: null };
|
|
243
|
+
try {
|
|
244
|
+
const raw = await page.evaluate(() => ({
|
|
245
|
+
events: window.__nextQaAnalytics?.events || [],
|
|
246
|
+
document: window.__nextQaAnalytics?.document || { url: String(location.href || "") },
|
|
247
|
+
}));
|
|
248
|
+
snapshot = Array.isArray(raw) ? { events: raw, document: null } : (raw || snapshot);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Most inventory/parity callers retain the historical best-effort
|
|
251
|
+
// behavior. Receipt Purchase proof is different: an unreadable settled
|
|
252
|
+
// terminal must be an explicit capture error, never a fabricated
|
|
253
|
+
// zero-signal capture that could consume the no-Purchase waiver.
|
|
254
|
+
if (strict) throw error;
|
|
255
|
+
}
|
|
256
|
+
const snapshotDocument = lookupDocument(snapshot.document || {}, {
|
|
257
|
+
fallbackUrl: safePageUrl(),
|
|
258
|
+
mainFrame: true,
|
|
259
|
+
});
|
|
260
|
+
activateMainDocument(snapshotDocument);
|
|
261
|
+
const currentDocument = activeMainDocument || snapshotDocument;
|
|
262
|
+
const snapshotEvents = Array.isArray(snapshot.events)
|
|
263
|
+
? snapshot.events.map((entry) => attributeEvent(entry, {
|
|
264
|
+
fallbackDocument: snapshotDocument,
|
|
265
|
+
mainFrame: true,
|
|
266
|
+
fallbackUrl: safePageUrl(),
|
|
267
|
+
})).filter(Boolean)
|
|
268
|
+
: [];
|
|
269
|
+
// Binding events preserve the whole traversal. The current in-page store
|
|
270
|
+
// is appended as a lossless backfill if a binding callback is still queued;
|
|
271
|
+
// normalizeCapture is intentionally duplicate-insensitive.
|
|
272
|
+
const journeyEvents = bindingAttached
|
|
273
|
+
? [...accumulatedEvents, ...snapshotEvents]
|
|
274
|
+
: snapshotEvents;
|
|
275
|
+
const currentEvents = journeyEvents.filter(
|
|
276
|
+
(entry) => entry.document?.mainFrame === true
|
|
277
|
+
&& entry.document.generation === currentDocument.generation,
|
|
278
|
+
);
|
|
279
|
+
const currentTagFires = tagFires.filter(
|
|
280
|
+
(fire) => fire.document?.mainFrame === true
|
|
281
|
+
&& fire.document.generation === currentDocument.generation,
|
|
282
|
+
);
|
|
283
|
+
return {
|
|
284
|
+
journey: normalizeCapture({ events: journeyEvents, tagFires }),
|
|
285
|
+
currentDocument: {
|
|
286
|
+
...normalizeCapture({ events: currentEvents, tagFires: currentTagFires }),
|
|
287
|
+
document: publicDocument(currentDocument),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
tagFires,
|
|
293
|
+
collectScopes: readScopes,
|
|
294
|
+
// The raw push log, one entry per push per hooked layer, in arrival order,
|
|
295
|
+
// each attributed to the document that pushed it. Binding events only: the
|
|
296
|
+
// in-page store is a per-document backfill that would double-count a push
|
|
297
|
+
// the binding already delivered, and a reader of this log is counting.
|
|
298
|
+
// `complete` is false when the page could not expose the binding, in which
|
|
299
|
+
// case the log is empty and must be reported as unmeasured, not as zero.
|
|
300
|
+
rawEvents() {
|
|
301
|
+
return {
|
|
302
|
+
complete: bindingAttached,
|
|
303
|
+
events: accumulatedEvents.map((entry) => ({
|
|
304
|
+
layer: entry.layer,
|
|
305
|
+
data: entry.data,
|
|
306
|
+
document: publicDocument(entry.document),
|
|
307
|
+
})),
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
async collect({ strict = false, scope = "journey" } = {}) {
|
|
311
|
+
const captures = await readScopes({ strict });
|
|
312
|
+
return scope === "current-document" ? captures.currentDocument : captures.journey;
|
|
313
|
+
},
|
|
314
|
+
detach() {
|
|
315
|
+
try { page.off("request", onRequest); } catch (_) { /* page may be closed */ }
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Pure classification + normalization (unit-testable, no browser).
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function hostMatches(host, suffix) {
|
|
325
|
+
return host === suffix || host.endsWith(`.${suffix}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Classify an outbound request URL as an analytics tag fire, extracting the
|
|
329
|
+
// provider kind + container/pixel id + query params (params carry the Meta
|
|
330
|
+
// Purchase `eid`/`event_id` dedup key). Returns null for non-analytics hosts.
|
|
331
|
+
export function classifyTagFire(urlString, extraHostSubstrings = []) {
|
|
332
|
+
let url;
|
|
333
|
+
try { url = new URL(urlString); }
|
|
334
|
+
catch (_) { return null; }
|
|
335
|
+
const host = url.hostname.toLowerCase();
|
|
336
|
+
const path = url.pathname;
|
|
337
|
+
const params = Object.fromEntries(url.searchParams.entries());
|
|
338
|
+
const fire = (kind, id) => ({ kind, id: id ? String(id) : null, host, params });
|
|
339
|
+
const subs = [...DEFAULT_EXTRA_ANALYTICS_HOST_SUBSTRINGS, ...extraHostSubstrings];
|
|
340
|
+
|
|
341
|
+
if (hostMatches(host, "googletagmanager.com")) {
|
|
342
|
+
const id = params.id || null;
|
|
343
|
+
if (id && /^GTM-/i.test(id)) return fire("gtm", id);
|
|
344
|
+
if (id && /^G-/i.test(id)) return fire("ga4", id);
|
|
345
|
+
if (id && /^AW-/i.test(id)) return fire("google_ads", id);
|
|
346
|
+
return fire("gtm", id);
|
|
347
|
+
}
|
|
348
|
+
if (hostMatches(host, "google-analytics.com") || hostMatches(host, "analytics.google.com")) {
|
|
349
|
+
return fire("ga4", params.tid || null);
|
|
350
|
+
}
|
|
351
|
+
if (hostMatches(host, "googleadservices.com") || hostMatches(host, "googlesyndication.com")) {
|
|
352
|
+
return fire("google_ads", params.id || params.tid || null);
|
|
353
|
+
}
|
|
354
|
+
if (hostMatches(host, "facebook.com") || hostMatches(host, "facebook.net")) {
|
|
355
|
+
return fire("meta", params.id || null);
|
|
356
|
+
}
|
|
357
|
+
if (hostMatches(host, "tiktok.com")) {
|
|
358
|
+
return fire("tiktok", params.sdkid || params.tid || null);
|
|
359
|
+
}
|
|
360
|
+
for (const sub of subs) {
|
|
361
|
+
if (sub && host.includes(String(sub).toLowerCase())) {
|
|
362
|
+
return fire(String(sub).toLowerCase().includes("everflow") ? "everflow" : "other", params.id || null);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Pull purchase fields out of a pushed dataLayer event, tolerating both the SDK
|
|
369
|
+
// shape (`event: dl_purchase`, fields under `.ecommerce`) and arbitrary legacy
|
|
370
|
+
// shapes (`event: purchase`/`Purchase`, fields at top level). Returns null when
|
|
371
|
+
// the event is not a purchase.
|
|
372
|
+
export function extractPurchase(event) {
|
|
373
|
+
if (!event || typeof event !== "object") return null;
|
|
374
|
+
// GA4-only and some legacy funnels push the name as `event_name`, not `event`.
|
|
375
|
+
const name = String(event.event || event.event_name || "").toLowerCase();
|
|
376
|
+
if (name !== "purchase" && name !== "dl_purchase") return null;
|
|
377
|
+
return extractPurchaseFields(event);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Field extraction shared by the main-purchase reader and the per-event
|
|
381
|
+
// purchase map (dl_upsell_purchase and friends carry the same GA4 shape).
|
|
382
|
+
function extractPurchaseFields(event) {
|
|
383
|
+
if (!event || typeof event !== "object") return null;
|
|
384
|
+
const ec = (event.ecommerce && typeof event.ecommerce === "object") ? event.ecommerce : {};
|
|
385
|
+
// Elevar-style dl_* shape (the campaign-cart SDK's): value/currency live at
|
|
386
|
+
// ecommerce.purchase.actionField.revenue + ecommerce.currencyCode.
|
|
387
|
+
const actionField = (ec.purchase && typeof ec.purchase === "object"
|
|
388
|
+
&& ec.purchase.actionField && typeof ec.purchase.actionField === "object")
|
|
389
|
+
? ec.purchase.actionField
|
|
390
|
+
: {};
|
|
391
|
+
const value = firstNumber([ec.value, ec.revenue, actionField.revenue, event.value, event.revenue]);
|
|
392
|
+
const currency = firstString([ec.currency, ec.currencyCode, event.currency]);
|
|
393
|
+
const transactionId = firstString([
|
|
394
|
+
ec.transaction_id, ec.order_id, actionField.id,
|
|
395
|
+
event.transaction_id, event.order_id, event.order_number,
|
|
396
|
+
]);
|
|
397
|
+
return { value, currency, transactionId };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Money is a finite number or a plain decimal string ("45.00"). Number() would
|
|
401
|
+
// also read a value out of hex/binary/exponent strings ("0x2d" → 45) and out of
|
|
402
|
+
// non-money types entirely (true → 1, [45] → 45), none of which a real
|
|
403
|
+
// dataLayer purchase payload emits — skip those rather than believe them.
|
|
404
|
+
const DECIMAL_MONEY_PATTERN = /^-?\d+(\.\d+)?$/;
|
|
405
|
+
|
|
406
|
+
function firstNumber(candidates) {
|
|
407
|
+
for (const c of candidates) {
|
|
408
|
+
if (typeof c === "number") {
|
|
409
|
+
if (Number.isFinite(c)) return c;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (typeof c !== "string") continue;
|
|
413
|
+
const trimmed = c.trim();
|
|
414
|
+
if (!trimmed || !DECIMAL_MONEY_PATTERN.test(trimmed)) continue;
|
|
415
|
+
const n = Number(trimmed);
|
|
416
|
+
if (Number.isFinite(n)) return n;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function firstString(candidates) {
|
|
422
|
+
for (const c of candidates) {
|
|
423
|
+
if (c === null || c === undefined) continue;
|
|
424
|
+
const s = String(c).trim();
|
|
425
|
+
if (s) return s;
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Build a normalized capture: distinct event names, the purchase summary, the
|
|
431
|
+
// runtime tag/container/pixel inventory, and the Meta Purchase dedup key.
|
|
432
|
+
export function normalizeCapture({ events = [], tagFires = [] } = {}) {
|
|
433
|
+
const rawEvents = events.map((e) => (e && e.data ? e.data : e)).filter(Boolean);
|
|
434
|
+
const eventNames = [];
|
|
435
|
+
let purchase = null;
|
|
436
|
+
// Per-event purchase extraction: upsell purchases (dl_upsell_purchase) carry
|
|
437
|
+
// their own value/currency distinct from the main dl_purchase; keyed here so
|
|
438
|
+
// scenario checks can target a specific purchase-shaped event.
|
|
439
|
+
const purchasesByEvent = {};
|
|
440
|
+
for (const ev of rawEvents) {
|
|
441
|
+
const name = ev && typeof ev === "object" ? String(ev.event || ev.event_name || "") : "";
|
|
442
|
+
if (name && !eventNames.includes(name)) eventNames.push(name);
|
|
443
|
+
// Keep the first purchase-shaped event, but let a later event carrying an
|
|
444
|
+
// actual value upgrade an earlier value-less one: a malformed first
|
|
445
|
+
// dl_purchase (value == null) must not shadow the real purchase and mask a
|
|
446
|
+
// missing value as "present with no value."
|
|
447
|
+
if (!purchase || purchase.value === null) {
|
|
448
|
+
const p = extractPurchase(ev);
|
|
449
|
+
if (p) purchase = p;
|
|
450
|
+
}
|
|
451
|
+
if (name && /purchase/i.test(name)) {
|
|
452
|
+
const fields = extractPurchaseFields(ev);
|
|
453
|
+
if (fields && (!purchasesByEvent[name] || purchasesByEvent[name].value === null)) {
|
|
454
|
+
purchasesByEvent[name] = fields;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const inventory = { gtm: [], ga4: [], google_ads: [], meta: [], tiktok: [], everflow: [], other: [] };
|
|
460
|
+
let metaPurchaseEventId = null;
|
|
461
|
+
let metaPurchaseFired = false;
|
|
462
|
+
let ga4PurchaseFired = false;
|
|
463
|
+
for (const fire of tagFires) {
|
|
464
|
+
if (!fire || !inventory[fire.kind]) continue;
|
|
465
|
+
if (fire.id && !inventory[fire.kind].includes(fire.id)) inventory[fire.kind].push(fire.id);
|
|
466
|
+
if (fire.kind === "meta") {
|
|
467
|
+
const ev = fire.params?.ev || fire.params?.event;
|
|
468
|
+
if (ev && String(ev).toLowerCase() === "purchase") {
|
|
469
|
+
metaPurchaseFired = true;
|
|
470
|
+
metaPurchaseEventId = metaPurchaseEventId || fire.params?.eid || fire.params?.event_id || null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (fire.kind === "ga4") {
|
|
474
|
+
// GA4 Measurement Protocol fires carry the event name in `en` (one per
|
|
475
|
+
// event, or en=purchase among batched `&en=` params on /g/collect).
|
|
476
|
+
const en = fire.params?.en || fire.params?.event_name;
|
|
477
|
+
if (en && String(en).toLowerCase() === "purchase") ga4PurchaseFired = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
eventNames,
|
|
483
|
+
purchase: purchase ? { present: true, ...purchase } : { present: false },
|
|
484
|
+
purchasesByEvent,
|
|
485
|
+
inventory,
|
|
486
|
+
metaPurchaseEventId,
|
|
487
|
+
// Purchase can fire from any of three sources (dataLayer event, Meta pixel,
|
|
488
|
+
// GA4) — campaigns that block the SDK event and fire Meta/GA4 manually still
|
|
489
|
+
// "purchased." effectivePurchase() reads these so correctness/parity don't
|
|
490
|
+
// false-fail a deliberate `blockedEvents` setup.
|
|
491
|
+
purchaseSignals: {
|
|
492
|
+
dataLayer: !!purchase,
|
|
493
|
+
meta: metaPurchaseFired,
|
|
494
|
+
ga4: ga4PurchaseFired,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The effective purchase across all fire sources — the source-of-truth answer
|
|
500
|
+
// to "did this funnel record a purchase," independent of whether the SDK
|
|
501
|
+
// dataLayer event was used or blocked-and-fired-manually via a pixel.
|
|
502
|
+
export function effectivePurchase(capture = {}) {
|
|
503
|
+
const p = capture.purchase || { present: false };
|
|
504
|
+
const s = capture.purchaseSignals || {};
|
|
505
|
+
const via = p.present ? "datalayer" : s.meta ? "meta" : s.ga4 ? "ga4" : null;
|
|
506
|
+
return {
|
|
507
|
+
fired: !!(p.present || s.meta || s.ga4),
|
|
508
|
+
via,
|
|
509
|
+
// value/currency/txn are only knowable from the dataLayer event; a
|
|
510
|
+
// pixel-only purchase fires without them in our capture.
|
|
511
|
+
value: p.present ? p.value : null,
|
|
512
|
+
currency: p.present ? p.currency : null,
|
|
513
|
+
transactionId: p.present ? p.transactionId : null,
|
|
514
|
+
metaEventId: capture.metaPurchaseEventId || null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Diff → parity assertions (pure; this is the contract enforcement).
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
// Packet 01 / INV-3(c): when the differ knows the candidate URL that was
|
|
523
|
+
// captured, every emitted assertion — pass and fail alike — carries it,
|
|
524
|
+
// top-level and in evidence, so a blocked verdict always names what was
|
|
525
|
+
// measured (only the sibling :capture assertion used to carry it).
|
|
526
|
+
function parityAssertion({ id, status, severity, expected, actual, evidence, url }) {
|
|
527
|
+
return {
|
|
528
|
+
id,
|
|
529
|
+
family: "analytics-parity",
|
|
530
|
+
page: "analytics",
|
|
531
|
+
status,
|
|
532
|
+
...(url ? { url } : {}),
|
|
533
|
+
...(severity ? { severity } : {}),
|
|
534
|
+
expected,
|
|
535
|
+
actual,
|
|
536
|
+
...(evidence || url ? { evidence: { ...(url ? { url } : {}), ...(evidence || {}) } } : {}),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function valuesEqual(a, b) {
|
|
541
|
+
if (a === null || b === null) return false;
|
|
542
|
+
return Math.abs(Number(a) - Number(b)) <= VALUE_EPSILON;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Diff a baseline (legacy) capture against a candidate (migrated) capture and
|
|
546
|
+
// emit parity assertions. Blockers enforce the canonical commerce gate; WARNs
|
|
547
|
+
// flag carried-over-tag regressions for human review.
|
|
548
|
+
// `options.url` is the candidate URL that was captured (the resolved capture
|
|
549
|
+
// target) — stamped on every emitted assertion, pass and fail alike.
|
|
550
|
+
export function diffAnalyticsParity(baseline, candidate, options = {}) {
|
|
551
|
+
const assertions = [];
|
|
552
|
+
const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
|
|
553
|
+
const emit = (fields) => parityAssertion({ url: auditedUrl, ...fields });
|
|
554
|
+
const b = baseline || {};
|
|
555
|
+
const c = candidate || {};
|
|
556
|
+
const bp = b.purchase || { present: false };
|
|
557
|
+
const cp = c.purchase || { present: false };
|
|
558
|
+
// Source-aware: a campaign may block dl_purchase and fire Purchase manually
|
|
559
|
+
// via the Meta/GA4 pixel. The effective purchase counts ALL sources, so a
|
|
560
|
+
// deliberate `blockedEvents` setup doesn't false-fail.
|
|
561
|
+
const cEff = effectivePurchase(c);
|
|
562
|
+
|
|
563
|
+
// 1. Purchase present on candidate — the highest-value blocking check.
|
|
564
|
+
assertions.push(emit({
|
|
565
|
+
id: "analytics-parity:purchase-present",
|
|
566
|
+
status: cEff.fired ? STATUS.PASS : STATUS.FAIL,
|
|
567
|
+
severity: SEVERITY.BLOCKER,
|
|
568
|
+
expected: "candidate fires a Purchase (dl_purchase, or Meta/GA4 pixel if the SDK event is blocked)",
|
|
569
|
+
actual: cEff.fired ? `purchase fired via ${cEff.via}` : "no purchase fire captured on candidate (dataLayer, Meta, or GA4)",
|
|
570
|
+
evidence: { via: cEff.via, candidate_events: c.eventNames || [], candidate_signals: c.purchaseSignals || {}, baseline_purchase: bp },
|
|
571
|
+
}));
|
|
572
|
+
|
|
573
|
+
if (cEff.fired) {
|
|
574
|
+
// 2. Value parity — same offer driven through both funnels, so client-fired
|
|
575
|
+
// values must match. Compared client-vs-client, never vs a backend total
|
|
576
|
+
// (tax is excluded from the client value on headless checkouts). Only
|
|
577
|
+
// knowable when BOTH fired the dataLayer event (a pixel-only purchase
|
|
578
|
+
// carries no client value in our capture) → else manual review.
|
|
579
|
+
if (bp.present && bp.value !== null && bp.value !== undefined && cp.present && cp.value !== null && cp.value !== undefined) {
|
|
580
|
+
const ok = valuesEqual(bp.value, cp.value);
|
|
581
|
+
assertions.push(emit({
|
|
582
|
+
id: "analytics-parity:purchase-value",
|
|
583
|
+
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
584
|
+
severity: SEVERITY.BLOCKER,
|
|
585
|
+
expected: `client-fired purchase value == baseline (${bp.value})`,
|
|
586
|
+
actual: cp.value,
|
|
587
|
+
evidence: { baseline_value: bp.value, candidate_value: cp.value, note: "client-fired; excludes backend-calculated tax" },
|
|
588
|
+
}));
|
|
589
|
+
} else if (!cp.present) {
|
|
590
|
+
// Candidate fired Purchase pixel-only (e.g. dl_purchase blocked) — the
|
|
591
|
+
// client value isn't in our capture, so value parity can't be asserted.
|
|
592
|
+
assertions.push(emit({
|
|
593
|
+
id: "analytics-parity:purchase-value",
|
|
594
|
+
status: STATUS.MANUAL_REVIEW,
|
|
595
|
+
severity: SEVERITY.WARN,
|
|
596
|
+
expected: "client-fired value to compare",
|
|
597
|
+
actual: `candidate purchase fired via ${cEff.via} (pixel-only) — no client value captured`,
|
|
598
|
+
evidence: { via: cEff.via },
|
|
599
|
+
}));
|
|
600
|
+
} else {
|
|
601
|
+
assertions.push(emit({
|
|
602
|
+
id: "analytics-parity:purchase-value",
|
|
603
|
+
status: STATUS.MANUAL_REVIEW,
|
|
604
|
+
severity: SEVERITY.WARN,
|
|
605
|
+
expected: "baseline purchase value to compare against",
|
|
606
|
+
actual: `no baseline value captured; candidate fired ${cp.value}`,
|
|
607
|
+
evidence: { baseline_value: bp.value ?? null, candidate_value: cp.value },
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 3. Currency parity (dataLayer-only; skipped for pixel-only purchases).
|
|
612
|
+
if (cp.present && bp.present && bp.currency) {
|
|
613
|
+
const ok = bp.currency === cp.currency;
|
|
614
|
+
assertions.push(emit({
|
|
615
|
+
id: "analytics-parity:purchase-currency",
|
|
616
|
+
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
617
|
+
severity: SEVERITY.BLOCKER,
|
|
618
|
+
expected: `purchase currency == baseline (${bp.currency})`,
|
|
619
|
+
actual: cp.currency,
|
|
620
|
+
evidence: { baseline_currency: bp.currency, candidate_currency: cp.currency },
|
|
621
|
+
}));
|
|
622
|
+
} else {
|
|
623
|
+
assertions.push(emit({
|
|
624
|
+
id: "analytics-parity:purchase-currency",
|
|
625
|
+
status: STATUS.MANUAL_REVIEW,
|
|
626
|
+
severity: SEVERITY.WARN,
|
|
627
|
+
expected: "baseline purchase currency to compare against",
|
|
628
|
+
actual: `no baseline currency captured; candidate fired ${cp.currency ?? "none"}`,
|
|
629
|
+
evidence: { baseline_currency: null, candidate_currency: cp.currency ?? null },
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 4. transaction_id PRESENCE (not equality — different orders have different
|
|
634
|
+
// ids). Only checkable when the dataLayer event fired.
|
|
635
|
+
if (cp.present) {
|
|
636
|
+
assertions.push(emit({
|
|
637
|
+
id: "analytics-parity:purchase-transaction-id",
|
|
638
|
+
status: cp.transactionId ? STATUS.PASS : STATUS.FAIL,
|
|
639
|
+
severity: SEVERITY.BLOCKER,
|
|
640
|
+
expected: "candidate purchase carries a non-empty transaction_id",
|
|
641
|
+
actual: cp.transactionId || "missing",
|
|
642
|
+
evidence: { candidate_transaction_id: cp.transactionId || null },
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 5. Meta CAPI dedup — candidate's Meta Purchase fire carries an eventID,
|
|
647
|
+
// consistent with the order id so client + server events dedup.
|
|
648
|
+
const baselineHasMeta = (b.inventory?.meta || []).length > 0;
|
|
649
|
+
const candidateHasMeta = (c.inventory?.meta || []).length > 0;
|
|
650
|
+
if (baselineHasMeta || candidateHasMeta) {
|
|
651
|
+
const eid = c.metaPurchaseEventId;
|
|
652
|
+
const consistent = eid && cp.transactionId && String(eid) === String(cp.transactionId);
|
|
653
|
+
assertions.push(emit({
|
|
654
|
+
id: "analytics-parity:capi-dedup",
|
|
655
|
+
status: eid ? (consistent ? STATUS.PASS : STATUS.WARN) : STATUS.FAIL,
|
|
656
|
+
severity: eid && !consistent ? SEVERITY.WARN : SEVERITY.BLOCKER,
|
|
657
|
+
expected: "Meta Purchase fire carries an eventID keyed on the order id (CAPI dedup)",
|
|
658
|
+
actual: eid ? `eventID=${eid}` : "no eventID on candidate Meta Purchase",
|
|
659
|
+
evidence: { candidate_event_id: eid || null, candidate_transaction_id: cp.transactionId || null },
|
|
660
|
+
}));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 6. Carried-over tag regression — any baseline container/pixel absent on the
|
|
665
|
+
// candidate is a WARN (likely attribution regression; human confirms drop).
|
|
666
|
+
const kinds = ["gtm", "ga4", "google_ads", "meta", "tiktok", "everflow", "other"];
|
|
667
|
+
for (const kind of kinds) {
|
|
668
|
+
const baselineIds = (b.inventory?.[kind]) || [];
|
|
669
|
+
const candidateIds = new Set((c.inventory?.[kind]) || []);
|
|
670
|
+
for (const id of baselineIds) {
|
|
671
|
+
if (id === null) {
|
|
672
|
+
// Baseline fired this kind with an unknown/null id — can't reliably match
|
|
673
|
+
// against candidate ids, so a human must confirm carryover.
|
|
674
|
+
assertions.push(emit({
|
|
675
|
+
id: `analytics-parity:carryover:${kind}:present`,
|
|
676
|
+
status: STATUS.MANUAL_REVIEW,
|
|
677
|
+
severity: SEVERITY.WARN,
|
|
678
|
+
expected: `carried-over ${kind} tag (unidentified baseline id) verified on candidate`,
|
|
679
|
+
actual: candidateIds.size > 0
|
|
680
|
+
? `candidate has ${candidateIds.size} ${kind} id(s) but baseline id was null — verify manually`
|
|
681
|
+
: `no ${kind} tag on candidate`,
|
|
682
|
+
evidence: { kind, id: null, baseline: baselineIds, candidate: [...candidateIds] },
|
|
683
|
+
}));
|
|
684
|
+
} else {
|
|
685
|
+
const present = candidateIds.has(id);
|
|
686
|
+
assertions.push(emit({
|
|
687
|
+
id: `analytics-parity:carryover:${kind}:${id}`,
|
|
688
|
+
status: present ? STATUS.PASS : STATUS.WARN,
|
|
689
|
+
severity: present ? undefined : SEVERITY.WARN,
|
|
690
|
+
expected: `carried-over ${kind} tag ${id} still fires on candidate`,
|
|
691
|
+
actual: present ? "present" : "absent on candidate",
|
|
692
|
+
evidence: { kind, id, baseline: baselineIds, candidate: [...candidateIds] },
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return assertions;
|
|
699
|
+
}
|