Skip to content

Add install attribution matching support#456

Open
yusuftor wants to merge 19 commits into
developfrom
feature/mmp
Open

Add install attribution matching support#456
yusuftor wants to merge 19 commits into
developfrom
feature/mmp

Conversation

@yusuftor

@yusuftor yusuftor commented Mar 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add install attribution matching support to the iOS SDK
  • emit a typed attribution_match event and write shared acquisition_* user attributes
  • retry install attribution once after ATT is granted and filter out the all-zero IDFA

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • 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.
  • I have reviewed the contributing guide

Greptile Summary

This PR adds install attribution matching to the SDK. The main changes are:

  • MMP install matching and acquisition attribute merging.
  • A typed attribution_match event for MMP and Apple Search Ads.
  • ATT-grant retry handling for a second attribution attempt with IDFA.
  • New storage gates, endpoint plumbing, models, docs, changelog, and tests.

Confidence Score: 4/5

This is close, but the attribution lifecycle issues should be fixed before merging.

  • The post-ATT retry can overlap with the initial install match and leave attribution attributes with the older response.
  • The new completion flags can survive reset while the user attributes they gate are cleared.
  • The endpoint and event-model changes look consistent with the surrounding code.

Sources/SuperwallKit/Superwall.swift and Sources/SuperwallKit/Storage/Cache/CacheKeys.swift

Important Files Changed

Filename Overview
Sources/SuperwallKit/Superwall.swift Adds initial MMP matching and ATT-triggered retry handling, but the retry path can overlap with the initial request.
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Adds MMP attribution flags in app-specific storage, which can survive reset while the gated user attributes are cleared.
Sources/SuperwallKit/Storage/Storage.swift Adds install-attribution eligibility, completion, and attribution-window checks for MMP matching.
Sources/SuperwallKit/Network/Network.swift Adds MMP request construction, response handling, event emission, and acquisition attribute merging.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift Adds the typed attribution match event surface used by delegates and internal tracking.

Comments Outside Diff (5)

  1. Sources/SuperwallKit/Network/Network.swift, line 592-596 (link)

    P1 IDFA logged in error payload

    info: ["payload": request] passes the full MMPMatchRequest struct, which includes idfa, idfv, and deviceId, into the structured log on every failed /api/match call. Once the user grants ATT — exactly when the retry fires — a real IDFA will appear in crash/debug logs captured by any connected logging tool.

    The existing sendToken failure already follows this same pattern (info: ["payload": token]), so this isn't unique to the new code, but it's worth flagging as the IDFA is a more sensitive identifier than an AdServices token. Consider redacting or omitting the identifier fields from the error payload:

    Logger.debug(
      logLevel: .error,
      scope: .network,
      message: "Request Failed: /api/match",
      info: [
        "platform": request.platform,
        "appVersion": request.appVersion,
        "hasIdfa": request.idfa != nil,
      ],
      error: error
    )
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Network/Network.swift
    Line: 592-596
    
    Comment:
    **IDFA logged in error payload**
    
    `info: ["payload": request]` passes the full `MMPMatchRequest` struct, which includes `idfa`, `idfv`, and `deviceId`, into the structured log on every failed `/api/match` call. Once the user grants ATT — exactly when the retry fires — a real IDFA will appear in crash/debug logs captured by any connected logging tool.
    
    The existing `sendToken` failure already follows this same pattern (`info: ["payload": token]`), so this isn't unique to the new code, but it's worth flagging as the IDFA is a more sensitive identifier than an AdServices token. Consider redacting or omitting the identifier fields from the error payload:
    
    ```swift
    Logger.debug(
      logLevel: .error,
      scope: .network,
      message: "Request Failed: /api/match",
      info: [
        "platform": request.platform,
        "appVersion": request.appVersion,
        "hasIdfa": request.idfa != nil,
      ],
      error: error
    )
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/Network/Network.swift, line 587-588 (link)

    P1 matchMMPInstall returns true even on server-side "no match"

    matchMMPInstall returns true whenever the HTTP request succeeds (regardless of whether response.matched is true or false), and false only on a network error. This causes DidCompleteMMPInstallAttributionMatch to be saved even when the server returns matched: false with no attribution, which permanently prevents the initial match path from retrying on future launches.

    The tracking-permission retry path (shouldAttemptTrackingPermissionMMPInstallAttributionMatch) uses a different gate (IsEligibleForMMPInstallAttributionMatch) and will still fire correctly after ATT is granted — so the retry does work. The naming didCompleteMatch and the stored flag DidCompleteMMPInstallAttributionMatch are ambiguous because they conflate "HTTP request completed" with "attribution was found."

    Consider renaming the return value and the flag to DidCompleteMMPInstallAttributionRequest to make the intent explicit, or leaving a comment at the return true site clarifying that success here means "request processed; no need to retry the initial path."

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Network/Network.swift
    Line: 587-588
    
    Comment:
    **`matchMMPInstall` returns `true` even on server-side "no match"**
    
    `matchMMPInstall` returns `true` whenever the HTTP request succeeds (regardless of whether `response.matched` is `true` or `false`), and `false` only on a network error. This causes `DidCompleteMMPInstallAttributionMatch` to be saved even when the server returns `matched: false` with no attribution, which permanently prevents the *initial* match path from retrying on future launches.
    
    The tracking-permission retry path (`shouldAttemptTrackingPermissionMMPInstallAttributionMatch`) uses a different gate (`IsEligibleForMMPInstallAttributionMatch`) and will still fire correctly after ATT is granted — so the retry does work. The naming `didCompleteMatch` and the stored flag `DidCompleteMMPInstallAttributionMatch` are ambiguous because they conflate "HTTP request completed" with "attribution was found."
    
    Consider renaming the return value and the flag to `DidCompleteMMPInstallAttributionRequest` to make the intent explicit, or leaving a comment at the `return true` site clarifying that success here means "request processed; no need to retry the initial path."
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. Sources/SuperwallKit/Storage/Storage.swift, line 716-731 (link)

    P2 shouldAttemptInitialMMPInstallAttributionMatch skips only when BOTH conditions are true

    The early-exit guard is:

    if hadTrackedAppInstallBeforeConfigure && didCompleteMatch {
      return false
    }

    This means a fresh install (hadTrackedAppInstallBeforeConfigure == false) will always fall through to check the attribution window — even if DidCompleteMMPInstallAttributionMatch was somehow already persisted (e.g. from a previous install that wasn't fully cleaned up). In that edge case, an extra MMP request is fired unnecessarily.

    A more defensive guard would check didCompleteMatch independently of hadTrackedAppInstallBeforeConfigure:

    if didCompleteMatch {
      return false
    }

    The hadTrackedAppInstallBeforeConfigure == false path always has didCompleteMatch == false in practice, so removing the conjunction doesn't change real-world behavior while making the intent clearer.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Storage/Storage.swift
    Line: 716-731
    
    Comment:
    **`shouldAttemptInitialMMPInstallAttributionMatch` skips only when BOTH conditions are true**
    
    The early-exit guard is:
    
    ```swift
    if hadTrackedAppInstallBeforeConfigure && didCompleteMatch {
      return false
    }
    ```
    
    This means a fresh install (`hadTrackedAppInstallBeforeConfigure == false`) will *always* fall through to check the attribution window — even if `DidCompleteMMPInstallAttributionMatch` was somehow already persisted (e.g. from a previous install that wasn't fully cleaned up). In that edge case, an extra MMP request is fired unnecessarily.
    
    A more defensive guard would check `didCompleteMatch` independently of `hadTrackedAppInstallBeforeConfigure`:
    
    ```swift
    if didCompleteMatch {
      return false
    }
    ```
    
    The `hadTrackedAppInstallBeforeConfigure == false` path always has `didCompleteMatch == false` in practice, so removing the conjunction doesn't change real-world behavior while making the intent clearer.
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. Sources/SuperwallKit/Superwall.swift, line 613-618 (link)

    Initial retry race

    When ATT is granted while the initial install match is still running, this retry can start a second /api/match request with the real IDFA before the fire-and-forget initial request finishes. Both calls merge acquisition_* attributes, so the earlier pre-ATT response can finish last and overwrite the deterministic post-ATT match. The same in-flight gate needs to cover the initial request, or the retry should wait for or cancel the initial match before sending the upgraded request.

  5. Sources/SuperwallKit/Storage/Cache/CacheKeys.swift, line 62-80 (link)

    Reset keeps completion

    These new MMP flags live in .appSpecificDocuments, but Storage.reset() only clears user-specific storage. After reset() clears user attributes for a logout or new user, the persisted DidCompleteMMPInstallAttributionRequest flag can still make shouldAttemptInitialMMPInstallAttributionMatch return false on the next configure. The new user then never receives the shared acquisition_* attributes, even though the attribution values were removed during reset. Either clear these MMP flags during reset or store the completion state in the same user-scoped area as the attributes it gates.

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/Superwall.swift:613-618
**Initial retry race**

When ATT is granted while the initial install match is still running, this retry can start a second `/api/match` request with the real IDFA before the fire-and-forget initial request finishes. Both calls merge `acquisition_*` attributes, so the earlier pre-ATT response can finish last and overwrite the deterministic post-ATT match. The same in-flight gate needs to cover the initial request, or the retry should wait for or cancel the initial match before sending the upgraded request.

### Issue 2 of 2
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift:62-80
**Reset keeps completion**

These new MMP flags live in `.appSpecificDocuments`, but `Storage.reset()` only clears user-specific storage. After `reset()` clears user attributes for a logout or new user, the persisted `DidCompleteMMPInstallAttributionRequest` flag can still make `shouldAttemptInitialMMPInstallAttributionMatch` return false on the next configure. The new user then never receives the shared `acquisition_*` attributes, even though the attribution values were removed during reset. Either clear these MMP flags during reset or store the completion state in the same user-scoped area as the attributes it gates.

Reviews (13): Last reviewed commit: "Merge origin/develop into feature/mmp" | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)

Comment thread Sources/SuperwallKit/Network/Network.swift Outdated
Comment thread Sources/SuperwallKit/Network/Network.swift Outdated
Comment thread Sources/SuperwallKit/Superwall.swift
Comment thread Sources/SuperwallKit/Network/Endpoint.swift
Comment thread Sources/SuperwallKit/Network/Network.swift Outdated
Comment thread Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift Outdated
Comment thread Sources/SuperwallKit/Storage/Storage.swift
Comment thread Sources/SuperwallKit/Network/Endpoint.swift Outdated
Comment thread Sources/SuperwallKit/Network/Network.swift
yusuftor and others added 10 commits March 26, 2026 16:43
Resolves conflicts across CHANGELOG, Constants/podspec (4.16.0),
Package.resolved, and the attribution stack:
- AttributionFetcher: keep develop's zero-IDFA UUID comparison
- Network.sendToken: adopt develop's throwing AdServicesResponse API
- Superwall: rely on develop's config-gated AdServices auto-fire; keep
  MMP install-match path (hadTrackedAppInstallBeforeConfigure)
- AttributionPoster: take develop's refactored token flow and re-graft
  the .appleSearchAds AttributionMatch tracking onto it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oder

- DeviceHelper: guard screenWidth/screenHeight/devicePixelRatio behind
  #if os(visionOS) (UIScreen.main is meaningless there), matching the
  existing interfaceStyle pattern.
- SK2StoreProduct / ProductPurchaserSK2: add visionOS 26.4 to the
  billing-plan availability checks so the 26.4-only StoreKit APIs aren't
  used under a guard that only covered iOS, fixing the visionOS build.
- Storage: document that the post-ATT MMP match deliberately re-runs to
  upgrade the pre-ATT (no-IDFA) match with the real IDFA, not a bug.
- EndpointKind: add a per-kind jsonEncoder mirroring jsonDecoder; route
  all Endpoint bodies through Kind.jsonEncoder so casing follows the
  backend (core=snake_case, SubscriptionsAPI=camelCase) instead of being
  hand-picked per call site. Behaviour-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
screenWidth/screenHeight/devicePixelRatio were computed via UIScreen.main
(deprecated since iOS 16, main-thread-only) but read from the background
async matchMMPInstall, a data race under strict concurrency. Cache them
once at init on the main thread, preferring the connected UIWindowScene's
screen and falling back to UIScreen.main only when no scene is attached.
Safe to cache: the values feed only the MMP install payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move `[weak self]` from the inner `Task` to the enclosing completion
closure in `TransactionManager.observe` and `WebEntitlementRedeemer`'s
Stripe checkout `onClose` handlers, so the stored closures don't
strongly retain `self`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Decode `MMPMatchResponse.queryParams` as `[String: JSON]` instead of
  `[String: String]`. The backend returns array values when a query key
  repeats in the click URL, which previously threw and failed the whole
  response decode — silently reporting a real match as `request_failed`
  and dropping the acquisition attributes.
- Decode `confidence` leniently so an unrecognised value (e.g. a future
  tier) degrades to `nil` rather than failing the entire response.
- Document the real `matchScore` range (75-117).
- Add unit tests for the install-attribution gating logic and response
  decoding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yusuftor

Copy link
Copy Markdown
Collaborator Author

@pullfrog

Resolved conflicts:
- CHANGELOG.md: combined into a single 4.16.0 section (MMP install
  attribution + develop's annual-subscriptions / EventTrackingBehavior /
  crash & intro-offer fixes); dropped the orphan 4.15.4 heading since
  develop folded that content into the unreleased 4.16.0.
- SK2StoreProduct.swift / ProductPurchaserSK2.swift: took develop's
  broadened availability list (iOS/macOS/tvOS/watchOS/visionOS 26.4) from
  the "Fix for billing plans with visionOS" commit, a superset of the
  branch's iOS+visionOS-only check.
- Example app Package.resolved: took develop's newer RevenueCat pin (5.80.0).
- project.pbxproj: kept both new test files; regenerated via xcodegen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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