Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_analytics-953fef5.zip · 15 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/analytics
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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/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)
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.

No comments yet.

Reviews (0)

No reviews yet.

Related