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.

Dartlib/main.dart
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

apiKeyStringRequired
Project API key (live or test). The engine resolves the environment from the key.
endpointStringdefault https://api.sankofa.dev
Server base URL. Pass a regional endpoint to pin data residency.
debugbooldefault false
Verbose logging. Disable in production.
trackLifecycleEventsbooldefault true
Auto-track $app_opened, $app_foregrounded, $app_backgrounded.
enableAnalyticsbooldefault true
Master switch for analytics — track()/screen(), auto lifecycle + deep-link capture, and the presence heartbeat. Set false to ship a build that sends zero analytics events while keeping every other product live.
enableCatchbooldefault true
Auto-install the Catch singleton + chained FlutterError.onError / PlatformDispatcher.onError handlers. Set false only to defer Catch boot for integration tests.
enableFlagsbooldefault true
Auto-construct SankofaSwitch (feature flags). Read via Sankofa.instance.flags. Server-gated via handshake.
flagDefaultsMap<String, FlagDecision>?
Bundled flag defaults returned before the first handshake and while offline.
enableConfigbooldefault true
Auto-construct SankofaConfig (remote config). Read via Sankofa.instance.config. Server-gated via handshake.
configDefaultsMap<String, ItemDecision>?
Bundled config defaults returned before the first handshake and while offline.
enablePulsebooldefault true
Auto-register Pulse (in-app surveys). Auto-show works with no navigator wiring — Pulse discovers the root navigator from the widget tree. Read via Sankofa.instance.pulse.
enableDeploybooldefault false
Auto-construct the Deploy module (OTA updates). When true the SDK reads sankofa.yaml, checks for staged patches on boot, and arms the SankofaUpdater API. See the Deploy section below.
enableSessionReplaybooldefault true
Capture session replays. You must still wrap your app in <SankofaReplayBoundary>.
replayModeSankofaReplayModedefault SankofaReplayMode.screenshot
Either wireframe (lightweight JSON) or screenshot (pixel-perfect, ~10x bandwidth).
replayFpsintdefault 1
Frame capture rate for replay.
catchEnvironmentStringdefault live
Environment tag stamped on every captured event (live, staging, dev, custom).
releaseString?
Release identifier (e.g. '[email protected]+42') sent on every Catch event for grouping.
appVersionString?
App version override sent in the Catch device context.
beforeSendBeforeSendFn?
Synchronous hook fired AFTER event composition but BEFORE enqueue. Return the (possibly modified) event to ship it, or null to drop. Throws are swallowed.

Analytics — events, identify, setPerson

Dart
// 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:

Dart
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.

Dart
// 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:

Dart
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:

Dart
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 helperDescription
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:

Dart
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:

Dart
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:

Dart
await Sankofa.instance.pulse.show(context, surveyId: 'srv_post_purchase_feedback');

Listen for events:

Dart
final sub = Sankofa.instance.pulse.on(PulseEvent.surveyCompleted, (event) {
print('Survey ${event.surveyId} completed');
});
// later: sub.cancel();

Inspect eligibility:

Dart
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:

RuleHow it resolves on FlutterYou must…
SamplingDeterministic hash of the respondent's distinct_id
ScreenMatches the SDK's current screenCall Sankofa.instance.screen('Vendor Details') (or use SankofaNavigatorObserver) so the SDK knows the screen
User propertyMatches traits from setPerson/peopleSet (cached + persisted), plus setDefaultTargetingContextSet the trait via setPerson({...}) or pass it in
EventCounts custom events tracked on this device within ~30 daystrack('your_event') so the count accrues
CohortMembership resolved server-side and delivered in the handshakeNothing — the server ships the membership map
Frequency capOn-device count of confirmed submissions for the survey— (server is authoritative at submission)
Feature flagReads merged SankofaSwitch flagsHave enableFlags on (default)
URLIgnored on Flutter — URL targeting is web-onlyUse 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():

Dart
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:

bash
npm install -g sankofa-cli
sankofa login
sankofa init --deploy

Run 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

  1. 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.yaml
    dependencies:
    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.yaml

    It also pins flutter_lints: ^4.0.0 if your project shipped with ^6.0.0carrier_info (a transitive Sankofa dep) requires the older version. Lints are dev-only, so the downgrade has no runtime impact.

  2. sankofa.yaml — project keys

    Written at the project root. Run sankofa login and it fills both app_id and api_key from the project you select — nothing to paste:

    YAMLsankofa.yaml
    app_id: proj_xxxxxxxxxxxxx
    api_key: sk_live_xxxxxxxxxxxxx   # filled automatically by `sankofa login`

    init and login are order-independent — whichever you run first, the other completes the setup. The SDK reads everything else at runtime from the asset bundle.

  3. .sankofa/ — local vendor + version pin

    The managed local helper lands in .sankofa/dynamic_modules/, and .sankofa/flutter-version pins the bundled Sankofa Flutter runtime. Commit these — they're what your pubspec.yaml path-override resolves against, so committing them means a fresh clone and CI build with flutter pub get straight away (no re-running init). init adds a single .gitignore block that ignores only the disposable bits: .sankofa.json, build/, and the transient .sankofa/build/ + .sankofa/baseline/.

  4. lib/main.dart — startup wiring

    Imports dynamic_modules and sankofa_flutter, then injects two calls into main() before runApp:

    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());
    }

    registerLoader initializes the Sankofa updater once at boot; preFlight reads sankofa.yaml, applies any patch staged on the previous run, and schedules a "patch healthy" confirmation 10 seconds after the first frame paints.

  5. Android + iOS native config

    AndroidManifest gets <meta-data android:name="com.sankofa.appId" /> + <meta-data android:name="com.sankofa.endpoint" /> + the INTERNET permission. MainActivity.kt is rewritten to extend SankofaFlutterActivity. iOS AppDelegate.swift extends SankofaFlutterAppDelegate; Info.plist gets the same com.sankofa.appId + com.sankofa.endpoint keys.

    No custom Application class 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:

  1. flutter pub add sankofa_flutter and pin flutter_lints: ^4.0.0 in dev_dependencies:.
  2. 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.
  3. Add the dependency_overrides: + flutter.assets: stanzas shown above to pubspec.yaml.
  4. Create sankofa.yaml with your app_id and api_key.
  5. Update lib/main.dart per the snippet above.
  6. (Optional, for native crash bootstrapping) Extend SankofaFlutterActivity in MainActivity.kt and SankofaFlutterAppDelegate in ios/Runner/AppDelegate.swift.

Check + download from anywhere

Dart
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:

bash
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:

ModePayloadBest for
SankofaReplayMode.wireframeLightweight JSON skeletons of the UI tree (~5 KB/s)Good UX for replays of structural flow.
SankofaReplayMode.screenshotPixel-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:

Dart
void main() {
runApp(
  SankofaReplayBoundary(
    child: MyApp(),
  ),
);
}

Privacy

By default every TextField is masked. Manually mark sensitive widgets:

Dart
SankofaMask(
child: Text('Account balance: \$4,238'),
)

API summary

MethodDescription
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 / setExtraSticky 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).

What's next

Edit this page on GitHub