Skip to content

fix(attribution): match deferred installs against their click - #44

Merged
onamfc merged 1 commit into
mainfrom
brandon/fix-deferred-attribution-matching
Sep 4, 2026
Merged

fix(attribution): match deferred installs against their click#44
onamfc merged 1 commit into
mainfrom
brandon/fix-deferred-attribution-matching

Conversation

@onamfc

@onamfc onamfc commented Sep 4, 2026

Copy link
Copy Markdown
Member

Deferred attribution was inconsistent on iOS or Android.

Cause

calculateConfidenceScore compares five signals worth 100 points and attributes at ≥70. Two of them are captured differently by the SDKs than by the web click they get compared against, so neither would match:

Signal Click side Install side (SDK) Result
User agent (30) …(iPhone; CPU iPhone OS 17_5…) Safari/604.1iphone|safari MyApp/1.0.0 iOS/17.5| never equal
Screen (10) 393×852 (CSS px) 1179×2556 (native px) never equal

normalizeUserAgent extracts a platform token (iPhone|iPad|Android|…) and a browser token. The SDK string built by FingerprintCollector.generateUserAgent() contains neither — iOS is not in the platform pattern — so it always reduced to "|".

That capped every mobile install at 40 (ip) + 10 (timezone) + 10 (language) = 60, permanently below the threshold. Android hit the same ceiling via "android|" vs "android|chrome".

The unit tests missed it because they compare a browser UA against another browser UA on both sides, which never happens in the real deferred flow — the install side always comes from an SDK.

Fix

When either side of a comparison comes from an SDK user agent:

  • Compare platform and OS major version instead of the raw user agent, splitting the same 30 points 20/10 so the weights still total 100. Both sides already record these; they were simply never scored.
  • A known platform mismatch disqualifies the candidate outright rather than scoring zero. A device cannot have clicked from a different OS, and without this an iOS install could still reach exactly 70 against an Android click on the same subnet via ip + timezone + language + a coincidental screen ratio.
  • Compare screens by proportion, since the two contexts differ by the device pixel ratio. The ratio must hold on both axes, which is what separates a scaled match from two different screens.

Major version only, because Safari reports the OS version less precisely than UIDevice.systemVersion and comparing point releases reintroduces false misses.

Browser-to-browser matching is unchanged — same weights, same exact-equality screen comparison. All pre-existing tests pass untouched.

Results

Scores from the real function, same device and network, click then install:

Scenario Before After
Typical TestFlight install 60 organic 100 attributed
Click sent no fp_sw/fp_sh 60 organic 90 attributed
iPad, 2x scale 60 organic 100 attributed
Install on cellular, click on wifi 60 organic 60 organic
Android click vs iOS install 60 organic 0 disqualified

The last two are the guardrails: a genuinely unrelated install stays organic, and a cross-platform candidate is now rejected outright rather than squeaking over the line.

Testing

7 new cases covering the deferred flow the suite never exercised, including both guardrails. Full suite: 220 passed, tsc --noEmit clean.

Note

One adjacent case is left alone deliberately: in browser-to-browser matching, two different platforms can still reach 70 on ip + timezone + language + screen. That is pre-existing behaviour on a path this bug does not affect, so it is not changed here.

Deferred attribution could never succeed on iOS or Android. Two of the
five scored signals are captured differently by the SDKs than by the web
click they are compared against, so neither could ever match:

- The SDK sends "MyApp/1.0.0 iOS/17.5", which carries no browser token
  and no platform token that normalizeUserAgent recognises, so it
  reduced to "|" against a browser's "iphone|safari". The 30-point user
  agent factor was therefore unreachable for every install.
- The SDK sends native pixels (bounds * scale) while the web sends CSS
  pixels, so the 10-point screen factor never matched either.

That capped every mobile install at 40 (ip) + 10 (timezone) + 10
(language) = 60, below the 70 threshold, and every install was recorded
as organic no matter how well it actually matched.

When one side of a comparison comes from an SDK, compare platform and OS
major version instead of the raw user agent, splitting the same 30 points
20/10 so the total still sums to 100. A known platform mismatch now
disqualifies the candidate outright, since a device cannot have clicked
from a different operating system and the remaining signals are all
shared-network coincidences. Screen sizes are compared by proportion
across that boundary, as the two differ by the device pixel ratio.

Browser-to-browser matching is unchanged: same weights, same exact screen
comparison, existing tests untouched.

A same-device, same-network install now scores 100 where it scored 60.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.24060% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/lib/fingerprint.ts 96.24% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@onamfc
onamfc merged commit 7fb0c45 into main Sep 4, 2026
13 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant