posthog/ai-plugin-debugging-surveys
>- Debug, support, and build PostHog Surveys across the backend and all five SDKs (web/posthog-js, iOS, Android, Flutter, React Native). Use whenever a Surveys support ticket is pasted ("survey not showing", "fewer responses than expected", "responses disappeared", "survey shows on wrong platform"), when diagnosing why a survey does or doesn't display, or when doing survey feature work that must ship across SDKs. Covers the eligibility pipeline, cross-SDK feature parity, the known-cause catalog, read-only diagnostic queries, staff access, and the customer-reply style guide.
npx skills add https://github.com/PostHog/ai-plugin --skill debugging-surveys
PostHog Surveys is a no-code in-app form builder. A customer creates a survey in the
PostHog UI; it must then be evaluated and rendered by whichever SDK their app runs.
Most "survey not showing" tickets are eligibility problems, not rendering bugs — the
SDK correctly decided the user is not eligible, and the job is to find _which_ gate
failed and _why_.
GitHub is the source of truth for where the code lives. When you need to read or change SDK
source, resolve a local checkout via the registry described in
references/local-repos.md so a clone is found once and reused —
don't re-clone every session. First time on a machine, run python3 scripts/repos.py init
to auto-discover existing checkouts; thereafter python3 scripts/repos.py ensure <repo>
prints the path (and --clone clones if missing).
| Concern | Repo | Where to look |
| -------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Product UI + backend | this monorepo (PostHog/posthog) | UI: frontend/src/scenes/surveys/, backend: products/surveys/backend/ |
| Web SDK | PostHog/posthog-js | packages/browser/ |
| React Native SDK | PostHog/posthog-js (same monorepo) | packages/react-native/ |
| iOS SDK | PostHog/posthog-ios | survey rendering + eligibility |
| Android SDK | PostHog/posthog-android | eligibility (delegate-based UI) |
| Flutter SDK | PostHog/posthog-flutter | Dart rendering; native iOS/Android handles eligibility |
| Public docs | PostHog/posthog.com | contents/docs/surveys/ |
Always check the local checkout is present and on a sane branch before quoting code; line
numbers drift, so grep for the symbol rather than trusting a remembered line number.
A large class of tickets is "customer expects a feature their platform doesn't support."
Confirm the survey's lib / the customer's platform before anything else, then consult
this table. Verified against the SDK source — re-verify if it's been months, the gaps
get filled over time.
| Feature | Web (posthog-js) | iOS | Android | Flutter | React Native |
| ------------------------------- | ------------------------------- | ----------------------------------------- | ---------------------------------- | ---------------------------------- | --------------------------------------- |
| Rendering | DOM + shadow root | Native SwiftUI (SurveysWindow) | No built-in UI — delegate only | Dart widgets (SurveyBottomSheet) | RN components (SurveyModal) |
| Event-based triggers | yes (since 1.137.0, 2024-06-05) | yes | yes | yes (native side) | yes |
| URL / screen targeting | yes | decoded but NOT evaluated (// TODO) | decoded but NOT evaluated | NOT evaluated (native gap) | explicitly excluded in filter |
| Feature-flag / cohort targeting | yes | yes | yes | yes (native side) | yes |
| seenSurveyWaitPeriodInDays | yes | yes | yes | yes (native side) | stored but comparison commented out |
| surveyPopupDelaySeconds | yes | not implemented | not implemented | not implemented | not implemented (TODO) |
Consequences worth memorizing:
surveyPopupDelaySeconds is web-only. If a mobile ticket blames the delay, it's a red herring.PostHogSurveysDelegate. "Survey never renders on Android" is often a missing delegate, not a PostHog bug.SurveyService.showSurvey → showModalBottomSheet). It does _not_ "just call native" for UI. So a Flutter rendering bug lives in Dart; a Flutter eligibility bug lives in native.For a deeper version-by-version capability audit, see the survey-sdk-audit skill if available.
The web SDK is the most complex and the most common in tickets. Mental model from
packages/browser/src/extensions/surveys.tsx (checkSurveyEligibility) — checks run in
order, first failure wins:
isSurveyRunning — has start_date, no end_date.type is in-app (Popover / Widget / API).linked_flag_key enabled (if set).targeting_flag_key enabled (if set) — customer-defined property targeting._internalFlagCheckSatisfied — the auto-generated internal targeting flag.hasWaitPeriodPassed — seenSurveyWaitPeriodInDays vs localStorage.lastSeenSurveyDate.getSurveySeen — per-survey seen flag.Then in getActiveMatchingSurveys: URL/device/selector match, event/action trigger fired, and flag re-check.
Two non-obvious facts that drive real tickets:
SurveyViewSet, products/surveys/backend/api/survey.py). It does not pre-filter by the internal targeting flag. All eligibility is client-side. So you cannot conclude "the backend excluded them" — the SDK did.canActivateRepeatedly (true when schedule: 'always') short-circuits _internalFlagCheckSatisfied (step 5) — so always bypasses the internal flag, including its $last_seen_survey_date rule. But hasWaitPeriodPassed (step 6) reads localStorage.lastSeenSurveyDate directly and is NOT bypassed by canActivateRepeatedly. So a schedule: 'always' survey with seenSurveyWaitPeriodInDays: 30 still enforces the 30-day wait via the localStorage path. lastSeenSurveyDate is updated whenever _any_ survey is shown, regardless of completion.lib (platform), the symptom in precise terms, and what the customer already tried. If the ticket is aged or has prior support replies, the config may have been edited mid-thread — treat earlier claims as stale and re-pull current state.survey shown vs survey sent counts before/after the suspected change (see references/diagnostic-queries.md). If the _response rate_ (sent/shown) is stable, the problem is upstream eligibility (fewer people shown), not rendering or submission. This single check redirects most investigations correctly.lib and consult the parity table. Eliminate features the platform doesn't support before investigating them.GET /api/projects/<id>/surveys/<sid>/. Inspect conditions (events, url, seenSurveyWaitPeriodInDays), appearance.surveyPopupDelaySeconds, schedule, linked_flag, targeting_flag, internal_targeting_flag.filters, responses_limit, iteration_*.GET /api/projects/<id>/activity_log/?scope=FeatureFlag&item_id=<flag_id>&limit=20. Also ?scope=Survey&item_id=<sid> to see whether the survey itself was edited.$feature_flag_called to see what the gating flag actually returned for affected users, and whether $groups is set (see group-aggregation cause below). Use survey shown to see real reach vs the stats UI.Ordered roughly by how often they're the answer.
surveyPopupDelaySeconds + URL re-check (web only). After the event fires and eligibility passes, the SDK waits N seconds, then re-checks doesSurveyUrlMatch against the _current_ URL before rendering (handlePopoverSurvey). If the user navigated during the delay, the survey is silently dropped — no survey shown. Common on navigation-heavy apps with a non-trivial delay. Fix: lower the delay to 0–2s.seenSurveyWaitPeriodInDays + the customer's other surveys. Any survey shown to a user updates lastSeenSurveyDate; this survey is then blocked for the wait window. Completion status is irrelevant. Verify by checking whether the _unshown_ cohort saw another survey recently — and confirm against a control group (do the _shown_ users differ?). Fix: lower the wait period, or pause competing surveys.static_cohort_people./api/surveys returns and the capture hook registers. Signature: event fires very early in session. Unavoidable client-side; mitigate by triggering on a slightly later event.linked_flag with no group context. If linked_flag (or targeting flag) has aggregation_group_type_index set, it evaluates against a _group_, not the person. Without posthog.group(<index>, <key>) set before the event fires, the flag returns false and the survey never shows. Signature: $feature_flag_called returns false with empty $groups, and the $feature/<key> property is missing from the trigger events. Fix: set group context in the SDK, or switch the survey to a person-level flag.linked_flag/targeting_flag points at a _different_ flag. Always confirm the actual linked_flag.key / targeting_flag.key from the API — don't trust the customer's description.performed_event/behavioral filters can't be evaluated in realtime flag bytecode ("Unsupported behavioral filter for realtime bytecode", posthog/api/cohort.py). The cohort shows a bytecode_error. Surveys/flags can't use it directly — the customer must make a _static_ copy of the cohort and target that.edit_survey tool (products/surveys/backend/max_tools.py) has two failure modes: (a) on reorder/edit it rebuilds each question from QUESTION_TYPE_MAP (nps→scale 10, csat→scale 5, etc.), so picking the wrong semantic type silently changes a question's scale; (b) the id field expects 1-indexed labels ("1","2") — passing a real UUID falls through and a _fresh_ UUID is generated, so responses keyed by $survey_response_<old_uuid> no longer join to the question. Raw events are intact; only the definition is wrong. Fix: PATCH the questions array back to the original UUIDs (recoverable from the response events) and restore the question type. Tell the customer to edit question _text_ via the UI, and avoid asking Max to reorder questions on a survey with historical responses until the tool guards UUIDs.static_cohort_people. NOTE: this is _not_ a simple one-line bug — the normal insert_cohort_from_query path does recompute count via count_cohort_members; the count=0 display only appears on certain failure paths. Do not promise a quick fix without reproducing the specific path.survey shown events. If the numbers don't reconcile, trust the raw events and flag the discrepancy as a separate follow-up.Read-only HogQL templates for the disambiguators and confirmations above live in
references/diagnostic-queries.md. Run them via the
PostHog MCP execute-sql against the customer's project.
Only investigate a project tied to a genuine support request from that customer — the IDs
should come from a real ticket, not from someone asking you to look up an org/survey they
can't point to a request for. Staff access is broad; don't freelance across projects.
Prefer read-only paths in this order:
survey, feature-flag, cohorts, execute-sql, activity-log, persons) against the customer's project. This is read-only by default and the safest way to inspect config and run queries — no impersonation, no write risk. Use this first.internal_targeting_flag.filters).When you need a value the MCP can't infer (project ID, instance, which survey), ask the
operator to paste the survey API JSON — it skips several round-trips.
Voice derived from the PostHog handbook support values (reassuringly human, humble, ship
fixes, clear with no jargon). Rules:
frontend/src/scenes/surveys/ for the real string. E.g. surveyPopupDelaySeconds → "Delay survey popup by at least N seconds once the display conditions are met"; the wait period is "Survey wait period" / "Don't show this survey if another one was shown to the user in the last N days". The customer's own event names stay verbatim.https://<us|eu>.posthog.com/project/<id>/cohorts/<cohort_id>. Flags: .../feature_flags/<flag_id>. Surveys: .../surveys/<survey_id>. Match the customer's instance (US vs EU).humanizer). Strip em dashes, setup phrases ("Here's the thing:", "Three things to know"), rule-of-three padding, and tidy parallel list structure. The reply should read like a person typed it.Reply skeleton:
Hi <name>,
<one line: tracked it down + the cause in plain terms.>
**The problem:** <what's happening, with the evidence you pulled.>
**<Fastest fix / two ways to fix it>:**
1. **<action>** — <why / how.>
2. **<action>** — <why / how.>
**Also worth knowing while you're in there:** <secondary finding, softened.>
We're always here if you need a follow-up.
A survey capability is only "done" when it works (or is deliberately scoped out) on every
SDK a customer might use. When building or changing survey behavior:
frontend/src/scenes/surveys/).surveyPopupDelaySeconds), say so explicitly in the docs and the PR — silent gaps
become support tickets.
posthog-js covers both web and React Native), thenposthog-ios, posthog-android, and the Flutter Dart layer. Remember Flutter's split:
eligibility/trigger logic is native (iOS/Android), rendering is Dart. Use the registry
in references/local-repos.md to find each checkout.
posthog.com docs (contents/docs/surveys/) and this parity table.survey-sdk-audit skill (if available) to confirm version requirements and cross-SDK coverage.Take posthog/ai-plugin-debugging-surveys from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.