Observability
PostHog analytics and Sentry crash reporting — consent-gated and no-op without keys.
The template ships two observability integrations: PostHog for product analytics and Sentry for crash/error reporting. Both are Integrated, both no-op without a key, and analytics is consent-gated — a headline behavior for App Store review. This page covers how each is wired and the privacy rules that keep them review-safe.
Key files
Analytics (PostHog)
AnalyticsController wraps the PostHog SDK. The design detail that matters:
start() is not called from App.init — it's called from the
Consent Gate once the user accepts, because App
Store reviewers flag analytics SDKs that fire before any consent UX.
To keep call sites simple, events posted before start() are buffered
(capped at 50) and drained on start — so you can track(...) from anywhere without
knowing whether consent has cleared yet:
func track(_ event: AnalyticsEvent) {
guard isConfigured else { buffer(.track(event)); return } // pre-consent → buffer
guard isEnabled else { return } // no key / disabled → drop
PostHogSDK.shared.capture(event.name, properties: event.properties)
}Typed events
Events are a typed AnalyticsEvent enum, not stringly-typed call sites — the raw
PostHog name and property dictionary live in one place:
enum AnalyticsEvent {
case screenViewed(path: String, params: [String: String]? = nil)
case onboardingCompleted(slideCount: Int)
case paywallViewed(source: String?)
case referralInviteShared(channel: String?)
case purchaseCompleted(tier: String, period: String, source: String?)
case purchaseRestored(source: String?)
}Identity
The controller subscribes to the auth session stream: on sign-in it identifys
with the Supabase user UUID (+ email), and on sign-out it resets — so events tie
to the right person and don't bleed across accounts.
Configuration & kill-switch
| Key | Effect |
|---|---|
POSTHOG_API_KEY | Enables analytics; absent → isEnabled == false, all calls no-op |
POSTHOG_HOST | Defaults to https://us.i.posthog.com |
POSTHOG_DISABLED | true → hard kill-switch even with a key present |
POSTHOG_DEBUG | Verbose SDK logging |
Screen views are captured manually (captureScreenViews = false) so you control
the paths; app-lifecycle events are on.
Crash reporting (Sentry)
SentryReporter.shared.start(dsn:authRepository:) is called once from
AppEnvironment.init and no-ops when SENTRY_DSN is empty. It sets environment
(development in DEBUG, else production), a 10% traces sample rate, and mirrors
auth identity onto the Sentry user (cleared on sign-out).
The error bus + boundary
Errors reach Sentry through a small global bus rather than scattered try/catch:
view model → AppErrorBus.shared.post("message", underlyingError: err)
│
▼
ErrorBoundary (mounted at RootView) → full-screen fallback card
│ "Send a report"
▼
SentryReporter.captureUserFeedback(comment) → Sentry feedback inboxErrorBoundary wraps the whole app (see App Gates). Its
Send a report button attaches the user's comment as Sentry user feedback;
stack-trace detail is shown in DEBUG only, never in release builds. You can also
call SentryReporter.shared.capture(error:) / capture(message:) directly.
dSYM upload warning is non-blocking. An Xcode "did not include a dSYM for Sentry.framework" warning during upload doesn't stop TestFlight or purchases — it only affects symbolication of some frames. See Troubleshooting.
Privacy & App Store review
- Consent before tracking. Analytics literally cannot fire before the consent
gate clears (
start()gates on acceptance). Keep it that way. - App Privacy labels. Declare what each SDK collects when you fill out App Privacy — PostHog (usage/identifiers), Sentry (crash diagnostics), plus RevenueCat + Supabase. Mapped on the release page.
- ATT is separate. The Permissions screen's Tracking card governs third-party IDFA tracking; first-party PostHog analytics does not depend on ATT consent.
Verify
- Without keys, the app runs normally — no analytics, no crash reporting, no crashes.
- With
POSTHOG_API_KEYset, accept consent → events appear in PostHog; pre-consent events arrive too (buffered then drained). - With
SENTRY_DSNset, triggerAppErrorBus.shared.post(...)→ the boundary card shows, Send a report lands in the Sentry feedback inbox. -
POSTHOG_DISABLED = truesuppresses analytics even with a key.
