forge_ops_tracker (Dart)

Dart error reporting client for ForgeOps. Requires Dart 3.0+. Captures unhandled errors automatically via a guarded entry point (or, for Flutter apps, the companion flutter_forge_ops_tracker package), lets you report caught errors explicitly, and scrubs likely personal data before anything leaves the process.

This directory holds two packages:

  • forge_ops_tracker (this directory): the core client. Works in any Dart VM environment: server apps, CLI tools, and Flutter apps on mobile/desktop.
  • flutter_forge_ops_tracker: a separate, small package adding Flutter-specific automatic capture (FlutterError.onError, PlatformDispatcher.onError) on top of the core client. Kept separate specifically so a plain Dart (non-Flutter) consumer of the core package never has to pull in the Flutter SDK.

Installation

# pubspec.yaml
dependencies:
  forge_ops_tracker: ^0.7.0
  # Flutter apps only:
  flutter_forge_ops_tracker: ^0.1.0

Or from the command line: dart pub add forge_ops_tracker (add flutter pub add flutter_forge_ops_tracker too for Flutter apps).

Dependencies

The core package has zero runtime dependencies: dart:io's HttpClient and dart:convert's jsonEncode cover HTTP delivery and JSON encoding directly from the standard library.

One real platform limitation this implies: dart:io is not available on Flutter Web. This client covers server/CLI Dart and Flutter on mobile/desktop, not Flutter Web. Extending it to Web would mean swapping dart:io's HttpClient for package:http (or dart:html's fetch-equivalent) behind a conditional import: a real, known gap, not silently ignored, just out of scope for this round.

Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment variable or explicitly:

import 'package:forge_ops_tracker/forge_ops_tracker.dart' as forge_ops_tracker;

forge_ops_tracker.init((config) {
  config.dsn = 'https://<api_key>@getforgeops.net/api/v1/events'; // or leave unset to read FORGE_OPS_DSN
  config.release = '...';
  config.environment = 'production';
});

Call init once at startup.

What gets reported automatically, and what doesn't

Plain Dart (server apps, CLI tools): wrap your entry point in runGuarded:

void main() {
  forge_ops_tracker.runGuarded(() {
    // your app
  });
}

This reports any error that escapes body: a synchronous throw, or an unawaited Future's error: then re-throws, so the same crash still happens exactly as it would without this wrapper. Verified directly (not assumed): both a synchronous throw and an unawaited Future's uncaught error crash a plain Dart program by default, with no zone at all, so runGuarded genuinely changes nothing about program behavior besides also reporting first.

Flutter apps: use the separate flutter_forge_ops_tracker package instead, which wires the two Flutter-specific error paths runGuarded doesn't cover on its own:

import 'package:flutter_forge_ops_tracker/flutter_forge_ops_tracker.dart';

void main() {
  forge_ops_tracker.init((config) {
    config.dsn = 'https://<api_key>@getforgeops.net/api/v1/events';
  });
  installFlutterErrorHandlers();
  runApp(MyApp());
}
  • FlutterError.onError: an error the framework itself catches while building, laying out, or painting a widget (what would otherwise render Flutter's own red "error" screen in debug mode).
  • PlatformDispatcher.onError: anything that escapes the root zone instead: an async callback's error, an uncaught Future error, anything that never went through a widget build.

Both chain to whatever handler was already installed rather than replacing it, so installing this package changes nothing observable about how an error is presented besides also reporting it first.

An error your own code catches and handles is different in both cases: report it explicitly, right at the catch site:

try {
  chargeCard(order);
} catch (error, stackTrace) {
  forge_ops_tracker.captureException(error, stackTrace, {'order_id': order.id});
}

Delivery happens on an async drain loop with a bounded queue and a short per-request HTTP timeout (Configuration.timeout, 2s default): see DeliveryQueue's own comment for why Dart's single-threaded, cooperative concurrency model makes this safe without any locking. Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.

One real limitation worth knowing: delivery is in-memory only, with no durable on-disk queue. A crash reported via runGuarded or the Flutter handlers may still terminate the isolate before that delivery finishes. A caught-and-handled captureException call, made while the program is still healthy, doesn't have this limitation.

Identifying users

forge_ops_tracker.captureException(error, stackTrace, null, {'id': user.id, 'email': user.email});

Or setUser to attach it to every subsequently reported error (an explicit captureException call, or anything runGuarded/the Flutter handlers catch) until changed or cleared, rather than passing it to every call by hand, e.g. right after sign-in:

forge_ops_tracker.setUser({'id': user.id, 'email': user.email});
// on sign-out:
forge_ops_tracker.setUser(null);

There's no way to automatically detect "the current user" the way a server-side web framework with its own session/auth middleware can (see gems/forge_ops_tracker's Warden integration for what that looks like in a language that has one), so this is always manual. setUser is a plain, single global value: correct for a Flutter or CLI app, which is effectively single-user (there's no concurrent, unrelated request to leak into, the same reasoning sdks/swift/sdks/android use for their own plain static property), but shared across every concurrently in-flight call if this isolate is also handling more than one request at once, e.g. a Dart server built on shelf or similar. For that case, use runWithUser instead, which scopes the value to one call chain via Dart's own Zone mechanism (the same continuation-local-storage idea Node's AsyncLocalStorage is built on, for the identical reason: an ambient value needs to be scoped to one request, not shared process-wide):

Future<Response> handleRequest(Request request) {
  return forge_ops_tracker.runWithUser({'id': currentUser(request).id}, () async {
    // ...request handling; anything captured in here (including across an await, or from a
    // Timer/microtask this scheduled) sees the scoped user, not whatever setUser last set
    // globally or whatever another concurrent request's own runWithUser call set.
  });
}

runWithUser always wins over setUser when both are in play. id/email/username are all independently optional. Shows up on an issue's own detail page, and as its own affected-users count alongside the regular event count.

A small, bounded trail of recent events attached to whatever error gets reported next, so an issue's detail page can show what led up to it, not just the moment it happened:

forge_ops_tracker.addBreadcrumb('charging card', category: 'payment', data: {'order_id': order.id});

category and level default to "custom"/"info"; only the 30 most recent (configurable via Configuration.maxBreadcrumbs) are kept, oldest dropped first. Turn it off entirely with Configuration.trackBreadcrumbs = false.

There's no web server framework built into this client to record one from automatically (unlike sdks/go's HTTP middleware), so every breadcrumb here is one you record by hand, wherever it's meaningful in your own app. The same global-vs-scoped choice setUser/runWithUser make applies here too, for the identical reason: addBreadcrumb accumulates into a single global trail by default, correct for a Flutter or CLI app, but for a Dart server handling more than one request at a time on the same isolate, wrap each request in runWithBreadcrumbs so it gets its own fresh, isolated trail:

Future<Response> handleRequest(Request request) {
  return forge_ops_tracker.runWithBreadcrumbs(() {
    return forge_ops_tracker.runWithUser({'id': currentUser(request).id}, () async {
      // ...request handling; addBreadcrumb calls in here only ever show up on this request's own
      // reports, not another concurrent request's.
    });
  });
}

Call clearBreadcrumbs() at the start of a new logical unit of work (a Flutter app's own next screen/route, say) to start that unit with a fresh trail; there's no navigation hook here to do it automatically.

Performance monitoring

Times whatever you wrap and reports one small aggregate per transaction (how many times it ran, total and maximum duration) at most once per performanceFlushInterval (60s by default), for the Performance page's per-transaction table. Not one network call per timed call.

Each aggregate also carries a small latency histogram (a count per fixed latency bucket: 50, 100, 250, 500, 1000, 2500, 5000 and 10000ms, plus an overflow bucket), so ForgeOps can show an approximate p50/p95/p99 per transaction, not just an average. Percentiles are accurate to the width of whichever bucket a duration falls into; the SDK never stores the individual durations.

// Wrap a whole request handler, or any block you want on the Performance page:
final response = await forge_ops_tracker.timeTransactionAsync('GET /users/:id', () => handleRequest(request));
final user = forge_ops_tracker.timeTransaction('load-user', () => loadUserSync(id)); // a synchronous body

// Or record a duration you measured yourself:
forge_ops_tracker.recordPerformance('nightly-export', stopwatch.elapsedMilliseconds.toDouble());

This client has no web framework integration (unlike sdks/go's net/http and Gin middleware), so nothing is timed automatically: you choose what to wrap. Keep transaction names low-cardinality ('GET /users/:id', not 'GET /users/42'): every distinct name is its own row. Both wrappers record even if the body throws. Turn it off with Configuration.trackPerformance = false; it also does nothing when reporting isn't enabled for the current environment.

There is no background timer, and that has a consequence you should know about. Every other client in this repo flushes from a daemon thread that never keeps its process alive. Dart has no equivalent: a pending Timer keeps the isolate's event loop running, so a timer here would stop a plain Dart CLI program from ever exiting on its own. Instead, recordPerformance starts a flush itself once a full performanceFlushInterval has passed since the last one. A long-lived server never notices. But a window with no further recordPerformance call after it sits unsent, so call await forge_ops_tracker.flushPerformance() yourself before a short-lived program exits, or from a Flutter app's AppLifecycleState.paused handler.

A failed delivery keeps every tally, so the next flush's window just grows. What a flush delivered is subtracted from the tallies afterward, never the whole map cleared: a recordPerformance call that lands while a delivery is awaiting the network would otherwise be silently discarded (a real bug sdks/go had and fixed; gems/forge_ops_tracker's reference implementation still has it). A deterministic test pins this.

Distributed tracing

A slow call's own breakdown: which database calls, HTTP calls, or pieces of your code the time went to, shown as a span tree on ForgeOps. Wrap the unit of work in trace/traceAsync, and anything inside it, including across awaits, can add spans; the trace is sent only when the whole thing took at least traceCaptureThreshold (one second by default), so fast calls cost nothing on the wire. Traces are per service; nothing is propagated across services.

final response = await traceAsync('GET /checkout', () async {
  final order = await spanAsync('load order', () => repo.find(id), kind: 'database', data: {'order_id': id});
  await spanAsync('charge card', () => gateway.charge(order));
  return render(order);
});

// Synchronous work: trace(...) and span(...). Something you timed yourself (kind is one of
// controller/service/database/redis/http/job/other):
recordSpan('SELECT orders', kind: 'database', startedAt: startedAt, durationMs: elapsedMs);

This client has no web framework integration, so nothing starts a trace or records a span automatically: you wrap what you want traced. Like runWithUser/runWithBreadcrumbs, the trace is scoped with Dart's Zone mechanism, so a Dart server handling several requests at once keeps each trace apart, and the parent of a new span is a zone value too: futures started in parallel inside one span each parent under it, and a span opened after another closed goes back to the outer parent. span records even when the body throws (the error propagates unchanged) and just runs the body outside a trace; a trace inside another trace records a span instead. kind outside that list is sent as other, since the server rejects a whole trace over one unknown kind. A trace holds at most 500 spans.

Delivery runs on the event loop through a bounded queue (like error reports), not a thread, so a short-lived program, or a Flutter app about to be paused, should await flushSpans() before it exits. Turn the feature off with Configuration.trackTracing = false.

Custom metrics and infrastructure monitoring

Two explicit calls (nothing is automatic, so there is no trackMetrics flag): a business event you name yourself, and a reading from one of your own hosts.

captureMetric('signup');                     // value defaults to 1: a bare counter
captureMetric('payment', 49.0);              // a real magnitude; it may be negative (a refund)

captureInfrastructureMetric('cpu', 0.42);                       // hostname defaults to serverName
captureInfrastructureMetric('disk', 0.81, hostname: 'db-1');
await flushMetrics();                                           // send right now

Each capture is buffered and flushed as one batch every metricFlushInterval / infrastructureMetricFlushInterval (60 seconds by default). Dart has no background timer to flush on (a pending Timer would stop a CLI program from exiting on its own), so a flush starts from a capture once the interval has elapsed since the last one, and a program about to stop (a CLI tool, or a Flutter app on AppLifecycleState.paused) should await flushMetrics(). Every entry is stored as it was captured (a signup is a row, not a running total), so a count or sum you compute later is exact. Both are a no-op when reporting isn't enabled for the environment.

A failed delivery keeps every entry for the next flush, and an entry captured while a delivery is in flight is kept too (the Ruby gem's own buffer loses it; a test pins this with a gated delivery: a flush awaits the network, so captures really do interleave with it). Each buffer holds at most 1000 entries and drops further ones until a flush succeeds, since a plan without the feature rejects every flush and would otherwise grow it for as long as the program runs. A NaN or infinite value is dropped at capture: jsonEncode throws on one. Requires a ForgeOps plan that includes custom metrics / infrastructure monitoring.

in_app backtrace frames

A Dart StackTrace has no structured frame API at all: the only thing available is StackTrace.toString(), a multi-line format like:

#0      ChargeService.chargeCard (package:my_app/charge_service.dart:42:7)
#1      main (package:my_app/main.dart:10:3)

so this client parses that format with a regex (verified directly against real caught-and-rethrown stack traces before relying on this shape). Set Configuration.packageName (e.g. "my_app", matching your pubspec.yaml's own name:) to mark a frame in_app when its location is "package:<packageName>/..."; unset by default, meaning every frame reports as not-in-app until you set it, the safe default. A dart:... location (the Dart SDK itself) and any other package:... location (a third-party pub dependency) are never in_app, regardless of configuration.

Source context

By default, each in-app backtrace frame (never a third-party pub dependency or the Dart SDK itself) is captured along with the 5 lines of source on either side of the culprit line, read straight off disk at capture time, so an issue's detail page can show the actual code that broke, not just a file:line:method reference. A package: frame location has no filesystem path of its own, so this client resolves it against the running isolate's own package configuration first (Isolate.resolvePackageUriSync) before reading; this works out of the box in a normal dart run/ Flutter debug session, and fails silently, the same as an unreadable file, wherever that resolution isn't available (an AOT-compiled build with no bundled package config, for instance). This never applies to a frame outside your own app's code, and it fails silently (no context, not an exception) for any file that can't be read for whatever reason.

This is a real, deliberate exception to "off by default is safer": literal source code is being transmitted, not just a reference to it, and the real protection here isn't this field. Every project on ForgeOps has its own setting (on by default, off durably and immediately once an org owner turns it off, regardless of what any individual app's own local Configuration is still set to) that governs whether the server will ever actually store what a client sends. Set captureSourceContext to false if you'd rather this client never even attempt the disk read in the first place:

forge_ops_tracker.init((config) => config.captureSourceContext = false);

PII scrubbing

The message, backtrace, and any context you attach are scanned for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token formats, and anything under a suspiciously-named key like password, api_key, or ssn) and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so this is a second, earlier layer, not the only one. The user attached via captureException's user argument, setUser, or runWithUser above is a deliberate exception: it's never scrubbed, since redacting it would defeat the whole point of identifying users in the first place.

To disable it:

forge_ops_tracker.init((config) => config.scrubPii = false);

Running the tests

cd sdks/dart
dart pub get
dart test
dart analyze

cd flutter_forge_ops_tracker
flutter pub get
flutter test
flutter analyze

Libraries

forge_ops_tracker
Dart error reporting client for ForgeOps: