analytics
Use when instrumenting product or web analytics — GA4/PostHog SDK wiring, event taxonomy, funnels, double-counted events, consent gating, PII scrubbing. NOT charting that data (that is dashboard), NOT choosing which metrics matter (that is kpi-framework), NOT experiment math (tha
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/analytics
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Analytics — the instrumentation layer
This skill owns the capture side of analytics: deciding what to track, how to name it, where the SDK lives in the codebase, and how not to leak PII or break consent law. It produces three checkable artifacts — an event taxonomy, tracking code (GA4 and/or PostHog), and a consent wiring. Everything downstream of capture (charts, KPI choice, experiment stats, raw-event SQL, legal text) belongs to a sibling; see the routing table below.
The order of work is fixed: taxonomy → SDK wiring → consent gate → PII scrub → funnel + validation.
When NOT to use
| The ask | Route to |
|---|---|
| Chart the captured data on a board | dashboard |
| Decide which metrics matter (North Star, AARRR) | kpi-framework |
| Scheduled stakeholder reports / exports | reporting |
| Variant assignment, significance, experiment design | ab-testing (PostHog experiments live there; PostHog event capture lives here) |
| Query a warehouse of raw events with SQL | clickhouse-analytics / duckdb / sql |
| App error/trace/uptime telemetry (Sentry, OpenTelemetry) | observability |
| Cookie-banner legal text, DPA, ROPA, subject rights | gdpr-privacy / data-policy |
| Predict future values from a series | forecasting |
The load-bearing line: analytics = events flow in; dashboard/reporting = events flow out.
Decision: GA4 vs PostHog vs both
| You need | Pick |
|---|---|
| Web/ads attribution, Google Ads conversions, marketing audiences | GA4 |
| Product behavior, funnels, feature flags, session replay, self-serve insights | PostHog |
| Both marketing attribution and deep product analytics (very common) | Both — GA4 for ads, PostHog for product |
Running both is normal and fine. Keep one taxonomy shared across both so a purchase means the same
thing everywhere. Do not let the two tools drift into two naming schemes.
Step 1 — Event taxonomy first, code second
An event name is a contract: design the taxonomy before you write a single SDK call, and never rename a
live event in production. Every funnel, audience, dashboard, and saved insight downstream is keyed by the
exact event name and property keys. Rename signup_completed to sign_up after launch and you silently
fork the metric into two — the old funnel flatlines, the new one starts from zero, and nobody notices for a
week. You can add events forever; you can never safely rename one.
Name events object_action in snake_case: signup_completed, checkout_started, invoice_paid. The
object is the noun, the action is a past-tense verb. Detail goes in properties, never in the
name — cta_clicked with { location: "navbar" }, not three events navbar_cta, hero_cta, footer_cta.
GA4 hard constraints (the SDK silently truncates or drops violators): event names ≤ 40 chars, alphanumeric +
underscore only, must start with a letter; ≤ 25 params per event; ≤ 25 user properties. Prefer GA4
recommended events — sign_up, login, purchase, add_to_cart, search, generate_lead — with
their prescribed params, because they unlock prebuilt reports and audiences you cannot get from a custom name.
Bad Good
"Clicked The Big Button" → cta_clicked { location: "hero" }
trackSignup_v2 → signup_completed { method: "google" }
purchaseEvent2 → purchase { value: 49, currency: "EUR" }
NavbarCheckoutButton → checkout_started { source: "navbar" }
Identify vs anonymous. Before login the user is anonymous (client_id / distinct_id). On
authentication, call identify(stableUserId, { plan, signup_date }) — the stable id is your DB user id, a
UUID, never the email. On logout call reset() so the next visitor on a shared machine does not inherit
the previous person. The full starter SaaS + e-commerce catalog and property conventions are in
references/event-taxonomy.md.
Step 2 — Wire the SDK
GA4 with the global site tag (Next.js Script shown; the consent block in Step 3 must run before this):
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
PostHog (posthog-js) — the cost/privacy-correct defaults:
import posthog from 'posthog-js';
posthog.init('phc_xxx', {
api_host: '/ingest', // reverse proxy: first-party path beats ad-blockers
ui_host: 'https://eu.posthog.com',
person_profiles: 'identified_only', // no profile per anonymous visitor — cheaper, more private
defaults: '2025-05-24',
// autocapture: false, // turn off if you want a deliberate, named-only taxonomy
});
// on login: posthog.identify(user.id, { plan: user.plan });
// on logout: posthog.reset();
person_profiles: 'identified_only' is the recommended default — it avoids creating a person profile for
every anonymous visitor. A reverse proxy (serving the SDK + ingestion under a first-party path like
/ingest) is standard practice for both PostHog and GA to dodge ad-blockers and tracking-prevention.
Server-side capture for actions off the browser — payment confirmation, webhooks, cron. With
@posthog/next, await getPostHog() works in server components, route handlers, and server actions; it
reads identity from the PostHog cookie (and opts the route into dynamic rendering, since it calls
cookies()). GA4 server events use the Measurement Protocol with the client_id. Full snippets — gtag
install, Consent Mode v2, Measurement Protocol, recommended-event param tables — are in
references/ga4-setup.md and references/posthog-setup.md.
Step 3 — Consent before collection
Decision: do you serve EEA / UK / CH traffic? If yes, Consent Mode v2 is not optional. Since 21 July
2025 Google enforces it for EEA/UK traffic: tags without connected consent signals lose conversion
tracking, remarketing, and demographics. Four params are required and default to denied for EEA/UK/CH:
<!-- This block MUST run BEFORE the gtag('config', ...) call in Step 2. Order is load-bearing. -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500,
});
// when the banner is accepted:
// gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted', ... });
</script>
PostHog's equivalent is posthog.optOut() / posthog.optIn() — start opted-out for EEA visitors and opt
in on acceptance. The legal text of the banner (what it says, the DPA, retention) is gdpr-privacy's job;
this skill only wires the signal the banner emits. Region-scoped defaults live in references/ga4-setup.md.
Step 4 — PII discipline
Never pass these into a capture( / gtag('event' / track( call. They turn an analytics store into a
breach-reportable PII store and violate most processing agreements:
| Banned in event props | Allowed instead |
|---|---|
email, phone, full name |
a hashed id, or set on the person profile only — not on every event |
| raw IP, geolocation coords | let the SDK derive coarse geo server-side |
password, token, secret, API keys, session_id |
nothing — these never belong in analytics |
credit_card, ssn, IBAN |
nothing |
Scrub at the boundary — a single capture() wrapper that strips known PII keys is far safer than trusting
every call site. A GA4 user_id is a stable opaque identifier, not an email; sending an email as the
user_id is a PII leak and a violation of Google's policy.
Step 5 — Funnels & validation
Define the funnel from the named events, in order, e.g. signup_started → signup_completed → project_created → invoice_paid. The funnel is only as reliable as the names, which is why Step 1 comes first.
Before you ship, validate — do not trust that it works:
- GA4: open the DebugView (or watch the network tab for
/g/collecthits) and confirm each event fires once with the right params. - PostHog: watch the Activity / live events feed; confirm
distinct_idis stable across the session. - Do not fire events on render. A
capture()in a React component body or an unguardeduseEffectre-fires on every re-render and double-counts. Fire on the user action, or in auseEffectwith a correct dependency array / a fire-once guard. - Stitching: GA4 Measurement Protocol events must arrive within 48h of the client-side timestamp to
stitch to the right
client_id. If you setuser_idserver-side, set the sameuser_idbrowser-side or you create duplicate users. - Checking a PostHog feature flag emits a
$feature_flag_calledevent — expected, not a bug; budget for it.
Verify
Run scripts/verify.sh [path] (default: cwd). It is a read-only static lint, never a network call. It
flags: PII-looking literals inside capture( / gtag('event' / .track( calls; GA4 event names that break
the ≤ 40-char / leading-letter / charset rule; GA present without a gtag('consent','default' gate; and
posthog.init( with no host (reverse-proxy reminder). It exits 0 on a clean or empty target.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Rename a live event in prod | Forks the metric; old funnel flatlines, new one starts at zero | Add a new event; deprecate the old one in a doc, never rename |
| Treat autocapture as the taxonomy | Autocapture is noisy DOM events, not your domain — funnels become unbuildable | Design named domain events; autocapture is a supplement |
| Email/token in event props | Turns analytics into a breach-reportable PII store; violates the DPA | Scrub at a capture() wrapper; ids only |
| No consent gate for EEA/UK | Since 21 Jul 2025, Google drops conversions/remarketing/demographics | gtag('consent','default', denied) before config; PostHog optOut |
capture() in render / unguarded effect |
Re-fires every re-render → double-counting | Fire on the action or a fire-once-guarded effect |
Server user_id ≠ browser user_id |
Creates duplicate users; funnel splits | Use the same stable id on both sides; stitch within 48h |
posthog.init with no proxy host |
Ad-blockers eat ~20-40% of events | Serve SDK + ingest under a first-party path (/ingest) |
Email as GA4 user_id |
PII leak + Google policy violation | A stable opaque id (DB id / UUID) |
Files (rsc-harness)
-
evals
-
cases.yaml 3.5 KB
skill: analytics should_trigger: - prompt: "Set up GA4 on our Next.js app and track signup and checkout." why: Core instrumentation — install the SDK and wire named events for the signup/checkout funnel. - prompt: "Add PostHog and design the event taxonomy for our SaaS." why: Taxonomy design plus SDK wiring — the exact capture-layer work this skill owns. - prompt: "Our purchase events are firing twice and counting double — fix the tracking." why: Non-obvious. Double-counting from render-fired/unguarded-effect captures is a capture-layer bug, not a dashboard or stats problem. - prompt: "Instrumentar analítica de producto con PostHog y configurar el consentimiento." why: Spanish trigger covering capture + consent wiring. - prompt: "Consent Mode v2 enforcement broke our Google Ads conversions — wire consent properly." why: Non-obvious privacy-of-collection task — wiring the gtag consent signal, not writing legal text. - prompt: "Make sure we're not leaking user emails into our analytics event properties." why: PII discipline at the capture boundary — scrubbing props before capture/track. - prompt: "Els esdeveniments de compra es compten dos cops — revisa la instrumentació." why: Catalan trigger for double-counting in the capture layer. - prompt: "Send a server-side purchase event from our Stripe webhook into GA4 with the right client_id." why: Server-side Measurement Protocol capture with client_id stitching — squarely instrumentation. should_not_trigger: - prompt: "Build a dashboard to chart our weekly active users." route_to: dashboard why: Visualization of data already captured — events flow out, not in. - prompt: "Decide our North Star metric and the activation KPI." route_to: kpi-framework why: Choosing what to measure, not wiring the measurement in code. - prompt: "Run an A/B test on the new pricing page and tell me if it's significant." route_to: ab-testing why: Experiment design and significance math; this skill only owns the event the experiment reads. - prompt: "Write our cookie policy and the GDPR data-processing notice." route_to: gdpr-privacy why: Legal text and policy drafting, not the consent signal wiring. - prompt: "Set up Sentry / OpenTelemetry tracing for our API errors." route_to: observability why: Error/trace/uptime telemetry, a different telemetry domain. - prompt: "Query the raw events table in ClickHouse with SQL." route_to: clickhouse-analytics why: Warehouse querying of landed events, not capture. capability: - scenario: > Add product analytics to a Next.js SaaS that serves EU users: pick a tool, design the signup -> activation -> purchase funnel events, wire the SDK, gate on consent, and avoid PII. must_include: - An event taxonomy table using object_action snake_case names (e.g. signup_completed, invoice_paid) - A tool-choice rationale (PostHog vs GA4 vs both) tied to the product-analytics need - An SDK init snippet with reverse-proxy host and person_profiles 'identified_only' (PostHog) OR a gtag config block (GA4) - Consent Mode v2 default-denied (or PostHog opt_out) gating that runs BEFORE collection, because EU users are served - A PII rule — no email/token in event props; identify uses a stable id, not the email - A funnel defined from the named events in order - reset() on logout and a correct identify on login - A validation step before shipping (GA4 DebugView or PostHog live events / network tab) confirming each event fires once -
README.md 779 B
# analytics — evals These cases feed the repo's skill-eval harness. `should_trigger` and `should_not_trigger` check routing precision: each negative names the real sibling id it should route to instead (`dashboard`, `kpi-framework`, `ab-testing`, `gdpr-privacy`, `observability`, `clickhouse-analytics`), so the grader can confirm the boundary holds. `capability` is a single rubric-graded scenario — the model proposes an instrumentation plan and code, and the grader checks the `must_include` list against the answer. No live GA4 or PostHog account is needed: nothing here sends real events; the harness grades the *proposed* taxonomy, wiring, consent gate, and PII handling, not a live data flow. Run it through the repo's standard eval runner against this `cases.yaml`.
-
-
references
-
event-taxonomy.md 3.5 KB
# Event taxonomy reference Depth offloaded from `../SKILL.md` Step 1. The taxonomy is the contract; design it before SDK code. ## Naming rules - **`object_action`, `snake_case`, action in past tense.** `signup_completed`, `checkout_started`, `invoice_paid`. Object = noun, action = what happened. - **Detail goes in properties, not the name.** One `cta_clicked` with `{ location }` beats four named variants you can never aggregate. - **GA4 hard limits** (the SDK silently truncates/drops violators): name ≤ 40 chars, `[a-z0-9_]` only, **must start with a letter**; ≤ 25 params/event; ≤ 25 user properties. - **Prefer GA4 recommended events** (`sign_up`, `login`, `purchase`, `add_to_cart`, `search`, `generate_lead`) — they unlock prebuilt reports/audiences a custom name cannot. - **The anti-rename rule:** you may add events forever; never rename a live one. A rename forks the metric — the old funnel flatlines, the new one starts at zero. Deprecate in a doc instead. ## Property conventions - Stable, lowercase, snake_case keys: `plan`, `source`, `value`, `currency`, `referrer`. - Types stay consistent: `value` is always a number, `currency` always an ISO code. Mixed types break aggregation downstream. - **No PII in props** — see the banlist below. Identity goes on the person profile via `identify`, set once, not stamped on every event. ## Identify vs anonymous vs group | Concept | When | Call | | --- | --- | --- | | Anonymous | pre-login; identity is `client_id` / `distinct_id` | nothing — auto | | Identify | on authentication; merge anonymous history into the user | `identify(stableId, { plan })` — `stableId` is a DB id / UUID, **never email** | | Group | B2B account-level rollups (per company/workspace) | `group('company', orgId, { name })` | | Reset | on logout, especially shared machines | `reset()` — prevents identity bleed | ## PII banlist (never in event props) `email`, `phone`, full name, raw IP, geo coordinates, `password`, `token`, `secret`, API keys, `session_id`, `credit_card`, `ssn`, IBAN. Scrub these at a single `capture()` wrapper rather than trusting every call site. ## Starter SaaS catalog | Event | Key properties | Funnel stage | | --- | --- | --- | | `signup_started` | `source` | acquisition | | `signup_completed` | `method` (`google` / `email`) | acquisition | | `onboarding_step_completed` | `step`, `step_number` | activation | | `project_created` | `template` | activation | | `invite_sent` | `count` | activation | | `subscription_started` | `plan`, `value`, `currency` | revenue | | `invoice_paid` | `value`, `currency`, `invoice_id` | revenue | | `feature_used` | `feature` | retention | Example funnel: `signup_started → signup_completed → project_created → invoice_paid`. ## Starter e-commerce catalog (GA4-aligned) | Event | Key properties | | --- | --- | | `view_item` | `items`, `value`, `currency` | | `add_to_cart` | `items`, `value`, `currency` | | `begin_checkout` | `items`, `value`, `currency` | | `add_payment_info` | `payment_type` | | `purchase` | `transaction_id`, `value`, `currency`, `items` | | `refund` | `transaction_id`, `value`, `currency` | Example funnel: `view_item → add_to_cart → begin_checkout → purchase`. ## Sources - GA4 event-name constraints / Measurement Protocol: https://developers.google.com/analytics/devguides/collection/protocol/ga4 - GA4 recommended events: https://support.google.com/analytics/answer/9267735 - PostHog identify/group: https://posthog.com/docs/references/posthog-js Accessed 2026-06-02. -
ga4-setup.md 4.5 KB
# GA4 setup reference Depth offloaded from `../SKILL.md` Steps 2-3. Use current gtag.js APIs. ## Install — Next.js App Router Load the tag once, globally. The consent default block (below) MUST execute before `gtag('config', ...)`. ```tsx // app/layout.tsx import Script from 'next/script'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> {children} <Script id="ga-consent" strategy="beforeInteractive">{` window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments); } gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', wait_for_update: 500, }); `}</Script> <Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX" strategy="afterInteractive" /> <Script id="ga-config" strategy="afterInteractive">{` gtag('js', new Date()); gtag('config', 'G-XXXXXXXXXX'); `}</Script> </body> </html> ); } ``` Custom event from the client: ```ts gtag('event', 'purchase', { value: 49.0, currency: 'EUR', transaction_id: 'ord_123' }); ``` ## Consent Mode v2 — region-scoped defaults Default to `denied` for EEA/UK/CH; you may default `granted` elsewhere. Enforced for EEA/UK traffic since **21 July 2025** — tags without connected consent lose conversion tracking, remarketing, and demographics. ```html <script> // EEA/UK/CH: deny by default gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', wait_for_update: 500, region: ['ES','FR','DE','IT','PT','NL','BE','IE','PL','SE','DK','FI','AT','GR','CZ','RO','HU','GB','CH','NO','IS','LI'], }); // rest of world: allow by default gtag('consent', 'default', { ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted', analytics_storage: 'granted', }); // on banner accept: function onConsentAccepted() { gtag('consent', 'update', { ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted', analytics_storage: 'granted', }); } </script> ``` The four params are required: `ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage`. `wait_for_update` (ms) holds tags briefly so a fast banner choice is respected before the first hit. ## Measurement Protocol — server-side event For actions off the browser (payment confirmation, webhook, cron). Send the **same `client_id`** the browser used so the event stitches to the right user. Must arrive within **48h** of the client timestamp. ```bash curl -X POST \ "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "client_id": "1234567.7654321", "user_id": "db_user_42", "events": [{ "name": "purchase", "params": { "value": 49.0, "currency": "EUR", "transaction_id": "ord_123" } }] }' ``` Limits: ≤ 25 events per request; event name ≤ 40 chars, alphanumeric + underscore, must start with a letter; ≤ 25 params/event; ≤ 25 user properties. If you set `user_id` server-side, set the **same** value browser-side or you create duplicate users. ## Recommended events (prefer over custom names) | Event | Key params | Unlocks | | --- | --- | --- | | `sign_up` | `method` | sign-up reports / audiences | | `login` | `method` | engaged-user segments | | `purchase` | `value`, `currency`, `transaction_id`, `items` | revenue reports, ecommerce | | `add_to_cart` | `value`, `currency`, `items` | cart-abandon funnels | | `search` | `search_term` | site-search reports | | `generate_lead` | `value`, `currency` | lead audiences for Ads | ## Validate before shipping Open **Admin → DebugView** (or filter the network tab for `/g/collect`). Confirm each event fires **once** with the expected params and that the `client_id` is stable. A double `/g/collect` for one action means a render-fired event — guard it. ## Sources - Consent guide + July-2025 enforcement: https://developers.google.com/tag-platform/security/guides/consent - Measurement Protocol: https://developers.google.com/analytics/devguides/collection/protocol/ga4 - Sending events: https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events - Recommended events: https://support.google.com/analytics/answer/9267735 Accessed 2026-06-02. -
posthog-setup.md 3.7 KB
# PostHog setup reference Depth offloaded from `../SKILL.md` Step 2. Use current `posthog-js` and `@posthog/next` APIs. ## `posthog.init` options that matter ```ts import posthog from 'posthog-js'; posthog.init('phc_xxx', { api_host: '/ingest', // reverse proxy first-party path (recommended) ui_host: 'https://eu.posthog.com', // EU cloud; use https://us.posthog.com for US person_profiles: 'identified_only', // no profile per anonymous visitor — cost + privacy default defaults: '2025-05-24', // pin behavior defaults to a known date autocapture: true, // clicks/inputs/forms on a, button, form, input, select, textarea, label capture_pageview: true, }); ``` - `person_profiles: 'identified_only'` — the recommended default. Without it you create a person profile for every anonymous visitor (more cost, more data). - **EU vs US host:** pick the region your account is in; EU keeps data in the EU for residency. - `autocapture: false` — set this if you want a deliberate, named-only taxonomy with no DOM noise. ## Core API ```ts posthog.capture('signup_completed', { method: 'google' }); // never put email/token in props posthog.identify(user.id, { plan: user.plan }); // stable id, NOT the email posthog.group('company', org.id, { name: org.name }); // B2B account-level analytics posthog.register({ app_version: '2.1.0' }); // super-properties on every event posthog.unregister('app_version'); posthog.reset(); // on logout — critical on shared machines ``` ## Consent ```ts // EEA visitor: start opted-out; opt in only on banner accept. posthog.opt_out_capturing(); function onConsentAccepted() { posthog.opt_in_capturing(); } ``` The legal banner text is `gdpr-privacy`'s job; this only wires the opt-in/opt-out signal. ## Reverse proxy (Next.js rewrite) Serving the SDK + ingestion under a first-party path beats ad-blockers and tracking-prevention, which otherwise eat a meaningful share of events. ```ts // next.config.js async rewrites() { return [ { source: '/ingest/static/:path*', destination: 'https://eu-assets.i.posthog.com/static/:path*' }, { source: '/ingest/:path*', destination: 'https://eu.i.posthog.com/:path*' }, ]; } ``` ## Server-side capture with `@posthog/next` Use for trusted server-side captures (payment confirmation, webhook, server action). `getPostHog()` reads identity from the PostHog cookie and opts the route into dynamic rendering (it calls `cookies()`). ```ts // app/api/checkout/route.ts import { getPostHog } from '@posthog/next'; export async function POST(req: Request) { const posthog = await getPostHog(); posthog.capture({ event: 'invoice_paid', properties: { value: 49, currency: 'EUR' } }); return Response.json({ ok: true }); } ``` ## Feature flags ```ts if (posthog.isFeatureEnabled('new-checkout')) { /* ... */ } const variant = posthog.getFeatureFlag('pricing-test'); ``` Reading a flag emits a `$feature_flag_called` event — expected, not a bug; budget for it in event volume. Experiment *design and stats* belong to `ab-testing`, not here. ## Validate Watch the **Activity / live events** feed in PostHog after a test action. Confirm each named event arrives **once** and that `distinct_id` stays stable across the session and survives login (via `identify`). ## Sources - JS SDK: https://posthog.com/docs/references/posthog-js — repo: https://github.com/posthog/posthog-js - Config: https://posthog.com/docs/libraries/js/config - Feature flags: https://posthog.com/docs/feature-flags - `@posthog/next`: https://github.com/posthog/posthog-js/tree/main/packages/next - Reverse proxy: https://posthog.com/docs/advanced/proxy Accessed 2026-06-02.
-
-
scripts
-
verify.sh 4.4 KB
#!/usr/bin/env bash set -euo pipefail # verify.sh — analytics skill gate. Run from your PROJECT root (or pass a path). # # What it does (read-only, idempotent, NEVER connects to a network/SDK): # 1. Discovers source files (js/ts/jsx/tsx/html/vue/svelte), skipping vendor dirs. # 2. PII literals inside capture/track/gtag('event' calls -> [fail] # 3. GA4 event names breaking <=40 chars / leading-letter / charset -> [fail] # 4. GA present (gtag(/gtag.js) but no gtag('consent','default' -> [fail] missing consent gate # 5. posthog.init( with no host option nearby -> [warn] reverse-proxy reminder # # Exit non-zero ONLY on a hard failure (2,3,4), printing the offending file:line. # Check 5 is advisory. An empty or clean target exits 0. Stock macOS bash 3.2. YELLOW=$'\033[33m'; GREEN=$'\033[32m'; RED=$'\033[31m'; NC=$'\033[0m' EXIT=0 skip() { printf '%s[skip]%s %s\n' "$YELLOW" "$NC" "$*"; } note() { printf '%s[warn]%s %s\n' "$YELLOW" "$NC" "$*"; } ok() { printf '%s[ok]%s %s\n' "$GREEN" "$NC" "$*"; } err() { printf '%s[fail]%s %s\n' "$RED" "$NC" "$*"; EXIT=1; } ROOT="${1:-$(pwd)}" if [ ! -e "$ROOT" ]; then skip "path not found: $ROOT — nothing to lint"; ok "verify.sh passed (empty target)"; exit 0; fi FILES=() while IFS= read -r -d '' f; do FILES+=("$f") done < <( find "$ROOT" \ \( -path '*/node_modules/*' -o -path '*/.git/*' -o -path '*/vendor/*' -o -path '*/.next/*' \ -o -path '*/dist/*' -o -path '*/build/*' -o -path '*/.venv/*' \) -prune -o \ -type f \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' \ -o -name '*.mjs' -o -name '*.cjs' -o -name '*.html' -o -name '*.vue' -o -name '*.svelte' \) \ -print0 2>/dev/null ) if [ "${#FILES[@]}" -eq 0 ]; then skip "no source files found under $ROOT — nothing to lint" ok "verify.sh passed (empty target)" exit 0 fi # Lines that look like an analytics capture call. CAPTURE_RE='(\.capture\(|posthog\.capture\(|\.track\(|gtag\([[:space:]]*['"'"'"]event['"'"'"])' # PII key names that must never ride inside a capture call. PII_RE='(email|e_mail|password|passwd|token|secret|api_?key|access_?key|ssn|credit_?card|card_?number|cvv|iban|phone_?number)' GA_PRESENT=0 GA_CONSENT=0 for f in "${FILES[@]}"; do # --- (2) PII literals inside capture/track/gtag event calls (hard fail) --- while IFS= read -r hit; do [ -n "$hit" ] && err "$f:$hit — PII-looking key inside a capture/track call; scrub before sending" done < <(grep -nEi "$CAPTURE_RE" "$f" 2>/dev/null | grep -Ei "$PII_RE" | cut -d: -f1) # --- (3) GA4 event names breaking the rule (hard fail) --- # extract names from gtag('event', '<name>'...) and validate. while IFS= read -r ln; do lineno="${ln%%:*}" name="$(printf '%s' "$ln" | sed -E "s/.*gtag\([[:space:]]*['\"]event['\"][[:space:]]*,[[:space:]]*['\"]([^'\"]*)['\"].*/\1/")" [ -z "$name" ] && continue if [ "${#name}" -gt 40 ]; then err "$f:$lineno — GA4 event name '$name' exceeds 40 chars"; fi if ! printf '%s' "$name" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*$'; then err "$f:$lineno — GA4 event name '$name' must start with a letter and use only [A-Za-z0-9_]" fi done < <(grep -nE "gtag\([[:space:]]*['\"]event['\"]" "$f" 2>/dev/null) # --- track GA presence + consent for check (4) --- if grep -Eq "gtag\(|googletagmanager\.com/gtag/js" "$f" 2>/dev/null; then GA_PRESENT=1; fi if grep -Eq "gtag\([[:space:]]*['\"]consent['\"][[:space:]]*,[[:space:]]*['\"]default['\"]" "$f" 2>/dev/null; then GA_CONSENT=1; fi # --- (5) posthog.init with no host nearby (advisory) --- while IFS= read -r ln; do lineno="${ln%%:*}" # look at the init line and the 8 lines after it for api_host/ui_host/host. if ! sed -n "${lineno},$((lineno+8))p" "$f" 2>/dev/null | grep -Eq '(api_host|ui_host|[^a-z_]host)[[:space:]]*:'; then note "$f:$lineno — posthog.init( with no api_host/ui_host; consider a reverse proxy to dodge ad-blockers" fi done < <(grep -nE 'posthog\.init\(' "$f" 2>/dev/null) done # --- (4) GA present but no consent default gate (hard fail) --- if [ "$GA_PRESENT" -eq 1 ] && [ "$GA_CONSENT" -eq 0 ]; then err "GA4/gtag detected but no gtag('consent','default', ...) gate found — required for EEA/UK since 21 Jul 2025" fi printf '\n' if [ "$EXIT" -eq 0 ]; then ok "verify.sh passed — scanned ${#FILES[@]} file(s)" else err "verify.sh found failures" fi exit "$EXIT"
-
-
SKILL.md 11.2 KB
--- name: analytics description: "Use when instrumenting product or web analytics — GA4/PostHog SDK wiring, event taxonomy, funnels, double-counted events, consent gating, PII scrubbing. NOT charting that data (that is dashboard), NOT choosing which metrics matter (that is kpi-framework), NOT experiment math (that is ab-testing), NOT cookie-policy text (that is gdpr-privacy)." tags: [analytics, ga4, posthog, event-tracking, telemetry, consent-mode, funnels, privacy] recommends: [kpi-framework, ab-testing, gdpr-privacy, nextjs, dashboard, clickhouse-analytics] origin: risco --- # Analytics — the instrumentation layer This skill owns the **capture** side of analytics: deciding *what to track*, *how to name it*, *where the SDK lives in the codebase*, and *how not to leak PII or break consent law*. It produces three checkable artifacts — an event taxonomy, tracking code (GA4 and/or PostHog), and a consent wiring. Everything downstream of capture (charts, KPI choice, experiment stats, raw-event SQL, legal text) belongs to a sibling; see the routing table below. The order of work is fixed: **taxonomy → SDK wiring → consent gate → PII scrub → funnel + validation.** ## When NOT to use | The ask | Route to | | --- | --- | | Chart the captured data on a board | `dashboard` | | Decide *which* metrics matter (North Star, AARRR) | `kpi-framework` | | Scheduled stakeholder reports / exports | `reporting` | | Variant assignment, significance, experiment design | `ab-testing` (PostHog *experiments* live there; PostHog *event capture* lives here) | | Query a warehouse of raw events with SQL | `clickhouse-analytics` / `duckdb` / `sql` | | App error/trace/uptime telemetry (Sentry, OpenTelemetry) | `observability` | | Cookie-banner legal text, DPA, ROPA, subject rights | `gdpr-privacy` / `data-policy` | | Predict future values from a series | `forecasting` | The load-bearing line: `analytics` = events flow **in**; `dashboard`/`reporting` = events flow **out**. ## Decision: GA4 vs PostHog vs both | You need | Pick | | --- | --- | | Web/ads attribution, Google Ads conversions, marketing audiences | **GA4** | | Product behavior, funnels, feature flags, session replay, self-serve insights | **PostHog** | | Both marketing attribution *and* deep product analytics (very common) | **Both** — GA4 for ads, PostHog for product | Running both is normal and fine. Keep **one taxonomy** shared across both so a `purchase` means the same thing everywhere. Do not let the two tools drift into two naming schemes. ## Step 1 — Event taxonomy first, code second **An event name is a contract: design the taxonomy before you write a single SDK call, and never rename a live event in production.** Every funnel, audience, dashboard, and saved insight downstream is keyed by the exact event name and property keys. Rename `signup_completed` to `sign_up` after launch and you silently fork the metric into two — the old funnel flatlines, the new one starts from zero, and nobody notices for a week. You can add events forever; you can never safely rename one. Name events `object_action` in `snake_case`: `signup_completed`, `checkout_started`, `invoice_paid`. The **object** is the noun, the **action** is a past-tense verb. Detail goes in **properties**, never in the name — `cta_clicked` with `{ location: "navbar" }`, not three events `navbar_cta`, `hero_cta`, `footer_cta`. GA4 hard constraints (the SDK silently truncates or drops violators): event names ≤ 40 chars, alphanumeric + underscore only, **must start with a letter**; ≤ 25 params per event; ≤ 25 user properties. Prefer GA4 **recommended events** — `sign_up`, `login`, `purchase`, `add_to_cart`, `search`, `generate_lead` — with their prescribed params, because they unlock prebuilt reports and audiences you cannot get from a custom name. ```text Bad Good "Clicked The Big Button" → cta_clicked { location: "hero" } trackSignup_v2 → signup_completed { method: "google" } purchaseEvent2 → purchase { value: 49, currency: "EUR" } NavbarCheckoutButton → checkout_started { source: "navbar" } ``` **Identify vs anonymous.** Before login the user is anonymous (`client_id` / `distinct_id`). On authentication, call `identify(stableUserId, { plan, signup_date })` — the stable id is your DB user id, a UUID, **never the email**. On logout call `reset()` so the next visitor on a shared machine does not inherit the previous person. The full starter SaaS + e-commerce catalog and property conventions are in `references/event-taxonomy.md`. ## Step 2 — Wire the SDK GA4 with the global site tag (Next.js `Script` shown; the consent block in Step 3 must run *before* this): ```html <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> <script> window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-XXXXXXXXXX'); </script> ``` PostHog (`posthog-js`) — the cost/privacy-correct defaults: ```ts import posthog from 'posthog-js'; posthog.init('phc_xxx', { api_host: '/ingest', // reverse proxy: first-party path beats ad-blockers ui_host: 'https://eu.posthog.com', person_profiles: 'identified_only', // no profile per anonymous visitor — cheaper, more private defaults: '2025-05-24', // autocapture: false, // turn off if you want a deliberate, named-only taxonomy }); // on login: posthog.identify(user.id, { plan: user.plan }); // on logout: posthog.reset(); ``` `person_profiles: 'identified_only'` is the recommended default — it avoids creating a person profile for every anonymous visitor. A **reverse proxy** (serving the SDK + ingestion under a first-party path like `/ingest`) is standard practice for both PostHog and GA to dodge ad-blockers and tracking-prevention. **Server-side capture** for actions off the browser — payment confirmation, webhooks, cron. With `@posthog/next`, `await getPostHog()` works in server components, route handlers, and server actions; it reads identity from the PostHog cookie (and opts the route into dynamic rendering, since it calls `cookies()`). GA4 server events use the Measurement Protocol with the `client_id`. Full snippets — gtag install, Consent Mode v2, Measurement Protocol, recommended-event param tables — are in `references/ga4-setup.md` and `references/posthog-setup.md`. ## Step 3 — Consent before collection **Decision: do you serve EEA / UK / CH traffic?** If yes, Consent Mode v2 is not optional. Since **21 July 2025** Google enforces it for EEA/UK traffic: tags without connected consent signals lose conversion tracking, remarketing, and demographics. Four params are required and **default to `denied`** for EEA/UK/CH: ```html <!-- This block MUST run BEFORE the gtag('config', ...) call in Step 2. Order is load-bearing. --> <script> window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments); } gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', wait_for_update: 500, }); // when the banner is accepted: // gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted', ... }); </script> ``` PostHog's equivalent is `posthog.optOut()` / `posthog.optIn()` — start opted-out for EEA visitors and opt in on acceptance. The legal *text* of the banner (what it says, the DPA, retention) is `gdpr-privacy`'s job; this skill only wires the **signal** the banner emits. Region-scoped defaults live in `references/ga4-setup.md`. ## Step 4 — PII discipline Never pass these into a `capture(` / `gtag('event'` / `track(` call. They turn an analytics store into a breach-reportable PII store and violate most processing agreements: | Banned in event props | Allowed instead | | --- | --- | | `email`, `phone`, full name | a hashed id, or set on the person profile only — not on every event | | raw IP, geolocation coords | let the SDK derive coarse geo server-side | | `password`, `token`, `secret`, API keys, `session_id` | nothing — these never belong in analytics | | `credit_card`, `ssn`, IBAN | nothing | Scrub at the boundary — a single `capture()` wrapper that strips known PII keys is far safer than trusting every call site. A GA4 `user_id` is a **stable opaque identifier, not an email**; sending an email as the `user_id` is a PII leak *and* a violation of Google's policy. ## Step 5 — Funnels & validation Define the funnel from the **named events**, in order, e.g. `signup_started → signup_completed → project_created → invoice_paid`. The funnel is only as reliable as the names, which is why Step 1 comes first. Before you ship, **validate** — do not trust that it works: - GA4: open the **DebugView** (or watch the network tab for `/g/collect` hits) and confirm each event fires once with the right params. - PostHog: watch the **Activity** / live events feed; confirm `distinct_id` is stable across the session. - **Do not fire events on render.** A `capture()` in a React component body or an unguarded `useEffect` re-fires on every re-render and double-counts. Fire on the user action, or in a `useEffect` with a correct dependency array / a fire-once guard. - **Stitching:** GA4 Measurement Protocol events must arrive **within 48h** of the client-side timestamp to stitch to the right `client_id`. If you set `user_id` server-side, set the **same** `user_id` browser-side or you create duplicate users. - Checking a PostHog feature flag emits a `$feature_flag_called` event — expected, not a bug; budget for it. ## Verify Run `scripts/verify.sh [path]` (default: cwd). It is a **read-only static lint**, never a network call. It flags: PII-looking literals inside `capture(` / `gtag('event'` / `.track(` calls; GA4 event names that break the ≤ 40-char / leading-letter / charset rule; GA present without a `gtag('consent','default'` gate; and `posthog.init(` with no host (reverse-proxy reminder). It exits 0 on a clean or empty target. ## Anti-patterns | Anti-pattern | Why it bites | Do instead | | --- | --- | --- | | Rename a live event in prod | Forks the metric; old funnel flatlines, new one starts at zero | Add a new event; deprecate the old one in a doc, never rename | | Treat autocapture as the taxonomy | Autocapture is noisy DOM events, not your domain — funnels become unbuildable | Design named domain events; autocapture is a supplement | | Email/token in event props | Turns analytics into a breach-reportable PII store; violates the DPA | Scrub at a `capture()` wrapper; ids only | | No consent gate for EEA/UK | Since 21 Jul 2025, Google drops conversions/remarketing/demographics | `gtag('consent','default', denied)` before config; PostHog `optOut` | | `capture()` in render / unguarded effect | Re-fires every re-render → double-counting | Fire on the action or a fire-once-guarded effect | | Server `user_id` ≠ browser `user_id` | Creates duplicate users; funnel splits | Use the same stable id on both sides; stitch within 48h | | `posthog.init` with no proxy host | Ad-blockers eat ~20-40% of events | Serve SDK + ingest under a first-party path (`/ingest`) | | Email as GA4 `user_id` | PII leak + Google policy violation | A stable opaque id (DB id / UUID) |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.