Flutter SDK
experimently (v0.2) provides feature flag evaluation, A/B experiment assignment and
event tracking for Flutter applications on iOS, Android, Web, macOS, Windows and Linux. A
Flutter-free entry point (experimently_core.dart) exposes the same client to plain Dart
programs.
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. Nothing is bucketed on the 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:getAssignmentsends 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/flutter.
Requirements
- Dart 2.17+ (null safety), Flutter 3.0+
- Dependencies:
http,crypto,shared_preferences
Installation
Not yet published. The experimently package is not on pub.dev, so build it from source: this
dependency takes sdk/flutter from this repository.
dependencies:
flutter:
sdk: flutter
experimently:
git:
url: https://github.com/getexperimently/experimently
path: sdk/flutter
Two library entry points are available:
| Import | Use from | Adds |
|---|---|---|
package:experimently/experimently.dart |
Flutter apps | everything below plus SharedPreferencesOfflineStore |
package:experimently/experimently_core.dart |
plain Dart (servers, CLIs, the contract smoke) | the client, models, cache, HTTP client, InMemoryOfflineStore, hashUser — no package:flutter code |
Quick Start
import 'package:experimently/experimently.dart';
final client = ExperimentationClient(
config: const SdkConfig(
apiKey: 'your-api-key', // sent as X-API-Key
baseUrl: 'http://localhost:8000', // origin only; the SDK appends /api/v1/...
),
offlineStore: SharedPreferencesOfflineStore(),
);
// Once, before any other call. Opens the offline store; makes no network request.
await client.init();
final flag = await client.evaluateFlag('new-checkout', 'user-123');
if (flag.enabled) {
showNewCheckout();
}
final assignment = await client.getAssignment('checkout-cta-copy', 'user-123',
attributes: {'plan': 'pro'});
final headline = (assignment?.configuration?['headline'] as String?) ?? 'Buy now';
await client.track('purchase', 'user-123',
experimentKey: 'checkout-cta-copy', value: 99.99, properties: {'sku': 'pro-plan'});
Configuration
const config = SdkConfig(
apiKey: 'your-api-key', // Required — sent as X-API-Key
baseUrl: 'https://api.example.com', // Required — origin only
timeout: Duration(seconds: 5), // Per-request HTTP timeout (default 5 s)
cacheTtl: Duration(minutes: 5), // How long a successful result is reused (default 5 min)
offlineFallback: true, // Persist results to the OfflineStore (default true)
);
| Parameter | Type | Default | Description |
|---|---|---|---|
apiKey |
String |
(required) | API key sent as X-API-Key |
baseUrl |
String |
(required) | Backend origin, e.g. https://api.example.com; a trailing / is stripped |
timeout |
Duration |
5s |
HTTP request timeout |
cacheTtl |
Duration |
5 min |
How long a successful evaluation/assignment is reused |
offlineFallback |
bool |
true |
Write successful results to the OfflineStore and serve them when the API is unreachable |
ExperimentationClient constructor parameters:
| Parameter | Description |
|---|---|
config |
SdkConfig (required) |
httpClient |
ApiHttpClient; build one with a package:http Client to inject MockClient in tests |
offlineStore |
SharedPreferencesOfflineStore() for Flutter apps; defaults to InMemoryOfflineStore() (session-scoped) |
Feature Flag Evaluation
evaluateFlag(String flagKey, String userId): Future<EvalResult>
Calls GET /api/v1/feature-flags/evaluate/{flagKey}?user_id=… and returns the EvalResult below. An
optional attributes: named argument is accepted and not sent: the request carries the user id
only (see the note at the top of this page).
| Property | Type | Description |
|---|---|---|
key |
String |
The flag key you asked for |
enabled |
bool |
Server decision for this user (false on any failure) |
config |
dynamic |
Always null: the evaluate endpoint returns config: null for every flag |
configMap |
Map<String, dynamic>? |
Always null, like config |
final flag = await client.evaluateFlag('dark-mode', 'user-456');
if (flag.enabled) {
enableDarkMode();
}
Never throws. A cached evaluation is returned before any request is made; on a network/HTTP
failure the persisted offline value is returned (if offlineFallback), otherwise
EvalResult.disabled(key).
Failures are never cached, so the next call retries. A 404 (an unknown flag key) is a
definitive answer: the flag is reported disabled and any persisted offline value is removed.
Concurrent calls for the same user + key share a single request.
isEnabled(String flagKey, String userId): Future<bool>
Shorthand for (await evaluateFlag(...)).enabled.
Experiment Assignment
getAssignment(String experimentKey, String userId, {Map<String, dynamic>? attributes}): Future<Assignment?>
Calls POST /api/v1/tracking/assign with {experiment_key, user_id, context: attributes}. The
server buckets the user, keeps the assignment sticky and records the exposure.
| Property | Type | Description |
|---|---|---|
experimentKey |
String |
The experiment key |
userId |
String |
The user the assignment belongs to |
variantId |
String? |
UUID of the assigned variant |
variantName |
String |
Assigned variant name (e.g. "control", "treatment") |
isControl |
bool |
true for the control variant |
configuration |
Map<String, dynamic>? |
The variant's configuration JSON from the experiment definition |
final assignment = await client.getAssignment('checkout-cta-copy', 'user-123',
attributes: {'plan': 'pro', 'country': 'US'});
switch (assignment?.variantName) {
case 'treatment-a': renderShortCta(); break;
case 'treatment-b': renderUrgencyCta(); break;
default: renderOriginalCta(); // control, or null on failure
}
Never throws. Returns null on a 404 (experiment not ACTIVE / unknown) or when the request failed
and neither the cache nor the offline store has a value. Failures are never cached.
variantKey remains as a deprecated alias of variantName.
getVariant(String experimentKey, String userId, {attributes}): Future<String?>
Shorthand for (await getAssignment(...))?.variantName.
Event Tracking
track(String eventName, String userId, {properties, experimentKey, featureFlagKey, value, eventType, timestamp}): Future<bool>
Never throws. Returns true when every request succeeded (or nothing had to be sent), false
otherwise.
await client.track('purchase', 'user-123', experimentKey: 'checkout-cta-copy', value: 99.99);
await client.track('search', 'user-123', featureFlagKey: 'new-search', properties: {'q': 'shoes'});
await client.track('page_view', 'user-123', properties: {'page': '/products'}); // no key: fanned out
Fan-out rule. With experimentKey and/or featureFlagKey the SDK sends one
POST /api/v1/tracking/track. Without a key it sends one POST /api/v1/tracking/batch
containing one entry per experiment the user has been assigned to through this client plus one
per flag evaluated for the user (from the in-memory cache). If nothing is cached, nothing is sent.
This is what makes a single track('purchase', …) count as a conversion for every experiment the
user is in.
Conversions are matched to metrics by event name: an experiment metric whose event_name is
purchase counts every purchase event, whatever event_type was sent. properties is sent as
metadata; eventType defaults to eventName; timestamp is sent as ISO-8601 UTC when given.
Because the fan-out reads the in-memory cache, it only sees assignments and flags evaluated
through the same client instance within cacheTtl. Pass the key explicitly otherwise.
trackEvent(TrackEvent event): Future<bool>
Same as track with a prebuilt TrackEvent(userId, eventName, {eventType, experimentKey,
featureFlagKey, value, properties, timestamp}).
trackBatch(List<TrackEvent> events): Future<bool>
Sends events with POST /api/v1/tracking/batch, at most 100 per request (longer lists are
chunked). Keyed events are sent as-is; events without a key are fanned out like track. Never
throws; returns false if any batch request failed.
final ok = await client.trackBatch([
TrackEvent(userId: 'user-123', eventName: 'purchase', experimentKey: 'checkout-cta-copy', value: 99.99),
TrackEvent(userId: 'user-123', eventName: 'search', featureFlagKey: 'new-search'),
]);
Cache and offline store
| Method | Description |
|---|---|
getAssignments(String userId): List<Assignment> |
Cached (successful, unexpired) assignments for the user |
getEvaluatedFlags(String userId): List<String> |
Keys of flags successfully evaluated (and still cached) for the user |
clearCache(): void |
Drop every cached evaluation and assignment (the offline store is kept) |
clearOfflineCache(): Future<void> |
Remove everything the SDK persisted to the offline store |
close(): Future<void> |
Clear the cache and close the HTTP connection pool |
Lookup order for evaluateFlag / getAssignment:
- In-memory cache — per user + key, expires after
cacheTtl. - API call — the server decides; the result is cached and, with
offlineFallback: true, written to theOfflineStore. - Offline store — on a network error or non-404 HTTP error, the last persisted result.
- Safe default —
EvalResult.disabled(key)/null.
OfflineStore is an interface (init, setFlag/getFlag/removeFlag,
setAssignment/getAssignment/removeAssignment, clear). SharedPreferencesOfflineStore
persists JSON under ep_sdk_flag_/ep_sdk_asgn_ keys across app launches;
InMemoryOfflineStore (the default) lasts for the process. The Dart VM is single-isolate, so the
client needs no locking; use one client per isolate.
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, isEnabled |
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, getVariant |
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, trackEvent |
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 |
These SDK paths share a per-IP rate-limit ceiling of SDK_RATE_LIMIT_PER_MINUTE requests
(default 6000) on the backend; a 429 is surfaced as a failure (disabled flag / null / false).
Error Handling
The public methods never throw on network or HTTP errors. The one exception is calling
evaluateFlag, getAssignment, trackEvent or trackBatch (or isEnabled, getVariant and
track, which call them) before init(), which throws StateError (a programming error). Only
ApiHttpClient throws:
| Exception | When |
|---|---|
ApiException (statusCode, message, body, isNotFound) |
Non-2xx responses (statusCode 401/404/422/429/5xx) and network errors or timeouts (statusCode == 0) |
Consistent Hash Utility
hashUser(String userId, String key): double implements the cross-SDK formula —
MD5("{userId}:{key}"), first 4 bytes as little-endian uint32, divided by 2^32. The SDK's own
test/hash_compatibility_test.dart pins it with its own copy of the vectors (seven of its eight are
in tests/sdk-contract/golden-vectors.json); the cross-SDK golden-vector job does not run this
SDK:
import 'package:experimently/experimently.dart';
hashUser('user-123', 'my-flag'); // 0.6927449859213084
It is exported as a utility only. Since assignment moved to the server, nothing in the SDK uses it to decide a variant.
Flutter Widget Integration
class FeatureFlagWidget extends StatefulWidget {
const FeatureFlagWidget({super.key, required this.client});
final ExperimentationClient client;
@override
State<FeatureFlagWidget> createState() => _FeatureFlagWidgetState();
}
class _FeatureFlagWidgetState extends State<FeatureFlagWidget> {
bool _enabled = false;
bool _loading = true;
@override
void initState() {
super.initState();
_evaluate();
}
Future<void> _evaluate() async {
final flag = await widget.client.evaluateFlag('new-ui', 'user-123'); // never throws
if (mounted) setState(() { _enabled = flag.enabled; _loading = false; });
}
@override
Widget build(BuildContext context) {
if (_loading) return const CircularProgressIndicator();
return Text(_enabled ? 'New UI' : 'Old UI');
}
}
A complete example app is in sdk/flutter/example/lib/main.dart.
Testing your own code
Inject a package:http MockClient (no code generation needed); the SDK's own tests in
sdk/flutter/test/client_test.dart use this pattern to record requests and serve canned responses:
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:experimently/experimently_core.dart';
final apiHttp = ApiHttpClient(
apiKey: 'test',
baseUrl: 'https://example.com',
timeout: const Duration(seconds: 2),
httpClient: MockClient((request) async =>
http.Response('{"key":"new-ui","enabled":true,"config":null}', 200)),
);
final client = ExperimentationClient(
config: const SdkConfig(apiKey: 'test', baseUrl: 'https://example.com', offlineFallback: false),
httpClient: apiHttp,
);
await client.init();
expect(await client.isEnabled('new-ui', 'user-1'), isTrue);
Contract smoke
Runs the four contract steps (sticky assignment, flag evaluation, keyed track, key-less fan-out plus a 2-event batch) against a live backend and prints one JSON line:
It prints:
{"sdk":"flutter","assign":{"variant_name":"control","is_control":true,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
The live runner (tests/sdk-contract/live/run_live_contract.py) invokes exactly
cd sdk/flutter && dart run example/contract_smoke.dart. 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 imports only experimently_core.dart, so no
package:flutter code is loaded — but the package depends on the flutter SDK, so dependency
resolution needs Flutter's bundled dart (run flutter pub get first so dart run prints
nothing but the JSON line).
CI does not run this SDK's contract smoke against a real API: flutter is not in the
sdks: list of the SDK Live Contract job in pr-qa-gate.yml.
Run python tests/sdk-contract/live/run_live_contract.py --sdk flutter --strict
on a machine with the Flutter SDK.
Development
flutter test: client (MockClient), hash golden vectors, SharedPreferences store
CI runs flutter pub get and flutter test on Flutter 3.19.6 in the SDK Unit Tests workflow
(sdk-unit-tests.yml), daily and on a pull request that changes sdk/flutter/. No workflow runs
flutter analyze.