diff --git a/.changeset/soft-pears-flow.md b/.changeset/soft-pears-flow.md new file mode 100644 index 000000000..00416f4bb --- /dev/null +++ b/.changeset/soft-pears-flow.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": patch +--- + +Include survey responses on iOS dismissal events and mark whether the dismissed survey was partially completed. diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index 3576c417c..ac0cd928a 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -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") @@ -514,7 +516,7 @@ // send survey dismissed event if needed if !activeSurveyCompleted { - sendSurveyDismissedEvent(survey: activeSurvey) + sendSurveyDismissedEvent(survey: activeSurvey, responses: activeSurveyResponses) } // mark as seen @@ -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) @@ -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] = [:]) { @@ -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] { diff --git a/PostHogTests/PostHogSurveyEventsTest.swift b/PostHogTests/PostHogSurveyEventsTest.swift index bc6353344..83629b6d3 100644 --- a/PostHogTests/PostHogSurveyEventsTest.swift +++ b/PostHogTests/PostHogSurveyEventsTest.swift @@ -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()