Skip to content

AppsFlyer: Fix - retry failed init instead of silently dropping every event - #2383

Draft
JohnathanWhite wants to merge 1 commit into
bitpay:developfrom
JohnathanWhite:bug/appsflyer-init-failure-drops-events-01
Draft

JohnathanWhite wants to merge 1 commit into
bitpay:developfrom
JohnathanWhite:bug/appsflyer-init-failure-drops-events-01

Conversation

@JohnathanWhite

Copy link
Copy Markdown
Collaborator

Investigating the report that ~half of Crypto Buy conversions never reach AppsFlyer (week of 6/22: 6,400 buys / $1.65M in Mixpanel vs 3,006 / $761K in AppsFlyer).

What I found

A single failed AppsFlyer init is permanent for the process, and every conversion event after it is dropped in silence.

  1. AppsFlyerWrapper.init() sets status = 'failed' when initSdk throws, and swallows the error instead of rethrowing.
  2. init() is called from exactly one place — analytics.effects.ts:93, inside Analytics.initialize(), which is guarded by _isInitialized. Because the AppsFlyer failure never propagates, _isInitialized is set to true anyway, so init() is never called again. Nothing reads getStatus() either.
  3. track() returned early on !isReady(). With status stuck at 'failed', that is every subsequent event for the rest of the process.
  4. The failure was reported via logManager.error(), which only writes a local line and a Sentry breadcrumb — not captureError — so it produced no Sentry issue and no metric. Nothing could have alerted on this.
  5. Braze and Mixpanel have no equivalent readiness gate, so they keep receiving the same events from the same Analytics.track fan-out.

Point 5 is why the two systems diverge at all: the fan-out in analytics.effects.ts is symmetric, so any Mixpanel-vs-AppsFlyer gap has to come from a drop inside the AppsFlyer wrapper. This is the only silent one.

What this changes

  • Extracts the init body into a module-private doInit() so it can be driven from more than the public init(). This is why the diff is large — the block moved out of the object literal and de-indented. The logic inside is unchanged apart from the two items below.
  • Adds ensureReady(), used by track(): awaits an in-flight init, and retries a previously failed one rather than dropping the event. Throttled to one retry per 5 minutes so a device that genuinely cannot reach AppsFlyer does not re-attempt initSdk on every conversion.
  • Reports init failure with logManager.captureError(), so it becomes a visible Sentry issue with a countable population.

What I could not verify

I can't prove this accounts for the full ~50%. Two limits:

  • Mixpanel's service-account credentials have expired (401 ... credentials have expired), so I could not check the live event data.
  • Init failures were invisible by construction — no Sentry issue, breadcrumb only — so there is no historical record of how many users hit one. The captureError change is what makes that measurable.

Also worth flagging for whoever owns the dashboard: no analytics event in the app sends a partner property. All seven Purchased Buy Crypto call sites send exchange, always hardcoded per-provider and never unset (scan.effects.ts ×6, MoonpayBuyEmbeddedCheckout.tsx:587). The only partner: keys in the codebase are lastPurchaseData in redux (BuyAndSellRoot.tsx:2265) and a Simplex URL payload (simplex-utils.ts:419) — neither reaches analytics. So a Mixpanel breakdown by partner showing "(not set)" for 3,326 buys may be measuring a property the app never sends, which would make the partner correlation an artifact of the segmentation rather than a signal. Worth confirming before treating "partner is unset" as the cause.

Verification this needs

Left as a draft because the mechanism is proven from code but the magnitude is not:

  1. Ship with the captureError change and watch for an [AppsFlyer] init failed issue in Sentry — that gives the affected population for the first time.
  2. Force an init failure on a device (airplane mode during launch, or block *.appsflyersdk.com at the DNS layer, which a real slice of users already do) and confirm a later Crypto Buy now arrives in AppsFlyer.
  3. Re-run the 6/22-style Mixpanel vs AppsFlyer reconciliation after a release with this in.

… event

A single failed AppsFlyer init was permanent for the process lifetime, and
every conversion event after it was dropped in silence.

The chain:

- AppsFlyerWrapper.init() sets `status = 'failed'` when initSdk throws, and
  swallows the error rather than rethrowing.
- init() is called from exactly one place, analytics.effects.ts:93, inside
  Analytics.initialize(), which is guarded by `_isInitialized`. Because the
  AppsFlyer failure never propagates, `_isInitialized` is still set to true,
  so init() is never called again.
- track() returned early on `!isReady()`. With status stuck at 'failed',
  that is every subsequent event, for the rest of the process.
- The failure was reported with logManager.error(), which only writes a
  local log line and a Sentry breadcrumb -- not captureError -- so it
  produced no Sentry issue and no metric. Nothing could have alerted on it.
- Braze and Mixpanel have no equivalent gate, so they kept receiving the
  same events. Any comparison between Mixpanel and AppsFlyer therefore
  shows AppsFlyer short by however many users hit a failed init.

That last point matches the reported symptom: an audit of Crypto Buys for
the week of 6/22 found roughly half the logged transactions missing from
AppsFlyer while Mixpanel had them all.

Changes:

- Extract the init body into a module-private `doInit()` so it can be
  driven from more than the public init(). This is what makes the diff
  large -- the block moved out of the object literal and de-indented; the
  logic inside is unchanged apart from the two items below.
- Add `ensureReady()`, used by track(): it awaits an in-flight init, and
  retries a previously failed one rather than dropping the event. Retries
  are throttled to once per 5 minutes so a device that genuinely cannot
  reach AppsFlyer does not re-attempt initSdk on every conversion.
- Report init failure with logManager.captureError() so it becomes a
  visible Sentry issue with a countable population.

Deliberately not changed: getId() still returns early when not ready, since
callers treat a missing ID as acceptable. No event queue is introduced --
a retry means the event that triggered it is sent once init succeeds, and
buffering conversions across a cold start is a larger change than this.
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.

1 participant