Skip to content

Add install attribution matching support#456

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

Add install attribution matching support#456
yusuftor wants to merge 18 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 introduces install attribution matching: on first launch the SDK fires a probabilistic /api/match request to the MMP service, then re-fires after ATT is granted to upgrade to a deterministic match using a real IDFA. An attribution_match event is emitted and matching acquisition_* values are written to user attributes.

  • Persistent flags (IsEligibleForMMPInstallAttributionMatch, DidCompleteMMPInstallAttributionRequest, DidCompleteMMPInstallAttributionRequestAfterTrackingPermission) gate the initial and ATT-retry paths with a 7-day attribution window, and the new TrackingPermissionMMPRetryGate actor prevents duplicate in-session retry tasks.
  • SuperwallEventObjc.attributionMatch is correctly appended after the previous last case (paywallPageView), preserving all existing raw Int values; debugPrint calls from earlier iterations have been replaced with Logger.debug; deviceId is now sourced from factory.makeDeviceId() rather than reusing vendorId.
  • One concurrency gap remains: the fire-and-forget initial match Task and the ATT-retry Task are not mutually exclusive, so granting ATT while the first request is still in-flight can produce two concurrent /api/match calls and two attribution_match events in the same session.

Confidence Score: 4/5

Safe to merge after addressing the concurrent initial-match + ATT-retry gap in Superwall.swift; all other changes are well-structured.

The attribution gating logic in Storage.swift is solid — persistent flags, 7-day window, and actor-based retry gate all work correctly for the scenarios they cover. The one gap is in Superwall.swift: the fire-and-forget Task from recordMMPInstallAttributionMatch and the ATT-retry Task are not mutually exclusive, so granting tracking permission while the first /api/match request is still in-flight produces two concurrent requests and two attribution_match events in the same session.

Sources/SuperwallKit/Superwall.swift — the configure() Task and retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded() need to share a single exclusion mechanism (e.g., extend TrackingPermissionMMPRetryGate to also cover the initial match).

Important Files Changed

Filename Overview
Sources/SuperwallKit/Superwall.swift Adds MMP attribution matching to configure(), ATT notification listeners, and a TrackingPermissionMMPRetryGate actor to deduplicate retry calls — but the initial match Task and the ATT-retry Task can still race concurrently.
Sources/SuperwallKit/Storage/Storage.swift Adds MMP attribution gating logic with persistent flags (eligible, completed-initial, completed-after-ATT) and a 7-day window check; the IsEligibleForMMPInstallAttributionMatch flag correctly enables retries after transient failures.
Sources/SuperwallKit/Network/Network.swift Adds matchMMPInstall() with proper Logger.debug calls, deviceId from factory.makeDeviceId() (separate from vendorId/idfv), and String(describing:) attribute comparison; all previous review concerns addressed.
Sources/SuperwallKit/Models/AdServicesResponse.swift Adds MMPMatchRequest (Encodable) and MMPMatchResponse with lenient confidence decoding (try?) and JSON-typed queryParams to handle array-valued query keys.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift attributionMatch is correctly appended after paywallPageView (the previous last case), preserving all existing Int raw values.
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Adds screenWidth/screenHeight/devicePixelRatio cached at init via DispatchQueue.main.sync when called off main thread — correct UIKit thread-safety pattern.
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Adds three new Storable cache key types for MMP attribution gating with explicit, stable key strings to survive SDK upgrades.
Sources/SuperwallKit/Network/EndpointKind.swift Adds jsonEncoder to EndpointKind protocol and concrete kinds; SubscriptionsAPI uses plain JSONEncoder() (camelCase) consistent with existing redeem/poll endpoints.
Tests/SuperwallKitTests/Storage/MMPInstallAttributionTests.swift New test suite covering all gating paths: fresh install, already-completed, upgrader without eligibility, returning eligible session, outside window, ATT retry deduplication.
Tests/SuperwallKitTests/Models/MMPMatchResponseTests.swift Tests array-valued queryParams decoding, unknown confidence degradation to nil, and all three known confidence levels.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant Superwall
    participant Storage
    participant Network
    participant ATT as ATTrackingManager

    App->>Superwall: configure()
    Superwall->>Storage: hasTrackedAppInstall()
    Superwall->>Storage: recordAppInstall()
    Superwall->>Storage: shouldAttemptInitialMMPInstallAttributionMatch()
    Storage-->>Superwall: "true (sets IsEligible=true)"
    Superwall->>Storage: "recordMMPInstallAttributionMatch { Task A }"
    Note over Superwall,Network: Task A fires concurrently (fire-and-forget)
    Task A->>Network: matchMMPInstall(idfa: nil/denied)
    Superwall->>Superwall: await fetchConfig

    App->>ATT: requestTrackingAuthorization()
    ATT-->>App: .authorized
    ATT-->>Superwall: superwallTrackingPermissionGranted notification
    Superwall->>Superwall: retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded()
    Superwall->>Superwall: gate.tryBegin() → true
    Superwall->>Storage: shouldAttemptTrackingPermissionMMP() → true
    Superwall->>Network: matchMMPInstall(idfa: realIDFA) [Task B]

    Note over Network: ⚠️ Task A + Task B may run concurrently

    Network-->>Superwall: response (Task A)
    Superwall->>Storage: "save DidCompleteMMPInstallAttributionRequest=true"
    Superwall->>Superwall: track AttributionMatch (event 1)

    Network-->>Superwall: response (Task B)
    Superwall->>Storage: "save DidCompleteAfterTrackingPermission=true"
    Superwall->>Superwall: track AttributionMatch (event 2)
    Superwall->>Superwall: gate.finish(didComplete: true)
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 Superwall
    participant Storage
    participant Network
    participant ATT as ATTrackingManager

    App->>Superwall: configure()
    Superwall->>Storage: hasTrackedAppInstall()
    Superwall->>Storage: recordAppInstall()
    Superwall->>Storage: shouldAttemptInitialMMPInstallAttributionMatch()
    Storage-->>Superwall: "true (sets IsEligible=true)"
    Superwall->>Storage: "recordMMPInstallAttributionMatch { Task A }"
    Note over Superwall,Network: Task A fires concurrently (fire-and-forget)
    Task A->>Network: matchMMPInstall(idfa: nil/denied)
    Superwall->>Superwall: await fetchConfig

    App->>ATT: requestTrackingAuthorization()
    ATT-->>App: .authorized
    ATT-->>Superwall: superwallTrackingPermissionGranted notification
    Superwall->>Superwall: retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded()
    Superwall->>Superwall: gate.tryBegin() → true
    Superwall->>Storage: shouldAttemptTrackingPermissionMMP() → true
    Superwall->>Network: matchMMPInstall(idfa: realIDFA) [Task B]

    Note over Network: ⚠️ Task A + Task B may run concurrently

    Network-->>Superwall: response (Task A)
    Superwall->>Storage: "save DidCompleteMMPInstallAttributionRequest=true"
    Superwall->>Superwall: track AttributionMatch (event 1)

    Network-->>Superwall: response (Task B)
    Superwall->>Storage: "save DidCompleteAfterTrackingPermission=true"
    Superwall->>Superwall: track AttributionMatch (event 2)
    Superwall->>Superwall: gate.finish(didComplete: true)
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 (12): Last reviewed commit: "Update Package.resolved" | 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 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

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