Building & Shipping

One PostHog Project, Two Platforms, One Person

Brett Ridenour Brett Ridenour · Published August 2026

Freebo has a web app and a native iOS operator app. Same operators sign into both. Some do quotes on their phone at the dock and refunds on the laptop at home. Until yesterday, PostHog didn’t know they were the same person.

The web SPA has been shipping events to PostHog since the beginning. The iOS app, sitting in TestFlight, had nothing. I spent one session yesterday wiring the PostHog Swift SDK and PostHog Error Tracking into the native app. The first hour was the tutorial part. The next two hours were the interesting part: making sure a booking_created from the web and a booking_created from the phone show up under one person on one timeline, not two people on two timelines.

The tutorial part

PostHogSDK.shared.setup(config) in AppDelegate.application(_:didFinishLaunchingWithOptions:). Add posthog-ios via Swift Package Manager. Read the project token from a bundled Config.env so a missing key silently no-ops instead of crashing the app or shipping analytics into the void.

The reason to have every PostHog call go through one wrapper file — not sprinkled through views — is that when this app folds into the monorepo in a couple months, the SDK surface needs to move in one file, not thirty. Same reason I isolate Stripe. The wrapper also lets tests no-op cleanly, which matters because UI tests were double-counting screen views before I gated everything on an isEnabled flag.

Analytics wrapper — one file for the entire PostHog surface

Two config choices worth calling out.

captureScreenViews = true. PostHog on iOS reads the SwiftUI navigation title and fires $screen events automatically. That’s more than you get on the web — on web you wire route changes yourself. Free coverage.

captureElementInteractions = false. On SwiftUI it’s noise. The auto-capture heuristic is tuned for UIKit; on SwiftUI it fires on shape taps and re-renders that aren’t real interactions. Turn it off and fire named events (operator_login, location_switched, calendar_book_day_tapped) from the actual button handlers. You end up with fewer events, all of them meaningful.

The interesting part

Here’s the trap. If you call PostHogSDK.shared.identify("op_abc123") on the phone, and the web SPA has already called posthog.identify("op_abc123") at some point on that operator’s laptop, PostHog does not automatically merge them. It creates two separate persons that happen to share a distinct ID. The web one has email, name, location_id as person properties. The iOS one has whatever you passed. They look identical in the person feed and they’re two entirely separate rows in the database.

The fix is a specific, boring rule: the iOS identify call has to pass the exact same distinct ID and the exact same person properties as the web identify call. Not “similar.” The same. If web sets location_id on the person, iOS must set location_id on the person. If either side skips a property the other side sets, funnels split, cohorts miscounts, and retention charts get quietly wrong in a way you won’t notice for a month.

For Freebo the invariant is: every event is scoped to a location. Operators can work multiple. So the identify call sets three things — user_id as the distinct ID, email as a person property, and location_id as both a person property and a super-property. Super-property means “attach this to every future event automatically until told otherwise.” When the operator switches locations in the UI, I re-register the super-property and fire a location_switched event. When they log out, I reset() — which drops identity and super-properties — then re-apply the static ones on the fresh anonymous session.

Crashes go through the same pipe

The other reason I chose PostHog over adding a second SDK for crashes: errorTrackingConfig.autoCapture = true. Uncaught exceptions and Swift crashes get captured to disk when they happen and sent to PostHog Error Tracking on next launch. They land in the same feed the web app’s client errors already land in. Same dashboard, same alert channel, no separate Crashlytics account, no second SDK to keep in sync with Xcode versions.

Handled API failures — a 4xx from the backend, a decoding error — get captured as a named api_error event with the method, path, error code, and status. Transport-layer noise (offline, timed-out, cancelled) is filtered out at the wrapper level so a subway ride doesn’t drown the dashboard.

Session replay, muzzled

Session replay on native iOS is real and it works. It’s also the fastest way to ship customer PII to a third party if you don’t lock it down.

Every text input is masked. Every image is masked. The Stripe PaymentSheet is a system-owned view, but the surrounding UI has reservation details, guest names, phone numbers. Default assumption: everything the user can see is sensitive. If I ever want to un-mask a specific field for a debugging session, that’s a targeted decision I make explicitly. The default is: nobody at Freebo — including me — can watch a replay and read a customer’s phone number.

The privacy manifest is not optional

Apple’s PrivacyInfo.xcprivacy requirement bit me for exactly ten minutes before I remembered it exists. If your app collects any of the categories PostHog collects — user ID, email, device ID, product interaction, crash data — you declare it in the manifest with the purpose (AppFunctionality, Analytics) and whether it’s linked to the user. Miss one and TestFlight builds still go through, but App Store review will flag it.

Freebo’s manifest declares seven data types, all linked, none for tracking. No ATT prompt. The app doesn’t do cross-app tracking, and PostHog isn’t in the tracking-domains list. That’s the whole point of NSPrivacyTracking = false — Apple’s definition of tracking is narrow (it’s about combining your data with other apps’ data for advertising), and PostHog product analytics doesn’t meet it. But you still have to declare every data type you collect. Getting that list right is a five-minute exercise the first time and zero minutes every time after.

Privacy manifest: seven data types, all linked, none tracked

What I’d do differently

Nothing structural. The one thing I want back is the hour I spent debugging why iOS events weren’t merging with web before I understood the person-property rule. If a tutorial had told me “you must set every person property the web sets, verbatim, or the persons are two persons forever,” I’d have skipped straight to the working config.

So consider that the tutorial: same distinct ID + same person properties = one person. Skip either half and you get two.

The rest of the interesting stuff — session replay masking, crash reporting through the same SDK, event naming that doesn’t create noise — is judgment, and judgment gets easier once the plumbing is right. The plumbing is a wrapper file, a config block, and a single identify call that matches the web’s identify call byte for byte.

Now the operator who does a quote on their phone at the dock and finalizes it on their laptop at home shows up as one person in PostHog. One session, one funnel, one retention cell. Which is what analytics was supposed to be measuring in the first place.