Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions PostHog/PostHogBootstrap.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
16 changes: 16 additions & 0 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions PostHog/PostHogStorageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
79 changes: 79 additions & 0 deletions PostHogTests/PostHogStorageManagerTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down