iOS Swift SDK
Experimently is a Swift Package (iOS 14+, macOS 11+, Swift 5.5+) providing feature flag
evaluation, experiment assignment and event tracking with async/await. It depends only on
Foundation (URLSession, NSCache, UserDefaults) and CommonCrypto.
Flag evaluation and experiment assignment are decided by the server: every call goes to the
public API with your X-API-Key, the server buckets the user (sticky per user + experiment), and
the SDK caches the answer per user + key for a TTL. Nothing is bucketed on device.
Flag evaluation sends only the user id.
GET /api/v1/feature-flags/evaluate/{key}?user_id=…carries none of the user's attributes, so the server evaluates a flag's targeting rules against an empty context. Experiment assignment sends them:getAssignment, withuser.attributes, sends the attributes ascontext. To evaluate a flag with attributes, call the evaluate endpoint with acontextquery parameter (a URL-encoded JSON object of the attributes).
Source: sdk/ios. A SwiftUI demo lives in sdk/ios/Examples/SwiftUIExample/ExampleApp.swift.
Installation
Not yet published. Build it from source: the manifest lives in sdk/ios of a clone of this
repository, so add the SDK as a local package:
// Package.swift
dependencies: [
.package(name: "Experimently", path: "../experimently/sdk/ios")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Experimently", package: "Experimently")])
]
Xcode: File → Add Package Dependencies… → Add Local… → sdk/ios → product Experimently.
Quick Start
import Experimently
let client = ExperimentationClient(config: SdkConfig(
baseURL: "http://localhost:8000", // origin only; the SDK appends /api/v1/...
apiKey: Secrets.experimentlyApiKey // sent as X-API-Key
))
let user = User(id: "user-123", attributes: ["plan": "pro"]) // attributes = assignment context
// 1. Assignment — POST /api/v1/tracking/assign (sticky, records the exposure)
var headline = "Buy now"
do {
let a = try await client.getAssignment("checkout_flow", user: user)
headline = a.configuration?["headline"] as? String ?? headline
} catch ExperimentationError.experimentNotFound {
// unknown or not ACTIVE (404): keep control
} catch {
// network / server error and nothing cached: keep control
}
// 2. Feature flag — GET /api/v1/feature-flags/evaluate/new_search?user_id=user-123
let flag = try? await client.evaluateFlag("new_search", user: user)
if flag?.enabled == true { /* ... */ }
// 3. Track with a key → one POST /api/v1/tracking/track (never throws)
try? await client.track(TrackEvent(userId: user.id, eventName: "purchase",
properties: ["sku": "pro-plan"],
experimentKey: "checkout_flow", value: 49.99))
// 4. Track without a key → fanned out to every cached assignment + flag of this user
await client.trackWithStatus(TrackEvent(userId: user.id, eventName: "page_view"))
Configuration
let config = SdkConfig(
baseURL: "http://localhost:8000",
apiKey: "your-api-key",
timeout: 10, // seconds
cacheSize: 1000,
cacheTTL: 300, // seconds
enableOfflineFallback: true
)
let client = ExperimentationClient(config: config, session: .shared, offlineStore: nil)
| Parameter | Type | Default | Description |
|---|---|---|---|
baseURL |
String |
"http://localhost:8000" |
Backend origin; trailing / tolerated |
apiKey |
String |
— (required) | Sent as X-API-Key on every request |
timeout |
TimeInterval |
10 |
URLRequest timeout per call |
cacheSize |
Int |
1000 |
Advisory NSCache countLimit for each in-memory cache (flags, assignments) |
cacheTTL |
TimeInterval |
300 |
Lifetime of a cached evaluation/assignment |
enableOfflineFallback |
Bool |
true |
Persist successful results in UserDefaults (prefix ep_sdk_) and serve them when a request fails |
session (client init) |
URLSession |
.shared |
Inject a mock session in tests |
offlineStore (client init) |
OfflineStore? |
OfflineStore() |
OfflineStore(suiteName:keyPrefix:) for App Groups / custom prefix |
ExperimentationClient(baseURL:apiKey:) is a convenience initializer with the defaults above. The
SdkConfig(... enableLocalEval:) initializer is deprecated and ignores the flag.
API Reference
ExperimentationClient conforms to ExperimentationClientProtocol (use it for injection/mocking).
| Method | Endpoint | Returns | On failure |
|---|---|---|---|
evaluateFlag(_ flagKey: String, user: User) async throws |
GET /feature-flags/evaluate/{key}?user_id= |
EvalResult |
throws ExperimentationError (see below) |
getAssignment(_ experimentKey: String, user: User) async throws |
POST /tracking/assign |
Assignment |
throws ExperimentationError |
track(_ event: TrackEvent) async throws |
/tracking/track or /tracking/batch |
Void |
never throws; throws kept for source compatibility |
trackWithStatus(_ event: TrackEvent) async |
same | Bool |
false on any failed request |
trackBatch(_ events: [TrackEvent]) async |
/tracking/batch (chunks of 100) |
Bool |
false when any chunk failed |
getAssignments(for userId: String) |
— | [Assignment] |
cached, unexpired, insertion order |
getEvaluatedFlags(for userId: String) |
— | [String] |
cached flag keys |
clearCache() |
— | Void |
drops the in-memory caches (offline store kept) |
clearOfflineCache() |
— | Void |
wipes everything the SDK wrote to UserDefaults |
close() |
— | Void |
clears memory caches and calls cancel() on its in-flight tasks; a request already sent is not stopped, and its caller still gets the answer |
refreshFlags() async throws |
— | Void |
deprecated no-op (no flag list to download) |
FeatureFlagEvaluator.hashUser(_ userId: String, flagKey: String) |
— | Double in [0, 1) |
pure |
Types
| Type | Members |
|---|---|
User(id:attributes:) |
id: String, attributes: [String: AnyCodable]? — sent as context on assignment |
EvalResult |
key: String, enabled: Bool, config: [String: Any]? (nil when the server returned null or a non-object) |
Assignment |
experimentKey: String, userId: String, variantId: String? (UUID), variantName: String ("control", "treatment", …), isControl: Bool, configuration: [String: Any]?; variantKey is a deprecated alias of variantName |
TrackEvent(userId:eventName:properties:experimentKey:featureFlagKey:value:eventType:timestamp:) |
properties → metadata; eventType defaults to eventName; value: Double?; timestamp: Date? → ISO-8601 (server-stamped when nil); hasKey, attributed(experimentKey:featureFlagKey:) |
ExperimentationError |
.invalidConfig(String), .networkError(Error), .decodingError(Error), .flagNotFound(String), .experimentNotFound(String), .serverError(Int, String), .cancelled — all LocalizedError |
Caching and failure behaviour
- Successful results are cached in memory per user + key (
UserKeyCache, NSCache-backed, TTLcacheTTL); a hit makes no request. Concurrent calls for the same user + key share one in-flight request, so mounting several views on the same experiment yields one assignment (and one exposure). Failures are never cached — the next call retries. - With
enableOfflineFallback(default) every successful result is also written toUserDefaults; those entries have no TTL and survive relaunches. - HTTP 404 (an experiment unknown or not ACTIVE, or an unknown flag key): throws
.flagNotFound(key)/.experimentNotFound(key), removes the persisted entry and never serves a stale value. - Network error, timeout, 401, 422, 429, 5xx, undecodable body: returns the persisted value
for that user + key when offline fallback is on and one exists; otherwise throws
.networkError,.serverError(status, body),.decodingErroror.cancelled. track,trackWithStatus,trackBatchnever throw.- The client is safe to use from any thread or task: caches and the in-flight table are
NSLock-protected and the lock is never held across a suspension point.
Tracking fan-out
With experimentKey and/or featureFlagKey set, track sends one POST /api/v1/tracking/track.
Without a key it sends one POST /api/v1/tracking/batch with one entry per experiment the user
was assigned to through this client (experiment_key) plus one per flag evaluated for the user
(feature_flag_key), taken from the in-memory cache. Nothing cached → nothing is sent and true
is reported. trackBatch keeps keyed events as-is and fans out the unkeyed ones; requests are
chunked at ExperimentationClient.batchLimit (100).
Conversions are matched to metrics by event name: a metric on purchase counts every
purchase event regardless of event_type.
SwiftUI
struct SearchView: View {
let client: ExperimentationClient
let user: User
@State private var newSearch = false
var body: some View {
Group { newSearch ? AnyView(NewSearch()) : AnyView(LegacySearch()) }
.onAppear { // `.task {}` on iOS 15+
Task { newSearch = (try? await client.evaluateFlag("new_search", user: user))?.enabled ?? false }
}
}
}
Consistent hash (compatibility utility)
FeatureFlagEvaluator.hashUser(userId, flagKey:) = MD5("{userId}:{flagKey}"), first 4 bytes
as little-endian UInt32, divided by 2^32 (0.6927449859213084 for "user-123", "my-flag").
It is kept only so the golden vectors in tests/sdk-contract/golden-vectors.json stay identical
across SDKs. Nothing in the SDK calls it to pick a variant — the server decides.
Backend endpoints used
Every request carries X-API-Key, Content-Type: application/json and Accept: application/json.
| SDK call | Method and path | Body / query | Response used |
|---|---|---|---|
evaluateFlag |
GET /api/v1/feature-flags/evaluate/{flag_key}?user_id=… |
— | {key, enabled, config}; enabled: false, reason: "inactive" when the flag is not ACTIVE; 404 only for an unknown key |
getAssignment |
POST /api/v1/tracking/assign |
{experiment_key, user_id, context?} |
{experiment_key, user_id, variant_id, variant_name, is_control, configuration}; 404 when the experiment is not ACTIVE |
track with a key |
POST /api/v1/tracking/track |
{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?} |
ignored |
track without keys, trackBatch |
POST /api/v1/tracking/batch |
{events: [<track body>, …]} (max 100 per request) |
ignored |
Errors: 401 bad key, 404 (an experiment unknown or not ACTIVE, or an unknown flag key), 422 invalid event, 429
rate limited (Retry-After). These paths share the backend's per-IP SDK_RATE_LIMIT_PER_MINUTE
ceiling (default 6000).
Contract smoke
It prints:
{"sdk":"ios","assign":{"variant_name":"control","is_control":true,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
SwiftPM build output goes to stderr (the repo runner uses swift run -q contract-smoke). Env:
EXPERIMENTLY_API_URL (default http://localhost:8000), EXPERIMENTLY_API_KEY (required),
CONTRACT_EXPERIMENT_KEY (default sdk_contract_ab), CONTRACT_FLAG_KEY (default
sdk_contract_flag), CONTRACT_USER_ID (default random smoke-<uuid>). The smoke uses
enableOfflineFallback: false, assigns on two clients (server stickiness), evaluates the flag,
tracks purchase with the experiment key, tracks page_view without a key and sends a 2-event
trackBatch. Fixtures: backend/scripts/seed_sdk_contract.py; repo-wide runner:
python tests/sdk-contract/live/run_live_contract.py --sdk ios --strict.
CI does not run this SDK's contract smoke against a real API: ios is not in the
sdks: list of the SDK Live Contract job in pr-qa-gate.yml.
Development
swift test: XCTest; URLSession is intercepted with a URLProtocol mock