PostlipMetrics for iOS
Native iOS SDK of Postlip Metrics. It speaks the Segment Tracking API Spec (see docs/api/tracking-api.md): product events, screens, identity, groups and aliases, plus foreground-based sessions, consent modes, an offline queue, app lifecycle events, crash and hang capture and Apple Search Ads attribution on the first open. It is written in Swift, has no third-party dependencies, runs on iOS 13 and later, and never reads the IDFV or any other hardware identifier.
Bu sayfa derleme sırasında depodaki packages/sdk-ios/README.md dosyasından üretilir.
The Android (com.postlip:metrics-android) and Flutter (postlip_metrics) SDKs have the same API and send the same messages.
Install
Swift Package Manager: in Xcode, File → Add Package Dependencies…, enter the URL of the postlip-metrics-ios repository and choose the version rule Up to Next Major from 0.1.0. In a Package.swift:
dependencies: [
.package(url: "<postlip-metrics-ios repository URL>", from: "0.1.0"),
],
targets: [
.target(name: "App", dependencies: [.product(name: "PostlipMetrics", package: "postlip-metrics-ios")]),
]CocoaPods:
pod 'PostlipMetrics', '~> 0.1'The package ships its privacy manifest (PrivacyInfo.xcprivacy, see Privacy).
Quick start
import PostlipMetrics
// AppDelegate.application(_:didFinishLaunchingWithOptions:) or the SwiftUI App's init — once per process.
PostlipMetrics.load("pk_live_…")
PostlipMetrics.screen("Home")
PostlipMetrics.identify("usr_7c1e", traits: ["email": "ayse@example.com", "plan": "pro"])
let id = PostlipMetrics.track("purchase_completed",
properties: ["order_id": "AJ-1001", "value": 240, "currency": "TRY"],
messageId: "purchase:AJ-1001")
// From the consent banner:
PostlipMetrics.consent(analytics: true, marketing: false)Examples/PostlipMetricsExample is a small SwiftUI app that does the above.
API
Every call is non-blocking and thread-safe and never throws into the app. The static methods of PostlipMetrics forward to PostlipMetrics.shared, a PostlipMetricsClient; tests can construct their own client with a separate storage directory, transport (MetricsHTTPClient or a URLSession), clock and scheduler.
Options
PostlipMetrics.Options — every field is optional and overrides what the panel configured (remote config).
From the panel the SDK uses the consent mode, the session timeout, the source's test flag (every message then carries context.test: true) and three automatic-capture switches: pageviews (off disables automatic screen views even with trackScreens), pageleave (off disables screen-leave timing) and errors (off disables crash and hang capture).
Debug mode
debug: true logs every message, drop, retry and server rejection. Errors that need attention — a refused write key (401), an invalid host — are logged in every mode. Messages sent with a pk_test_… key are test traffic: they show up in the panel's live event stream but stay out of the reports.
Consent
- The stored decision wins over the mode's default at the next launch.
analyticscontrols persistence (ids, session, the offline queue, lifecycle markers), crash and hang capture and screen-leave timing. Granting it keeps the current anonymous id, so the earlier events join; revoking it deletes everything stored except the decision itself (the ids stay in memory for the rest of the process).marketingcontrols the IDFA. Without it nothing reaches Meta or TikTok, not even hashed identifiers.- Every message carries
context.consent. The choice itself is stored (consent.json) so it holds at the next launch. - The SDK never asks for App Tracking Transparency permission; the app does, when it wants to.
Ad SDKs and deduplication
When the app also runs the Meta or TikTok SDK, the platform counts an event once if both sides send the same event id. The Postlip Metrics event id is the message id:
let id = "purchase:" + order.id // or: let id = PostlipMetrics.track("purchase_completed", properties: props)
PostlipMetrics.track("purchase_completed", properties: ["order_id": order.id, "value": 240, "currency": "TRY"], messageId: id)
// Meta / TikTok SDK: send the same `id` as the event's event id.A server-side copy of an event should send the same messageId and forward getContext() as its context.
Automatic capture
Sessions
A session starts at the first launch, after reset(), and when the app returns to the foreground after more than sessionTimeoutMinutes (30) in the background — or when a message arrives in the background (a push handler) after that long. While the app is in the foreground the session does not expire. The first message of a session carries context.session.start: true.
Lifecycle
Screens
Each screen() starts a screen view, sent with properties.pageview_id. With analytics consent the SDK times it: when the next screen view starts it sends $pageleave { path, pageview_id, active_ms, total_ms, final: true } for the previous one; when the app goes to the background it sends the same with final: false and pauses the clock, and the return to the foreground continues the same screen view. active_ms is foreground time, total_ms the time since the screen view started.
trackScreens: true turns on automatic screen views: UIViewController.viewDidAppear(_:) is swizzled once and every appearing controller is a screen named by its title, else its class name (without the module). Container controllers (UINavigationController, UITabBarController, UIPageViewController, UISplitViewController), Apple's own controllers — SwiftUI's hosting controllers among them — and controllers embedded in a custom parent are skipped. In SwiftUI, call screen() from .onAppear. When a new session starts at the return to the foreground, the current automatic screen is sent again as a new screen view of the new session.
Install attribution
$first_open carries context.install:
{ "installer": "app_store",
"appleSearchAds": { "attribution": true, "orgId": 1234567, "campaignId": 542370539, "adGroupId": 542317095,
"keywordId": 87675432, "adId": 542317136, "countryOrRegion": "TR",
"conversionType": "Download", "claimType": "Click" } }installer:testflightfor TestFlight (and development) builds, elseapp_store; omitted on the simulator.appleSearchAds(iOS 14.3+, AdServices): the SDK asksAAAttribution.attributionToken()and sends the token to Apple's attribution API; the answer's documented keys (plusclickDate) are kept. A404is retried three times 5 s apart; anything else, or no token (the simulator), leaves it out. The token itself never reaches the collector.- Resolved once per install, only with analytics consent;
load()never waits for it.
Crashes
With analytics consent and captureCrashes, uncaught Objective-C exceptions (NSSetUncaughtExceptionHandler) and fatal signals (SIGABRT, SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP — Swift runtime traps included) are written to a crash file as the app dies; the next launch sends them as one $crash event with the crashed process's own timestamp, anonymous id, user id and session:
{ "path": "/Checkout", "kind": "crash", "fatal": true, "type": "NSInvalidArgumentException",
"message": "Payment state [redacted]", "stack": "CoreFoundation __exceptionPreprocess\n…",
"stack_hash": "<32 lowercase hex>", "thread": "main", "app_version": "1.4.0", "app_build": "140" }messageandstackare redacted on the device before anything is written: e-mail addresses and runs of seven or more digits become[redacted], URL query strings are removed and absolute user paths become~/. The server scrubs again.stackis the exception's call stack without load addresses and offsets (at most 4000 characters, top frames first).stack_hashis the SHA-256 of the type and the frames' image and symbol names, so the same crash groups across launches and devices. A fatal signal records its name, time, thread and screen only: a signal handler may do nothing but write a record prepared in advance.- The handlers that were installed before are called after ours (other crash reporters keep working); the crash is never swallowed.
captureHangs: trueadds a watchdog: when the main thread does not answer for 5 s while the app is in the foreground, its stack is sampled and a$crashwithkind: "anr",fatal: false,type: "AppHang"is queued at once. It is paused in the background and while a debugger is attached.
Privacy
- The SDK never sends
device.id: no IDFV, no hardware identifier. The anonymous id is a random UUID of this install. - The IDFA is sent as
context.device.advertisingIdonly with marketing consent,collectAdvertisingIdand App Tracking Transparencyauthorized; revoking marketing consent stops it at once. contextcarries the app's name, version, build and bundle id, the device type,Appleand the model identifier (iPhone15,2), the OS version, the screen size, Wi-Fi / cellular, locale and time zone. TheUser-Agentnever contains the app's name, bundle id or device model.- Everything the SDK stores lives in
Application Support/PostlipMetrics/, excluded from backup, written atomically and never on the main thread. Without analytics consent only the consent decision is stored. If the files cannot be read yet (a background launch before the first unlock after a reboot), the SDK runs from memory for that process and writes nothing. - The server sees the request's IP address and user agent and stores neither with the event. For a conversion with marketing consent, while the app has a Meta or TikTok conversion destination, both are kept encrypted until the conversion is delivered to that platform, then deleted (Tracking API §9).
- Privacy manifest (
PrivacyInfo.xcprivacy):NSPrivacyTrackingis false and no tracking domain is listed (the collector receives every message, counted cookieless without consent). Declared data: product interaction, crash data, performance data (hangs), device id (the anonymous id; the IDFA only as described above), user id, advertising data (Apple Search Ads attribution) and coarse location (derived by the server from the IP address); the only required-reason API is the system boot time (35F9.1, the hang watchdog's clock). Identity traits your app passes toidentify()(e-mail, phone, name, address) are yours to declare in the app's own manifest and App Store privacy details.
Transport
Messages are batched — 20 messages, 30 s, when the app goes to the background (inside a background task) or on flush() — into POST {host}/v1/batch with a JSON body { batch, sentAt }, Authorization: Basic with the write key and the SDK's User-Agent. One request is in flight at a time; a request holds at most 20 messages and 512 000 bytes. A message over 32 KB is dropped (logged). sentAt is set at each attempt, so the server corrects the device clock.
Kept batches have no attempt limit: they leave the queue when delivered, when older than 30 days, or by the queue bound. The backoff starts over after a success and when the app returns to the foreground. With analytics consent the queue survives restarts (offline queue). Requests use an ephemeral URLSession (no cookies, no cache) with a 20 s timeout.