---
url: https://simpledatalogs.com/docs/sdks/swift.md
description: >-
  The SimpleDataLogs Swift package for macOS and iOS apps; install, configure,
  track, back-dated events, opt-out, flush and disable, sessions, crash
  redaction, the crash-safe outbox, lifecycle and caps.
---

# Swift

The `SimpleDataLogs` Swift package tracks events from macOS and iOS apps. It has no dependencies, uses Swift 6 strict concurrency, and keeps a crash-safe outbox on disk, so an event is never lost to a crash or a closed laptop.

* macOS 13+ and iOS 16+.
* Swift Package Manager.
* Ships a privacy manifest (`PrivacyInfo.xcprivacy`) for App Store review.

## Install

In Xcode, choose **File → Add Package Dependencies…**, enter `https://github.com/XKD-Labs/simpledatalogs-swift`, and add the `SimpleDataLogs` library to your app target.

Or in `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/XKD-Labs/simpledatalogs-swift", from: "0.4.0"),
],
targets: [
    .target(name: "MyApp", dependencies: [.product(name: "SimpleDataLogs", package: "simpledatalogs-swift")]),
]
```

::: info Early access
The package URL is published with the public launch. Until then, your invitation includes the package source: add it with **Add Local…** in Xcode, or `.package(path: "…")` in `Package.swift`.
:::

## Quick start

```swift
import SimpleDataLogs

// At launch: the opt-out first, then identity and default properties, then the key.
SimpleDataLogs.setEnabled(userAllowsUsageStatistics)
SimpleDataLogs.setDeviceID(anonymousDeviceID)
SimpleDataLogs.setDefaultProperties(["app_version": "1.1.0", "distribution": "website"])
SimpleDataLogs.configure(ingestKey: "sdl_pub_…")

// Anywhere, from any thread or actor; it returns at once.
SimpleDataLogs.track("screenshot_taken", properties: ["type": "area"])
```

Use a **public key** (`sdl_pub_…`) in apps. The SDK logs a warning if it sees a secret key.

## Autocapture

One argument sends launches, sessions and crashes without any `track` call. It is off until you pass it.

```swift
SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all)
```

| Option | What you get |
|---|---|
| `.appLaunches` | `app_launched` on every launch, with `is_first_launch` |
| `.sessions` | `session_start`, a `session_id` on every event, and `session_end` (`duration_s`) when the session ends; see [session modes](#session-modes) |
| `.crashes` | An uncaught `NSException` is saved at the crash and sent as `app_crashed` (`exception_name`, `reason`, `stack`) on the next launch |

`.all` is all three; pass a set to pick, e.g. `[.sessions, .crashes]`.

### Session modes

| `sessionMode:` | A session ends after | Suits |
|---|---|---|
| `.activation` (default) | 30 minutes away; macOS counts activation, iOS foreground and background | Regular windowed and iOS apps |
| `.inactivityTimeout(minutes: 30)` | That many minutes without a `track` call | Menu-bar and accessory apps, which are rarely frontmost |

```swift
SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all, sessionMode: .inactivityTimeout(minutes: 30))
```

* In `.inactivityTimeout`, the first `track` after a quiet gap sends `session_end` (stamped at the last activity) and `session_start` before the event itself.
* Back-dated events never move sessions.
* An app quit mid-session ends that session on the next launch, at its last activity.

### Crash reasons

By default the reason is sent word for word, up to 4,000 characters. Reasons can carry file paths, record ids or emails, so you can redact them:

```swift
SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all, crashReasonRedaction: .standard)
```

* `.standard` replaces digits with `#`, UUIDs and hex values with `<uuid>` and `<hex>`, paths and file URLs with `<path>`, and emails with `<email>`, then cuts the reason at 200 characters.

* Pick parts with `CrashReasonRedaction(shouldStripNumbers:shouldStripPaths:shouldStripEmails:maxLength:)`.

* File paths in `stack` frames are always cut to the file or module name.

* **Screen views** are opt-in per screen: `.trackScreenView("Settings")` on a SwiftUI view, or `SimpleDataLogs.trackScreen("Settings")` from UIKit/AppKit. Both send `screen_view` with a `screen` prop.

* Swift runtime traps (`fatalError`, force unwraps) and signals are not `NSException`s, so they are not captured.

* The opt-out applies: with `setEnabled(false)`, nothing is captured and a saved crash is discarded.

### Active and background

Every event tracked now says whether someone was using the app, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones:

* `app_state` is `active` while the app is in front, or on a Mac when there was keyboard or mouse input in the last 10 seconds (a menu-bar hotkey counts); otherwise `background`.
* `engaged_ms` is the time the app spent in front since the previous event.
* Mark background work yourself with `track("sync_finished", properties: ["app_state": "background"])`; your value wins.
* Turn it off with `configure(ingestKey: …, activity: .off)`. Back-dated events carry neither.

## API

| Call | What it does |
|---|---|
| `configure(ingestKey:endpoint:autocapture:sessionMode:crashReasonRedaction:)` | Starts the SDK once; `autocapture` defaults to none. `endpoint` defaults to `https://edge.simpledatalogs.com`; later calls are ignored |
| `trackScreen(_:properties:)` | Records `screen_view` with a `screen` prop. |
| `track(_:properties:)` | Records one event. `event_id` and `ts` are stamped now, when the event happens |
| `track(_:properties:at:eventID:)` | Records an event that happened earlier; see [back-dated events](#back-dated-events) |
| `track(_:properties:eventID:)` | Records an event now with your own `event_id`, so a resend is counted once |
| `setDeviceID(_:)` | Sets, or clears with `nil`, the `device_id` on every later event |
| `setDefaultProperties(_:)` | Replaces the properties merged into every event; the event's own properties win on a clash |
| `setEnabled(_:)` | The opt-out. `false` ignores new events and **deletes every unsent event**; `true` collects again |
| `flush() async` | Sends everything now and returns when that attempt ends. Undelivered events stay on disk |
| `disable()` | The same as `setEnabled(false)` |
| `flushAndDisable(timeLimit:)` | Ignores new events at once, sends what is queued (10 s by default), then turns collection off and deletes the rest. Async, or with a `completion:` callback |

### Properties

`PropertyValue` is a string, integer, double or boolean, and literals work directly:

```swift
SimpleDataLogs.track("export_finished", properties: ["format": "png", "duration_ms": 412, "is_pro": true])
```

Coming from Firebase or another loosely typed API? `PropertyValue(bridging:)` converts an `Any`, and returns `nil` for anything else:

```swift
SimpleDataLogs.track(eventName, properties: parameters.compactMapValues(PropertyValue.init(bridging:)))
```

`NaN` and infinity are dropped before the event is stamped.

### Back-dated events

Keep events from before the user agreed to share usage, such as onboarding steps, and send them later with their real time:

```swift
for step in savedOnboardingSteps {
    SimpleDataLogs.track("onboarding_step", properties: ["step": .string(step.name)], at: step.completedAt, eventID: step.id)
}
```

* `at` may be up to 3,650 days back, inside the ingest API's 10-year `ts` window. Older events are dropped with a log line; a future time is sent as now.
* `eventID` is optional. Pass a stable UUID when the same event might be sent twice, so it counts once.

## The opt-out

Call `setEnabled(_:)` **before** `configure`. A user who opted out then never sends a byte, and whatever an earlier launch left on disk is deleted instead of sent.

```swift
SimpleDataLogs.setEnabled(settings.isUsageSharingOn)
SimpleDataLogs.configure(ingestKey: "sdl_pub_…")

// Later, when the user flips the toggle:
SimpleDataLogs.setEnabled(newValue)
```

The SDK doesn't persist the choice. Your app's setting is the source of truth, so pass it on every launch.

To send what was already collected before stopping, use `flushAndDisable()` instead:

```swift
await SimpleDataLogs.flushAndDisable()
```

It ignores new events at once, skips any pending backoff for one last send, then deletes whatever did not make it within the time limit.

## How it behaves

### Batching

Events go out in batches of 50, or 10 seconds after the first event of a partial batch (5 seconds in debug builds). Every request stays under 256 KB.

### A crash-safe outbox

* Each event is appended to a journal on disk as it is tracked.
* Batches are sealed into files and deleted only after a `202`.
* The next launch resends anything left over, including a journal cut short by a crash.
* Files live in `Application Support/<bundle id>/SimpleDataLogs/` (inside the container for sandboxed apps) and are excluded from backups.
* On iOS, the files use the `completeUntilFirstUserAuthentication` protection class, so background work on a locked device can still track and send.

### Lifecycle

The SDK flushes when the app terminates and when the Mac goes to sleep (macOS), or when the app moves to the background (iOS). On iOS it asks for background time for that send, for at most 20 seconds. Anything a flush can't deliver in time stays on disk for the next launch.

Events tracked in `applicationWillTerminate` are kept and usually sent before the app quits:

* Once termination begins, the SDK holds the main thread until every queued event is in the journal (at most 1 second).
* Then it sends them, holding the quit until the send ends or 2 seconds have passed since termination began.
* Before termination, `track` never waits.

### Errors and offline

| Answer | What the SDK does |
|---|---|
| `202` | Deletes the batch file |
| `401` | Pauses sending and logs one configuration error. Re-probes one minute later, then every 10 minutes, and on the next launch. A new key that needs ~30 s to go live is picked up by the first re-probe |
| `413` | Splits the batch in half and resends. A single event still too large is dropped, with a log line |
| `415` | Sets the batch aside as `.unsupported` and moves on |
| Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, honoring `Retry-After` |

### Caps

* The outbox keeps at most **10,000 events, 10 MB or 500 batch files**. Past that, the oldest batches are dropped, with a log line.
* If more than 5,000 events wait to be written (a hot loop faster than the disk), new ones are skipped, with one log line, until it catches up.

Nothing throws or crashes your app.

## Debugging

The SDK logs through `os.Logger` with the subsystem `com.simpledatalogs.sdk`:

```sh
log stream --predicate 'subsystem == "com.simpledatalogs.sdk"' --level debug
```

For local development, point it at a local ingest:

```swift
SimpleDataLogs.configure(ingestKey: "sdl_pub_…", endpoint: URL(string: "http://localhost:4001")!)
```

::: tip Sandboxed Mac apps
A sandboxed app needs the `com.apple.security.network.client` entitlement to send.
:::

## Next steps

* [Core concepts](/guide/concepts#deployments): one deployment per build, such as `macos` and `macos-appstore`.
* [Privacy for senders](/reference/privacy): device ids, not emails.
* [Prompt recipes](/agents/recipes): have your coding agent wire it up.
