Skip to content

feat(native): make the deferred-link match rate measurable - #2587

Open
innolope-dev wants to merge 2 commits into
mainfrom
feat/deferred-link-telemetry
Open

feat(native): make the deferred-link match rate measurable#2587
innolope-dev wants to merge 2 commits into
mainfrom
feat/deferred-link-telemetry

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #2560, stacked on feat/deferred-deep-link-main. Adds the telemetry the deferred-link restore path is missing.

⚠️ Base: targets feat/deferred-deep-link-main, so the diff shown includes #2560's commits until that merges. Retarget to main once #2560 lands — this branch's own change is 3 files / +157.

Why

The restore path currently emits nothing, and the iOS clipboard catch {} swallows the one distinction that matters:

} catch {
    // user declined the paste prompt, or clipboard unavailable
}

A declined paste prompt and an organic install both end up as raw === null. So a hand-off that never works on iOS is indistinguishable from one nobody used — and the only signal either way is the absence of restores, which looks the same as success. The download modal (TASK-20769) is about to drive real volume through this, and the plan was always to measure the match rate before escalating to a blocking interstitial.

What

DEFERRED_LINK_RESTORED, fired once per fresh install with channel (referrer / clipboard / none) and one of four outcomes:

outcome meaning
restored payload found and applied
no_handoff nothing there — organic install
marker_missing payload present but no pnutdl=1 — organic Play referrer, or unrelated clipboard text that passed the URL gate
clipboard_unavailable paste prompt declined, or plugin missing — the broken-hand-off signal

On restored it also reports has_dest / has_locale / has_invite / has_campaign.

Plus trackDeferredHandoffCreated(platform) as the seam for the modal to report the denominator. Deliberately not fired inside playStoreUrlWithReferrer — that's a pure URL builder invoked on render, so it would count impressions as taps.

Privacy

Only booleans and the channel leave the device. The invite code, campaign tag and destination never do — putting a user's inviter and intended screen into analytics would be a real regression. There's a test asserting the payload values are absent from the captured events.

Safety

posthog.capture is wrapped in try {} catch {} at both call sites. This runs on the first-launch path, where a telemetry failure must not cost the user their restored context — covered by a test that makes capture throw and asserts the restore still returns.

Tests

4 added to the existing suite (25 pass total, including all 21 from #2560 unchanged):

  • successful restore reports the right fields, and no payload content
  • organic install vs declined paste prompt land on different outcomes
  • present-but-unmarked referrer is marker_missing, not no_handoff
  • a throwing posthog doesn't lose the restored context

tsc --noEmit clean on the touched files; prettier clean.

The restore path had no telemetry, and its clipboard catch swallowed the
one distinction that matters: a declined iOS paste prompt and an organic
install both end up as `raw === null`. A hand-off that never works would
therefore look exactly like one nobody used, and the download modal
(TASK-20769) is about to put real volume through it.

Split the empty cases into distinct outcomes — clipboard_unavailable,
no_handoff, marker_missing — and emit them with the channel alongside
which fields actually came back.

Only booleans and the channel are sent; the invite code, campaign tag and
destination never leave the device (asserted in a test). Capture is fully
guarded: this runs on first launch, where a telemetry failure must not
cost the user their restored context.

trackDeferredHandoffCreated is the seam for the modal to report the
denominator — deliberately not fired inside playStoreUrlWithReferrer,
which is a pure builder invoked on render and would count impressions.
@innolope-dev innolope-dev self-assigned this Jul 29, 2026
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Jul 30, 2026 3:51pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6e24bfc1-7610-48ca-8091-08bb231e9e68

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@kushagrasarathe kushagrasarathe 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.

Reviewed — the direction is right and I'd like to keep this PR (don't close it). Matches the repo's direct posthog.capture convention (useZeroDev, webauthn.utils), booleans-only payload is exactly the right privacy line, and the outcome taxonomy (no_handoff / marker_missing / clipboard_unavailable) is the diagnostic we'd otherwise wish we had after a silent iOS rollout. The posthog-throw isolation test is appreciated.

Two things before this can land, both fallout from the base branch moving under you today:

1. Stale base — the block you instrument was rewritten in ffc9e79e8 (Jota's review fixes). Your diff context still shows the old "one shot, even on failure" consumption. On the current base: Android only consumes on a definitive read (transient null retries next launch), and iOS consumes before the prompt-raising read. Needs a rebase onto current feat/deferred-deep-link-main.

2. After that rebase, the Android transient path will fire no_handoff on every retry launch — one install with a flaky first boot emits N events and inflates no_handoff, which skews the exact match-rate metric this PR exists to protect. Suggest a distinct read_failed outcome for the android transient-null path (it's also a signal worth seeing on its own — a rising read_failed means the 5s timeout is too tight), or alternatively only capture once consumption happens.

One non-blocking check: on the very first cold launch, capture may run before posthog.init — posthog-js buffers pre-init calls on the singleton so it should be fine, but worth one on-device confirmation via /dev/deferred when we run the internal-track test.

Plan-wise: #2560 merges first, then retarget this to main, rebase, adapt per above. Happy to do the rebase myself if you prefer — say the word.

…telemetry branch

#2560's review pass replaced the unconditional one-shot burn with
markConsumed(): a transient android referrer read no longer consumes and
retries on the next launch.

Fold the telemetry into that: track whether the one-shot was actually burned
and skip the empty-outcome capture when it wasn't — an unconsumed restore is
a pending retry, not an organic install, and firing there would double-count
the install and file a broken read as organic. The android-null test moves to
the iOS empty-clipboard case, which is what an organic install now looks like
on the reporting side; a new test pins the silent-retry behaviour.

Merges the base branch rather than main: main carries unsigned content-submodule
commits that the signature ruleset re-flags on a feature ref (GH013).
@innolope-dev
innolope-dev changed the base branch from feat/deferred-deep-link-main to main July 30, 2026 15:45
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Conflict resolved + retargeted to main.

#2560 merged, so the old base (feat/deferred-deep-link-main) is now part of main — base changed to main, and the branch merges the base-branch tip rather than main itself (main carries unsigned content-submodule commits that the signature ruleset re-flags on a feature ref, GH013).

The conflict wasn't purely textual. #2560's review pass replaced the unconditional one-shot burn with markConsumed(), so a transient android referrer read no longer consumes — the next launch retries. Under that behaviour the telemetry as written would have fired no_handoff on every retry, i.e. double-counting the install and filing a broken read as an organic one, which is the exact conflation this PR exists to remove. Resolution:

  • doRestore now tracks whether the one-shot was actually burned, and the empty-outcome capture is gated on it — an unconsumed restore is a pending retry and reports nothing. DEFERRED_LINK_RESTORED keeps its documented "once per fresh install" contract.
  • The android referrer: null test moved to the iOS empty-clipboard case: with the hardening, an organic android install arrives as Play's utm_source=google-play&utm_medium=organic string (already covered by the marker_missing test), while null means the read failed. A new test pins the silent-retry behaviour.

deferred-link + useNativePlugins suites: 34 passed. mergeable is green.

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