Mobile
Flutter SDK
Single Flutter package — sankofa_flutter on pub.dev — bundling seven products. Analytics, Catch, Switch, Config, Pulse, Replay, Deploy.
sankofa_flutter is the official Flutter SDK. Unlike the web SDK, which splits into seven packages, every product on Flutter ships in the same pub.dev package.
The product set on Flutter is seven: Analytics, Catch, Switch, Config, Pulse, Replay, and Deploy (App Store / Play Store compliant OTA updates).
For installation and project setup, see Install on Flutter.
Initialize
Initialize before runApp so default properties (device, OS, locale) are captured from the first frame. A single init() brings up every product — Analytics, Catch, Switch, Config and Pulse — with no per-product setup or register() step. Each one is gated server-side by the handshake, so enabling a product you don't subscribe to is a harmless no-op.
import 'package:flutter/material.dart';
import 'package:sankofa_flutter/sankofa_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Sankofa.instance.init(
apiKey: const String.fromEnvironment('SANKOFA_KEY'),
endpoint: 'https://api.sankofa.dev',
debug: !const bool.fromEnvironment('dart.vm.product'),
// Every product below defaults to true — listed here for clarity.
enableAnalytics: true, // events, screens, lifecycle, presence
enableCatch: true, // errors + native crashes
enableFlags: true, // Switch — feature flags
enableConfig: true, // remote config
enablePulse: true, // in-app surveys (auto-show, zero wiring)
enableDeploy: true, // OTA updates (Deploy defaults OFF; flip on here)
enableSessionReplay: true,
replayMode: SankofaReplayMode.screenshot, // or SankofaReplayMode.wireframe
);
runApp(const MyApp());
}After init, reach any product through the client getters: Sankofa.instance.flags, .config, .pulse, .errors, .deploy.
Sankofa.instance.init parameters
apiKeyStringRequiredendpointStringdefault https://api.sankofa.devdebugbooldefault falsetrackLifecycleEventsbooldefault trueenableAnalyticsbooldefault trueenableCatchbooldefault trueenableFlagsbooldefault trueflagDefaultsMap<String, FlagDecision>?enableConfigbooldefault trueconfigDefaultsMap<String, ItemDecision>?enablePulsebooldefault trueenableDeploybooldefault falseenableSessionReplaybooldefault truereplayModeSankofaReplayModedefault SankofaReplayMode.screenshotreplayFpsintdefault 1catchEnvironmentStringdefault livereleaseString?appVersionString?beforeSendBeforeSendFn?Analytics — events, identify, setPerson
// Track an event
await Sankofa.instance.track('checkout_started', {
'cart_value': 49.99,
'item_count': 3,
});
// Tag a screen explicitly (auto-tracked when using SankofaNavigatorObserver)
await Sankofa.instance.screen('Checkout');
// Identify after sign-in
await Sankofa.instance.identify('user_123');
// Update profile via the dedicated People setter
await Sankofa.instance.peopleSet({
'plan': 'growth',
'role': 'operator',
});
// Or set the named common traits
await Sankofa.instance.setPerson(
name: 'Ada Lovelace',
email: '[email protected]',
avatar: null,
properties: {'company': 'Sankofa Ltd'},
);
// Logout — rotates session, clears identity
await Sankofa.instance.reset();
// Force-flush
await Sankofa.instance.flush();For automatic screen tracking with go_router or any Navigator-based router, register the observer:
MaterialApp(
navigatorObservers: [SankofaNavigatorObserver()],
// ...
)Catch — error capture
Catch auto-boots inside Sankofa.instance.init. Once init resolves, every Sankofa.captureException / Sankofa.log call routes to the singleton — no separate SankofaCatch setup needed and no instance to thread through your widget tree.
// Capture a caught exception from anywhere — Sentry-style.
try {
await chargeCard(amount);
} catch (err, stack) {
Sankofa.captureException(err, stack);
}
// Capture a non-error event (warning-level).
Sankofa.captureMessage('Payment retry attempted');
// Crashlytics-style breadcrumb log — rides on the next captured
// event. Doesn't bill on its own.
Sankofa.log('checkout: applying coupon SUMMER25');
// Attach ambient context. Sticky — every subsequent event carries it.
Sankofa.setUser(CatchUserContext(id: 'user_123', email: '[email protected]'));
Sankofa.setTag('flow', 'checkout');
Sankofa.setExtra('cart_id', cart.id);
// Push a structured breadcrumb (HTTP, navigation, console, etc.).
Sankofa.addBreadcrumb(CatchBreadcrumb(
category: 'user-action',
message: 'Tapped checkout button',
));Automatic coverage
Out of the box Sankofa.instance.init installs:
FlutterError.onError— every framework-level error (build/layout/paint).PlatformDispatcher.onError— async + zone-uncaught Dart errors.- Isolate error listener — errors from background isolates.
- iOS
NSSetUncaughtExceptionHandler+ POSIX signal handlers — NSException, SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE, SIGTRAP, SIGSYS. Wired by the Flutter plugin's iOS code with no Pod dependency on the standalone iOS SDK. - Android chained
Thread.UncaughtExceptionHandler+ ANR watcher — JVM-uncaught exceptions and main-thread hangs > 5s. Wired by the Flutter plugin's Android code with no Maven dependency on the standalone Android SDK.
Both Dart-side and native-side captures POST to the same /api/catch/events endpoint. Sessions correlate by distinct_id.
withScope — Sentry-style temporary scope
Use Sankofa.withScope when you want tags/extras attached to ONE capture without polluting the global scope:
Sankofa.withScope((scope) {
scope.setTag('checkout_step', 'payment');
scope.setExtra('cart_id', cart.id);
scope.setLevel(CatchLevel.warning);
Sankofa.captureException(err);
});
// Outside the closure, those tags / extras are gone.Scopes are stack-scoped to the closure — async captures deferred past the closure's return won't see the scope.
beforeSend — scrub PII / drop noise
Pass beforeSend to Sankofa.instance.init for a synchronous hook that runs AFTER event composition but BEFORE enqueue. Return the (possibly modified) event to ship it, or null to drop entirely:
await Sankofa.instance.init(
apiKey: '...',
endpoint: '...',
beforeSend: (event) {
// Drop ResizeObserver-loop-limit-style noise.
if (event.message?.contains('setState() called after dispose') ?? false) {
return null;
}
// PII scrubbing — strip email from the user context.
if (event.user?.email != null) {
return event.copyWith(user: event.user!.copyWith(email: null));
}
return event;
},
);Throws inside beforeSend are swallowed — the original event ships through unchanged. A buggy hook can never break the capture pipeline.
Auto-discovered flag + config snapshots
If you've already constructed SankofaSwitch / SankofaConfig, every captured Catch event automatically carries a flag_snapshot and config_snapshot of the active decisions. The dashboard shows "which flags were ON when this error fired" without any host wiring.
| Static helper | Description |
|---|---|
Sankofa.captureException(err, [stack], [opts]) | Capture a handled exception. Returns event ID or '' if Catch isn't started. |
Sankofa.captureMessage(msg, [opts]) | Non-error variant. |
Sankofa.log(msg, [category]) | Crashlytics-style breadcrumb. Doesn't bill. |
Sankofa.setUser(user) / Sankofa.setUser(null) | Set / clear ambient user. |
Sankofa.setTag(k, v) / setTags({...}) | Sticky tags. |
Sankofa.setExtra(k, v) | Sticky extra context. |
Sankofa.addBreadcrumb(crumb) | Push to ring buffer. |
Sankofa.withScope(fn) | Temporary scope overlay. |
Sankofa.flushCatch() | Force-flush both Dart + native queues. |
Switch — feature flags
Switch is auto-constructed by init(enableFlags: true) (the default). Pass flagDefaults to init for the values returned before the first handshake and while offline, then read flags anywhere via Sankofa.instance.flags:
import 'package:sankofa_flutter/sankofa_flutter.dart';
await Sankofa.instance.init(
apiKey: 'sk_live_...',
endpoint: 'https://api.sankofa.dev',
flagDefaults: {
'new_checkout': FlagDecision(value: false, reason: 'default'),
'dark_mode_default': FlagDecision(value: false, reason: 'default'),
},
);
final flags = Sankofa.instance.flags!;
// Boolean
if (flags.getFlag('new_checkout')) showNewCheckout();
// Variant
final variant = flags.getVariant('checkout_redesign', defaultValue: 'control');
// Full envelope
final decision = flags.getDecision('new_checkout');
print(decision?.reason); // "rollout", "cohort:pro", "default", "halted", etc.
// Subscribe to changes
final unsubscribe = flags.onChange('new_checkout', (decision) {
setState(() => _newCheckoutEnabled = decision.value as bool);
});
// Later
unsubscribe();Config — remote config
Config is auto-constructed by init(enableConfig: true) (the default). Pass configDefaults to init and read via Sankofa.instance.config:
await Sankofa.instance.init(
// ...
configDefaults: {
'max_upload_mb': ItemDecision(value: 25, version: 1, reason: 'default'),
'support_email': ItemDecision(value: '[email protected]', version: 1, reason: 'default'),
},
);
final config = Sankofa.instance.config!;
// Generic typed read — infers T from defaultValue
final maxMB = config.get<int>('max_upload_mb', 25);
final email = config.get<String>('support_email', '[email protected]');
// Decision envelope
final decision = config.getDecision('max_upload_mb');
// Inspect
final allKeys = config.getAllKeys();
final allValues = config.getAll();
// Subscribe to changes
final unsubscribe = config.onChange('max_upload_mb', (decision) {
setState(() => _maxMB = decision.value as int);
});config.get<T> is generic — pass the expected type's default and the SDK validates at decode time.
Pulse — surveys
Pulse is auto-registered by init(enablePulse: true) (the default) — no register() call needed. Surveys flagged auto-show in the dashboard present themselves; Pulse discovers your app's navigator from the widget tree, so there is no navigator key to wire up. Reach it via Sankofa.instance.pulse.
Show a survey manually:
await Sankofa.instance.pulse.show(context, surveyId: 'srv_post_purchase_feedback');Listen for events:
final sub = Sankofa.instance.pulse.on(PulseEvent.surveyCompleted, (event) {
print('Survey ${event.surveyId} completed');
});
// later: sub.cancel();Inspect eligibility:
final eligible = await Sankofa.instance.pulse.activeMatchingSurveys();
print('Eligible: ${eligible.map((s) => s.id).toList()}');Targeting rules — what the SDK evaluates
A survey is eligible only when every targeting rule matches (AND). Surveys are fetched (with their rules embedded) from /api/pulse/surveys on register, on each handshake, and on resume; the SDK evaluates them on-device. Here's how each dashboard rule resolves on Flutter:
| Rule | How it resolves on Flutter | You must… |
|---|---|---|
| Sampling | Deterministic hash of the respondent's distinct_id | — |
| Screen | Matches the SDK's current screen | Call Sankofa.instance.screen('Vendor Details') (or use SankofaNavigatorObserver) so the SDK knows the screen |
| User property | Matches traits from setPerson/peopleSet (cached + persisted), plus setDefaultTargetingContext | Set the trait via setPerson({...}) or pass it in |
| Event | Counts custom events tracked on this device within ~30 days | track('your_event') so the count accrues |
| Cohort | Membership resolved server-side and delivered in the handshake | Nothing — the server ships the membership map |
| Frequency cap | On-device count of confirmed submissions for the survey | — (server is authoritative at submission) |
| Feature flag | Reads merged SankofaSwitch flags | Have enableFlags on (default) |
| URL | Ignored on Flutter — URL targeting is web-only | Use a Screen rule instead |
For rules whose data the SDK can't see on its own — extra user properties, cohort overrides, feature-flag values — supply them once and they apply to auto-show and every show():
Sankofa.instance.pulse.setDefaultTargetingContext(
userProperties: {'plan': 'pro', 'lifetime_orders': 12},
cohorts: {'coh_beta_testers': true}, // usually unnecessary — server delivers these
flagValues: {'new_checkout': true},
);
// Or per call:
await Sankofa.instance.pulse.show(
context,
surveyId: 'srv_nps',
properties: {'plan': 'pro'},
cohorts: {'coh_vip': true},
);Deploy — Flutter OTA updates
Sankofa Deploy ships bug fixes and UI tweaks to your already-released Flutter app without a new App Store / Play Store binary. Patches contain Dart code changes and are App Store + Play Store compliant.
Setup (one command)
The fastest way is sankofa init --deploy — the CLI scaffolds every piece:
npm install -g sankofa-cli
sankofa login
sankofa init --deployRun it inside a Flutter project root. The CLI is idempotent — re-running on an already-configured project skips each step that's already in place, so it's safe to ship in onboarding scripts.
What sankofa init --deploy writes
pubspec.yaml — SDK dependency
Adds the SDK and a managed
dependency_overrides:stanza so the local helper resolves without exposing any GitHub URL in your project:YAMLpubspec.yamldependencies: sankofa_flutter: ^0.2.2 # Sankofa-managed — do not edit by hand. Re-run `sankofa init` if # you need to regenerate the vendored package. dependency_overrides: dynamic_modules: path: .sankofa/dynamic_modules flutter: assets: - sankofa.yamlIt also pins
flutter_lints: ^4.0.0if your project shipped with^6.0.0—carrier_info(a transitive Sankofa dep) requires the older version. Lints are dev-only, so the downgrade has no runtime impact.sankofa.yaml — project keys
Written at the project root. Run
sankofa loginand it fills bothapp_idandapi_keyfrom the project you select — nothing to paste:YAMLsankofa.yamlapp_id: proj_xxxxxxxxxxxxx api_key: sk_live_xxxxxxxxxxxxx # filled automatically by `sankofa login`initandloginare order-independent — whichever you run first, the other completes the setup. The SDK reads everything else at runtime from the asset bundle..sankofa/ — local vendor + version pin
The managed local helper lands in
.sankofa/dynamic_modules/, and.sankofa/flutter-versionpins the bundled Sankofa Flutter runtime. Commit these — they're what yourpubspec.yamlpath-override resolves against, so committing them means a fresh clone and CI build withflutter pub getstraight away (no re-runninginit).initadds a single.gitignoreblock that ignores only the disposable bits:.sankofa.json,build/, and the transient.sankofa/build/+.sankofa/baseline/.lib/main.dart — startup wiring
Imports
dynamic_modulesandsankofa_flutter, then injects two calls intomain()beforerunApp:Dartlib/main.dart// ignore: depend_on_referenced_packages import 'package:dynamic_modules/dynamic_modules.dart'; import 'package:sankofa_flutter/sankofa_flutter.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); SankofaUpdater.registerLoader(loadModuleFromBytes); await SankofaUpdater.preFlight(); runApp(const MyApp()); }registerLoaderinitializes the Sankofa updater once at boot;preFlightreadssankofa.yaml, applies any patch staged on the previous run, and schedules a "patch healthy" confirmation 10 seconds after the first frame paints.Android + iOS native config
AndroidManifest gets
<meta-data android:name="com.sankofa.appId" />+<meta-data android:name="com.sankofa.endpoint" />+ theINTERNETpermission.MainActivity.ktis rewritten to extendSankofaFlutterActivity. iOSAppDelegate.swiftextendsSankofaFlutterAppDelegate;Info.plistgets the samecom.sankofa.appId+com.sankofa.endpointkeys.No custom
Applicationclass is required.
After running, sankofa login fills sankofa.yaml (app_id + api_key) for you — then flutter pub get and flutter run. Verify the setup with sankofa doctor — every check should be green.
Manual setup (if you can't run the CLI)
If your environment can't run Node-based CLIs (e.g. an air-gapped CI runner), reproduce what sankofa init --deploy does by hand:
flutter pub add sankofa_flutterand pinflutter_lints: ^4.0.0indev_dependencies:.- Generate the
.sankofa/dynamic_modules/helper — this comes from the CLI (sankofa init --deploy) and isn't separately distributable, so Deploy setup needs the CLI at least once to produce it. - Add the
dependency_overrides:+flutter.assets:stanzas shown above topubspec.yaml. - Create
sankofa.yamlwith yourapp_idandapi_key. - Update
lib/main.dartper the snippet above. - (Optional, for native crash bootstrapping) Extend
SankofaFlutterActivityinMainActivity.ktandSankofaFlutterAppDelegateinios/Runner/AppDelegate.swift.
Check + download from anywhere
final updater = SankofaUpdater();
// Currently-installed patch (null on baseline).
final current = await updater.readCurrentPatch();
print(current?.label ?? 'baseline');
// Is a new patch available?
final result = await updater.checkForUpdate();
if (result.hasUpdate) {
final update = result.update!;
if (update.isMandatory) {
// Skip the prompt — download immediately.
await updater.downloadUpdate(update);
} else {
// Gate behind a user confirmation.
final yes = await showUpdateDialog(context, update);
if (yes) {
await updater.downloadUpdate(
update,
onProgress: (received, total) {
setState(() => _progress = total > 0 ? received / total : 0);
},
);
}
}
}After downloadUpdate returns, the patch is staged on disk. The next cold launch picks it up automatically through preFlight().
Auto-rollback
Two crashes within 30 seconds of launch automatically disable the patch on that device and restore the last known-good version. The bad label is added to a local ban list so the same patch isn't re-downloaded. No host code required.
Publishing — release, then patch
Ship a base release once per store version (sankofa release ios builds your signed .ipa; sankofa release android builds your .aab), then ship code-only patches on top of it:
sankofa release ios # first: build the store binary + register the release
sankofa patch ios # then: ship code updates (OR sankofa patch android)patch packages your code change, signs it (when you've run sankofa keys generate), uploads it, and registers it with your dashboard. Set the rollout (default 100%) and devices on their next checkForUpdate() call receive the patch.
Flavored apps (gradle flavors + a per-flavor entrypoint) pass --flavor <name> and -t lib/main_<flavor>.dart to release. See the CLI reference and Releasing to the App Store for the full flow.
For a deeper architectural overview, see the Deploy product page.
Session replay
Two recording modes:
| Mode | Payload | Best for |
|---|---|---|
SankofaReplayMode.wireframe | Lightweight JSON skeletons of the UI tree (~5 KB/s) | Good UX for replays of structural flow. |
SankofaReplayMode.screenshot | Pixel-perfect snapshots throttled by frame rate (~50 KB/s) | Default. High-fidelity debugging of visual bugs. |
Wrap your app in SankofaReplayBoundary and pass enableSessionReplay: true to init:
void main() {
runApp(
SankofaReplayBoundary(
child: MyApp(),
),
);
}Privacy
By default every TextField is masked. Manually mark sensitive widgets:
SankofaMask(
child: Text('Account balance: \$4,238'),
)API summary
| Method | Description |
|---|---|
Sankofa.instance.init(...) | Initialize the SDK. |
Sankofa.instance.track(event, props?) | Record an event. |
Sankofa.instance.screen(name, props?) | Tag the current screen. |
Sankofa.instance.identify(userId) | Stitch anonymous → known. |
Sankofa.instance.peopleSet(traits) | Update profile traits (verbose name). |
Sankofa.instance.setPerson(name, email, avatar, properties) | Update profile traits (named common traits). |
Sankofa.instance.reset() | Rotate session + clear identity. |
Sankofa.instance.flush() | Force-drain the queue. |
Sankofa.instance.dispose() | Release SDK resources. |
Sankofa.captureException(err, [stack], [opts]) | Capture a handled exception (Sentry-style static). |
Sankofa.captureMessage(msg, [opts]) | Capture a non-error event. |
Sankofa.log(msg, [category]) | Crashlytics-style breadcrumb log (no event emitted). |
Sankofa.setUser(user) / setTag / setTags / setExtra | Sticky ambient context. |
Sankofa.withScope(fn) | Sentry-style temporary scope overlay. |
Sankofa.flushCatch() | Force-flush Dart + native Catch queues. |
Sankofa.instance.flags.getFlag(key, defaultValue?) | Boolean feature flag (Switch auto-constructed by enableFlags). |
Sankofa.instance.flags.getVariant(key, defaultValue?) | Variant feature flag. |
Sankofa.instance.config.get<T>(key, defaultValue) | Typed remote config (auto-constructed by enableConfig). |
Sankofa.instance.pulse.show(context, surveyId: ...) | Show a survey (Pulse auto-registered by enablePulse). |
Sankofa.instance.pulse.on(PulseEvent, listener) | Subscribe to survey lifecycle events. |
SankofaUpdater.preFlight() | Apply any OTA patch staged from the previous run. Call once in main() before runApp. |
SankofaUpdater().checkForUpdate() | Probe the server for a new OTA patch — returns metadata only, no download. |
SankofaUpdater().downloadUpdate(update, onProgress?) | Download + verify a patch, stage it for the next cold boot. |
SankofaUpdater().readCurrentPatch() | Read the patch label currently active on the device (null on baseline). |