Shipping an app is not the end of the story. It’s the beginning of a question: is it working?

For consumer apps, “working” means users aren’t hitting errors, flows are completing, and the app isn’t doing something unexpected in the wild that you never caught in testing. For an IoT app specifically, it also means: are devices behaving as expected? Are commands reaching the hardware? Are outage alerts firing? Is the WebSocket staying connected?

Without instrumentation, you’re flying blind. A user reports “the oven wouldn’t turn off” and you have nothing to go on. A power outage alert fires and you don’t know if the app received it. A WebSocket reconnects silently and you don’t know how often it’s dropping.

The naive response is to add print() statements. They work locally during development, but print() has three problems in a real app:

  1. It’s gone at runtime. Print output isn’t accessible on a device that’s not plugged into your Mac.
  2. It’s unstructured. There’s no way to filter “just auth events” or “just errors” in a sea of print statements.
  3. It exposes PII. A print statement that logs a user ID is visible to anyone with the device plugged in.

Structured telemetry means treating every meaningful event in your app as a first-class thing — a typed value you can filter, search, and reason about — rather than an ad-hoc string.

Apple’s unified logging system (the os.Logger API) gives you this for free on device. Logs are queryable in Console.app, filterable by subsystem and category, and respect privacy annotations that ensure sensitive data is never readable in crash reports or shared logs.

The key architectural move is to define your events as an enum. This gives you:

  • A complete catalogue of everything the app can log. New events are added to one place, not scattered across files.
  • Compile-time safety. You can’t typo a log message — the compiler enforces that events are valid.
  • Testability. Because events are typed values, a mock can record them and tests can assert on them with XCTAssertEqual.

The abstraction also means you can swap the logging backend later — Apple Unified Logging today, a cloud analytics service tomorrow — without changing anything in the ViewModels.


LogEvent — the catalogue of everything worth knowing

LogEvent is an Equatable enum covering every meaningful business event in the app:

enum LogEvent: Equatable {
    // Auth
    case sessionRestored(userId: String)
    case userLoggedIn(userId: String)
    case userLoggedOut
    case authFailed(AppError)

    // Device lifecycle
    case devicesLoaded(count: Int)
    case deviceAdded(deviceId: String, deviceType: String)

    // Oven
    case ovenPowerChanged(deviceId: String, compartment: String?, isOn: Bool)
    case ovenTemperatureSet(deviceId: String, compartment: String?, temperature: Int)

    // Fridge
    case fridgeDoorOpened(deviceId: String, isFreezer: Bool)
    case fridgeDoorClosed(deviceId: String, isFreezer: Bool, openDurationSeconds: Int)
    case fridgeDoorLeftOpenAlert(deviceId: String, openDurationSeconds: Int)
    case powerOutageDetected(deviceId: String)
    case powerRestored(deviceId: String)

    // Errors
    case commandFailed(command: String, deviceId: String, error: AppError)
    // ... and more
}

OSLogTelemetryService — production logging with privacy

The production implementation routes each event to a category-specific os.Logger. This means in Console.app you can filter to just fridge logs, or just errors, without seeing anything else:

final class OSLogTelemetryService: TelemetryService {
    private lazy var authLogger       = Logger(subsystem: subsystem, category: "auth")
    private lazy var fridgeLogger     = Logger(subsystem: subsystem, category: "fridge")
    private lazy var errorLogger      = Logger(subsystem: subsystem, category: "errors")

    func record(_ event: LogEvent) {
        switch event {
        case .userLoggedIn(let userId):
            // .private(mask: .hash) means the actual ID never appears in logs —
            // only a consistent hash, so you can correlate events for one user
            // without ever seeing their ID
            authLogger.info("User logged in — \(userId, privacy: .private(mask: .hash))")

        case .fridgeDoorLeftOpenAlert(let deviceId, let seconds):
            fridgeLogger.warning("Door left open \(seconds)s — device=\(deviceId)")

        case .powerOutageDetected(let deviceId):
            fridgeLogger.error("Power outage detected — device=\(deviceId)")

        case .commandFailed(let command, let deviceId, let error):
            errorLogger.error("Command '\(command)' failed — device=\(deviceId): \(error.localizedDescription ?? "", privacy: .public)")
        }
    }
}

MockTelemetryService — assertions in tests

Because LogEvent is Equatable, tests can assert on exactly what was logged. This is far more valuable than checking log strings:

let telemetry = MockTelemetryService()
let vm = FridgeViewModel(api: mock, telemetry: telemetry)

// Simulate a WebSocket update with door open
await vm.applyUpdate(deviceId: "fridge1", compartment: nil, data: openDoorData, deviceName: "Kitchen Fridge")

XCTAssertTrue(telemetry.contains(.fridgeDoorOpened(deviceId: "fridge1", isFreezer: false)))
XCTAssertEqual(telemetry.powerOutages.count, 0)

The mock also provides convenience accessors for common patterns:

var commandFailures: [(command: String, deviceId: String, error: AppError)] {
    events.compactMap {
        if case .commandFailed(let cmd, let id, let err) = $0 { return (cmd, id, err) }
        return nil
    }
}

Viewing logs in Console.app

To see live logs from the running app:

  1. Open Console.app on your Mac
  2. Select your connected device or simulator
  3. Filter by subsystem: com.smarthome.iotfridge
  4. Filter by category — fridge, oven, dishwasher, auth, websocket, or errors

Every command, every state change, every alert, and every error is visible in real time, filterable, searchable, and stored on device for later analysis — all without sending any data off-device.


Leave a Reply