A/B Testing iOS SDK
Native Swift SDK for A/B experiments, feature flags, and event tracking on iOS. Signed user tokens, SwiftUI-friendly, delegate callbacks, 17 built-in event types, offline-first.
Native Swift SDK for A/B testing and feature flags on iOS. SwiftUI-friendly with delegate callbacks, 17 built-in event types, and an offline-first event queue. Anything done on behalf of a user needs a signed user token; you can initialize the SDK before sign-in and it waits for the token. Installs with Swift Package Manager.
- iOS 13.0 or higher.
- Swift 5.7 or higher.
- Xcode 14.0 or higher.
- A backend endpoint that returns an Abrova user token for the signed-in user (see step 3, User tokens).
Installation#
Install the SDK with Swift Package Manager.
In Xcode: File → Add Package Dependencies, enter the URL below and choose version 0.2.3:
https://github.com/Abrova-co/abrova-ab-testing-ios-sdk.git
Or add to your Package.swift:
dependencies: [
.package(url: "https://github.com/Abrova-co/abrova-ab-testing-ios-sdk.git", from: "0.2.3")
]Then import the module:
import AbrovaAbTesting
Initialization#
Initialize AbrovaAbTesting once, in your AppDelegate or App struct. Pass a tokenProvider so the SDK can get a user token from your backend.
import AbrovaAbTesting
let config = AbrovaAbTestingConfig(
apiKey: "YOUR_API_KEY",
debug: true, // Log to the console (development only)
// flushInterval: 10.0, // Your own send interval in seconds. Leave it out to follow the console
maxQueueSize: 100, // Max events before auto-flush (default: 100)
tokenProvider: { completion in
// Ask YOUR backend for an Abrova user token (see step 3).
// Complete with .failure while no one is signed in.
MyBackend.fetchAbrovaToken { result in completion(result) }
}
)
AbrovaAbTesting.shared.initialize(config: config, delegate: self)No SDK method stops your app when you call it before initialize, before the first load has finished or before a user is signed in: getters return the default you pass and the track… methods do nothing. If the first load fails (no network), the SDK works from its cache and loads again when the network is back.
Configuration
| Option | Type | Required | Description |
|---|---|---|---|
| apiKey | String | - | Your project API key (required) |
| tokenProvider | AbrovaTokenProvider? | nil | Returns a user token minted by your backend, or fails while no one is signed in. Required for assigning variants, tracking and server flag evaluation |
| userToken | String? | nil | A token you already hold. Prefer tokenProvider: a fixed token stops working when it expires. Hand over a newer one with setUserToken(_:) |
| debug | Bool | false | Log to the console. Logs never include your API key, tokens or request bodies |
| flushInterval | TimeInterval? | nil | How often queued events are sent, in seconds. Leave it out (or pass nil) to follow the Send interval set in the Abrova console (see below) |
| maxQueueSize | Int | 100 | Queued events that trigger a flush |
Send interval and list cache
Two values come from your project's settings in the Abrova console (A/B Testing › Settings › Defaults):
- Send interval — how often queued events are sent. The SDK follows it when you leave
flushIntervalout or passnil. A value you pass is your own and is always used. - List cache — how long the SDK uses the experiment list before it asks the service again. There is no SDK option for it. Once the list is older than that, the SDK fetches it again in the background the next time your code reads a variant or tracks an event. The call doesn't wait for it, and a failed refresh is not reported to the delegate's
didReceiveError.
A change you make in the console (a new experiment, changed weights or traffic) reaches running apps within the list cache time, without a restart. A user who already has a variant keeps it. A changed send interval is picked up at that same refresh. The SDK keeps the console's values within safe limits, and sends every 30 seconds until it knows the console's value. Apps on SDK versions older than 0.2.3 keep their built-in behaviour.
User tokens#
The API key ships inside your app, so it can't prove which user a request is for. A user token can. Your backend mints it with your project's server secret, the SDK sends it with each request, and Abrova takes the user from the token.
A token is required to get a variant from the server, track events and conversions, sync queued events, and evaluate flags on the server. Loading the experiment and flag lists needs only the API key, so your app can fetch them before anyone signs in.
Mint the token on your backend
Add one endpoint, protected by your own login, that calls Abrova and returns the token to the signed-in user:
curl -X POST https://auth.abrova.ir/users/token \
-H "x-api-key: $ABROVA_API_KEY" \
-H "x-server-secret: $ABROVA_SERVER_SECRET" \
-H "Content-Type: application/json" \
-d '{"userId": "user-123", "expiresIn": 3600}'
# → { "token": "eyJhbGciOi…", "userId": "user-123", "expiresIn": 3600, "expiresAt": "…" }expiresIn is in seconds: 3600 (1 hour) by default, 86400 (24 hours) maximum. The service takes the user from the token, so if you call setUserId(_:) in the app, pass the same userId.
The server secret can mint a token for any user. Keep it on your server, out of your app binary and source code.
One token for A/B Testing, Chat and Sync
This is the same Abrova user token that Abrova Chat and Abrova Sync use. If your app uses more than one of them, mint one token and give it to every SDK's tokenProvider instead of fetching one per product.
Give the token to the SDK
tokenProvider is an AbrovaTokenProvider: a closure that receives a completion handler. Call completion with .success(token) or .failure(error), from any thread. The SDK caches the token, asks for a new one shortly before it expires, and asks once more if Abrova reports it expired; requests made at the same time share one call. A provider that never calls completion counts as having no token after 15 seconds.
let tokenProvider: AbrovaTokenProvider = { completion in
// No one signed in: complete with .failure. The SDK then waits for a token.
guard let session = SessionStore.shared.current else {
completion(.failure(AuthError.notSignedIn))
return
}
MyBackend.fetchAbrovaToken(for: session) { result in
completion(result) // Result<String, Error>
}
}If you would rather hand tokens over yourself, leave tokenProvider out and call AbrovaAbTesting.shared.setUserToken(token) after sign-in and whenever you refresh the token. A token passed as userToken in the config works the same way: it isn't refreshed, so hand over a newer one before it expires.
Before your user signs in
You can call initialize before anyone is signed in. Without a token the SDK waits instead of failing:
AbrovaAbTesting.shared.isAwaitingUserTokenistrue, andonAwaitingUserTokenis called once, on the main thread, each time the SDK starts waiting.- Both forms of
getVariantreturn the default you pass. - Nothing is sent for a user and no error is reported. Events you track are kept on the device (at most 1,000).
The SDK carries on by itself as soon as it has a token: it fetches the variants your app asked for (the delegate's didAssignVariant is called for each) and sends the kept events.
AbrovaAbTesting.shared.onAwaitingUserToken = { /* e.g. show the default experience */ }
// After sign-in
AbrovaAbTesting.shared.refreshUserToken() // with a tokenProvider: asks it again now
AbrovaAbTesting.shared.setUserToken(token) // without one: hand the token over
// After sign-out
AbrovaAbTesting.shared.clearUserId() // or setUserToken(nil)Events are credited to the user in the token. Events recorded before sign-in are only sent if they were recorded for the same user id that signs in, so call setUserId(_:) with the id your backend puts in the token before you track. Events recorded for an anonymous user are not credited to anyone and are dropped at sign-in.
A token is valid until it expires, so store it with the user's session (Keychain) and reuse it across app launches. Your tokenProvider then only calls your backend when the stored token is close to expiry, not on every start.
User Identification#
The user token decides who the user is: once the SDK has a token, it acts as the user that token was minted for. Call setUserId(_:) with that same ID when a user signs in, and clearUserId() when they sign out. Until there is a user, the SDK uses a random ID it generates on first launch.
// After login — use the same ID your backend mints the token for
AbrovaAbTesting.shared.setUserId("user-123")
AbrovaAbTesting.shared.refreshUserToken() // or setUserToken(token) if you hand tokens over yourself
// After logout — forget the user and wait for the next token
AbrovaAbTesting.shared.clearUserId()
// Get current user ID
let userId = AbrovaAbTesting.shared.getUserId()
// Set attributes for targeting rules
AbrovaAbTesting.shared.setUserAttributes([
"plan": "premium",
"country": "US",
"age": 28,
"platform": "ios"
])A different user ID (setUserId(_:)), a token for another user, or a sign-out (clearUserId(), setUserToken(nil)) is a user switch. The SDK clears the previous user's variants, attributes, token and pending events before it does anything for the next user. Events of the previous user that are ready to send go out once more under that user's own token, never under another user's. Set the user attributes again after a switch.
Delegate Callbacks#
Implement AbrovaAbTestingDelegate to listen for SDK events. Every method is optional. The SDK holds delegates weakly, so keep a strong reference to yours; add more with addDelegate(_:) and remove them with removeDelegate(_:).
extension AppDelegate: AbrovaAbTestingDelegate {
func abrovaAbTestingDidInitialize() {
print("SDK initialized")
}
func abrovaAbTesting(didAssignVariant variantKey: String,
forExperiment experimentKey: String,
config: [String: Any]?) {
print("Assigned: \(experimentKey) -> \(variantKey)")
}
func abrovaAbTesting(didRefreshExperiments experiments: [Experiment]) {
print("Refreshed \(experiments.count) experiments")
}
func abrovaAbTesting(didRefreshFeatureFlags flags: [FeatureFlag]) {
print("Refreshed \(flags.count) flags")
}
func abrovaAbTesting(didReceiveError error: AbrovaAbTestingError) {
print("Error: \(error.localizedDescription)")
}
}A/B Testing#
Get Variant
The variant always comes from the service (this needs a user token) and is then kept on the device; the SDK never picks one itself.
// Asynchronous (recommended) — waits for the service's answer;
// completion runs on the main queue
AbrovaAbTesting.shared.getVariant(experimentKey: "checkout-redesign") { variant in
switch variant {
case "variant-a":
self.showNewCheckout()
default:
self.showOriginalCheckout()
}
}
// Synchronous — never blocks, safe on the main thread
let variant = AbrovaAbTesting.shared.getVariant(
experimentKey: "checkout-redesign",
defaultVariant: "control" // returned until the service has answered
)The synchronous getVariant returns the variant the service assigned earlier. The first time you ask for an experiment it returns defaultVariant at once and fetches the assignment in the background; the delegate's didAssignVariant tells you when it has arrived. Use the completion form the first time a screen needs the variant, otherwise the user may briefly see the default before their real variant.
You get the default variant back when the experiment isn't running, the user doesn't match its targeting rules, there is no user token yet, or the service can't be reached and nothing is stored. A user the service keeps out of an experiment (targeting, traffic allocation) gets the control variant. Tracking calls are always safe to make: you don't need to check whether the user is in the experiment before you track. The SDK sends these events like any other and the service decides what counts; they never enter the experiment's results.
Get Variant Config
Available once the user has a variant for the experiment.
let config = AbrovaAbTesting.shared.getVariantConfig(experimentKey: "checkout-redesign") let buttonColor = config?["button_color"] as? String ?? "#7C3AED" let layout = config?["layout"] as? String ?? "default"
List & Refresh Experiments
Needs only the API key.
// Get all experiments
let experiments = AbrovaAbTesting.shared.getExperiments()
for exp in experiments {
print("\(exp.key) [\(exp.status.rawValue)] - \(exp.variants.count) variants")
}
// Refresh from server
AbrovaAbTesting.shared.refreshExperiments {
print("Experiments refreshed")
}Feature Flags#
Check Feature Flag (Local)
Evaluated on the device against the cached flag list.
let darkMode = AbrovaAbTesting.shared.isFeatureEnabled("dark-mode")
if darkMode {
enableDarkMode()
}Check Feature Flag (Server Evaluation)
Needs a user token. Without one, or if the server can't be reached, the SDK falls back to local evaluation. The completion runs on the main queue.
AbrovaAbTesting.shared.isFeatureEnabled("dark-mode", defaultValue: false) { enabled in
if enabled {
self.enableDarkMode()
}
}Get Feature Value
Returns Any?, so cast it to the type you expect.
let maxUpload = AbrovaAbTesting.shared.getFeatureValue("max-upload-size", defaultValue: 10) as? Int ?? 10
let buttonColor = AbrovaAbTesting.shared.getFeatureValue("button-color", defaultValue: "#7C3AED") as? String ?? "#7C3AED"List & Refresh Flags
// Get all flags
let flags = AbrovaAbTesting.shared.getFeatureFlags()
for flag in flags {
print("\(flag.key): enabled=\(flag.enabled), rollout=\(flag.rolloutPercentage)%")
}
// Refresh from server
AbrovaAbTesting.shared.refreshFeatureFlags {
print("Flags refreshed")
}Event Tracking#
Track user interactions with 17 built-in event types. Events are credited to the variant the user was assigned, so get a variant for an experiment before tracking events for it. Events tracked while the assignment is still loading (or while the SDK waits for a user token) are kept and sent once the variant is known; events for an experiment you never asked a variant for are dropped.
Queued events are saved to disk and sent when the device is back online. The queue holds at most 1,000 events; when it is full, the oldest are dropped first. Events are sent 100 at a time. When the service can't be reached, asks the SDK to slow down, or your project has reached its usage limit, the SDK waits before it tries again. Each event carries the time your app recorded it, so events sent together keep their order.
Core Events
AbrovaAbTesting.shared.trackView(experimentKey: "experiment-key")
AbrovaAbTesting.shared.trackClick(experimentKey: "experiment-key")
AbrovaAbTesting.shared.trackConversion(experimentKey: "experiment-key", value: 1000000)
// Custom event
AbrovaAbTesting.shared.trackCustomEvent(
experimentKey: "experiment-key",
eventName: "button_tap",
properties: ["element": "cta_button", "duration_ms": 1500]
)Conversion Events
AbrovaAbTesting.shared.trackAddToCart(experimentKey: "experiment-key", value: 300000, productId: "sku-123", properties: ["quantity": 2]) AbrovaAbTesting.shared.trackRemoveFromCart(experimentKey: "experiment-key", value: 300000, productId: "sku-123") AbrovaAbTesting.shared.trackBeginCheckout(experimentKey: "experiment-key", value: 600000) AbrovaAbTesting.shared.trackPurchase(experimentKey: "experiment-key", value: 890000, transactionId: "txn-456", properties: ["currency": "Toman"])
Engagement Events
AbrovaAbTesting.shared.trackScroll(experimentKey: "experiment-key", depth: 75.0) AbrovaAbTesting.shared.trackFormSubmit(experimentKey: "experiment-key", formName: "signup") AbrovaAbTesting.shared.trackSearch(experimentKey: "experiment-key", query: "shoes") AbrovaAbTesting.shared.trackShare(experimentKey: "experiment-key", method: "twitter")
Media Events
AbrovaAbTesting.shared.trackVideoStart(experimentKey: "experiment-key", videoId: "vid-001") AbrovaAbTesting.shared.trackVideoComplete(experimentKey: "experiment-key", videoId: "vid-001")
Auth Events
AbrovaAbTesting.shared.trackSignUp(experimentKey: "experiment-key", method: "apple") AbrovaAbTesting.shared.trackLogin(experimentKey: "experiment-key", method: "biometric") AbrovaAbTesting.shared.trackLogout(experimentKey: "experiment-key")
Generic Event
AbrovaAbTesting.shared.track(
experimentKey: "experiment-key",
eventType: .custom,
eventName: "page_load_time",
eventValue: 2.3,
properties: ["page": "/checkout", "cached": false]
)EventType Enum#
| Option | Type | Required | Description |
|---|---|---|---|
| .view | - | - | User viewed the experiment |
| .click | - | - | User clicked/tapped |
| .conversion | - | - | Conversion event |
| .custom | - | - | Custom event with name |
| .scroll | - | - | Scroll depth tracking |
| .formSubmit | - | - | Form submission |
| .search | - | - | Search query |
| .share | - | - | Social share |
| .addToCart | - | - | Add item to cart |
| .removeFromCart | - | - | Remove item from cart |
| .beginCheckout | - | - | Begin checkout flow |
| .purchase | - | - | Completed purchase |
| .videoStart | - | - | Video playback started |
| .videoComplete | - | - | Video playback completed |
| .signUp | - | - | User registration |
| .login | - | - | User login |
| .logout | - | - | User logout |
API Reference#
| Option | Type | Required | Description |
|---|---|---|---|
| initialize(config:delegate:) | - | - | Initialize the SDK |
| setUserId(_:) | - | - | Set user ID; call on login |
| clearUserId() | - | - | Sign-out: forget the user and wait for a token |
| setUserToken(_:) | - | - | Hand over a user token (nil after sign-out) |
| refreshUserToken() | - | - | Ask the tokenProvider again now (after sign-in or sign-out) |
| isAwaitingUserToken | - | - | true while the SDK waits for a user token |
| onAwaitingUserToken | - | - | Called once each time the SDK starts waiting |
| getUserId() | - | - | Get current user ID |
| setUserAttributes(_:) | - | - | Set targeting attributes |
| getVariant(experimentKey:defaultVariant:) | - | - | Get assigned variant (never blocks; default until the service has answered) |
| getVariant(experimentKey:defaultVariant:completion:) | - | - | Get assigned variant (waits for the service) |
| getVariantConfig(experimentKey:) | - | - | Get variant configuration |
| isFeatureEnabled(_:defaultValue:) | - | - | Check feature flag (local) |
| isFeatureEnabled(_:defaultValue:completion:) | - | - | Check flag (server eval) |
| getFeatureValue(_:defaultValue:) | - | - | Get feature flag value |
| getFeatureFlags() | - | - | Get all feature flags |
| refreshFeatureFlags(completion:) | - | - | Refresh flags from server |
| getExperiments() | - | - | Get all experiments |
| refreshExperiments(completion:) | - | - | Refresh experiments from server |
| track(experimentKey:eventType:eventName:eventValue:properties:) | - | - | Track any event type |
| addDelegate(_:) / removeDelegate(_:) | - | - | Add or remove a delegate |
| flush() | - | - | Force sync pending events |
| reset() | - | - | Clear all state, cache and token |
Complete Example#
Full SwiftUI integration with user tokens, experiments, feature flags, and event tracking:
import SwiftUI
import AbrovaAbTesting
@main
struct MyApp: App {
init() {
AbrovaAbTesting.shared.initialize(config: AbrovaAbTestingConfig(
apiKey: "YOUR_API_KEY",
tokenProvider: { completion in
MyBackend.fetchAbrovaToken { result in completion(result) }
}
))
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
// In your login flow, once your backend knows the user
func onLoggedIn(_ user: User) {
AbrovaAbTesting.shared.setUserId(user.id) // the ID your backend puts in the token
AbrovaAbTesting.shared.refreshUserToken() // asks tokenProvider again now
AbrovaAbTesting.shared.setUserAttributes(["plan": user.plan])
}
// In your logout flow
func onLoggedOut() {
AbrovaAbTesting.shared.clearUserId()
}
struct ContentView: View {
@State private var variant = "control"
@State private var darkMode = false
var body: some View {
VStack(spacing: 20) {
// Feature flag: dark mode
Toggle("Dark Mode", isOn: $darkMode)
.padding()
// A/B test: checkout button
Button(action: {
AbrovaAbTesting.shared.trackClick(experimentKey: "checkout-button")
checkout()
}) {
Text(variant == "variant-a" ? "Buy Now" : "Checkout")
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(variant == "variant-a" ? Color.green : Color.blue)
.cornerRadius(12)
}
.padding(.horizontal)
}
.onAppear {
// Check feature flag
darkMode = AbrovaAbTesting.shared.isFeatureEnabled("dark-mode")
// Get variant, then track the view
AbrovaAbTesting.shared.getVariant(experimentKey: "checkout-button") { v in
self.variant = v
AbrovaAbTesting.shared.trackView(experimentKey: "checkout-button")
}
}
}
func checkout() {
AbrovaAbTesting.shared.trackConversion(experimentKey: "checkout-button", value: 500000)
AbrovaAbTesting.shared.trackPurchase(experimentKey: "checkout-button", value: 500000, transactionId: "txn-123")
AbrovaAbTesting.shared.flush()
}
}Lifecycle#
// Refresh experiments from server AbrovaAbTesting.shared.refreshExperiments() // Refresh feature flags from server AbrovaAbTesting.shared.refreshFeatureFlags() // Flush pending events AbrovaAbTesting.shared.flush() // Clear cached experiments, flags, assignments and the token. // Mainly for tests — call initialize(config:) again before using the SDK. AbrovaAbTesting.shared.reset()
To switch users, use setUserId(_:) on login and clearUserId() on logout rather than reset().