Add install attribution matching support#456
Open
yusuftor wants to merge 18 commits into
Open
Conversation
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>
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
attribution_matchevent and write sharedacquisition_*user attributesChecklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.Greptile Summary
This PR introduces install attribution matching: on first launch the SDK fires a probabilistic
/api/matchrequest to the MMP service, then re-fires after ATT is granted to upgrade to a deterministic match using a real IDFA. Anattribution_matchevent is emitted and matchingacquisition_*values are written to user attributes.IsEligibleForMMPInstallAttributionMatch,DidCompleteMMPInstallAttributionRequest,DidCompleteMMPInstallAttributionRequestAfterTrackingPermission) gate the initial and ATT-retry paths with a 7-day attribution window, and the newTrackingPermissionMMPRetryGateactor prevents duplicate in-session retry tasks.SuperwallEventObjc.attributionMatchis correctly appended after the previous last case (paywallPageView), preserving all existing rawIntvalues;debugPrintcalls from earlier iterations have been replaced withLogger.debug;deviceIdis now sourced fromfactory.makeDeviceId()rather than reusingvendorId./api/matchcalls and twoattribution_matchevents 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
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)%%{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)Comments Outside Diff (3)
Sources/SuperwallKit/Network/Network.swift, line 592-596 (link)info: ["payload": request]passes the fullMMPMatchRequeststruct, which includesidfa,idfv, anddeviceId, into the structured log on every failed/api/matchcall. 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
sendTokenfailure 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:Prompt To Fix With AI
Sources/SuperwallKit/Network/Network.swift, line 587-588 (link)matchMMPInstallreturnstrueeven on server-side "no match"matchMMPInstallreturnstruewhenever the HTTP request succeeds (regardless of whetherresponse.matchedistrueorfalse), andfalseonly on a network error. This causesDidCompleteMMPInstallAttributionMatchto be saved even when the server returnsmatched: falsewith 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 namingdidCompleteMatchand the stored flagDidCompleteMMPInstallAttributionMatchare ambiguous because they conflate "HTTP request completed" with "attribution was found."Consider renaming the return value and the flag to
DidCompleteMMPInstallAttributionRequestto make the intent explicit, or leaving a comment at thereturn truesite clarifying that success here means "request processed; no need to retry the initial path."Prompt To Fix With AI
Sources/SuperwallKit/Storage/Storage.swift, line 716-731 (link)shouldAttemptInitialMMPInstallAttributionMatchskips only when BOTH conditions are trueThe early-exit guard is:
This means a fresh install (
hadTrackedAppInstallBeforeConfigure == false) will always fall through to check the attribution window — even ifDidCompleteMMPInstallAttributionMatchwas 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
didCompleteMatchindependently ofhadTrackedAppInstallBeforeConfigure:The
hadTrackedAppInstallBeforeConfigure == falsepath always hasdidCompleteMatch == falsein practice, so removing the conjunction doesn't change real-world behavior while making the intent clearer.Prompt To Fix With AI
Reviews (12): Last reviewed commit: "Update Package.resolved" | Re-trigger Greptile