Capture first launch deep link for product page attribution#491
Capture first launch deep link for product page attribution#491yusuftor wants to merge 1 commit into
Conversation
App Store custom product pages (iOS 18+) can each specify a deep link that's delivered on the first app open after install. When Superwall.handleDeepLink routes a deep link during the first app session, the SDK now stores it install-scoped and merges a firstDeepLinkUrl user attribute so campaign results can be broken down and audiences filtered by the product page that drove the install. The stored URL survives identity resets and is re-applied to the new user after reset(), mirroring Apple Search Ads and MMP install attribution. Redemption-code deep links are excluded, consistent with them not producing a deepLink_open event. Closes SW-5241. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — two minor observations inline.
Reviewed changes — a well-scoped feature that captures the first deep link of the first app session as the firstDeepLinkUrl user attribute for App Store custom product page attribution.
- Capture at the router choke point —
DeepLinkRouter.route(url:)now calls a new privatecaptureFirstSessionDeepLink(_:)at the same pointdeepLink_openis tracked, so redemption-code URLs are excluded (they short-circuit earlier) and Superwall app-links are captured in their mapped form. - Session-gated, first-wins storage —
Storage.recordFirstSessionDeepLink(_:)records under the storage serial queue, gated on_didTrackFirstSessionand any already-stored value, returningtrueonly when it actually writes. - Install-scoped persistence — new
FirstSessionDeepLinkURLcache key in.appSpecificDocuments, which survivesreset()(confirmedcleanDiskCache()only wipes the cache + user-specific document dirs). - Re-apply after reset —
reapplyFirstSessionDeepLinkAttribute()is invoked fromSuperwall.reset(duringIdentify:)alongside the ASA/MMP reapply calls, deliberately avoiding a sync read ofuserAttributesto prevent a deadlock on the identity queue. - Tests + version bump — 10 Swift Testing cases in
FirstSessionDeepLinkTests.swift;4.16.2→4.16.3acrossConstants.swift, podspec, andCHANGELOG.md.
I verified the load-bearing claims directly against the code: the deadlock-avoidance reasoning in reapplyFirstSessionDeepLinkAttribute is correct (mergeUserAttributes is queue.async, while the userAttributes getter is queue.sync on the identity queue that identify()-triggered reset runs on), the install-scoped value genuinely survives reset(), the reset re-apply ordering mirrors the existing MMP/ASA pattern, and redemption URLs are correctly excluded from capture. No blocking or must-address issues.
Claude Opus | 𝕏
| /// also saved as the `firstDeepLinkUrl` user attribute. If you assign deep links | ||
| /// to your App Store custom product pages, you can use this attribute to break | ||
| /// down results and filter audiences by the product page that drove the install. | ||
| /// |
There was a problem hiding this comment.
ℹ️ The static handleDeepLink(_:) overload gets this firstDeepLinkUrl doc note, but the deprecated instance handleDeepLink(_:) just above (line 1039) routes through the same capture path and didn't get it. Minor doc inconsistency worth mirroring, since both surfaces trigger the same behavior.
| func recordFirstSessionDeepLink(_ url: URL) -> Bool { | ||
| queue.sync { | ||
| if _didTrackFirstSession { | ||
| return false | ||
| } | ||
| if get(FirstSessionDeepLinkURL.self) != nil { | ||
| return false | ||
| } | ||
| save(url.absoluteString, forType: FirstSessionDeepLinkURL.self) | ||
| return true | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Repeated disk read on every first-session deep link
queue.sync is used to atomically check-and-write, which is correct. However, once the URL is stored (get() returns non-nil), every subsequent deep link during the first session still enters the sync block and performs a cache.read() disk read before returning false. The existing pattern for similar flags (e.g., _didTrackFirstSeen, _didTrackFirstSession) keeps an in-memory shadow variable that eliminates the disk round-trip on hot paths. Adding a private var _didRecordFirstSessionDeepLink = false (initialized from cache.read(FirstSessionDeepLinkURL.self) != nil in init, and set to true after the first successful write) would make all subsequent calls a pure in-memory check with no disk I/O.
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Storage/Storage.swift
Line: 365-377
Comment:
**Repeated disk read on every first-session deep link**
`queue.sync` is used to atomically check-and-write, which is correct. However, once the URL is stored (`get()` returns non-nil), every subsequent deep link during the first session still enters the sync block and performs a `cache.read()` disk read before returning `false`. The existing pattern for similar flags (e.g., `_didTrackFirstSeen`, `_didTrackFirstSession`) keeps an in-memory shadow variable that eliminates the disk round-trip on hot paths. Adding a `private var _didRecordFirstSessionDeepLink = false` (initialized from `cache.read(FirstSessionDeepLinkURL.self) != nil` in `init`, and set to `true` after the first successful write) would make all subsequent calls a pure in-memory check with no disk I/O.
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!
| router.route(url: url) | ||
| try await Task.sleep(nanoseconds: 500_000_000) | ||
|
|
||
| #expect(storage.get(FirstSessionDeepLinkURL.self) == url.absoluteString) |
There was a problem hiding this comment.
Fixed sleep used as async-completion barrier
Task.sleep(nanoseconds: 500_000_000) is used throughout the router tests to wait for mergeUserAttributes to propagate on the identity queue. This is fragile on loaded CI machines — a slow machine can take longer than 500 ms to schedule the async merge, causing spurious failures. Since userAttributes is already a queue.sync getter on the identity queue, wrapping the assertion in a helper that polls storedAttribute with a short sleep loop (or using withCheckedContinuation) would make the synchronization deterministic. The same pattern is used in all five router tests.
Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift
Line: 113
Comment:
**Fixed sleep used as async-completion barrier**
`Task.sleep(nanoseconds: 500_000_000)` is used throughout the router tests to wait for `mergeUserAttributes` to propagate on the identity queue. This is fragile on loaded CI machines — a slow machine can take longer than 500 ms to schedule the async merge, causing spurious failures. Since `userAttributes` is already a `queue.sync` getter on the identity queue, wrapping the assertion in a helper that polls `storedAttribute` with a short sleep loop (or using `withCheckedContinuation`) would make the synchronization deterministic. The same pattern is used in all five router tests.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Implements SW-5241: App Store custom product pages (iOS 18+) can each specify a deep link that's delivered on the first app open after install. If
Superwall.handleDeepLink(_:)is set up, the SDK now captures the first deep link received during the first app session and saves it as thefirstDeepLinkUrluser attribute, so results can be broken down and audiences filtered by the product page that drove the install.How it works
DeepLinkRouter.route(url:)is the single choke point every deep link flows through (statichandleDeepLinkpost-config, the pre-configpendingDeepLinkreplay, the deprecated instance method, and the checkout web view). Capture happens at the same point thedeepLink_openevent is tracked, so:deepLink_open).deepLink_open.!didTrackFirstSession(install-scoped, correct for existing users upgrading the SDK — they never capture). Only the first deep link of the first session is recorded..appSpecificDocuments) and re-applied to the new user afterreset()/identify(), mirroring Apple Search Ads and MMP install attribution. The re-apply deliberately avoids a sync read ofidentityManager.userAttributes—identify()triggers reset from the identity serial queue, so a sync read would deadlock (an adversarial review caught this; the same latent pattern exists in the MMP reapply path ondevelopand is tracked separately).user_attributesevent pipeline, so it respectsEventTrackingBehaviorthe same way other attribution attributes do.Known limitations (accepted)
route()when config is retrieved; if the app is backgrounded before that (flippingdidTrackFirstSession), the link isn't captured.pendingDeepLinkslot is last-wins (pre-existing behavior, unchanged).Tests
10 new Swift Testing tests in
FirstSessionDeepLinkTests.swift: storage gating (first-session records once, first-wins, no capture after first session), router capture → user attribute merge, mapped-URL capture for Superwall app-links, redemption-link exclusion, and reset re-apply. Full suite run on iPhone 17 Pro (iOS 26.5): 879 tests in 94 suites — all pass except one pre-existingCustomProductTestsflake that also fails on unmodifieddevelop(a different test in that suite fails run-to-run).Checklist
CustomProductTestsflake fails intermittently ondeveloptoo — unrelated to this change.)CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.firstDeepLinkUrlfor custom product page attribution.)🤖 Generated with Claude Code
Greptile Summary
Implements first-launch deep link capture for App Store custom product page attribution (SW-5241). When
Superwall.handleDeepLink(_:)is called, the first deep link URL received during the first app session is stored install-scoped (.appSpecificDocuments) and merged into the user's attributes asfirstDeepLinkUrl; it is re-applied afterreset()/identify(), mirroring the existing ASA and MMP attribution re-apply pattern.DeepLinkRouter.route()is the single capture point — redemption-code URLs short-circuit before capture and Superwall app-links are captured in their mapped form, consistent with thedeepLink_openevent.Storage.recordFirstSessionDeepLink()gates capture on!_didTrackFirstSession(set when the app backgrounds) and a first-wins disk guard, ensuring only one URL is ever stored per install.@Suite(.serialized)annotation prevents parallel interference between the stateful router tests.Confidence Score: 4/5
Safe to merge; the core capture, gating, and re-apply logic is correct and well-tested.
The atomicity design in recordFirstSessionDeepLink is sound — serial-queue ordering guarantees that _didTrackFirstSession is observed consistently even when recordFirstSessionTracked is dispatched async. The install-scoped storage key correctly survives cleanUserFiles, and the deadlock risk in reapplyFirstSessionDeepLinkAttribute is correctly avoided and documented. The two remaining concerns are that every deep link during the first session triggers a synchronous disk read on the calling thread (unlike the in-memory shadow-variable pattern used elsewhere), and that the router tests use fixed 500 ms sleeps rather than queue-ordered synchronization, leaving them potentially flaky under CI load.
The recordFirstSessionDeepLink method in Storage.swift and the router integration tests in FirstSessionDeepLinkTests.swift would benefit from a second look before merging.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant App participant DeepLinkRouter participant Storage participant IdentityManager App->>DeepLinkRouter: route(url:) alt redemption-code URL DeepLinkRouter-->>App: true (early return, no capture) else normal / Superwall app-link URL DeepLinkRouter->>DeepLinkRouter: map URL if Superwall app-link DeepLinkRouter->>Storage: recordFirstSessionDeepLink(url) [queue.sync] alt first session AND no URL stored yet Storage->>Storage: cache.write(url, FirstSessionDeepLinkURL) Storage-->>DeepLinkRouter: true DeepLinkRouter->>IdentityManager: mergeUserAttributes(["firstDeepLinkUrl": url]) [queue.async] else already recorded or session ended Storage-->>DeepLinkRouter: false (no-op) end DeepLinkRouter->>DeepLinkRouter: track deepLink_open event DeepLinkRouter-->>App: bool end Note over App,IdentityManager: On reset / identify... App->>DeepLinkRouter: reapplyFirstSessionDeepLinkAttribute() DeepLinkRouter->>Storage: get(FirstSessionDeepLinkURL) Storage-->>DeepLinkRouter: urlString (if stored) DeepLinkRouter->>IdentityManager: mergeUserAttributes(["firstDeepLinkUrl": urlString])%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant App participant DeepLinkRouter participant Storage participant IdentityManager App->>DeepLinkRouter: route(url:) alt redemption-code URL DeepLinkRouter-->>App: true (early return, no capture) else normal / Superwall app-link URL DeepLinkRouter->>DeepLinkRouter: map URL if Superwall app-link DeepLinkRouter->>Storage: recordFirstSessionDeepLink(url) [queue.sync] alt first session AND no URL stored yet Storage->>Storage: cache.write(url, FirstSessionDeepLinkURL) Storage-->>DeepLinkRouter: true DeepLinkRouter->>IdentityManager: mergeUserAttributes(["firstDeepLinkUrl": url]) [queue.async] else already recorded or session ended Storage-->>DeepLinkRouter: false (no-op) end DeepLinkRouter->>DeepLinkRouter: track deepLink_open event DeepLinkRouter-->>App: bool end Note over App,IdentityManager: On reset / identify... App->>DeepLinkRouter: reapplyFirstSessionDeepLinkAttribute() DeepLinkRouter->>Storage: get(FirstSessionDeepLinkURL) Storage-->>DeepLinkRouter: urlString (if stored) DeepLinkRouter->>IdentityManager: mergeUserAttributes(["firstDeepLinkUrl": urlString])Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Capture first-session deep link as first..." | Re-trigger Greptile