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 and anonymous id, so unidentified people are still counted as people. 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 the flush timer and on app resume, and honors the full ingest response policy.
  • Three package dependencies (http, device_info_plus, path_provider); no native code of its own, and no permissions added to your manifest.

Install

flutter pub add sauron_flutter

or, in pubspec.yaml:

dependencies:
  sauron_flutter: ^1.9.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',
      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:

await 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>/<environment_id>. Null, empty or malformed leaves the SDK disabled and every call a no-op.
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',
    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;
  },
);

Funnels

Funnels track the conversion rate of users progressing through a defined sequence of steps. By tracking a unique event at each step, the Sauron dashboard can visualize where users drop off.

// 1. User arrives at the pricing page
Sauron.track('pricing_viewed');

// 2. User clicks on a plan
Sauron.track('plan_selected', properties: <String, Object?>{'plan': 'pro'});

// 3. User successfully checks out
Sauron.track('checkout_completed', properties: <String, Object?>{'plan': 'pro', 'value': 42.5});

User Journeys

User journeys track the broader path a user takes through your application. Combine setScreen (to track navigation) and startWorkflow (to group a multi-step process) to see exactly how a user reached an outcome or encountered an error.

// Update the screen when the user navigates
Sauron.setScreen('OnboardingStep1');

// Start a workflow to group all subsequent events and errors
Sauron.startWorkflow('user_onboarding');

// Track specific actions within the journey
Sauron.track('profile_photo_uploaded');

Sauron.setScreen('OnboardingStep2');
Sauron.track('preferences_saved');

// End the workflow when the journey concludes
Sauron.endWorkflow();

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 — except the three workflow methods, which instead return a WorkflowResult(status: WorkflowStatus.disabled) (see Workflows).

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.

No identity needed. The distinct id is the scope user's id when Sauron.identify(...) (or Sauron.setUser(...)) has named one, and otherwise the persisted anonymous id — so events tracked before login still count, and a later identify stitches them onto the account.

The one case still dropped is an analytics item tracked before await Sauron.init(...) has finished, when neither id exists yet. The first drop prints [Sauron] dropped analytics item "<name>": no distinct_id … even with debug: false. Dropped rather than sent because distinct_id is a required string on the wire: null makes the gateway reject the entire envelope with 400 invalid_envelope, a 400 is non-retryable, and one such item would take every error, transaction and identify batched with it (up to maxBatchItems, default 30) down with it.

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,
  Map<String, String>? tags,
  Map<String, Object?>? extra,
})
Parameter Type Default Description
name: String required Route / operation label — the grouping key on the dashboard. Keep it low cardinality (GET /users/:id, not GET /users/8412), or every request becomes its own row.
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.
tags: Map<String, String>? null Indexed string→string labels. Filter with @tag.key:value on the Transactions page.
extra: Map<String, Object?>? null Freeform JSON — request body, response body, SQL text, row counts. Searchable with extra.key:value.

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

tags and extra are per-call only. Unlike captureException() and track(), a transaction does not inherit the scope: Sauron.setTag() / Sauron.setExtra() defaults are not merged in. Transactions are the highest-volume signal an app emits — one per navigation and per HTTP call — so inheriting a global blob would write it onto every row.

extra is serialized and capped at 16 KB. Past that the whole map is replaced with {'_truncated': true, '_bytes': N} and the dashboard says so on the row. The cap is not cosmetic: envelopes are batched, and one oversized body would push the whole envelope past the ingest limit and drop every unrelated span sent with it.

Nothing in extra is scrubbed. Use beforeSend for redaction, and think twice before attaching a body that can carry tokens, passwords or personal data.

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',
);

Example: an HTTP call, with request and response bodies

The package:http call and the span around it. extra is where the payload goes — name stays the grouping key.

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:sauron_flutter/sauron_flutter.dart';

Future<Order> createOrder(Map<String, Object?> payload) async {
  final Uri url = Uri.parse('https://api.example.com/orders');
  final String body = jsonEncode(payload);
  final Stopwatch sw = Stopwatch()..start();

  try {
    final http.Response res = await http.post(
      url,
      headers: <String, String>{'content-type': 'application/json'},
      body: body,
    );
    sw.stop();

    Sauron.trackTransaction(
      name: 'POST /orders',
      op: 'http',
      duration: sw.elapsed,
      httpMethod: 'POST',
      httpStatus: res.statusCode,
      url: url.toString(),
      status: res.statusCode < 400 ? 'ok' : 'error',
      tags: <String, String>{'api': 'orders', 'tier': currentPlan},
      extra: <String, Object?>{
        'request': body,
        'response': res.body,
        'response_bytes': res.bodyBytes.length,
        // Header VALUES are omitted on purpose — `authorization` lives there.
        'request_headers': <String>['content-type'],
      },
    );
    return Order.fromJson(jsonDecode(res.body) as Map<String, Object?>);
  } catch (e) {
    sw.stop();
    Sauron.trackTransaction(
      name: 'POST /orders',
      op: 'http',
      duration: sw.elapsed,
      httpMethod: 'POST',
      url: url.toString(),
      status: 'error',
      extra: <String, Object?>{'request': body, 'error': e.toString()},
    );
    rethrow;
  }
}

On the dashboard: Transactions → the row → expand. Both bodies render as a JSON tree, and every one of these finds it:

extra.response:~9001        # substring, inside the stored response body
@tag.api:orders             # indexed tag
op:http http.status:>=500   # the failures
duration:>2s                # the slow ones

Example: a SQL query (sqflite)

Same shape, different payload. Put the statement in extra and keep name a stable label — a query with literals baked in would mint a new dashboard row per execution.

import 'package:sqflite/sqflite.dart';
import 'package:sauron_flutter/sauron_flutter.dart';

Future<List<Map<String, Object?>>> recentOrders(Database db, String userId) async {
  const String sql =
      'SELECT id, total, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20';
  final Stopwatch sw = Stopwatch()..start();

  try {
    final List<Map<String, Object?>> rows = await db.rawQuery(sql, <Object?>[userId]);
    sw.stop();
    Sauron.trackTransaction(
      // The LABEL, not the statement. This SDK passes any `op` string through
      // unchanged, so `'db'` would be stored as-is — but the browser SDK
      // coerces anything outside navigation|http|resource|screen_load|custom
      // to 'custom', so `custom` + a tag is what keeps the five SDKs agreeing.
      name: 'SELECT orders',
      op: 'custom',
      duration: sw.elapsed,
      status: 'ok',
      tags: <String, String>{'db': 'sqflite', 'table': 'orders'},
      extra: <String, Object?>{
        'statement': sql,
        'row_count': rows.length,
        // Bind PARAMETERS are user data. Log them only if you have decided
        // that is acceptable, or log their shape instead.
        'params': <String, Object?>{'user_id': userId},
      },
    );
    return rows;
  } on DatabaseException catch (e) {
    sw.stop();
    Sauron.trackTransaction(
      name: 'SELECT orders',
      op: 'custom',
      duration: sw.elapsed,
      status: 'error',
      tags: <String, String>{'db': 'sqflite', 'table': 'orders'},
      extra: <String, Object?>{'statement': sql, 'error': e.toString()},
    );
    rethrow;
  }
}

Then @tag.table:orders duration:>500ms is your slow-query list.

Sauron.startTransaction / ActiveTransaction

For operations that span time asynchronously, use startTransaction to get an ActiveTransaction object. You can then call .end() or .cancel() on it. This ensures the SDK captures exactly when the transaction started and when it ended, automatically computing the correct duration for you without requiring manual stopwatches.

static ActiveTransaction startTransaction({
  required String name,
  String op = 'custom',
  String? status,
  String? httpMethod,
  int? httpStatus,
  String? url,
  Map<String, String>? tags,
  Map<String, Object?>? extra,
})

Calling .end() records the transaction with the computed duration. You can override properties like status or httpStatus at the end:

final tx = Sauron.startTransaction(
  name: 'Fetch Data',
  op: 'http',
  httpMethod: 'GET',
);

try {
  final response = await http.get(Uri.parse('https://api.example.com/data'));
  tx.end(status: 'ok', httpStatus: response.statusCode);
} catch (e) {
  // Cancelling automatically ends it and sets status to 'cancelled: network_error'
  tx.cancel('network_error');
}

tags and extra are also mutable fields on ActiveTransaction, which is usually what you want: the interesting facts about a call are known after it returns, not before.

final tx = Sauron.startTransaction(
  name: 'POST /orders',
  op: 'http',
  httpMethod: 'POST',
  extra: <String, Object?>{'request': body},   // known up front
);

final res = await http.post(url, body: body);

tx.extra!['response'] = res.body;              // known now
tx.tags = <String, String>{'api': 'orders'};
tx.end(status: 'ok', httpStatus: res.statusCode);

.end(tags: …, extra: …) also accepts both, but it replaces each map wholesale — matching how status / httpStatus / url behave there. To add to what is already set, mutate the field as above; passing a partial map would silently discard the rest.

If you call .end() or .cancel() multiple times on the same ActiveTransaction, it safely ignores subsequent calls and only records the span once.

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

The $screen event goes through track, so it needs an identity — see the note under Sauron.track. The screen name itself is still recorded and still stamped onto errors when the $screen event is dropped.

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

Sauron.startWorkflow / Sauron.endWorkflow / Sauron.cancelWorkflow / Sauron.workflow

A workflow is a named, explicitly-bounded span of activity you declare around a multi-step flow (checkout, onboarding, a background sync) so the dashboard can group the errors/events/transactions captured inside it. Entirely optional: if you never call startWorkflow, nothing changes — no field is added to any item, and workflow_id/workflow_name are omitted from the wire rather than sent as null.

static WorkflowResult startWorkflow(String name, {bool force = false})
static WorkflowResult endWorkflow([String? name])
static WorkflowResult cancelWorkflow([String? name, String? reason])
static ActiveWorkflow? get workflow
Parameter Type Default Description
name (start) String required The workflow's name. Trimmed; must be non-empty and at most 120 chars after trimming, or the call is rejected.
force: bool false When true and another workflow is already active, that workflow is closed first ($workflow_cancel, reason: 'superseded') and the new one starts. When false, an already-active workflow makes the call a no-op.
name (end/cancel) String? null Optional guard: when given, the call only takes effect if it equals the active workflow's name. null closes whichever workflow is active.
reason (cancel only) String? null Free-form cancellation reason. Trimmed and capped at 120 chars; defaults to 'user' when omitted or blank.

While a workflow is active, its workflow_id (a fresh, client-generated UUIDv4 — never derived from the session id, device id, or the name itself) and workflow_name are stamped onto every captured error, track() event, and trackTransaction(), in addition to riding along in the lifecycle event's own properties. identify() is never stamped — the server has no workflow columns for it.

Starting, ending, and cancelling a workflow each emit one reserved analytics event through track()$workflow_start, $workflow_end, or $workflow_cancel — so they show up in Events like anything else. endWorkflow adds duration_ms; cancelWorkflow adds both duration_ms and reason. Because they go through track(), they need an identity — see the note under Sauron.track. The workflow itself still starts, still returns ok, and is still stamped onto errors when its lifecycle event is dropped.

All three mutators return a WorkflowResult { status, workflowId }. Exactly six WorkflowStatus values, never a seventh:

Status From Meaning
ok any The call took effect. workflowId is the id of the workflow it affected.
alreadyActive startWorkflow Another workflow is active and force was not set. No-op.
invalidName startWorkflow name is empty after trimming, or over 120 chars. No-op.
notActive endWorkflow/cancelWorkflow No workflow is active. No-op.
nameMismatch endWorkflow/cancelWorkflow The given name does not equal the active workflow's name — including when the given name itself fails normalization (blank, or over 120 chars). invalidName is only ever returned by startWorkflow; a malformed name on end/cancel is nameMismatch.
disabled any The SDK did not perform the call: before init, after close(), after the transport auto-disabled itself (401/403), or an unexpected internal error. Never a claim about workflow state or your input — just "the SDK did not do this."

Sauron.workflow reads the active ActiveWorkflow { workflowId, name, startedAt }, or null when none is active (including before init / after close()).

Abandonment. A workflow with no further stamped activity for 30 minutes reads as abandoned when you view it on the dashboard. This is computed on read from the last stamped event's timestamp — it is never stored, and no client action is needed. If a later event or error is stamped with that same workflow_id after the 30-minute mark, the workflow simply reads as active again; nothing needs to be "resumed."

// A genuine bounded span: start, do work (events + a captured error land
// inside it), then end.
final WorkflowResult started = Sauron.startWorkflow('checkout');
if (started.status == WorkflowStatus.ok) {
  Sauron.track('checkout_started');
  try {
    await placeOrder();
    Sauron.track('checkout_completed', properties: <String, Object?>{'cart_value': 42.5});
  } on Exception catch (error, stack) {
    Sauron.captureException(error, stackTrace: stack); // stamped with this workflow
  }
  Sauron.endWorkflow(); // emits $workflow_end with duration_ms
}

// Cancelling instead of ending — e.g. the user backs out of the flow.
Sauron.startWorkflow('checkout');
Sauron.cancelWorkflow(); // reason defaults to 'user'

Sauron.startWorkflow('checkout');
Sauron.cancelWorkflow('checkout', 'payment declined 3x');

Sauron.identify

static Future<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 Future<void>await it. Emits an identify item and updates the scope user, keeping the existing email. Never sampled.

The item carries anonymous_id only when the anonymous id was actually used as a distinct_id first — that is what tells the server to stitch the pre-login activity onto this account. On a first-ever launch with no anonymous activity it is null, deliberately: the server writes a permanent alias row for any non-empty anonymous_id, and a speculative one mis-merges two people forever.

Auto-reset on identity switch. identify() also detects a login by a DIFFERENT user than last time on this device — the common case of a forgotten reset() on logout — and mints a fresh anonymous id (and rotates the session id) before sending, so anonymous_id is null instead of an alias to the previous person. This can't undo an alias already sent under the old id — still call reset() on logout — but it bounds a missed reset() to one corrupted guest window instead of every one after it. See The anonymous id for where the digest this relies on is stored.

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

Sauron.reset

static Future<void> reset()

Clears the scope user and the last-identified record, and mints a fresh anonymous id and session id.

Call this on logout. Without it the next person to use the device inherits the persisted anonymous id, and their first identify aliases that id — and with it the previous person's anonymous activity — onto the new account, permanently, server-side. Unlike the browser SDK, setUser(null) does not do this for you: persisting the new id is asynchronous and setUser is not.

Rotating the session id matters too: the server's bump_session is last-write-wins on distinct_id, so without it one sessions row could otherwise serially represent two different people and record only whichever wrote last.

await Sauron.reset();

Sauron.anonymousId

static String? get anonymousId

The persisted anonymous id, or null before init has completed.

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, 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, its DSN parsed successfully, and the transport has not disabled itself. The transport disables itself permanently for the process when the gateway rejects the ingest key with a 401/403 (see Transport & delivery) — so isEnabled going false mid-session means the key was revoked or rotated, and nothing more will be delivered until the app restarts with a valid key.

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.
workflow ActiveWorkflow? get Current workflow, or null. Same as Sauron.workflow; startWorkflow/endWorkflow/cancelWorkflow also exist here with identical signatures to the facade.
isEnabled bool get Whether the DSN parsed, the client is not closed, and the transport has not auto-disabled on a 401/403.
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, 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? workflowId, String? workflowName, 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. workflowId/workflowName are stamped by captureException from the active workflow, if any — omitted from the wire (never null) when there isn't one.
EventItem EventItem({required String name, required DateTime timestamp, String? distinctId, String? sessionId, String? workflowId, String? workflowName, String? screen, Map<String, Object?>? properties, Map<String, String>? tags, Map<String, Map<String, Object?>>? contexts, Map<String, Object?>? extra}) workflowId/workflowName — see ErrorItem.
IdentifyItem IdentifyItem({required String distinctId, String? anonymousId, Map<String, Object?>? traits}) Never carries workflow fields — the server has no workflow columns for identify.
TransactionItem TransactionItem({required String name, required double durationMs, String op = 'custom', String? status, String? httpMethod, int? httpStatus, String? url, String? distinctId, String? sessionId, String? workflowId, String? workflowName, DateTime? timestamp}) workflowId/workflowName — see ErrorItem.
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 []}) projectId is the DSN's path segment — despite the name, this is the environment id since the ingest key now lives on the environment, not the app. Dsn.parse(String input) throws FormatException. envelopeEndpointUri of .../api/{environment_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.6.0' Sent in header.sdk.version.
WorkflowStatus enum ok, alreadyActive, notActive, nameMismatch, invalidName, disabled Wire values (in lifecycle-adjacent server logs) are snake_case: already_active, not_active, name_mismatch, invalid_name. See Workflows.
WorkflowResult WorkflowResult(WorkflowStatus status, [String? workflowId]) workflowId is set when status == ok.
ActiveWorkflow ActiveWorkflow({required String workflowId, required String name, required DateTime startedAt}) What Sauron.workflow/client.workflow returns; null when none is active.
ActiveTransaction returned by startTransaction Has .end({status, httpStatus, url}) and .cancel([reason]).
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, falling back to the anonymous 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 device id, the anonymous 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.

The anonymous id

distinct_id falls back to a persisted anonymous id whenever no user has been identified, so a person who has not logged in is still counted as one person rather than dropped.

  • Format anon_<uuidv4>, and storage <app-support>/sauron/sauron_prefs.json under the key sauron.anon_id — the same shape and key name the browser SDK uses in localStorage. Active Users is a distinct count over distinct_id, so the two client SDKs have to produce ids of the same species.
  • Minted once, on first run, and adopted verbatim from then on. It is never re-minted or reformatted on upgrade: rewriting a stored id is indistinguishable downstream from that person uninstalling and a new one appearing, which shows up as a spike of new users that never happened.
  • Per install, not per device or per person. Uninstalling resets it, and nothing links it to a hardware identifier.
  • Sauron.reset() mints a fresh one — call it on logout.

It is a durable first-party identifier stored on the user's device, which is a retention and consent consequence, not just an implementation detail. Like the device id it is written during init on every install, error-tracking-only apps included; it only ever leaves the device once something is tracked without an identified user.

Because it is durable, not calling reset() on logout aliases the next person to the last one: identify() sends the current anonymous id as anonymous_id, and the server records that alias permanently. On a shared or kiosk device, the next anonymous user reuses the stored sauron.anon_id, and their activity is merged into the previous account server-side, forever. There is no server-side undo.

As a safety net for exactly that scenario, identify() also persists a short one-way digest (never the id itself) of the last user who identified, under sauron.last_identified in the same sauron_prefs.json file — the same key name and digest algorithm the browser SDK uses in localStorage. If the next identify() on this device is for a DIFFERENT person, the SDK detects the mismatch and mints a fresh anonymous id (and rotates the session id) before sending — so a forgotten reset() corrupts only that one guest window instead of every one from then on. This cannot undo an alias already sent under the old id, so still call reset() on logout regardless.

That digest is not a security boundary — it's an unkeyed hash, so over a possibly low-entropy id (an email address, say) it's a confirmation oracle, not a secret: anyone with local read access and a guess can verify it instantly. It exists only so sauron.last_identified isn't a second plaintext copy of your users' ids, not to keep those ids confidential.

reset() does NOT clear the device id (sauron.device_id) — that identifies the install, not the person.

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, when a batch fills, and when the app returns to the foreground (AppLifecycleState.resumed). The SDK ships no connectivity plugin — the HTTP response is the only reachability signal it trusts, so a backlog accumulated while offline moves on the next flush tick or the next resume, whichever comes first.
  • 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/{environment_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]/<environment_id>; the environment id is the last path segment.
Requests leave but nothing lands Your proxy does not expose ingest at /api/{environment_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 (Sauron.isEnabled flips to false) 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.
Errors arrive, track/$screen/$workflow_* events do not, and the log says dropped analytics item … no distinct_id The item was tracked before init finished, so there is neither an identified user nor a resolved anonymous id. distinct_id is a required string on the wire, so the item is dropped rather than 400ing the whole envelope await Sauron.init(...) before tracking — or track from inside appRunner, which only runs once init has completed.
After a logout on a shared device, the next person's pre-login activity is merged into the previous person's account setUser(null) clears the user but keeps the anonymous id, so the next person inherits it and their identify aliases it Call await Sauron.reset() on logout — it mints a fresh anonymous id.
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

LGPL-3.0-only — GNU Lesser General Public License v3.0. LGPLv3 applies on top of the GNU GPL v3, whose text ships alongside it in COPYING.

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

Libraries

sauron_flutter
Sauron — error reporting + product analytics for Flutter.