Skip to content

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

Open
thaisrocine wants to merge 4 commits into
mainfrom
thaisrocine/analytics-runtime-source
Open

thaisrocine wants to merge 4 commits into
mainfrom
thaisrocine/analytics-runtime-source

Conversation

@thaisrocine

@thaisrocine thaisrocine commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

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, purge decocms/analytics, the collector Worker
visitor_id hashing, ingest, ClickHouse, dashboard decocms/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.

Written for commit ef66c14. Summary will update on new commits.

View guided diff Turn on auto-fix

thaisrocine and others added 2 commits November 9, 2026 15:17
The tag's code moves to blocks so it is written in one place for every
consumer, while the standalone script stays a build artifact: decocms/analytics
bundles these entries and its collector Worker keeps serving /_dq/a.js to v7,
checkout and sites outside deco. Serving stays in the Worker because one purge
fixes the whole fleet; a script inlined into each site's build would need a
dependency bump per repo.

The move is byte-for-byte: built from this folder (and from the packed tarball)
with the esbuild the collector uses, all ten bundles hash identically to what
production serves today, so no site receives a different script.

Two things keep it that way. The folder's tsconfig.json sets strict and ships
with src, because without it esbuild drops the "use strict" every production
bundle opens with. Biome formats the folder but neither lints it nor organizes
its imports: reordering imports reordered the core bundle, and the lint fixes
rewrite size-tuned code. tag.test.ts bundles every entry and fails if anything
outside the folder reaches the tag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A v8 site should not need the external tag to collect. startAnalytics(), on
the new @decocms/blocks/analytics/client subpath, runs the same files the
collector bundles into /_dq/a.js: it fills window.__dq with the fields the
collector writes ahead of the tag, imports core, then each module in order.
Identification, the queue, the transport and the automatic collection keep one
implementation, and the standalone build is untouched.

Order matters because every module registers on load through __dqRegister, and
core replays the page's stub in a microtask that a module in its own chunk
misses. So the client holds the stub back and replays it once every module is
in. It does nothing on the server and never rejects, so a failure stops
analytics and nothing else. Events go to /_dq/e on the page's own host, which
keeps the site resolved from Host.

commerce.ts and vitals.ts gain an empty export so they can be imported as
modules; the ten bundles still hash to decocms/analytics' tag.lock.json.
client.test.ts checks that a page view and a click reach the collector exactly
as they do from the standalone bundle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@thaisrocine thaisrocine changed the title feat(blocks): host the Deco Analytics tag's source feat(blocks): host the Deco Analytics client's source, and run it natively on v8 Nov 9, 2026
@thaisrocine
thaisrocine marked this pull request as ready for review November 10, 2026 13:53
@thaisrocine
thaisrocine requested a review from a team November 10, 2026 13:53
thaisrocine and others added 2 commits November 10, 2026 11:28
decocms/analytics pins @decocms/blocks at an exact version, so a change to the
client's source reaches /_dq/a.js only when someone bumps it there and its
e2e passes. Agreed with @tlgimenes to keep that step manual for now; written
here so an agent releasing a change to the client knows the second half.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants