Contact Us

Analytics & Consent

DOC-00044 reference implementor, developer

The theme ships a CORE-OWNED consent mechanism that gates analytics script loading behind user consent. This document covers configuration, consent states, the script injection lifecycle, the trackEvent() event taxonomy, custom snippet setup, consent revocation, and environment-aware behavior.

For provider configuration fields (googleTagManager, googleAnalyticsGA4, consentRequired, consent, customSnippets), see Site Configuration.

Who this is for

  • Implementors configuring analytics and consent for a site
  • Developers extending event tracking or adding custom snippets
  • AI agents wiring components into the analytics layer

Component: src/core/components/base/ConsentBanner.astro (CORE-OWNED)

The consent banner is a fixed bottom bar with three equally-weighted buttons: Accept, Reject, and Required Only. It appears on first visit when consentRequired is true and at least one analytics provider or custom snippet is configured.

Visibility Rules

ConditionBanner visible?
consentRequired: falseNo — scripts load immediately
consentRequired: true, no providers or snippets configuredNo — nothing to consent to
consentRequired: true, providers configured, no stored consentYes
consentRequired: true, providers configured, valid consent existsNo
consentRequired: true, providers configured, expired consentYes — re-prompt

Configuration

All banner text is configurable in site.ts under analytics.consent:

analytics: {
  consentRequired: true,
  consent: {
    message: "This site uses cookies...",
    acceptLabel: "Accept",
    rejectLabel: "Reject",
    requiredOnlyLabel: "Required Only",
    policyUrl: "/privacy/",
    policyLinkLabel: "Privacy Policy",
    expirationDays: 365,
    cookieClearingPatterns: ["_ga", "_gid", "_gat", "_gcl"],
  },
},

Consent is stored in localStorage under the key analytics-consent as a JSON object:

{ "status": "accepted", "timestamp": 1710000000000 }

Three statuses are supported:

StatusScripts load?Description
acceptedYesUser clicked Accept — all configured scripts inject
rejectedNoUser clicked Reject — no scripts inject
required-onlyNoUser clicked Required Only — no analytics scripts inject; required scripts (theme mode) are unaffected

Expiration

Consent expires after expirationDays (default: 365). On page load, the banner checks timestamp + (expirationDays × 86400000) < Date.now(). If expired, the stored consent is cleared and the banner re-appears.

Script Injection Lifecycle

Analytics.astro uses a two-script architecture:

  1. Inline script (is:inline): Runs synchronously in <head>. Sets window.__analyticsConfig, validates custom snippet IDs, and in production mode performs consent-gated dynamic injection.
  2. Module script: Imports trackEvent() from src/lib/analytics.ts and assigns it to window.trackEvent.

Injection Flow (Production)

  1. Inline script reads consent state from localStorage
  2. If consentRequired is false → inject all configured scripts immediately
  3. If consentRequired is true and consent is accepted → inject all configured scripts
  4. If consentRequired is true and no valid consent → register a consent-granted event listener; scripts inject when the user accepts via the banner (no page reload needed)
  5. GTM takes precedence over GA4 — when both are enabled, only GTM loads
  6. Custom snippets inject after GTM/GA4 in config array order

Dev Mode

In development (import.meta.env.DEV):

  • No analytics scripts inject, regardless of consent state or config
  • trackEvent() logs to console.debug instead of pushing to dataLayer
  • The consent banner still follows its normal visibility rules (config-driven, not suppressed)

Use npm run build && npm run preview to verify script injection behavior.

Event Taxonomy

The theme defines 8 shared events routed through trackEvent():

EventSourcePayload
cta_clickCallToAction.astro{ label, href }
form_submitContactForm.astro, NewsletterForm.astro{ formId: "contact" | "newsletter" }
search_openedSearch modal{ source: "trigger" | "shortcut" }
search_submittedSearch modal/page{ queryLength }
search_results_viewedSearch modal/page{ resultCount }
search_result_clickedSearch modal/page{ resultIndex }
search_no_resultsSearch modal/page{ queryLength }

page_view is left to GTM/GA4 auto-tracking — manual emission would double-count.

Using trackEvent()

// In a module script:
window.trackEvent("cta_click", { label: "Get Started", href: "/contact/" });

trackEvent() is consent-aware:

  • In dev mode → logs to console.debug
  • In prod, consent required and not accepted → silent no-op
  • In prod, no analytics targets configured → silent no-op
  • Otherwise → pushes { event: name, ...payload } to window.dataLayer

Site-specific events can use any string name — trackEvent() accepts arbitrary event names for extensibility.

Search Events

Search events are exposed via src/lib/search-events.ts with a stable public API:

import {
  trackSearchSubmitted,
  trackSearchResultClicked,
} from "@/lib/search-events";

trackSearchSubmitted(query.length);
trackSearchResultClicked(0);

These functions call trackEvent() internally. Search payloads use queryLength/resultCount/resultIndex — no raw query text is captured.

Custom Snippets

Custom snippets support non-Google tracking and marketing scripts. They follow the same consent gate as GTM/GA4.

customSnippets: [
  { id: "hotjar", src: "https://static.hotjar.com/c/hotjar-XXXXX.js", location: "head" },
  { id: "inline-pixel", content: "console.log('pixel loaded')", location: "body-end" },
],

Each entry requires a unique id (deduplication key) and exactly one of src (external URL) or content (inline script). The location field controls injection into <head> or before </body>.

Duplicate id values are logged as console.warn in both dev and prod — only the first matching entry injects per page load.

CSP note: The theme does not set or enforce Content Security Policy headers. If your hosting environment uses CSP, ensure script-src permits any external snippet URLs and unsafe-inline if using inline content snippets. CSP is a hosting/deployment concern.

Component: src/core/components/base/ConsentReset.astro (CORE-OWNED)

The ConsentReset component provides a “Reset Cookie Preferences” button for placement on the privacy policy page. It renders whenever consentRequired is true (server-side gate).

Revocation Sequence

  1. Clears localStorage consent state
  2. Clears cookies matching cookieClearingPatterns prefixes (default: _ga, _gid, _gat, _gcl)
  3. Triggers a full page reload

After reload, the consent banner re-appears (no stored consent) and no analytics scripts inject.

Cookie clearing uses prefix matching — "_ga" matches _ga, _ga_XXXXX (GA4 client ID cookies), and _gat. Cookies are cleared with path=/ and both bare and dotted domain to cover common cookie configurations.

To clear cookies set by custom snippets, add their name prefixes to cookieClearingPatterns:

consent: {
  cookieClearingPatterns: ["_ga", "_gid", "_gat", "_gcl", "_hj"],
},

Placement

Import in any MDX page:

import ConsentReset from "@core/components/base/ConsentReset.astro";

## Manage Your Consent

<ConsentReset />

consent.policyUrl vs forms.privacyPolicyUrl

Both default to "/privacy/" and typically point to the same page, but they are independent config values:

  • analytics.consent.policyUrl — drives the consent banner’s privacy policy link
  • forms.privacyPolicyUrl — drives the consent checkbox links on ContactForm and NewsletterForm

Update both if your privacy policy page is at a different URL.

REQ-00100 implemented Analytics and tracking scripts shall be blocked until consent.
REQ-00101 implemented A cookie consent mechanism shall be provided.
REQ-00102 implemented Consent states shall support Accept, Reject, and Required Only.
REQ-00103 implemented Required scripts shall execute regardless of consent state.
REQ-00104 normative GDPR compliance mechanisms shall be supported.
REQ-00105 normative CCPA compliance mechanisms shall be supported.
REQ-00171 implemented The configuration override hierarchy shall follow a deterministic four-layer precedence: Parent defaults -> Child theme overrides -> Content frontmatter overrides -> Runtime user preferences. Runtime preferences are limited to theme mode and consent state.
REQ-00172 implemented The system shall support environment-aware configuration so that behavior can differ between development and production builds.
REQ-00234 implemented Search interactions shall emit a defined event taxonomy (search_opened, search_submitted, search_results_viewed, search_result_clicked, search_no_results) routed through the analytics trackEvent system, with privacy-safe payloads (no raw query text; use queryLength, resultCount, resultIndex).
REQ-00237 implemented All forms collecting personal data shall include a required GDPR consent checkbox linking to the site's privacy policy URL, preventing submission without explicit consent.
REQ-00244 implemented A consent revocation mechanism (ConsentReset) shall be provided that clears consent state from localStorage, removes known analytics cookies (configurable prefix list), disables event tracking for the remainder of the session, and triggers a page reload to ensure a clean runtime state.
REQ-00245 implemented Site configuration shall support custom analytics snippet injection (script URL or inline code, head or body-end location, unique id for deduplication) with the same consent-gating rules as built-in GTM/GA4 providers.
REQ-00246 implemented The analytics system shall define a shared event taxonomy (page_view, cta_click, form_submit, search_submitted, search_result_clicked) dispatched via a centralized trackEvent function that routes to the dataLayer in GTM format. Event payloads shall follow privacy-safe conventions (no PII, no raw search query text).
REQ-00247 implemented In development mode (import.meta.env.DEV), analytics scripts shall not be injected into the page and trackEvent calls shall log to console.debug instead of dispatching to the dataLayer, preventing dev activity from polluting production analytics.

Search

Search across pages and articles. Use arrow keys to navigate results.

Search across pages and articles.

Loading search...

Search is unavailable. Please try again later.

    No results for ""

    Try different keywords or fewer words.