Integrations No. 415

Product Analytics skill for Claude Code

One person per user, one name per event. Makes Claude implement analytics the way a careful data team would: a typed track wrapper instead of scattered strings, identify with internal IDs and reset on logout, revenue captured from verified payment webhooks with dedupe IDs, and every SDK held behind consent. Funnels are checked against raw events so dashboards can be trusted rather than argued about.

  • nameproduct-analytics
  • size218 lines · ≈2,300 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Integrates product analytics SDKs - PostHog, Mixpanel, and GA4 - into web and server code with a consistent event taxonomy, correct identify/alias and reset flows, group analytics for B2B accounts, server-side capture for revenue events, consent gating, and funnel and retention definitions. Use when adding PostHog, Mixpanel or GA4 to an app, users appear duplicated or anonymous, funnels do not add up, events need to be sent from the backend, or when implementing cookie consent for analytics.

Try asking

  • Add PostHog to our React app with identify on login and cookie consent
  • Users show up twice in Mixpanel after they log in on mobile - fix the identity merge
  • Send purchase events to GA4 from our Stripe webhook instead of the thank-you page

Use it when

  • Adding PostHog, Mixpanel, or GA4 SDKs to web or backend code
  • Users are duplicated, merged wrongly, or stay anonymous after login
  • Revenue or signup counts in analytics disagree with the database
  • Analytics must respect a cookie consent banner or Consent Mode v2

Skip it when

How to check the result

Walk the core funnel in the tool's debug view, confirm each event fires once with typed properties, a second login merges into one person, and declining consent stops all analytics requests.

The common mistake it prevents

Skipping reset() on logout merges the next person on the same device into the previous user's profile, corrupting every per-user metric.

~/.claude/skills/product-analytics/SKILL.md

--- name: product-analytics description: Integrates product analytics SDKs - PostHog, Mixpanel, and GA4 - into web and server code with a consistent event taxonomy, correct… license: MIT ---

Analytics is integrated well when one user is one person across devices and logins, events follow one naming convention and carry typed properties, business-critical events (signup, purchase, subscription change) are captured server-side, nothing fires before consent where consent is required, and the key funnel can be reproduced from raw events.

Ground rules

  • One event taxonomy, enforced in code: a typed track() wrapper with an allow-list of event names, not scattered string literals. The tracking plan itself (who owns which event, naming spec, QA) is the analytics-tracking-plan skill; this skill implements it.
  • Capture outcomes on the server, intent on the client. Ad blockers drop 10-30% of client events on developer audiences; payments must never depend on the browser.
  • Identify with your stable internal user ID, never email (emails change and are PII).
  • Call reset() on logout, or the next person on a shared device inherits the identity.
  • Project API keys for PostHog/Mixpanel/GA4 are public by design; personal/admin API keys and GA4 Measurement Protocol api_secret stay server-side in env.
  • Do not send PII (email, name, free text, full URLs with tokens) as event properties unless the privacy policy and DPA cover it. Strip query strings containing tokens.

Tool choice

NeedDefault
Product analytics + flags + replay + experiments in one, self-host optionPostHog
Mature behavioral analytics, strong funnels/retention UI, warehouse syncMixpanel
Marketing attribution, Google Ads audiences, SEO landing reportsGA4 (often alongside one of the above)
Data warehouse is the source of truthSend events to a CDP/warehouse first (Segment, RudderStack, or direct), fan out

Running GA4 for marketing plus PostHog or Mixpanel for product is common; keep event names aligned through one wrapper so both receive the same taxonomy.

Workflow

- [ ] 1. Read the tracking plan (or draft a minimal one: 10-20 events)
- [ ] 2. Build a typed track/identify wrapper
- [ ] 3. Initialize SDKs behind consent
- [ ] 4. Implement identity: anonymous -> identify -> reset
- [ ] 5. Add group/account analytics for B2B
- [ ] 6. Move revenue and lifecycle events server-side
- [ ] 7. Define funnels and verify against raw events

2. Typed wrapper

// analytics/events.ts
export type Events = {
  signup_completed: { method: "email" | "google" | "github"; plan: string };
  project_created: { project_id: string; template: string | null };
  invite_sent: { project_id: string; role: "admin" | "member" };
  checkout_started: { plan: string; billing_period: "monthly" | "annual" };
};

export function track<E extends keyof Events>(event: E, props: Events[E]) {
  if (!consent.analytics) return;
  posthog.capture(event, props);
  window.gtag?.("event", event, props);
}

Naming: object_action in snake_case past tense (project_created), properties in snake_case, IDs as strings, money in minor units plus currency. Read references/event-taxonomy.md when drafting or reviewing the event list, properties, or GA4 limits.

PostHog:

import posthog from "posthog-js";

posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
  api_host: import.meta.env.VITE_POSTHOG_HOST,   // or your reverse proxy path
  person_profiles: "identified_only",
  opt_out_capturing_by_default: requiresConsent(),  // EU/UK visitors
  capture_pageview: true,
});
// when the user accepts analytics cookies:
posthog.opt_in_capturing();

Mixpanel:

import mixpanel from "mixpanel-browser";
mixpanel.init(import.meta.env.VITE_MIXPANEL_TOKEN, {
  track_pageview: "url-with-path",
  persistence: "localStorage",
  opt_out_tracking_by_default: requiresConsent(),
});
// on consent: mixpanel.opt_in_tracking();

GA4 with Consent Mode v2 (defaults must run before the gtag config):

<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
  });
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXX');
  // on consent: gtag('consent', 'update', { analytics_storage: 'granted' });
</script>
  • Route client SDK traffic through your own domain (reverse proxy) to reduce blocker loss; PostHog documents proxy setups for common hosts.
  • Consent must be revocable: call opt_out_capturing() / opt_out_tracking() / consent update denied when the user withdraws.

4. Identity

MomentPostHogMixpanelGA4
Anonymous visitautomatic anonymous IDautomatic device IDclient_id cookie
Signup or loginposthog.identify(user.id, { plan })mixpanel.identify(user.id)gtag('config', ID, { user_id })
Profile traits$set via identify / posthog.setPersonPropertiesmixpanel.people.set({...})user properties via gtag('set', 'user_properties', {...})
Logoutposthog.reset()mixpanel.reset()clear user_id
  • Identify immediately after auth succeeds on the client, and use the same ID in server events so they merge.
  • Never call identify with a different user ID without reset first; most tools merge the two identities permanently.
  • For server events right after signup, pass the anonymous/distinct ID from the client (header or form field) when the tool needs it to stitch the pre-signup journey.

5. B2B group analytics

  • PostHog: posthog.group("company", org.id, { name, plan }); server: capture({ distinctId, event, groups: { company: org.id } }).
  • Mixpanel: group analytics with a group key (mixpanel.set_group("company_id", id)), enabled per project.
  • Always also put org_id as an event property; it survives tool migrations.

6. Server-side capture

PostHog Node:

import { PostHog } from "posthog-node";
const ph = new PostHog(process.env.POSTHOG_KEY!, { host: process.env.POSTHOG_HOST });

ph.capture({
  distinctId: user.id,
  event: "subscription_started",
  properties: { plan: "pro", amount_cents: 2900, currency: "USD" },
  groups: { company: user.orgId },
});
await ph.shutdown(); // in serverless/short-lived processes, flush before exit

GA4 Measurement Protocol (server secret from env; validate first against the debug endpoint):

curl -s "https://www.google-analytics.com/debug/mp/collect?measurement_id=$GA_MEASUREMENT_ID&api_secret=$GA_API_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"client_id":"123.456","user_id":"u_42","events":[{"name":"purchase","params":{"currency":"USD","value":29.0,"transaction_id":"inv_981"}}]}'
  • Fire revenue events from the payment webhook (after signature verification), not from the success page. Use the invoice/charge ID as the dedupe key (transaction_id in GA4, $insert_id in Mixpanel, uuid in PostHog where supported).
  • Batch and flush: SDKs queue events; in Lambda/edge functions flush or await shutdown, or events are lost.

7. Funnels and retention

  • Define each funnel as an ordered list of events with a conversion window and the property breakdowns you need; write it down next to the taxonomy.
  • Count users, not events, unless the question is volume.
  • Retention needs a clear “active” event (a value action like report_viewed, not $pageview).
  • Exclude internal users and test accounts with a person property or filter (is_internal).
  • For statistical decisions on experiments, hand off to experiment-analysis.

Gotchas

  • SPAs: pageviews on route change need the SDK’s history tracking or a manual capture in the router; otherwise every session looks like one page.
  • Double-firing in React StrictMode/dev: effects run twice; guard init with a module-level flag and fire events from handlers, not effects.
  • GA4 truncates and rejects: event names over 40 chars, parameter values over 100 chars, more than 25 parameters per event. Custom dimensions must be registered before they show in reports, and are not backfilled.
  • Changing an event name splits history. Version with a new event and keep the old one until dashboards migrate.
  • Timestamps: server events sent later must include the original timestamp, or funnels order them wrongly.

Verification

  1. Open the tool’s live/debug view (PostHog activity, Mixpanel events, GA4 DebugView with debug_mode) and walk the core funnel: each step appears once, with typed properties.
  2. Log in on a second browser as the same user: one person, both sessions merged. Log out and sign up as someone else: a new person, no merge.
  3. Decline consent: confirm no network requests to analytics endpoints.
  4. Trigger a test payment webhook: the revenue event arrives once, server-side, with the transaction ID.
  5. Compare funnel step counts in the UI with a raw-event query (SQL/HogQL/export) for one day; they should match.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/event-taxonomy.md 82 lines · 4.1 KB
# Event taxonomy template and limits

Read this when drafting a new event list, reviewing a pull request that adds events, or
mapping events to GA4.

## Starter taxonomy for a SaaS product

Keep the first version to 10-20 events. Each must answer a question someone will ask.

| Event | When it fires | Where | Required properties |
|---|---|---|---|
| `signup_started` | Signup form first interaction | client | `method` |
| `signup_completed` | Account row created | server | `method`, `plan`, `referrer_source` |
| `login_completed` | Session created | server | `method` |
| `onboarding_step_completed` | Each onboarding step done | client | `step` (string), `step_index` |
| `project_created` | Core object created | server | `project_id`, `template` |
| `invite_sent` | Invite email queued | server | `role`, `project_id` |
| `invite_accepted` | Invitee joins | server | `role`, `inviter_id` |
| `feature_used` | Key value action (pick 1-3 named events instead if possible) | client | `feature` |
| `checkout_started` | Checkout session created | server | `plan`, `billing_period` |
| `subscription_started` | Payment provider confirms | server (webhook) | `plan`, `amount_cents`, `currency`, `transaction_id` |
| `subscription_changed` | Upgrade/downgrade | server (webhook) | `from_plan`, `to_plan`, `mrr_delta_cents` |
| `subscription_canceled` | Cancellation effective | server (webhook) | `plan`, `reason` |
| `error_shown` | User-facing error rendered | client | `error_code`, `surface` |

## Naming rules

- Event names: `object_action`, snake_case, past tense, no product area prefix unless
  ambiguous (`report_exported`, not `Exported Report` or `click_export_btn`).
- Track what happened, not the UI element: `report_exported` with `source: "toolbar"`
  beats `export_button_clicked`.
- Properties: snake_case; booleans prefixed `is_`/`has_`; IDs as strings; enums as
  lowercase strings from a fixed set; money as integer minor units + ISO 4217 `currency`.
- Never put variable data in the event name (`viewed_project_123`).

## Global properties (attach to every event)

- `app_version`, `platform` (`web` | `ios` | `android` | `server`)
- `org_id` (B2B), `plan`
- `is_internal` (employees/test accounts)

Register these once (PostHog `posthog.register({...})`, Mixpanel `mixpanel.register({...})`)
instead of passing them on every call.

## Spec format per event

```yaml
event: subscription_started
description: Payment provider confirmed the first successful charge for a subscription.
owner: growth-team
fires_from: server - billing webhook handler after signature verification
properties:
  plan: { type: string, enum: [starter, pro, team], required: true }
  amount_cents: { type: integer, required: true }
  currency: { type: string, format: ISO-4217, required: true }
  transaction_id: { type: string, required: true, note: dedupe key }
destinations: [posthog, ga4:purchase]
added: 2026-03-02
```

## GA4 mapping and limits

- Use GA4 recommended event names where they exist so reports and Ads integrations work:
  `sign_up`, `login`, `purchase`, `begin_checkout`, `generate_lead`, `share`, `search`.
  Map your names in the wrapper (`subscription_started` -> `purchase`).
- Limits: event name <= 40 chars; parameter name <= 40 chars; parameter value <= 100
  chars; <= 25 parameters per event; user property name <= 24 chars, value <= 36 chars.
- Standard properties have quotas on custom dimensions (event-scoped and user-scoped);
  register only the parameters you report on.
- Names reserved by GA4 (prefixes `ga_`, `google_`, `firebase_`, and events like
  `session_start`, `first_visit`) cannot be used for custom events.
- `purchase` needs `currency`, `value`, and `transaction_id`; GA4 dedupes on
  `transaction_id`.

## Review checklist for PRs adding events

- [ ] Name follows `object_action` past tense and is not a near-duplicate of an existing event
- [ ] Added to the typed `Events` map; no string literal calls
- [ ] No PII or free-text in properties
- [ ] Revenue/lifecycle events fire server-side with a dedupe ID
- [ ] Spec entry written (description, owner, properties, destinations)
- [ ] Verified once in the tool's live/debug view

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc