eugenemind.com
← Back to blog

August 22, 2026 · 10 min read

iOS Logging and Analytics: A Practical Guide

#engineering #observability #mobile

Logging and analytics often get added almost mechanically. Add an SDK, send a few events, tick the box, move on. The problem is that they solve different things. If nobody thinks about that early, a few months later you may have plenty of data and still no useful answer.

Logging is for developers

Logs are mostly for developers. We write down important events and errors, then barely look at them while everything is working. The real test comes when something breaks. If the logging was added without a plan, finding one useful line in all that noise gets painful fast.

Usually we remember all of this too late: after a bug is already in production, or when a flow depends on too many external systems to test properly. That is when it becomes obvious that logging and analytics are easier to design before release than after.

Logs do not make bad code good, and analytics does not prevent bugs. They just make it much faster to understand where things went wrong.

What analytics is for

Analytics is mainly a product tool. How many people use the feature? Where do they drop out? Which step hurts conversion? Those are the questions it should help answer.

Main tools for logging

Three specific tools will come up as we go, here’s what’s inside each one.

os.Logger (native, Apple)

  • What it’s for: structured logs right on the device
  • Price: free, part of the iOS SDK
  • Own backend: not needed, everything stays on the device
  • Working with data: local only (Console.app, Xcode, sysdiagnose), no dashboard, no aggregation across users
  • Advanced features: subsystem/category and levels are already split apart at the API level

Firebase Crashlytics

  • What it’s for: crash reporting
  • Price: free, part of Firebase
  • Own backend: not needed, managed by Google
  • Working with data: dashboard with crashes grouped by stack trace, searchable by version and device
  • Advanced features: -

Sentry

  • What it’s for: error tracking and performance
  • Price: free tier with an event cap, paid beyond that
  • Own backend: managed (sentry.io) or self-hosted (open source)
  • Working with data: dashboards with release/environment filters, breadcrumb search
  • Advanced features: catches App Hangs/ANR, auto-captures breadcrumbs, configurable scrubbing

Where to start

Most SDKs already collect the app version and build number. The more interesting part is what you need to design yourself.

1. An anonymous ID that survives a reinstall. The app should be able to identify both authenticated and unauthenticated users. For an authenticated user, use the user ID provided by the backend or authentication system. For an unauthenticated user, generate an anonymous ID, for example a UUID, and store it in the Keychain so it remains available after the app is reinstalled.

enum AnonymousUserID {
    static var current: String {
        if let existing = Keychain.shared.string(forKey: "analytics_uid") {
            return existing
        }
        let id = UUID().uuidString
        Keychain.shared.set(id, forKey: "analytics_uid")
        return id
    }
}

The next question is where that ID should go. Calling Analytics.setUserId, Crashlytics.crashlytics().setUserID, and SentrySDK.setUser directly at every call site is a bad idea, three vendor calls smeared across the app, and swapping one tool out means hunting down every one of them by hand. Better to hide the SDKs behind a single interface and call that everywhere instead:

protocol AppLoggerProtocol {
    func setUserId(_ id: String)
    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any])
}

final class AppLogger {
    static let shared = AppLogger(destinations: [
        CrashlyticsDestination(), SentryDestination(), NativeLoggerDestination(),
    ])

    private let destinations: [AppLoggerProtocol]
    init(destinations: [AppLoggerProtocol]) { self.destinations = destinations }

    func setUserId(_ id: String) {
        destinations.forEach { $0.setUserId(id) }
    }

    func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any] = [:]) {
        destinations.forEach { $0.log(level, event, domain: domain, metadata: metadata) }
    }
}

AppLogger.shared.setUserId(AnonymousUserID.current)

Each *Destination here is just a small wrapper around one SDK: CrashlyticsDestination.setUserId internally just calls Crashlytics.crashlytics().setUserID, and so on. A tool gets added or dropped, and one destination changes instead of dozens of call sites across the app. From here on, AppLogger.shared is that single interface.

After login, call setUserId again with the real user ID. That makes it easier to connect what happened before and after authentication.

2. Severity and domain are two different axes, don’t conflate them.

Severity (debug/info/notice/error/fault) answers one question: how serious is this?

Domain (checkout/auth/onboarding) answers another one: where did it happen? Merge them into one and you either end up with three levels for the whole project with no way to filter by feature, or ten “domains” tangled up with severity that nobody can navigate. Well-thought-out domains later make it easy to group errors on a dashboard, you can immediately see which part of the app breaks the most, so it’s worth laying them out up front rather than adding them as you go.

For simplicity, the domains in the examples below are just a few enum cases. In a real project, typing is worth thinking through separately, one enum for the whole app, one per module, or something else, depending on the project’s structure, but a typed value is almost always better than a raw string: it makes searching the code and refactoring much easier.

AppLogger.shared.log(.error, "payment_failed", domain: .checkout, metadata: ["reason": "timeout"])
AppLogger.shared.log(.info, "screen_appeared", domain: .checkout)

Apple’s native os.Logger already has this split built in by default: category is essentially domain, and calling .error/.info is severity, separated at the API level, not by team convention:

import os

private let checkoutLog = Logger(subsystem: "com.example.app", category: "checkout")
checkoutLog.error("payment_failed reason=timeout")
checkoutLog.info("screen_appeared")

That’s exactly why the destination list when initializing AppLogger includes NativeLoggerDestination, it just forwards domain into category and severity into the os.Logger level, and the structured logs stay available locally (Console.app, sysdiagnose) even without a network connection and without any vendor SDKs.

3. Log app state transitions explicitly. A lot of annoying bugs come from lifecycle. The user backgrounds the app, comes back, receives a push, opens the same flow another way, then backgrounds it again. You will never test every combination by hand, but users will eventually find them. You can hook this through NotificationCenter, or, if the app is built on UIScene, directly in the UIWindowSceneDelegate methods, more accurate for apps with multiple scenes:

NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil, queue: .main
) { _ in AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle) }

Or the same thing in the delegate method, if the app is built on UIScene:

func sceneDidEnterBackground(_ scene: UIScene) {
    AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle)
}

Before adding your own hook, it’s worth checking whether a connected SDK already logs this: Sentry has automatic session tracking tied to exactly these foreground/background transitions, and there’s no need to duplicate it by hand.

4. Breadcrumbs, but scrubbed of secrets, not left raw. Sentry, for example, auto-captures breadcrumbs, network requests, navigation, taps, which is convenient, but it’s exactly why scrubbing secrets ahead of time matters: if a token is passed as a query parameter, Sentry will happily log that too. Hook the scrubbing before the event is sent, not after, don’t count on the system sorting it out on its own:

SentrySDK.configureScope { scope in
    scope.setBeforeBreadcrumb { crumb in
        if var url = crumb.data?["url"] as? String {
            url = redactQueryParams(url, keys: ["token", "access_token"])
            crumb.data?["url"] = url
        }
        return crumb
    }
}

5. User properties, where possible, but only what actually gets used in a dashboard, not “just in case.”

6. Screen stack, advanced territory, hard to track by hand. With custom transitions, modals presented over tab bars, and nested navigation controllers, figuring out “what screen is the user actually on” isn’t trivial. Some SDKs take this off your hands automatically: Firebase Analytics swizzles viewDidAppear and fires screen_view on its own, but the screen name ends up being the class name, not always what you want in a dashboard.

// Firebase's automatic screen_view will show "CheckoutReviewViewController".
// Override explicitly where the class name doesn't say anything useful:
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
    AnalyticsParameterScreenName: "checkout_review",
    AnalyticsParameterScreenClass: String(describing: type(of: self)),
])

7. A point where a user or tester can explicitly send logs. Automatic delivery is not enough in every case. If a tester hits a weird bug, a “Send logs” button is much more useful than asking them to reconstruct every step from memory. It’s important that this entry point stays reachable in any app state, logged in or logged out: a bug can just as easily show up right on the login screen, before the user has authenticated at all. That means buffering logs to disk locally even before they’ve gone anywhere, then flushing on an explicit action:

enum DiagnosticLogBuffer {
    static func append(_ line: String) {
        // size-capped ring buffer, persisted to disk
    }

    static func flushAndSend(reason: String) {
        SupportAPI.uploadDiagnostics(readAll(), reason: reason)
    }
}

// A visible entry point, debug menu, support screen, shake gesture
Button("Send diagnostics") {
    DiagnosticLogBuffer.flushAndSend(reason: "user_initiated")
}

You do not always need your own server for this. For a small team, there is a much simpler option. Simpler to export the buffer to a file and hand it to the native share sheet, for a tester and for a regular user alike, if a bug ever makes it to production: whoever sends it decides whether it goes to Slack, AirDrop, or email. Slack usually already has an obvious landing spot for this, a support or team channel that these things end up in anyway, so there’s no destination to invent, and you don’t have to stand up any infrastructure for it:

func shareDiagnosticLogs(from viewController: UIViewController) {
    let fileURL = DiagnosticLogBuffer.exportToFile()
    let activity = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
    viewController.present(activity, animated: true)
}

What to send to analytics

Once you have a single interface, it becomes very tempting to send everything to analytics. Every tap, every screen, every tiny state change. I would avoid that. Send every little thing and the dashboard quickly turns into the same noise mentioned at the start of this post, just a product-side version instead of a technical one.

Choose deliberately: important product events (checkout_started, payment_failed, onboarding_completed), and it’s worth adding critical errors that genuinely break the user’s flow to that list too. They’re more useful seen right inside the funnel, next to the step where they happened, than sitting in a separate log divorced from that user’s context. Raw technical detail, stack traces, intermediate state, debug info, doesn’t belong there: that’s what logging and crash reporting, covered above, are for.

Every tool has limits

Every tool has limits. Some get expensive quickly, some have weak search, and some are great for one type of problem but poor for another. It’s usually smarter to run several systems at once rather than betting on one: each has different strengths and weaknesses, and you only really find that out on a real commercial project.

There is also a practical detail with crash reporting: the report does not reach the server at the exact moment the app crashes. At that point the process is already failing, so the SDK usually stores the report locally first. Firebase Crashlytics, for example, only uploads it on the app’s next launch. If the user never reopens the app, that report might never reach the server at all, or arrive very late.

Sentry is built differently, and catches something a plain crash reporter can’t see in the first place, app hangs (App Hangs on iOS, ANR on Android): cases where the main thread is blocked for several seconds but the app never actually crashes.

For anything genuinely critical, waiting for “next launch” isn’t always convenient, and that’s where the same mechanism from point 7 above comes in handy: if the app already has an explicit place to collect all the data and send it (that same share sheet), you can reuse it here too, instead of building a separate channel just for emergencies.

And it’s worth keeping the limits in mind separately, especially on paid tools, and always having a way to turn a logging system off if it starts causing the problem itself.