analytics-instrumentation
World-class product-analytics instrumentation playbook (PostHog/Mixpanel/Amplitude class, NOT GA4). Use when setting up event tracking, conversion funnels, dash
npx skills add analytics-instrumentation
Analytics Instrumentation Playbook
When to use this skill
Triggers: "set up analytics", "instrument events", "build tracking", "add PostHog/Mixpanel/Amplitude", "build conversion funnel", "set up dashboards", "track conversion", "revenue tracking", "alerts when X drops", "automate KPI report".
Scope: product analytics (behavior, funnels, retention). NOT for: GA4 web analytics (useseo-monitor skill), application performance monitoring (Datadog/Sentry), or pure ETL/warehouse pipelines.
Iron rules (never violate)
- Notifications must self-contain the data. Lark/Slack/email body MUST include the actual numbers/names/Top-N that triggered the alert. "See dashboard" alone = incomplete. (See
~/.claude/rules/lessons.md2026-04-26.) - Exclude internal accounts from every query. Founder/staff/test accounts pollute DAU 3-4x. Maintain a registry file (single source of truth). Apply at query time.
- Object_Action naming, snake_case, past-tense.
task_created✅ —taskCreate❌,created_task❌,Task Created❌. - Server-side is source-of-truth for revenue/conversion. Client events for clicks only. Stripe API direct for $ amounts.
- No analytics SDK in non-primary backend layer. Tycoon rule: TS only, never Python — Python emits via CLI → TS API → trackServer. Pick one identification layer per stack.
- Don't filter anonymous (no-email) users. They're top-of-funnel prospects, not noise. PostHog merges anon→identified on signup.
- Idempotency on revenue alerts. Dedupe by Stripe charge ID via PostHog marker event. Cron runs overlap; without dedup you double-page.
The 4-layer tracking stack
| Layer | What | Where | Purpose |
|---|---|---|---|
| 1. Server SDK | trackServer(userId, event, props, workspaceId) | API routes, webhooks | Source of truth. Funnel, revenue, conversion. |
| 2. Client SDK | useTrack() React hook | Components | Click-level only (autocapture handles the rest). Hero CTA, FAB, invite. |
| 3. Source-of-truth API | Stripe / DB direct | Cron scripts | $ amounts, MRR, churn. Don't trust event proxies for money. |
| 4. Background jobs | HogQL queries → Lark cards | Scheduled cron | Self-contained alerts. Funnel anomaly, daily brief, stuck users, revenue signal. |
lib/analytics-server.ts # trackServer wrapper, identify/groupIdentify
lib/use-track.ts # client hook
scripts/analytics-cron/ # 4 cron jobs: daily-brief, funnel-anomaly, revenue-signal, stuck-users
_posthog.ts # HogQL helper (read)
_stripe.ts # Stripe API helper (read)
_internal-accounts.ts # registry of internal emails/domains/distinct_ids
_notify.ts # data-rich Lark/Slack helper
Event naming convention (Object_Action)
Pattern: — snake_case, past-tense action.
| ✅ | ❌ |
|---|---|
task_created | createTask, tasks_created, task_create |
signup_completed | userSignedUp, signup_done, Signed Up |
pricing_cta_clicked | clickedCTA, pricing-cta-click |
first_chat_sent | firstChat, chat_first_sent |
STATUS_CONFIG, TaskStatus.DONE, etc.). If your codebase has a BACKLOG/TODO/IN_PROGRESS/DONE enum, your status_changed events use those literal strings.
Standard taxonomy library (copy-paste)
Lifecycle
signup_started,signup_completedonboarding_started,onboarding_step_viewed,onboarding_completedfirst_— milestone events (one-shot per user/workspace)_
Engagement
,_viewed ,_clicked _used — e.g._ _clicked nav_settings_clicked,fab_clicked,invite_clicked
Revenue (server-side from webhooks)
checkout_started,checkout_completedpayment_succeeded,payment_failedsubscription_created,subscription_updated,subscription_canceledfirst_paid_topup— milestonerevenue_alert_sent— internal marker for cron idempotency (chargeId in props)
Always include in props
{
userId, // who
workspaceId, // tenant context
source, // attribution: "chat" | "cron" | "manual" | "stripe_checkout" | etc
surface, // where in UI: "hero" | "tier-card" | "fab" | "nav"
// event-specific props after these
}
Generic 8-step conversion funnel
Adapt to product type. The structure stays:
1. landing_view (== $pageview for autocapture-instrumented routes)
- signup_completed
- onboarding_completed
- first_<key_action> (the "aha" moment for your product)
- <key_action>_completed
- first_paid_topup (or first_subscription, first_purchase)
- recurring_active (paying again — retention signal)
- referral_sent (or invite_sent — viral loop)
| Product type | Step 4 (aha) | Step 5 |
|---|---|---|
| AI agent platform (Tycoon) | first_chat_sent | first_task_completed |
| E-commerce | cart_added | purchase_completed |
| Dev tool | first_api_call | first_integration_live |
| SaaS B2B | first_doc_created | first_team_invited |
| Social/network | first_post_created | first_friend_added |
4-dashboard architecture
Build these in PostHog/Mixpanel/Amplitude UI (don't build custom in-app):
🎯 Activation Funnel
- 8-step funnel insight (last 30d + 7d)
- Time-to-convert distribution (signup → aha)
- Cohort breakdown by source/channel
📈 Engagement
- DAU / WAU / MAU trend
- Tasks/messages/key-action volume daily (stacked)
- Top users by event count (last 7d)
- Weekly retention cohort from
signup_completed - Lifecycle (new/returning/resurrected/dormant)
💰 Revenue
- Daily revenue (sum of
$revenueproperty) — pull from Stripe direct, not PostHog events - New paying customers daily
- Funnel:
signup_completed → checkout_started → payment_succeeded - Top customers by LTV
- MRR + active subscription count
🛡 Health
- Failure event count by
errorReasonbreakdown - API error rate proxy (any
*_failedevents) - Avg
_completed.totalDurationMs subscription_checkout_faileddaily
Self-contained notification rule (critical)
❌ Bad — forces user to click through:
🚨 Funnel anomaly detected. See dashboard for details.
✅ Good — decision-ready:
🚨 Funnel anomaly: signup_completed → onboarding_completed dropped 32.4% WoWRequired body items: trigger metric value, comparison (WoW or threshold), Top-N entities, suggested next step.
- This week: 41.2% (87/211)
- Last week: 60.9% (134/220)
- Sample: 211 vs 220
- Top 3 stuck this week:
cmo_xyz,cmn_abc,cmp_def
Implementation: query data source FIRST (HogQL/Stripe/DB), THEN compose message body. Never compose-then-link.
Internal-account exclusion
Maintain _internal-accounts.ts (or equivalent JSON for non-TS):
export const INTERNAL_EMAILS: readonly string[] = [
"[email protected]",
"[email protected]",
// ↓ add teammates as they join ↓
] as const
export const INTERNAL_DOMAINS: readonly string[] = [
"@yourcompany.com",
"@partner-agency.com",
] as const
export function excludeInternalPersons(): string {
// HogQL fragment. PostHog person.properties.email is set when identify() called.
const emailIns = INTERNAL_EMAILS.map(e => '${e}').join(", ")
const domainOrs = INTERNAL_DOMAINS.map(d => person.properties.email LIKE '%${d}').join(" OR ")
return NOT (person.properties.email IN (${emailIns}) OR (${domainOrs}))
}
Apply in EVERY HogQL query: WHERE event = 'x' AND timestamp > ... AND ${excludeInternalPersons()}.
For PostHog dashboard insights: PATCH each insight's query.source.properties to add a not_icontains person-property filter. Same domain/email list.
For Mixpanel: use cohort with email property NOT contains @yourcompany.com, apply as global filter.
For Stripe path: import INTERNAL_EMAILS/DOMAINS, check customer.email, skip alerts for matches.
Cron + alert cadence (recommended)
| Cron | Schedule | Purpose | Channel |
|---|---|---|---|
| daily-brief | 1am UTC (9am Beijing) | Day-over-day KPI digest | business_automation |
| funnel-anomaly | hourly | Conversion drop ≥20% WoW + sample ≥50 | product_updates |
| revenue-signal | every 30 min | First payment, ≥$50 charge, churn | business_automation |
| stuck-users | every 2h | ≥10 users 24h+ on a step | product_updates |
revenue_alert_sent events with same chargeId in last 7 days; if exists, skip; if sending, also fire revenue_alert_sent marker. Across cron run boundaries this prevents double-alerts.
Failure policy: process.exit(0) even on error. Cron failure should NOT page; missing data should. Log to stdout for searchable GH Actions logs.
Anti-patterns (don't do these)
| ❌ Don't | ✅ Do instead |
|---|---|
| Build custom dashboards in-app | Use PostHog/Mixpanel/Amplitude native UI |
| Filter anonymous users | Track them — they're top-of-funnel |
| Add analytics SDK to multiple backend layers | Pick one (frontend stack), funnel others through it |
| camelCase/spaces in event names | snake_case, past-tense action |
| Track full email/PII in event props | Track on person.properties only (PostHog/Mixpanel handle privacy) |
| Poll DB every 30s for revenue | Webhook → trackServer real-time |
| Store cron seen-state in /tmp | CI is ephemeral. Use marker events in PostHog for idempotency |
| Send "alert detected, see dashboard" | Embed actual numbers in message body |
| Mix internal + external in DAU | Always filter by internal-account registry |
Cross-tool decision matrix
| Need | Tool | Why |
|---|---|---|
| Behavioral funnel + retention | PostHog (default) | Cheapest, open source, autocapture, session replay included |
| Same as PostHog but enterprise | Mixpanel / Amplitude | Better team features, more expensive |
| Real-time custom dashboards | Built-in product UI | Don't reinvent in PostHog |
| Source-of-truth revenue | Stripe API direct | Events lie, Stripe charges don't |
| Alerting on anomaly | Cron + Slack/Lark webhook | PostHog/Mixpanel native alerts are weaker |
| Session replay | PostHog (free tier) / FullStory | Quality varies |
| A/B testing | PostHog feature flags / GrowthBook | Open source preferred |
Implementation checklist (new project)
[ ] Pick analytics platform — default PostHog (cheapest + OSS)
[ ] Server SDK + client SDK installed
[ ] Reverse proxy /ph (or equivalent) for ad-block resilience
[ ] identify() called on auth callback with email + workspace group
[ ] _internal-accounts.ts registry with founder/test/team
[ ] 8-step conversion funnel mapped to product
[ ] Server events fire from webhooks (not just routes)
[ ] Client events only on what autocapture misses
[ ] Stripe API direct integration if revenue tracking matters
[ ] 4 dashboards created (Activation/Engagement/Revenue/Health)
[ ] All dashboard insights have internal-account filter
[ ] Cron jobs (4) with data-rich Lark/Slack messages
[ ] Idempotency on revenue-signal via PostHog marker events
[ ] CI run validated via workflow_dispatch (don't wait for first scheduled run)
[ ] Mandatory change notification fired (per company comms policy)
Tycoon reference (study these as canonical example)
Real shipped 2026-04-26:
- Server:
~/tycoon/ts/src/lib/posthog-server.ts - Client:
~/tycoon/ts/src/lib/use-track.ts - Cron:
~/tycoon/ts/scripts/analytics-cron/{daily-brief,funnel-anomaly,revenue-signal,stuck-users}.ts - Helpers:
_posthog.ts,_stripe.ts,_internal-accounts.ts - Notify:
~/tycoon/ts/src/lib/lark-notify.ts - Dashboards via API:
~/tycoon/ts/scripts/analytics-cron/posthog-dashboards.json - GH Actions schedule:
~/tycoon/.github/workflows/analytics-cron.yml
References
- PostHog best practices: posthog.com/docs/product-analytics
- Amplitude taxonomy: amplitude.com/docs/data/taxonomy-best-practices
- Mixpanel naming: docs.mixpanel.com/docs/data-structure/events-and-properties/best-practices
- Object-Action framework: industry standard, derived from Segment + Mixpanel docs
- Internal
~/.claude/rules/lessons.md2026-04-26 entries on notifications + internal-account filtering