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
5 changes: 5 additions & 0 deletions .changeset/soft-pears-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": patch
---

Include survey responses on iOS dismissal events and mark whether the dismissed survey was partially completed.
95 changes: 56 additions & 39 deletions PostHog/Surveys/PostHogSurveyIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,9 @@

/// Handle a survey dismiss
private func handleSurveyClosed(survey: PostHogDisplaySurvey) {
let (activeSurvey, activeSurveyCompleted) = activeSurveyLock.withLock { (self.activeSurvey, self.activeSurveyCompleted) }
let (activeSurvey, activeSurveyCompleted, activeSurveyResponses) = activeSurveyLock.withLock {
(self.activeSurvey, self.activeSurveyCompleted, self.activeSurveyResponses)
}

guard let activeSurvey, survey.id == activeSurvey.id else {
hedgeLog("Received a close event for a non-active survey")
Expand All @@ -514,7 +516,7 @@

// send survey dismissed event if needed
if !activeSurveyCompleted {
sendSurveyDismissedEvent(survey: activeSurvey)
sendSurveyDismissedEvent(survey: activeSurvey, responses: activeSurveyResponses)
}

// mark as seen
Expand Down Expand Up @@ -543,15 +545,44 @@
/// - survey: The completed survey
/// - responses: Dictionary of collected responses for each question
private func sendSurveySentEvent(survey: PostHogSurvey, responses: [String: PostHogSurveyResponse]) {
let responsesProperties: [String: Any] = responses.compactMapValues { resp in
switch resp.type {
case .link: resp.linkClicked == true ? "link clicked" : nil
case .multipleChoice: resp.selectedOptions
case .singleChoice: resp.selectedOptions?.first
case .openEnded: resp.textValue
case .rating: resp.ratingValue.map { "\($0)" }
}
}
let additionalProperties = buildSurveyResponseProperties(survey: survey, responses: responses).merging(
[
"$set": [getSurveyInteractionProperty(survey: survey, property: "responded"): true],
],
uniquingKeysWith: { _, new in new }
)

sendSurveyEvent(
event: "survey sent",
survey: survey,
additionalProperties: additionalProperties
)
}

/// Sends a `survey dismissed` event to PostHog instance
private func sendSurveyDismissedEvent(survey: PostHogSurvey, responses: [String: PostHogSurveyResponse]) {
let additionalProperties = buildSurveyResponseProperties(survey: survey, responses: responses).merging(
[
"$survey_partially_completed": surveyHasResponses(responses),
"$set": [
getSurveyInteractionProperty(survey: survey, property: "dismissed"): true,
],
],
uniquingKeysWith: { _, new in new }
)

sendSurveyEvent(
event: "survey dismissed",
survey: survey,
additionalProperties: additionalProperties
)
}

private func buildSurveyResponseProperties(
survey: PostHogSurvey,
responses: [String: PostHogSurveyResponse]
) -> [String: Any] {
let responsesProperties: [String: Any] = responses.compactMapValues { getSurveyResponseValue(for: $0) }

let surveyQuestions = survey.questions.enumerated().map { index, question in
let responseKey = question.id.isEmpty ? getOldResponseKey(for: index) : getNewResponseKey(for: question.id)
Expand All @@ -567,35 +598,21 @@
return questionData
}

let questionProperties: [String: Any] = [
"$survey_questions": surveyQuestions,
"$set": [getSurveyInteractionProperty(survey: survey, property: "responded"): true],
]

// TODO: Should be doing some validation before sending the event?

let additionalProperties = questionProperties.merging(responsesProperties, uniquingKeysWith: { _, new in new })

sendSurveyEvent(
event: "survey sent",
survey: survey,
additionalProperties: additionalProperties
)
return ["$survey_questions": surveyQuestions].merging(responsesProperties, uniquingKeysWith: { _, new in new })
}

/// Sends a `survey dismissed` event to PostHog instance
private func sendSurveyDismissedEvent(survey: PostHogSurvey) {
let additionalProperties: [String: Any] = [
"$set": [
getSurveyInteractionProperty(survey: survey, property: "dismissed"): true,
],
]
private func surveyHasResponses(_ responses: [String: PostHogSurveyResponse]) -> Bool {
responses.values.contains { getSurveyResponseValue(for: $0) != nil }
}

sendSurveyEvent(
event: "survey dismissed",
survey: survey,
additionalProperties: additionalProperties
)
private func getSurveyResponseValue(for response: PostHogSurveyResponse) -> Any? {
switch response.type {
case .link: response.linkClicked == true ? "link clicked" : nil
case .multipleChoice: response.selectedOptions
case .singleChoice: response.selectedOptions?.first
case .openEnded: response.textValue
case .rating: response.ratingValue.map { "\($0)" }
}
}

private func sendSurveyEvent(event: String, survey: PostHogSurvey, additionalProperties: [String: Any] = [:]) {
Expand Down Expand Up @@ -989,8 +1006,8 @@
sendSurveySentEvent(survey: survey, responses: responses)
}

func testSendSurveyDismissedEvent(survey: PostHogSurvey) {
sendSurveyDismissedEvent(survey: survey)
func testSendSurveyDismissedEvent(survey: PostHogSurvey, responses: [String: PostHogSurveyResponse] = [:]) {
sendSurveyDismissedEvent(survey: survey, responses: responses)
}

func testGetBaseSurveyEventProperties(for survey: PostHogSurvey) -> [String: Any] {
Expand Down
79 changes: 79 additions & 0 deletions PostHogTests/PostHogSurveyEventsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,85 @@ class PostHogSurveyEventsTest {
postHog.reset()
}

@Test("survey dismissed event includes responses and partial completion when there are answers")
func surveyDismissedEventIncludesResponsesWhenThereAreAnswers() async throws {
let postHog = getSut()

let integration = try getSurveyIntegration(postHog)

let survey = getTestSurvey(
id: "dismissed-responses-survey",
name: "Dismissed Responses Survey",
questions: defaultQuestions
)

let responses: [String: PostHogSurveyResponse] = [
integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"),
integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"),
integration.testGetResponseKey(questionId: "qID3"): .rating(4),
"$survey_response": .openEnded("Great product!"),
"$survey_response_1": .singleChoice("Very likely"),
"$survey_response_2": .rating(4),
]

integration.testSendSurveyDismissedEvent(survey: survey, responses: responses)

let events = try await getServerEvents(server)

#expect(events.count == 1)
let event = events[0]

#expect(event.event == "survey dismissed")
#expect(event.properties["$survey_partially_completed"] as? Bool == true)
#expect(event.properties["$survey_response_qID1"] as? String == "Great product!")
#expect(event.properties["$survey_response_qID2"] as? String == "Very likely")
#expect(event.properties["$survey_response_qID3"] as? String == "4")
#expect(event.properties["$survey_response"] as? String == "Great product!")
#expect(event.properties["$survey_response_1"] as? String == "Very likely")
#expect(event.properties["$survey_response_2"] as? String == "4")

let questions = event.properties["$survey_questions"] as? [[String: Any]]
#expect(questions?.count == 3)
#expect(questions?[0]["response"] as? String == "Great product!")
#expect(questions?[1]["response"] as? String == "Very likely")
#expect(questions?[2]["response"] as? String == "4")

postHog.close()
postHog.reset()
}

@Test("survey dismissed event marks partial completion false when there are no answers")
func surveyDismissedEventMarksPartialCompletionFalseWhenThereAreNoAnswers() async throws {
let postHog = getSut()

let integration = try getSurveyIntegration(postHog)

let survey = getTestSurvey(
id: "dismissed-empty-survey",
name: "Dismissed Empty Survey",
questions: defaultQuestions
)

integration.testSendSurveyDismissedEvent(survey: survey, responses: [:])

let events = try await getServerEvents(server)

#expect(events.count == 1)
let event = events[0]

#expect(event.event == "survey dismissed")
#expect(event.properties["$survey_partially_completed"] as? Bool == false)

let questions = event.properties["$survey_questions"] as? [[String: Any]]
#expect(questions?.count == 3)
#expect(questions?[0]["response"] == nil)
#expect(questions?[1]["response"] == nil)
#expect(questions?[2]["response"] == nil)

postHog.close()
postHog.reset()
}

@Test("survey dismissed event with iteration has correct interaction property")
func surveyDismissedEventWithIterationHasCorrectInteractionProperty() async throws {
let postHog = getSut()
Expand Down
Loading