AppGlance

1.2.4

AppGlance for Apple platforms: the Swift SDK. Live, privacy-first app analytics for iOS, macOS, tvOS, watchOS and visionOS. No advertising id, no consent banner, one line to integrate.
AppGlance/appglance-apple

What's New

1.2.4

2026-08-22T15:37:10Z

Added

  • Existing users are no longer counted as new. An app that adds AppGlance years after launch
    has a user base the SDK has never seen, and every one of those installs used to read as a brand
    new user on the day that build shipped: one enormous spike, with the real arrivals buried in it.
    The SDK now reports when the app first arrived, so the dashboard can tell the two apart.

    On Apple platforms the answer is already in hand and costs nothing: the signed AppTransaction
    the SDK fetches to label the environment also carries originalPurchaseDate, the date this
    Apple ID first got the app. No new API, no permission, no privacy-manifest entry, nothing added
    to your App Store answers - it is a date about the app, not about the person, and it is sent
    once per install. TestFlight builds send no store date: the sandbox answers every account with
    the same placeholder, which is not evidence of anything.

    Nothing to do to get it. Upgrading is enough: an install that has never sent one backfills on
    its next session.start, so an app already running an older AppGlance corrects its whole base
    as people open it.

  • Configuration.firstInstalledAt, for when your app knows better than the App Store does. The
    store's date is per Apple ID, so it cannot see a user who had an account with you before they
    had this device. If you keep your own signup or first-launch date, pass it and it wins:

    var config = AppGlance.Configuration(apiKey: "glance_live_…")
    config.firstInstalledAt = myAccount.createdAt
    AppGlance.configure(config)

    A date in the future, or one from before 2001, is ignored.

AppGlance for Apple platforms

CI SwiftPM Platforms License

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.

Install

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.

For AI coding agents

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.
  • configure alone 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: true to 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.identify and user.reset are 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". Pass firstInstalledAt only 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.

Set up

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.

Track things

AppGlance.track("paywall.viewed", metadata: ["source": "settings"])   // any lowercase.dotted name; ≤ 20 string keys
AppGlance.trackScreen("paywall")                                     // records screen.paywall - the cheapest funnel step

In 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.

Who, if you choose

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-out

The 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.

Configuration

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).

Environments

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.

Adding AppGlance to an app that already has users

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 firstInstalledAt and 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.

Guarantees

  • 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. install is 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, 5xx and offline keep the batch for later; 413 halves it; any other 4xx (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.

Bring your own Supabase

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.

Privacy and App Store labels

  • 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 …ThisDeviceOnly and 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.xcprivacy declaring the above and uses no required-reason APIs beyond its own UserDefaults keys.

Documentation and support

License

MIT - see LICENSE.

Description

  • Swift Tools 5.10.0
View More Packages from this Author

Dependencies

  • None
Last updated: Mon Sep 07 2026 03:45:16 GMT-0900 (Hawaii-Aleutian Daylight Time)