Skip to content

Add install attribution matching support#456

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

Add install attribution matching support#456
yusuftor wants to merge 15 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 support to the iOS SDK. When MMP integrations are configured on the Superwall dashboard, the SDK attempts to match each install against a performance marketing campaign by posting device fingerprint data to a new /api/match endpoint, and optionally re-runs the match with a real IDFA once ATT permission is granted.

  • Apple Search Ads path (AttributionPoster): fires a typed attribution_match event after the existing AdServices token flow completes, and sets acquisition_* user attributes on a successful match.
  • MMP path (Network.matchMMPInstall): fires on configure, gates duplicate calls with persistent flags and a 7-day attribution window, and retries with IDFA once ATT is granted using a new TrackingPermissionMMPRetryGate Swift actor to prevent concurrent duplicate requests.
  • New public API: AttributionMatchInfo struct and SuperwallEvent.attributionMatch(info:) case exposed to SDK consumers via the delegate.

Confidence Score: 4/5

The new attribution matching feature is reasonably safe to merge; the main orchestration paths are well-gated by persistent flags and an actor-based concurrency guard, and all previously-raised thread concerns appear resolved in the current code.

The feature touches a non-trivial coordination path: two attribution providers, two independent storage flags, an actor retry gate, and lifecycle notifications for ATT. The implementation correctly handles idempotency, the 7-day attribution window, and race conditions. No new functional defects were found beyond what was already addressed in previous review threads. The gap between the initial match flag and the ATT-retry flag means a failed initial attempt followed by a successful ATT retry still re-issues the initial request on the next launch — a harmless extra network call given server idempotency, but worth keeping in mind if request volume becomes a concern.

Sources/SuperwallKit/Storage/Storage.swift and Sources/SuperwallKit/Superwall.swift warrant the closest attention — they carry the flag-gating logic and the ATT notification coordination that together determine whether attribution fires zero, one, or two times per device lifecycle.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Superwall.swift Adds MMP match orchestration on configure, notification listeners for ATT grant/foreground events, and a Swift actor gate (TrackingPermissionMMPRetryGate) to prevent concurrent duplicate retries. Race condition handling is well-designed.
Sources/SuperwallKit/Network/Network.swift Adds matchMMPInstall() and mergeMMPAcquisitionAttributesIfNeeded(); uses Logger throughout; deviceId now sourced from factory.makeDeviceId() rather than vendorId.
Sources/SuperwallKit/Storage/Storage.swift Adds gating logic for initial and ATT-based MMP attribution attempts with a 7-day window check; flag semantics are correct and persistent across launches.
Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift Adds attribution_match event emission to the Apple Search Ads path; correctly handles matched/unmatched/cancelled/failed states with appropriate event payloads.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift Adds public AttributionMatchInfo struct and attributionMatch(info:) case; new case is appended safely without affecting Swift enum layout.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift attributionMatch appended as the last case of the Int-backed ObjC enum, preserving all existing raw integer values for downstream consumers.
Sources/SuperwallKit/Network/Endpoint.swift Adds matchMMPInstall endpoint using EndpointKinds.SubscriptionsAPI with host:.mmp; developer confirmed MMP service shares the subscriptions API contract.
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Three new Storable keys for MMP attribution flags stored under appSpecificDocuments; key strings are unique and well-named.

Sequence Diagram

sequenceDiagram
    participant App
    participant Superwall
    participant Storage
    participant Network
    participant ATT as ATT/PermissionHandler
    participant Server as MMP Server

    App->>Superwall: configure()
    Superwall->>Storage: hasTrackedAppInstall()
    Superwall->>Storage: recordAppInstall()
    Superwall->>Storage: shouldAttemptInitialMMPInstallAttributionMatch()
    alt Eligible (window open, not completed)
        Storage-->>Superwall: true
        Superwall->>ATT: checkTrackingPermission()
        ATT-->>Superwall: status
        Superwall->>Storage: recordMMPInstallAttributionMatch
        Storage->>Network: matchMMPInstall(idfa: nil or real)
        Network->>Server: POST /api/match
        Server-->>Network: MMPMatchResponse
        Network->>Superwall: track(AttributionMatch)
        Network-->>Storage: true
        Storage->>Storage: save(DidCompleteMMPInstallAttributionRequest)
    end
    Note over App,Superwall: User grants ATT permission
    App->>Superwall: ATT granted
    Superwall->>Superwall: TrackingPermissionMMPRetryGate.tryBegin()
    Superwall->>Storage: shouldAttemptTrackingPermissionMMPInstallAttributionMatch()
    alt Eligible
        Superwall->>Network: matchMMPInstall(idfa: realIDFA)
        Network->>Server: POST /api/match (with IDFA)
        Server-->>Network: MMPMatchResponse
        Network->>Superwall: track(AttributionMatch)
        Superwall->>Storage: save(DidCompleteMMPInstallAttributionRequestAfterTrackingPermission)
        Superwall->>Superwall: TrackingPermissionMMPRetryGate.finish(completed)
    end
Loading

Comments Outside Diff (3)

  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.

Reviews (11): Last reviewed commit: "Cache screen metrics at init instead of ..." | Re-trigger Greptile

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