From 580c33d68f8e9d0c43c25ac39cdde61a81547c95 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:59:48 -0700 Subject: [PATCH 1/2] feat(identity): bootstrap anonymousId via config and runtime setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two surfaces for caller-supplied anonymous IDs, mirroring posthog-js behaviour requested in #471 (and posthog-js#862): 1. `PostHogConfig.anonymousId` — pre-seeds the anonymous ID before `setup()` so events captured synchronously during initialization (Application Installed/Updated, screen views) use the caller's ID instead of the SDK-generated UUID. Honoured only when no value is already persisted. 2. `PostHogSDK.setAnonymousId(_:)` — overrides the persisted anonymous ID at runtime, intended to be called immediately after `reset()` to seed the next anonymous session. Both ignore empty strings to avoid clobbering with sentinel values. Closes #471 --- PostHog/PostHogConfig.swift | 10 +++++ PostHog/PostHogSDK.swift | 14 +++++++ PostHog/PostHogStorageManager.swift | 10 ++++- PostHogTests/PostHogStorageManagerTest.swift | 41 ++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index b5b13b7aee..d0340a5376 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -187,6 +187,16 @@ 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 anonymous ID used on the first call to `getAnonymousId()` when no + /// persisted value exists. + /// + /// Set this before `setup()` to ensure events captured synchronously during initialization + /// (such as `Application Installed` / `Application Updated`) use your own ID rather than + /// the SDK's auto-generated UUID. Ignored when an anonymous ID is already persisted. + /// + /// Defaults to `nil` (use the SDK-generated UUID). + @objc public var anonymousId: String? + /// 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/PostHogSDK.swift b/PostHog/PostHogSDK.swift index b25ad2fa5e..2af0ffb776 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -241,6 +241,20 @@ let maxRetryDelay = 30.0 getStorageManagerValue { $0.getAnonymousId() } } + /// Overrides the persisted anonymous ID at runtime. + /// + /// Useful after `reset()` to seed the next anonymous session with a caller-controlled ID + /// (mirroring `posthog-js`'s `posthog.reset({ bootstrap: { distinctID } })`). No-op when + /// the SDK is not set up or when `anonymousId` is empty. + /// + /// - Parameter anonymousId: The anonymous ID to persist. + @objc public func setAnonymousId(_ anonymousId: String) { + guard isEnabled(), !anonymousId.isEmpty, let storageManager = config.storageManager else { + return + } + storageManager.setAnonymousId(anonymousId) + } + /// Returns the stable device identifier used for device-level feature flag bucketing. /// /// This ID persists across `identify()` and `reset()` calls, only changing on a fresh diff --git a/PostHog/PostHogStorageManager.swift b/PostHog/PostHogStorageManager.swift index 77881e2493..571b30b447 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 bootstrapAnonymousId: String? private var distinctId: String? private var cachedDistinctId = false @@ -32,6 +33,7 @@ public class PostHogStorageManager { init(_ config: PostHogConfig) { storage = PostHogStorage(config) idGen = config.getAnonymousId + bootstrapAnonymousId = config.anonymousId } /// Returns the persisted anonymous ID, creating one if needed. @@ -43,8 +45,12 @@ public class PostHogStorageManager { var anonymousId = storage.getString(forKey: .anonymousId) if anonymousId == nil { - let uuid = UUID.v7() - anonymousId = idGen(uuid).uuidString + if let bootstrap = bootstrapAnonymousId, !bootstrap.isEmpty { + anonymousId = bootstrap + } else { + let uuid = UUID.v7() + anonymousId = idGen(uuid).uuidString + } setAnonId(anonymousId ?? "") } else { // update the memory value diff --git a/PostHogTests/PostHogStorageManagerTest.swift b/PostHogTests/PostHogStorageManagerTest.swift index dd5964f10e..029d729611 100644 --- a/PostHogTests/PostHogStorageManagerTest.swift +++ b/PostHogTests/PostHogStorageManagerTest.swift @@ -59,6 +59,47 @@ class PostHogStorageManagerTest: QuickSpec { sut.reset(true) } + it("Uses bootstrap anonymousId from config on fresh install") { + let config = PostHogConfig(projectToken: "test_project_token") + config.anonymousId = "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" + + sut.reset(true) + } + + it("Ignores empty bootstrap anonymousId and falls back to UUID") { + let config = PostHogConfig(projectToken: "test_project_token") + config.anonymousId = "" + 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("Uses bootstrap anonymousId after reset clears the persisted value") { + let config = PostHogConfig(projectToken: "test_project_token") + config.anonymousId = "A-bootstrap-id-after-reset" + let sut = self.getSut(config) + + _ = sut.getAnonymousId() + sut.reset(false, true) // clear persisted + memory + + // Next read should pick the bootstrap value again, not generate a UUID. + expect(sut.getAnonymousId()) == "A-bootstrap-id-after-reset" + + sut.reset(true) + } + it("Uses the correct fallback value for isIdentified") { let anonymousIdToSet = UUID.v7() let distinctIdToSet = UUID.v7().uuidString From ea0e05614f9ac225bb6a74a462cc7d1b07a55771 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:58:22 -0700 Subject: [PATCH 2/2] refactor(identity): replace anonymousId with Bootstrap struct + identified gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #637 from @marandaneto: 1. Match posthog-js's `bootstrap` config shape. The previous PR exposed `config.anonymousId: String?` and a runtime `setAnonymousId(_:)` method. Both are dropped in favor of a single `config.bootstrap: PostHogBootstrap?` that mirrors the documented `bootstrap` option in posthog-js (distinctID + isIdentifiedID). This narrows the public surface to one optional config field, with room to extend later if feature-flag bootstrap is wanted. 2. Don't override an already-identified user. `applyBootstrapIfNeeded()` now also gates on `storage.getBool(.isIdentified)`. If a previous session has already run `identify(...)` and merged anon→identified, the bootstrap values are ignored entirely — bootstrap only seeds the very first session, never re-links traffic across a prior merge. 3. Support `isIdentifiedID: true`. When set, the bootstrap distinctID is treated as an already-identified user — `.anonymousId`, `.distinctId`, and `.isIdentified` are all seeded on first launch, so subsequent events are emitted on the identified person without an `$identify` merge event. Mirrors the same option in posthog-js. Tests updated to cover the new surface and both gates (persisted anon ID wins, identified state wins). `setAnonymousId(_:)` SDK method is gone — bootstrap is the only public path. --- PostHog/PostHogBootstrap.swift | 45 +++++++++++++++ PostHog/PostHogConfig.swift | 18 ++++-- PostHog/PostHogSDK.swift | 14 ----- PostHog/PostHogStorageManager.swift | 38 ++++++++++--- PostHogTests/PostHogStorageManagerTest.swift | 60 ++++++++++++++++---- 5 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 PostHog/PostHogBootstrap.swift diff --git a/PostHog/PostHogBootstrap.swift b/PostHog/PostHogBootstrap.swift new file mode 100644 index 0000000000..3aa795a41c --- /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 d0340a5376..8585ba1015 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -187,15 +187,21 @@ 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 anonymous ID used on the first call to `getAnonymousId()` when no - /// persisted value exists. + /// Pre-seeded identity applied on the very first SDK launch when no per-device state + /// has been persisted yet. /// - /// Set this before `setup()` to ensure events captured synchronously during initialization - /// (such as `Application Installed` / `Application Updated`) use your own ID rather than - /// the SDK's auto-generated UUID. Ignored when an anonymous ID is already persisted. + /// 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 anonymousId: String? + @objc public var bootstrap: PostHogBootstrap? /// Flag to reuse the anonymous Id between `reset()` and next `identify()` calls /// diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2af0ffb776..b25ad2fa5e 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -241,20 +241,6 @@ let maxRetryDelay = 30.0 getStorageManagerValue { $0.getAnonymousId() } } - /// Overrides the persisted anonymous ID at runtime. - /// - /// Useful after `reset()` to seed the next anonymous session with a caller-controlled ID - /// (mirroring `posthog-js`'s `posthog.reset({ bootstrap: { distinctID } })`). No-op when - /// the SDK is not set up or when `anonymousId` is empty. - /// - /// - Parameter anonymousId: The anonymous ID to persist. - @objc public func setAnonymousId(_ anonymousId: String) { - guard isEnabled(), !anonymousId.isEmpty, let storageManager = config.storageManager else { - return - } - storageManager.setAnonymousId(anonymousId) - } - /// Returns the stable device identifier used for device-level feature flag bucketing. /// /// This ID persists across `identify()` and `reset()` calls, only changing on a fresh diff --git a/PostHog/PostHogStorageManager.swift b/PostHog/PostHogStorageManager.swift index 571b30b447..71e9f5a241 100644 --- a/PostHog/PostHogStorageManager.swift +++ b/PostHog/PostHogStorageManager.swift @@ -21,7 +21,7 @@ public class PostHogStorageManager { private let identifiedLock = NSLock() private let personProcessingLock = NSLock() private let idGen: (UUID) -> UUID - private let bootstrapAnonymousId: String? + private let bootstrap: PostHogBootstrap? private var distinctId: String? private var cachedDistinctId = false @@ -33,7 +33,33 @@ public class PostHogStorageManager { init(_ config: PostHogConfig) { storage = PostHogStorage(config) idGen = config.getAnonymousId - bootstrapAnonymousId = config.anonymousId + 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. @@ -45,12 +71,8 @@ public class PostHogStorageManager { var anonymousId = storage.getString(forKey: .anonymousId) if anonymousId == nil { - if let bootstrap = bootstrapAnonymousId, !bootstrap.isEmpty { - anonymousId = bootstrap - } else { - let uuid = UUID.v7() - anonymousId = idGen(uuid).uuidString - } + let uuid = UUID.v7() + anonymousId = idGen(uuid).uuidString setAnonId(anonymousId ?? "") } else { // update the memory value diff --git a/PostHogTests/PostHogStorageManagerTest.swift b/PostHogTests/PostHogStorageManagerTest.swift index 029d729611..0e31f23cf0 100644 --- a/PostHogTests/PostHogStorageManagerTest.swift +++ b/PostHogTests/PostHogStorageManagerTest.swift @@ -59,9 +59,9 @@ class PostHogStorageManagerTest: QuickSpec { sut.reset(true) } - it("Uses bootstrap anonymousId from config on fresh install") { + it("Seeds the anonymous ID from bootstrap.distinctID on fresh install") { let config = PostHogConfig(projectToken: "test_project_token") - config.anonymousId = "A-bootstrap-id-123" + config.bootstrap = PostHogBootstrap(distinctID: "A-bootstrap-id-123") let sut = self.getSut(config) let anonymousId = sut.getAnonymousId() @@ -69,13 +69,15 @@ class PostHogStorageManagerTest: QuickSpec { // 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 anonymousId and falls back to UUID") { + it("Ignores empty bootstrap.distinctID and falls back to UUID") { let config = PostHogConfig(projectToken: "test_project_token") - config.anonymousId = "" + config.bootstrap = PostHogBootstrap(distinctID: "") let sut = self.getSut(config) let anonymousId = sut.getAnonymousId() @@ -86,20 +88,56 @@ class PostHogStorageManagerTest: QuickSpec { sut.reset(true) } - it("Uses bootstrap anonymousId after reset clears the persisted value") { + it("Seeds both anonymous and distinct IDs when bootstrap.isIdentifiedID is true") { let config = PostHogConfig(projectToken: "test_project_token") - config.anonymousId = "A-bootstrap-id-after-reset" + config.bootstrap = PostHogBootstrap(distinctID: "user-42", isIdentifiedID: true) let sut = self.getSut(config) - _ = sut.getAnonymousId() - sut.reset(false, true) // clear persisted + memory - - // Next read should pick the bootstrap value again, not generate a UUID. - expect(sut.getAnonymousId()) == "A-bootstrap-id-after-reset" + 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