Skip to content

P-2207: Web-to-mobile attribution (Android)#51

Merged
keiloktql merged 17 commits into
mainfrom
feature/p-2207
Jul 21, 2026
Merged

P-2207: Web-to-mobile attribution (Android)#51
keiloktql merged 17 commits into
mainfrom
feature/p-2207

Conversation

@keiloktql

@keiloktql keiloktql commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

Adds web-to-mobile attribution for Android — so we know where a user came from before they installed (e.g. tapped a link on example.com → Play Store → install). The scenario from P-2207 now works end-to-end on Android.

Stacked on #50 (P-2199). Base retargets to main once that merges; review only the top commit here.

The platform reality

  • Android → deterministic. The Play Store passes the referrer through the install; the Google Play Install Referrer API returns it on first launch. The SDK already parsed it — it just wasn't wired up.
  • iOS → not possible from the SDK. Apple exposes no install-referrer API. Attributing an iOS install to a referring website needs a 3rd-party service (Branch/AppsFlyer) or fingerprint matching — out of scope. (App Store Connect only gives aggregate web-referrer data, never per-install.)

Changes

  • Declare react-native-play-install-referrer as an optional peer dep, so the (already-written) Android capture actually runs instead of silently no-op'ing.
  • Await the install-referrer capture before lifecycle fires, so the referrer is populated when Application Installed is tracked (was fire-and-forget → race). Safe to await now: it's a fast one-shot native call that no-ops instantly off-Android.
  • Attach referrer / utm_* to the Application Installed event (the issue's explicit referrer=example.com), filtering empty fields.
  • Remove the dead iOS AdServices path — it referenced react-native-ad-services-attribution, which doesn't exist on npm, and only ever covered Apple Search Ads anyway. iOS is now documented as unsupported for web→mobile.

Testing

  • New test: a stored traffic source lands on Application Installed (with empty fields filtered).
  • pnpm test — 229 passing; pnpm typecheck clean; pnpm audit --prod clean.

How it works (Android)

A marketer's link points at play.google.com/store/apps/details?id=…&referrer=utm_source%3Dexample.com…. On first launch the SDK reads that back, stores it as the traffic source, and stamps it on Application Installed + subsequent event context.

P-2207

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

keiloktql and others added 7 commits July 20, 2026 14:27
Fix three mobile analytics gaps found while testing the RN SDK against
the ingestion pipeline (the SDK was already ~80% complete; the real
issues were at the SDK <-> Tinybird seams):

- session_id: the SDK sent none, so the pipeline (which groups sessions
  by session_id with no fallback) collapsed all mobile users of a project
  into one session. Now generated with a 30-min inactivity timeout.
- user_agent: empty on the Expo path, so device/OS classification (done
  purely from the UA string) resolved to "unknown". Now synthesized from
  the device info the SDK already collects.
- screen page_url: was app://<name>, making the screen name the origin
  (fragmenting sessions) and leaving page_path empty. Now
  app://<bundleId>/<name> - stable origin per app, real path per screen.

Add per-event debug logging and 3 test suites pinning each fix to the
pipeline's classifier. 226 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
session_id/anonymous_id now use Web Crypto (getRandomValues/randomUUID)
when available, addressing the CodeQL js/insecure-randomness alert.
Falls back to Math.random only on runtimes without Web Crypto so the
SDK never throws; these IDs are analytics identifiers, not security
tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend overrides to bump the fixable transitive deps (shell-quote, tmp,
ws@6/7, ws@8, js-yaml@3) and document-ignore the 3 residuals with no
clean fix (@babel/core, ws, js-yaml) - all in react-native/wagmi build
tooling, never shipped in the SDK runtime. pnpm audit --prod now passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the no-Web-Crypto fallback (previously Math.random) with a
monotonic-counter + timestamp hashed via SHA-256, so there is no PRNG in
the code at all — clearing the residual CodeQL js/insecure-randomness
alert. Secure path (Web Crypto) unchanged; fallback stays crash-free and
collision-free within a process. Adds tests exercising the fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Attribute mobile installs to where the user came from (e.g. a link on
example.com -> Play Store -> install), via the Google Play Install
Referrer:

- declare react-native-play-install-referrer as an optional peer dep so
  the (already-written) Android capture actually runs
- await the install-referrer capture before lifecycle fires, so the
  referrer is available when Application Installed is tracked
- attach the captured referrer/utm_* to the Application Installed event
  properties (referrer=example.com), filtering empty fields
- remove the dead iOS AdServices path (referenced a non-existent npm
  package; Apple exposes no install-referrer API, so iOS web-to-mobile
  attribution is not possible from the SDK)

Android is deterministic; iOS is documented as unsupported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 20, 2026

Copy link
Copy Markdown

P-2207

@socket-security

socket-security Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedreact-native-play-install-referrer@​1.1.99110010081100

View full report

@keiloktql
keiloktql marked this pull request as draft July 20, 2026 09:21
@keiloktql keiloktql self-assigned this Jul 20, 2026
@keiloktql
keiloktql requested a review from yosriady July 20, 2026 09:33
@keiloktql
keiloktql marked this pull request as ready for review July 20, 2026 09:33
keiloktql and others added 2 commits July 20, 2026 16:42
- Pin @babel/plugin-transform-modules-systemjs override below v8. The
  lockfile regen let it float to 8.0.1 (peer @babel/core@^8, Node >=22.18)
  while the tree uses @babel/core@7 and CI runs Node 22.14 (P1).
- Restore Math.random in the no-Web-Crypto UUID fallback for cross-process
  entropy; two processes starting in the same millisecond otherwise collide
  (P2). Math.random is fine here (analytics IDs, not security tokens); the
  CodeQL insecure-randomness alert on this line is dismissed as a false
  positive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bundle-id-host format (app://<bundleId>/<name>) conflicts with the
Tinybird mobile page-event handling in formono#2033, whose page_path
parser strips the app:// scheme and would leak the bundle id into the
path (/com.formo.app/home instead of /home). The backend derives origin
from the app identifier in context and page_path from the URL, so the SDK
emits the screen name as-is. Test updated to guard against re-encoding a host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yosriady

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86ac51ad64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/FormoAnalytics.ts
Comment on lines +155 to +158
await captureInstallReferrer({
customRefParams: analytics.options.referral?.queryParams,
pathPattern: analytics.options.referral?.pathPattern,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a timeout to the install-referrer await

When the Play Install Referrer native callback never arrives (for example, a stalled Play Store/service connection), captureAndroidReferrer leaves its promise pending indefinitely. This new await makes FormoAnalytics.init hang in that case; consequently the provider never sets a real SDK instance and all tracking remains on the no-op context. Keep the attribution fetch bounded and continue initialization after the timeout, as the previous fire-and-forget path did.

Useful? React with 👍 / 👎.

Comment thread package.json
Comment on lines +85 to +87
"react-native-play-install-referrer": {
"optional": true
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document installation of the Android referrer module

For Android consumers following the README installation command, this optional peer is not installed, so the lazy require in captureAndroidReferrer resolves to null and the new attribution path silently skips. Because marking a peer optional also suppresses the missing-peer warning, users receive neither the P-2207 behavior nor an actionable indication of what to add; document react-native-play-install-referrer as an Android setup dependency (including native rebuild instructions).

Useful? React with 👍 / 👎.

- Add a 3s timeout to the Play Install Referrer native call. init() awaits
  this capture, so a stalled Play Store service connection would otherwise
  leave the promise pending forever and hang SDK initialization, leaving
  every consumer on the no-op context with tracking dead (P1). On timeout
  we skip and retry next launch rather than marking attribution resolved.
- Document react-native-play-install-referrer as an Android setup step in
  the README (install + native rebuild + referrer link format), and warn
  instead of debug-log when it is missing on Android, since marking the
  peer optional suppresses the missing-peer warning (P2).
- Add regression tests for the hung-callback path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
keiloktql and others added 3 commits July 21, 2026 10:12
The events-gateway authorizer computes session_id as
hash(dailySalt + domain + sourceIp + userAgent) — a privacy-friendly,
daily-rotating identifier (Plausible-style, see apps/aws
functions/events-gateway/authorizer.ts). handlerV0 then applies
`obj?.session_id || session_id`, so a body-provided session_id overrides
it; that escape hatch exists for server-side SDKs whose request IP/UA
would otherwise collapse every call into one session.

Emitting session_id from the mobile SDK hijacked that escape hatch and
replaced the daily-rotating hash with a client UUID, giving web and mobile
two different definitions of a session. Reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	pnpm-lock.yaml
#	src/constants/storage.ts
@yosriady

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3bc68ed386

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
Comment on lines +85 to +86
"react-native-play-install-referrer": {
"optional": true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the native referrer module non-optional

When an Android consumer does not install this peer, Metro will fail while bundling: src/lib/installReferrer/index.ts contains a statically analyzable require("react-native-play-install-referrer"), and Metro resolves such dependencies before the try/catch can run. Marking the peer optional therefore makes the documented no-module path a build-time Unable to resolve module failure rather than a runtime warning/no-op. Make it required, or replace the static dependency with an integration approach that Metro can safely omit.

Useful? React with 👍 / 👎.

Comment thread src/FormoAnalytics.ts
Comment on lines 151 to +156
if (analytics.isAttributionEnabled("installReferrer")) {
captureInstallReferrer({
customRefParams: analytics.options.referral?.queryParams,
pathPattern: analytics.options.referral?.pathPattern,
}).catch((error) => {
try {
await captureInstallReferrer({
customRefParams: analytics.options.referral?.queryParams,
pathPattern: analytics.options.referral?.pathPattern,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blocking provider initialization on the referrer timeout

When the Play Store service stalls and never invokes its callback, this awaits the full three-second timeout before the SDK is available. FormoAnalyticsProvider keeps supplying its no-op context until FormoAnalytics.init() resolves, so screens or startup interactions during that interval silently drop their analytics events—the same failure mode the timeout comment identifies, just bounded to three seconds. Capture the referrer without holding up the usable SDK (or otherwise preserve events while it is pending).

Useful? React with 👍 / 👎.

keiloktql and others added 3 commits July 21, 2026 12:46
Team decision: the ingestion edge's session derivation is a web design.
The authorizer computes hash(dailySalt + domain + sourceIp + userAgent),
and for a native app all three inputs degenerate at once — no Origin
header (domain is the constant 'unknown'), the HTTP User-Agent is the
client library's and near-identical across users on the same app build,
and carrier CGNAT shares IPs — so unrelated mobile users collapse into a
single session.

handlerV0 already honours a body-provided session_id
(obj?.session_id || session_id), so the mobile SDK supplies its own with
a 30-minute inactivity window. Reverts 3847a8a and corrects the rationale
comment, which previously described the edge as cookie-based.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Init awaits this capture, and the provider serves a no-op context until
init resolves — so the timeout is also the window in which startup events
are dropped. The Play bind is normally sub-second, so bound it tightly
rather than generously. The cost is first-launch-only: the capture is
one-shot and later launches short-circuit before the native call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@keiloktql

Copy link
Copy Markdown
Member Author

Thanks — conflict resolved, and both addressed. One of these I tested rather than reasoned about, and it came out the other way.


P1 — optional peer / Metro resolution: not reproducible

I built the exact failing condition and Metro bundled fine.

Setup: the with-react-native example app, which

  • does not have react-native-play-install-referrer installed, and
  • does not stub it in metro.config.js (its blockedModules list contains only @metamask/sdk and react-native-device-info)

Result:

$ npx expo export --platform android
Android Bundled 10920ms node_modules/expo-router/entry.js (2992 modules)
› android bundles (1): entry-….hbc (7.49 MB)
Exported: /tmp/expo-export-test

And the code was in the graph — the shipped bundle contains the module's own log strings:

InstallReferrer: already resolved
InstallReferrer: captured Android install referrer
InstallReferrer: Play API did not respond within …

So this isn't a case of the file being tree-shaken out; installReferrer/index.ts is bundled, the module is absent, and the build still succeeds.

The reason is that Metro treats a require() inside try/catch as an optional dependency (transformer.allowOptionalDependencies, on by default in React Native). An unresolvable optional require compiles to a runtime throw rather than a resolution error — which our try/catch catches, producing exactly the documented warning-and-no-op path.

This is also the pattern the SDK already relies on for react-native-device-info, expo-device and expo-application, so making this one required would be inconsistent — and would force a native dependency (and break Expo Go) for every consumer, including iOS-only apps that can never use it.

Keeping it optional. Happy to revisit if you've seen it fail on a specific Metro/RN version — I only proved it for the version this example app pins.


P2 — blocking init on the timeout: valid, tightened

You're right about the mechanism: FormoAnalyticsProvider holds useState(defaultContext) (no-op methods) until FormoAnalytics.init() resolves, so anything tracked in that window is dropped.

Two things narrow it:

  1. It's first-launch-only. captureInstallReferrer is one-shot — every later launch short-circuits on LOCAL_INSTALL_REFERRER_RESOLVED_KEY before reaching the native call, so init is unaffected from launch 2 onward.
  2. The bind is normally sub-second; 3s was a generous safety bound, not an expected cost.

Still, the worst case was larger than it needed to be, so I've cut the timeout to 1.5s (2904ef9) and documented the trade-off at the constant.

I deliberately didn't make it fire-and-forget again — that's what caused the race this PR fixes (the referrer wouldn't be in storage when Application Installed fires, which is the whole point of P-2207).

Worth flagging separately: the provider dropping events during init is pre-existing and broader than this PRstartDeepLinkCapture(), lifecycleManager.start() and AsyncStorage init all sit inside the same window. Buffering events until the real instance is ready would fix all of them at once. Happy to open that as its own issue if you'd like.

@yosriady

Copy link
Copy Markdown
Contributor

@codex review

@yosriady yosriady left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel free to merge after resolving the codex review comments

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 2904ef9d6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Base automatically changed from feature/p-2199 to main July 21, 2026 08:21
#50 landed the installReferrer module with the dead iOS AdServices path
still in it (react-native-ad-services-attribution does not exist on npm;
the require is try/caught so it only ever no-ops). Resolved the storage.ts
comment conflict in favour of this branch, which removes that path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@keiloktql
keiloktql merged commit 60edd72 into main Jul 21, 2026
10 checks passed
@keiloktql
keiloktql deleted the feature/p-2207 branch July 21, 2026 08:31
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.

2 participants