Debugging Surveys
PostHogOfficialPublished by PostHog
- Skill
- Role or persona
- Data and AI
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured diagnostic workflow for investigating PostHog Surveys support tickets. It covers the end-to-end lifecycle of a survey, from the eligibility pipeline and SDK-specific rendering capabilities to response storage and branching logic. It emphasizes distinguishing between actual bugs and expected behavior caused by survey configuration, such as branching, optional questions, or platform-specific feature limitations.
What it does
- Provides a step-by-step diagnostic workflow for survey-related support tickets.
- Includes a cross-SDK feature parity table to identify platform-specific limitations.
- Explains the web eligibility pipeline and common causes for surveys not displaying.
- Details how survey responses are stored, including partial responses and branching logic.
- Offers HogQL templates for querying survey events, response rates, and gating flags.
- Maintains a catalog of known causes for common symptoms like incomplete responses or unexpected reach.
When to use it
- When a customer reports that a survey is not showing or is showing to fewer users than expected.
- When diagnosing issues with incomplete or missing survey response data.
- When investigating reports of surveys appearing on the wrong platform or displaying incorrect questions.
- When verifying if a reported behavior is a genuine SDK bug or a result of specific survey configurations.
Advantages
- Reduces reliance on guesswork by providing clear diagnostic paths for common symptoms.
- Prevents expensive and unnecessary advice, such as rebuilding surveys, by identifying configuration-based causes.
- Ensures consistent troubleshooting across different SDKs (web, iOS, Android, Flutter, React Native).
- Leverages standardized HogQL queries to reconcile raw event data with UI-reported metrics.
Usage examples
- “The customer says their survey is not showing on their Android app. How do I start?”
- The agent will first confirm if the Android SDK supports the survey's features, check for a missing PostHogSurveysDelegate, and verify if the survey's gating flag is correctly configured for the user.
- “A user reports that their survey responses are incomplete, showing only the first question answered.”
- The agent will examine the survey's branching and optional question settings to determine if the 'incomplete' responses are actually valid paths where subsequent questions were skipped.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/posthog/debugging-surveys/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip debugging-surveys.zip -d .agents/skills
Documentation by PostHog
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.
Access requirements
Use PostHog MCP tools or the survey API to inspect survey configuration and responses. A checkout of the PostHog repository is not required for these diagnostic steps.
Cross-SDK feature parity (check this FIRST)
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) | yes (≥ 4.81.5) |
surveyPopupDelaySeconds | yes | not implemented | not implemented | not implemented | not implemented (TODO) |
enable_partial_responses | yes (≥ 1.240.0) | no | no | no | no |
skipSubmitButton (auto-submit) | yes (≥ 1.244.0) | no | no | no | no |
The last two rows are per the editor's own help text ("Doesn't work with the mobile SDKs for now" / "Not available for the mobile SDKs at the moment") rather than a per-SDK source audit.
Consequences worth memorizing:
surveyPopupDelaySecondsis web-only. If a mobile ticket blames the delay, it's a red herring.Partial responses and auto-submit are web-only too. On mobile a survey always stores one response at the end, and a rating tap never self-submits. Don't carry a web diagnosis onto a mobile ticket.
URL targeting is effectively web-only. Mobile SDKs decode the field but never enforce it; React Native filters those surveys out entirely. A mobile survey with a URL condition behaves as "no URL condition" (mobile/flutter) or "never shows" (RN).
Android ships no survey UI. The app (or the Flutter plugin) must provide a
PostHogSurveysDelegate. "Survey never renders on Android" is often a missing delegate, not a PostHog bug.Flutter is hybrid: triggering/eligibility runs in the native iOS/Android layer; rendering is Dart (
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.React Native enforces the wait period from 4.81.5. Older versions skip the check, so on those versions don't blame the wait period.
For a deeper version-by-version capability audit, see the survey-sdk-audit skill if available.
How a survey actually gets shown (the web eligibility pipeline)
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— hasstart_date, noend_date.survey
typeis in-app (Popover / Widget / API).linked_flag_keyenabled (if set).targeting_flag_keyenabled (if set) — customer-defined property targeting._internalFlagCheckSatisfied— the auto-generated internal targeting flag.hasWaitPeriodPassed—seenSurveyWaitPeriodInDaysvslocalStorage.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:
The server returns ALL non-archived surveys (
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.The wait period has TWO independent implementations.
canActivateRepeatedly(true whenschedule: 'always') short-circuits_internalFlagCheckSatisfied(step 5) — soalwaysbypasses the internal flag, including its$last_seen_survey_daterule. ButhasWaitPeriodPassed(step 6) readslocalStorage.lastSeenSurveyDatedirectly and is NOT bypassed bycanActivateRepeatedly. So aschedule: 'always'survey withseenSurveyWaitPeriodInDays: 30still enforces the 30-day wait via the localStorage path.lastSeenSurveyDateis updated whenever any survey is shown, regardless of completion. The same short-circuit also drops the internal flag's$survey_responded/<id> is_not_setrule, soschedule: 'always'lets one person respond repeatedly — checkuniq(distinct_id)againstcount()onsurvey sentbefore reading a response count as a respondent count.
How a response actually gets stored
Showing a survey and storing a response are separate pipelines. "Wrong data" tickets are about the second one, and its UI copy is genuinely misleading — reason from the code, not from the label.
"Response collection" maps to enable_partial_responses
The radio in frontend/src/scenes/surveys/SurveyResponsesCollection.tsx. "Any question: when at
least one question is answered…" is true; "Complete survey: the response is stored when all
questions are answered" is false.
true->sendSurveyEventfires on everyonNextButtonClick, so onesurvey sentper question answered, all sharing a$survey_submission_idwith$survey_completedrunningfalse ... true.false-> onesurvey sentevent, at the end of the path.
The setting controls capture, not response visibility. The results include every survey sent
and every survey dismissed or survey abandoned marked $survey_partially_completed = true.
buildMergedSubmissionsSubquery in frontend/src/scenes/surveys/utils.ts merges the latest
answer per question for each submission ID; events without an ID stay separate. Raw event
counts can exceed response counts. A completed event takes precedence over earlier partial
closures. Otherwise the outcome chip reads "Dismissed" when the latest response event is a
dismissal, and "Abandoned" for other unfinished submissions. "Abandoned" does not prove a
page-unload event or inactivity timeout occurred; a later completion updates the same row.
The defaults disagree by creation path: NEW_SURVEY sets true
(frontend/src/scenes/surveys/constants.tsx) but the Django model default is False
(products/surveys/backend/models.py). Read it, don't infer it from the creation date.
"All questions answered" does not mean what it says
isSurveyCompleted is getNextSurveyStep(...) === End — the respondent reached the end of their
own branching path. Nothing checks that every question holds a value, and two things then blank
out cells on a complete response: questions skipped by branching have no key at all
(onNextButtonClick prunes to visitedIndices before capture, so absent rather than "" or
null), and optional: true questions clicked past are stored as null
(submitDisabled is isNull(rating) && !question.optional).
So on a branching survey with optional tail questions, "only the first question was answered" is
the expected shape of a complete response. Blank ≠ unanswered. Check branching and optional
on every question before believing a bug.
Branching is in the API — read it, don't ask for it
It's questions[].branching, returned verbatim by both the management API and the SDK payload, in
four shapes: next_question, end (the confirmation message), specific_question + index, and
response_based + responseValues. Two traps: for rating questions responseValues is keyed
by bucket (negative/neutral/positive, or NPS detractors/passives/promoters), not by
value; and a bucket missing from responseValues silently falls through to
currentQuestionIndex + 1. So {negative: 3, neutral: 3} on a 5-scale is a complete, working
config that reads like an omission. Bucket boundaries per scale are in
references/reading-responses.md — don't guess them, scale 2 is
inverted.
The event vocabulary
survey shown (fired inside showSurvey() at the moment the popup becomes visible, i.e. after
surveyPopupDelaySeconds, so shown→sent latency is real time-on-popup), survey sent,
survey dismissed (explicit close), survey abandoned (handlePageUnload →
sendSurveyAbandonedEvent). $survey_partially_completed appears on dismissed and abandoned
only, never on sent. All of them carry sessionRecordingUrl, so a disputed submission can often
be watched instead of theorized about. A populated URL only proves a session id was captured,
not that a recording exists: replay is off by default, and the default cloud retention of 30 days is
well short of these queries' 180-day window. Open the link before offering it as evidence.
Read answers with getSurveyResponse, never a guessed key
getSurveyResponse(<index>, '<questionId>') is the HogQL helper the product itself reads
answers with (posthog/hogql/functions/survey.py, used across
products/surveys/backend/responses/). Prefer it over hand-writing a property key: response keys
come in three formats, and the helper coalesces the current UUID-keyed one
($survey_response_<questionId>) with the legacy index-keyed one, so it returns what the customer's
own results table shows. Pass true as a third argument for multiple-choice questions. A
hand-written properties.$survey_response_<uuid> misses legacy-keyed answers, and inside a
countIf that silently converts a real answer into "unanswered" and inflates the incomplete ratio.
Whichever you use, the index and id must come from questions[] in the survey JSON. Guessing
which UUID belongs to which question is the fastest route to a wrong conclusion: it reads one
question's answers under another's label, and the resulting ratio looks like a serious bug. If you
have no survey JSON, unroll $survey_questions, which carries the question text alongside the
answer. Formats and templates: references/reading-responses.md,
references/diagnostic-queries.md.
Sanity check the mapping before trusting the numbers: if a column you labeled as a single-choice question contains free text, the mapping is wrong.
Debugging workflow
Parse the ticket. Extract: org/project ID, instance (US vs EU — URLs differ), survey ID(s), the
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.Disambiguate "none" vs "fewer." Customers say "no responses" when they mean "fewer." Pull the
survey shownvssurvey sentcounts 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.
2b. For a data-quality ticket, disambiguate "incomplete" vs "complete but sparse." Before anything else, check whether the blank cells are questions the respondent was never asked. Branching plus optional questions makes a complete response look abandoned, and this is the single most common reason a survey gets reported as broken when it isn't. See How a response actually gets stored.
Platform parity check. Confirm the
liband consult the parity table. Eliminate features the platform doesn't support before investigating them.Pull the survey definition.
GET /api/projects/<id>/surveys/<sid>/. Inspectconditions(events, url, seenSurveyWaitPeriodInDays, repeatedActivation),appearance.surveyPopupDelaySeconds,appearance.position,schedule,linked_flag,targeting_flag,internal_targeting_flag.filters,responses_limit,iteration_*,enable_partial_responses— and then go inside each question:id,type,scale,optional,skipSubmitButton,branching. The per-question fields answer most "wrong data" tickets on their own and are easy to miss, since a shallow look at top-level keys doesn't surface them.Pull the targeting-flag activity log for any "stopped showing" ticket. Cohort swaps and rollout changes are invisible in the current config but show up here:
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.Confirm with events. Use
$feature_flag_calledto see what the gating flag actually returned for affected users, and whether$groupsis set (see group-aggregation cause below). Usesurvey shownto see real reach vs the stats UI.Diagnose against the known-cause catalog, confirm with one targeted query, then write the reply.
Before you call it an SDK bug
These tickets attract a specific failure mode: the symptom looks impossible, so the SDK gets blamed and the customer is told to pause the survey, file a bug, and rebuild their questions. That advice is expensive and hard to walk back — restructuring destroys the comparability of every response already collected, and reordering questions risks the UUID problem below. Clear all four gates before writing "this is a bug":
Read the survey JSON, per-question
branching,optional,skipSubmitButtonandscaleincluded. Most "impossible" behavior is configured behavior.Reproduce it in Preview.
Read the SDK source for the handler you're accusing. Grep the symbol; don't reason from what a setting is named.
enable_partial_responsesand "all questions answered" both mean something narrower than they sound.Re-check your own query before trusting a shocking ratio. "89% of responses are incomplete" is more often a bad response-key mapping than a real defect.
If it survives all four it's a bug: name the file and handler, and file it upstream.
Known-cause catalog
Ordered roughly by how often they're the answer.
"Survey shows to fewer users than expected"
surveyPopupDelaySeconds+ URL re-check (web only). After the event fires and eligibility passes, the SDK waits N seconds, then re-checksdoesSurveyUrlMatchagainst the current URL before rendering (handlePopoverSurvey). If the user navigated during the delay, the survey is silently dropped — nosurvey 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 updateslastSeenSurveyDate; 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.Cohort composition changed. If the survey targets a cohort and someone edited the source dynamic cohort (e.g. added a behavioral filter), every static snapshot taken afterward inherits the narrower definition. Reach drops without any survey-side change. Find it in the flag activity log (cohort swap) and confirm cohort sizes via
static_cohort_people.
"Event-based survey never fires"
Timing race at session start. Event captured before
/api/surveysreturns and the capture hook registers. Signature: event fires very early in session. Unavoidable client-side; mitigate by triggering on a slightly later event.Group-aggregated
linked_flagwith no group context. Iflinked_flag(or targeting flag) hasaggregation_group_type_indexset, it evaluates against a group, not the person. Withoutposthog.group(<index>, <key>)set before the event fires, the flag returns false and the survey never shows. Signature:$feature_flag_calledreturnsfalsewith 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.Customer wired the survey to the wrong flag. They create a flag with email/property targeting but the survey's
linked_flag/targeting_flagpoints at a different flag. Always confirm the actuallinked_flag.key/targeting_flag.keyfrom the API — don't trust the customer's description.Behavioral cohort in a realtime flag. A cohort with
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 abytecode_error. Surveys/flags can't use it directly — the customer must make a static copy of the cohort and target that.
"Responses are incomplete — only the first question has a value"
Usually not a bug — see How a response actually gets stored. Work these four first; only then is the completion condition genuinely not being honored.
Branching skipped the blank questions. Map it from
questions[].branchingyourself. Customers describe their intent, which may not be what's saved, and a rating bucket absent fromresponseValuesfalls through to the next index.The blank questions are
optional: trueand the respondent clicked past them. A rating plus two skipped optional open-text questions is a complete submission with one value in it.Partial responses is on and you're reading intermediate events in raw SQL that the UI collapses by
$survey_submission_id.The response keys were guessed. Re-run keyed off
questions[].id.
"The respondent says they didn't mean to submit"
skipSubmitButton, shown in the editor as "Automatically submit on selection". When true no submit button renders at all (BottomSection.tsx) and one click both records the answer and advances —setRating(response)thenhandleSubmit(response)→onNextButtonClickin the same handler invocation, no debounce, no confirm. Rating and single-choice-without-open-choice only (canQuestionSkipSubmitButton); web-only. It is on by default in every survey template (constants.tsx) and in quick-create, so customers rarely know they enabled it.It compounds with popup placement.
appearance.position: middle_center, a largemaxWidth, and a non-zerosurveyPopupDelaySecondsmean the popup materializes seconds after the trigger under a cursor that's still moving, and the next click lands on an answer. Consecutive auto-submitting questions let someone click through most of a survey without reading it.Diagnose with shown→sent latency, then confirm from the replay (
sessionRecordingUrlonsurvey sent). A cluster of sub-10-second completions on a multi-question survey is the signature; genuine respondents take longer and leave text behind. Fix is to uncheck the setting — not to move the question, since the behavior follows the question wherever it sits.
"Responses show as zero in the UI but raw events exist"
Max AI corrupted the survey definition. Max's
edit_surveytool (products/surveys/backend/max_tools.py) has two failure modes: (a) on reorder/edit it rebuilds each question fromQUESTION_TYPE_MAP(nps→scale 10,csat→scale 5, etc.), so picking the wrong semantic type silently changes a question's scale; (b) theidfield 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 thequestionsarray 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.
"Cohort count shows 0 but the cohort is populated"
Cosmetic UI bug, does not affect targeting. Confirm the real count via
static_cohort_people. NOTE: this is not a simple one-line bug — the normalinsert_cohort_from_querypath does recompute count viacount_cohort_members; thecount=0display only appears on certain failure paths. Do not promise a quick fix without reproducing the specific path.
General caution
Aged tickets are dirty. Config may have been edited by the customer or a prior agent during troubleshooting. Pull activity logs; frame secondary findings as "while you're in there, double-check X" rather than "we found X is broken."
The stats UI can undercount vs raw
survey shownevents. If the numbers don't reconcile, trust the raw events and flag the discrepancy as a separate follow-up.Check both
$emailandemailon the person.person.properties.$emailis what the SDKs set; plenty of customers also set a bareemail. Querying one and concluding "this user was never identified" is a common miss — usecoalesce(person.properties.$email, person.properties.email).
Diagnostic queries
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.
Access for debugging
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:
PostHog MCP tools (
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.Survey/flag API endpoints read via the browser while impersonating (staff). Good for the full JSON the MCP may not surface verbatim (e.g. raw
internal_targeting_flag.filters).Django admin only when 1 and 2 can't answer it. It's powerful and write-capable, so treat it as read-only by discipline: look, don't change. Never edit a customer's survey/flag/cohort from admin without explicit customer consent.
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.
Writing the customer reply
Voice derived from the PostHog handbook support values (reassuringly human, humble, ship fixes, clear with no jargon). Rules:
Lead with the cause, then the fix. One line on what's happening, then what to do.
Bold the issue and bold each action. Make the problem and the next step scannable.
Use the labels the customer sees in the UI, never internal field names. Grep
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";skipSubmitButton→ "Automatically submit on selection";enable_partial_responses→ the "Response collection" radio ("Any question…" / "Complete survey…");optional→ the question's "Optional" toggle. The customer's own event names stay verbatim.When correcting an earlier wrong answer, say so plainly and early, then give the real cause. Don't bury it or let the customer keep acting on advice we've retracted. If they've already been told to pause or rebuild something, tell them explicitly that they can stop.
Link every PostHog entity by ID. Cohorts:
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).Predict the expected outcome so the customer can verify the fix worked ("you should see ~X going forward").
Gauge the customer's technical level. If they can run SQL and hit the API, offer the patch path. If not, offer to apply the fix ourselves (and confirm any destructive detail first).
Do NOT offer to "hop on a call" or book a meeting. PostHog support is async-first. Close with "We're always here if you need a follow-up."
Never leak internals — no MCP tool names, code paths, line numbers, Django admin, staff impersonation, or other customers. Keep it to product concepts a customer recognizes.
Run the final draft through a humanizer skill before sending (if you have one, e.g.
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.Files
- ATTRIBUTION.mdAdded by AutoGineer853 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md27.7 KiB
- references/diagnostic-queries.md11.7 KiB
- references/reading-responses.md4.0 KiB
references/diagnostic-queries.md
Diagnostic queries (HogQL, read-only)
Run via the PostHog MCP execute-sql against the customer's project. Adjust the date and
survey ID. Tables: events, static_cohort_people (NOT person_static_cohort — that's
the ClickHouse name; HogQL exposes static_cohort_people), persons.
Contents
Shown vs sent, before/after a change (the "none vs fewer" disambiguator)
What did the gating flag return, and was group context set
Did a static cohort actually populate (with country breakdown)
Real reach by survey, before/after a date (find the affected surveys)
Was partial-response collection ever on?
Full event funnel including abandonment
Every question and its answer, keyed off the survey JSON (preferred)
Same thing without the survey JSON: unroll
$survey_questionsAnswer rate per question (is "incomplete" just branching?)
Shown → sent latency (accidental / stray-click submissions)
Replay link per response (watch a disputed submission)
Shown vs sent, before/after a change (the "none vs fewer" disambiguator)
SELECT
countIf(event = 'survey shown' AND timestamp < toDateTime('<CUTOFF>')) AS shown_before,
countIf(event = 'survey shown' AND timestamp >= toDateTime('<CUTOFF>')) AS shown_after,
countIf(event = 'survey sent' AND timestamp < toDateTime('<CUTOFF>')) AS sent_before,
countIf(event = 'survey sent' AND timestamp >= toDateTime('<CUTOFF>')) AS sent_after
FROM events
WHERE properties.$survey_id = '<SURVEY_ID>' AND timestamp >= toDateTime('<WINDOW_START>')Stable sent/shown ratio ⇒ upstream eligibility issue, not rendering/submission. Always normalize by period length (before vs after windows are rarely equal).
What did the gating flag return, and was group context set
SELECT distinct_id, timestamp,
properties.$feature_flag_response AS flag_response,
properties.$groups AS groups_in_session,
person.properties.email AS email
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag = '<FLAG_KEY>'
AND timestamp >= toDateTime('<WINDOW_START>')
ORDER BY timestamp DESC LIMIT 50All false with empty $groups ⇒ group-aggregated flag without posthog.group().
Did a static cohort actually populate (with country breakdown)
SELECT cohort_id, count() AS persons,
countIf(person.properties.$geoip_country_code = 'DE') AS in_DE
FROM static_cohort_people
WHERE team_id = <TEAM_ID> AND cohort_id IN (<IDS>)
GROUP BY cohort_idReal reach by survey, before/after a date (find the affected surveys)
SELECT properties.$survey_id AS survey_id,
countIf(timestamp < toDateTime('<CUTOFF>')) AS shown_before,
countIf(timestamp >= toDateTime('<CUTOFF>')) AS shown_after,
uniqIf(distinct_id, timestamp >= toDateTime('<CUTOFF>')) AS users_after
FROM events
WHERE event = 'survey shown' AND timestamp >= toDateTime('<WINDOW_START>')
GROUP BY survey_id HAVING shown_before > 0 OR shown_after > 0
ORDER BY shown_before DESCWas partial-response collection ever on?
SELECT
coalesce(toString(properties.$survey_completed), '(not set)') AS completed,
count() AS events,
uniq(properties.$survey_submission_id) AS submissions,
min(timestamp) AS first_seen, max(timestamp) AS last_seen
FROM events
WHERE event = 'survey sent' AND properties.$survey_id = '<SURVEY_ID>'
AND timestamp >= now() - INTERVAL 180 DAY
GROUP BY completed ORDER BY events DESCAny completed = false rows ⇒ enable_partial_responses was true in that window, so raw
survey sent rows include intermediate saves the UI collapses. Compare events vs submissions
to see how much of the gap is partial saves.
(not set) does not mean an old SDK on its own. Only the web SDK sets $survey_completed (and
$survey_submission_id) at all — React Native's sendSurveyEvent sets neither, the other mobile
SDKs don't support partial responses, and an api-type survey is captured by the customer's own
code with whatever properties they choose. So (not set) means web SDK below 1.240.0 or a
non-web SDK or a hand-rolled survey sent. Check the lib property before reading it as a
version signal.
Full event funnel including abandonment
SELECT event, count() AS events,
uniq(distinct_id) AS people,
uniq(properties.$survey_submission_id) AS submissions
FROM events
WHERE properties.$survey_id = '<SURVEY_ID>'
AND event IN ('survey shown', 'survey sent', 'survey dismissed', 'survey abandoned')
AND timestamp >= now() - INTERVAL 180 DAY
GROUP BY event ORDER BY events DESCevents above submissions on survey sent is the normal partial-response shape — partial mode
fires one survey sent per question (see above), so the raw event count inflates on its own.
Compare submissions against people instead: submissions well above people ⇒ repeat
responders, one likely cause being schedule: 'always' bypassing the internal targeting flag.
(people is uniq(distinct_id), which one person spread across several IDs inflates, so this
comparison undercounts repeats rather than inventing them.)
Every question and its answer, keyed off the survey JSON (preferred)
Use getSurveyResponse(<index>, '<questionId>') — the HogQL helper the product itself uses
(posthog/hogql/functions/survey.py; callers in products/surveys/backend/responses/). It
coalesces the UUID key with the legacy index key for you, so it can't miss an older response the way
a hand-written properties.$survey_response_<uuid> does, and it returns what the results table
shows the same customer. Pass a third argument true for multiple-choice questions so the array is
unpacked. Both the index and the id must be literal constants.
Take the index and id from questions[] in the survey JSON, in order:
SELECT timestamp,
coalesce(person.properties.$email, person.properties.email) AS email,
getSurveyResponse(0, '<Q1_UUID>') AS q1_rating,
getSurveyResponse(1, '<Q2_UUID>') AS q2_single_choice,
getSurveyResponse(2, '<Q3_UUID>', true) AS q3_multiple_choice,
properties.$survey_completed AS completed,
properties.$survey_submission_id AS submission_id
FROM events
WHERE event = 'survey sent' AND properties.$survey_id = '<SURVEY_ID>'
AND timestamp >= now() - INTERVAL 180 DAY
ORDER BY timestamp DESC LIMIT 60Reading the raw property directly is still fine for a one-off sanity check, but backtick it because
of the dashes — properties. `$survey_response_<uuid>` — and remember it sees only the
UUID-keyed format.
Same thing without the survey JSON: unroll $survey_questions
$survey_questions is an array of {id, question, response} over the full question list, so the
arrays line up positionally with the survey's questions. Note the ifNull — without it ClickHouse
rejects the query with Nested type Array(String) cannot be inside Nullable type, because
properties.$survey_questions is Nullable(String).
SELECT timestamp,
properties.$survey_submission_id AS submission_id,
properties.$survey_completed AS completed,
arrayMap(x -> JSONExtractString(x, 'question'),
JSONExtractArrayRaw(ifNull(toString(properties.$survey_questions), '[]'))) AS questions,
arrayMap(x -> JSONExtractRaw(x, 'response'),
JSONExtractArrayRaw(ifNull(toString(properties.$survey_questions), '[]'))) AS responses
FROM events
WHERE event = 'survey sent' AND properties.$survey_id = '<SURVEY_ID>'
AND timestamp >= now() - INTERVAL 180 DAY
ORDER BY timestamp DESC LIMIT 60Answer rate per question (is "incomplete" just branching?)
Cross-tab the branching question's answer against whether the downstream questions got values. If the split lines up exactly with the branching rules, the data is fine and the complaint is explained.
SELECT getSurveyResponse(<BRANCH_IDX>, '<BRANCHING_Q_UUID>') AS branch_answer,
count() AS submissions,
countIf(coalesce(getSurveyResponse(<DOWN_IDX>, '<DOWNSTREAM_Q_UUID>'), '') != '') AS answered_downstream
FROM events
WHERE event = 'survey sent' AND properties.$survey_id = '<SURVEY_ID>'
AND coalesce(toString(properties.$survey_completed), 'true') != 'false'
AND timestamp >= now() - INTERVAL 180 DAY
GROUP BY branch_answer ORDER BY branch_answergetSurveyResponse matters more here than in a browsing query: a legacy-keyed answer read through
the raw UUID property counts as unanswered inside countIf, which inflates the very "incomplete"
ratio you're trying to disprove.
Shown → sent latency (accidental / stray-click submissions)
survey shown fires when the popup becomes visible, after surveyPopupDelaySeconds, so this is
real time-on-popup. A cluster of sub-10-second completions on a multi-question survey points at
skipSubmitButton plus a centered popup rather than considered feedback.
Pair each response with the survey shown immediately before it, not with the first one in the
session. Grouping by $session_id alone silently drops repeat submissions: a schedule: 'always'
survey can be shown and answered twice in one session, and minIf then pairs the first display with
the first response and discards the rest. survey shown carries no $survey_submission_id, so
there's no shared key to join on — carry the last-seen display forward with a window function
instead. The filter on that result needs the subquery, since a window function can't be referenced
from the same WHERE.
SELECT session, submission_id, shown_at, sent_at,
dateDiff('second', shown_at, sent_at) AS seconds_to_submit
FROM (
SELECT $session_id AS session, event, timestamp AS sent_at,
coalesce(nullIf(properties.$survey_submission_id, ''), toString(uuid)) AS submission_id,
max(if(event = 'survey shown', timestamp, NULL)) OVER (
PARTITION BY $session_id ORDER BY timestamp
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS shown_at
FROM events
WHERE properties.$survey_id = '<SURVEY_ID>'
AND event IN ('survey shown', 'survey sent')
AND timestamp >= now() - INTERVAL 180 DAY
)
WHERE event = 'survey sent' AND shown_at IS NOT NULL
ORDER BY seconds_to_submit ASC
LIMIT 1 BY submission_id
LIMIT 60In partial-response mode this measures time to first answer, because LIMIT 1 BY submission_id
after an ascending sort keeps each submission's earliest row. That's the right signal for a stray
click — how fast something got clicked — but it is not time to completion, so don't relabel it.
Events with no submission id (non-web SDKs, pre-1.240.0 web) fall back to the event UUID and stay as
separate rows.
Replay link per response (watch a disputed submission)
SELECT timestamp, distinct_id,
coalesce(person.properties.$email, person.properties.email) AS email,
properties.sessionRecordingUrl AS replay_url,
properties.$survey_submission_id AS submission_id, $session_id
FROM events
WHERE event = 'survey sent' AND properties.$survey_id = '<SURVEY_ID>'
AND timestamp >= now() - INTERVAL 180 DAY
ORDER BY timestamp DESC
LIMIT 1 BY coalesce(nullIf(properties.$survey_submission_id, ''), toString(uuid))
LIMIT 60Beats arguing about whether a submission was intentional — when a recording exists. Every
survey sent / dismissed / abandoned event carries sessionRecordingUrl, but that only points
at a session id: replay is off by default, and the default cloud retention of 30 days is shorter
than this 180-day window, so the recording may never have been captured or may have aged out. Open
the link before citing it. The LIMIT 1 BY collapses partial mode's
per-question survey sent rows to the latest one per submission — matching how the results table
dedupes — so you get one link per response, not one per intermediate save. The coalesce falls
back to the event UUID for pre-1.240.0 events, which have no $survey_submission_id, so those are
kept as distinct rows rather than folded together.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/omnibus/debugging-surveys · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download debugging-surveys.zip