Analytics, personalization and consent for iOS, tvOS, macOS and watchOS.
Built on mixpanel-swift. This SDK's foundation — its storage layer, concurrency model, type system, retry curve and flush loop — is derived from mixpanel-swift, which Mixpanel released under the Apache License 2.0. We inherited a design that has run in production for the better part of a decade rather than rediscovering its lessons. See NOTICE for the file-by-file attribution. Intempt is not affiliated with or endorsed by Mixpanel, Inc.
| Swift | 5.9+ |
| Xcode | 15.0+ |
| iOS / tvOS | 15.0+ |
| macOS | 12.0+ |
| watchOS | 8.0+ |
dependencies: [
.package(url: "https://github.com/intempt/intempt-swift.git", from: "0.1.0")
]In Xcode: File → Add Package Dependencies, enter the repository URL, and
choose Up to Next Major Version from 0.1.0.
pod 'Intempt', '~> 0.1.0'This is also what a React Native or other cross-platform wrapper depends on:
s.dependency 'Intempt', '0.1.0'The version appears in three places — Intempt.podspec, Intempt.sdkVersion,
and the v0.1.0 git tag the podspec resolves against. scripts/check-version-sync.sh
fails CI when they disagree, because a pod that ships reporting a version it is
not only surfaces when someone debugs a support ticket against the wrong source.
import Intempt
let intempt = try IntemptInstance.initialize(
apiKey: "yourPrefix.yourSecret",
orgId: "your-org",
projectId: "your-project",
sourceId: "your-source-id"
)
intempt.track(eventTitle: "Checkout Started", data: [
"cart_value": 129.99,
"item_count": 3,
])
intempt.identify(userId: "[email protected]", userAttributes: [
"plan": "pro",
"signup_date": Date(),
])initialize is declared on IntemptInstance. The Intempt enum is a namespace
for constants (sdkVersion, defaultFeedFields), not an entry point.
Later, reach the same instance from anywhere with IntemptInstance.mainInstance(),
which returns an optional rather than trapping if you call it before initialize.
initialize throws rather than logging and continuing. A blank orgId is a
build-time integration error, and silently posting to .../(null)/projects/...
forever is not a recovery.
intempt.track(eventTitle: String, data: [String: IntemptType]?)
intempt.identify(userId: String, eventTitle: String, userAttributes: [String: IntemptType]?, data: [String: IntemptType]?)
intempt.group(accountId: String, eventTitle: String, accountAttributes: [String: IntemptType]?)
intempt.alias(userId: String, anotherUserId: String)
intempt.record(eventTitle: String, userId: String?, accountId: String?, data: ..., userAttributes: ..., accountAttributes: ...)identify sets the user. group sets the account — Intempt's concept
for the company a user belongs to. record does both in one call.
intempt.productView(productId: String)
intempt.productAdd(productId: String, quantity: Int)
intempt.productOrdered(products: [(productId: String, quantity: Int)])intempt.experiments(names: ["checkout-button"]) { result in
// Result<[ExperimentChoice], IntemptError>
// ExperimentChoice: .experience, .variant, .target
if case .success(let choices) = result {
for choice in choices where choice.experience == "checkout-button" {
apply(choice.variant)
}
}
}
intempt.products(feedId: "feed-id", count: 10) { result in
// Result<[ProductRecommendation], IntemptError>
}experiments also takes groups:, optimizationType: (.experiment or
.personalization) and productId:. All are optional; omitting every filter
asks for everything that matches.
products defaults fields: to Intempt.defaultFeedFields deliberately. An
unfielded request returns every catalog column including raw ML embedding
vectors — measured at 443x the payload. Widen it on purpose, never by
omission.
intempt.consent(action: .accept, validUntil: 31_536_000)
intempt.consent(action: .reject, validUntil: 0).reject enforces the decision: collection stops and the queue is purged.
The withdrawal record itself is preserved and still transmitted — it's the
evidence the user objected, and dropping it would mean the objection is
recorded nowhere.
intempt.optOut() // stop collecting AND discard what's queued
intempt.optIn()
intempt.hasOptedOut()
intempt.logOut() // rotate anonymous identity
intempt.reset() // new identity, empty queueintempt.flush() // fire and forget
intempt.flush { sent in ... } // completion carries the count
intempt.flushInterval = 30 // seconds; 0 disables the timerintempt.autocapture.configure(.all)
intempt.autocapture.start()Or pick the families you want:
intempt.autocapture.configure(AutocaptureOptions(
screens: true, // UIViewController appeared -> "View screen"
taps: true, // UIControl action, e.g. a button -> "Action"
controlChanges: true, // UIControl value changed -> "Edit Field"
screenExits: true, // disappeared, carries dwell time -> "Leave screen"
rawTouches: true // taps NOT on a UIControl -> "Touch"
))taps and rawTouches are separate on purpose: a button press already emits
Action, so counting it as a Touch too would double-count every press.
rawTouches covers what the first one misses — taps that land on plain views.
Off by default, and nothing is swizzled until start(). .all turns on all
five; .none is the other preset.
intempt.setPushToken(deviceToken) // didRegisterForRemoteNotificationsWithDeviceToken
intempt.trackPushOpen(userInfo) // userNotificationCenter(_:didReceive:)
intempt.trackPushReceived(userInfo) // didReceiveRemoteNotification, incl. silentPass the raw Data. Do not stringify it first: the widespread
token.description idiom has produced the literal "32 bytes" since iOS 13,
and setPushToken returns false rather than registering that. The token is
sent as apns_token_<sourceId> on an App Install/Upgrade event, which is how
the destinations job finds the device.
APNs only. There is no Firebase or FCM dependency at any layer.
IntemptType accepts String, Int, UInt, Double, Float, Bool,
Date, URL, NSNull, NSNumber, and arrays/dictionaries of the same,
nested to any depth. Values that cannot survive the wire — NaN, infinity —
are rejected at the call boundary rather than serialized as the string "nan".
Events land in SQLite (WAL mode) under Library/, excluded from iCloud and
iTunes backup, with iOS data protection set to
COMPLETEUNTILFIRSTUSERAUTHENTICATION so a background flush cannot hang on a
locked screen. The queue is capped at 5,000 events; past that the oldest are
evicted, so an offline device cannot fill the user's disk.
A batch is deleted only after the server acknowledges it. Not before the POST, and never as a blanket delete.
swift test