Skip to content

feat(apps-website): Stats, the first-party analytics collector - #501

Merged
nicacioliveira merged 2 commits into
mainfrom
feat/deco-analytics-stats
Aug 28, 2026
Merged

feat(apps-website): Stats, the first-party analytics collector#501
nicacioliveira merged 2 commits into
mainfrom
feat/deco-analytics-stats

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

The React counterpart of the Fresh <Stats /> in deco-cx/apps#1660, so the TanStack fleet has a path off OneDollarStats. Sites import it exactly where they import its predecessor:

import Stats from "@decocms/apps-website/components/Stats";
// …
<DecoRootLayout  >
  <Stats />
</DecoRootLayout>

Why it is twenty lines and OneDollarStats is three hundred

Not because it does less — because the work is on the other side. The lilstts SDK has no notion of how this app routes, no notion of the deco_segment cookie and no notion of window.DECO.events, so that component has to wrap history.pushState, poll for globals, decode the cookie and forward every commerce event by hand.

The deco collector's own bundle already does all of it, and is tested doing it: first pageview through the prerender guard, pushState/replaceState/popstate, flush on pagehide and visibilitychange, deco_segment into experiment assignments, and the window.DECO.events subscription with the commerce vocabulary mapped. Reimplementing any of it here would be a second version, drifting from the first.

So there is no useEffect, no readiness polling and no module-level StrictMode guard — there is no client state to guard.

data- attributes, not a global

dev and debug are read off the tag. On this framework that is load-bearing: TanStack hoists <script async> into <head> above any inline configuration block — measured at byte 190 against byte 1108 on a real site. A component that set a global and expected the collector to find it would boot into silence, with the collector seeing a development host and skipping without an error.

Gating

DECO_ANALYTICS_ENABLED must be "true" — the inverse of ONEDOLLAR_ENABLED, which defaults to on, because one is the incumbent and the other is being introduced. The gates are independent, so a site can run both during a shadow comparison and neither can turn the other off.

Optional DECO_ANALYTICS_ORIGIN and DECO_ANALYTICS_SITE_KEY. Same-origin by default, which is the intended deployment: script and beacon served from the site's own hostname, so no third-party request is involved. A site key is only for sites not behind our edge — sites that are get identified by the Host header, which a visitor cannot forge, and a tag-sourced identity must never reach an invoice.

Tests

Five, covering what this file actually decides: the gate (including that =1 does not enable it), same-origin default and the preconnect only when cross-origin, the data- attributes and their absence when off, the site key only when configured, and async unless defer is asked for. Each re-imports the module, because the gate is read at module load and a test that set the variable afterwards would pass or fail on file order.

54/54 in the package, tsc --noEmit clean.

🤖 Generated with Claude Code


Summary by cubic

Introduces Stats in @decocms/apps-website, a first-party analytics loader that can replace or run alongside OneDollarStats. Previously the component handled routing and event plumbing; now the collector bundle owns behavior, reducing app code and drift.

Review and rollout

  • New component: mount once in the root layout where OneDollarStats was mounted; it renders nothing unless DECO_ANALYTICS_ENABLED="true".
  • Behavior: defaults to same-origin /_dq/a.js; adds preconnect only when DECO_ANALYTICS_ORIGIN (or origin prop) is cross-origin.
  • Config: optional DECO_ANALYTICS_SITE_KEY (or siteKey prop) for sites not behind our edge; the key goes in the URL query string (?k=), which is where the collector reads it while rendering the bundle.
  • Debugging: dev and debug are data-* attributes on the tag (not globals) to avoid TanStack hoisting issues.
  • Loading: async by default; use the defer prop to switch.
  • Gates are independent of ONEDOLLAR_ENABLED, allowing shadow runs. Tests cover gating, origin/preconnect, attributes, site key, and async/defer.

Migration

  • Add <Stats /> to the root layout (can coexist with <OneDollarStats />).
  • Set DECO_ANALYTICS_ENABLED="true" where enabled.
  • If hosting the collector elsewhere, set DECO_ANALYTICS_ORIGIN; if not on our edge, set DECO_ANALYTICS_SITE_KEY.
  • Optional: pass dev and/or debug props during local development or troubleshooting.

Written for commit 5d20a4c. Summary will update on new commits.

Review in cubic

The React counterpart of the Fresh <Stats /> in deco-cx/apps, so the TanStack
fleet has a path off OneDollarStats. Sites import it exactly where they import
its predecessor, from @decocms/apps-website/components/Stats.

It is twenty lines where OneDollarStats is three hundred, and not because it
does less. The lilstts SDK has no notion of how this app routes, no notion of
the deco_segment cookie and no notion of window.DECO.events, so that component
has to wrap history.pushState, poll for globals, decode the cookie and forward
every commerce event by hand. The deco collector's bundle already does all of
it and is tested doing it -- first pageview through the prerender guard,
pushState/replaceState/popstate, flush on pagehide and visibilitychange, cookie
into experiment assignments, and the DECO event subscription with the commerce
mapping. Reimplementing any of that here would be a second version of it,
drifting from the first.

So there is no useEffect, no readiness polling and no module-level StrictMode
guard, because there is no client state to guard.

dev and debug are data- attributes rather than a global, and on this framework
that is load-bearing: TanStack hoists <script async> into <head> ABOVE any
inline configuration block -- measured at byte 190 against byte 1108 on a real
site. A component that set a global would boot into silence, with the collector
seeing a development host and skipping without an error.

Off by default: DECO_ANALYTICS_ENABLED must be "true". The inverse of
ONEDOLLAR_ENABLED, which defaults to on, because one is the incumbent and the
other is being introduced. The gates are independent, so a site can run both
during a shadow comparison and neither can turn the other off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nicacioliveira
nicacioliveira requested a review from a team August 23, 2026 00:15
The collector resolves the site server-side while RENDERING the bundle: it reads
?k= from the request and writes the resolved config into the script it returns.
A key on the element arrives long after that decision, and is read by nothing --
the bundle only looks at data-dev and data-debug.

As data-site this rendered perfectly, resolved nothing, served the s:"unknown"
fallback bundle and collected exactly zero, with no error anywhere. The same
shape as the bug that once made the entire self-serve tier silent, which is how
this was noticed: a key-registered site is precisely the case a site not behind
our CDN would be.

The admin API's own snippet already does it this way, which is what the
component should have matched from the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nicacioliveira
nicacioliveira merged commit 3f2daef into main Aug 28, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 7.54.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants