Dart / Flutter error tracking

Know when your Dart / Flutter app breaks, before your users do

ForgeOps connects the errors your Dart / Flutter app reports to the deploys, traces, and changes around them, so when production breaks, the incident already says who's affected, what changed, and where to look.

runGuarded wraps a plain Dart entry point automatically; FlutterError.onError and PlatformDispatcher.onError cover a Flutter app's own two error paths. Running on ForgeOps' own dedicated infrastructure, so nothing about a Dart / Flutter app's errors or its users' data gets routed through a third-party error-tracking vendor. See an incident investigated →

Install

dart pub add forge_ops_tracker
flutter pub add forge_ops_tracker flutter_forge_ops_tracker # Flutter apps: add both

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

Covers server/CLI Dart and Flutter on mobile/desktop; dart:io has no Flutter Web support yet.

Every client reports the same shape

Wherever it comes from, an event arrives with an exception class, a message, and a backtrace, plus whatever environment/release/server context that client can gather on its own. ForgeOps fingerprints and groups on the first three, so a Dart / Flutter app's issues list works exactly the same way every other language's does: report the same bug a thousand times and it's still one row, not a thousand.

No agent, no sidecar: it's a plain HTTPS POST to ForgeOps' own ingestion API, so nothing about a Dart / Flutter app's traffic gets routed through a third-party pipeline first.


Works with your framework


The Dart / Flutter client hooks in at the framework level, not by wrapping individual calls, so nothing about how you already write Dart / Flutter code has to change.


Plain Dart (server, CLI)

runGuarded wraps your whole main, reporting anything that escapes it and sending it before the program ends (waiting at most 2 seconds). Before calling exit() yourself, await flush().

Flutter (mobile, desktop)

FlutterError.onError and PlatformDispatcher.onError cover a widget-build error and an async error, both installed by the separate flutter_forge_ops_tracker package.

What it looks like

The issue list

Every Dart / Flutter issue reported into a project shows up here: title, status, assignee, event count, and when it was last seen, the same columns as any other language's project.

The issue list for a project reporting from a Dart/Flutter app

The issue detail

Every occurrence keeps its own timestamp, environment, release, and server, plus the backtrace exactly as Dart / Flutter reported it, expandable per occurrence rather than flattened into one generic stack.

A Dart issue's page in ForgeOps: its title, the exception class and the code it came from beneath it, then its verdict with its failure count, customers affected, and when it was first seen, above the triage buttons

Alerting

A new Dart / Flutter issue, a regression, or a spike past a threshold you set can reach Slack, Microsoft Teams, email, PagerDuty, Opsgenie, or a generic webhook, configured per project, per trigger.

Notification rules configured per trigger and channel, e.g. a new issue to Slack, a regression to email

Heartbeats

For the failure mode error tracking can't see on its own: a Dart / Flutter cron job or recurring task that's supposed to run but silently stopped. Each heartbeat gets its own ping URL and its own grace period before it alerts.

A heartbeat's ping URL and status, showing its expected interval, grace period, and last check-in

Flutter apps

A Flutter app adds one extra package, flutter_forge_ops_tracker, on top of forge_ops_tracker. It is separate so a plain Dart server or command-line tool never has to depend on the Flutter SDK. Call installFlutterErrorHandlers() once, after init and before runApp. It covers the two error paths a Flutter app has: FlutterError.onError, for an error the framework catches while building, laying out or painting a widget (the red error screen in debug mode), and PlatformDispatcher.onError, for anything that escapes the root zone, such as an error in an async callback or a Future nobody caught. Both keep whatever handler was already installed, so Flutter's own error screen and console output are unchanged. This client uses dart:io, so it covers Flutter on mobile and desktop, not Flutter Web.

import 'package:forge_ops_tracker/forge_ops_tracker.dart' as forge_ops_tracker;
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());
}

A mobile app is paused shortly after it goes to the background, and there is no background timer to flush on, so send whatever is still buffered when the app pauses. Performance samples, custom metrics and slow-request traces are each flushed by their own call; call the ones you use.

class _AppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      forge_ops_tracker.flushPerformance();
      forge_ops_tracker.flushMetrics();
      forge_ops_tracker.flushSpans();
    }
  }
}

Performance monitoring

Times whatever you wrap and reports one small aggregate per transaction, at most once per performanceFlushInterval, never one network call per timed call. This client has no web framework integration, so nothing is timed automatically: you choose what to wrap. Keep transaction names low-cardinality ('GET /users/:id', not 'GET /users/42'), since every distinct name is its own row. Both wrappers record even if the body throws. Turn it off with Configuration.trackPerformance = false. There is no background timer, because a pending Timer would keep a plain Dart program from ever exiting: recordPerformance starts a flush itself once a full interval has passed, so a long-lived server never notices. Call await flushPerformance() yourself before a short-lived program exits, or from a Flutter app's AppLifecycleState.paused handler. Each report also carries a small latency histogram, so ForgeOps shows an approximate p50/p95/p99 per transaction, not just an average, accurate to the width of the latency bucket a duration falls into. Requires a plan that includes performance monitoring; on a plan that doesn't, the periodic reports are accepted but not recorded (the response says why), so a working setup never looks broken.

The Performance page, showing each transaction's request count and p50, p95 and p99 latency, plus slow queries
// 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());

Distributed tracing

There is no web framework here, so you wrap the unit of work you want traced in trace or traceAsync, and anything inside it, including across awaits, can add spans. Only a slow call's trace is ever sent: whether it crossed the threshold (one second by default, configurable) is decided entirely inside your process, so a fast call costs nothing over the wire. Requires a plan that includes distributed tracing.

A trace's waterfall for one slow request, with its controller, service, database, cache and HTTP spans
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);

Make a request inside a trace with httpSpanAsync and it's recorded as its own http span and handed the standard traceparent header to send, so your backend's request nests under it; a Dart server continues a caller's trace by passing the incoming header to traceAsync. An error captured inside the trace carries its trace id, even across awaits: with your backend on the current ForgeOps server SDK and the two projects linked in ForgeOps, the failed checkout shows the API error from the very same request. Limit which hosts get the header with tracePropagationTargets. Needs forge_ops_tracker 0.9.0.

A mobile app's checkout failure listing the API's gateway timeout as the same request, matched by trace ID
await traceAsync('checkout', () async {
  try {
    final response = await httpSpanAsync('POST', uri, (headers) async {
      final request = await client.postUrl(uri);
      headers.forEach(request.headers.set); // the traceparent header, when one applies
      request.write(orderJson);
      return request.close();
    });
    if (response.statusCode != 201) throw HttpException('order rejected: ${response.statusCode}', uri: uri);
  } catch (error, stackTrace) {
    captureException(error, stackTrace); // carries this trace's trace_id
  }
});

SQL in a request

When an error is captured inside a trace that ran database queries, its issue opens with the slowest of them: how long it took, its share of the trace's time, how many queries ran, and a likely N+1 warning when the same statement ran five or more times. This client doesn't instrument a database driver, so pass a query's SQL as statement on a database span (a Flutter app's local SQLite query, or a server's own). The statement is masked before it leaves your process, every string and number replaced with a question mark, and the same SQL shows under that span's bar in the trace's waterfall. Needs forge_ops_tracker 0.11.0, on a plan that includes distributed tracing.

An issue's Slowest query in this request card: a 2.7 second order search that took 86% of a 3.2 second request, a likely N+1 warning for a line_items query run 24 times, and the query plan calling out a sequential scan on orders
const sql = "SELECT * FROM messages WHERE thread_id = 42 AND read = 0";

final messages = await traceAsync('load inbox', () async {
  return spanAsync('Load messages', () => db.rawQuery(sql),
      kind: 'database', statement: sql, dbSystem: 'sqlite');
});
// Sent as "SELECT * FROM messages WHERE thread_id = ? AND read = ?"

What changed

When a feature flag flips or a remote config value changes, record it from your config client's callback, and it shows under What changed on any issue that starts in the next two hours, and on the project's Changes page beside your releases, labeled potentially relevant, never as the cause. The call is queued on the event loop like an error report, so it never blocks or throws; a short-lived CLI can await flushChanges() before it exits. This client runs in apps and CLI tools, so every change is one you record. Needs forge_ops_tracker 0.10.0, on a plan that includes change tracking.

An issue's verdict listing what changed just before it started, labeled potentially relevant: the gateway_retry_v2 flag turned on for 100% of checkouts 3 minutes before, and a stripe upgrade and a new environment variable detected at startup after the deploy 7 minutes before
import 'package:forge_ops_tracker/forge_ops_tracker.dart' as forge_ops_tracker;

// Your remote config client's update listener, with the key and the old and new values
remoteConfig.onValueChanged((String key, Object? from, Object? to) {
  forge_ops_tracker.recordChange(
    'config',
    '$key changed from $from to $to',
    details: {'key': key, 'from': from, 'to': to},
    actor: 'remote-config',
  );
});

Custom metrics and infrastructure monitoring

Track a business event you name yourself (a signup, a payment) or a reading from one of your own hosts, with one explicit call each; nothing is automatic. The value defaults to 1, so a bare call is a counter; pass a real amount for anything else (it can be negative, for a refund). A captured value is stored as its own row, so counts and sums you compute later are exact. Calls are buffered and flushed as one batch in the background, so they are safe to make inside a request, and flushMetrics sends whatever is buffered right now. The hostname of an infrastructure reading defaults to the configured server name. Requires a plan that includes custom metrics and infrastructure monitoring.

A dashboard with custom-metric widgets (orders placed, checkout conversion, orders over time) and infrastructure widgets (average readings by host and by metric)
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

Database errors

When an error comes from a database call, the issue shows which stored procedure, table or view its SQL touched, so you know where to start looking. package:sqlite3's SqliteException carries the statement, and the error text sqflite and SQLite produce is recognized too, so a local database error needs nothing added. The names are sent by default and are identifiers, never values. The SQL statement itself is opt-in, with every string and number replaced by a question mark before it leaves your app, and a project setting can stop ForgeOps storing the statement at all.

An issue occurrence's Database section, naming the stored function and the view the failing query touched, with the statement's values replaced by question marks
forge_ops_tracker.init((config) {
  config.captureSqlStatement = true; // default false
  // config.captureSqlObjects = false; // default true; false stops even the names
});

Questions

Do I need to change how I already handle errors?

Almost never. Every client hooks the framework's own exception handling directly, the same way the install snippet above shows, so your own logs, error pages, and rescue blocks keep working exactly as they did before, and ForgeOps just also finds out about it. The one common exception is a background-job runner that doesn't forward through that same mechanism on its own; that needs one small hook added instead, not a change to anything already there.

Where does the data actually go?

Straight to ForgeOps' own ingestion API over plain HTTPS. No agent, no sidecar, and no third-party ingestion service in between, so nothing about your app's traffic or its users' data gets routed through anyone else's pipeline first.

What happens if the same bug fires a thousand times?

It's fingerprinted from its exception class, message, and backtrace, so a thousand reports of the same bug still show up as one issue with an event count of 1,000, not a thousand separate rows to dig through. And a runaway loop can't use up your monthly event quota: once one issue repeats faster than your plan's hourly limit (50 an hour on Free), further repeats are still counted, and still count toward spike alerts, but they aren't stored or charged to your quota.

Is anything scrubbed before it's stored?

Yes. Emails, credit card numbers, and known API key/token formats are redacted out of every event before it's even written, on every plan, with room for per-project custom field names on top of the defaults.

Try it with your own Dart / Flutter app

Free plan included, no credit card required. New organizations start with 14 days of Business.

Get started free