git:20260608.8cf5ea0 to git:20261009.0ae007b

72 added, 38 removed. Audit A to A.

---
name: configure-analytics
- description: Use when adding product + performance analytics to a frontend — creates one provider-agnostic analytics seam (trackEvent/trackPageView, like the captureError seam), wires Core Web Vitals as real-user monitoring, fires page views on route change, and stays privacy-first (cookieless default, consent-gated, no PII). No-ops without config.
+ description: Use when adding analytics to a frontend — one provider-agnostic seam (trackEvent/trackPageView), Core Web Vitals as real-user data, consent-gated by default (GDPR/TDDDG), path-only page views, no PII, no-op without config.
---
# Configure Analytics
## 1. Audit current state
```bash
- grep -E '"(plausible-tracker|posthog-js|@vercel/analytics|web-vitals)"' package.json 2>/dev/null
+ cat .claude/stack-profile.md 2>/dev/null || cat ~/.claude/stack-profile.md 2>/dev/null # frontend.framework, frontend.meta, observability, package_manager
+ grep -E '"(@plausible-analytics/tracker|plausible-tracker|posthog-js|@vercel/analytics|web-vitals)"' package.json 2>/dev/null
ls src/libs/analytics.ts 2>/dev/null
```
- **Prerequisites:** `optimize-performance` (we forward its `web-vitals`) and `set-up-routing` (page views fire on route change).
+ Read `frontend.framework` (router hook in step 7) and `package_manager` (commands use pnpm); `observability.backend` is for errors and traces, not product analytics — keep them separate. **Prerequisites:** `optimize-performance` (we forward its `web-vitals`) and `set-up-routing` (page views fire on route change).
## 2. Decide what to do
- No analytics → full setup (seam + vitals + page views).
- Analytics calls scattered in components → centralize behind the seam (step 5).
- ## 3. Choose a provider (privacy-first default)
- - **Cookieless** (Plausible, Fathom): no banner needed in most jurisdictions, no PII, tiny script. **Default.**
- - **Product analytics** (PostHog): funnels/feature-flags/session — richer, but review its privacy/consent settings.
- - Avoid cookie-based GA unless you already run a consent banner.
+ ## 3. Choose a provider, and decide consent first
+ - **Consent decides the code, not the provider.** The seam sends nothing until the user opts in (`CONSENT_REQUIRED = true`). This is the default for any EU/German audience: GDPR needs a legal basis and transparency, and § 25 TDDDG requires consent for any read or write on the user's device. A cookieless tool can fall outside § 25 and still needs a documented basis; the npm Plausible tracker, for one, reads `localStorage.plausible_ignore`. Set `CONSENT_REQUIRED = false` only with a written legal/DPO decision for that product.
+ - **Cookieless** (Plausible, Fathom): no cookies, no personal profile, tiny script. **Default provider.**
+ - **Product analytics** (PostHog): funnels and session replay are richer and need consent plus a data-processing agreement; review its privacy settings.
+ - Avoid cookie-based GA unless a consent banner already exists.
+ Why: `analytics-patterns.md`.
## 4. Install
```bash
- pnpm add plausible-tracker # or posthog-js, @vercel/analytics — the seam hides the choice
+ pnpm add @plausible-analytics/tracker # or posthog-js, @vercel/analytics — the seam hides the choice
```
+ (`plausible-tracker`, the older package, was last released in 2022 and its API differs; use the `@plausible-analytics/tracker` package.)
- ## 5. The analytics seam (provider-agnostic, no-op without config)
+ ## 5. The analytics seam (provider-agnostic, no-op without config or consent)
- One module owns analytics — swapping providers is a one-file change, exactly like `captureError`.
+ One module owns analytics — swapping providers is a one-file change, exactly like `captureError`. Checked against `@plausible-analytics/tracker` 0.4 (`init` runs once; `track(name, { props })` takes string props).
```ts
// src/libs/analytics.ts
- import Plausible from 'plausible-tracker';
+ import { init, track } from '@plausible-analytics/tracker';
type Props = Record<string, string | number | boolean>;
- let api: ReturnType<typeof Plausible> | null = null;
+ declare global {
+ interface Navigator {
+ globalPrivacyControl?: boolean; // not in lib.dom yet
+ }
+ }
- export function initAnalytics() {
- const domain = import.meta.env.VITE_ANALYTICS_DOMAIN;
- if (!domain || !hasConsent()) return; // no-op locally / without consent
- api = Plausible({ domain });
- api.enableAutoPageviews?.(); // or fire manually via trackPageView
+ /** true = nothing is sent before an explicit opt-in. Flip only with a written legal/DPO decision. */
+ const CONSENT_REQUIRED = true;
+ const CONSENT_KEY = 'analytics-consent';
+ let started = false;
+
+ function consentGranted(): boolean {
+ try {
+ return localStorage.getItem(CONSENT_KEY) === 'granted';
+ } catch {
+ return false; // storage blocked: treat as no consent
+ }
}
- export function trackEvent(name: string, props?: Props) {
- api?.trackEvent(name, props ? { props } : undefined);
+ /** Evaluated on every request, so withdrawing consent stops sending at once. */
+ function mayTrack(): boolean {
+ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return false;
+ return !CONSENT_REQUIRED || consentGranted();
}
- export function trackPageView(url: string) {
- api?.trackPageview({ url });
+ export function initAnalytics(): void {
+ const domain = import.meta.env.VITE_ANALYTICS_DOMAIN;
+ if (!domain || started || !mayTrack()) return; // no config, already running, or no consent: no-op
+ init({
+ domain,
+ autoCapturePageviews: false, // page views come from the router (step 7), the same for any provider
+ transformRequest: (payload) => (mayTrack() ? payload : null),
+ });
+ started = true;
}
- const REQUIRES_CONSENT = false; // Plausible/Fathom (cookieless): false. GA/PostHog (cookies): true.
+ export function trackEvent(name: string, props?: Props): void {
+ if (!started) return;
+ // Plausible custom properties are strings.
+ const stringProps = props && Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]));
+ track(name, stringProps ? { props: stringProps } : {});
+ }
- function hasConsent(): boolean {
- if (!REQUIRES_CONSENT) return true; // cookieless provider — no banner needed
- if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return false;
- return localStorage.getItem('analytics-consent') === 'granted';
+ export function trackPageView(path: string): void {
+ // Path only: query strings carry tokens, emails and search terms.
+ if (started) track('pageview', { url: `${location.origin}${path.split(/[?#]/)[0]}` });
}
- // call from your cookie banner: (re)inits analytics on grant; gate Sentry replay the same way
+ /** Call from the consent banner (the banner itself is outside this skill). */
export function setConsent(granted: boolean): void {
- localStorage.setItem('analytics-consent', granted ? 'granted' : 'denied');
- if (granted) initAnalytics();
+ try {
+ localStorage.setItem(CONSENT_KEY, granted ? 'granted' : 'denied');
+ } catch {
+ /* storage blocked: the choice lasts for this page view only */
+ }
+ if (granted) {
+ initAnalytics();
+ trackPageView(location.pathname);
+ }
}
```
- Call `initAnalytics()` once from the app entry.
+ Call `initAnalytics()` once from the app entry. Gate any other third-party script the same way (Sentry replay, chat widgets): call it from `setConsent`, never at load. Add `VITE_ANALYTICS_DOMAIN` and the tracker's endpoint to the CSP `connect-src` (`set-up-security-headers`); proxy the endpoint through your own domain if ad blockers matter.
## 6. Wire Core Web Vitals as real-user monitoring
Feed the `reportWebVitals` from `optimize-performance` into the seam:
```ts
// app entry, prod only
import { reportWebVitals } from '@/libs/reportWebVitals';
import { trackEvent } from '@/libs/analytics';
if (import.meta.env.PROD) {
reportWebVitals(({ name, value }) =>
- trackEvent('web-vital', { metric: name, value: Math.round(name === 'CLS' ? value * 1000 : value) }),
+ trackEvent('web-vital', { metric: name, value: Math.round(name === 'CLS' ? value * 1000 : value) }), // CLS ×1000: rounding a 0.0x score to an integer would erase it
);
}
```
- Now LCP/INP/CLS are measured on **real users**, not just your Lighthouse run.
+ Now LCP/INP/CLS are measured on **real users**, not just your Lighthouse run. They go through the same seam, so they obey the same consent gate: no consent, no field data. (CLS reports only from Chromium.)
## 7. Page views on route change
```ts
- // React (TanStack Router)
- router.subscribe('onResolved', ({ toLocation }) => trackPageView(toLocation.href));
+ // React (TanStack Router) — fires on the first load too
+ router.subscribe('onResolved', ({ toLocation }) => trackPageView(toLocation.pathname));
// Vue Router
- router.afterEach((to) => trackPageView(to.fullPath));
+ router.afterEach((to) => trackPageView(to.path));
```
- (Skip if you used the provider's `enableAutoPageviews`.)
+ The seam passes the path only, so the router hook never leaks a query string. (A tracker's own SPA auto-capture would double count; step 5 turns it off.)
## 8. Track meaningful events, not everything
Define a small taxonomy (`signup_completed`, `todo_created`, `checkout_started`) and call `trackEvent` at those moments — typically inside the mutation `onSuccess`. Never put PII (email, name, IDs that identify a person) in props.
## 9. Verify
```bash
pnpm build && pnpm preview
```
- With `VITE_ANALYTICS_DOMAIN` set: navigating fires page views, a web-vital event arrives, and a sample `trackEvent` shows in the provider dashboard. Without it set: nothing loads, app unaffected.
+ With `VITE_ANALYTICS_DOMAIN` set **and consent granted**: navigating fires page views, a web-vital event arrives, and a sample `trackEvent` shows in the provider dashboard. Network tab before consent: no request to the provider. After `setConsent(false)`: no further requests. Without the env var: nothing loads, app unaffected.
## References
- - ./analytics-patterns.md — the seam, privacy/consent, no-PII, web-vitals RUM, event taxonomy, no-op-without-config.
+ - ./analytics-patterns.md — the seam, consent (GDPR/TDDDG), no-PII, web-vitals RUM, event taxonomy, no-op-without-config.
- ../optimize-performance/SKILL.md — the `reportWebVitals` source.
- ../configure-error-tracking/SKILL.md — the sibling seam pattern.