.agents/skills/posthog-instrumentation/SKILL.md
One idea: every event exists to answer a question ("how do people filter?", "v3 vs v4?"), and it captures shape/metadata — NEVER raw values. If you cannot name the question an event answers, do not add it. If a property could contain user content, that is a bug.
When adding a meaningful user action to web/**, decide explicitly whether it
should emit an event — do not skip the question silently.
usePostHogClientCapture() → capture(eventName, props?)
(usePostHogClientCapture.ts).
The component calls the hook's capture — it never imports or touches
posthog directly. The only client-side exceptions are app-shell
infrastructure in _app.tsx ($pageview, identify, register).resource:action, snake_case action. Enforced by the typed
registry — the events object + EventName type in that file. New events
MUST be added to the registry or they will not typecheck (e.g.
saved_views:view_selected, table:filter_builder_open). Names are static
strings only — never interpolate (a page_view_${name} explodes into
un-analyzable definitions). Event names cannot be renamed after they exist —
version instead (…_v2).objectAdjective (filterType,
valueCount), is/has prefix for booleans (isV4, hasFreeText),
Date/Timestamp suffix for times._app.tsx (e.g. v4BetaEnabled via
posthog.register, removed on sign-out via posthog.unregister) so they
attach to every event. Also put a per-event copy on events where the value
at the moment of the action matters (Rule 4). For org/project-level
segmentation, PostHog group analytics is the documented mechanism — see
references/posthog-code-patterns.md §4.ServerPosthog (e.g. cloud_signup_complete, playground analytics) with
event names distinct from any frontend event — keep it that way; reusing a
name across client and server double-counts.productAnalyticsAvailability.ts:
the browser SDK (posthog-js) is only initialized when
getPostHogClientConfig() returns a config — init() itself fetches remote
config, so skip it rather than opting out after. The server SDK
(posthog-node, via ServerPosthog) is constructed as usual, then
disable()d when isProductAnalyticsAvailable() is false. Missing env vars
are NOT a gate — ServerPosthog falls back to Langfuse's telemetry key.
Never add a per-call-site region list, and never construct a PostHog client
that bypasses that module.PRIVACY is rule #1 — metadata only, never raw values. Safe: type,
column, operator, key (a field name, not a value), counts
(valueCount, conditionCount), lengths (queryLength = char count, NOT
the text), enums (trigger, reason), tableName, booleans. Never:
the raw filter value, search text, the AI prompt, userId/sessionId,
metadata content, tag names.
⚠ Real leak: table:filter_builder_close sent { filter: filterState }
— the whole filter including values → PII in PostHog. Fixed to
{ filterCount } in #14929. Grep any event you touch for a raw value.
💡 How PostHog itself keeps PII out (see
references/posthog-code-patterns.md §2):
not SDK denylists — the payload only accepts metadata. Encode
"counts/lengths/booleans/enums only" into parameter types (raw content
→ a type error) and shared sanitize*() helpers for complex objects.
Reserve a client-side before_send hook for last-mile secrets (tokens in
URLs/share links) and for disabling capture on publicly-embedded views —
a backstop, not the primary defense.
Instrument the INTENT seam, not the low-level setter. Capture in the
per-action function the user triggered (updateFilter, updateOperator,
commit), NOT the shared state setter (setFilterState) — the setter also
fires on programmatic restores (saved views, URL nav, defaults) →
double-count / phantom events. Find the function that maps 1:1 to "the user
did X".
Fire once per action — dedup. Guard no-op triggers (a blur with no change must NOT emit). When one emitting handler nests another (e.g. an operator toggle that internally calls updateFilter), use a suppress-ref so it emits once. Beware stale refs in external-sync effects that commit via the raw setter and leave a baseline count stale → the next edit mis-fires.
Carry the key segmentation DIMENSION on every event — and make sure it is
actually populated. For Langfuse the headline dimension is v3 vs v4
(fast mode) — filtering (and much else) behaves very differently across
them. Put isV4 on every relevant event, derived from the surface at the
moment of the action (v4 events table / grammar search bar → true;
v3/legacy → false; shared components → from the table/view context).
⚠ Real P1: a component-level isV4/tableName prop that defaults to
"unknown"/false is worthless if callers do not forward it — forward
it from EVERY call site and verify the emitted event carries the real
value, not the default. The super property is a global backstop, not the
only source.
Cover every sub-path of a surface. A "filter applied" event that only fires for some facet kinds is a silent hole. ⚠ Real gap: keyed-facet handlers (metadata / scores) called the setter directly and emitted nothing — on exactly the surfaces we most wanted data. Enumerate a surface's actions and confirm each emits.
VERIFY LIVE — intercept the real capture calls; do not assume. Spy on
window.posthog.capture (or the network POST to the PostHog ingest
endpoint) with Playwright and assert: (a) the action fires the event
exactly once (no double-count, no blur-refire); (b) the key dimension
is present and correct (contrast a v4 surface vs a v3 surface → isV4
true vs false); (c) PRIVACY — dump every payload and confirm NO raw value
/ search text / prompt / id appears. A green typecheck ≠ correct
analytics; the dimension-populated and privacy checks catch the real bugs.
Treat session replay as a separate data-export surface from analytics events.
Apply these rules when changing posthog.init, replay configuration, or a UI
renderer that can display customer-controlled data:
title, aria-*, or other attributes. Ordinary rendered text can be as
sensitive as an <input> value.ph-no-capture on the
smallest shared component or renderer that owns a sensitive value. Block
the complete subtree so text, attributes, nested media, and later view-mode
changes inherit the protection. Keep native input masking and a
contenteditable selector as backstops, not the primary policy.Before implementation, write a one-paragraph data-boundary plan: what remains
visible, what must never leave the browser, which deployment classes are
eligible, and which shared renderers enforce it. If that boundary includes
customer-controlled content, get an explicit product/legal decision rather
than inferring consent from existing analytics. Also run the
security-review skill and its
client-telemetry-privacy.md
checklist.
capture() calls. (This IS the PR description.)events object (typed).capture() at the intent seams (Rule 2), with dedup (3), the
dimension (4), full coverage (5), metadata-only (1).For replay-only changes, use the session-replay data-boundary plan and recorder probe above instead of inventing an analytics event or taxonomy entry.
"unknown" (Rule 4).resource:action snake_case; do not drift to
spaces or rename existing events.posthog.capture in a component instead of the typed hook.maskAllInputs covers rendered text, custom editors, attributes,
or portals.isV4 dimension, and three review fixes (unforwarded dimension,
coverage gap, stale-ref misfire) are the canonical case study.