diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9e790ec..92abe0e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: Support the `propertiesSanitizer` config ([#154](https://github.com/PostHog/posthog-ios/pull/154)) + ## 3.6.3 - 2024-07-26 - recording: fix: respect session replay project settings from app start ([#150](https://github.com/PostHog/posthog-ios/pull/150)) diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index fbd0d057c..86bc090a3 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ 69278D472AE6BC7200BB541A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 69278D462AE6BC7200BB541A /* main.m */; }; 69278D4B2AE6BC9000BB541A /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; 69278D4C2AE6BC9000BB541A /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */; }; + 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */; }; 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6955CB722C517651008EFD8D /* CGSize+Util.swift */; }; 69779BEC2AE68E6900D7A48E /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69779BEB2AE68E6900D7A48E /* UIViewController.swift */; }; 6992AA832AFE51A000087600 /* PostHogExampleTvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6992AA822AFE51A000087600 /* PostHogExampleTvOSApp.swift */; }; @@ -316,6 +318,8 @@ 69278D432AE6BC7200BB541A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 69278D452AE6BC7200BB541A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 69278D462AE6BC7200BB541A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogPropertiesSanitizer.swift; sourceTree = ""; }; + 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleSanitizer.swift; sourceTree = ""; }; 6955CB722C517651008EFD8D /* CGSize+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Util.swift"; sourceTree = ""; }; 69779BEB2AE68E6900D7A48E /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; 6992AA802AFE51A000087600 /* PostHogExampleTvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PostHogExampleTvOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -541,6 +545,7 @@ 69779BEB2AE68E6900D7A48E /* UIViewController.swift */, 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */, 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */, + 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */, ); path = PostHog; sourceTree = ""; @@ -559,6 +564,7 @@ 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */, 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */, 699C5FEE2C20242A007DB818 /* UUIDTest.swift */, + 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */, ); path = PostHogTests; sourceTree = ""; @@ -1080,6 +1086,7 @@ 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */, 69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */, 3A0F108529C9ABB6002C0084 /* ReadWriteLock.swift in Sources */, + 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */, 3AE3FB3D29924E8200AFFC18 /* PostHogSDK.swift in Sources */, 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */, 3AE3FB3F29924F4F00AFFC18 /* PostHogConfig.swift in Sources */, @@ -1114,6 +1121,7 @@ 690FF0BB2AEF8B8200A0B06B /* PostHogContextTest.swift in Sources */, 690FF0E32AEFD12900A0B06B /* PostHogConfigTest.swift in Sources */, 3A62647129CAF67B007E8C07 /* PostHogSessionManagerTest.swift in Sources */, + 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */, 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */, 690FF0BD2AEF93F400A0B06B /* PostHogFeatureFlagsTest.swift in Sources */, 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index b05e715f4..78487e9df 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -27,6 +27,10 @@ import Foundation @objc public var debug: Bool = false @objc public var optOut: Bool = false @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + /// Hook that allows to sanitize the event properties + /// The hook is called before the event is cached or sent over the wire + @objc public var propertiesSanitizer: PostHogPropertiesSanitizer? + /// Internal var snapshotEndpoint: String = "/s/" diff --git a/PostHog/PostHogPropertiesSanitizer.swift b/PostHog/PostHogPropertiesSanitizer.swift new file mode 100644 index 000000000..789f19793 --- /dev/null +++ b/PostHog/PostHogPropertiesSanitizer.swift @@ -0,0 +1,34 @@ +// +// PostHogPropertiesSanitizer.swift +// PostHog +// +// Created by Manoel Aranda Neto on 06.08.24. +// + +import Foundation + +/// Protocol to sanitize the event properties +@objc(PostHogPropertiesSanitizer) public protocol PostHogPropertiesSanitizer { + /// Sanitizes the event properties + /// - Parameter properties: the event properties to sanitize + /// - Returns: the sanitized properties + /// + /// Obs: `inout` cannot be used in Swift protocols, so you need to clone the properties + /// + /// ```swift + /// private class ExampleSanitizer: PostHogPropertiesSanitizer { + /// public func sanitize(_ properties: [String: Any]) -> [String: Any] { + /// var sanitizedProperties = properties + /// // Perform sanitization + /// // For example, removing keys with empty values + /// for (key, value) in properties { + /// if let stringValue = value as? String, stringValue.isEmpty { + /// sanitizedProperties.removeValue(forKey: key) + /// } + /// } + /// return sanitizedProperties + /// } + /// } + /// ``` + @objc func sanitize(_ properties: [String: Any]) -> [String: Any] +} diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 09fc25997..47c4f04c8 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -379,13 +379,16 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30 } let oldDistinctId = getDistinctId() + let properties = buildProperties(distinctId: distinctId, properties: [ + "distinct_id": distinctId, + "$anon_distinct_id": getAnonymousId(), + ], userProperties: sanitizeDicionary(userProperties), userPropertiesSetOnce: sanitizeDicionary(userPropertiesSetOnce)) + let sanitizedProperties = sanitizeProperties(properties) + queue.add(PostHogEvent( event: "$identify", distinctId: distinctId, - properties: buildProperties(distinctId: distinctId, properties: [ - "distinct_id": distinctId, - "$anon_distinct_id": getAnonymousId(), - ], userProperties: sanitizeDicionary(userProperties), userPropertiesSetOnce: sanitizeDicionary(userPropertiesSetOnce)) + properties: sanitizedProperties )) if distinctId != oldDistinctId { @@ -469,15 +472,19 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30 } let distinctId = getDistinctId() + + let properties = buildProperties(distinctId: distinctId, + properties: sanitizeDicionary(properties), + userProperties: sanitizeDicionary(userProperties), + userPropertiesSetOnce: sanitizeDicionary(userPropertiesSetOnce), + groups: groups, + appendSharedProps: !snapshotEvent) + let sanitizedProperties = sanitizeProperties(properties) + let posthogEvent = PostHogEvent( event: event, distinctId: distinctId, - properties: buildProperties(distinctId: distinctId, - properties: sanitizeDicionary(properties), - userProperties: sanitizeDicionary(userProperties), - userPropertiesSetOnce: sanitizeDicionary(userPropertiesSetOnce), - groups: groups, - appendSharedProps: !snapshotEvent) + properties: sanitizedProperties ) // Replay has its own queue @@ -515,13 +522,24 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30 ].merging(sanitizeDicionary(properties) ?? [:]) { prop, _ in prop } let distinctId = getDistinctId() + + let properties = buildProperties(distinctId: distinctId, properties: props) + let sanitizedProperties = sanitizeProperties(properties) + queue.add(PostHogEvent( event: "$screen", distinctId: distinctId, - properties: buildProperties(distinctId: distinctId, properties: props) + properties: sanitizedProperties )) } + private func sanitizeProperties(_ properties: [String: Any]) -> [String: Any] { + if let sanitizer = config.propertiesSanitizer { + return sanitizer.sanitize(properties) + } + return properties + } + @objc public func alias(_ alias: String) { if !isEnabled() { return @@ -538,10 +556,14 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30 let props = ["alias": alias] let distinctId = getDistinctId() + + let properties = buildProperties(distinctId: distinctId, properties: props) + let sanitizedProperties = sanitizeProperties(properties) + queue.add(PostHogEvent( event: "$create_alias", distinctId: distinctId, - properties: buildProperties(distinctId: distinctId, properties: props) + properties: sanitizedProperties )) } @@ -597,10 +619,14 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30 // Same as .group but without associating the current user with the group let distinctId = getDistinctId() + + let properties = buildProperties(distinctId: distinctId, properties: props) + let sanitizedProperties = sanitizeProperties(properties) + queue.add(PostHogEvent( event: "$groupidentify", distinctId: distinctId, - properties: buildProperties(distinctId: distinctId, properties: props) + properties: sanitizedProperties )) } diff --git a/PostHogTests/ExampleSanitizer.swift b/PostHogTests/ExampleSanitizer.swift new file mode 100644 index 000000000..9e2f03999 --- /dev/null +++ b/PostHogTests/ExampleSanitizer.swift @@ -0,0 +1,23 @@ +// +// ExampleSanitizer.swift +// PostHogTests +// +// Created by Manoel Aranda Neto on 06.08.24. +// + +import Foundation +import PostHog + +class ExampleSanitizer: PostHogPropertiesSanitizer { + public func sanitize(_ properties: [String: Any]) -> [String: Any] { + var sanitizedProperties = properties + // Perform sanitization + // For example, removing keys with empty values + for (key, value) in properties { + if let stringValue = value as? String, stringValue.isEmpty { + sanitizedProperties.removeValue(forKey: key) + } + } + return sanitizedProperties + } +} diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index e7eecfdcb..98d917d62 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -16,7 +16,8 @@ class PostHogSDKTest: QuickSpec { sendFeatureFlagEvent: Bool = false, captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, - optOut: Bool = false) -> PostHogSDK + optOut: Bool = false, + propertiesSanitizer: PostHogPropertiesSanitizer? = nil) -> PostHogSDK { let config = PostHogConfig(apiKey: "123", host: "http://localhost:9001") config.flushAt = flushAt @@ -26,6 +27,7 @@ class PostHogSDKTest: QuickSpec { config.disableQueueTimerForTesting = true config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents config.optOut = optOut + config.propertiesSanitizer = propertiesSanitizer return PostHogSDK.with(config) } @@ -704,6 +706,22 @@ class PostHogSDKTest: QuickSpec { expect(FileManager.default.fileExists(atPath: appFolder.path)) == true } + + it("client sanitize properties") { + let sanitizer = ExampleSanitizer() + let sut = self.getSut(propertiesSanitizer: sanitizer) + + let props: [String: Any] = ["empty": ""] + + sut.capture("event", properties: props) + + let events = getBatchedEvents(server) + + expect(events[0].properties["empty"] as? String).to(beNil()) + + sut.reset() + sut.close() + } } }