Sankofa Flutter SDK ๐Ÿš€

Pub Version License: MIT Sankofa

The official Flutter SDK for Sankofa โ€” seven products in one package: Analytics, Catch, Switch, Config, Pulse, Replay, and Deploy (App-Store-compliant Flutter OTA updates). Plus a standalone native crash bridge that captures iOS NSException + POSIX signals and Android JVM-uncaught + ANR with zero dependency on the iOS / Android SDKs.


โœจ Features

  • Analytics: events, identify, peopleSet, deep-link attribution, offline-first queueing.
  • Catch (Crashlytics + Sentry merged): Dart-level errors via FlutterError.onError / PlatformDispatcher.onError / isolate listeners โ€” plus iOS NSException + POSIX signals + main-queue stalls and Android JVM-uncaught + ANR via the bundled Flutter plugin.
  • Switch: feature flags with bundled defaults + onChange listeners.
  • Config: remote-config with typed get<T> accessors.
  • Pulse: in-app surveys (NPS, CSAT, custom).
  • Session Replay: wireframe + screenshot modes, automatic input masking, SankofaMask widget.
  • Deploy: Flutter OTA updates โ€” fix bugs and tweak UI without a new App Store / Play Store release. App Store compliant under Apple PLA ยง 3.3.2 and Google Play DNA's interpreted-code carve-out.

๐Ÿš€ Quick start

1. Install

Add the dependency to your pubspec.yaml:

dependencies:
  sankofa_flutter: ^0.2.8

2. Initialize

One call wires every product. Analytics, Catch, Switch, Config, Pulse, Replay and Deploy all come up from a single init() โ€” no per-product new SankofaX() boilerplate, no register() step. Each product is gated server-side via the handshake, so a flag you don't subscribe to is simply a no-op. Both Dart errors AND iOS/Android native crashes flow through Sankofa.captureException.

import 'package:sankofa_flutter/sankofa_flutter.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Sankofa.instance.init(
    apiKey: 'YOUR_PROJECT_API_KEY',
    endpoint: 'https://api.sankofa.dev',
    debug: true,

    // โ”€โ”€ Product switches (all default true except Deploy) โ”€โ”€
    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-shows, no extra wiring)
    enableDeploy: true,    // OTA updates

    // โ”€โ”€ Optional per-product config โ”€โ”€
    catchEnvironment: 'production',
    release: 'myapp@1.4.0',
    flagDefaults: {'new_checkout': FlagDecision(value: false, reason: 'default')},
    configDefaults: {'max_upload_mb': ItemDecision(value: 25, version: 1, reason: 'default')},
    // Sentry-style hook to scrub PII / drop noise.
    beforeSend: (event) {
      if (event.message?.contains('setState called after dispose') ?? false) return null;
      return event;
    },
  );

  runApp(const MyApp());
}

After init(), reach any product through the client: Sankofa.instance.flags, .config, .pulse, .errors, .deploy.

Turning a product off: pass enableFlags: false (etc.) to skip constructing it entirely. enableAnalytics: false ships a build that sends zero analytics events while keeping Catch/Switch/Config/Pulse live.


๐Ÿ›  Usage

Analytics

// Events
Sankofa.instance.track('completed_purchase', {
  'item_name': 'Vintage Camera',
  'price': 120.50,
  'currency': 'USD',
});

// Identity
Sankofa.instance.identify('user_99');
Sankofa.instance.setPerson(
  name: 'Jane Doe',
  email: 'jane@example.com',
  properties: {'membership': 'Gold'},
);

Catch โ€” error capture

Once init() resolves, every helper below works from anywhere. No SankofaCatch.instance to thread through your widget tree.

// Capture a handled exception
try {
  await chargeCard(amount);
} catch (err, stack) {
  Sankofa.captureException(err, stack);
}

// Non-error event
Sankofa.captureMessage('payment retry attempted');

// Crashlytics-style breadcrumb log โ€” rides on next capture, doesn't bill.
Sankofa.log('checkout: applying coupon SUMMER25');

// Ambient context
Sankofa.setUser(CatchUserContext(id: 'u_42', email: 'ada@example.com'));
Sankofa.setTag('flow', 'checkout');
Sankofa.setExtra('cart_id', cart.id);

// Sentry-style temporary scope โ€” tags only on this capture.
Sankofa.withScope((scope) {
  scope.setTag('checkout_step', 'payment');
  scope.setLevel(CatchLevel.warning);
  Sankofa.captureException(err);
});

Session replay

MaterialApp(
  navigatorObservers: [SankofaNavigatorObserver()],
  home: const SankofaReplayBoundary(
    child: MyHomePage(),
  ),
);

// Hide sensitive UI from replays โ€” replaced with solid black in the
// upload, untouched in the live UI.
SankofaMask(
  child: TextField(controller: _passwordController, obscureText: true),
);

// Suppress capture for screens that host external textures (video,
// maps, web views) โ€” protects the surrounding scroll position + avoids
// "Invalid external texture" log spam on Android.
SankofaReplaySuppress(
  child: BetterPlayer(controller: _videoController),
);

Switch โ€” feature flags

Auto-constructed by init(enableFlags: true). Read from anywhere via Sankofa.instance.flags:

final flags = Sankofa.instance.flags!; // pass flagDefaults to init() for offline-first values

if (flags.getFlag('new_checkout')) showNewCheckout();
final variant = flags.getVariant('checkout_redesign', defaultValue: 'control');

Config โ€” remote config

Auto-constructed by init(enableConfig: true). Read via Sankofa.instance.config:

final config = Sankofa.instance.config!; // pass configDefaults to init() for offline-first values

final maxUploads = config.get<int>('max_uploads_per_day', 25);

Pulse โ€” surveys

Auto-registered by init(enablePulse: true) โ€” no register() call needed. Surveys flagged auto-show in the dashboard appear on their own; Pulse discovers your app's navigator automatically, so there's no navigator key to wire up. Present one manually with:

await Sankofa.instance.pulse.show(context, surveyId: 'nps-2024');

// React to lifecycle events
Sankofa.instance.pulse.on(PulseEvent.surveyCompleted, (e) {
  print('Survey ${e.surveyId} completed');
});

Targeting: rules are AND-ed and evaluated on-device. For Screen rules, tag screens with Sankofa.instance.screen('โ€ฆ') (or SankofaNavigatorObserver). User-property rules read traits from setPerson, Event rules read on-device track() counts, Cohort rules are resolved server-side via the handshake, and Feature-flag rules read SankofaSwitch. URL rules are web-only and ignored on Flutter โ€” use a Screen rule. Supply data the SDK can't see itself via Sankofa.instance.pulse.setDefaultTargetingContext(userProperties: โ€ฆ, cohorts: โ€ฆ, flagValues: โ€ฆ) or per-call show(..., properties: โ€ฆ, cohorts: โ€ฆ).

Deploy โ€” Flutter OTA updates

Ship bug fixes and UI tweaks without an App Store / Play Store release. Sankofa Deploy is built to comply with both stores' policies on runtime updates (Apple PLA ยง 3.3.2, Google Play's DNA policy).

Setup: run the Sankofa CLI once โ€” it wires everything for you: creates sankofa.yaml, adds the OTA runtime binding to your project, lists sankofa.yaml under flutter.assets, and initializes the loader in main().

npm i -g sankofa-cli
sankofa init --deploy

Then paste your project's api_key into sankofa.yaml:

app_id: proj_xxxxxxxxxxxxx
api_key: sk_live_xxxxxxxxxxxxxxxx

After setup, your main() looks like this (the CLI writes it for you):

import 'package:dynamic_modules/dynamic_modules.dart';
import 'package:sankofa_flutter/sankofa_flutter.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  SankofaUpdater.registerLoader(loadModuleFromBytes);
  await SankofaUpdater.preFlight();   // applies any staged patch on cold launch
  runApp(const MyApp());
}

After registerLoader, every other API call is loader-free. Engine version + signing keys live in sankofa.yaml (managed by the CLI); customer code never touches them.

Check + download from anywhere in your app:

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 applies it automatically (the preFlight() call in step 3 picks it up). Optionally prompt for restart so the patch takes effect immediately.

Auto-rollback: the SDK auto-disables any patch that crashes the app twice within 30 seconds of launch. The last known-good patch is restored automatically, and the bad label is added to a local ban list so the same patch isn't re-downloaded. No host code required.


๐Ÿชค Native crash bridge

The Flutter SDK ships a standalone native crash reporter built into the plugin (iOS + Android) โ€” no separate native SDK to install.

Layer Captured
Dart FlutterError.onError, PlatformDispatcher.onError, isolate listeners
iOS plugin NSSetUncaughtExceptionHandler, POSIX signals (SIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE/SIGTRAP/SIGSYS), main-queue stalls
Android plugin Chained Thread.UncaughtExceptionHandler, ANR watcher (main thread > 5s)

All three report through the same Catch pipeline. Sankofa.setUser / setTag(s) / flushCatch mirror to the native side automatically.


๐Ÿฉบ Troubleshooting

OTA updates aren't applying after restart

Verify in this order:

  1. SankofaUpdater.preFlight() is called before runApp(). Without it, the staged patch isn't read off disk on cold launch.
  2. sankofa.yaml is listed in pubspec.yaml's flutter.assets:. Otherwise the SDK can't find your api_key at runtime.
  3. The dashboard's rollout for the release is 100% (or explicitly includes your device's distinct_id).
  4. app_version matches. The server matches the release's target_binary_version to the device's app version verbatim โ€” no fuzzy semver matching.

๐Ÿ“‘ Documentation

For full API references and integration guides, visit the Sankofa docs.


๐Ÿ›ก License

Distributed under the MIT License. See LICENSE for more information.

Libraries

deploy
Subpath import for Sankofa Deploy only.
sankofa_flutter
Flutter client SDK for Sankofa Analytics, Deploy, Switch, and Config โ€” offline queueing, session replay, feature flags, and remote config.