feat(blocks): host the Deco Analytics client's source, and run it natively on v8

TH
11h ago
+4332-1

Why

The Deco Analytics tag (clicks, page views, heatmap, commerce, errors, vitals) still has to reach v7 sites, checkout and sites outside deco through the standalone /_dq/a.js. If v8 got its own copy of the collection logic, we would maintain the same code in two repos. As agreed with @tlgimenes, the code moves to blocks and the standalone script becomes a build artifact, the way a package's source lives in the repo and its published version lives on npm.

This is the first of three steps:

  1. This PR: the tag's source lives in blocks. Nothing consumes it yet, so it changes nothing for any site.
  2. decocms/analytics: the collector's build bundles from @decocms/blocks (pinned version) instead of its own runtime/, which is deleted. Its CI checks that the bundle hashes are unchanged, and its e2e (browser to Worker to ClickHouse) runs before deploy.
  3. Docs: EXECUTION_PLAN.md and DECO_ANALYTICS.md in decocms/context.

What stays where

Where
Tag source (runtime/, shared/contract.ts, shared/anchor.ts)blocks, packages/blocks/src/v8/analytics/
Standalone build, serving /_dq/a.js, purgedecocms/analytics, the collector Worker
visitor_id hashing, ingest, ClickHouse, dashboarddecocms/analytics

AnalyticsScript keeps rendering <script async src="/_dq/a.js"> and does not inline the tag. The Worker is in the delivery path so that a single purge fixes the whole fleet in seconds. If each site bundled the tag at build time, every fix would need a dependency bump in N repos.

Nothing changes for existing sites

The move is byte-for-byte. Built with the esbuild version the collector uses (0.23.1), with the collector's flags, all ten bundles hash identically to what production serves today. That holds both from this folder and from the npm pack tarball. The collector's cache key (RUNTIME_VERSION) is a hash of those same bytes, so no browser is served a different script.

sha256 of each bundle (identical before and after)
346c3817…   8545 core
20b69be6…    502 commerce
3f79dd70…   3390 errors
7fc8c4dc…   2556 engagement
ffed1ce2…   1139 vitals
13856988…   3669 deco
93d41598…   1190 consent
76d9a2a0…   1775 click
e92c1225…   5799 heatmap
b6d057d1…  16939 view

Three things were needed to keep it that way:

  • analytics/tsconfig.json sets strict and ships with src. Without it, esbuild drops the "use strict"; every production bundle opens with, so each bundle changes by 13 bytes and runs in sloppy mode.
  • Biome formats the folder but neither lints it nor organizes its imports. Organizing imports reordered the core bundle (same length, different hash). The lint fixes (== null, assignments in expressions, the export {}) rewrite code that was tuned for size. Bringing it to blocks' lint rules can be a separate PR that has to keep the hashes.
  • A comment in contract.ts named v7's invoke path literally. sc-12 forbids that string in src/v8, so I reworded the comment. Comments never reach the bundle.

Tests

analytics/tag.test.ts bundles every entry the Worker composes:

  • each bundle is one self-contained strict-mode IIFE (no import, require or node:);
  • nothing outside the folder is pulled in, so the CMS runtime and React can never reach a script that runs on every page;
  • the runtime imports nothing from the tag.

I checked that the guards fail when they should: an import from experiments.ts in a module fails the second test, and removing the tsconfig fails all ten strict-mode tests.

bun run check and bun run test pass. The one warning is the existing compat-v7 suppression.

Second commit: the client runs natively on v8

@decocms/blocks/analytics/client exports startAnalytics(options), so a v8 site collects without the external tag. It runs the same files the collector bundles. Identification, the queue, the transport and the automatic collection keep one implementation.

  • Nothing happens at import. It is safe on the server and in the CI's plain-Node import of every export.
  • In the browser, it fills window.__dq with the fields the collector writes ahead of /_dq/a.js (r, tier, hash, k, hr, er, e, dev). It then imports core and each module in options.modules, one chunk each. Bundled as a site would bundle it, the entry is about 1 KB. Core (about 6.6 KB) and each module load only when started.
  • Load order. Each module registers on load through __dqRegister. Core replays the page's stub queue in a microtask, and a module in its own chunk would miss that replay. So the client holds the stub back and replays it once every module has registered.
  • It never rejects. It resolves to null on the server, when collection is off for the visitor (automation, opt-out, a dev host), or on any failure.
  • One client per page. If the tag booted first, it is left alone and its API is returned.
  • Endpoint. The default is /_dq/e on the page's own host, so the collector resolves the site from Host. The collector ignores the body's site and falls back to a key.
  • Bundle bytes. commerce.ts and vitals.ts gain an empty export {} so they can be imported as modules. All ten bundles still match decocms/analytics' worker/tag.lock.json, checked with that repo's ops/build-tag.mjs --check --src.

client.test.ts (jsdom) covers:

  • a page view sent by itself to /_dq/e;
  • a client-side navigation counted as a page view;
  • the click and commerce modules collecting by themselves;
  • a helper queued before start, delivered once its module is in;
  • settings mapped like the collector's bundle;
  • the tag left alone;
  • nothing collected on a dev host;
  • a page view and a click posting exactly the same payload as the standalone bundle run in a separate JSDOM.

client.server.test.ts checks that it imports and does nothing on the server.

Mutation checks: dropping the stub replay fails one test, and not loading the modules fails three.

What this commit does not do, deliberately:

  • No per-site config from the collector's KV. Modules, sampling and tier come from the site's code, so the admin API does not switch them for a site running natively. The collector can still stop collection remotely: a {k:1} in a beacon's response kills it.
  • No fleet-wide fix by purge for a native site. A fix to the client ships with each site's dependency bump. That is the trade-off DECO_ANALYTICS.md names, and it changes there once this is agreed.
  • No AnalyticsScript change. A site picks one of the two. Leaving decoAnalyticsKey and decoAnalyticsFirstParty unset renders no tag.
  • No DOM-free core. Splitting it would change the core bundle every site loads. The non-browser sender already exists (EdgeIngest over a service binding).

The two commits are independent. The first is the byte-identical move. The second changes no bundle and can be split into its own PR if preferred.

Until step 2 lands

decocms/analytics runtime/ is still what production serves. A change to the tag goes to both copies until the collector builds from this package. CLAUDE.md says so.

🤖 Generated with Claude Code


Summary by cubic

Moves the Deco Analytics tag's source (runtime/, shared/) into packages/blocks/src/v8/analytics/ so the collection logic lives in one place, and adds @decocms/blocks/analytics/client so a v8 site can collect without loading the external tag. /_dq/a.js still serves v7, checkout and sites outside deco unchanged.

The move is byte-for-byte. Built with the collector's esbuild (0.23.1) and flags, all ten bundles hash identically to what production serves today, so the collector's RUNTIME_VERSION cache key is unchanged and decocms/analytics keeps serving from its Worker. Serving stays in the delivery path because a single purge fixes the whole fleet, whereas inlining into each site's build would need a dependency bump per repo.

Keeping the bytes identical

  • analytics/tsconfig.json sets strict and ships src; without it esbuild drops the "use strict" every production bundle opens with.
  • Biome formats the folder but neither lints it nor organizes imports, both of which change the bundle bytes.
  • A comment in contract.ts was reworded to avoid a string that sc-12 forbids in src/v8; comments never reach the bundle.

startAnalytics() runs the same files the collector bundles: it fills window.__dq with the fields the collector writes ahead of the tag, imports core, then each module in order. It holds the page's stub back until every module has registered, because core replays the stub in a microtask that a module in its own chunk misses. It does nothing on the server and never rejects. Events go to /_dq/e on the page's own host. commerce.ts and vitals.ts gained an empty export to be importable as modules; the ten bundles still hash to decocms/analytics' tag.lock.json.

analytics/tag.test.ts bundles every entry the Worker composes and fails if anything outside the folder reaches the tag or if the runtime imports from it; client.test.ts checks that a page view and a click reach the collector exactly as they do from the standalone bundle. Until the collector builds from this package, decocms/analytics' own runtime/ is still what production serves, and since it pins @decocms/blocks at an exact version, a change to the client reaches /_dq/a.js only after someone bumps the pin there and its e2e passes — CLAUDE.md documents that step as deliberate. bun run check and bun run test pass.