Skip to content

4.15.2#471

Merged
yusuftor merged 27 commits into
masterfrom
develop
May 14, 2026
Merged

4.15.2#471
yusuftor merged 27 commits into
masterfrom
develop

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented May 14, 2026

4.15.2

Enhancements

  • Improves Apple Search Ads attribution capture rate.
  • Filters out the all-zeros IDFA sentinel (returned when App Tracking Transparency is denied) so it no longer pollutes the idfa attribute on attribution payloads.

Fixes

  • Changes the Superscript spm package repo source to a new lightweight repo meaning that the download of the package is way faster.

Greptile Summary

This PR releases version 4.15.2 with two attribution improvements: filtering out the all-zeros IDFA sentinel (returned when ATT is denied), and a substantial overhaul of the Apple Search Ads attribution retry system to improve capture rate.

  • Dependency swap: Superscript-iOS replaced by the lighter superscript-ios-next (v1.0.14) across all Package.resolved, Package.swift, project.yml, and Xcode project files.
  • IDFA filter: AttributionFetcher now discards the 00000000-… UUID returned when ATT is not authorized, preventing it from appearing as a real advertiser ID in attribution payloads.
  • Attribution retry overhaul: AttributionPoster gains a cross-launch attempt budget (8 tries, 24h window), an in-session backoff for transient Apple SDK errors (2/6/15s), a generation-stamp single-flight guard, cooperative cancellation for reset races, and a migration (v4→v5) that moves the token sentinel from user-specific to app-scoped storage so it survives user logout without re-fetching.

Confidence Score: 4/5

Safe to merge; the attribution logic is well-guarded and the storage migration has a no-op path for users without legacy data.

The attribution poster rewrite is large but thoroughly commented and backed by new tests covering the key guard conditions. The one notable style issue is zeroAdvertisingIdentifier being typed as UUID? rather than UUID, meaning the sentinel filter relies on optional equality — harmless in practice since the literal is always valid, but worth tightening. No data-loss, auth, or crash risks were identified.

Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift — the zeroAdvertisingIdentifier optional type; Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift — the redesigned retry/cancellation logic is the most complex part of this PR and deserves a careful read.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift Major rewrite adding cross-launch retry budget (8 attempts, 24h window), single-flight generation stamps, in-session token-fetch backoff, and cancellation support; logic is well-structured and commented with appropriate cooperative-cancellation checks throughout.
Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift Adds IDFA zero-sentinel filter and canProduceAdServicesToken guard; zeroAdvertisingIdentifier is typed UUID? rather than UUID, creating a potentially silent no-op comparison if the optional were nil.
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Moves AdServicesTokenStorage from .userSpecificDocuments to .appSpecificDocuments and adds three new app-scoped storage types for retry bookkeeping, unsupported-device sentinel, and cached attribution data; all appropriately scoped.
Sources/SuperwallKit/Storage/Migration/V4Migrator.swift New migrator that moves the AdServices token sentinel from user-specific to app-specific storage on upgrade, with a safe no-op path when no legacy data exists and a guard against overwriting an already-migrated value.
Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift Adds v5 to DataStoreVersion and wires V4Migrator into the recursive migration chain; existing tests were updated to match the new terminal version.
Sources/SuperwallKit/Superwall.swift Removes the upfront ASA token call from configure() (replaced by config-subscription-driven triggering in AttributionPoster) and adds cancelInFlight + reapplyCachedAttribution around storage.reset() in the reset path.
Sources/SuperwallKit/Network/Network.swift Changes sendToken from returning [String: Any] (swallowing errors) to throwing AdServicesResponse, letting the caller handle failures and bump the retry budget.
Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift New test suite covering the main guard conditions (max attempts, retry window expiry, permanently-unsupported sentinel, previously-posted sentinel) plus Codable round-trips and storage round-trips; uses the Swift Testing framework as required.
Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift Updated end-to-end migration test to assert v5 as the final version, and added three V4→V5 migration scenarios (normal, no legacy data, legacy-vs-new collision).
Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift New Codable/Equatable struct tracking attempt count and first/last attempt dates for cross-launch retry bookkeeping.
Package.swift Switches the Superscript dependency from Superscript-iOS repo (bloated xcframework) to the lighter superscript-ios-next at version 1.0.14.

Sequence Diagram

sequenceDiagram
    participant SW as Superwall
    participant AP as AttributionPoster
    participant CM as ConfigManager
    participant AF as AttributionFetcher
    participant NW as Network
    participant ST as Storage

    SW->>AP: init() → listenToConfig()
    CM-->>AP: "configState emits (enabled=true)"
    AP->>AP: getAdServicesTokenIfNeeded()
    AP->>ST: get(AdServicesAttributionAttemptsStorage)
    AP->>AP: canStartAttempt() guards
    Note over AP: Already posted? Unsupported? No token env? Config off? Budget/window exceeded?
    AP->>AP: "Task { runAttempt() }"
    AP->>AF: adServicesToken (with 0s/2s/6s/15s backoff)
    AF-->>AP: token or error
    alt Permanent error
        AP->>ST: save(true, AdServicesAttributionUnsupportedStorage)
    else Transient error
        AP->>ST: recordFailedAttempt()
    else Token obtained
        AP->>NW: sendToken(token) throws AdServicesResponse
        NW-->>AP: AdServicesResponse
        AP->>ST: save(token, AdServicesTokenStorage)
        AP->>ST: save(attribution, AdServicesAttributionDataStorage)
        AP->>SW: setUserAttributes(attribution)
    end

    SW->>AP: reset() → cancelInFlight()
    AP->>AP: task.cancel() + ownerGeneration++
    SW->>ST: storage.reset()
    SW->>AP: reapplyCachedAttribution()
    AP->>ST: get(AdServicesAttributionDataStorage)
    AP->>SW: setUserAttributes(cachedAttribution)
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift:59
`zeroAdvertisingIdentifier` is declared as `UUID?` because `UUID(uuidString:)` returns an optional. The comparison `identifierValue == Self.zeroAdvertisingIdentifier` uses optional equality: if `zeroAdvertisingIdentifier` were ever nil (impossible for this literal, but the type allows it), the comparison would be `false` and the all-zeros sentinel would silently pass through unfiltered. Using a force-unwrap makes the non-optional intent explicit and removes the optionality from the comparison site.

```suggestion
  // swiftlint:disable:next force_unwrapping
  private static let zeroAdvertisingIdentifier = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
```

Reviews (1): Last reviewed commit: "Merge pull request #470 from superwall/i..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

anglinb and others added 25 commits May 4, 2026 18:56
Migrates from `superwall/Superscript-iOS` (which committed the ~250 MB
libcel.xcframework into git, ballooning history past 1.2 GB and making
SPM clones painfully slow) to the new slim repo
`superwall/superscript-ios-next`, which distributes the xcframework as
a GitHub Release asset and uses SPM's binaryTarget(url:checksum:).

The Superscript Swift module name is unchanged, so source files don't
need edits. CocoaPods consumers are intentionally NOT touched in this
PR — the `Superscript` pod on Trunk is still published from the legacy
repo's pipeline. SuperwallKit.podspec keeps depending on
`Superscript`, '1.0.13'. We can flip the pod over later once the new
repo's COCOAPODS_TRUNK_TOKEN is configured and a release is published.
Switch SwiftPM Superscript dep to superscript-ios-next
Make the AdServices integration retry resiliently and only mark the
token as posted after the backend confirms. Previously a single
transient failure (Apple's attribution endpoint isn't ready yet, brief
network blip, etc.) permanently lost attribution for that install
because the token was saved to storage before the backend round-trip.

- AdServicesResponse now decodes top-level `eligible` / `error` so the
  backend can signal "this user wasn't from Search Ads" distinctly from
  a transient failure.
- Network.sendToken throws instead of swallowing into [:].
- AttributionPoster: in-session backoff for both AAAttribution and the
  backend post; cross-launch retry budget (8 attempts within 48h of
  first attempt); permanent AAAttribution errors don't burn the budget;
  thread-safe single-flight via serial queue; foreground retry priority
  bumped from .background to .utility; listenToConfig no longer uses
  .first so toggling the dashboard flag mid-session still triggers a
  fetch; cancelInFlight() called from reset.
- AttributionFetcher.identifierForAdvertisers filters the all-zeros
  UUID sentinel iOS returns when ATT is denied, so we stop polluting
  attribution payloads with a junk IDFA.
- Drop the dead getAdServicesTokenIfNeeded() kickoff from configure()
  that always bailed at the config-enabled guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cancelInFlight() only cancels the inner runAttempt task, but the outer
getAdServicesTokenIfNeeded() function is blocked on `await task.value`
and not tracked. When it unblocks after cancellation, its `defer`
unconditionally cleared isCollecting/currentTask — which could clobber
state belonging to a newly started call (e.g. the fetch reset() kicks
off right after cancelInFlight).

Stamp each outer call with a monotonic generation when it claims the
slot, and only run the cleanup defer if we still own that generation.
cancelInFlight bumps the generation so any in-flight outer call's
defer becomes a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cancelInFlight (called from reset(duringIdentify:)) cancels the inner
runAttempt task, which causes fetchTokenWithBackoff or
postTokenWithBackoff to throw CancellationError from their
Task.isCancelled checks. That was falling through to the generic catch
and calling recordFailedAttempt with the pre-reset `existingAttempts`
snapshot — writing a stale attempt count into the freshly cleared
storage for the new user.

Catch CancellationError explicitly in both call sites and return
without bookkeeping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post response's `error` field was decoded but not consulted, so a
backend error response was indistinguishable from success: we'd save
the sentinel and stop retrying. Now a non-nil `error` is thrown into
the retry path so the next launch/foreground tries again.

Also surface the failure through the AdServicesTokenRetrieval analytics
event (previously only the SDK-side fetch failure was tracked, not the
post failure), and add a comment clarifying that `eligible == false`
intentionally falls through to the success path — Apple has given a
definitive answer that this user wasn't from Search Ads and there's
nothing to retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous removeDuplicates { _, _ in true } sat after .filter, so by
the time it saw values they were all `true` and it suppressed every
emission after the first — observably identical to the .first { ... }
it replaced. The intended "re-trigger when the dashboard flag flips
back on mid-session" behaviour never happened.

Map config → bool first, dedup on the bool so toggles are visible, then
filter to true. First-emission semantics unchanged; off→on now fires
again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CustomURLSession.request already wraps every call in Task.retrying, and
the AdServices endpoint is configured with retryCount: 3,
retryInterval: 5 (Endpoint.swift). The outer postTokenWithBackoff was
stacking 4 more attempts on top, meaning a single
getAdServicesTokenIfNeeded could fire up to 12 HTTP requests.

Call network.sendToken once and let Task.retrying handle transient
transport errors. Persistent failures (including a 200 response with a
non-nil `error` payload, which Task.retrying doesn't see) fall through
to recordFailedAttempt and the cross-launch attempt budget picks them
up on the next launch — the right level for those, since the same
token retried back-to-back will get the same answer.

AAAttribution.attributionToken() is NOT covered by Task.retrying, so
fetchTokenWithBackoff stays — rename the constant accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A permanent AAAttribution error (platformNotSupported,
attributionUnsupported) was returning from runAttempt without writing
any state — the attempt budget is intentionally not bumped for
non-transient errors, but the success sentinel wasn't written either.
On every subsequent launch all the guards passed, the SDK call ran
again, and the same permanent error was raised again. Indefinitely.

Add AdServicesAttributionUnsupportedStorage as a dedicated boolean
sentinel for this state. Check it alongside the success sentinel and
write it from the permanent-error catch so affected devices stop
re-attempting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cancelInFlight() only cancels currentTask, so it's a no-op during the
window between getAdServicesTokenIfNeeded's `await track(.start)` and
the subsequent `currentTask = task` write — currentTask is still nil
in that window. If reset(duringIdentify:) fires there, cancel misses,
storage.reset() runs, and the outer call resumes to create+await a
fresh task with the pre-reset existingAttempts snapshot, which can
write the old attribution sentinel into the new user's storage.

After the track call, re-check ownerGeneration AND create+store the
task in a single stateQueue.sync block. The combined atomicity is
important: a separate re-check before task creation would still let
cancelInFlight slip in between Task() and the store, leaving the new
task uncancellable.

Also extracted the start-condition guards into canStartAttempt() to
keep the function under the cyclomatic-complexity budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover the budget / window / unsupported-sentinel bails in the
preconditions check. Each test sets up storage state that should make
canStartAttempt return false, calls getAdServicesTokenIfNeeded, and
asserts that storage state was not mutated (no attempts bumped, no
token sentinel written).

Expose maxAttempts and maxRetryWindow as internal so tests can
reference them without hardcoding magic numbers.

Concurrency / cancellation / network paths still need NetworkMock
support and an injectable AAAttribution seam — tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swift Testing runs tests in parallel by default, but several of these
tests mutate on-disk storage at fixed keys, so concurrent runs read
each other's in-flight writes. The existing AttributionTests suite uses
@suite(.serialized) for the same reason — match it here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified against paywall-next:/apple-search-ads/token (apps/web/fapi/
adServices.ts). The real response shape is:
  - 200 success: { status: "ok", attribution: { ... } }
  - 4xx error:   { status: "error", error: "..." }

I had added `eligible` (doesn't exist anywhere in the response) and
`error` (only present on non-2xx responses, which Task.retrying in
CustomURLSession throws before we ever decode the body). The
`if let backendError = response.error` block in runAttempt was dead
code — those bodies are never decoded.

Strip both fields. Backend errors still drive retries because
Task.retrying surfaces non-2xx as a thrown URLError, which the generic
catch in runAttempt already handles. Update the decoder test to use
the actual backend success shape (with `status` field that we don't
model and snake_case apple_search_ads_* attribution keys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 48h was loose hedging. Apple's docs specify the
attribution token is valid for 24h after generation and posts should
happen in that window. We generate a fresh token on each attempt, so
token freshness isn't the binding constraint — it's Apple's
install-side attribution data, which becomes unmatchable to a campaign
past ~24h regardless of token freshness. Retrying for another 24h
beyond that was burning attempts on requests that couldn't succeed.

The existing test references the constant via
AttributionPoster.maxRetryWindow, so it stays correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The outer closure already captures self weakly, so inside it `self` is
a Self? optional. Re-capturing that with [weak self] on the inner Task
doesn't make it weaker — it just creates a second weak reference to
the same underlying object. The `self?.` at the call site works either
way because the outer self is already optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple Search Ads attribution is install-scoped: the campaign that drove
the install is a fixed device-level fact that doesn't change when a
user logs out and another logs in. The previous design wiped the
attribution sentinel on reset() and re-fetched per user, which works
within Apple's 24h post-install window but silently fails past it —
and burns extra Apple traffic even when it succeeds, since the
underlying campaign is the same.

Changes:
- All AdServices storables (token sentinel, attempts, unsupported)
  move to .appSpecificDocuments so they survive reset(duringIdentify:).
- New AdServicesAttributionDataStorage caches the decoded attribution
  dict, also app-scoped.
- runAttempt now writes the dict to that cache on success.
- New AttributionPoster.reapplyCachedAttribution() reads the cache and
  calls setUserAttributes — wired into reset(duringIdentify:) after
  storage.reset() wipes user files, so the new user inherits the
  install-scoped campaign keys without re-fetching.
- Migration: on AttributionPoster init, if the old user-specific token
  exists and the new app-specific one doesn't, copy it over. The
  legacy file is left in place (its on-disk key collides with the new
  one in memCache; a delete would evict the entry we just wrote).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the inline migration in AttributionPoster.init with a proper
V4Migrator that fits the existing FileManagerMigrator pattern:

- Bump DataStoreVersion to v5.
- New V4Migrator (v4 → v5) reads the legacy user-specific
  AdServicesTokenStorage, writes it back at the new app-specific
  location, deletes the legacy file. Runs once via the version-keyed
  migration chain Storage.migrateData() already calls.
- LegacyUserScopedAdServicesTokenStorage moves out of CacheKeys.swift
  and lives next to the migrator, matching V3Migrator's pattern with
  LegacyLatestRedeemResponse.

The memCache delete-collision concern from the old inline approach
doesn't apply here: migration runs once at Storage init, before any
other code reads AdServicesTokenStorage, so deleting the legacy
entry doesn't evict a "just-written" memCache entry that any caller
depends on — the next read just falls through to the new app-specific
disk file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migrateFromV1ToV4 now runs through V4 too, so the final version is v5
— rename and update the expectation. The migrateRedeemResponseFromV3
test calls V3Migrator directly, so its .v4 expectation is unchanged.

Add three focused V4Migrator tests:
- Happy path: legacy user-specific token moves to app-specific, legacy
  file is deleted, version bumps to v5.
- No legacy data: version still bumps to v5, nothing else touched.
- Both legacy and new present: don't overwrite the new value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous check matched code 2 OR 3 against the AAAttributionErrorDomain.
From Apple's AAAttribution.h:

  AAAttributionErrorCodeNetworkError = 1          (transient)
  AAAttributionErrorCodeInternalError = 2         (transient)
  AAAttributionErrorCodePlatformNotSupported = 3  (permanent)

So matching code 2 was wrong — internalError is documented as
"unable to provide a token because of an internal error", i.e. a
server-side issue worth retrying, not a permanent device state. The
fictional `attributionUnsupported` from the old comment doesn't exist
in the API.

Match only code 3 (`platformNotSupported`) and name the constant with
a citation to the Apple header. We don't reference the Swift
`AAAttributionErrorCode` enum directly because NS_ERROR_ENUM types
don't import reliably across SDK versions ("cannot find
'AAAttributionErrorCode' in scope" at compile time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backoff loop used `try?` on Task.sleep, which silently discards the
CancellationError that Task.sleep throws when its task is cancelled.
The next Task.isCancelled check only fires after the sleep completes,
so cancelInFlight() during a 15s backoff delay would leave the inner
task running until the full sleep elapsed — defeating the point of
cancellation during reset(duringIdentify:).

Use `try` so the CancellationError unwinds immediately through
fetchTokenWithBackoff and into runAttempt's `catch is CancellationError`
handler, which already returns without bookkeeping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Task in the config sink now uses .utility, matching the foreground
  call site. Default-priority tasks can be deferred under load, and
  attribution is bounded by Apple's 24h install window.
- isPermanentTokenError is private — it's only used inside this file.

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

Issue 1: track(.start) was emitted from the outer getAdServicesTokenIfNeeded
before the ownership re-check, so a cancelInFlight() that fired while
we were suspended on the track call left an orphan .start with no
.complete or .fail. Move .start into runAttempt at the top (guarded by
a Task.isCancelled check) so it only fires when the inner task
actually runs. Both `catch is CancellationError` branches now emit
.fail(CancellationError()) so every .start has a paired terminal.

Issue 2: simulator-without-mock and !canImport(AdServices) builds were
hitting fetchTokenWithBackoff, throwing PosterError.tokenUnavailable
(classified as transient), and burning ~23s of sleep plus one of the 8
cross-launch attempts on every dev launch. Add
AttributionFetcher.canProduceAdServicesToken and check it in
canStartAttempt so we skip cleanly before claiming the slot — no
backoff burn, no attempts bookkeeping. Recovers automatically when the
developer adds SUPERWALL_MOCK_AD_SERVICES_TOKEN and relaunches.

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

Improve Apple Search Ads attribution capture
Comment thread Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift Outdated
yusuftor and others added 2 commits May 14, 2026 17:42
UUID(uuidString:) returns Optional, which would silently make the
equality check at the call site false if the literal ever failed to
parse — the zero-IDFA sentinel would then slip through unfiltered.
Switch to UUID(uuid:) which takes the 16-byte tuple directly and
returns a non-optional, removing the optionality from the comparison
without resorting to force-unwrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The failure mode is the opposite of what the comment said: an
Optional `nil` would make the equality check return `false`, so the
`if` wouldn't fire and the zero IDFA would pass through unfiltered —
a false negative (we failed to filter when we should have), not a
false positive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yusuftor yusuftor merged commit 4495a19 into master May 14, 2026
3 checks passed
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.

3 participants