A full trace of how a person's data actually moves through the product for email and push — identity, events, consent gates, and the two real breakages found along the way. Built to answer directly: did we land on the issue where emails or push tokens didn't reach Customer.io — and if so, exactly where. Checked against live code and the live database, not assumed.
registerDeviceToken()sets context.device.tokenrecommend-plan, revenuecat-webhookGreen = confirmed working as intended. Amber = a real, evidenced gap. Detail on every box below.
AnalyticsClient.identify(userId, email) is called from 5 places — initial sign-in, cached-session restore, sign-in success, and the point analytics consent is first granted. Every call sends one Segment identify(), which Segment fans out to every enabled destination from a single payload — there's no way for the app to send email to Customer.io but not Amplitude from one call. That's exactly why the Amplitude email fix was done as a Segment-dashboard mapping (a destination-level filter dropping the email trait for Amplitude specifically) rather than removing email from the app's call — confirmed by reading identify() directly, it still sends email unconditionally today, so Customer.io's copy of every identify call is untouched by that fix.
identify() · called from AuthenticationManager.swift:40,55,232, AppView.swift:335, SettingsView.swift:448| Moment | Event(s) | Source |
|---|---|---|
| Onboarding | onboarding_started, onboarding_step_viewed, onboarding_completed, onboarding_skipped | OnboardingFlow.swift |
| Sign-in / account | sign_in_succeeded, sign_in_failed, sign_out, account_deleted, consent_granted | AuthenticationManager.swift, AppView.swift |
| Check-in | checkin_started, checkin_completed | AppView.swift |
| Plan | plan_step_started, plan_step_completed, plan_block_completed | AppView.swift |
| Audio session | audio_session_started, audio_session_completed, audio_session_paywalled | AudioSession.swift |
| Paywall / subscription | paywall_viewed, paywall_continued_free, paywall_restore_tapped, paywall_dismissed, subscribe_clicked, subscribe_succeeded, subscribe_failed, subscribe_cancelled | PaywallView.swift, SubscriptionManager.swift |
| Notifications / deep links | notification_opened, deeplink_opened | AppDelegate.swift, AppView.swift |
| Referral | referral_share_presented | AppView.swift |
Every one of these is gated by the client-side consent check (below) before it fires — but see the audit-trail gap for why that gate's real-world coverage is uncertain.
recommend-plan sends plan_block_generated plus a Customer.io identify() carrying real selected life-areas and 1–10 ratings, on every plan generation. revenuecat-webhook forwards every subscription lifecycle event (started, renewed, cancelled, billing issue, expired). Both run entirely server-side — triggered by an HTTP call or RevenueCat's own webhook — so the app's on-device consent flag never applies to them at all.
AnalyticsClient.hasAnalyticsConsent is checked before configure()/identify()/track() — that part works. The problem is what it's checking: a local UserDefaults flag, with no server-side record of whether that flag reflects a real, provable grant. Checked live just now: the user_consents table still doesn't exist in production, and record_consent_granted's live version still only writes a single blanket timestamp with no argument for which consent was actually chosen. So the gate that decides whether any of this fires is real, but unaudited — there's no way to demonstrate, for any given user, that their current device-local flag matches an actual choice they made._shared/consent.ts helper is written and wired into both recommend-plan and revenuecat-webhook, fail-closed (no consent row, or a lookup error, resolves to "don't send"). Checked live just now: both functions are still at the exact same versions as before this was built (recommend-plan v33, revenuecat-webhook v5) — the gate exists on a branch, not in production. Every plan generation and every subscription event is still forwarded to Segment unconditionally today.
context.device.token when it's already set on one of three specific events — Application Opened, Application Installed, or Application Uninstalled. Segment's SDK only sets that field once registeredForRemoteNotifications(deviceToken:) is called (confirmed directly in the analytics-swift source — it writes to context.device.token via a dedicated plugin).AllinApp.swift, AnalyticsClient.shared.configure() starts the SDK, which fires Application Opened automatically, essentially at launch. In AppDelegate.swift, requesting the push token requires a real network round-trip to Apple's servers before the token is known — structurally slower than a same-run-loop event. The two paths are independent, unsynchronized Tasks. On a cold launch, Application Opened almost certainly fires — and is sent to Customer.io — before the token exists to attach to it. This isn't a first-install-only problem: Application Opened fires on every cold launch.analytics-swift (Segment), purchases-ios-spm (RevenueCat), facebook-ios-sdk, and supabase-swift are linked in the project — no Customer.io native SDK exists in this app at all. The fragile Segment-context mechanism above is the only path a device token has to reach Customer.io, and it's the one racing.
AppDelegate — fully decoupled from Segment's timing, the path Customer.io's own docs recommend for this exact setup. Needs a new SPM package (a build change) and a new Push API Key from the Customer.io dashboard.Application Opened event, which also reaches Amplitude by default and would double-count that event — analytics-swift 1.9.3 has no simple per-call destination filter, so this needs its own careful build to avoid corrupting an Amplitude metric while fixing a Customer.io one.
registerDeviceToken · AppDelegate.swift · AllinApp.swift · Package.resolved (no CustomerIO SDK) · Segment/Customer.io's own documented integration requirementsSEGMENT_ACCESS_TOKEN, already known). Worth two minutes: Customer.io (Actions) destination → mappings → confirm what's actually wired for email and device.token, rather than relying on what's typical.