Skip to content

Errors and grouping

An app with a thousand users generates the same failure a thousand times. Read that as a list and the bug that started an hour ago sits three hundred rows below the one you already know about.

The Errors screen groups them, so one bug is one row with a count beside it.

The CodeScout errors screen: Payment declined grouped 26 times across 7 sessions, expanded to its latest stack trace

Anything you log at error or fatal appears here. There is nothing to switch on.

try {
await payments.confirm(order);
} catch (e, stack) {
CodeScout.instance.e(
'Payment declined',
error: e,
stackTrace: stack,
tags: {'checkout', 'payments'},
metadata: {'order': order.id, 'total': order.total},
);
}

Pass stackTrace and it is parsed into frames, so the dashboard can show you file and line rather than a wall of text. Pass metadata for the things that differ between one occurrence and the next: the order id, the user’s plan, whatever you will want when you are looking at this in three weeks.

Network failures arrive here on their own. The interceptors log a failed call at error, so you do not have to catch anything for them to show up.

The grouping key is computed once, when the log is ingested, and stored as an indexed column. It is not a search that runs when you open the page, which is why the screen is fast on a project with millions of rows.

Ordinary errors group on the message with its varying parts blanked. Uuids, quoted strings, hex runs, numbers, and identifiers that mix letters and digits like ord_8812f are all replaced before the key is computed, so these are one row:

User 4821 not found → User {n} not found
User 9134 not found → User {n} not found
Order ord_8812f rejected → Order {id} rejected

That is what lets you write a message with the offending value in it, which is the message you actually want at 2am, without turning every occurrence into its own group.

Only the first line is used. An error whose message carries its own stack trace would otherwise never match another occurrence of itself.

Network failures group on the method and the normalised path instead. Every network failure the SDK reports carries the literal message Network Error, so grouping those on the message would collapse every broken endpoint in your app into one useless row. Grouping on method plus path keeps POST /v2/pay and GET /v2/cart apart, and keeps GET /v2/user/4821 and GET /v2/user/9134 together.

Write the message as a description of the problem and put the specifics in metadata. These group well:

CodeScout.instance.e('Payment declined', metadata: {'order': order.id});
CodeScout.instance.e('Cache miss on cold start', metadata: {'key': key});

The failure mode to avoid is the opposite of what you might expect. A message built entirely out of variable parts does not split into one group per occurrence: blanking eats all of it, so every such message collapses to the same key and unrelated failures land in one row together.

// Avoid: this blanks to "{id} {n} {n}-{n}-{n}T{n}:{n}:{n}", and so does every
// other error whose message is nothing but values. They all become one group.
CodeScout.instance.e('${order.id} ${order.total} ${DateTime.now()}');

Leave some prose in the message and the problem disappears.

A response body your model could not read is the same question. Name the endpoint in the message, and pass the call’s id as requestId:

CodeScout.instance.e('Could not read GET /v2/cart',
error: e, stackTrace: st, requestId: response.codeScoutRequestId);

That message is grouped as Could not read GET /{id}/cart, so each path is a row of its own apart from the blanked parts: /v1/cart and /v2/cart share a row, since v1 and v2 mix a letter and a digit. Could not parse response would put every endpoint in one. The request id plays no part in the grouping. It is what takes you from the error to the body that caused it, as When the API changes under you describes.

Each row carries the count, how many sessions it touched, and when it was first and last seen. First seen is the one people forget to read: a bug first seen four minutes ago is a different conversation from one that has been happening quietly since March.

Open a row and you get the latest stack trace, plus two ways out:

  • View in logs takes you to every occurrence, not just the one on the row. It is a search for the group’s fingerprint, so it survives a reload and can be pasted to someone else.
  • Latest session opens the launch it last happened in, which is usually the fastest way to see what the person was doing when it went wrong.

An error logged with a call’s request id has one more way out. Expand an occurrence in the log viewer and its Request link opens the call’s own page, where the error is listed above the call’s request and response.

get_error_groups returns the same list, and search_logs takes a fingerprint:"…" clause for one group’s occurrences. So “what is the newest error in production and what caused it” is a question you can hand to a coding agent rather than answer by hand.

See Reading it with an AI agent.

CodeScout does not catch errors for you. There is no FlutterError.onError hook installed and no zone guard, because a logging SDK that quietly swallows your error handling is a bad neighbour, and you may well already have Crashlytics or Sentry doing exactly that.

If you want uncaught errors here too, wire it yourself in one line:

FlutterError.onError = (details) {
CodeScout.instance.f(
details.exceptionAsString(),
error: details.exception,
stackTrace: details.stack,
);
FlutterError.presentError(details);
};

Keep the presentError call unless you mean to replace your existing reporter, not sit beside it.