All articles
14 min·

Adding tracking to Keepio without betraying its users

How I instrumented Keepio with Sentry and TelemetryDeck while respecting privacy: a façade, adapters, an exhaustive enum, and split opt-in/opt-out consent.

Par Carolane Lefebvre
SwiftSwiftUIAnalyticsConfidentialitéArchitecture
Adding tracking to Keepio without betraying its users

The context

Keepio is an iOS app for projects, notes and tasks that I have been building solo for over a year. Until now I focused purely on the development and workflow side, to give users the best possible experience. And lately an idea kept nagging at me: I need to understand how my users actually use the app, and above all whether I have crashes.

So I started looking for tools that would get me there. I asked other devs, read around, and the answer looked clear: to protect my users' data as well as I can, Firebase was not the solution. So I went with the Sentry and TelemetryDeck combo.

Picking the tools

Need → Tool → Why

  • Crashes and errors → Sentry → The market standard, rich dashboards, symbolicated stack traces, breadcrumbs

  • Product analytics → TelemetryDeck → Privacy-first (server-side k-anonymity), no cookies, no IDFA, GDPR compliant without explicit consent

The key idea: Sentry is opt-out (crashes have to be tracked even without consent, it is a safety matter), TelemetryDeck is opt-in (product analytics means you need the user's explicit agreement).

Why does that distinction matter so much? Because a crash report is not behavioural data: it is a signal of a technical failure that says nothing about who the user is. Legally and ethically you can collect it by default, provided it is anonymized (no IP, no device identifier). Knowing that "this user ran 3 Pomodoros today", on the other hand, is usage data that calls for explicit consent. Mixing both into a single all-in-one tool forces you either to ask for blocking consent on crashes (and lose 30 to 50% of your reports), or to collect behavioural data without agreement. Two tools means two clear consent regimes.

The architecture

For the architecture I reused the Keepio base with a very specific namespacing:

App.Core.Tracking
├── Service                  ← single façade called by the rest of the app
├── Event                    ← exhaustive enum of every event
│   ├── SignalMapping        ← Event → signal name + parameters
│   └── SupportingTypes      ← associated enums (PaywallTrigger, TabName, etc.)
└── Providers
    ├── Sentry               ← wrapper around the Sentry SDK
    └── TelemetryDeck        ← wrapper around the TelemetryDeck SDK

The Service is the only thing the rest of the app knows about. If I swap TelemetryDeck for PostHog tomorrow, I touch a single file.

What we are putting in place here is a Façade + Adapters pattern. The façade (Service) exposes a stable, minimal API to the rest of the app: one track(_:) method. The adapters (Providers.Sentry, Providers.TelemetryDeck) translate that API into the third-party SDKs. Why does this matter beyond elegance? Because analytics SDKs have an uncertain life expectancy: TelemetryDeck could shut down tomorrow, Sentry could change its pricing, a new tool could show up. If you write import Sentry or import TelemetryDeck in 47 places across your app, you are stuck. With this architecture the import lives in two files, and the 47 call sites talk to tracking.track(...). The migration cost drops from "several weeks" to "one afternoon".

Step 1: model events as an enum

The trap with tracking is magic strings scattered everywhere. The fix: an exhaustive enum with associated values.

extension App.Core.Tracking {
    enum Event {
        case appLaunched, appFirstLaunched
        case paywallViewed(trigger: PaywallTrigger)
        case purchaseCompleted(plan: PremiumPlan)
        case purchasedFailed(reason: PurchaseFailureReason)
       // …
    }
}

Done this way, it is impossible to send a signal that does not exist, the parameters are typed (no ["plan": "yerly"] slipping into production), and the enum doubles as documentation of what you measure.

The mapping to TelemetryDeck is centralized:

extension App.Core.Tracking.Event {
    var signalName: String {
        switch self {
        case .appLaunched:        return "App.Launch"
        case .projectCreated:     return "Project.Create"
        case .pomodoroCompleted:  return "Productivity.Pomodoro.Complete"
        // ...
        }
    }
    var parameters: [String: String] {
        switch self {
        case .projectCreated(let hasColor, let hasSymbol):
            return [
                "hasColor": String(hasColor),
                "hasSymbol": String(hasSymbol)
            ]
        // ...
        default: return [:]
        }
    }
}

Why separate the enum declaration from its mapping? Because they answer two different questions. Event answers "what can my app measure?" (domain semantics), SignalMapping answers "how do I name that on the TelemetryDeck side?" (an external convention). If TelemetryDeck changes its naming convention tomorrow, I edit one file without touching the list of events. If I add a domain event tomorrow, I do not have to wade through one monolithic file.

Why PascalCase and dots in signalName? That is the TelemetryDeck convention: their dashboard groups signals by prefix automatically. Project.Create, Project.Archive and Project.Delete show up as one visual cluster, which makes trends far easier to read. Send project_created, archive_project, deleteProject and you lose that grouping.

Why associated values rather than a [String: Any] parameter on track(_:)? Because track("Project.Create", ["hasColor": true]) compiles even if I type "hasColr" or forget the parameter entirely. With case projectCreated(hasColor: Bool, hasSymbol: Bool), the compiler refuses to move on until I supply both booleans, in the right order, with the right types. Swift becomes my analytics schema, instead of an internal wiki that will go stale.

Step 2: wrap each SDK in a Provider

Sentry first. Privacy-safe by default: no IP, no IDFV.

extension App.Core.Tracking.Providers {
    enum Sentry {
        static func start() {
            #if DEBUG
            return  // dev crashes = noise on the production dashboard
            #endif
            guard let dsn = dsnFromBundle(), !dsn.isEmpty else { return }
            SentrySDK.start { options in
                options.dsn = dsn
                options.sendDefaultPii = false
                options.environment = "production"
            }
        }
        static func addBreadcrumb(category: String, message: String, data: [String: String]) {
            #if !DEBUG
            let crumb = Breadcrumb(level: .info, category: category)
            crumb.message = message
            crumb.data = data
            SentrySDK.addBreadcrumb(crumb)
            #endif
        }
    }
}

TelemetryDeck next, in the same spirit:

extension App.Core.Tracking.Providers {
    enum TelemetryDeck {
        static func start() {
            #if !DEBUG
            guard let appID = appIDFromBundle(), !appID.isEmpty else { return }
            let config = TDClient.Config(appID: appID)
            TDClient.initialize(config: config)
            #endif
        }
        static func signal(_ name: String, parameters: [String: String] = [:]) {
            #if !DEBUG
            TDClient.signal(name, parameters: parameters)
            #endif
        }
    }
}

⚠️ if you call TelemetryDeck.signal() without having called initialize() first, the SDK throws a fatalError. So the #if !DEBUG has to wrap both methods, not just start(). Same for Sentry.addBreadcrumb, which otherwise spammed the Xcode console in debug.

Why enum rather than struct or class for the Providers? Because an enum with no cases is an excellent "inert namespace" in Swift: it cannot be instantiated (Sentry() does not compile), it carries no state, it just groups static methods. That is exactly the semantics we want: a Provider has no identity, it is an access point to a global SDK. Using class would have allowed instantiation and invited misuse.

Why read the DSN and AppID from Info.plist rather than hardcoding them? Two reasons. The trivial one: you do not want to commit a secret to the repo (even though a Sentry DSN is not strictly a secret, it is good hygiene). The subtler one: having the value in Info.plist lets you use different configurations per Xcode scheme (Debug, Beta, Release) through Build Settings, which pays off the day you want a separate Sentry project for TestFlight builds.

Why sendDefaultPii = false? By default, Sentry collects the user's IP address (for rough geolocation on the dashboard). On Keepio I have no use for it: I want to understand crashes, not where they come from geographically. Turning the flag off is a privacy-by-default choice: the less you collect, the smaller the leak risk, and the less GDPR paperwork you have to deal with.

Why does #if !DEBUG have to wrap signal() and not just start()? That is the lesson that cost me a production crash during an internal test. The TelemetryDeck SDK enforces a strict contract: call signal() before initialize() and you get an immediate fatalError, not a catchable exception, a clean crash. Since start() is skipped in debug, initialize() is never called, so any call to signal() from a debug build would take the app down. The fix: skip signal() in debug too. It matches the intent (no tracking in debug) and removes a foot-gun.

This is the heart of it. One @Observable class, one public method, track(_:).

extension App.Core.Tracking {
    @Observable
    final class Service {
        static let shared = Service()
        /// Source of truth: UserDefaults
        var analyticsEnabled: Bool {
            didSet { handleAnalyticsToggle(oldValue: oldValue) }
        }
        init() {
            self.analyticsEnabled = UserDefaults.standard.bool(forKey: Keys.analyticsEnabled)
        }
        /// Sentry: always. TelemetryDeck: only with consent.
        func start() {
            Providers.Sentry.start()
            if analyticsEnabled {
                Providers.TelemetryDeck.start()
            }
        }
        func track(_ event: Event) {
            // Sentry gets an anonymous breadcrumb (useful if a crash follows)
            Providers.Sentry.addBreadcrumb(
                category: "tracking",
                message: event.signalName,
                data: event.parameters
            )
            guard analyticsEnabled else { return }
            Providers.TelemetryDeck.signal(event.signalName, parameters: event.parameters)
        }
        private func handleAnalyticsToggle(oldValue: Bool) {
            guard analyticsEnabled != oldValue else { return }
            UserDefaults.standard.set(analyticsEnabled, forKey: Keys.analyticsEnabled)
            if analyticsEnabled {
                Providers.TelemetryDeck.start()
                track(.analyticsOptedIn)
            } else {
                // Send the opt-out BEFORE the guard blocks everything
                Providers.TelemetryDeck.signal(Event.analyticsOptedOut.signalName)
            }
        }
    }
}

Two important subtleties:

  1. Sentry breadcrumbs are sent even without analytics consent: they hold no PII, and they are precious for understanding the sequence of actions that led to a crash.

  2. The analyticsOptedOut event goes through the provider directly (not through track()) because otherwise the analyticsEnabled guard, which has just flipped to false, would block it. A detail that looks minor but let me measure how many people turn analytics off.

Why @Observable? Because the "Analytics" toggle in the app Settings is bound to tracking.analyticsEnabled. With @Observable (a Swift 5.9+ macro built on the Observation framework), SwiftUI redraws the Toggle automatically when the value changes, with no @Published and no ObservableObject to expose. Less boilerplate, and a more precise reactivity model (per-property granularity instead of per-object).

Why static let shared AND @Environment at the same time? The shared instance serves contexts that cannot reach the SwiftUI environment: managers, services, launch code in App.init(). The @Environment one serves Views and ViewModels, which is more testable (you can inject a fake) and is the SwiftUI standard. Keeping a shared reference is not an anti-pattern here, because both point at the same object (Service.shared is injected into the environment at startup). It is a singleton in practice, not a mutable global.

Why UserDefaults as the source of truth? Because the "analytics on/off" preference has to (a) survive across launches, (b) be readable before SwiftData and CloudKit are initialized (we read it in init(), ahead of everything else), and (c) NEVER leave the device. CloudKit would have been a bad idea: if the user opts out on their iPhone, they do not expect that choice to sync to their iPad, or the other way round. It is a per-device choice, and UserDefaults is the perfect tool for it.

Why the didSet rather than exposing a setAnalyticsEnabled(_:) method? Because it lets the SwiftUI binding $tracking.analyticsEnabled work directly with the Toggle. Going through a method would mean an onChange(of:) everywhere the preference is touched. The didSet centralizes the whole transition (saving to UserDefaults, starting TelemetryDeck, sending the opt-in/opt-out event) in one place, triggered no matter where the value is changed from.

Why send the analyticsOptedOut signal BEFORE the guard blocks it? This one is twisted but important. At T-1 the user has analyticsEnabled = true. They toggle it to false. The didSet fires: analyticsEnabled is ALREADY false at that point. Going through track(.analyticsOptedOut) would hit guard analyticsEnabled else { return } and drop the signal, leaving me with no record of the opt-out. Bypassing it with a direct Providers.TelemetryDeck.signal(...) preserves a crucial measure: how many people actually turn analytics off? It is meta data about consent, and it is itself ethical to measure, because at the moment it is sent the user still had consent active.

Step 4: wiring it at startup

In App.swift:

init() {
    /// Sentry first — signal handler installed before the ModelContainer init
    App.Core.Tracking.Providers.Sentry.start()
    // ... ModelContainer init ...
    /// Unified service — Sentry.start() is a no-op here, TelemetryDeck starts if consented
    tracking.start()
    tracking.track(.appLaunched)
}

The order is deliberate: Sentry has to start before any code that could crash, in particular the ModelContainer init, which can fail (SwiftData migrations).

Why this exact order? When Sentry starts, it installs a C signal handler that intercepts SIGSEGV, SIGABRT, SIGBUS and friends. That is what lets it capture a crash, serialize the stack trace and write it to disk for sending at the next launch. Until that handler is installed, a crash gives you "nothing": the user sees the app close, and you never learn what happened. And the ModelContainer init is one of the riskiest spots in the app: a failed SwiftData migration, a corrupted store file, an incompatible schema, and it is an immediate fatalError. Starting Sentry BEFORE that call guarantees those early crashes make it back to you.

Why call Providers.Sentry.start() AND tracking.start()? The second call is slightly redundant on the Sentry side (already started), but it is what initializes TelemetryDeck, which has no need to start that early: product analytics can wait until the app is functional. It is a deliberate trade: a little redundancy in the code for a strong guarantee on crash reporting.

Why track .appLaunched here rather than in a View.onAppear? Because onAppear fires after the first render, which can take a few hundred milliseconds, and may never fire at all if the app crashes during bootstrap. Measuring the launch in init() captures every session, including the ones that do not survive the first screen.

Step 5: calling it from the ViewModels

Once the Service is injected as @Environment(Service.self), tracking becomes invisible on the View side:

@Observable @MainActor
final class CreateProjectViewModel {
    func save() {
        // ... creation logic ...
        tracking.track(.projectCreated(
            hasColor: project.color != nil,
            hasSymbol: project.symbol != nil
        ))
    }
}

The compiler rejects anything that does not match the enum. No typos, no forgotten parameter.

Why track booleans like "does it have a colour?" rather than the colour itself? That is a privacy-by-design choice. Knowing that "60% of created projects have a custom colour" is useful product information: it tells me personalization is valued. Knowing that "user X picked blue #4A90E2" is fine-grained behavioural data that brings nothing and widens the GDPR risk surface. The rule I hold myself to: always send the minimum information needed to answer the product question. If I cannot phrase the question, I do not send the parameter.

Why call tracking.track(...) from the ViewModel rather than the View? Because tracking follows the domain logic, not the display logic. If I refactor CreateProject tomorrow into two Views (one iPhone, one iPad) sharing the same ViewModel, tracking keeps working with no duplication. Track from the View instead and you risk forgetting the call in one variant, or double-tracking from two different Views.

Why does this approach scale well? Because adding a new trackable event takes exactly 3 steps: (1) add a case to the Event enum, (2) add a line to signalName and parameters, (3) call tracking.track(.newCase) where it matters. The compiler forces you through steps 1 and 2 (otherwise the switch statements stop being exhaustive), so you cannot forget. It is a design that turns discipline into muscle memory.

What it actually gives me on Keepio

  • Sentry: I see production crashes in real time with symbolicated stack traces, and the breadcrumbs give me the context (the user created a project, opened a note, then crashed in the Pomodoro).

  • TelemetryDeck: I know which features are genuinely used, where paywall conversions come from, and which widgets are underused. That data drives my roadmap.

The lessons

  1. Separate crash reporting from product analytics. They are two different problems with two different consent models.

  2. Façade + Adapters, always. App code should never import a third-party SDK directly.

  3. The exhaustive enum is your best friend. It costs you half an hour to set up and saves you months of production typos.

  4. #if !DEBUG everywhere in your providers*, not just at init. Otherwise your production dashboard gets polluted by local tests and your dev builds throw mysterious fatalErrors.

  5. Privacy-first is not just a marketing line. sendDefaultPii = false on Sentry, k-anonymity on TelemetryDeck, explicit opt-in in the UI: that is the baseline.

App mentioned

Keepio

Capture et organise tes idées

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

More articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI