Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wise-dolphins-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": patch
"posthog-android": patch
---

Include survey responses on Android dismissal events, including question id based response keys and partial completion state. Null rating responses are ignored instead of being serialized as "null".
Original file line number Diff line number Diff line change
Expand Up @@ -248,73 +248,91 @@ public class PostHogSurveysIntegration(
val onSurveyShown: OnPostHogSurveyShown = { shownSurvey ->
// Check if shownSurvey is originalSurvey
if (shownSurvey.id == originalSurvey.id) {
val currentActiveSurvey = activeSurvey

// If currentActiveSurvey is null, set this originalSurvey as active
if (currentActiveSurvey == null) {
setActiveSurvey(originalSurvey)
// If no survey is active, set this originalSurvey as active
synchronized(activeSurveyLock) {
if (activeSurvey == null) {
activeSurvey = originalSurvey
activeSurveyCompleted = false
currentSurveyResponses.clear()
}
}

// Send survey shown event
sendSurveyShownEvent(originalSurvey)

// Clear up event-activated surveys if this survey has events
if (hasEvents(originalSurvey)) {
eventActivatedSurveys.remove(originalSurvey.id)
synchronized(eventActivationLock) {
eventActivatedSurveys.remove(originalSurvey.id)
}
}
} else {
config.logger.log("Received a show event for a non-matching survey: ${shownSurvey.id} vs ${originalSurvey.id}")
}
}

val onSurveyResponse: OnPostHogSurveyResponse = { responseSurvey, questionIndex, response ->
// Get current active survey
val currentActiveSurvey = activeSurvey
val onSurveyResponse: OnPostHogSurveyResponse = onSurveyResponse@{ responseSurvey, questionIndex, response ->
// Calculate next question based on current response
val nextQuestion = getNextQuestion(originalSurvey, questionIndex, response)
var responsesToSend: Map<String, PostHogSurveyResponse>? = null

// Validate that this survey matches the currently active survey
if (currentActiveSurvey == null || responseSurvey.id != currentActiveSurvey.id) {
config.logger.log("Received a response event for a non-active survey")
null
} else {
// Calculate next question based on current response
val nextQuestion = getNextQuestion(originalSurvey, questionIndex, response)
synchronized(activeSurveyLock) {
// Validate that this survey matches the currently active survey
val currentActiveSurvey = activeSurvey
if (currentActiveSurvey == null || responseSurvey.id != currentActiveSurvey.id) {
config.logger.log("Received a response event for a non-active survey")
return@onSurveyResponse null
}

// Store the response for survey completion tracking
currentSurveyResponses[getResponseKey(questionIndex)] = response
currentSurveyResponses[getLegacyResponseKey(questionIndex)] = response
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.

This was pre-existing but I think currentSurveyResponses should be synchronized with activeSurveyLock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call, fixed. I wrapped the active survey response/completion reads and writes with activeSurveyLock and snapshot the responses before sending the event.

originalSurvey.questions.getOrNull(questionIndex)?.id?.takeIf { it.isNotEmpty() }?.let { questionId ->
currentSurveyResponses[getQuestionIdResponseKey(questionId)] = response
}

// Check if survey is completed (needed on close event)
activeSurveyCompleted = nextQuestion.isSurveyCompleted

// Send completion event if survey is finished
if (activeSurveyCompleted) {
sendSurveySentEvent(originalSurvey, currentSurveyResponses)
responsesToSend = currentSurveyResponses.toMap()
}

nextQuestion
}

responsesToSend?.let { sendSurveySentEvent(originalSurvey, it) }

nextQuestion
}

val onSurveyClosed: OnPostHogSurveyClosed = onSurveyClosed@{ _ ->
// Get current active survey and completion state
val currentActiveSurvey = activeSurvey
var surveyResponses: Map<String, PostHogSurveyResponse> = emptyMap()
var wasSurveyCompleted = false

synchronized(activeSurveyLock) {
// Validate that this survey matches the currently active survey
val currentActiveSurvey = activeSurvey
if (currentActiveSurvey == null || originalSurvey.id != currentActiveSurvey.id) {
config.logger.log("[Surveys] Received a close event for a non-active survey")
return@onSurveyClosed
}

// Get current active survey and completion state
surveyResponses = currentSurveyResponses.toMap()
wasSurveyCompleted = activeSurveyCompleted

// Validate that this survey matches the currently active survey
if (currentActiveSurvey == null || originalSurvey.id != currentActiveSurvey.id) {
config.logger.log("[Surveys] Received a close event for a non-active survey")
return@onSurveyClosed
activeSurvey = null
activeSurveyCompleted = false
currentSurveyResponses.clear()
}

// Send survey dismissed event if survey was not completed
if (!activeSurveyCompleted) {
sendSurveyDismissedEvent(originalSurvey)
if (!wasSurveyCompleted) {
sendSurveyDismissedEvent(originalSurvey, surveyResponses)
}

// Mark survey as seen
setSurveySeen(originalSurvey)

// Clear active survey
clearActiveSurvey()

// Show next survey in queue after a short delay
Thread {
Thread.sleep(NEXT_SURVEY_TRANSITION_DELAY_MS)
Expand Down Expand Up @@ -606,18 +624,6 @@ public class PostHogSurveysIntegration(
return survey.type == SurveyType.POPOVER || survey.type == SurveyType.WIDGET
}

/**
* Sets the currently active survey.
* This prevents multiple surveys from being shown simultaneously.
*/
private fun setActiveSurvey(survey: Survey?) {
synchronized(activeSurveyLock) {
activeSurvey = survey
activeSurveyCompleted = false
currentSurveyResponses.clear()
}
}

/**
* Clears the active survey when it's completed or dismissed.
* This allows the next survey to be shown.
Expand Down Expand Up @@ -652,26 +658,14 @@ public class PostHogSurveysIntegration(
survey: Survey,
responses: Map<String, PostHogSurveyResponse>,
) {
val questionProperties =
mutableMapOf<String, Any>(
"\$survey_questions" to survey.questions.map { it.question },
)

// Add survey interaction property for "responded"
questionProperties["\$set"] =
mapOf(
getSurveyInteractionProperty(survey, "responded") to true,
)

// Convert responses to simple values
val responsesProperties =
responses.mapNotNull { (key, response) ->
response.toResponseValue()?.let { value ->
key to value
}
}.toMap()

val additionalProperties = questionProperties + responsesProperties
val additionalProperties =
buildSurveyResponseProperties(survey, responses) +
mapOf(
"\$set" to
mapOf(
getSurveyInteractionProperty(survey, "responded") to true,
),
)

sendSurveyEvent(
event = "survey sent",
Expand All @@ -683,15 +677,19 @@ public class PostHogSurveysIntegration(
/**
* Sends a "survey dismissed" event to PostHog instance
*/
private fun sendSurveyDismissedEvent(survey: Survey) {
private fun sendSurveyDismissedEvent(
survey: Survey,
responses: Map<String, PostHogSurveyResponse>,
) {
val additionalProperties =
mapOf(
"\$survey_questions" to survey.questions.map { it.question },
"\$set" to
mapOf(
getSurveyInteractionProperty(survey, "dismissed") to true,
),
)
buildSurveyResponseProperties(survey, responses) +
mapOf(
"\$survey_partially_completed" to surveyHasResponses(responses),
"\$set" to
mapOf(
getSurveyInteractionProperty(survey, "dismissed") to true,
),
)

sendSurveyEvent(
event = "survey dismissed",
Expand All @@ -700,6 +698,37 @@ public class PostHogSurveysIntegration(
)
}

private fun buildSurveyResponseProperties(
survey: Survey,
responses: Map<String, PostHogSurveyResponse>,
): Map<String, Any> {
val responsesProperties =
responses.mapNotNull { (key, response) ->
response.toResponseValue()?.let { value ->
key to value
}
}.toMap()

val surveyQuestions =
survey.questions.mapIndexed { index, question ->
mutableMapOf<String, Any>().apply {
question.id?.let { put("id", it) }
question.question?.let { put("question", it) }

val responseKey =
question.id?.takeIf { it.isNotEmpty() }?.let(::getQuestionIdResponseKey)
?: getLegacyResponseKey(index)
responsesProperties[responseKey]?.let { put("response", it) }
}
}

return mapOf("\$survey_questions" to surveyQuestions) + responsesProperties
}

private fun surveyHasResponses(responses: Map<String, PostHogSurveyResponse>): Boolean {
return responses.values.any { it.toResponseValue() != null }
Copy link
Copy Markdown
Contributor

@dustinbyrne dustinbyrne Apr 27, 2026

Choose a reason for hiding this comment

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

Given how Rating accepts a nullable integer, and how it's converted to a response value, it looks like it could return the string "null" and cause this function to return true.

Seems like a weird edge case, though, and given the way we're tracking responses, it looks unlikely? I'll leave it up to you.

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.

I'm not 100% sure if this will generate "null" string either, but doesn't hurt to be covered in a test?

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.

Yeah looks like it will generate "null"

We should probably change this from is Rating -> rating.toString() to is Rating -> rating?.toString()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, fixed this. Rating(null) now returns null instead of the string "null", and I added both a core response test and a dismissed payload test for that case.

}

/**
* Helper method to send survey events with consistent properties
*/
Expand Down Expand Up @@ -759,14 +788,22 @@ public class PostHogSurveysIntegration(
* Generate the property key used to store a response for a given question index.
* For index 0 returns "$survey_response", otherwise returns "$survey_response_<index>".
*/
private fun getResponseKey(index: Int): String {
private fun getLegacyResponseKey(index: Int): String {
return if (index == 0) {
"\$survey_response"
} else {
"\$survey_response_$index"
}
}

/**
* Generate the property key used to store a response for a given question id.
* Returns "$survey_response_<questionId>".
*/
private fun getQuestionIdResponseKey(questionId: String): String {
return "\$survey_response_$questionId"
}

// Seen Survey Tracking Methods

/**
Expand Down
Loading
Loading