-
Notifications
You must be signed in to change notification settings - Fork 44
feat(surveys): add displaySurvey to show a survey on demand #643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "posthog": minor | ||
| "posthog-android": minor | ||
| --- | ||
|
|
||
| Add `PostHog.displaySurvey(surveyId)` to display a survey on demand, bypassing display conditions (targeting flags, event triggers, and seen/wait-period checks). This is the mobile counterpart of the web SDK's `posthog.displaySurvey()` and also enables API-type surveys, which are never auto-displayed. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,35 @@ public class PostHogSurveysIntegration( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Displays the survey with the given ID on demand. | ||
| * | ||
| * Unlike automatic display, this bypasses display conditions (targeting flags, | ||
| * event triggers, and the seen/wait-period checks), so it also works for | ||
| * API-type surveys, which are never auto-displayed. The survey must be present | ||
| * in the surveys loaded from remote config. If another survey is already being | ||
| * displayed, this call is ignored. | ||
| * | ||
| * @param surveyId The ID of the survey to display | ||
| */ | ||
| 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) | ||
| } | ||
|
Comment on lines
+151
to
+167
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 atomicallyWhy we think it's a valid issue
Issue descriptionThe docstring for Suggested fixMark the survey as claimed atomically with the decision to render, inside the same 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)</potential_solution> |
||
|
|
||
| /** | ||
| * Resolves the surveys delegate. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| package com.posthog.android.surveys | ||
|
|
||
| import androidx.test.core.app.ApplicationProvider | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import com.posthog.PostHogConfig | ||
| import com.posthog.PostHogInterface | ||
| import com.posthog.surveys.OnPostHogSurveyClosed | ||
| import com.posthog.surveys.OnPostHogSurveyResponse | ||
| import com.posthog.surveys.OnPostHogSurveyShown | ||
| import com.posthog.surveys.PostHogDisplaySurvey | ||
| import com.posthog.surveys.PostHogSurveysDelegate | ||
| import com.posthog.surveys.Survey | ||
| import com.posthog.surveys.SurveyConditions | ||
| import com.posthog.surveys.SurveyEventCondition | ||
| import com.posthog.surveys.SurveyEventConditions | ||
| import com.posthog.surveys.SurveyType | ||
| import org.junit.runner.RunWith | ||
| import org.mockito.kotlin.any | ||
| import org.mockito.kotlin.mock | ||
| import org.mockito.kotlin.whenever | ||
| import kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
| import kotlin.test.assertFalse | ||
| import kotlin.test.assertTrue | ||
|
|
||
| /** | ||
| * Tests for the manual displaySurvey() API, which displays a survey by ID on demand, | ||
| * bypassing display conditions (event triggers, seen checks) — the mobile counterpart | ||
| * of the web SDK's posthog.displaySurvey(). | ||
| */ | ||
| @RunWith(AndroidJUnit4::class) | ||
| internal class PostHogSurveysDisplaySurveyTest { | ||
| private val context = ApplicationProvider.getApplicationContext<android.content.Context>() | ||
|
|
||
| /** | ||
| * A delegate that records rendered survey IDs and reports each survey as shown, | ||
| * so the integration tracks it as the active survey (like the real UI delegates do). | ||
| */ | ||
| private class RecordingDelegate : PostHogSurveysDelegate { | ||
| val renderedSurveyIds = mutableListOf<String>() | ||
|
|
||
| override fun renderSurvey( | ||
| survey: PostHogDisplaySurvey, | ||
| onSurveyShown: OnPostHogSurveyShown, | ||
| onSurveyResponse: OnPostHogSurveyResponse, | ||
| onSurveyClosed: OnPostHogSurveyClosed, | ||
| ) { | ||
| renderedSurveyIds.add(survey.id) | ||
| onSurveyShown(survey) | ||
| } | ||
|
|
||
| override fun cleanupSurveys() {} | ||
| } | ||
|
|
||
| private fun createIntegration( | ||
| delegate: RecordingDelegate, | ||
| surveysEnabled: Boolean = true, | ||
| ): PostHogSurveysIntegration { | ||
| val config = | ||
| PostHogConfig("test-api-key").apply { | ||
| surveys = surveysEnabled | ||
| surveysConfig.surveysDelegate = delegate | ||
| } | ||
| val integration = PostHogSurveysIntegration(context, config) | ||
| val fake = mock<PostHogInterface>() | ||
| whenever(fake.isFeatureEnabled(any(), any(), any())).thenReturn(true) | ||
| integration.install(fake) | ||
| return integration | ||
| } | ||
|
|
||
| private fun createSurvey( | ||
| id: String, | ||
| type: SurveyType, | ||
| conditions: SurveyConditions? = null, | ||
| ): Survey { | ||
| return Survey( | ||
| id = id, | ||
| name = "Test Survey $id", | ||
| type = type, | ||
| questions = emptyList(), | ||
| description = null, | ||
| featureFlagKeys = null, | ||
| linkedFlagKey = null, | ||
| targetingFlagKey = null, | ||
| internalTargetingFlagKey = null, | ||
| conditions = conditions, | ||
| appearance = null, | ||
| currentIteration = null, | ||
| currentIterationStartDate = null, | ||
| startDate = java.util.Date(), | ||
| endDate = null, | ||
| schedule = null, | ||
| ) | ||
| } | ||
|
|
||
| private fun eventConditions(eventName: String): SurveyConditions { | ||
| return SurveyConditions( | ||
| url = null, | ||
| urlMatchType = null, | ||
| selector = null, | ||
| deviceTypes = null, | ||
| deviceTypesMatchType = null, | ||
| seenSurveyWaitPeriodInDays = null, | ||
| events = SurveyEventConditions(repeatedActivation = null, values = listOf(SurveyEventCondition(name = eventName))), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey renders an API-type survey that is never auto-displayed`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) | ||
| assertFalse(delegate.renderedSurveyIds.contains("api-1"), "API survey should not be auto-displayed") | ||
|
|
||
| integration.displaySurvey("api-1") | ||
|
|
||
| assertTrue(delegate.renderedSurveyIds.contains("api-1"), "displaySurvey should render the API survey") | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey bypasses event trigger conditions`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| val survey = createSurvey("popover-1", SurveyType.POPOVER, conditions = eventConditions("some_event")) | ||
| integration.onSurveysLoaded(listOf(survey)) | ||
| assertFalse( | ||
| delegate.renderedSurveyIds.contains("popover-1"), | ||
| "Survey with an unfired event trigger should not be auto-displayed", | ||
| ) | ||
|
|
||
| integration.displaySurvey("popover-1") | ||
|
|
||
| assertTrue( | ||
| delegate.renderedSurveyIds.contains("popover-1"), | ||
| "displaySurvey should render the survey even though its event trigger never fired", | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey does nothing when the survey ID is unknown`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) | ||
|
|
||
| integration.displaySurvey("unknown-id") | ||
|
|
||
| assertEquals(emptyList(), delegate.renderedSurveyIds) | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey is ignored while another survey is active`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| val surveys = | ||
| listOf( | ||
| createSurvey("popover-1", SurveyType.POPOVER), | ||
| createSurvey("api-1", SurveyType.API), | ||
| ) | ||
| integration.onSurveysLoaded(surveys) | ||
| assertEquals(listOf("popover-1"), delegate.renderedSurveyIds) | ||
|
|
||
| integration.displaySurvey("api-1") | ||
|
|
||
| assertEquals(listOf("popover-1"), delegate.renderedSurveyIds) | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey does nothing when surveys are disabled in config`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate, surveysEnabled = false) | ||
| integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) | ||
|
|
||
| integration.displaySurvey("api-1") | ||
|
|
||
| assertEquals(emptyList(), delegate.renderedSurveyIds) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2016,6 +2016,16 @@ public class PostHog private constructor( | |
| } | ||
| } | ||
|
|
||
| override fun displaySurvey(surveyId: String) { | ||
| if (!isEnabled()) { | ||
| return | ||
| } | ||
|
|
||
| surveysHandler?.displaySurvey(surveyId) ?: run { | ||
| config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.") | ||
| } | ||
| } | ||
|
Comment on lines
+2019
to
+2027
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 usesWhy we think it's a valid issue
Issue descriptionEvery other place in this file that can trigger Suggested fixWrap 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)</potential_solution> |
||
|
|
||
| override fun getSessionId(): UUID? { | ||
| if (!isEnabled()) { | ||
| return null | ||
|
|
@@ -2329,6 +2339,10 @@ public class PostHog private constructor( | |
| shared.stopSessionReplay() | ||
| } | ||
|
|
||
| override fun displaySurvey(surveyId: String) { | ||
| shared.displaySurvey(surveyId) | ||
| } | ||
|
|
||
| override fun getSessionId(): UUID? { | ||
| return shared.getSessionId() | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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
Why we think it's a valid issue
PostHogInterface.kt:327-340), the implementation (PostHogSurveysIntegration.kt:151-167), the auto-display filter (getActiveMatchingSurveys, lines 255-306), howcachedSurveysis populated (line 128 ←PostHog.kt:299←PostHogRemoteConfig.getSurveys()atPostHogRemoteConfig.kt:1243-1247), theSurveymodel (Survey.kt:26-29), and the new test fixtures.PostHogInterface.kt:330explicitly 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. ButdisplaySurvey(line 161) resolves the survey solely bycachedSurveys.firstOrNull { it.id == surveyId }and callsshowSurveywith nostartDate/endDateguard.getActiveMatchingSurveysappliesif (survey.startDate == null || survey.endDate != null) return@filter falseas its very first check (line 260, commented "must have start date and no end date") against the samecachedSurveyslist. 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.cachedSurveys(line 128) is assigned the unfiltered list fromonSurveysLoaded, which receivesremoteConfig.getSurveys()verbatim; no date filtering happens anywhere on that path.displaySurvey("ended-or-draft-id")for a survey still present in the cache will render it and firesurvey shown/sent/dismissedevents, 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 thegetActiveMatchingSurveysrunning check), and the new test suite (createSurveyalways usesstartDate = 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
displaySurveyonly checks that the survey ID exists incachedSurveys(cachedSurveys.firstOrNull { it.id == surveyId }, line 161) — it never checkssurvey.startDate/survey.endDate. Compare this withgetActiveMatchingSurveys()(lines 255-306), which explicitly filtersif (survey.startDate == null || survey.endDate != null) return@filter falseas its very first "is this survey running" check before any of the other display-condition checks thatdisplaySurveyintentionally bypasses.cachedSurveysitself is the raw, unfiltered list pushed straight fromPostHogRemoteConfig.getSurveys()(confirmed inPostHog.ktline 299 andPostHogRemoteConfig.ktline 1243-1247), so it can legitimately contain surveys that haven't started yet (nostartDate) or have already ended (endDateset) — the existence of the dedicated filter ingetActiveMatchingSurveysis proof that such surveys are present in the cache.As written, calling
PostHog.displaySurvey("ended-survey-id")for a survey whoseendDatehas already passed (e.g. a closed campaign) will still render it and firesurvey shown/survey sent/survey dismissedevents for it, contradicting the documented "must be running" requirement and potentially polluting the closed survey's response data after its intended end date. The newPostHogSurveysDisplaySurveyTestdoesn't cover this: every survey fixture in that test file is constructed withstartDate = Date()andendDate = null(always running), so this gap has no test coverage.Suggested fix
Add the same "is running" check that
getActiveMatchingSurveys()already uses before callingshowSurvey(survey), e.g.: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)
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>