diff --git a/PostHog/PostHogBootstrap.swift b/PostHog/PostHogBootstrap.swift new file mode 100644 index 000000000..3aa795a41 --- /dev/null +++ b/PostHog/PostHogBootstrap.swift @@ -0,0 +1,45 @@ +// +// PostHogBootstrap.swift +// PostHog +// + +import Foundation + +/// Pre-seeded values applied on the very first SDK launch when no per-device state has +/// been persisted yet. +/// +/// Set ``PostHogConfig/bootstrap`` before calling `setup(_:)` to ensure events captured +/// synchronously during initialization (`Application Installed` / `Application Updated`, +/// pre-identify lifecycle events) carry a caller-controlled `$distinct_id` rather than +/// the SDK-generated UUID. Mirrors the [`bootstrap` option in `posthog-js`](https://posthog.com/docs/feature-flags/bootstrapping). +/// +/// Bootstrap only seeds the very first session. Once an anonymous ID is persisted on +/// disk, or `identify(...)` has been called, the bootstrap values are ignored — they +/// never override an already-identified user or re-link traffic across a previous +/// anon→identified merge. +@objc(PostHogBootstrap) public class PostHogBootstrap: NSObject { + /// The distinct ID to seed on first launch. + /// + /// When ``isIdentifiedID`` is `false` (the default), this becomes the anonymous ID — + /// the `$distinct_id` on pre-identify events. When `true`, it is treated as an + /// already-identified user's distinct ID and the SDK skips the `$identify` merge + /// for it. + @objc public var distinctID: String? + + /// Whether ``distinctID`` represents an already-identified user. + /// + /// Defaults to `false`. Set to `true` when the host application has resolved the + /// user's identity outside the SDK (for example from a backend session token) and + /// wants the iOS SDK to treat them as identified from the first event onward. + @objc public var isIdentifiedID: Bool = false + + @objc public override init() { + super.init() + } + + @objc public init(distinctID: String?, isIdentifiedID: Bool = false) { + self.distinctID = distinctID + self.isIdentifiedID = isIdentifiedID + super.init() + } +} diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index b5b13b7ae..8585ba101 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -187,6 +187,22 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// - Returns: The UUID to persist as the anonymous ID. @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + /// Pre-seeded identity applied on the very first SDK launch when no per-device state + /// has been persisted yet. + /// + /// Set this before calling `setup(_:)` to ensure events captured synchronously during + /// initialization (`Application Installed` / `Application Updated`, pre-identify + /// lifecycle events) carry a caller-controlled `$distinct_id` rather than the + /// SDK-generated UUID. Mirrors the [`bootstrap` option in `posthog-js`](https://posthog.com/docs/feature-flags/bootstrapping). + /// + /// Bootstrap only seeds the very first session. Once an anonymous ID is persisted + /// on disk, or `identify(...)` has been called, the bootstrap values are ignored — + /// they never override an already-identified user or re-link traffic across a + /// previous anon→identified merge. + /// + /// Defaults to `nil` (use the SDK-generated UUID). + @objc public var bootstrap: PostHogBootstrap? + /// Flag to reuse the anonymous Id between `reset()` and next `identify()` calls /// /// If enabled, the anonymous Id will be reused for all anonymous users on this device, diff --git a/PostHog/PostHogStorageManager.swift b/PostHog/PostHogStorageManager.swift index 77881e249..71e9f5a24 100644 --- a/PostHog/PostHogStorageManager.swift +++ b/PostHog/PostHogStorageManager.swift @@ -21,6 +21,7 @@ public class PostHogStorageManager { private let identifiedLock = NSLock() private let personProcessingLock = NSLock() private let idGen: (UUID) -> UUID + private let bootstrap: PostHogBootstrap? private var distinctId: String? private var cachedDistinctId = false @@ -32,6 +33,33 @@ public class PostHogStorageManager { init(_ config: PostHogConfig) { storage = PostHogStorage(config) idGen = config.getAnonymousId + bootstrap = config.bootstrap + applyBootstrapIfNeeded() + } + + /// Persists the bootstrap distinct ID exactly once, on the very first launch with no + /// per-device state. Skipped when the device already has an anonymous ID on disk, + /// when the user is already identified, or when the caller did not supply one. + /// + /// When `bootstrap.isIdentifiedID` is `true`, the value is treated as the + /// already-identified distinct ID — both `.anonymousId` and `.distinctId` are seeded + /// to the same value and `isIdentified` is set, so subsequent events are emitted on + /// the identified person without an `$identify` merge. + private func applyBootstrapIfNeeded() { + guard let bootstrap, let bootstrapId = bootstrap.distinctID, !bootstrapId.isEmpty else { + return + } + // Persisted state wins — never override an existing anonymous ID, and never + // re-link traffic across a previous anon→identified merge. + if storage.getString(forKey: .anonymousId) != nil { return } + if storage.getBool(forKey: .isIdentified) == true { return } + + setAnonymousId(bootstrapId) + + if bootstrap.isIdentifiedID { + setDistinctId(bootstrapId) + setIdentified(true) + } } /// Returns the persisted anonymous ID, creating one if needed. diff --git a/PostHogTests/PostHogStorageManagerTest.swift b/PostHogTests/PostHogStorageManagerTest.swift index dd5964f10..0e31f23cf 100644 --- a/PostHogTests/PostHogStorageManagerTest.swift +++ b/PostHogTests/PostHogStorageManagerTest.swift @@ -59,6 +59,85 @@ class PostHogStorageManagerTest: QuickSpec { sut.reset(true) } + it("Seeds the anonymous ID from bootstrap.distinctID on fresh install") { + let config = PostHogConfig(projectToken: "test_project_token") + config.bootstrap = PostHogBootstrap(distinctID: "A-bootstrap-id-123") + let sut = self.getSut(config) + + let anonymousId = sut.getAnonymousId() + expect(anonymousId) == "A-bootstrap-id-123" + + // Subsequent calls return the same persisted value. + expect(sut.getAnonymousId()) == "A-bootstrap-id-123" + // Not flagged as identified — anonymous-only seed. + expect(sut.isIdentified()) == false + + sut.reset(true) + } + + it("Ignores empty bootstrap.distinctID and falls back to UUID") { + let config = PostHogConfig(projectToken: "test_project_token") + config.bootstrap = PostHogBootstrap(distinctID: "") + let sut = self.getSut(config) + + let anonymousId = sut.getAnonymousId() + expect(anonymousId.isEmpty) == false + // Should be a UUID, not an empty string. + expect(UUID(uuidString: anonymousId)) != nil + + sut.reset(true) + } + + it("Seeds both anonymous and distinct IDs when bootstrap.isIdentifiedID is true") { + let config = PostHogConfig(projectToken: "test_project_token") + config.bootstrap = PostHogBootstrap(distinctID: "user-42", isIdentifiedID: true) + let sut = self.getSut(config) + + expect(sut.getAnonymousId()) == "user-42" + expect(sut.getDistinctId()) == "user-42" + expect(sut.isIdentified()) == true + + sut.reset(true) + } + + it("Does not re-apply bootstrap once an anonymous ID is persisted") { + let firstConfig = PostHogConfig(projectToken: "test_project_token") + firstConfig.bootstrap = PostHogBootstrap(distinctID: "A-original") + let firstSut = self.getSut(firstConfig) + _ = firstSut.getAnonymousId() + + // Simulate a second SDK init in the same install: storage already has + // the original anonymous ID. The new config supplies a different + // bootstrap value, which must NOT override the persisted one. + let secondConfig = PostHogConfig(projectToken: "test_project_token") + secondConfig.bootstrap = PostHogBootstrap(distinctID: "A-different") + let secondSut = PostHogStorageManager(secondConfig) + + expect(secondSut.getAnonymousId()) == "A-original" + + firstSut.reset(true) + } + + it("Does not re-apply bootstrap after the user has been identified") { + let firstConfig = PostHogConfig(projectToken: "test_project_token") + let firstSut = self.getSut(firstConfig) + firstSut.setIdentified(true) + firstSut.setDistinctId("identified-user") + let originalAnon = firstSut.getAnonymousId() + + // A subsequent SDK init that supplies a bootstrap must not re-seed + // either the anonymous ID or the distinct ID — that would silently + // re-link traffic across the prior anon→identified merge. + let secondConfig = PostHogConfig(projectToken: "test_project_token") + secondConfig.bootstrap = PostHogBootstrap(distinctID: "A-new", isIdentifiedID: true) + let secondSut = PostHogStorageManager(secondConfig) + + expect(secondSut.getAnonymousId()) == originalAnon + expect(secondSut.getDistinctId()) == "identified-user" + + firstSut.reset(true) + } + it("Uses the correct fallback value for isIdentified") { let anonymousIdToSet = UUID.v7() let distinctIdToSet = UUID.v7().uuidString