Skip to content

Posthog Integration Playbook

This page is the playbook for Posthog in the beef monorepo. It documents which services emit what, where the SDK is configured, the canonical event taxonomy actually emitted by code (cross-checked against the live project), the feature flags in use, and the operational guardrails. Logs / metrics / traces are documented separately in operations/observability.md — this page covers product analytics only.

Posthog provides three things to beef:

  1. Product analytics — funnel, retention, revenue, NSFW, acquisition.
  2. Feature flags — experiment gating and remote configuration consumed from both browser (posthog-js) and server (posthog-node, posthog-go).
  3. Session replay + web vitals — for product debugging, sampled.
  • Project: Phoenix (id: 111262) under organization Foxy AI (018cac02-779a-0000-316e-837fe06b96a4).
  • Region: US Cloud (https://us.i.posthog.com for ingestion, us.posthog.com for the app).
  • Verified via: mcp__posthog__dashboards-get-all (130 dashboards on 2026-09-03), mcp__posthog__feature-flag-get-all (14 active/stale flags), mcp__posthog__read-data-schema (200+ events surfaced for the team).
Service SDK Init / client path What is captured
brisket (Next.js, browser) posthog-js ^1.265.1 (apps/brisket/package.json) apps/brisket/src/features/analytics/posthog/PosthogProvider.tsx — posthog.init(NEXT_PUBLIC_POSTHOG_KEY, { api_host, person_profiles: 'identified_only', capture_pageview: true, capture_pageleave: false, before_send: sampleByEvent(['$web_vitals'], 0.2) }); provider wired in apps/brisket/src/app/layout.tsx Pageviews, autocapture (off in dev), web vitals (sampled 20%), explicit product events via posthog?.capture(...) and useTrackEvent (apps/brisket/src/features/analytics/tracking/useTrackEvent.tsx). Feature flag reads via useFeatureFlagEnabled / useFeatureFlagPayload.
brisket (Next.js, server) posthog-node ^4.18.0 (apps/brisket/package.json) apps/brisket/src/lib/posthog.ts — new PostHog(NEXT_PUBLIC_POSTHOG_KEY, { host }) Server-side posthog.capture({...}) from Clerk webhooks (apps/brisket/src/app/api/webhook/clerk/route.ts) and tRPC routers; posthog.isFeatureEnabled('nsfw-onboarding', userId) in apps/brisket/src/server/api/routers/{character,subscription}.ts.
sirloin (Go) github.com/posthog/posthog-go v1.11.2 (apps/sirloin/go.mod) apps/sirloin/internal/pkg/posthog/client.go — posthog.NewWithConfig(apiKey, posthog.Config{Interval: 30s, BatchSize: 100}). If SIRLOIN_POSTHOG_API_KEY is empty the constructor returns a nil-safe no-op client. Billing/payment lifecycle events emitted from apps/sirloin/internal/app/worker/{pollchargebeeevents,pollprimerpayments,pollprimerfailedpayments,retryrenewalpayments}.go. Sets revenue properties on identify (SetRevenueProperties).
brain (NestJS) None wired — Confirmed: no posthog-* package in apps/brain/package.json.
fennec (admin React) None wired — Confirmed: no posthog-* package in apps/fennec/package.json.
strip / round (Go) None wired — Out of scope for product analytics.
flank / chuck / shank None wired — Out of scope.

Only brisket and sirloin are first-class Posthog producers today.

CV2 creation and management share six recipe-neutral events. See Character recipe analytics for their ownership, metric denominators, legacy mappings, and rollout checks.

This taxonomy is the union of what code actually emits today, cross-referenced with what Posthog has recorded for the project (verified via mcp__posthog__read-data-schema {kind: events} — 200+ events present in the project, of which the table below is the subset we own from code).

Names live in apps/sirloin/internal/pkg/posthog/events.go. All include distinct_id = userID (the Clerk user id from sirloin’s user model).

Event name Where emitted Properties
subscription_created events.go:104 TrackSubscriptionCreated, called from Chargebee event poller plan_id, plan_type, duration, amount, currency, valid_until, invoice_id, discount_code
subscription_renewed events.go:118 TrackSubscriptionRenewed, Chargebee renewal events same as subscription_created
subscription_cancelled events.go:132 TrackSubscriptionCancelled plan_id, cancellation_reason, months_subscribed, credits_provided
subscription_upgraded events.go:142 TrackSubscriptionUpgraded from_plan_type, to_plan_type, effective_date, ...
subscription_downgraded events.go:155 TrackSubscriptionDowngraded from_plan_type, to_plan_type, effective_date, ...
payment_succeeded events.go:167 TrackPaymentSucceeded, Primer payment poller / renewal worker amount, currency, payment_method_type, transaction_type, invoice_id, subscription_id, attempt_number, psp, country
payment_failed events.go:181 TrackPaymentFailed, Primer failed-payment poller amount, currency, decline_code, decline_message, decline_type, processor_decline_code, network_decline_code, reason_type, payment_method_type, ...
billing.payment.outcome TrackPaymentOutcome (unified success/fail stream) union of payment props + status: succeeded | failed
refund_issued TrackRefundIssued amount, currency, original_payment_id, refund_type, invoice_id
top_up_purchased TrackTopUpPurchased (credit packs) item_price_id, pack_name, credits_amount, amount, currency, invoice_id
card_fingerprint_limit_exceeded pollprimerfailedpayments.go:314 + paymentmethods.go recordCardFingerprintOnPayment (fraud, observe-only since FOXY-629 — no flag/block) payment_id, card_fingerprint, accounts
high_risk_decline_flagged pollprimerfailedpayments.go:442 (fraud) payment_id, bin6, reason_code
verified_unfinished_character_removal, velocity_blocked_cooldown, velocity_triggered, coupon_abuse_blocked, payment_ineligible_flagged, payment_ineligible_disposable_email, payment_throttle_blocked, fraud_payment_failed sirloin fraud + verification flows per-event

Names are centralised in apps/brisket/src/lib/constants.ts as POSTHOG_EVENTS and consumed via the posthog?.capture(POSTHOG_EVENTS.X, props) pattern, plus the generic useTrackEvent wrapper at apps/brisket/src/features/analytics/tracking/useTrackEvent.tsx.

Selected canonical events emitted from code (representative subset; see POSTHOG_EVENTS for the full enumeration):

Event name Source file Trigger
funnel_step_viewed apps/brisket/src/features/funnel/renderer/use-screen-effects.ts Step reached, deduplicated by step/branch while mounted. Properties: funnel_id, string funnel_version, step_id, and branch_id for branch-owned steps. Replaces internal screen-state events.
funnel_step_completed apps/brisket/src/features/funnel/machine.ts Successful advance only. Same identifiers plus native selected answer, photo_count, or skipped: true as applicable. No typed text, files or signup credentials; signup completion remains a server conversion. See funnel event reference.
Generation Requested features/analytics/posthog/hooks/use-media-generation-request-track/index.tsx:139,183 User submits a generation
Generation Downloaded hooks/use-generation-downloaded-track/index.tsx:28 User downloads output
Generation Favorited hooks/use-generation-favorited-track/index.tsx:27 User favorites a media
Generation Archived hooks/use-generation-archived-track/index.tsx:27 User archives a generation
Generation Review Submitted hooks/use-generation-review-submit-track/index.tsx:25 Generation review form
Character Media Prescreened / Character Media Accepted hooks/use-media-prescreened/index.tsx:42 Onboarding image gates
Character Dataset Edit Saved hooks/use-dataset-edit-track/index.tsx:14 Character dataset save
Welcome Media Review Submitted hooks/use-welcome-media-review-track/index.tsx:30 Welcome flow review
Explore Search Submitted hooks/use-explore-search-track/index.tsx:27 Explore-page search
Explore Category Clicked hooks/use-explore-category-click-track/index.tsx:26 Explore category tap
Explore Preset Clicked apps/brisket/src/features/analytics/posthog/hooks/use-explore-preset-click-track/index.tsx Explore card tap; carries example_id, available, and the view_id + position of the card so it joins the impression batches below
Explore Card Impressions apps/brisket/src/features/analytics/posthog/hooks/use-explore-impression-track/index.tsx, buffered by apps/brisket/src/features/explore-page/explore-impressions.ts One batch of qualified card impressions per Explore feed view, sent every 60s while non-empty, at 100 cards, on a feed change, on grid unmount, when the tab hides, and on pagehide
Shop VI Character Purchased hooks/use-vi-character-purchase-track/index.tsx:21 Shop checkout success
Pro Tier Selected features/checkout/components/v2/pro-tier-slider/use-track-pro-tier-selected.ts User lands on a Pro tier via the slider (paywall / change-plan / usage / upgrade); carries surface, plan_type, plan_display_name, subscription_duration, credits_per_month, price. Also re-fires select_item for the slid-to tier (pricing-v3)
Character Required Dialog Viewed / ... Proceed to Shop / ... Proceed to Creation features/explore-page/components/character-required-dialog/index.tsx:58,71,160 Gating dialog interactions
sign_in_with_email, sign_in_with_google, sign_in_with_apple, sign_in_with_facebook, sign_in_with_twitter app/(auth)/sign-{in,up}/... Auth method chosen
Deleted Account app/(main-layout)/profile/account/page.tsx:118 Self-serve delete
Referral Link Copied app/(main-layout)/referrals/page.tsx:48 Referral CTA
nsfw_* (e.g. nsfw_funnel_entry_clicked, nsfw_paywall_viewed, nsfw_kyc_started) features/analytics/posthog/hooks/use-nsfw-funnel-track/index.ts:24 NSFW funnel hooks
Pageview events (Explore viewed, Create viewed, Shop viewed, Profile viewed, Billing viewed, Change Plan viewed) features/analytics/posthog/posthog-page-visit-tracking/index.tsx:38 SPA route changes

Explore impressions. A card qualifies once per feed view after being at least 50% visible for 1s with the tab visible; qualified cards are buffered and sent as one Explore Card Impressions event. Batch-level properties: view_id (new for every Explore visit and for every feed query: mode, category, search or media-type change), count, flush_reason (interval | cap | feed_change | unmount | hidden | pagehide), mode (social | full_access), ordering (newest, top_day, top_week, or top_month), category_id, parent_category_id, search_term, media_type_filter, is_subscribed, has_subscribed, is_mobile. Each entry of the impressions array is { example_id, position, media_type, aspect_ratio, explicitness }; position is the card’s index in the whole loaded feed, not within a page, and explicitness is standard or full_access. Per-card metrics unnest the array in a SQL insight:

SELECT
JSONExtractString(arrayJoin(JSONExtractArrayRaw(properties.impressions)), 'example_id') AS example_id,
count() AS impressions
FROM events
WHERE event = 'Explore Card Impressions'
GROUP BY example_id

Cards covered by an open dialog still count, so early click-through rates run slightly low until that is addressed.

Plus Posthog system events: $pageview, $pageleave (disabled), $web_vitals (sampled 20%), $identify, $feature_flag_called, $exception (autocapture).

Drift watch. The live Posthog project also reports events that don’t appear to be emitted from the current monorepo, e.g. welcome_quiz_*, Logged In, Account Created, purchase, add_payment_info, begin_checkout, select_item, credit_purchase, download_image, download_video, Subscription WinBack Offer Viewed. Some of these are GTM-mirrored e-commerce events (begin_checkout, purchase, add_payment_info, select_item) emitted from the same useTrackEvent sanitiser; others are likely older events still in dashboards. TODO(@law): audit dashboards using these events and either re-add the emitter or replace the dashboard with the canonical name.

Phase 2 removes the welcome quiz emitters WELCOME_QUIZ_STARTED, WELCOME_QUIZ_FINISHED, WELCOME_QUIZ_QUESTION_ANSWERED and new welcome_quiz_* property writes. Historical analytics are retained. The quiz-entry user_signup emitter is removed rather than moved to /hop; existing Clerk-webhook ACCOUNT_CREATED remains the account-creation signal. Login, funnel impression/conversion, Klaviyo, referral and purchase tracking remain. No remote flags or dashboards are changed by this code release. See onboarding funnels.

Set in brisket on every signed-in render via apps/brisket/src/features/analytics/posthog/PosthogProvider.tsx:

posthog.identify(user.user.id, {
email: user.user.primaryEmailAddress,
signed_in_date: createdAt.toISOString(), // when present
auth_user_id: user.user.id,
auth_session_id: session.session.id,
primary_email_address: primaryEmailAddress,
});

Set in sirloin on revenue-relevant events via apps/sirloin/internal/pkg/posthog/events.go SetRevenueProperties:

posthog.Properties{
"current_plan": props.CurrentPlan,
"mrr": props.MRR,
"total_payments": props.TotalPayments,
"billing_period": props.BillingPeriod,
"currency": props.Currency,
"subscription_start_date": ...RFC3339,
}

person_profiles is identified_only, so anonymous traffic does not create person rows — anonymous events are still captured but only resolve to a person on identify.

Verified via mcp__posthog__feature-flag-get-all (14 entries): 11 ACTIVE, 3 STALE. Code-consumed flags:

Flag key Status Consumed in Default
nsfw-onboarding ACTIVE apps/brisket/src/hooks/use-is-nsfw-ff-enabled.ts:4; server-side apps/brisket/src/server/api/routers/character.ts:368 and subscription.ts:92 (posthog.isFeatureEnabled('nsfw-onboarding', userId)) off
shop-vi ACTIVE apps/brisket/src/hooks/use-shop-enabled.ts:5 off
onboarding-quiz-v2 Code retired No consumer after welcome quiz removal; remote retirement is separate n/a
having-problems-message implied ACTIVE apps/brisket/src/features/global-issue-message/index.tsx:8 off
allow-primary-payment-removal ACTIVE (test only) apps/brisket/src/features/billing-page/primer/primer-payments/payment-methods/payment-method/{index,delete-payment-method/index}.tsx off
credits-and-subscription-upgrade-together ACTIVE apps/brisket/src/features/usage/index.tsx:29 off
yamber-warnings implied ACTIVE apps/brisket/src/features/character-page-ccv4/hooks/use-yamber-enabled-ff.tsx:19 (posthog?.isFeatureEnabled?.('yamber-warnings')) off
welcome-paywall-slider-variant ACTIVE No code consumer (grep across apps/); likely remote-config / GTM-only n/a
landing-of-creators-a-b-c-test ACTIVE No code consumer (grep across apps/); likely remote-config / GTM-only n/a
sizzle-video-display Code retired No consumer after welcome quiz removal; remote retirement is separate n/a
sirloin-tester-skip-identity-lock ACTIVE No code consumer (grep across apps/sirloin/** returned no match); likely Posthog-only operational toggle n/a
Paywall-AB-test, welcome-quiz-variant, magic-link-method STALE drop or remove n/a

Create navigation, pages, and overlays no longer consume create-flow or faceted-product rollout flags; they always use the shared faceted flow.

Flags listed without a code consumer exist in Posthog but no source-grep hit — they may be remote-config-only or owned by a GTM-side experiment.

The brisket useFeatureFlagEnabled hook returns undefined until flags load; always coerce with !! (already done in use-shop-enabled.ts, use-is-nsfw-ff-enabled.ts) or gate behind useFeatureFlagReady (apps/brisket/src/hooks/use-feature-flag-ready.ts) to avoid flicker.

Verified via mcp__posthog__dashboards-get-all on 2026-09-03 (130 dashboards total, 9 pinned). The pinned set is the canonical “we look at this” surface:

Operating order Dashboard URL
0 0. CEO Operating Dashboard /dashboard/1990337
1 1. Growth, Acquisition & Unit Economics /dashboard/2051295
2 2. Conversion (2026) /dashboard/1471691
3 3. Activation (2026) /dashboard/1475850
4 4. Engagement (2026) /dashboard/1475851
5 5. Retention (2026) /dashboard/1292835
6 6. Revenue (2026) /dashboard/1475852
7 7. Monetization (2026) /dashboard/1492686
8 8. GPU & Credit Economics | CEO Cockpit /dashboard/2054440

The numbered operating set is maintained by growth. Chargebee is the subscriber source of truth (per the Acquisition dashboard description); CAC currently includes only Meta — Google/TikTok/Snap spend is not yet wired.

Brain stores the Fal daily total in inference_provider_spend and its endpoint allocation in inference_provider_spend_breakdown. The schema and replacement behavior are defined in apps/brain/prisma/schema.prisma and apps/brain/src/modules/domain/spend/repositories/spend.repository.ts.

The breakdown table is intentionally not part of the Brain HTTP contract. PostHog reads both spend tables directly from the existing Fennec Postgres warehouse source, so no Brain or Fennec application endpoint is required. The source does not automatically sync newly discovered schemas. Follow the Fal spend to PostHog rollout runbook for the migration, bounded backfill, warehouse refresh, query change, billing reconciliation, and rollback procedure.

  • Capture queue overflow. sirloin batches at BatchSize: 100 and flushes every Interval: 30s. If the Posthog API is unreachable longer than the worker container’s lifetime, in-flight events are lost — there is no disk spool. Mitigation: workers run continuously; restart-induced loss is bounded to 30 s.
  • Identify race. brisket calls posthog.identify(user.user.id, ...) inside a useEffect keyed on user.user?.id. Events captured between hydration and the effect firing are anonymous and only attach to a person retroactively via Posthog’s $anon_distinct_id merge. person_profiles: 'identified_only' keeps anonymous traffic from creating ghost persons but means pre-identify funnel steps depend on alias merging.
  • Flag eval failures. useFeatureFlagEnabled returns undefined while flags load and on network failure. All consumers must coerce with !! or use useFeatureFlagReady. Server-side posthog.isFeatureEnabled(key, userId) is await-ed in tRPC routers — a Posthog outage adds latency to those calls; current code does not have a per-call timeout. TODO(@law): add AbortSignal.timeout wrapper.
  • Browser error filtering. Brisket’s browser telemetry excludes known PostHog fetch failures from error reporting. Check analytics delivery separately when dashboards have gaps; missing error reports do not prove successful analytics ingestion.
  • Autocapture noise in dev. Disabled when NEXT_PUBLIC_DEV_FEATURES === 'true' (PosthogProvider).
  • Web-vitals sampling. before_send: sampleByEvent(['$web_vitals'], 0.2) — 80% of $web_vitals events are dropped client-side.
Variable Where it’s used Notes
BRISKET_NEXT_PUBLIC_POSTHOG_KEY (project key, public) brisket browser + server (apps/brisket/src/lib/posthog.ts, PosthogProvider.tsx) Bundled into the JS bundle; safe to expose.
BRISKET_NEXT_PUBLIC_POSTHOG_HOST brisket SDK config Defaults to https://us.i.posthog.com per apps/brisket/.env.example.
SIRLOIN_POSTHOG_API_KEY sirloin Go client (apps/sirloin/internal/pkg/posthog/client.go) Project key. Empty = no-op client (dev/test).
SIRLOIN_FOXY360_MCP_POSTHOG_URL sirloin Foxy360 connector (read path) Used by the Posthog source tool in Foxy360.
SIRLOIN_FOXY360_MCP_POSTHOG_AUTH_HEADER sirloin Foxy360 connector Bearer <personal-api-key> for read-only queries.

The personal API key (held only in SIRLOIN_FOXY360_MCP_POSTHOG_AUTH_HEADER and in operator credentials for Posthog MCP usage) is not ingest-scoped and grants read access to the whole project. Treat it as a secret. See standards/security-model.md for the broader secrets posture.

TODO(@law): event volume budget and retention contract. Posthog billing tier is not documented in-repo. Hot levers if costs spike:

  • Drop or sample $autocapture (already off in dev).
  • Increase $web_vitals sampling (currently 20% kept).
  • Drop $dead_click / $rageclick if not used by any pinned dashboard.
  • Consolidate near-duplicate funnel events (the nsfw_* lower-snake-case set vs the NSFW * Title-Case set looks like duplication — TODO(@law) with growth before pruning).
  • person_profiles: 'identified_only' — anonymous events do not create person rows.
  • email, primary_email_address, auth_user_id, auth_session_id are set as person properties on identify — classified as Account PII in standards/security-model.md#data-classification. These flow only over TLS to us.i.posthog.com.
  • Payment instrument data (PAN, CVV) is never sent to Posthog. sirloin emits only Primer’s transactionId, BIN6, and card fingerprint — see the Data Classification table in security-model.md.
  • apps/brisket/src/features/analytics/tracking/useTrackEvent.tsx scrubs NSFW-related strings before pushing to GTM dataLayer (the scrubber does not apply to Posthog capture — Posthog is the authoritative store and keeps the raw values).
  • Session replay: brisket loads posthog-js with no session_recording / disable_session_recording config in PosthogProvider.tsx (grep returned no replay-related keys), so the posthog-js default applies — replay follows the project’s Posthog dashboard setting. TODO(@law): confirm the project-level session-recording toggle in the Posthog UI is off in production.