@growth-labs/analytics
v0.16.1
Published
On Cloudflare Workers, publicly cacheable middleware-injected HTML is deterministic across consent, country, visitor/session, attribution, and auth state. Its serialized config contains deployment and route constants only; browser-readable consent and eph
Readme
@growth-labs/analytics
Public HTML invariance
On Cloudflare Workers, publicly cacheable middleware-injected HTML is
deterministic across consent, country, visitor/session, attribution, and auth
state. Its serialized config contains deployment and route constants only;
browser-readable consent and ephemeral session identity are hydrated after
delivery. A consented runtime Cloudflare SSR navigation necessarily sets
visitor/session cookies and therefore receives private, no-store on
Cache-Control, CDN-Cache-Control, and Cloudflare-CDN-Cache-Control. That private
representation carries only the non-identifying isNewSession and
sessionPageCount lifecycle signals needed to preserve session_start and
immediate multi-page session_engaged; it still carries no visitor ID, session
ID, attribution, country, or consent decision. Astro prerender always keeps
false/1 lifecycle defaults so build-time state cannot become a static artifact.
Synthetic/request-specific disabled HTML receives the same full triplet even
when no cookie is set and no downstream Vary header exists. Every event-route
outcome—including malformed JSON, schema failure, denied/synthetic events, and
accepted writes—returns 204 with the full private/no-store triplet.
Denied or unresolved Worker requests emit no analytics page view, Web Vitals,
or Cloudflare Web Analytics activity. Static builds place a configured public
CWA token in deterministic bootstrap state without loading the beacon, and the
client creates the beacon script only after analytics consent. Cache-invariant
Cloudflare SSR and static HTML both assign exactly one page view to the client;
legacy/non-Worker SSR can retain server ownership through the explicit
serverPageView flag. Astro prerender is detected explicitly;
it does not depend on a build request exposing request.cf. If an older or
nonstandard static artifact has no analytics data node, consent's deterministic
<meta name="growth-labs-consent" content="…"> marker preserves the configured
cookie identity and the same fail-closed browser gate.
WAE-primary analytics with auto-injected behavioral tracking, attribution, and conversion event routing. Requires Cloudflare Workers Paid plan ($5/mo) for WAE access. No D1 fallback for behavioral events.
Config
import analytics from '@growth-labs/analytics'
analytics({
siteId: 'fedweek', // Cookie prefix + WAE index
waeBinding: 'ANALYTICS', // WAE binding in wrangler.toml
d1Binding: 'SITE_DB', // D1 for conversion events
cookiePrefix: 'fedweek', // → fedweek_vid, fedweek_sid, fedweek_attr
session: { timeoutMinutes: 30, engagedThresholdSeconds: 10 },
scroll: { thresholds: [25, 50, 75, 90] },
media: { thresholds: [10, 25, 50, 75, 100] },
ecommerce: { enabled: true, currencyCode: 'USD' },
webVitals: { enabled: true },
cloudflareWebAnalytics: {
enabled: true,
token: 'public-beacon-token',
tokenBinding: 'CF_WEB_ANALYTICS_TOKEN',
// consentGated defaults to false: cookieless CWA loads unconditionally.
},
ga4: {
measurementId: 'G-XXXXXXXX', // public GA4 id (client + server)
apiSecretBinding: 'GA4_MP_API_SECRET', // server Measurement Protocol secret
clientTag: true, // consent-gated browser gtag.js tag
},
socialSharing: {
enabled: true,
defaultProperty: 'fronts',
serviceTokenBinding: 'SHARE_LINK_SERVICE_TOKEN',
shortLinkBaseUrl: 'https://go.fronts.co',
campaigns: ['fronts__membership-push__2026q2'],
},
})What It Injects
Middleware (server-side, every request):
- Resolves consent before creating visitor/session/attribution state
- Reads/creates visitor ID cookie (
{cookiePrefix}_vid) only when granted - Manages session and attribution cookies only when granted
- Owns visitor/session/attribution state; cache-invariant page views use the client route
- Injects deterministic public route/deployment config with no request identifiers; consented lifecycle flags appear only on a state-setting private response
- Bot filtering (private/no-store disabled HTML for known bot user-agents)
Routes:
POST /api/analytics/event— beacon receiver for client-side eventsGET /api/consent/status— injected by@growth-labs/consent; private, server-authoritative no-decision geography used by direct browser analytics- Optional social routes when
socialSharing.enabledis true:/admin/share,/api/share-links,/api/internal/share-links/service,/api/social-spend/import,/go/[shareLinkId]
The internal service route is for trusted automation such as Foundry. It uses
Authorization: Bearer <token> from socialSharing.serviceTokenBinding, not a
browser session, and writes idempotent gl_share_links rows from caller-supplied
UTM-tagged destination URLs.
Client script (auto-injected):
page_viewon cache-invariant Cloudflare and static pages (see "Page-view ownership" below)- Scroll depth tracking (fires at 25/50/75/90%)
- Media progress tracking (native audio/video + Vidstack, including dynamic elements)
- Core Web Vitals (LCP, INP, CLS)
- Outbound link clicks, file downloads, 404 detection
- Form tracking (start, submit, abandon)
- E-commerce events (product_viewed, add_to_cart, checkout_success)
- Search events (search query + result count)
window.glAnalytics.track(eventName, data),.identify(userId), and.reset()public API
Cloudflare Web Analytics (optional, cookieless — unconditional by default):
- When
cloudflareWebAnalytics.enabledis true, the externalstatic.cloudflareinsights.com/beacon.min.jsscript is injected on every page — SSR, cache-invariant Cloudflare SSR, Astro prerender, and the schema-missing fallback — regardless of the consent decision. CWA is privacy-first RUM that sets no cookies, so it is not consent-gated. The beacon is an external tag with no inline configuration, so a hash-based CSP stays stable. The injection dedupes if the HTML already carries a beacon. - SSR middleware reads the public RUM token from the runtime binding named by
cloudflareWebAnalytics.tokenBinding(defaultCF_WEB_ANALYTICS_TOKEN) and falls back tocloudflareWebAnalytics.token. Static/prerendered HTML uses the explicittoken. If neither token source is set, no beacon loads. - Set
cloudflareWebAnalytics.consentGated: trueto opt back into the deferred path: static/prerendered HTML then carries only the public token in an inert<template>, and the browser creates the beacon element only after analytics consent is granted (or the private/api/consent/statusroute confirms a non-required region). Use this only where policy requires even cookieless RUM to wait for consent.
GA4 (Google Analytics 4) (optional):
- Client tag (
ga4.clientTag: true): a consent-gated browser gtag.js tag. Nothing loads until the@growth-labs/consentanalytics-category consent is granted. On grant, the client tracker injects the externalwww.googletagmanager.com/gtag/js?id=<measurementId>loader and callsgtag('config', …)with anonymize defaults (anonymize_ip: true,allow_google_signals: false,allow_ad_personalization_signals: false). A later revocation flipswindow['ga-disable-<measurementId>'] = trueand pushes a deniedconsent update, so no further hits leave the browser. The tag is external-script-only — no inline gtag bootstrap — so hash-CSP sites only allowlist the origins below (no per-render inline hash to maintain). The first-party WAE layer stays primary; GA4 is a parallel tag and never gates, replaces, or double-counts WAE. - Server Measurement Protocol (
ga4.measurementId+ga4.apiSecretBinding): unchanged. Server-emitted conversion events (which never reach a client tag) are POSTed straight to GA4's Measurement Protocol; dormant until the api secret binding resolves. Both layers share the same publicga4.measurementId.
Content Security Policy — sources to allowlist
These tags load external scripts and endpoints, so a site with an enforced CSP
must declare the following sources (see @growth-labs/conformance
SecurityHeadersConfig — add them to the exact-origin scriptSources /
connectSources / imgSources allowlists, matching how existing origins are
declared). All are exact HTTPS origins; no wildcards for script hosts.
| Tag | Directive | Source |
| --- | --- | --- |
| CWA beacon | script-src | https://static.cloudflareinsights.com |
| CWA beacon (RUM upload) | connect-src | https://cloudflareinsights.com |
| GA4 gtag.js loader | script-src | https://www.googletagmanager.com |
| GA4 collect / config fetch | connect-src | https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com |
| GA4 pixel fallback | img-src | https://www.google-analytics.com https://*.google-analytics.com |
Both GA4 and CWA tags are external <script src> tags with no inline bootstrap,
so sites on Astro-native hash-based CSP do not need any per-render inline
hash for them — allowlisting the script origins above is sufficient.
Page-view ownership (dedup rule)
page_view is emitted by exactly one side per pageview — never both:
- Cache-invariant Cloudflare SSR and STATIC / prerendered pages: deterministic
config carries
serverPageView: false. After consent, the client owns and emits exactly onepage_view; the server does not emit a duplicate. A static page with no analytics config self-bootstraps from the DOM, the consent package's deterministic meta descriptor, and cookies. - Legacy/non-Worker SSR: after a consented server write, config carries
serverPageView: trueand suppresses the client page view. An unresolved first visit carriesfalse, allowing a newly written non-required helper to resolve before the browser emits its one page view.
The explicit serverPageView flag is route/build state, not request state. A
public data node contains no consent decision, country, visitor/session
identifier, session lifecycle signal, or attribution and is byte-stable for a
fixed route and deployment. A consented Cloudflare response is already private
because it sets state cookies; its data node may carry isNewSession and
sessionPageCount so the client preserves the existing WAE session lifecycle
without exposing either identifier. Prerendered/static data always keeps the
deterministic false/1 defaults. The node is inert HTML-safe JSON, so a strict
CSP does not need a nonce or unsafe-inline.
stripAnalyticsConfigDataScript remains exported for compatibility with older
consumers, but public-cache safety no longer depends on calling it:
import { stripAnalyticsConfigDataScript } from '@growth-labs/analytics/client-config'
const legacySanitizedHtml = stripAnalyticsConfigDataScript(renderedHtml)Both paths use deterministic consent metadata: consentManaged +
consentCookieName in analytics config and the matching
growth-labs-consent meta marker as the static fallback. The browser reads that
configured decision cookie and its ${cookieName}_required helper; it does not
hardcode gl_consent. The readable helper may hydrate the first-party client,
but it is not authority: SSR and event-route writes recompute consent from the
trusted request, while Cloudflare Web Analytics and Zaraz require an explicit
decision or a private /api/consent/status response confirming non-required
geography. Missing, malformed, or unavailable trusted state fails closed.
Every granted navigation produces one page view through its assigned owner.
For a client page_view, the /api/analytics/event route re-derives the WAE
source dimensions server-side from the posted navigation url + referrer (the
beacon's request url is the analytics endpoint and carries no source signal),
using the same parseTouchPoint precedence the middleware uses — read-only for
the blobs, so beacons never write an attribution touch. The resulting WAE
page_view is equivalent to the server middleware's for the same navigation
(blob1='page_view', blob2=siteId from the route env/config, blob6=pagePath,
blob16=contentSlug, blob17=contentType, blob8–11 server-derived source
dims), so client and server page-views are indistinguishable downstream.
Event Routing
| Event type | Destination | Examples | |------------|-------------|---------| | Behavioral | WAE only | page_view, scroll_50, page_exit | | Conversion | WAE + D1 | newsletter_subscribed, checkout_success, form_submit | | System | WAE only | session_start, session_engaged | | Vital | WAE only | web_vital_lcp, web_vital_inp, web_vital_cls |
WAE Schema
20 blobs + 12 doubles per data point. Key fields:
- blob1: eventName, blob2: siteId, blob3: visitorId, blob5: pageUrl
- blob8-10: UTM params, blob11: derivedSource, blob12: country
- blob13: deviceType, blob16: contentSlug, blob18: eventCategory
- double1: timestamp, double2: eventValue / page-exit loaded seconds, double3: web vital value, double5: active/watch/listen seconds
Server-side helpers should use the exported WAE builders/writers instead
of ad hoc schemas. writeAuthEvent() is available for non-Astro auth
Workers; buildWAEDataPoint() remains the low-level schema builder for
package-owned event emitters.
Utility Exports
@growth-labs/analytics/utils re-exports the core event helpers plus
the v0.3 WAE infrastructure primitives:
writeAuthEvent()for best-effort auth success rows from Worker-only auth flows.trackServerEvent()for Astro/Worker server routes that need the same WAE + D1 attribution path as/api/analytics/event.appendCheckoutAttribution()for LemonSqueezy checkout links with visitor/session/first/latest/content custom data and discount code propagation.- Social attribution helpers:
buildSocialShareUrl(),parseSocialUtmContent(),extractShareIdFromUtmContent(),classifySocialShare(),parsePlatformClickEvidence(), andbuildPlatformTrackingFields(). queryWAE(),fetchWAE(), andassertValidWaeDataset()for Cloudflare Analytics Engine reads.buildQueryContext(),resolveDataSourceContext(),fetchSiteD1(),finalizeDataSourceStatus(), andpickDataForStatus()for analytics-platform-style live/demo/error data-source handling.cachedFetchWAE()/waeCacheKey()for KV-backed dashboard query caching.validateCloudflareCredentials(),probeWaeDataset(), andprobeSiteD1()for setup probes with injectable fetch/query hooks.compactNumber(),formatDate(),formatDuration(),formatPercent(), andformatPercentChange()for dashboard display formatting.encryptSecret()/decryptSecret()using Web Crypto AES-GCM.resolveWindow()/windowSqlFragment()for closed UTC reporting windows that exclude partial today, plus rolling sub-day windows ({ minutes: N },{ hours: N }) anchored tonow()for short-interval scheduled digests.@growth-labs/analytics/client/helpersfor consent-aware browser instrumentation of search, newsletter conversion, share/related links, client errors, HTTP error mapping, and redirect attribution.
Content Meta Helper
inferAnalyticsContentMeta(pathname, opts?) (exported from the package root
and from @growth-labs/analytics/content-meta) is a pure, framework-agnostic
helper that maps a URL pathname to
{ contentSlug, contentType, sourcePath } | null. Channel layouts call it once
and render the result as three <meta> tags in <head> so every site emits
identical gl-content-* semantics. The SSR analytics middleware
(extractPageContentContext) reads those exact tag names back out of the
rendered HTML to attribute page-view and content events; this helper is the
single source of truth for the path → content mapping. It agrees with the
middleware's inferPathContent fallback (sharing
DEFAULT_COLLECTION_CONTENT_TYPES) for the canonical single-segment content
path shape (/<collection>/<slug>); when a meta tag is emitted the middleware
reads it verbatim, so multi-segment slugs are still attributed correctly even
though the helper's slug match is more permissive than the fallback's.
Article-route pages that actually carry a video or podcast must pass
contentTypeOverride to preserve fronts' automatic video/podcast
reclassification, or they will be typed article:
---
import { inferAnalyticsContentMeta } from '@growth-labs/analytics/content-meta'
const meta = inferAnalyticsContentMeta(Astro.url.pathname, {
contentTypeOverride: item?.video ? 'video' : item?.audio ? 'podcast' : undefined,
})
---
{meta && (
<>
<meta name="gl-content-slug" content={meta.contentSlug} />
<meta name="gl-content-type" content={meta.contentType} />
<meta name="gl-source-path" content={meta.sourcePath} />
</>
)}D1 Tables (prefixed gl_)
gl_conversion_events— conversion event log with e-commerce fieldsgl_analytics_visitors— visitor summary (session count, first/last seen)gl_content_progress— legacy table retained by migrations; high-volume reading/watching progress now writes to WAE onlygl_identity_links— anonymous visitor to authenticated identity bridgegl_conversion_attribution_touches— bounded touch history materialized only when a conversion is writtengl_social_authors— public, non-PII author slug/id metadatagl_social_handles— social account metadata with brand-account flaggl_share_links— generated social links and canonical UTM fieldsgl_social_spend_daily— daily spend/ad-object metadata mapped to share links
Social Attribution
Social is measured through the normal attribution pipeline, not a separate analytics layer. The link builder emits canonical UTMs:
utm_source: platform, e.g.x,instagram,facebookutm_medium:socialorpaid_socialutm_campaign:property__initiative__periodutm_content:sl__{property}__{platform}__{handle_id}__{author_id}__{share_id}utm_term: placement, e.g.feed,story,bio,ad
The /admin/share route is for an admin operator. Writers do not log in;
the operator explicitly selects or type-to-creates the human author, and
the generated gl_share_links.created_by records the operator separately
from the marketing author_id. Author Social batch links may omit a posting
handle when the operator is minting a link for the selected author to post from
their own account; those rows use handle_id = 'na' in the legacy NOT NULL
column and leave handle_id_v2 null.
Wrangler Bindings
[analytics_engine_datasets]
binding = "ANALYTICS"
[[d1_databases]]
binding = "SITE_DB"
database_id = "..."Runtime Behavior
Server entrypoints side-effect import virtual:growth-labs/analytics/config, so package state seeds itself in the Worker that actually handles the request.
Bindings resolve from the standard Cloudflare surfaces:
cloudflare:workersfor env bindingsAstro.locals.cfContext.waitUntil()for background writes
The package no longer reads legacy locals.runtime; consumers should not patch @growth-labs/analytics/state or add package-specific runtime-state middleware.
Consent Integration
Optional peer dependency on @growth-labs/consent. If installed:
- Consent middleware publishes a deterministic managed/cookie-name descriptor.
- Missing trusted state, unresolved, or denied consent → zero page views, browser events, Web Vitals, CWA loads, tracking cookies, or attribution.
- The readable helper never authorizes server writes or direct CWA/Zaraz loads.
- Granted consent → full tracking (cookies, attribution, all events).
If consent package NOT installed → tracking runs unconditionally.
Client-Side Event API
// gl:* CustomEvent pattern for loose coupling
document.dispatchEvent(new CustomEvent('gl:track', {
detail: { event: 'product_viewed', data: { productId: '123' } }
}))
// Or direct API
window.glAnalytics.track('custom_event', { label: 'something' })
window.glAnalytics.identify('user_123')
window.glAnalytics.reset()Key Patterns
- Virtual module:
virtual:growth-labs/analytics/config - Runtime config self-seeds in middleware and routes via the virtual module
gl:*CustomEvent pattern ondocumentfor loose coupling between client-side packages. Package-localgl:*events are not automatically forwarded; usewindow.glAnalytics.track(...)when an event should be written to WAE.- Package-owned WAE writes use the shared schema helpers; auth Workers use
writeAuthEvent() - Bot filtering runs before any tracking
gl_prefix on all D1 table names
