.agents/skills/posthog-instrumentation/references/posthog-code-patterns.md
Real code patterns from the PostHog product monorepo (PostHog/posthog, React + Kea,
frontend/src/). Complements the docs (posthog-best-practices.md). Where PostHog's practice beats
its docs, the practice wins.
eventUsageLogicfrontend/src/lib/utils/eventUsageLogic.ts — a single Kea logic with 267 posthog.capture( calls
(the next-densest file has 17). Two-part shape: actions({ reportX: (…) => payload }) types every
event; listeners({ reportX }) is the only place that calls posthog.capture(name, props). A
component calls reportInsightViewed(...) — it never names the event string or touches posthog.
~165 consumers via useActions(eventUsageLogic). A few domain satellites follow the same shape
(onboarding/session-recording event logics). → Langfuse's typed usePostHogClientCapture registry is
the same instinct. The borrow is discipline: every event string + property shape in ONE reviewable
module; forbid raw posthog.capture at component call sites (a lint rule is a candidate hardening) so
nothing leaks the pattern.
sanitize* helpers AT THE CALL SITE — NOT SDK denylistsKey correction to the docs' framing: PostHog carries ZERO property_denylist /
sanitize_properties / mask_all_text in its SDK init (frontend/src/loadPostHogJS.tsx). Raw content
never becomes a property because the action payload only accepts metadata. Examples from
eventUsageLogic.ts:
sanitizeQuery — "returns properties that don't contain sensitive data": emits query_kind,
series_length, event_entity_count, has_properties (bool), display — never the filter values.sanitizeInsight strips result before send.reportPersonDetailViewed → properties_count, has_email (bool), has_name (bool) — a rich
profile with no property value.name_length/tags_count/searchLength/resultsCount everywhere — counts/lengths, never content.objectClean(...) drops undefined before send.→ For Langfuse: encode "counts/lengths/booleans/enums only" into the typed registry's parameter
types so passing raw content is a type error; add shared sanitize*(query|filter|…) helpers for
complex objects. This is stronger than a runtime denylist. (Keep before_send only for last-mile
secrets — §6.)
resource:action snake_case is BETTER; keep itEmpirically PostHog is inconsistent: of 781 distinct event names, ~80% are space-separated lowercase
("insight viewed", "viewed dashboard"), ~19% snake_case (newer areas: ai_query_prompted,
error_tracking_issues_sorted), 0 use a colon scheme. The one thing they get right is
resource-first ordering (grouping by leading word). Names can't be renamed → they froze
'viewed dashboard' for back-compat. → Langfuse's resource:action snake_case delivers
resource-grouping explicitly and machine-parseably. Don't drift to spaces. Version instead of rename
(…_v2).
Stamped at login/preflight, not on every capture:
frontend/src/scenes/userLogic.ts: posthog.register({ is_demo_project }) (super prop) +
posthog.group('project', uuid, {...}) + posthog.group('organization', id, {...}) +
posthog.group('customer', …); person props via posthog.people.set({ email: anonymize?null:email, realm }).frontend/src/scenes/PreflightCheck/preflightLogic.tsx: posthog.register({ realm, commit_sha, … })
— commit_sha on every event.frontend/src/scenes/billing/billingLogic.tsx: dynamic per-product usage super-props.frontend/src/taxonomy/taxonomy.tsx CLOUD_INTERNAL_POSTHOG_PROPERTY_KEYS — a catalog of the
internal dimensions.→ For Langfuse (multi-tenant org→project): group analytics
(posthog.group('organization'|'project', …)) + register version/edition/v4BetaEnabled once is
the scalable shape, instead of repeating global dims per event. Keep a catalog. NOTE: isV4/fast-mode
is per-action surface state → it stays a per-event prop (the user-global v4-beta flag can also be
a super prop, but the per-event value reflects the surface at the action).
From products/notebooks/plan/notebooks_observability_gaps.md (an internal instrumentation-design
review — the closest thing PostHog has to a playbook): server-side capture at the creation choke
point = source of truth for counts; distinct event names structurally prevent double-counting
("rename the frontend capture to notebook created (client)"); keep the FE event for context the
server can't see ($session_id replay linkage, $current_url surface attribution, flag enrollment,
funnel continuity); a stable id (short_id) as idempotency key. → Langfuse already keeps server events
(ServerPosthog: cloud_signup_complete, playground analytics, telemetry) on distinct names — never
reuse an event name across FE and server.
before_send + replay opt-out for last-mile secrets; kill tracking on embedded/shared viewsfrontend/src/loadPostHogJS.tsx exposes a before_send hook; frontend/src/exporter/index.tsx sets
apiKey=undefined on shared/embedded dashboards (don't log customers' end-users) and, on the public
interview page, a before_send strips a secret token from $current_url/$referrer + a
maskCapturedNetworkRequestFn scrubs it from replay. Replay opt-out via
session_recording.blockSelector: '.ph-replay-block'; staff impersonation →
opt_out_capturing_by_default. → For Langfuse: before_send is for secrets that shouldn't be
captured even in principle (tokens in URLs/share links) + disabling capture on publicly-embeddable
views (e.g. public traces) — NOT the primary PII mechanism (that's §2).
Component → const { reportX } = useActions(eventUsageLogic) → call reportX(...) in the handler.
No posthog-js/react usePostHog() hook is used in their app (raw posthog-js singleton wrapped
in Kea). Direct posthog.capture in a .tsx exists only for lib-level infra widgets (Spinner,
toolbar). → Langfuse's usePostHogClientCapture() hook is the equivalent seam (Langfuse isn't Kea) —
the rule is the same: the component calls a typed verb; it never names the event or touches
posthog.
Guidance is scattered (.cursor/rules/react-typescript.mdc forces the Kea-first "events are data"
pattern; their AGENTS.md has a naming casing rule + a Celery capture gotcha). → A dedicated Langfuse
instrumentation skill is MORE disciplined than PostHog's own setup — a genuine improvement, not just a
copy.
PostHog/posthog)frontend/src/lib/utils/eventUsageLogic.ts · frontend/src/loadPostHogJS.tsx ·
frontend/src/exporter/index.tsx ·
frontend/src/scenes/{userLogic.ts,PreflightCheck/preflightLogic.tsx,billing/billingLogic.tsx} ·
frontend/src/taxonomy/taxonomy.tsx · .cursor/rules/react-typescript.mdc ·
products/notebooks/plan/notebooks_observability_gaps.md.