Skip to content

Capture first launch deep link for product page attribution#491

Open
yusuftor wants to merge 1 commit into
developfrom
first-launch-deep-link-attribute
Open

Capture first launch deep link for product page attribution#491
yusuftor wants to merge 1 commit into
developfrom
first-launch-deep-link-attribute

Conversation

@yusuftor

@yusuftor yusuftor commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

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 the firstDeepLinkUrl user 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 (static handleDeepLink post-config, the pre-config pendingDeepLink replay, the deprecated instance method, and the checkout web view). Capture happens at the same point the deepLink_open event is tracked, so:
    • Redemption-code URLs are excluded (they short-circuit earlier, consistent with them not producing deepLink_open).
    • Superwall app-links are captured in their mapped form, consistent with the URL tracked on deepLink_open.
  • First-session gating uses !didTrackFirstSession (install-scoped, correct for existing users upgrading the SDK — they never capture). Only the first deep link of the first session is recorded.
  • The URL is stored install-scoped (.appSpecificDocuments) and re-applied to the new user after reset()/identify(), mirroring Apple Search Ads and MMP install attribution. The re-apply deliberately avoids a sync read of identityManager.userAttributesidentify() 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 on develop and is tracked separately).
  • Attribute syncs to the backend via the existing user_attributes event pipeline, so it respects EventTrackingBehavior the same way other attribution attributes do.

Known limitations (accepted)

  • A deep link stored pre-config replays through route() when config is retrieved; if the app is backgrounded before that (flipping didTrackFirstSession), the link isn't captured.
  • The pre-config pendingDeepLink slot 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-existing CustomProductTests flake that also fails on unmodified develop (a different test in that suite fails run-to-run).

Checklist

  • All unit tests pass. (One pre-existing CustomProductTests flake fails intermittently on develop too — unrelated to this change.)
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs. (SDK doc comments updated; online docs need a note about firstDeepLinkUrl for custom product page attribution.)
  • I have reviewed the contributing guide

🤖 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 as firstDeepLinkUrl; it is re-applied after reset()/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 the deepLink_open event.
  • 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.
  • 10 new Swift Testing tests cover storage gating, router capture, mapped-URL capture, redemption-link exclusion, and reset re-apply; the @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

Filename Overview
Sources/SuperwallKit/DeepLinkRouter.swift Adds captureFirstSessionDeepLink and reapplyFirstSessionDeepLinkAttribute; logic and thread-safety reasoning are sound; deadlock risk for reapply is correctly documented and avoided
Sources/SuperwallKit/Storage/Storage.swift Adds recordFirstSessionDeepLink(_:) using queue.sync with disk I/O; correct atomicity but blocks calling thread (typically main) on every deep link during the first session, not just the first
Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift Good coverage of storage gating and router capture; router tests rely on 500 ms Task.sleep for async attribute-merge synchronization, which is fragile on loaded CI
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Adds FirstSessionDeepLinkURL storable with correct .appSpecificDocuments directory, ensuring it survives user resets
Sources/SuperwallKit/Superwall.swift Adds reapplyFirstSessionDeepLinkAttribute() call in reset(duringIdentify:) mirroring the ASA and MMP re-apply pattern; doc comment updated for handleDeepLink
Sources/SuperwallKit/Dependencies/DependencyContainer.swift Passes storage and identityManager into DeepLinkRouter constructor; straightforward wiring

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])
Loading
%%{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])
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
Sources/SuperwallKit/Storage/Storage.swift:365-377
**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.

### Issue 2 of 2
Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift:113
**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.

Reviews (1): Last reviewed commit: "Capture first-session deep link as first..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

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>
@linear

linear Bot commented Jul 20, 2026

Copy link
Copy Markdown

SW-5241

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 pointDeepLinkRouter.route(url:) now calls a new private captureFirstSessionDeepLink(_:) at the same point deepLink_open is 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 storageStorage.recordFirstSessionDeepLink(_:) records under the storage serial queue, gated on _didTrackFirstSession and any already-stored value, returning true only when it actually writes.
  • Install-scoped persistence — new FirstSessionDeepLinkURL cache key in .appSpecificDocuments, which survives reset() (confirmed cleanDiskCache() only wipes the cache + user-specific document dirs).
  • Re-apply after resetreapplyFirstSessionDeepLinkAttribute() is invoked from Superwall.reset(duringIdentify:) alongside the ASA/MMP reapply calls, deliberately avoiding a sync read of userAttributes to prevent a deadlock on the identity queue.
  • Tests + version bump — 10 Swift Testing cases in FirstSessionDeepLinkTests.swift; 4.16.24.16.3 across Constants.swift, podspec, and CHANGELOG.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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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.
///

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.

Comment on lines +365 to +377
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
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant