İçeriğe geç
Belgeler menüsüiOS SDK (Swift)

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.

Bu sayfa paketin README dosyasından üretilir ve İngilizcedir; kod, seçenek ve API adları paketle birebir aynıdır.

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:

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:

RUBY
pod 'PostlipMetrics', '~> 0.1'

The package ships its privacy manifest (PrivacyInfo.xcprivacy, see Privacy).

Quick start

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

CallNotes
load(_ writeKey:, options:)Once per process (a second call is ignored and logged). Reads GET {host}/v1/config/{writeKey} (3 s timeout, kept in memory for the process); if that fails, built-in defaults are used. Calls made before the config is applied — also before load() — are buffered (at most 200, oldest dropped) and replayed in order with their own call time as timestamp.
track(_ event:, properties:, messageId:) -> StringProduct event. Names starting with $ are reserved for system events and refused (logged). messageId (1–100 printable ASCII characters, no spaces) is the id a server copy of the same event carries, so the event is stored once; anything else is ignored and the SDK makes its own id. Returns the message's id (yours, else the new one — also before load() finished and when the event is refused): pass it to the app's Meta or TikTok SDK as its event id (see Ad SDKs).
screen(_ name:, properties:)A screen view (type: "screen"). properties["path"] — a string starting with / — becomes the screen's path, else "/" + name.
identify(_ userId:, traits:)userId must be opaque (no e-mail or phone). It is kept for every later message until reset() (stored with analytics consent). Traits are sent as given and never stored by the SDK. The standard identity traits (email, phone, firstName, lastName, name, address { street, city, postalCode, country, state }) open or update the person's Mailing & SMS contact, encrypted, and are never stored with the event (Tracking API §4.1); the consent traits email_opt_in / sms_opt_in set the contact's e-mail / SMS consent with a proof. identify(traits: ["email": …]) without a user id works for a guest checkout. Call identify() before the track() of the same action (sign-up, sign-in, purchase): the event then carries the user id.
group(_ groupId:, traits:)Links the person to an account. An empty id is refused (logged).
alias(_ userId:, previousId:)previousId defaults to the current user id, else the anonymous id. The kept user id does not change.
reset()On logout: a new anonymous id, no user id, a new session. Consent stays.
consent(analytics:, marketing:)Applies and remembers the user's choice (see below). Only the flags you pass change.
flush()Sends the queue now.
getAnonymousId(), getContext()getContext() returns { anonymousId, userId?, session: { id }, consent: { analytics, marketing } } (Codable, or .dictionary) — what a server-side event of your backend forwards as its context so it joins this device's session and consent. Both are nil before the SDK has started.

Options

PostlipMetrics.Options — every field is optional and overrides what the panel configured (remote config).

OptionDefault
hosthttps://e.postlip.comCollector origin; trailing slashes are removed.
consentthe source's mode, else .required.required · .implied · .notRequired (sent as none).
debugfalseLogs every message, drop, retry and server rejection (rejected[], warnings[]) to os_log, subsystem com.postlip.metrics.
flushAt20Queue size that triggers a send; also the most messages per request (1–20).
flushInterval30 (seconds)The longest a message waits before a send.
maxQueueSize1000Queue bound; the oldest messages are dropped first.
sessionTimeoutMinutesthe source's value, else 30Clamped to 5–240.
trackLifecycletrue$app_open, $first_open, $app_updated.
trackScreensfalseAutomatic screen views (see Screens).
captureCrashestrueUncaught exceptions and fatal signals (analytics consent only).
captureHangsfalseMain-thread hangs of 5 s or more.
collectAppleSearchAdstrueApple Search Ads attribution on $first_open (iOS 14.3+).
collectAdvertisingIdtrueThe IDFA — still only with marketing consent and App Tracking Transparency authorized.
remoteConfigtruefalse skips GET /v1/config and runs on built-in defaults and these options.

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.

ModeBefore consent()
.required (default)Analytics and marketing are off. Messages are still sent — screens, $app_open, track, identify — and the server counts them cookieless; the anonymous id, session and queue live in memory only, so every launch is a new anonymous visitor. No $first_open / $app_updated, no crash capture, no IDFA.
.impliedAnalytics and marketing are on until the user declines.
.notRequiredEverything is on.
  • The stored decision wins over the mode's default at the next launch.
  • analytics controls 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).
  • marketing controls 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:

SWIFT
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

EventWhenproperties
$app_openAt a cold start (when load() runs with the app in the foreground, or at the first foreground after a background launch) and at every return to the foreground. Sent with or without analytics consent.{ from_background }
$first_openOnce per install, with analytics consent. Its timestamp is the launch's start (the load() call, also when consent comes later in that launch), so it is never later than the launch's other messages and its install source becomes the session's touch; it waits up to 20 s for the install facts (below) and carries context.install. If the process dies before, the next launch sends it with the stored timestamp and session.{}
$app_updatedWith analytics consent, at start, when the app's version or build differs from the stored one.{ previous_version, previous_build, version, build }

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:

JSON
{ "installer": "app_store",
  "appleSearchAds": { "attribution": true, "orgId": 1234567, "campaignId": 542370539, "adGroupId": 542317095,
                      "keywordId": 87675432, "adId": 542317136, "countryOrRegion": "TR",
                      "conversionType": "Download", "claimType": "Click" } }
  • installer: testflight for TestFlight (and development) builds, else app_store; omitted on the simulator.
  • appleSearchAds (iOS 14.3+, AdServices): the SDK asks AAAttribution.attributionToken() and sends the token to Apple's attribution API; the answer's documented keys (plus clickDate) are kept. A 404 is 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:

JSON
{ "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" }
  • message and stack are 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.
  • stack is the exception's call stack without load addresses and offsets (at most 4000 characters, top frames first). stack_hash is 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: true adds 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 $crash with kind: "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.advertisingId only with marketing consent, collectAdvertisingId and App Tracking Transparency authorized; revoking marketing consent stops it at once.
  • context carries the app's name, version, build and bundle id, the device type, Apple and the model identifier (iPhone15,2), the OS version, the screen size, Wi-Fi / cellular, locale and time zone. The User-Agent never 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): NSPrivacyTracking is 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 to identify() (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.

AnswerWhat happens
200The batch leaves the queue. In debug mode rejected[] and warnings[] are logged; rejected messages are never resent.
400, 403The batch leaves the queue (logged).
401Kept and retried with backoff; an error is logged each time (the write key may be wrong).
413The batch is split in two and each half retried; a single message leaves the queue.
429Kept; after Retry-After seconds the same batch (same messageIds) is sent again.
5xx, network error, timeoutKept; backoff min(24 h, 10 s × 4^attempt) ± 20 %.

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.