Swift
The native Apple-platform SDK (iOS 15+, macOS 12+) — module Quonfig,
Swift-only, Swift Concurrency throughout, zero external dependencies.
Like the JavaScript and React clients it is a frontend SDK: it fetches
the server-evaluated config for a context, caches it, polls for updates with
ETag/304, and reads typed values synchronously. Evaluation happens
server-side in Quonfig — there is no on-device targeting engine.
Install
Distributed via Swift Package Manager. In Xcode: File → Add Package
Dependencies… and enter https://github.com/quonfig/sdk-swift.git, or add it
to your Package.swift:
dependencies: [
.package(url: "https://github.com/quonfig/sdk-swift.git", from: "0.1.0"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "Quonfig", package: "sdk-swift"),
]
),
]
Then import Quonfig.
Initialize the client
Initialize with your frontend SDK key (the same key type the JavaScript / React SDKs use — never embed a backend key in a mobile binary, it's extractable):
import Quonfig
let quonfig = try await Quonfig.initialize(
context: QuonfigContext([
"user": ["key": .string("u_123"), "email": .string("a@example.com")],
"device": ["mobile": .bool(true)],
]),
options: .init(
sdkKey: "YOUR_FRONTEND_SDK_KEY",
domain: "quonfig.com" // one knob for api + telemetry hosts
)
)
initialize is async throws and resolves once the first envelope is
available — from the network, or, after a bounded initTimeout (default 5s),
from the cold-start cache or empty defaults. A hung network never hangs your UI.
While the store is warming (before the first envelope), isEnabled returns
false, the typed getters return your supplied default, and shouldLog logs
everything.
Read flags & configs
Reads are synchronous and never block — they serve from an in-memory snapshot. An absent key (or a not-yet-ready store) returns your default.
let on = quonfig.isEnabled("new-checkout") // Bool
let color = quonfig.string("button-color", default: "blue") // String
let limit = quonfig.int("rate-limit", default: 100) // Int
let ratio = quonfig.double("sample-ratio", default: 0.1) // Double
let pricing = quonfig.json("pricing") // [String: Any]?
// Full resolution details (value + reason + variant), for experiments/telemetry.
let details = quonfig.details("checkout-experiment")
Configs (non-flag values) are read the same way. By default configs are not sent to client SDKs — enable "Send to client SDKs" on each config you want a frontend SDK to read. Feature flags are always sent.
Context & updateContext
The context is a plain multi-namespace object. To switch identity (e.g. after a
sign-in), call updateContext — it immediately refetches the evaluated envelope
for the new context and keeps polling with it:
try await quonfig.updateContext(
QuonfigContext(["user": ["key": .string("u_456"), "email": .string("b@example.com")]])
)
React to live updates
The SDK polls (default 60s foreground) with ETag/If-None-Match, so an
unchanged flag set is a cheap 304. It refetches immediately on app foreground
and on updateContext, and suspends polling in the background.
Subscribe to be notified after every change (diff-before-notify, so unchanged polls don't churn) — SwiftUI-friendly:
let token = await quonfig.subscribe {
// re-read flags here and update your UI
}
// Hold the token; dropping it unsubscribes.
Dynamic log levels
Drive a logger's minimum level from a Quonfig log_level config, so you can
raise or lower verbosity from the dashboard without a redeploy. QuonfigLogger
is built on Apple's unified logging (os.Logger):
let log = QuonfigLogger(quonfig: quonfig, loggerKey: "log-level.my-app")
log.trace("entering render")
log.debug("cache warm: \(count) entries") // suppressed unless level <= DEBUG
log.info("signed in as \(userID)")
log.warn("retrying after \(err)")
log.error("checkout failed: \(err)")
The severity ladder is TRACE < DEBUG < INFO < WARN < ERROR < FATAL (the same
ranks every Quonfig SDK uses). A record emits iff its level is at or above the
configured threshold — a log_level config of WARN emits WARN/ERROR/FATAL
and drops the rest. The gate is re-checked on every call, so flipping the config
takes effect on the next log line. Message arguments are autoclosures, so a
suppressed record never builds its string.
Just want the decision (to gate your own logging framework)?
if quonfig.shouldLog(.debug, loggerKey: "log-level.my-app") { /* … */ }
Supply a QuonfigLogSink to bridge to another backend instead of os.Logger:
struct MySink: QuonfigLogSink {
func emit(level: QuonfigLogLevel, message: String) { /* forward anywhere */ }
}
let log = QuonfigLogger(quonfig: quonfig, loggerKey: "log-level.my-app", sink: MySink())
Quonfig evaluates server-side, so a frontend SDK gates against the single
threshold the server resolved for its current context. Per-logger overrides
(the backend SDKs' per-quonfig-sdk-logging.key routing) require on-device
evaluation and aren't available here — one log_level config drives one
threshold for the whole client. Best for application-wide log-level control, or
rules that key on stable context (user, app version, environment). Call
updateContext to re-evaluate after a context change.
Exposure-decoupled reads
Every getter has a logExposure: variant so debug screens / pre-render probes
can read a flag without it counting as an exposure for telemetry:
let v = quonfig.isEnabled("new-checkout", logExposure: false)
Offline & server-side evaluation
Because Quonfig evaluates entirely server-side:
- Cold start / offline for a previously-seen context works — the cached envelope is served instantly (no flicker), and also on a failed poll, so the last known-good values stay on screen.
- Offline with a new context can't be evaluated on-device; the SDK serves the cached envelope for a previously-seen context, or your supplied defaults.
This is the same trade-off LaunchDarkly and Statsig make.
Telemetry & privacy
By default the SDK uploads per-flag evaluation summaries (opt out with
collectEvaluationSummaries: false). Context shapes/examples are captured
server-side; tune with collectContextMode (PERIODIC_EXAMPLE default,
SHAPE_ONLY, or NONE).
The SDK ships an App Store PrivacyInfo.xcprivacy (wired into the SPM package
resources), and disables the eval request's URLCache so the context-bearing
eval URL is never written to an on-device cache.
Delivery options
How the SDK delivers telemetry, and what it does when the endpoint is slow or down, is the same in
every SDK and is explained once on the Telemetry page.
The Swift Configuration options and defaults (v0.1.0+):
| option | default |
|---|---|
telemetryFlushInterval | 60 (seconds) |
telemetryTimeout | 15 (seconds, foreground POST, connect and TLS included) |
telemetryMaxRetainedBatches | 5 |
telemetryMaxRetainedBytes | 524_288 (512KB) |
telemetryMaxRetainedAge | 300 (seconds) |
telemetryMaxEvaluationSummaries | 100_000 |
logSink | nil (os.Logger, subsystem com.quonfig.sdk, category Telemetry) |
Disk queue. Each window is serialized once and written to an on-disk queue (Application
Support, one file per batch) before it is POSTed, and deleted only after a 2xx. A window in flight
when the app is backgrounded or killed is resent byte for byte on the next foreground tick or
launch, unless it is older than 5 minutes by then.
Background. When the app goes to the background, the live window is written to disk and
POSTed once inside an OS background activity (ProcessInfo.performExpiringActivity), with a ~5s
network budget. shutdown() does the same final write-then-POST with the same ~5s bound, but runs
it directly in your await, not inside a background activity. In both cases older queued batches
wait for the next foreground tick, and the final POST is skipped if another POST is already in
flight or the 30s post-failure floor or a server Retry-After has not elapsed yet; the window
stays on disk and is sent on the next foreground tick or launch.
Changes in 0.1.0: a 401, 403 or 404 now stops telemetry for the process with one error and
deletes the queue (before, it was retried forever); the flush interval is a fixed 60s (was an 8s
to 300s backoff); the foreground timeout is 15s (was the 60s URLSession default); the disk queue is
capped at 5 batches / 512KB / 5 minutes (was 50 windows with no age limit). The 0.0.1 queue file is
deleted on first launch and its windows are not resent.
Teardown
await quonfig.shutdown() // stops polling + telemetry, sending one last window (~5s max)
Reference
Quonfig methods
| method | purpose |
|---|---|
Quonfig.initialize(context:options:initTimeout:) | create a client; resolves once the first envelope (network or cache) is available |
isEnabled(_:logExposure:) | Bool (default false) for a flag in the current context |
string(_:default:) / int(_:default:) / double(_:default:) | typed value or your default |
json(_:) | [String: Any]? for an object config |
details(_:) | full resolution details (value + reason + variant) |
subscribe(_:) | run a closure after every change; returns a cancellation token |
updateContext(_:) | switch identity and refetch |
shouldLog(_:loggerKey:) | whether a record at a level should emit under a log_level config |
logLevelThreshold(for:) | the resolved QuonfigLogLevel for a log_level config, or nil |
shutdown() | stop polling + telemetry (sends one last window; queued batches are not drained) |
context / isReady | current context; whether an envelope has been applied |
Configuration options
| option | type | default | description |
|---|---|---|---|
sdkKey | String | required | your frontend SDK key |
domain | String | "quonfig.com" | single knob flipping api + telemetry hosts in lockstep |
apiURLs | [URL]? | derived from domain | explicit API base URLs (failover order); wins over domain |
telemetryURL | URL? | derived from domain | explicit telemetry base URL; wins over domain |
pollInterval | TimeInterval | 60 | foreground poll interval; 0 disables polling |
collectEvaluationSummaries | Bool | true | upload per-flag evaluation summaries; other telemetry* options are under Delivery options |
collectContextMode | CollectContextMode | .periodicExample | .periodicExample, .shapeOnly, or .none |
requestTimeout / resourceTimeout | TimeInterval? | nil | per-request / resource timeouts |
customHeaders | @Sendable () -> [String: String] | {} | headers recomputed per request (e.g. rotating proxy tokens) |