feat(identity): bootstrap anonymousId via config and runtime setter#637
feat(identity): bootstrap anonymousId via config and runtime setter#637tsushanth wants to merge 1 commit into
Conversation
Adds two surfaces for caller-supplied anonymous IDs, mirroring posthog-js behaviour requested in PostHog#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 PostHog#471
|
| 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) | ||
| } |
There was a problem hiding this comment.
Non-parameterised tests for bootstrap value handling
The first two new specs ("Uses bootstrap anonymousId…" and "Ignores empty bootstrap…") differ only in the input value and the expected outcome. Per the project's preference for parameterised tests, these could be expressed as a single ParameterizedTest or Quick sharedExamples block driven by ("A-bootstrap-id-123", "A-bootstrap-id-123") and ("", nil) entries, making it easy to add more sentinel/edge-case inputs later without duplicating the setup and teardown code.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: PostHogTests/PostHogStorageManagerTest.swift
Line: 62-87
Comment:
**Non-parameterised tests for bootstrap value handling**
The first two new specs ("Uses bootstrap anonymousId…" and "Ignores empty bootstrap…") differ only in the input value and the expected outcome. Per the project's preference for parameterised tests, these could be expressed as a single `ParameterizedTest` or Quick `sharedExamples` block driven by `("A-bootstrap-id-123", "A-bootstrap-id-123")` and `("", nil)` entries, making it easy to add more sentinel/edge-case inputs later without duplicating the setup and teardown code.
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
dustinbyrne
left a comment
There was a problem hiding this comment.
Hey @tsushanth, thank you for the pull request!
You mentioned that application lifecycle events are being recorded with an anonymous identifier. Once identify() is called, those anonymous events should become linked with the identified person. It sounds like this isn't what you want, so out of curiosity, could you describe what the specific gap is? It'd be helpful for me to understand if there's a particular usage pattern that we're not accounting for here.
|
Hey @dustinbyrne — thanks for the look. You're right that once 1. Cross-SDK / cross-platform funnels. Apps that ship a backend or a web SDK alongside iOS often want a single anonymous identity across all three before the user signs in. With 2. Stable feature-flag bucketing pre-identify. Feature flags evaluated before sign-in bucket by the anonymous ID. When that ID is deterministic from the app's own auth/install attribution layer, A/B exposures are stable across reinstalls (where the iOS SDK would otherwise mint a new UUID and shuffle the bucket). 3. The PR is the matching iOS surface for the Re: the Greptile note about Two cleanish options if you'd like me to follow up:
Happy to push either as a follow-up commit on this branch — let me know your preference. |
|
Hi @tsushanth, thanks for that information! What I'm hoping to understand is your specific use case in which
iOS and Android both have Sorry for all the questions, I'm mainly trying to understand if this is part of a broader workflow we don't have great support for, or if it's more of a targeted fix specifically in response to #471 |
|
Fair point — The hook's signature constrains the anonymous ID to a
In each of those, I want PostHog's first event from iOS to carry that exact string as Concrete shape: // Web has already set the user as anonymous_id = "A-r3K7-cookie-2A8F"
// and handed it to iOS via a Universal Link.
let cookieFromWeb = deepLink.queryItem("anon") // "A-r3K7-cookie-2A8F"
let config = PostHogConfig(apiKey: …)
config.anonymousId = cookieFromWeb // ← what this PR adds
// First event from iOS carries $distinct_id = "A-r3K7-cookie-2A8F", same as webWithout that, the same flow needs either (a) a synchronous Scope honesty: yes, this is targeted at #471 — but the issue itself was filed against the same gap (the reporter wanted to seed an ID matching their web/server tracking on init). I think it generalises cleanly because it mirrors what If you'd rather not expand the iOS surface and the recommended path is "use |
|
i think the public api should match https://posthog.com/docs/feature-flags/bootstrapping |
|
another concern (is it?) is if the user is already identified with a stable distinct id and a merged anon id, and then someone starts using the bootstraped distinct if after the user is identified, not sure if this would mess with the user merging |
Why
Closes #471 (and mirrors what `posthog-js#862` is asking for on the JS side).
Two real users on #471 hit the same wall: `Application Installed` and `Application Updated` are captured synchronously during `setup()`, before there's any chance to call `identify()` or to mutate `PostHogStorageManager` (which is public for backwards compatibility but not really intended for app code). Those lifecycle events end up tied to the SDK's auto-generated UUID instead of the caller's account ID.
What
Two additive surfaces. Both honour empty-string as a no-op so a sentinel can't accidentally clobber the stored value.
1. `PostHogConfig.anonymousId: String?` — set before `setup()`. On the first call to `getAnonymousId()`, if nothing is persisted, the SDK uses this value instead of generating a UUID v7. Pre-existing `PostHogConfig.getAnonymousId` (UUID → UUID hook) is unaffected.
```swift
let config = PostHogConfig(apiKey: "phc_...")
config.anonymousId = "A-\(myUserId)"
PostHogSDK.shared.setup(config)
// "Application Installed" now reports anonymousId = "A-..."
```
2. `PostHogSDK.setAnonymousId(_:)` — runtime override, intended to seed the next anonymous session after `reset()`:
```swift
PostHogSDK.shared.reset()
PostHogSDK.shared.setAnonymousId("A-guest-\(UUID().uuidString)")
```
This is the iOS equivalent of `posthog.reset({ bootstrap: { distinctID } })` from the JS proposal in posthog-js#862.
Tests
Three new specs in `PostHogStorageManagerTest`:
Note: `swift test` is currently broken on `main` (pre-existing test target rot, see commit 853e716 — "ci: run the iOS test target on a simulator and fix the rot it surfaced"). The SDK target builds clean; the new tests follow the existing Quick/Nimble pattern and should pass on the iOS-simulator CI path.
Compatibility