Skip to content

Commit 81c5433

Browse files
committed
feat(surveys): support event property filters
1 parent d23e036 commit 81c5433

7 files changed

Lines changed: 284 additions & 22 deletions

File tree

.changeset/thick-squids-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"posthog-ios": minor
3+
---
4+
5+
support survey event property filters

PostHog/Models/Surveys/PostHogSurveyConditions.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,23 @@ struct PostHogSurveyActionsConditions: Decodable {
4040
let values: [PostHogEventCondition]
4141
}
4242

43+
/// Represents a single property filter for event condition matching
44+
struct PostHogPropertyFilter: Decodable, Equatable {
45+
/// The values to compare against
46+
let values: [String]
47+
/// The comparison operator
48+
let matchOperator: PostHogSurveyMatchType
49+
50+
enum CodingKeys: String, CodingKey {
51+
case values
52+
case matchOperator = "operator"
53+
}
54+
}
55+
4356
/// Represents a single event condition used in survey targeting
4457
struct PostHogEventCondition: Decodable, Equatable {
4558
/// Name of the event (e.g., "content loaded")
4659
let name: String
60+
/// Property filters that must match for this event condition (optional)
61+
var propertyFilters: [String: PostHogPropertyFilter]? = nil
4762
}

PostHog/Models/Surveys/PostHogSurveyEnums.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ enum PostHogSurveyMatchType: Decodable, Equatable {
8888
case isNot
8989
case iContains
9090
case notIContains
91+
case gt
92+
case lt
9193
case unknown(value: String)
9294

9395
init(from decoder: any Decoder) throws {
@@ -107,6 +109,10 @@ enum PostHogSurveyMatchType: Decodable, Equatable {
107109
self = .iContains
108110
case "not_icontains":
109111
self = .notIContains
112+
case "gt":
113+
self = .gt
114+
case "lt":
115+
self = .lt
110116
default:
111117
self = .unknown(value: valueString)
112118
}

PostHog/PostHogSDK.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,7 @@ let maxRetryDelay = 30.0
882882
}
883883

884884
#if os(iOS)
885-
surveysIntegration?.onEvent(event: posthogEvent.event)
885+
surveysIntegration?.onEvent(event: posthogEvent.event, properties: posthogEvent.properties)
886886
#endif
887887
}
888888

PostHog/Surveys/PostHogSurveyIntegration.swift

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
private var allSurveys: [PostHogSurvey]?
3333

3434
private var eventsToSurveysLock = NSLock()
35-
private var eventsToSurveys: [String: [String]] = [:]
35+
private var eventsToSurveys: [String: [(surveyId: String, condition: PostHogEventCondition)]] = [:]
3636

3737
private var seenSurveyKeysLock = NSLock()
3838
private var seenSurveyKeys: [AnyHashable: Any]?
@@ -145,12 +145,18 @@
145145

146146
// TODO: Decouple PostHogSDK and use registration handlers instead
147147
/// Called from PostHogSDK instance when an event is captured
148-
func onEvent(event: String) {
149-
let activatedSurveys = eventsToSurveysLock.withLock { eventsToSurveys[event] } ?? []
150-
guard !activatedSurveys.isEmpty else { return }
148+
func onEvent(event: String, properties: [String: Any]) {
149+
let candidates = eventsToSurveysLock.withLock { eventsToSurveys[event] } ?? []
150+
guard !candidates.isEmpty else { return }
151+
152+
let matchingSurveyIds = candidates
153+
.filter { matchPropertyFilters($0.condition.propertyFilters, eventProperties: properties) }
154+
.map(\.surveyId)
155+
156+
guard !matchingSurveyIds.isEmpty else { return }
151157

152158
eventActivatedSurveysLock.withLock {
153-
for survey in activatedSurveys {
159+
for survey in matchingSurveyIds {
154160
eventActivatedSurveys.insert(survey)
155161
}
156162
}
@@ -214,10 +220,12 @@
214220
private func decodeAndSetSurveys(remoteConfig: [String: Any]?, callback: @escaping SurveyCallback) {
215221
let loadedSurveys: [PostHogSurvey] = decodeSurveys(from: remoteConfig ?? [:])
216222

217-
let eventMap = loadedSurveys.reduce(into: [String: [String]]()) { result, current in
218-
if let surveyEvents = current.conditions?.events?.values.map(\.name) {
219-
for event in surveyEvents {
220-
result[event, default: []].append(current.id)
223+
let eventMap = loadedSurveys.reduce(into: [String: [(surveyId: String, condition: PostHogEventCondition)]]()) { result, current in
224+
if let surveyEvents = current.conditions?.events?.values {
225+
for eventCondition in surveyEvents {
226+
result[eventCondition.name, default: []].append(
227+
(surveyId: current.id, condition: eventCondition)
228+
)
221229
}
222230
}
223231
}
@@ -389,6 +397,24 @@
389397
storage?.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(date))
390398
}
391399

400+
/// Checks if the given event properties satisfy all property filters.
401+
/// Returns true if propertyFilters is nil or empty (no filters = match all).
402+
private func matchPropertyFilters(
403+
_ propertyFilters: [String: PostHogPropertyFilter]?,
404+
eventProperties: [String: Any]
405+
) -> Bool {
406+
guard let propertyFilters, !propertyFilters.isEmpty else {
407+
return true
408+
}
409+
return propertyFilters.allSatisfy { propertyName, filter in
410+
guard let eventValue = eventProperties[propertyName] else {
411+
return false
412+
}
413+
let eventValueString = String(describing: eventValue)
414+
return filter.matchOperator.matches(targets: filter.values, value: eventValueString)
415+
}
416+
}
417+
392418
/// Checks if a survey has been previously activated by an associated event
393419
private func isSurveyEventActivated(survey: PostHogSurvey) -> Bool {
394420
eventActivatedSurveysLock.withLock {
@@ -831,25 +857,25 @@
831857
private extension PostHogSurveyMatchType {
832858
func matches(targets: [String], value: String) -> Bool {
833859
switch self {
834-
// any of the targets contain the value (matched lowercase)
860+
// value contains any of the targets (case-insensitive)
835861
case .iContains:
836862
targets.contains { target in
837-
target.lowercased().contains(value.lowercased())
863+
value.lowercased().contains(target.lowercased())
838864
}
839-
// *none* of the targets contain the value (matched lowercase)
865+
// value contains *none* of the targets (case-insensitive)
840866
case .notIContains:
841867
targets.allSatisfy { target in
842-
!target.lowercased().contains(value.lowercased())
868+
!value.lowercased().contains(target.lowercased())
843869
}
844-
// any of the targets match with regex
870+
// value matches any of the targets as a regex pattern
845871
case .regex:
846872
targets.contains { target in
847-
target.range(of: value, options: .regularExpression) != nil
873+
value.range(of: target, options: .regularExpression) != nil
848874
}
849-
// *none* if the targets match with regex
875+
// value matches *none* of the targets as a regex pattern
850876
case .notRegex:
851877
targets.allSatisfy { target in
852-
target.range(of: value, options: .regularExpression) == nil
878+
value.range(of: target, options: .regularExpression) == nil
853879
}
854880
// any of the targets is an exact match
855881
case .exact:
@@ -861,6 +887,22 @@
861887
targets.allSatisfy { target in
862888
target != value
863889
}
890+
// any of the targets is numerically less than the value (value > target)
891+
case .gt:
892+
targets.contains { target in
893+
if let targetNum = Double(target), let valueNum = Double(value) {
894+
return valueNum > targetNum
895+
}
896+
return false
897+
}
898+
// any of the targets is numerically greater than the value (value < target)
899+
case .lt:
900+
targets.contains { target in
901+
if let targetNum = Double(target), let valueNum = Double(value) {
902+
return valueNum < targetNum
903+
}
904+
return false
905+
}
864906
case .unknown:
865907
false
866908
}
@@ -955,6 +997,25 @@
955997
getNewResponseKey(for: questionId)
956998
}
957999

1000+
func testMatchPropertyFilters(
1001+
_ propertyFilters: [String: PostHogPropertyFilter]?,
1002+
eventProperties: [String: Any]
1003+
) -> Bool {
1004+
matchPropertyFilters(propertyFilters, eventProperties: eventProperties)
1005+
}
1006+
1007+
func testSetEventsToSurveys(_ map: [String: [(surveyId: String, condition: PostHogEventCondition)]]) {
1008+
eventsToSurveysLock.withLock {
1009+
eventsToSurveys = map
1010+
}
1011+
}
1012+
1013+
func testIsEventActivated(surveyId: String) -> Bool {
1014+
eventActivatedSurveysLock.withLock {
1015+
eventActivatedSurveys.contains(surveyId)
1016+
}
1017+
}
1018+
9581019
static func clearInstalls() {
9591020
integrationInstalledLock.withLock {
9601021
integrationInstalled = false

0 commit comments

Comments
 (0)