The Swift SDK for AppGlance: privacy-first, live analytics for apps. It answers who is using your app right now, how many opened it today, where they are, and anything you choose to track - with a random install id as the only identity, no IDFA, no consent banner, and two lines of setup. The Kotlin SDK is at AppGlance/appglance-android.
Xcode: File → Add Package Dependencies… and paste the repository URL. Or in Package.swift:
.package(url: "https://github.com/AppGlance/appglance-apple.git", from: "1.2.3")| Platform | Minimum |
|---|---|
| iOS / iPadOS | 16.0 |
| macOS | 13.0 |
| tvOS | 16.0 |
| watchOS | 9.0 |
| visionOS | 1.0 |
| Swift / Xcode | 5.10 / 15.3 |
No third-party dependencies, no binary blobs, and about a quarter of a megabyte added to an app. Compiles warning-free under strict concurrency checking and in Swift 6 language mode.
Facts that integrations most often get wrong, stated once:
- The package URL above resolves with no extra setup; use the version floor the Install
section quotes. The SwiftPM product name is
AppGlance. configurealone does not start sessions on Apple platforms..trackAppLifecycle()on the root view is mandatory, or every event the install ever sends folds into a single session.- Debug and Simulator builds do not send by default. Pass
debug: trueto send while testing; those events are tagged and appear under the dashboard's All scope, never under Live. This is the most common reason a fresh integration looks broken. - Write keys start
glance_live_and are write-only for one app's stream, which is why one can ship inside a binary. - Heartbeats,
user.identifyanduser.resetare never billed and never count as events. - Adding AppGlance to an app that already has users does not report them all as new: the SDK
sends when the app first arrived (
AppTransaction.originalPurchaseDate) and the dashboard counts them as "already had it". PassfirstInstalledAtonly if the app knows a date that reaches further back than the App Store's. - A paste-in integration prompt and the full agent-facing summary live at appglance.app/quickstart and appglance.app/llms.txt.
Create an app in the dashboard, copy its write key, and configure the
SDK as early as possible - your App initializer is the natural place:
import AppGlance
@main
struct MyApp: App {
init() {
AppGlance.configure(apiKey: "glance_live_…")
}
var body: some Scene {
WindowGroup {
ContentView()
.trackAppLifecycle() // sessions, presence, flush on background
}
}
}.trackAppLifecycle() on your root view records session.start when the app comes to the
front after more than five minutes away, sends a presence ping once the app has been in front for
a minute with nothing else sent - a real event proves presence exactly as a ping does, so an app
that is sending events never pings - and flushes when it leaves. The server may ask for a sparser
cadence for your account's plan, and the SDK then uses that. Brief interruptions - a notification,
a quick app switch - do not start a new session; neither does quitting and relaunching inside the
timeout.
It is not optional. Nothing else reports foreground and background, so without it no session
ever opens, "active right now" stays empty, and every event the install sends belongs to one
session that never ends. If the SDK is configured and sending, and no foreground signal arrives
within ten seconds, it prints one line to the console saying exactly that. A build the
environment gate has closed prints why it is silent instead, so turn on debug: true to see the
lifecycle warning from a Simulator or Debug run.
UIKit apps report the same transitions themselves, from the scene or application delegate:
func sceneDidBecomeActive(_ scene: UIScene) { AppGlance.setActive(true) }
func sceneWillResignActive(_ scene: UIScene) { AppGlance.setActive(false) }See yourself on the dashboard while integrating. By default Simulator and Debug builds send nothing, so your numbers only ever contain real installs. Turn on debug mode while you wire things up:
AppGlance.configure(apiKey: "glance_live_…", debug: true)This build now sends too - events tagged simulator / debug, visible under All in the
dashboard's scope switch, never in Live - and the SDK logs to the console ([AppGlance] …):
the environment and install id at configure, each event as it is queued, each send and what
the server said. Without debug mode, a gated build prints exactly one line saying it is not
sending and why.
AppGlance.track("paywall.viewed", metadata: ["source": "settings"]) // any lowercase.dotted name; ≤ 20 string keys
AppGlance.trackScreen("paywall") // records screen.paywall - the cheapest funnel stepIn SwiftUI, .trackScreen("paywall") on a view records screen.paywall each time it appears.
Keep names short and stable, and never put personal data in a signal or its metadata.
AppGlance.identify(id: account.id, email: account.email, name: account.name) // labels on the install
AppGlance.setUserProperties(["plan": "pro"]) // free-form, filterable
AppGlance.reset() // on sign-outThe install id stays the analytics identity; these are labels merged onto it. Calling
identify with the same values on every launch is free - only a change is sent, as
user.identify (never billable). Reserved keys $id, $email, $name; up to 20 keys of 40
characters, values up to 200; an empty string removes a key. Passing an email or a name changes
your App Store privacy answers - the dashboard's Setup page shows exactly how.
AppGlance.configure(AppGlance.Configuration(
apiKey: "glance_live_…",
enabledEnvironments: [.appStore], // narrow: production only
heartbeatInterval: 120
))| Option | Default | Notes |
|---|---|---|
flushInterval |
10 s |
Wait before sending a partial batch. Clamped to 1-3600. |
maxBatchSize |
20 |
Send at once when this many events are queued. Clamped to 1-500, the largest batch the ingest API accepts. |
heartbeatInterval |
60 s |
Seconds of silence in the foreground before a presence ping (drives "active now"). A real event resets it; the server may raise it for the account's plan. Never billable. Clamped to 15-3600: there is no way to switch presence off here. |
sessionTimeout |
300 s |
Away longer than this and coming back is a new session - the dashboard splits on the same gap. Clamped to 1-86400. |
isEnabled |
true |
Master off-switch (e.g. behind a user setting). Wins over everything, including debug. Turning it off also discards whatever an earlier run left queued on disk and the user properties identify stored, so a consent withdrawal covers what was already recorded, not just what comes next. |
collectsCountry |
true |
The device's region setting as a two-letter code. Not GPS, not IP. |
enabledEnvironments |
[.appStore, .testFlight] |
Which environments send; Simulator and Debug builds never do by default. |
firstInstalledAt |
nil (asks the App Store) |
When this person first got your app. Only worth setting if your app knows better than the store does; see below. |
debug |
false |
Sends from any environment (tag stays truthful) and logs to the console. |
endpoint |
hosted ingest | Point it at your own deployment of the ingest service. |
appID, appVersion |
bundle id, CFBundleShortVersionString |
Informational in hosted mode (the key identifies the app). |
Every event is tagged appstore, testflight, simulator or debug. Simulator and Debug
are compile-time facts and always tagged correctly; they are excluded by the default
enabledEnvironments, and debug mode lifts that gate without changing the tag.
The split between the two store channels comes from the store's signed AppTransaction:
.sandbox is TestFlight, .production the App Store, and .xcode (a store-signed build
launched from Xcode) is tagged debug, so a developer's machine never appears in Live. The
store answers asynchronously, so each launch starts from a guess - the receipt, and on macOS
the beta-distribution signing certificate - and the first flush waits briefly for the real
answer; anything already queued is restamped when it arrives. A build the store cannot vouch
for at all, such as an ad hoc or enterprise build or a Mac app downloaded from a website,
keeps the guess.
Every existing install mints its AppGlance id the first time your new build runs, so without help they would all read as new users on the day you ship: one enormous spike, with your real arrivals buried in it.
They do not. The SDK reports when the app first arrived - AppTransaction.originalPurchaseDate,
the date this Apple ID first got your app, which the SDK is already fetching to work out whether
this is a TestFlight or App Store build. The dashboard counts those people as already had it
rather than new, and your new-user numbers mean what they say from day one.
Nothing to switch on. It costs no extra API call, needs no permission, and adds nothing to your
App Store privacy answers: it is a date about the app, not about the person, sent once per
install alongside the install event.
Three things worth knowing:
-
The store's date is per Apple ID, not per device. Someone who bought your app three years ago, deleted it and downloads it again today counts as already having had it - which is true.
-
TestFlight builds send no store date. The sandbox answers every account with the same placeholder purchase date, so testers count as new unless your app passes a date of its own.
-
It cannot see further back than the App Store. If you keep your own signup or first-launch date, pass it as
firstInstalledAtand it wins over the store's answer:var config = AppGlance.Configuration(apiKey: "glance_live_…") config.firstInstalledAt = myAccount.createdAt AppGlance.configure(config)
Already shipped an older AppGlance and watched that spike happen? Upgrading fixes it. Every install that has not sent its date yet backfills on its next session, so the base corrects itself as people open the app.
- Every public call is cheap and non-blocking. Calls apply strictly in call order on one
background queue, timestamps are taken at call time, and calls made before
configure(or before the Keychain is readable after a reboot) are held - up to 200 - and replayed. - The install id is a random UUID in the Keychain, so delete-and-reinstall keeps it and the
person is counted once. It is device-bound: a backup restored onto a second handset mints a
fresh id there rather than reporting two devices as one.
installis recorded exactly once, first. - Configure from the app, not from an app extension. An extension is a separate process with its own container and its own Keychain access group, so it cannot see the app's install id; the SDK records nothing there and says so once on the console.
- Events are written to disk as they are tracked, so a crash loses nothing. The queue is capped
at 500 (oldest dropped), sent oldest-first in slices of 100, one send at a time.
429,5xxand offline keep the batch for later;413halves it; any other4xx(an unknown key, say) drops that slice rather than wedging the queue. - Retries never double-count. Every event carries a client-minted id and the server ignores replays; the presence ping - which is folded into rollups on arrival - is re-sent only when the server provably never saw it, and a ping dropped instead of retried stops pacing the next one, so presence is proved again rather than waiting out a second interval. A flush on the way to the background runs under a process assertion, so iOS does not suspend the app mid-request.
The SDK can also write straight from the device to a Supabase project you control - nothing
passes through AppGlance. It needs a project running the AppGlance events schema (the events
table, its unique index over (app_id, event_id), and an insert-only policy for the publishable
key). Configure with the project's URL and publishable key:
AppGlance.configure(.init(
supabaseURL: URL(string: "https://YOUR-REF.supabase.co")!,
publishableKey: "sb_publishable_…",
appID: "com.example.app"
))The publishable key can insert events, nothing else: raw rows are never readable from outside
(Row Level Security with no SELECT policy), and inserts are idempotent over (app_id, event_id),
so a retried batch is stored once.
- Identity is a random per-install UUID in the Keychain - not the IDFA, not tied to the
person. Reinstalls keep it; "erase all content" mints a new one. It stays on the device it was
minted on: the Keychain item is
…ThisDeviceOnlyand the local copy that backs it up is excluded from backup, so an iCloud restore or a device-to-device transfer does not carry it and the second device mints its own. - Country is
Locale.current.region- a settings value, not location data. - With the default setup, declare under Data Not Linked to You: Identifiers → User ID and
Usage Data → Product Interaction, purpose Analytics, no tracking. With
identify, add Contact Info (Email Address / Name), all under Data Linked to You, still no tracking. No ATT prompt either way - nothing here is tracking in Apple's sense. The dashboard's Setup page generates the exact answers per app. - The package ships a
PrivacyInfo.xcprivacydeclaring the above and uses no required-reason APIs beyond its own UserDefaults keys.
- Guides and the HTTP API: appglance.app/docs
- Release notes: CHANGELOG.md and GitHub Releases
- Questions or problems: open an issue or email support@appglance.app
- Contributing: CONTRIBUTING.md · Security: SECURITY.md
MIT - see LICENSE.