Experiments and Feature Flags
Experiments and feature flags
Most apps run A/B tests and roll features out behind flags, but the results usually live in a different tool than your crash, performance, and session data. Embrace closes that gap: when you tell the SDK which experiments and feature flags a user is exposed to, every session part, log, crash, and trace produced from that point on carries that context.
That lets you answer questions like:
- Did the new checkout variant increase the crash rate?
- Is the app slower to start for users in the treatment group?
- Which flag was enabled on the sessions where this error spikes?
How it works
You declare enrollment; Embrace does not assign it. The SDK is not an experimentation platform and does not decide which variant a user gets. You keep using whatever system you already have and pass the resulting assignment to Embrace so your telemetry can be segmented by it.
A few properties are worth knowing before you integrate:
- Declared state belongs to the process. Once declared, a record is attached to everything the SDK reports for as long as the app runs. That covers every session part produced by the process, and every user session those parts belong to.
- It is not persisted across launches. Cold-starting the app clears the declared state, so you have to declare it again on every start.
- Experiments and feature flags are separate kinds. A record is identified by its kind together with its id, so an experiment and a feature flag can share the id
dark-modeand stay independent of each other. - The first call wins. The first call for a given id fixes its variant and start time. Later calls for that same id change nothing, even after the record has been ended.
- Ended records are kept. Ending a record records its end time rather than removing it, so a variant the user was exposed to earlier in the process stays visible in the data.
These APIs do nothing until the SDK has been started. Calls made before EmbraceIO.start(options:) are ignored rather than queued, so declare enrollment after you start the SDK.
Because the state is process-scoped, it does not follow a user session across process death. A user session can outlive the process that started it — a background launch or a relaunch within the inactivity timeout continues it — but the new process starts with nothing declared. Declaring enrollment on every start, as described below, keeps the records attached to every part of the user session.
Tracking experiments
Use trackExperiment(id:variant:startedAt:) for a single experiment:
import EmbraceIO
EmbraceIO.shared.trackExperiment(
id: "checkout-redesign",
variant: "treatment"
)
Both variant and startedAt are optional. Omitting startedAt uses the moment of the call, which is what you want when you declare enrollment as soon as you resolve it:
// No variant — you only care that the user is in the experiment
EmbraceIO.shared.trackExperiment(id: "new-onboarding")
// Enrollment started earlier, and you are catching Embrace up
EmbraceIO.shared.trackExperiment(
id: "checkout-redesign",
variant: "treatment",
startedAt: assignment.receivedAt
)
When you resolve several assignments at once — the usual case, right after your experimentation SDK finishes fetching — declare them in a single call with trackExperiments(_:):
import EmbraceIO
let experiments = assignments.map { assignment in
TrackedExperiment(
id: assignment.key,
variant: assignment.variant
)
}
EmbraceIO.shared.trackExperiments(experiments)
TrackedExperiment and TrackedFeatureFlag live in EmbraceSemantics, which EmbraceIO re-exports, so import EmbraceIO is all you need.
Tracking feature flags
Feature flags work exactly like experiments and follow the same rules. They are kept as a separate kind so the dashboard can present them as a distinct experience:
import EmbraceIO
// A single flag
EmbraceIO.shared.trackFeatureFlag(id: "new-video-player", variant: "enabled")
// Several at once
EmbraceIO.shared.trackFeatureFlags([
TrackedFeatureFlag(id: "new-video-player", variant: "enabled"),
TrackedFeatureFlag(id: "offline-mode", variant: "disabled"),
TrackedFeatureFlag(id: "beta-search")
])
Ending an experiment or flag
When a user stops being exposed to an experiment or flag during the same app run, mark it as ended. The record stays in your data with an end time attached, so the end is known rather than inferred:
// A single record
EmbraceIO.shared.untrackExperiment(id: "checkout-redesign")
EmbraceIO.shared.untrackFeatureFlag(id: "new-video-player")
// Several at once, with an explicit end time
EmbraceIO.shared.untrackExperiments(ids: ["checkout-redesign", "new-onboarding"])
EmbraceIO.shared.untrackFeatureFlags(ids: ["offline-mode"], endedAt: userLoggedOutAt)
Untracking is scoped to the kind you call it on: untrackExperiment(id: "dark-mode") leaves a feature flag with the same id untouched. Only the first call for a given id takes effect, and ids that were never tracked are ignored.
You do not need to untrack anything at app exit. The declared state lives only for the current process.
A typical integration
The common shape is to declare enrollment as soon as your experimentation SDK hands you assignments, after Embrace has been started:
import EmbraceIO
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
do {
try EmbraceIO.start(
options: EmbraceIO.Options.withAppId("YOUR_APP_ID")
)
} catch let e {
print("Error starting Embrace \(e.localizedDescription)")
}
experimentationClient.fetchAssignments { assignments in
EmbraceIO.shared.trackExperiments(
assignments.experiments.map {
TrackedExperiment(id: $0.key, variant: $0.variant)
}
)
EmbraceIO.shared.trackFeatureFlags(
assignments.flags.map {
TrackedFeatureFlag(id: $0.key, variant: $0.value)
}
)
}
return true
}
}
If a user's assignments change mid-run — after a login, or a remote config refresh — declare the new ids as they appear and untrack the ones that no longer apply. Re-declaring an id you already tracked does nothing, so you can safely call trackExperiments(_:) with your full assignment set after every refresh.
Experiments and user personas
Experiments and user personas both segment your data, but they are scoped differently and are not interchangeable:
- A persona describes the user, is written with a
MetadataLifespan, and can outlive the process —.userSessionmetadata survives process death for as long as the user session does, and.permanentmetadata persists until you remove it. - An experiment or feature flag describes an assignment for the life of the process, carries a variant and a start and end time, and is never persisted.
Use a persona for a durable user characteristic, and an experiment or feature flag for the variant that is active right now.
Limits and validation
The SDK bounds what it accepts so that a misconfigured experimentation setup cannot grow your payloads without limit. The defaults are:
| Limit | Default |
|---|---|
| Records held per process | 500 |
| Identifier length | 128 |
| Variant length | 128 |
Records held per process covers active and ended records together, and experiments and feature flags share the budget. Embrace can adjust these limits remotely, within fixed ceilings.
Validation is per entry, so one bad entry never invalidates the rest of a batch:
- Surrounding whitespace is trimmed from ids and variants.
- An id that is empty once trimmed is dropped.
- An entry whose id or variant is over the allowed length is dropped. Values are never truncated, since a truncated id would refer to a different experiment.
nil, an empty string, and a whitespace-only string all mean the same thing forvariant: no variant.- Duplicate ids within a single call resolve to the first one.
Dropped entries are silent — nothing is thrown and nothing is logged — so it's worth checking your ids and variants against these limits while you integrate.
Where the data shows up
Tracked records are attached as an emb.experiments attribute on session part spans and on every log the SDK emits, which is what makes them available for filtering and comparison in the Embrace dashboard. Because the value is also mirrored to storage, a crash report recovered on the next launch still carries the records that were declared when the crash happened.
The attribute is set as an internal attribute, so its value is exempt from the standard attribute value length limit and does not count against the span's attribute limit. The limits above are what bound it instead.
If you export your telemetry to another backend, the same emb.experiments attribute travels with the exported spans and logs.