Skip to content

feat(surveys): add displaySurvey to show a survey on demand#643

Draft
bs1180 wants to merge 1 commit into
mainfrom
posthog-code/display-survey-manual-api
Draft

feat(surveys): add displaySurvey to show a survey on demand#643
bs1180 wants to merge 1 commit into
mainfrom
posthog-code/display-survey-manual-api

Conversation

@bs1180

@bs1180 bs1180 commented Jul 20, 2026

Copy link
Copy Markdown

💡 Motivation and Context

The web SDK exposes posthog.displaySurvey() to display a survey on demand (see implementing custom surveys), but there is no mobile equivalent, so surveys can only be shown when the automatic display conditions are met, and API-type surveys can't be shown at all. This is tracked for the other mobile SDKs in PostHog/posthog-ios#648, PostHog/posthog-flutter#225, and PostHog/posthog-js#3817 (there is no posthog-android issue, but the gap is the same). This adds the Android counterpart:

PostHog.displaySurvey("survey-id")
  • New displaySurvey(surveyId) on PostHogInterface/PostHog, delegated through PostHogSurveysHandler to PostHogSurveysIntegration.
  • Looks the survey up in the cached surveys and routes it through the existing showSurvey() path (translations, active-survey tracking, survey shown/sent/dismissed events), so all downstream behavior is unchanged.
  • Bypasses display conditions (targeting flags, event triggers, seen/wait-period checks), matching the web SDK's default behavior, and thereby enables API-type surveys, which showNextSurvey() intentionally never auto-displays.
  • No-op (with a log) when surveys are disabled, the survey isn't found, or another survey is already being displayed.

Sibling PRs: PostHog/posthog-ios#730 and a posthog-flutter PR that builds on this API (Flutter delegates survey logic to the native SDKs).

💚 How did you test it?

Added PostHogSurveysDisplaySurveyTest covering: displaying an API-type survey by ID, bypassing an unfired event trigger, unknown ID as a no-op, ignoring the call while another survey is active, and the surveys-disabled config case. Ran :posthog:apiCheck :posthog-android:apiCheck :posthog-android:testDebugUnitTest --tests "com.posthog.android.surveys.PostHogSurveysDisplaySurveyTest" locally — all green.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with PostHog Code (Claude Code) from a request to bring the JS SDK's displaySurvey to the Flutter SDK; since Flutter's survey pipeline lives in the native SDKs, this Android API is a prerequisite. Design choices: expose the method on PostHogInterface (so the Flutter plugin can call the static PostHog.displaySurvey), reuse the internal showSurvey() path rather than a parallel code path, and keep v1 to a single surveyId parameter (no ignoreConditions/prefill options yet). API dumps updated for both modules; PostHogFake extended for the new interface method.


Created with PostHog Code

Adds PostHog.displaySurvey(surveyId), the mobile counterpart of the web SDK's posthog.displaySurvey(). It looks the survey up in the cached surveys and routes it through the existing showSurvey() path (translations, active-survey tracking, shown/sent/dismissed events), bypassing display conditions such as targeting flags, event triggers, and the seen/wait-period checks. This also enables API-type surveys, which are never auto-displayed.

Generated-By: PostHog Code
Task-Id: 96def33a-2460-42b7-9690-0a75ae8383fe
@bs1180
bs1180 requested review from ioannisj and turnipdabeets July 20, 2026 13:24

bs1180 commented Jul 20, 2026

Copy link
Copy Markdown
Author

Hi @ioannisj @turnipdabeets 👋 — tagging you both since you maintain the surveys code here. This is the Android half of the manual displaySurvey API (iOS: PostHog/posthog-ios#730, tracked in PostHog/posthog-ios#648 and PostHog/posthog-flutter#225 — there's no posthog-android issue for it, happy to file one if useful). It's a prerequisite for Flutter support, since the Flutter plugin drives surveys through the native SDKs. No rush, and happy to adjust the API shape if you'd prefer something different. Thanks for taking a look!

@github-actions

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-07-20 13:31:22 UTC
Duration: 118473ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 410ms
Format Validation.Event Has Uuid 30ms
Format Validation.Event Has Lib Properties 32ms
Format Validation.Distinct Id Is String 31ms
Format Validation.Token Is Present 27ms
Format Validation.Custom Properties Preserved 28ms
Format Validation.Event Has Timestamp 27ms
Retry Behavior.Retries On 503 7031ms
Retry Behavior.Does Not Retry On 400 4025ms
Retry Behavior.Does Not Retry On 401 4030ms
Retry Behavior.Respects Retry After Header 7023ms
Retry Behavior.Implements Backoff 17022ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7021ms
Retry Behavior.Retries On 504 7018ms
Retry Behavior.Max Retries Respected 17038ms
Deduplication.Generates Unique Uuids 35ms
Deduplication.Preserves Uuid On Retry 7017ms
Deduplication.Preserves Uuid And Timestamp On Retry 12032ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7018ms
Deduplication.No Duplicate Events In Batch 38ms
Deduplication.Different Events Have Different Uuids 27ms
Compression.Sends Gzip When Enabled 20ms
Batch Format.Uses Proper Batch Structure 18ms
Batch Format.Flush With No Events Sends Nothing 13ms
Batch Format.Multiple Events Batched Together 36ms
Error Handling.Does Not Retry On 403 4023ms
Error Handling.Does Not Retry On 413 4021ms
Error Handling.Retries On 408 5028ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 35ms
Request Payload.Flags Request Uses V2 Query Param 22ms
Request Payload.Flags Request Hits Flags Path Not Decide 22ms
Request Payload.Flags Request Omits Authorization Header 23ms
Request Payload.Token In Flags Body Matches Init 26ms
Request Payload.Groups Round Trip 26ms
Request Payload.Groups Default To Empty Object 22ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 32ms
Request Payload.Disable Geoip Omitted Defaults To False 21ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 23ms
Request Lifecycle.No Flags Request On Init Alone 10ms
Request Lifecycle.No Flags Request On Normal Capture 26ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 37ms
Request Lifecycle.Mock Response Value Is Returned To Caller 22ms
Retry Behavior.Retries Flags On 502 325ms
Retry Behavior.Retries Flags On 504 323ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 22ms

@posthog

posthog Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 1 must fix, 2 should fix, 0 consider.

Published 3 findings (view the review).

@posthog

posthog Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 3 issues

Files (8)
  • .changeset/display-survey-manual-api.md
  • posthog-android/api/posthog-android.api
  • posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt
  • posthog/api/posthog.api
  • posthog/src/main/java/com/posthog/PostHog.kt
  • posthog/src/main/java/com/posthog/PostHogInterface.kt
  • posthog/src/main/java/com/posthog/internal/surveys/PostHogSurveysHandler.kt
  • posthog/src/testFixtures/java/com/posthog/PostHogFake.kt

Comment on lines +151 to +167
public override fun displaySurvey(surveyId: String) {
if (!config.surveys) {
config.logger.log("[Surveys] Cannot display survey $surveyId - surveys are disabled in the config")
return
}
val isIntegrationStarted = synchronized(lifecycleLock) { isStarted }
if (!isIntegrationStarted) {
config.logger.log("[Surveys] Cannot display survey $surveyId - surveys integration is not started")
return
}
val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } }
if (survey == null) {
config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found")
return
}
showSurvey(survey)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displaySurvey() doesn't verify the survey is currently running, despite the documented contract requiring it

should_fix bug

Why we think it's a valid issue
  • Checked: the public contract (PostHogInterface.kt:327-340), the implementation (PostHogSurveysIntegration.kt:151-167), the auto-display filter (getActiveMatchingSurveys, lines 255-306), how cachedSurveys is populated (line 128 ← PostHog.kt:299PostHogRemoteConfig.getSurveys() at PostHogRemoteConfig.kt:1243-1247), the Survey model (Survey.kt:26-29), and the new test fixtures.
  • Found: PostHogInterface.kt:330 explicitly documents "The survey must be running ...; targeting flags, event triggers, and the seen/wait-period checks are bypassed" — enumerating only three bypassed conditions and requiring running. But displaySurvey (line 161) resolves the survey solely by cachedSurveys.firstOrNull { it.id == surveyId } and calls showSurvey with no startDate/endDate guard.
  • Found: getActiveMatchingSurveys applies if (survey.startDate == null || survey.endDate != null) return@filter false as its very first check (line 260, commented "must have start date and no end date") against the same cachedSurveys list. That guard existing on the auto-display path is direct in-repo evidence that the raw cache is expected to contain non-running (not-yet-started or ended) surveys — so the premise is not speculative.
  • Found: cachedSurveys (line 128) is assigned the unfiltered list from onSurveysLoaded, which receives remoteConfig.getSurveys() verbatim; no date filtering happens anywhere on that path.
  • Impact: displaySurvey("ended-or-draft-id") for a survey still present in the cache will render it and fire survey shown/sent/dismissed events, collecting responses for a stopped/unlaunched campaign — contradicting the method's own documented "must be running" contract and polluting that survey's response data past its intended end date. This is a real correctness/contract gap on newly added code with a trivial one-line fix (mirror the getActiveMatchingSurveys running check), and the new test suite (createSurvey always uses startDate = Date(), endDate = null) never exercises it. It clears the keep bar: concrete trigger and concrete consequence, not overengineering, style, or defensive paranoia.
Issue description

The public contract in PostHogInterface.kt (lines 327-341) explicitly states: "The survey must be running and returned by the project's remote config; targeting flags, event triggers, and the seen/wait-period checks are bypassed." The PR description and changeset also scope the bypass to only "targeting flags, event triggers, and seen/wait-period checks" — the active-window (running) requirement is not listed among the bypassed conditions.

However, the actual implementation of displaySurvey only checks that the survey ID exists in cachedSurveys (cachedSurveys.firstOrNull { it.id == surveyId }, line 161) — it never checks survey.startDate/survey.endDate. Compare this with getActiveMatchingSurveys() (lines 255-306), which explicitly filters if (survey.startDate == null || survey.endDate != null) return@filter false as its very first "is this survey running" check before any of the other display-condition checks that displaySurvey intentionally bypasses. cachedSurveys itself is the raw, unfiltered list pushed straight from PostHogRemoteConfig.getSurveys() (confirmed in PostHog.kt line 299 and PostHogRemoteConfig.kt line 1243-1247), so it can legitimately contain surveys that haven't started yet (no startDate) or have already ended (endDate set) — the existence of the dedicated filter in getActiveMatchingSurveys is proof that such surveys are present in the cache.

As written, calling PostHog.displaySurvey("ended-survey-id") for a survey whose endDate has already passed (e.g. a closed campaign) will still render it and fire survey shown/survey sent/survey dismissed events for it, contradicting the documented "must be running" requirement and potentially polluting the closed survey's response data after its intended end date. The new PostHogSurveysDisplaySurveyTest doesn't cover this: every survey fixture in that test file is constructed with startDate = Date() and endDate = null (always running), so this gap has no test coverage.

Suggested fix

Add the same "is running" check that getActiveMatchingSurveys() already uses before calling showSurvey(survey), e.g.:

val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } }
if (survey == null || survey.startDate == null || survey.endDate != null) {
    config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found or not running")
    return
}

Alternatively, if showing not-yet-started/ended surveys on demand is actually intended (e.g. to preview drafts), update the docstring in PostHogInterface.kt (and the changeset) to drop the "must be running" claim so the documented contract matches the real behavior, and add a test exercising an ended/draft survey ID to lock in the intended behavior either way.

Prompt to fix with AI (copy-paste)
## Context
@posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt#L151-167

<issue_description>
The public contract in PostHogInterface.kt (lines 327-341) explicitly states: "The survey must be running and returned by the project's remote config; targeting flags, event triggers, and the seen/wait-period checks are bypassed." The PR description and changeset also scope the bypass to only "targeting flags, event triggers, and seen/wait-period checks" — the active-window (running) requirement is not listed among the bypassed conditions.

However, the actual implementation of `displaySurvey` only checks that the survey ID exists in `cachedSurveys` (`cachedSurveys.firstOrNull { it.id == surveyId }`, line 161) — it never checks `survey.startDate`/`survey.endDate`. Compare this with `getActiveMatchingSurveys()` (lines 255-306), which explicitly filters `if (survey.startDate == null || survey.endDate != null) return@filter false` as its very first "is this survey running" check before any of the other display-condition checks that `displaySurvey` intentionally bypasses. `cachedSurveys` itself is the raw, unfiltered list pushed straight from `PostHogRemoteConfig.getSurveys()` (confirmed in `PostHog.kt` line 299 and `PostHogRemoteConfig.kt` line 1243-1247), so it can legitimately contain surveys that haven't started yet (no `startDate`) or have already ended (`endDate` set) — the existence of the dedicated filter in `getActiveMatchingSurveys` is proof that such surveys are present in the cache. 

As written, calling `PostHog.displaySurvey("ended-survey-id")` for a survey whose `endDate` has already passed (e.g. a closed campaign) will still render it and fire `survey shown`/`survey sent`/`survey dismissed` events for it, contradicting the documented "must be running" requirement and potentially polluting the closed survey's response data after its intended end date. The new `PostHogSurveysDisplaySurveyTest` doesn't cover this: every survey fixture in that test file is constructed with `startDate = Date()` and `endDate = null` (always running), so this gap has no test coverage.
</issue_description>

<issue_validation>
- **Checked:** the public contract (`PostHogInterface.kt:327-340`), the implementation (`PostHogSurveysIntegration.kt:151-167`), the auto-display filter (`getActiveMatchingSurveys`, lines 255-306), how `cachedSurveys` is populated (line 128 ← `PostHog.kt:299` ← `PostHogRemoteConfig.getSurveys()` at `PostHogRemoteConfig.kt:1243-1247`), the `Survey` model (`Survey.kt:26-29`), and the new test fixtures.
- **Found:** `PostHogInterface.kt:330` explicitly documents "The survey must be running ...; targeting flags, event triggers, and the seen/wait-period checks are bypassed" — enumerating only three bypassed conditions and requiring running. But `displaySurvey` (line 161) resolves the survey solely by `cachedSurveys.firstOrNull { it.id == surveyId }` and calls `showSurvey` with no `startDate`/`endDate` guard.
- **Found:** `getActiveMatchingSurveys` applies `if (survey.startDate == null || survey.endDate != null) return@filter false` as its very first check (line 260, commented "must have start date and no end date") against the *same* `cachedSurveys` list. That guard existing on the auto-display path is direct in-repo evidence that the raw cache is expected to contain non-running (not-yet-started or ended) surveys — so the premise is not speculative.
- **Found:** `cachedSurveys` (line 128) is assigned the unfiltered list from `onSurveysLoaded`, which receives `remoteConfig.getSurveys()` verbatim; no date filtering happens anywhere on that path.
- **Impact:** `displaySurvey("ended-or-draft-id")` for a survey still present in the cache will render it and fire `survey shown`/`sent`/`dismissed` events, collecting responses for a stopped/unlaunched campaign — contradicting the method's own documented "must be running" contract and polluting that survey's response data past its intended end date. This is a real correctness/contract gap on newly added code with a trivial one-line fix (mirror the `getActiveMatchingSurveys` running check), and the new test suite (`createSurvey` always uses `startDate = Date(), endDate = null`) never exercises it. It clears the keep bar: concrete trigger and concrete consequence, not overengineering, style, or defensive paranoia.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add the same "is running" check that `getActiveMatchingSurveys()` already uses before calling `showSurvey(survey)`, e.g.:

```kotlin
val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } }
if (survey == null || survey.startDate == null || survey.endDate != null) {
    config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found or not running")
    return
}

Alternatively, if showing not-yet-started/ended surveys on demand is actually intended (e.g. to preview drafts), update the docstring in PostHogInterface.kt (and the changeset) to drop the "must be running" claim so the documented contract matches the real behavior, and add a test exercising an ended/draft survey ID to lock in the intended behavior either way.
</potential_solution>


</details>

Comment on lines +2019 to +2027
override fun displaySurvey(surveyId: String) {
if (!isEnabled()) {
return
}

surveysHandler?.displaySurvey(surveyId) ?: run {
config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New displaySurvey() entry point lacks the exception guard every other survey-triggering call site uses

must_fix bug

Why we think it's a valid issue
  • Checked: the new displaySurvey entry point (PostHog.kt:2019-2027), the three sibling survey-triggering call sites it is compared against, and whether the guard is instead applied inside the integration (PostHogSurveysIntegration.displaySurvey/showSurvey).
  • Found (siblings all guarded): PostHog.kt:193-198 wraps surveysHandler?.onSurveysLoaded(surveys) in try/catch (Throwable); PostHog.kt:298-303 wraps the install-time it.onSurveysLoaded(surveys); and PostHog.kt:816 surveysHandler?.onEvent(...) sits inside capture()'s top-level try { ... } catch (e: Throwable) (lines 713-822). All three reach showSurvey() (via showNextSurvey()), so the SDK deliberately guards this exact chain at the PostHog.kt boundary.
  • Found (new path unguarded, synchronous): PostHog.kt:2024 calls surveysHandler?.displaySurvey(surveyId) with no guard. The integration's displaySurvey (PostHogSurveysIntegration.kt:151-166) calls showSurvey(survey) directly and synchronously, and showSurvey runs resolveSurveyTranslations, PostHogDisplaySurvey.toDisplaySurvey, then getSurveysDelegate().renderSurvey(...) at line 437 — none wrapped in try/catch. So a throw from the customer-supplied PostHogSurveysDelegate or from translation/model conversion propagates synchronously back out through the public PostHog.displaySurvey().
  • Impact: displaySurvey is a public, synchronous API invoked from app code (e.g. a button tap, typically the main thread) and is the only path that renders API-type/condition-bypassing surveys — the shapes least exercised elsewhere — and the delegate is an explicit customer extension point. An uncaught exception there crashes the host app, a failure mode an analytics SDK is expected to absorb and that every other survey call site in this file already absorbs. Concrete trigger + concrete consequence, directly on the new code, matching an established unanimous convention — clears the keep bar; not defensive paranoia (the delegate is real external code that can throw) or style.
  • Priority: Keeping must_fix — the defect is a host-app crash escaping a public SDK entry point, and 3/3 sibling call sites treat this chain as requiring the guard, so the omission is a clear crash-safety regression rather than a minor inconsistency.
Issue description

Every other place in this file that can trigger PostHogSurveysIntegration.showSurvey() (and thus delegate renderSurvey(), translation resolution, and PostHogDisplaySurvey.toDisplaySurvey conversion) wraps the call in try { } catch (e: Throwable): the onRemoteConfigLoaded callback's surveysHandler?.onSurveysLoaded(surveys) (lines 193-198), the install-time cached-surveys push it.onSurveysLoaded(surveys) (lines 298-303), and the event-driven surveysHandler?.onEvent(event, mergedProperties) inside capture()'s own top-level try/catch (lines 704-822). The new override fun displaySurvey(surveyId: String) calls surveysHandler?.displaySurvey(surveyId) with no such guard. displaySurvey is a public, synchronous, on-demand API explicitly meant to be invoked directly from app code (e.g. a button tap) and, per the PR description, is also the only path that renders API-type surveys and any survey configuration that bypasses the normal display-condition filtering — i.e. it is the first path likely to hit malformed/unexercised survey shapes or a buggy custom PostHogSurveysDelegate (delegates are an explicit customer extension point). If showSurvey() or the delegate throws, that exception now propagates uncaught straight out of the SDK's public API into the host app, unlike every other survey code path in this file, risking a host-app crash from what is supposed to be a resilient analytics SDK call.

Suggested fix

Wrap the delegate call the same way the other call sites in this file do:

override fun displaySurvey(surveyId: String) {
    if (!isEnabled()) {
        return
    }

    try {
        surveysHandler?.displaySurvey(surveyId) ?: run {
            config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.")
        }
    } catch (e: Throwable) {
        config?.logger?.log("Failed to display survey $surveyId: $e.")
    }
}
Prompt to fix with AI (copy-paste)
## Context
@posthog/src/main/java/com/posthog/PostHog.kt#L2019-2027

<issue_description>
Every other place in this file that can trigger `PostHogSurveysIntegration.showSurvey()` (and thus delegate `renderSurvey()`, translation resolution, and `PostHogDisplaySurvey.toDisplaySurvey` conversion) wraps the call in `try { } catch (e: Throwable)`: the `onRemoteConfigLoaded` callback's `surveysHandler?.onSurveysLoaded(surveys)` (lines 193-198), the install-time cached-surveys push `it.onSurveysLoaded(surveys)` (lines 298-303), and the event-driven `surveysHandler?.onEvent(event, mergedProperties)` inside `capture()`'s own top-level try/catch (lines 704-822). The new `override fun displaySurvey(surveyId: String)` calls `surveysHandler?.displaySurvey(surveyId)` with no such guard. `displaySurvey` is a public, synchronous, on-demand API explicitly meant to be invoked directly from app code (e.g. a button tap) and, per the PR description, is also the only path that renders API-type surveys and any survey configuration that bypasses the normal display-condition filtering — i.e. it is the first path likely to hit malformed/unexercised survey shapes or a buggy custom `PostHogSurveysDelegate` (delegates are an explicit customer extension point). If `showSurvey()` or the delegate throws, that exception now propagates uncaught straight out of the SDK's public API into the host app, unlike every other survey code path in this file, risking a host-app crash from what is supposed to be a resilient analytics SDK call.
</issue_description>

<issue_validation>
- **Checked:** the new `displaySurvey` entry point (`PostHog.kt:2019-2027`), the three sibling survey-triggering call sites it is compared against, and whether the guard is instead applied inside the integration (`PostHogSurveysIntegration.displaySurvey`/`showSurvey`).
- **Found (siblings all guarded):** `PostHog.kt:193-198` wraps `surveysHandler?.onSurveysLoaded(surveys)` in `try/catch (Throwable)`; `PostHog.kt:298-303` wraps the install-time `it.onSurveysLoaded(surveys)`; and `PostHog.kt:816` `surveysHandler?.onEvent(...)` sits inside `capture()`'s top-level `try { ... } catch (e: Throwable)` (lines 713-822). All three reach `showSurvey()` (via `showNextSurvey()`), so the SDK deliberately guards this exact chain at the `PostHog.kt` boundary.
- **Found (new path unguarded, synchronous):** `PostHog.kt:2024` calls `surveysHandler?.displaySurvey(surveyId)` with no guard. The integration's `displaySurvey` (`PostHogSurveysIntegration.kt:151-166`) calls `showSurvey(survey)` directly and synchronously, and `showSurvey` runs `resolveSurveyTranslations`, `PostHogDisplaySurvey.toDisplaySurvey`, then `getSurveysDelegate().renderSurvey(...)` at line 437 — none wrapped in try/catch. So a throw from the customer-supplied `PostHogSurveysDelegate` or from translation/model conversion propagates synchronously back out through the public `PostHog.displaySurvey()`.
- **Impact:** `displaySurvey` is a public, synchronous API invoked from app code (e.g. a button tap, typically the main thread) and is the only path that renders API-type/condition-bypassing surveys — the shapes least exercised elsewhere — and the delegate is an explicit customer extension point. An uncaught exception there crashes the host app, a failure mode an analytics SDK is expected to absorb and that every other survey call site in this file already absorbs. Concrete trigger + concrete consequence, directly on the new code, matching an established unanimous convention — clears the keep bar; not defensive paranoia (the delegate is real external code that can throw) or style.
- **Priority:** Keeping `must_fix` — the defect is a host-app crash escaping a public SDK entry point, and 3/3 sibling call sites treat this chain as requiring the guard, so the omission is a clear crash-safety regression rather than a minor inconsistency.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Wrap the delegate call the same way the other call sites in this file do:
```kotlin
override fun displaySurvey(surveyId: String) {
    if (!isEnabled()) {
        return
    }

    try {
        surveysHandler?.displaySurvey(surveyId) ?: run {
            config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.")
        }
    } catch (e: Throwable) {
        config?.logger?.log("Failed to display survey $surveyId: $e.")
    }
}

</potential_solution>


</details>

Comment on lines +151 to +167
public override fun displaySurvey(surveyId: String) {
if (!config.surveys) {
config.logger.log("[Surveys] Cannot display survey $surveyId - surveys are disabled in the config")
return
}
val isIntegrationStarted = synchronized(lifecycleLock) { isStarted }
if (!isIntegrationStarted) {
config.logger.log("[Surveys] Cannot display survey $surveyId - surveys integration is not started")
return
}
val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } }
if (survey == null) {
config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found")
return
}
showSurvey(survey)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displaySurvey()'s documented 'ignored if another survey is active' guarantee is not enforced atomically

should_fix bug

Why we think it's a valid issue
  • Checked: the guard/claim ordering across showSurvey (line 318-323, 437), canShowNextSurvey (line 683-685), the activeSurvey write in the onSurveyShown callback (lines 341-361), the default delegate (PostHogSurveysDefaultDelegate.renderSurvey), and the surveyPopupDelaySeconds field in the public appearance model.
  • Found (non-atomic claim): showSurvey only reads activeSurvey via canShowNextSurvey() (line 320 → 683-685) and then calls getSurveysDelegate().renderSurvey(...) (line 437). The corresponding write activeSurvey = originalSurvey happens at line 347, but only inside the onSurveyShown lambda that the delegate invokes — never during showSurvey itself. So between renderSurvey being called and the delegate later firing onSurveyShown, activeSurvey stays null and any second showSurvey passes the check. This is a genuine TOCTOU, confirmed by the code, not the snippet.
  • Found (async is the norm, not the exception): onSurveyShown is a "survey became visible" callback fired by delegate UI code after renderSurvey returns; the public surveyPopupDelaySeconds field (PostHogDisplaySurveyAppearance.kt:40, SurveyAppearance.kt:27) explicitly encodes a delay between trigger and display, so the window is architecturally expected. The bundled PostHogSurveysDefaultDelegate renders nothing and never calls onSurveyShown, and its own log says "Implement your own PostHogSurveysDelegate to render surveys" — i.e. every functional deployment uses a custom delegate subject to this window.
  • Found (spurious events): in onSurveyShown, sendSurveyShownEvent(originalSurvey, ...) (line 354) runs unconditionally, outside the if (activeSurvey == null) block (lines 346-351), so two overlapping renders each emit a survey shown (and later dismissed) event even though only one wins the active slot.
  • Impact: displaySurvey is a public API built for uncoordinated app-code invocation (button tap, double-tap, or racing an automatic onEvent/onSurveysLoaded trigger). Two calls landing in the render window both reach renderSurvey, breaking the docstring's "If another survey is already being displayed, this call is ignored" guarantee and polluting analytics with events for surveys the user never saw. Concrete trigger + concrete consequence on newly added code; the suggested fix (claim activeSurvey in the same activeSurvey Lock section as the check) is the standard correct pattern. Clears the keep bar — a race corrupting a documented shared-state invariant, not speculation or paranoia.
  • Priority: keeping should_fix — real invariant break with data-quality/UX impact, but it needs an overlapping-trigger window and yields duplicate events rather than a crash, data loss, or security hole, so must_fix would overstate it.
Issue description

The docstring for displaySurvey (lines 140-150) promises: "If another survey is already being displayed, this call is ignored." That guarantee relies entirely on showSurvey()'s canShowNextSurvey() check (synchronized(activeSurveyLock) { activeSurvey == null }), but activeSurvey is only ever set later, inside the onSurveyShown callback that the delegate invokes asynchronously — potentially after a configurable popup delay (e.g. surveyPopupDelaySeconds in the bundled Compose delegate), or on whatever schedule a custom PostHogSurveysDelegate implementation chooses. Between showSurvey(survey) being called and the delegate eventually invoking onSurveyShown, activeSurvey remains null, so a concurrent second call — another displaySurvey(otherId), or an automatic trigger via onEvent/onSurveysLoaded firing in that same window — also passes canShowNextSurvey() and reaches getSurveysDelegate().renderSurvey(...). Before this PR, only automatic triggers could race each other; displaySurvey is designed to be called from arbitrary, uncoordinated app code (e.g. a button's onClick), which meaningfully raises the odds of hitting this window in production and produces two concurrent renderSurvey() calls, spurious survey shown/survey dismissed events for a survey the user never actually saw, and a violated 'single active survey' invariant.

Suggested fix

Mark the survey as claimed atomically with the decision to render, inside the same activeSurveyLock critical section that performs the canShowNextSurvey() check, instead of deferring that write to the async onSurveyShown callback, e.g.:

internal fun showSurvey(survey: Survey) {
    synchronized(activeSurveyLock) {
        if (activeSurvey != null) {
            config.logger.log("Cannot show survey - another survey is already active")
            return
        }
        activeSurvey = survey
        activeSurveyCompleted = false
        currentSurveyResponses.clear()
    }
    // ... existing setup, with onSurveyShown now just sending the 'survey shown' event
    // instead of also setting activeSurvey
}
Prompt to fix with AI (copy-paste)
## Context
@posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt#L151-167

<issue_description>
The docstring for `displaySurvey` (lines 140-150) promises: "If another survey is already being displayed, this call is ignored." That guarantee relies entirely on `showSurvey()`'s `canShowNextSurvey()` check (`synchronized(activeSurveyLock) { activeSurvey == null }`), but `activeSurvey` is only ever *set* later, inside the `onSurveyShown` callback that the delegate invokes asynchronously — potentially after a configurable popup delay (e.g. `surveyPopupDelaySeconds` in the bundled Compose delegate), or on whatever schedule a custom `PostHogSurveysDelegate` implementation chooses. Between `showSurvey(survey)` being called and the delegate eventually invoking `onSurveyShown`, `activeSurvey` remains `null`, so a concurrent second call — another `displaySurvey(otherId)`, or an automatic trigger via `onEvent`/`onSurveysLoaded` firing in that same window — also passes `canShowNextSurvey()` and reaches `getSurveysDelegate().renderSurvey(...)`. Before this PR, only automatic triggers could race each other; `displaySurvey` is designed to be called from arbitrary, uncoordinated app code (e.g. a button's onClick), which meaningfully raises the odds of hitting this window in production and produces two concurrent `renderSurvey()` calls, spurious `survey shown`/`survey dismissed` events for a survey the user never actually saw, and a violated 'single active survey' invariant.
</issue_description>

<issue_validation>
- **Checked:** the guard/claim ordering across `showSurvey` (line 318-323, 437), `canShowNextSurvey` (line 683-685), the `activeSurvey` write in the `onSurveyShown` callback (lines 341-361), the default delegate (`PostHogSurveysDefaultDelegate.renderSurvey`), and the `surveyPopupDelaySeconds` field in the public appearance model.
- **Found (non-atomic claim):** `showSurvey` only *reads* `activeSurvey` via `canShowNextSurvey()` (line 320 → 683-685) and then calls `getSurveysDelegate().renderSurvey(...)` (line 437). The corresponding *write* `activeSurvey = originalSurvey` happens at line 347, but only inside the `onSurveyShown` lambda that the delegate invokes — never during `showSurvey` itself. So between `renderSurvey` being called and the delegate later firing `onSurveyShown`, `activeSurvey` stays `null` and any second `showSurvey` passes the check. This is a genuine TOCTOU, confirmed by the code, not the snippet.
- **Found (async is the norm, not the exception):** `onSurveyShown` is a "survey became visible" callback fired by delegate UI code after `renderSurvey` returns; the public `surveyPopupDelaySeconds` field (`PostHogDisplaySurveyAppearance.kt:40`, `SurveyAppearance.kt:27`) explicitly encodes a delay between trigger and display, so the window is architecturally expected. The bundled `PostHogSurveysDefaultDelegate` renders nothing and never calls `onSurveyShown`, and its own log says "Implement your own PostHogSurveysDelegate to render surveys" — i.e. every functional deployment uses a custom delegate subject to this window.
- **Found (spurious events):** in `onSurveyShown`, `sendSurveyShownEvent(originalSurvey, ...)` (line 354) runs unconditionally, outside the `if (activeSurvey == null)` block (lines 346-351), so two overlapping renders each emit a `survey shown` (and later `dismissed`) event even though only one wins the active slot.
- **Impact:** `displaySurvey` is a public API built for uncoordinated app-code invocation (button tap, double-tap, or racing an automatic `onEvent`/`onSurveysLoaded` trigger). Two calls landing in the render window both reach `renderSurvey`, breaking the docstring's "If another survey is already being displayed, this call is ignored" guarantee and polluting analytics with events for surveys the user never saw. Concrete trigger + concrete consequence on newly added code; the suggested fix (claim `activeSurvey` in the same `activeSurvey Lock` section as the check) is the standard correct pattern. Clears the keep bar — a race corrupting a documented shared-state invariant, not speculation or paranoia.
- **Priority:** keeping `should_fix` — real invariant break with data-quality/UX impact, but it needs an overlapping-trigger window and yields duplicate events rather than a crash, data loss, or security hole, so must_fix would overstate it.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Mark the survey as claimed atomically with the decision to render, inside the same `activeSurveyLock` critical section that performs the `canShowNextSurvey()` check, instead of deferring that write to the async `onSurveyShown` callback, e.g.:
```kotlin
internal fun showSurvey(survey: Survey) {
    synchronized(activeSurveyLock) {
        if (activeSurvey != null) {
            config.logger.log("Cannot show survey - another survey is already active")
            return
        }
        activeSurvey = survey
        activeSurveyCompleted = false
        currentSurveyResponses.clear()
    }
    // ... existing setup, with onSurveyShown now just sending the 'survey shown' event
    // instead of also setting activeSurvey
}

</potential_solution>


</details>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant