Install any skill in seconds. Free to start, no credit card required.
Get Started Free →SaaS analytics event taxonomy, metric formulas (MRR, churn, LTV), provider-agnostic tracking, funnel analysis, cohort setup, and privacy-respecting instrumentation.
.claude/skills/saas-analytics-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | — | — |
| case-08 | ✗→✓ | ▲ Improved | — | — |
| case-11 | ✗→✓ | ▲ Improved | — | — |
| case-13 | ✗→✓ | ▲ Improved | — | — |
| case-20 | ✗→✓ | ▲ Improved | — | — |
Provider-agnostic analytics for SaaS products. Track what matters, name it consistently, respect privacy.
Use object_action format. Past tense for completed actions.
typescript// GOOD: structured object_action naming const Events = { USER_SIGNED_UP: 'user_signed_up', PLAN_UPGRADED: 'plan_upgraded', PLAN_DOWNGRADED: 'plan_downgraded', PAYMENT_FAILED: 'payment_failed', TRIAL_STARTED: 'trial_started', FEATURE_USED: 'feature_used', INVITE_SENT: 'invite_sent', ONBOARDING_COMPLETED: 'onboarding_completed', } as const // BAD: ad-hoc, inconsistent naming // 'click_upgrade_button' -- UI action, not business event // 'userSignedUp' -- camelCase breaks grouping in dashboards // 'Signed Up' -- spaces break queries // 'signup' -- ambiguous (started? completed?)
Never couple your app to a specific vendor (Mixpanel, Amplitude, PostHog).
typescriptinterface AnalyticsProvider { identify(userId: string, traits: Record<string, unknown>): void track(event: string, properties?: Record<string, unknown>): void page(name: string, properties?: Record<string, unknown>): void reset(): void } class Analytics { private providers: AnalyticsProvider[] = [] private consentGiven = false addProvider(p: AnalyticsProvider): void { this.providers = [...this.providers, p] } setConsent(granted: boolean): void { this.consentGiven = granted } track(event: string, properties: Record<string, unknown> = {}): void { if (!this.consentGiven) return const enriched = { ...properties, timestamp: new Date().toISOString() } for (const p of this.providers) p.track(event, enriched) } identify(userId: string, traits: Record<string, unknown> = {}): void { if (!this.consentGiven) return for (const p of this.providers) p.identify(userId, traits) } reset(): void { for (const p of this.providers) p.reset() } } export const analytics = new Analytics()
typescriptfunction calculateMetrics(d: { activeCustomers: number; customersAtPeriodStart: number; customersLost: number recurringRevenue: number; revenueLost: number totalAcquisitionSpend: number; newCustomers: number }) { const mrr = d.recurringRevenue const arr = mrr * 12 const churnRate = d.customersAtPeriodStart > 0 ? (d.customersLost / d.customersAtPeriodStart) * 100 : 0 const arpu = d.activeCustomers > 0 ? mrr / d.activeCustomers : 0 const ltv = churnRate > 0 ? arpu * (1 / (churnRate / 100)) : 0 const cac = d.newCustomers > 0 ? d.totalAcquisitionSpend / d.newCustomers : 0 const ltvCacRatio = cac > 0 ? ltv / cac : 0 // target: > 3 return { mrr, arr, churnRate, arpu, ltv, cac, ltvCacRatio } }
Typed property schemas keep every event consistent and queryable.
typescriptinterface BaseProperties { timestamp: string platform: 'web' | 'ios' | 'android' session_id: string } interface BillingProperties extends BaseProperties { plan_id: string; plan_name: string amount_cents: number; currency: string previous_plan_id?: string } // GOOD: typed, every field documented trackBilling(Events.PLAN_UPGRADED, { timestamp: new Date().toISOString(), platform: 'web', session_id: 'sess_abc', plan_id: 'plan_pro', plan_name: 'Pro', amount_cents: 4900, currency: 'USD', previous_plan_id: 'plan_free', }) // BAD: analytics.track('upgraded', { plan: 'pro', price: 49 })
Track each lifecycle stage: signup, onboarding, activation, retention.
typescriptconst Funnel = { SIGNUP: 'funnel_signup_completed', ONBOARDING: 'funnel_onboarding_completed', ACTIVATION: 'funnel_activation_reached', RETAINED_D7: 'funnel_retained_day_7', RETAINED_D30: 'funnel_retained_day_30', } as const // Define activation with YOUR product's criteria async function checkActivation(userId: string): Promise<boolean> { const projects = await db.project.count({ where: { userId } }) const invites = await db.invite.count({ where: { invitedBy: userId } }) if (projects >= 3 && invites >= 1) { analytics.track(Funnel.ACTIVATION, { user_id: userId, projects, invites }) return true } return false }
Track experiment exposure, then correlate with conversion outcomes.
typescriptfunction evaluateFlag(userId: string, flagKey: string): string { const variant = featureFlags.evaluate(flagKey, userId) analytics.track('feature_flag_evaluated', { flag_key: flagKey, variant, user_id: userId }) return variant } // Correlate: SELECT variant, COUNT(*) FROM events // WHERE event='plan_upgraded' AND user_id IN ( // SELECT user_id FROM events WHERE event='feature_flag_evaluated' // AND flag_key='new_pricing') GROUP BY variant
typescript// CLIENT: UI interactions, page views (blockable by ad blockers) analytics.track('button_clicked', { button_id: 'cta_hero' }) // SERVER: revenue, activation, lifecycle (never blocked = source of truth) async function onSubscription(sub: Subscription): Promise<void> { await serverAnalytics.track('plan_upgraded', { user_id: sub.userId, plan_id: sub.planId, amount_cents: sub.amountCents, }) } // Revenue + activation events: ALWAYS server-side // UI interactions: client-side is acceptable
typescriptfunction privacyWrap(base: AnalyticsProvider): AnalyticsProvider { return { identify(userId, traits) { const hashed = createHash('sha256').update(userId).digest('hex') base.identify(hashed, { plan: traits.plan, signup_date: traits.signup_date }) }, track(event, props = {}) { const { email, ip_address, user_agent, user_id, ...safe } = props as Record<string, unknown> // Hash user_id if present to prevent PII leak to analytics provider if (user_id) (safe as Record<string, unknown>).user_id = createHash('sha256').update(String(user_id)).digest('hex').slice(0, 16) base.track(event, safe) }, page: (n, p) => base.page(n, p), reset: () => base.reset(), } } function initAnalytics(consent: 'none' | 'essential' | 'full'): void { if (consent === 'none') return analytics.setConsent(true) if (consent === 'essential') analytics.addProvider(privacyWrap(serverProvider)) if (consent === 'full') { analytics.addProvider(serverProvider); analytics.addProvider(clientProvider) } }
typescriptfunction assignCohort(user: { id: string; createdAt: Date; plan: string }): void { const month = `${user.createdAt.getFullYear()}-${String(user.createdAt.getMonth() + 1).padStart(2, '0')}` analytics.identify(user.id, { cohort_signup_month: month, cohort_plan_at_signup: user.plan, cohort_channel: getAttributionChannel(user.id), }) } // Retention query: SELECT cohort_signup_month, // DATEDIFF(week, first_seen, event_date) AS week_n, // COUNT(DISTINCT user_id) AS active // FROM events GROUP BY 1, 2 ORDER BY 1, 2
Key principles: Name events object_action. Track revenue server-side. Abstract your provider from day one. Define activation explicitly. Strip PII before sending to any third party.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +35 percentage points is the difference between those two pass rates over the 23 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.