sauron_flutter 1.2.0 copy "sauron_flutter: ^1.2.0" to clipboard
sauron_flutter: ^1.2.0 copied to clipboard

Sauron SDK for Flutter — captures uncaught Flutter/Dart errors across all four layers, records breadcrumbs, exposes track()/identify(), and batches, gzips, persists and delivers envelopes to the Sauro [...]

sauron_flutter #

Client SDK for Sauron (Sentry-style crash reporting fused with PostHog-style product analytics), for Flutter apps on Android, iOS, macOS, Windows and Linux. It runs inside your app, on the user's device — if you are instrumenting a server, use @edraj/sauron-node, sauron-sdk (Python) or the C# SDK instead; for a browser page, use @edraj/sauron-browser.

  • Captures uncaught errors across all four Flutter/Dart layers (FlutterError.onError, PlatformDispatcher.onError, Isolate.addErrorListener, runZonedGuarded).
  • Records breadcrumbs (app lifecycle, navigation, custom) and stamps every signal with the current screen and a per-launch session id.
  • track() / identify() / trackTransaction() for product analytics and latency percentiles.
  • Auto-collects device / OS / runtime context plus a stable, per-install device_id. App version/build are supplied by you at init — no plugin needed.
  • Batches → gzips → persists envelopes to an offline JSONL queue that survives app restarts, drains on reconnect, and honors the full ingest response policy.
  • Four package dependencies (http, device_info_plus, connectivity_plus, path_provider); no native code of its own.

Install #

flutter pub add sauron_flutter

or, in pubspec.yaml:

dependencies:
  sauron_flutter: ^1.0.0

Requires Dart SDK >=3.4.0 <4.0.0 and Flutter >=3.19.0.

Android builds additionally need Android Gradle Plugin >=8.12.1, Gradle wrapper >=8.13, and Kotlin 2.2.0. This floor comes from the device_info_plus plugin, not from Sauron itself.

Quick start #

import 'package:flutter/material.dart';
import 'package:sauron_flutter/sauron_flutter.dart';

Future<void> main() async {
  await Sauron.init(
    SauronOptions(
      dsn: 'https://pk_test@localhost:8081/1',
      environment: 'production',
      release: 'app@1.4.2+1402',
    ),
    appRunner: () => runApp(const MyApp()),
  );
}

appRunner runs WidgetsFlutterBinding.ensureInitialized(), installs the capture layers, awaits bootstrap() and then launches your app — all inside a single runZonedGuarded zone. Do not call runApp yourself when you pass appRunner.

Keep ensureInitialized() out of main(). Flutter pins runApp to the zone the binding was built in, so initializing it before Sauron.init makes the zone layer unavailable — see Startup ordering.

That is enough for uncaught errors. Add analytics and manual capture anywhere:

Sauron.identify('u_123', traits: <String, Object?>{'plan': 'pro'});
Sauron.setScreen('Checkout');
Sauron.track('checkout_completed',
    properties: <String, Object?>{'cart_value': 42.5});

try {
  await placeOrder();
} on Exception catch (error, stack) {
  Sauron.captureException(error, stackTrace: stack);
}

Configuration #

Sauron.init takes a SauronOptions. Every parameter is named and optional; fields stay mutable afterwards, so SauronOptions(dsn: dsn)..debug = true also works. Every field, in constructor order:

Option Type Default Description
dsn String? null Required to send anything. https://<public_key>@<host>/<project_id>. Null, empty or malformed leaves the SDK disabled and every call a no-op.
environment String 'production' Deployment environment, stamped on the envelope header.
release String? null Release identifier, e.g. app@1.4.2+1402. Stamped on the envelope header.
appVersion String? null App version for context.app, e.g. 1.4.2. Developer-supplied — the SDK does not read it from the platform.
appBuild String? null App build number for context.app, e.g. 1402. When this and appVersion are both null the app block is omitted.
screen String? null Seeds the initial screen name, stamped on events/errors until setScreen (or SauronNavigatorObserver) changes it.
sampleRate double 1.0 Fraction of errors sent, clamped to [0.0, 1.0]. Analytics events, identifies and transactions are never sampled.
maxBreadcrumbs int 100 Breadcrumb ring-buffer size; oldest evicted first. <= 0 disables breadcrumbs entirely.
tags Map<String, String> {} Default tags seeded into the global scope at init.
contexts Map<String, Map<String, Object?>> {} Default structured context blocks seeded into the global scope.
extra Map<String, Object?> {} Default freeform extra seeded into the global scope.
beforeSend BeforeSendCallback? null Runs on every outgoing item just before it is enqueued. Return the item to send, null to drop.
flushInterval Duration Duration(seconds: 5) Transport auto-flush cadence.
maxBatchItems int 30 Buffered-item count that triggers an eager flush.
maxItemsPerEnvelope int 1000 Hard ceiling on items per envelope, matching the server limit. The buffer is packed into chunks of this size. <= 0 means "one envelope, whatever the size".
maxQueueBytes int 5 * 1024 * 1024 On-disk offline-queue cap. Oldest envelopes evicted FIFO; the newest is always kept.
gzipThresholdBytes int 1024 Bodies at or above this size are gzipped, where gzip is available.
debug bool false Emit [Sauron] … diagnostics via debugPrint, including every item delivered to the server — see Seeing what is sent.
attachStacktrace bool true Attach StackTrace.current to captured errors that arrive without one.
httpClient http.Client? null Injected HTTP client (tests). Defaults to a fresh http.Client().

Two derived getters are also public: normalizedSampleRate (sampleRate clamped to [0.0, 1.0]) and isConfigured (dsn is non-null and non-empty).

Seeing what is sent #

debug: true prints every item the server accepted, so you can confirm what actually left the device instead of inferring it from the absence of errors. The line is emitted on delivery, not on capture — items are queued, may be split or retried, and can survive a restart before they land:

[Sauron] delivered 3 item(s) to https://ingest.example.com/api/42/envelope:
[Sauron]   identify u_123 (traits={"plan":"pro"})
[Sauron]   event checkout_completed (distinct_id=u_123, screen=Checkout, properties={"cart_value":42.5})
[Sauron]   error StateError: Bad state: card declined (level=error, screen=Checkout)

Transactions and breadcrumb batches are logged the same way (transaction GET /orders op=http 120.0ms status=null, breadcrumb_batch 12 crumb(s)). Long values are truncated to keep one item on one line. Keep debug off in release builds — the payload summaries include user-supplied properties and traits.

Everything set at once:

await Sauron.init(
  SauronOptions(
    dsn: 'https://pk_test@ingest.example.com/42',
    environment: 'staging',
    release: 'app@1.4.2+1402',
    appVersion: '1.4.2',
    appBuild: '1402',
    screen: 'Splash',
    sampleRate: 0.25,
    maxBreadcrumbs: 50,
    tags: <String, String>{'tier': 'free'},
    contexts: <String, Map<String, Object?>>{
      'build': <String, Object?>{'flavor': 'beta'},
    },
    extra: <String, Object?>{'boot_ms': 412},
    beforeSend: (Object item) {
      if (item is EventItem && item.name == 'secret') return null;
      return item;
    },
    flushInterval: const Duration(seconds: 10),
    maxBatchItems: 50,
    maxItemsPerEnvelope: 500,
    maxQueueBytes: 2 * 1024 * 1024,
    gzipThresholdBytes: 2048,
    debug: true,
    attachStacktrace: true,
    httpClient: null, // leave null outside tests
  ),
  appRunner: () => runApp(const MyApp()),
);

Supplying app version #

The SDK does not read your app's version off the platform — that would pull in a plugin (and its Android toolchain requirements) for two strings you already know at build time. Supply them yourself via appVersion / appBuild.

The dependency-free option is --dart-define, which keeps the values in your build command and out of the source tree:

flutter build apk \
  --dart-define=APP_VERSION=1.4.2 \
  --dart-define=APP_BUILD=1402
SauronOptions(
  dsn: dsn,
  appVersion: const String.fromEnvironment('APP_VERSION'),
  appBuild: const String.fromEnvironment('APP_BUILD'),
);

If you already depend on package_info_plus for other reasons, read it from there instead — the SDK is happy either way:

final PackageInfo info = await PackageInfo.fromPlatform();
SauronOptions(dsn: dsn, appVersion: info.version, appBuild: info.buildNumber);

Leave both unset and the app context block is omitted; nothing else is affected. Note release is separate — it identifies the build on the envelope header and is what the dashboard groups by, so set it regardless.

BeforeSendCallback #

typedef BeforeSendCallback = Object? Function(Object item);

The argument is the outgoing EnvelopeItem — an ErrorItem, EventItem, IdentifyItem, TransactionItem or BreadcrumbBatchItem. Return it (possibly mutated), return a replacement item, or return null to drop it. It runs on every item type, so guard on the runtime type if you only care about a subset:

SauronOptions(
  dsn: dsn,
  beforeSend: (Object item) {
    if (item is! ErrorItem) return item;
    if (item.exception.value.contains('@')) return null; // drop PII
    return item;
  },
);

API reference #

Convention below: named parameters are written with a trailing colon (stackTrace:); everything else is positional. Required parameters say so in the Default column.

All Sauron.* members are static and delegate to Sauron.client. Before init (or after close) the client is null and every call is a silent no-op.

Sauron.init #

static Future<void> init(
  SauronOptions options, {
  FutureOr<void> Function()? appRunner,
})
Parameter Type Default Description
options SauronOptions required The configuration object — see Configuration.
appRunner: FutureOr<void> Function()? null When supplied, binding init + integrations + bootstrap() + your app all run inside one runZonedGuarded. If the binding is already initialized the zone is skipped — see Startup ordering.

Returns Future<void>. Without appRunner, init calls WidgetsFlutterBinding.ensureInitialized(), installs the integrations and awaits bootstrap() itself — you then call runApp yourself and forgo the runZonedGuarded layer.

// With the zone (recommended):
await Sauron.init(SauronOptions(dsn: dsn),
    appRunner: () => runApp(const MyApp()));

// Without it:
await Sauron.init(SauronOptions(dsn: dsn));
runApp(const MyApp());

Sauron.captureException #

static void captureException(
  Object error, {
  StackTrace? stackTrace,
  Mechanism? mechanism,
  SauronLevel level = SauronLevel.error,
  String? screen,
  Map<String, String>? tags,
  Map<String, Map<String, Object?>>? contexts,
  Map<String, Object?>? extra,
})
Parameter Type Default Description
error Object required Any thrown value. runtimeType becomes the exception type, toString() the value.
stackTrace: StackTrace? null Falls back to StackTrace.current when attachStacktrace is true, otherwise no frames.
mechanism: Mechanism? Mechanism(type: 'manual', handled: true) How the error reached the SDK.
level: SauronLevel SauronLevel.error Severity.
screen: String? current screen Per-call screen override.
tags: Map<String, String>? null Per-call tags, merged over scope tags by key.
contexts: Map<String, Map<String, Object?>>? null Per-call context blocks, replacing same-named scope blocks.
extra: Map<String, Object?>? null Per-call extra, merged over scope extra by key.

Returns void. Subject to sampleRate; attaches the current breadcrumbs; then triggers an eager (unawaited) flush, because errors are worth sending now.

try {
  throw const FormatException('bad payload');
} on FormatException catch (error, stack) {
  Sauron.captureException(
    error,
    stackTrace: stack,
    level: SauronLevel.warning,
    tags: <String, String>{'endpoint': '/orders'},
    extra: <String, Object?>{'retries': 3},
  );
}

Sauron.track #

static void track(
  String name, {
  Map<String, Object?>? properties,
  Map<String, String>? tags,
  Map<String, Map<String, Object?>>? contexts,
  Map<String, Object?>? extra,
})
Parameter Type Default Description
name String required Event name, e.g. checkout_completed.
properties: Map<String, Object?>? null Event properties (JSON-encodable).
tags: Map<String, String>? null Per-call tags.
contexts: Map<String, Map<String, Object?>>? null Per-call context blocks.
extra: Map<String, Object?>? null Per-call extra.

Returns void. The event carries the current distinct id, session id and screen. Never sampled. The static facade has no screen: parameter — use Sauron.client!.track(name, screen: 'Checkout') for a per-call screen override.

Sauron.track(
  'checkout_completed',
  properties: <String, Object?>{'cart_value': 42.5, 'currency': 'USD'},
  tags: <String, String>{'plan': 'pro'},
);

Sauron.trackTransaction #

static void trackTransaction({
  required String name,
  required Duration duration,
  String op = 'custom',
  String? status,
  String? httpMethod,
  int? httpStatus,
  String? url,
})
Parameter Type Default Description
name: String required Route / operation label — the grouping key on the dashboard.
duration: Duration required Serialized as fractional milliseconds (inMicroseconds / 1000.0).
op: String 'custom' One of navigation, http, resource, screen_load, custom.
status: String? null Free-form outcome, e.g. ok, error.
httpMethod: String? null HTTP verb for http transactions.
httpStatus: int? null HTTP response status for http transactions.
url: String? null Request URL for http / resource transactions.

Returns void. The current distinct id and session id are attached automatically.

final Stopwatch sw = Stopwatch()..start();
final int statusCode = await fetchUsers();
sw.stop();
Sauron.trackTransaction(
  name: 'GET /users',
  op: 'http',
  duration: sw.elapsed,
  httpMethod: 'GET',
  httpStatus: statusCode,
  url: 'https://api.example.com/users',
  status: statusCode < 400 ? 'ok' : 'error',
);

Sauron.setScreen / Sauron.screen #

static void setScreen(String name)
static String? get screen
Parameter Type Default Description
name String required The new screen/route name.

Returns void. A no-op when name equals the current screen. On an actual change it emits one $screen analytics event with properties: {'screen': name} so dwell time can be computed server-side, and every later event/error is stamped with the new screen. Sauron.screen reads the current value (null until set or seeded via options.screen).

Sauron.setScreen('Checkout');
Sauron.setScreen('Checkout'); // no-op, no second $screen event
assert(Sauron.screen == 'Checkout');

Sauron.identify #

static void identify(String distinctId, {Map<String, Object?>? traits})
Parameter Type Default Description
distinctId String required Stable user id; becomes the scope user's id and the distinct_id on later events.
traits: Map<String, Object?>? null User traits. When null, the existing user's traits are preserved.

Returns void. Emits an identify item and updates the scope user, keeping the existing email. Never sampled.

Sauron.identify('u_123', traits: <String, Object?>{'plan': 'pro'});

Sauron.addBreadcrumb #

static void addBreadcrumb(Breadcrumb crumb)
Parameter Type Default Description
crumb Breadcrumb required The breadcrumb to append to the ring buffer.

Returns void. Breadcrumbs are not sent on their own; a snapshot of the buffer rides along with each captured error.

Sauron.addBreadcrumb(Breadcrumb.ui('Tapped: checkout'));
Sauron.addBreadcrumb(Breadcrumb.navigation('/settings'));
Sauron.addBreadcrumb(
  Breadcrumb.log('cache miss', level: SauronLevel.warning),
);

Sauron.setUser #

static void setUser(SauronUser? user)
Parameter Type Default Description
user SauronUser? required Replaces the scope user wholesale. null clears it.

Returns void. The user is serialized into context.user of every envelope.

Sauron.setUser(const SauronUser(id: 'u_123', email: 'dev@example.com'));
Sauron.setUser(null); // on sign-out

Sauron.setTag / Sauron.setTags #

static void setTag(String key, String value)
static void setTags(Map<String, String> values)
Parameter Type Default Description
key String required Tag key.
value String required Tag value.
values Map<String, String> required Tags merged into the scope, last-write-wins by key.

Both return void.

Sauron.setTag('feature', 'checkout');
Sauron.setTags(<String, String>{'tier': 'free', 'ab_bucket': 'b'});

Sauron.setContext #

static void setContext(String name, Map<String, Object?> block)
Parameter Type Default Description
name String required Block name.
block Map<String, Object?> required Structured block; replaces any existing block with the same name.

Returns void.

Sauron.setContext('order', <String, Object?>{'id': 7, 'total': 42.5});

Sauron.setExtra #

static void setExtra(String key, Object? value)
Parameter Type Default Description
key String required Extra key.
value Object? required Any JSON-encodable value.

Returns void.

Sauron.setExtra('cart_size', 3);

Sauron.flush #

static Future<void> flush()

Zero-argument. Packs the buffer into an envelope, persists it, and drains the on-disk queue. Awaiting it awaits the drain attempt (not delivery of retried envelopes).

await Sauron.flush();

Sauron.close #

static Future<void> close()

Zero-argument. Flushes, cancels the timers, disposes the connectivity listener, closes the HTTP client, uninstalls the four capture layers (restoring the handlers they replaced), and clears Sauron.client.

Terminal and idempotent: isEnabled flips to false, and anything captured afterwards is dropped rather than buffered — a long-lived process that closes the SDK does not accumulate events. Re-init in the same process is not supported.

await Sauron.close();

Sauron.addIsolateErrorListener #

static void addIsolateErrorListener(Isolate isolate)
Parameter Type Default Description
isolate Isolate required An isolate you spawned.

Returns void. The SDK auto-listens on Isolate.current only; isolates you spawn must be registered explicitly. No-op on web.

final Isolate isolate =
    await Isolate.spawn<String>(_entry, 'payload', paused: true);
Sauron.addIsolateErrorListener(isolate);
isolate.resume(isolate.pauseCapability!);

Sauron.client / Sauron.isEnabled #

static SauronClient? get client
static bool get isEnabled

client is null before init and after close. isEnabled is true only when a client exists and its DSN parsed successfully.

SauronClient #

The engine behind the facade. Sauron.client hands you the live instance; it is also constructible directly (the tests do this) when you want two clients or full control of the lifecycle.

SauronClient(SauronOptions options)

It exposes every capture/scope method the facade does, with two differences and four additions:

Member Signature Notes
options final SauronOptions The options this client was built with.
sessionId final String UUIDv4 generated at construction; stamped on errors, events and transactions.
screen String? get Current screen.
isEnabled bool get Whether the DSN parsed.
installIntegrations() void installIntegrations() Installs the error layers + lifecycle observer. Must run after WidgetsFlutterBinding.ensureInitialized(). Called for you by Sauron.init.
bootstrap({Directory? queueDirectory}) Future<void> Resolves the queue directory (defaults to <app-support>/sauron), loads device context, starts the transport and replays anything captured before it was ready. Idempotent.
track(...) adds screen: client.track(name, properties:, screen:, tags:, contexts:, extra:) — the facade omits screen:.
final SauronClient client = SauronClient(
  SauronOptions()..dsn = 'https://pk_test@localhost:8081/1',
);
await client.bootstrap(queueDirectory: Directory.systemTemp);
client.track('viewed', screen: 'Home');
await client.close();

Types #

Everything below is exported from package:sauron_flutter/sauron_flutter.dart.

Type Constructor / signature Notes
SauronLevel enum debug, info, warning, error, fatal Wire value is .name.
Breadcrumb Breadcrumb({required String type, required String category, String? message, SauronLevel level = SauronLevel.info, DateTime? timestamp, Map<String, Object?>? data}) All named. timestamp defaults to DateTime.now().toUtc(), data to {}.
Breadcrumb.navigation (String route, {Map<String, Object?>? data}) type navigation, category route.
Breadcrumb.ui (String message, {Map<String, Object?>? data}) type ui, category click.
Breadcrumb.log (String message, {SauronLevel level = SauronLevel.info, Map<String, Object?>? data}) type log, category console.
SauronUser const SauronUser({String? id, String? email, Map<String, Object?> traits = const {}}) Has copyWith({id, email, traits}).
Mechanism const Mechanism({required String type, bool handled = false}) type is the capture layer.
SauronException const SauronException({required String type, required String value, required Mechanism mechanism, List<StackFrame> stacktrace = const []}) Built for you by captureException.
StackFrame const StackFrame({String? function, String? filename, int? lineno, int? colno, bool inApp = false}) inApp is true for package:/file: frames that are not dart:, package:flutter/, package:flutter_test/ or package:sauron_flutter/.
DebugMeta const DebugMeta({String? buildId, String? isolateDsoBase, String? arch, String? os}) and DebugMeta.fromTrace(String raw, {String? os}) Parses build_id: and isolate_dso_base: out of an AOT trace header.
DeviceDescriptor const DeviceDescriptor({String? family, String? model, String? arch, String? deviceId}) Has copyWith.
OsDescriptor const OsDescriptor({String? name, String? version})
AppDescriptor const AppDescriptor({String? version, String? build})
RuntimeDescriptor const RuntimeDescriptor({String? name, String? version})
SauronContext const SauronContext({DeviceDescriptor? device, OsDescriptor? os, AppDescriptor? app, RuntimeDescriptor? runtime, SauronUser? user}) Has copyWith. Built by the SDK each send.
EnvelopeHeader const EnvelopeHeader({required String dsn, required DateTime sentAt, required String environment, String? release, String sdkName = kSauronSdkName, String sdkVersion = kSauronSdkVersion})
Envelope const Envelope({required EnvelopeHeader header, required SauronContext context, required List<EnvelopeItem> items}) encode() returns the compact wire JSON.
EnvelopeItem abstract; String get type, Map<String, Object?> toJson(), int get approximateBytes Base of all items below.
ErrorItem ErrorItem({required SauronException exception, required DateTime timestamp, SauronLevel level = SauronLevel.error, List<Breadcrumb> breadcrumbs = const [], List<String>? fingerprint, String? sessionId, String? screen, String? rawStacktrace, DebugMeta? debugMeta, Map<String, String> tags = const {}, Map<String, Map<String, Object?>> contexts = const {}, Map<String, Object?> extra = const {}}) fingerprint is never set by the SDK — null lets the server group.
EventItem EventItem({required String name, required DateTime timestamp, String? distinctId, String? sessionId, String? screen, Map<String, Object?>? properties, Map<String, String>? tags, Map<String, Map<String, Object?>>? contexts, Map<String, Object?>? extra})
IdentifyItem IdentifyItem({required String distinctId, String? anonymousId, Map<String, Object?>? traits})
TransactionItem TransactionItem({required String name, required double durationMs, String op = 'custom', String? status, String? httpMethod, int? httpStatus, String? url, String? distinctId, String? sessionId, DateTime? timestamp})
BreadcrumbBatchItem BreadcrumbBatchItem({required List<Breadcrumb> breadcrumbs, DateTime? timestamp}) Part of the wire contract; the Flutter SDK never emits one on its own. Construct and pass it through a SauronClient only if you need standalone breadcrumbs.
Dsn Dsn({required String scheme, required String publicKey, required String host, required int port, required String projectId, List<String> pathPrefix = const []}), Dsn.parse(String input) parse throws FormatException. envelopeEndpointUri of .../api/{project_id}/envelope; toString() round-trips the canonical DSN.
DartStackTraceParser const DartStackTraceParser(), List<StackFrame> parse(Object? stackTrace), static bool isNoise(String line) Parses JIT and AOT traces; unrecognized lines are dropped.
isObfuscatedDartTrace bool isObfuscatedDartTrace(String raw) true when the trace contains isolate_dso_base or build_id:.
sauronIso String sauronIso(DateTime dateTime) ISO-8601 UTC with a trailing Z.
kSauronSdkName const String = 'sauron.flutter' Sent in header.sdk.name.
kSauronSdkVersion const String = '1.0.0' Sent in header.sdk.version.
SauronNavigatorObserver see Flutter integration
SauronWidgetsBindingObserver see Flutter integration

Scope & metadata #

There is exactly one scope per client. It holds the user, a bounded breadcrumb buffer, and three developer-owned maps: tags (flat String -> String), contexts (named structured blocks) and extra (freeform JSON). The machine-collected context (device / os / app / runtime / user) is separate and is never touched by these setters.

Precedence, lowest to highest:

  1. Init defaultsoptions.tags / options.contexts / options.extra seed the scope when the client is constructed.
  2. Runtime setterssetTag, setTags, setContext, setExtra mutate that same scope; they overwrite seeded values by key.
  3. Per-call argumentstags: / contexts: / extra: on captureException and track apply to that one item only.

Merge semantics differ by kind:

  • tags and extra merge shallowly, per key; the later write wins.
  • contexts merges by block name — a per-call block replaces the same-named scope block wholesale rather than deep-merging into it.
  • Empty tags / contexts / extra maps are omitted from the wire entirely.
// init:      tags {env_tag: seed}, contexts {order: {id: 1}}, extra {boot: true}
Sauron.setTag('env_tag', 'runtime');          // overrides the seed
Sauron.setContext('cart', <String, Object?>{'items': 2});
Sauron.client!.track(
  'checkout',
  tags: <String, String>{'env_tag': 'call'},  // wins over scope
  contexts: <String, Map<String, Object?>>{
    'order': <String, Object?>{'id': 99},     // replaces the seeded block
  },
);
// on the wire: tags {env_tag: call}
//              contexts {order: {id: 99}, cart: {items: 2}}
//              extra {boot: true}

User. setUser replaces the whole user. identify(id, traits:) sets the user's id to id, preserves the existing email, and preserves existing traits when traits is null. The resulting user is serialized into context.user on every envelope; distinct_id on events/transactions comes from user.id.

Breadcrumbs. A FIFO ring buffer capped at maxBreadcrumbs. A snapshot is attached to each ErrorItem at capture time; the buffer is not cleared afterwards, so a later error still carries the same history.

Screen. options.screen seeds it, setScreen changes it (emitting one $screen event per change), SauronNavigatorObserver can drive it from named routes, and screen: on captureException / client.track overrides it for a single item.

Flutter integration #

Bootstrap #

Future<void> main() async {
  await Sauron.init(
    SauronOptions(dsn: 'https://pk_test@localhost:8081/1'),
    appRunner: () => runApp(const MyApp()),
  );
}

appRunner is the supported path: it calls WidgetsFlutterBinding.ensureInitialized(), installIntegrations() and bootstrap() inside runZonedGuarded, so binding-owned callbacks and any failure during startup are captured too. If you must control runApp yourself, omit appRunner — you keep layers 1-3 and lose the zone catch-all.

Startup ordering

Flutter records the zone the binding was created in and asserts runApp still runs in it. So the zone layer is only available when Sauron initializes the binding — that is, when nothing touched it first:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();   // ← binding pinned to this zone
  final config = await loadConfig();
  await Sauron.init(
    SauronOptions(dsn: config.dsn),
    appRunner: () => runApp(MyApp(config: config)),   // would be a second zone
  );
}

Sauron detects this and runs your app in the current zone instead, so you never see Flutter's Zone mismatch. assertion — but layer 4 is skipped (with debug: true the SDK logs that it did). Layers 1-3 still catch everything: PlatformDispatcher.onError is Flutter's supported catch-all for async errors outside a guarded zone.

To keep all four layers, move the pre-runApp work into appRunner, which already runs after ensureInitialized() inside the zone:

Future<void> main() async {
  await Sauron.init(
    SauronOptions(dsn: const String.fromEnvironment('SAURON_DSN')),
    appRunner: () async {
      final config = await loadConfig();   // binding is up, same zone as runApp
      runApp(MyApp(config: config));
    },
  );
}

If your DSN itself comes from that async work, keep the pre-init version — the zone layer is the only thing you give up.

Error capture layers #

# Layer Installed by Mechanism type handled Level
1 FlutterError.onError installIntegrations() FlutterError.onError false error
2 PlatformDispatcher.instance.onError installIntegrations() PlatformDispatcher.onError false error
3 Isolate.current.addErrorListener installIntegrations(), skipped on web Isolate.addErrorListener false fatal
4 runZonedGuarded passing appRunner: to init runZonedGuarded false error

Layer 1 chains the previous FlutterError.onError (so the debug red screen and any existing console reporting survive), falling back to FlutterError.presentError if there was none. Layer 2 chains the previous handler and returns true, marking the error handled. Layer 3 covers the current isolate only — register isolates you spawn with Sauron.addIsolateErrorListener(isolate). Manual captureException calls default to Mechanism(type: 'manual', handled: true).

Each layer installs at most once per process. close() uninstalls all of them, restoring the handlers they replaced.

SauronNavigatorObserver #

SauronNavigatorObserver(
  SauronClient client, {
  bool recordTransactions = true,
  bool trackScreens = true,
})
Parameter Type Default Description
client SauronClient required Usually Sauron.client!.
recordTransactions: bool true Emit a navigation transaction for the route being left, timed by its dwell duration.
trackScreens: bool true Drive setScreen from route.settings.name on each change.

Records a navigation/route breadcrumb on every push, pop, replace and remove (with data: {'operation': …}), naming unnamed routes <unnamed>. Transactions and screen tracking both require route.settings.name, so unnamed routes contribute neither. Instrumentation failures are swallowed — it never breaks navigation.

MaterialApp(
  navigatorObservers: <NavigatorObserver>[
    if (Sauron.client != null) SauronNavigatorObserver(Sauron.client!),
  ],
  home: const HomePage(),
);

The two switches are independent. Set recordTransactions: false to attribute events to screens without emitting navigation timings, or trackScreens: false for the reverse.

// Screen attribution only — no navigation transactions.
SauronNavigatorObserver(Sauron.client!, recordTransactions: false);

SauronWidgetsBindingObserver #

SauronWidgetsBindingObserver(SauronClient client)
static void install(SauronClient client)
static void uninstall()

Installed automatically by installIntegrations() as a process-wide singleton. It records a navigation/app.lifecycle breadcrumb on every lifecycle change and flushes the transport on paused and detached, so buffered data survives backgrounding. You only need the class directly if you manage the binding yourself.

Device, app and privacy #

bootstrap() collects, once, and caches:

  • device — Android: manufacturer / model / first supported ABI; iOS: Apple / utsname.machine; macOS: Apple / model / arch; Windows: PC / product name; Linux: name / pretty name.
  • os — name and version per platform.
  • appversion and build, taken verbatim from the appVersion / appBuild options. Not read from the platform; omitted entirely when neither is set. See Supplying app version.
  • runtimeDart plus the major.minor from Platform.version.
  • device_id — a UUIDv4 minted on first run and persisted to <app-support>/sauron/sauron_prefs.json under the key sauron.device_id. The backend treats it as the stable device identity. It is per-install, not per-user: uninstalling the app resets it, and nothing links it to a hardware identifier. Every plugin read is guarded — a failure yields null fields rather than a lost error report.

The stable device id and the per-launch sessionId are the only identifiers the SDK creates on its own. Everything else about a user comes from your identify / setUser calls, and beforeSend is the escape hatch for redacting any of it before it leaves the device.

Stack traces & symbolication #

Debug/JIT traces are parsed on-device into normalized StackFrames (function, filename, lineno, colno, in_app).

Release AOT builds are different: the trace is program-counter offsets, not names. When the raw trace contains isolate_dso_base or build_id: (isObfuscatedDartTrace), the SDK additionally ships:

  • raw_stacktrace — the verbatim trace string, and
  • debug_metabuild_id and isolate_dso_base parsed out of its header,

so the server can resolve the addresses against the matching --split-debug-info ELF via DWARF. Frames are never symbolicated on-device. Build with --split-debug-info=<dir> (the flag that produces the symbol file; usually paired with --obfuscate) and upload the result before you need to read a trace:

sauron-symcli upload-dart --api <url> --token <jwt> --app <uuid> \
    --platform android --arch arm64 --debug-id <build-id> app.symbols

Without an uploaded symbol file the dashboard falls back to showing the raw trace.

Transport & delivery #

  • Batching. Items are buffered in memory and packed into an envelope on a flushInterval timer (default 5s), when the buffer reaches maxBatchItems (30), when an error is captured, on app paused/detached, and on flush() / close().
  • Envelope bounds. The buffer is packed into chunks of at most maxItemsPerEnvelope (1000) so a backlog never goes out as a single oversized request.
  • Compression. Bodies ≥ gzipThresholdBytes (1024) are gzipped with dart:io's GZipCodec and sent with Content-Encoding: gzip. A compile-time conditional import supplies a no-op stub where dart:io is absent.
  • Offline queue. Every envelope is appended to a JSONL file at <app-support>/sauron/queue.jsonl before the send is attempted, so it survives an app kill. Total size is capped at maxQueueBytes (5 MiB) with FIFO eviction of the oldest entries; the newest entry is always retained even if it alone exceeds the cap. A corrupt or unreadable queue file is discarded rather than crashing the app.
  • Draining. The queue is drained on bootstrap() (picking up a previous session's envelopes), on the flush timer, and whenever connectivity_plus reports a non-none interface. Connectivity is only a hint; the HTTP response is the authoritative signal.
  • Retry. Exponential backoff with full jitter — min(30, 2^attempt) seconds plus 0-999 ms, capped at 30 s. The attempt counter resets on the first success.

Request shape:

POST /api/{project_id}/envelope
Content-Type: application/json
Content-Encoding: gzip            # when compressed
X-Sauron-Key: <public_key>

{ "header": {…}, "context": {…}, "items": [ … ] }

Response policy:

Status Action
200 / 202 Success — remove from the queue.
400 Drop, no retry.
401 / 403 Drop and disable the transport for this process.
413 Split the envelope in half and re-queue both. A single-item envelope is dropped.
429 Retry, honoring Retry-After — an integer number of seconds, or any date DateTime.tryParse accepts (ISO-8601).
408 / 5xx / network error Retry with backoff + jitter.
any other 4xx Drop, no retry.

Platform support #

Platform Supported Notes
Android yes All four layers, offline queue, gzip.
iOS yes All four layers, offline queue, gzip.
macOS / Windows / Linux yes Same as mobile.
Web no lib/src/client.dart, queue.dart, device_id_store.dart and device_context.dart import dart:io unconditionally, and path_provider has no web implementation, so a web build does not compile. The kIsWeb guards and the gzip stub are groundwork, not a shipped target — use @edraj/sauron-browser for the web.

Troubleshooting #

Symptom Cause Fix
Nothing arrives, no logs dsn unset/empty, so the SDK is disabled Pass dsn: to SauronOptions; check Sauron.isEnabled.
Nothing arrives, [Sauron] invalid DSN, SDK disabled DSN failed to parse Use https://<public_key>@<host>[:port]/<project_id>; the project id is the last path segment.
Requests leave but nothing lands Your proxy does not expose ingest at /api/{project_id}/envelope on the DSN's host (plus any DSN path prefix) Route that exact path to the gateway — events otherwise drop silently and look delivered.
Delivery stops permanently mid-session A 401/403 disabled the transport Verify the public key belongs to the project; restart the app after fixing.
Events arrive, errors do not sampleRate < 1.0, or beforeSend returned null Pass sampleRate: 1.0; log inside beforeSend.
No breadcrumbs on errors maxBreadcrumbs <= 0 Set a positive maxBreadcrumbs.
Stack traces are hex addresses Obfuscated AOT build with no symbols uploaded Upload the --split-debug-info output (see above).
Errors from a spawned isolate are missing Only Isolate.current is auto-listened Call Sauron.addIsolateErrorListener(isolate).
Nothing captured after close() close() is terminal: it disables the client and uninstalls the capture layers Treat close() as end-of-process; do not re-init.
Web build fails on dart:io Web is not a supported target Use @edraj/sauron-browser.
Need to see what the SDK is doing debug: true prints [Sauron] … lines (sampling drops, beforeSend drops, invalid DSN, network errors, retry schedule, non-retryable drops).

Development #

flutter pub get
flutter analyze     # flutter_lints + strict-casts + strict-raw-types
flutter test

Run the sample app (all four layers, track, identify, flush wired to buttons):

cd example
flutter pub get
flutter run

test/envelope_test.dart holds the locked golden envelope shape that guards byte-for-byte parity with the Rust backend and the other SDKs — update it only alongside the wire contract.

License #

AGPL-3.0-only — GNU Affero General Public License v3.0.

Repo: https://github.com/edraj/sauron — wiki: https://github.com/edraj/sauron/wiki

0
likes
0
points
315
downloads

Documentation

Documentation

Publisher

unverified uploader

Weekly Downloads

Sauron SDK for Flutter — captures uncaught Flutter/Dart errors across all four layers, records breadcrumbs, exposes track()/identify(), and batches, gzips, persists and delivers envelopes to the Sauron ingest gateway.

Repository (GitHub)
View/report issues

Topics

#error-tracking #analytics #observability #monitoring

License

unknown (license)

Dependencies

connectivity_plus, device_info_plus, flutter, http, path_provider

More

Packages that depend on sauron_flutter