sink_logger

One log() call fans out to three sinks — the developer console, a bounded in-memory buffer that export() turns into shareable text, and a broadcast event stream — and each sink can be handed a narrower version of the message.

test pub package codecov style: very good analysis Verified Publisher


Why

A log line serves three audiences at once, and they do not deserve the same amount of truth. You want the whole payload on screen while you are debugging. You want enough of it in the file a user attaches to a bug report. You want almost none of it in the crash reporter that ships off the device.

Most loggers make that one decision — a level, a filter — and apply it to the message as a whole. sink_logger turns it into three strings chosen at the call site, the only place that knows which part is the secret:

log(
  'auth token=$token',                       // console: everything
  name: 'AuthRepo',
  bufferedMessage: 'auth token=<redacted>',  // bug report: shape, not value
  sanitizedMessage: 'auth token acquired',   // off-device: the outcome only
);

Pure Dart, one dependency (meta). The only environment declaration it reads is the built-in dart.vm.product flag, to decide whether the console starts on; it never reads your --dart-define values or Platform.environment.

Install

dependencies:
  sink_logger: ^0.1.0

The package itself needs Dart 3.9; working on it needs Dart 3.12 or newer, which is what its dev dependencies and CI require.

Getting started

import 'package:sink_logger/sink_logger.dart';

void main() {
  // Once per isolate entry point. See "Background isolates" below.
  configureLogger(const LoggerConfig(tagPrefix: 'App'));

  // Buffered as `09:41:02.317 [App.Boot] starting up`. The console stays quiet
  // under a plain `dart run` — see "Swapping the console sink" below.
  log('starting up', name: 'Boot');

  // `silent` gates the console alone: this is still buffered, and still
  // reaches every subscriber of LogBuffer.events.
  log('cache miss', name: 'Repo', silent: true);
}

name is the logical source; tagPrefix is joined to it with a dot, so tagPrefix: 'App' and name: 'Boot' produce the tag App.Boot.

How one call fans out

How a log() call reaches the console, the buffer and the event stream

Each sink may see less than the one before it:

sink text it receives
console message
buffer, and so export() bufferedMessage ?? message
subscribers of LogBuffer.events sanitizedMessage ?? bufferedMessage ?? message

Read the third row carefully: bufferedMessage must narrow message, never widen it. Despite its name it is not device-only — with no sanitizedMessage alongside it, it is exactly what subscribers publish off the device. Detail that must stay on the device belongs in message, with sanitizedMessage: '' to silence the subscribers.

Two details in that picture are easy to miss. A call carrying no payload at all — empty message, no error, no stackTrace — reaches the console and is then dropped: nothing is buffered and no event is emitted. And subscribers are notified in a later microtask, so none of them has run by the time log() returns.

Keeping secrets on the device

Redact at the call site, where the secret is known. Two shapes cover almost everything.

Publish a narrowed variant. The console keeps the value you need while debugging, the export keeps its shape, and only a summary leaves the device:

log(
  'POST /v1/pay body=$body',
  name: 'Http',
  bufferedMessage: 'POST /v1/pay body=${body.length} bytes',
  sanitizedMessage: 'POST /v1/pay → 200',
);

Publish nothing. An empty sanitizedMessage marks an entry buffer-only: it still lands in the export a user attaches to a bug report, but no subscriber may send it anywhere:

log('response body: $body', name: 'Http', sanitizedMessage: '');

error and stackTrace bypass redaction entirely. They are appended to the buffered line and carried on the event untouched, so neither bufferedMessage nor sanitizedMessage can hide a secret that an exception's toString() exposes. Redact the error object itself.

Forwarding to a crash reporter

LogBuffer.events is a broadcast stream carrying one LogEvent per surviving log() call. Subscribe once per isolate, right after configureLogger:

import 'dart:async';

import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:sink_logger/sink_logger.dart';

StreamSubscription<LogEvent> installCrashlyticsForwarder() {
  return LogBuffer.instance.events.listen((event) {
    final text = event.publishableMessage;
    if (text.isEmpty) return; // entry marked device-only
    FirebaseCrashlytics.instance.log('[${event.name}] $text');
  });
}

With the three calls from above, that forwards [App.AuthRepo] auth token acquired and [App.Http] POST /v1/pay → 200 — and nothing at all for the response body. The raw token never leaves the device.

Two rules for any forwarder, Sentry included:

  • publish event.publishableMessage and never event.message, or the whole three-tier split collapses into one line;
  • do not forward error and stackTrace here if your app already calls recordError, or every fatal is reported twice.

Events raised while nobody listens are dropped, as broadcast streams do, so a forwarder installed late never receives a backlog.

A subscriber must never call log() and must never throw. Delivery happens in a later microtask, so a subscriber that logs — a catch that reports the forwarding failure, say — schedules the next delivery forever and the event loop stops running. And a thrown error surfaces as unhandled in the zone that called listen, out of reach of any guard around the log() call. Wrap the body in its own try/catch and swallow.

Exporting for a bug report

final text = LogBuffer.instance.export(header: 'MyApp 1.4.2+318');
MyApp 1.4.2+318

── 2026-08-31 ──
09:41:02.317 [App.Boot] starting up
09:41:04.881 [App.Http] POST /v1/pay body=412 bytes
09:41:04.902 [App.Http] request failed
  error: SocketException: Connection reset by peer
  stackTrace:
  #0      _NativeSocket.read (dart:io-patch/socket_patch.dart:1099:34)

Day markers are synthesized during export() from each entry's own date, never stored, so the output always opens with the correct day even after the entry that introduced it has been evicted.

Configuration

configureLogger replaces the whole configuration — fields are never merged, and the last call wins.

field default notes
tagPrefix '' joined to name with a dot; a blank name collapses to the prefix alone
maxEntries 10 000 entry cap; oldest evicted first. Day markers are not entries
maxPayloadBytes 5 MiB size cap in UTF-16 code units of the formatted line — equal to bytes for ASCII only
consoleEnabled !dart.vm.product live everywhere except release builds; gates the console only
consoleWriter dart:developer log wrapper replaceable: stdout for a CLI, a capturing closure in tests

The defaults are load-bearing, not placeholders: code that logs before configureLogger runs — or in an isolate that never called it — silently runs on them, so keep them equal to what you actually install.

Both bounds are enforced when an entry is inserted. Lowering them through a later configureLogger call does not shrink an already-filled buffer until the next entry arrives.

Swapping the console sink

The default writer forwards to dart:developer, which a Flutter app or an attached DevTools surfaces and a plain dart run stays quiet about. A command-line program points it at stdout instead:

import 'dart:io';

configureLogger(
  LoggerConfig(
    consoleWriter:
        (message, {required time, required name, error, stackTrace}) =>
            stdout.writeln('[$name] $message'),
  ),
);

The same seam captures output under test:

final printed = <String>[];
configureLogger(
  LoggerConfig(
    consoleWriter:
        (message, {required time, required name, error, stackTrace}) =>
            printed.add(message),
  ),
);

A writer must not throw — the exception propagates out of log() and that entry is never buffered or emitted — and must not call log() itself.

Use isConsoleLoggingEnabled to skip building a message the console would discard, then still call log() with whatever you did build. Guarding the log() call itself would also drop the entry from the buffer and the event stream.

Background isolates

Each isolate has its own configuration and its own buffer

Isolates do not share mutable memory, so the configuration and LogBuffer.instance are per-isolate state — that is Dart, not a choice this package made. A background isolate (Isolate.spawn, compute, a vm:entry-point handler) never runs main(), which has two consequences:

  • it logs on the defaults unless you call configureLogger in its own entry point — no tag prefix, console live outside release builds;
  • its entries never reach the main isolate's export, and its events never reach a forwarder subscribed there. When the isolate ends, its buffer goes with it.

The way out is a forwarder per isolate, not a shared buffer: after its own configureLogger, a background isolate subscribes its own listener to its own LogBuffer.events and ships to the same external sink.

This package deliberately does not merge buffers across isolates. Discovery is the blocker: isolates communicate only over SendPort/ReceivePort, and an isolate spawned by a plugin or the OS has nobody to hand the main isolate's port. The one mechanism that solves it, IsolateNameServer, lives in dart:ui and would force a Flutter dependency on a package that has none.

Sharp edges

  • maxPayloadBytes counts UTF-16 code units, which equals bytes for ASCII logs only; non-ASCII text encodes to more UTF-8 bytes than this counts.
  • A single entry larger than the budget is still stored — eviction never discards the entry currently being added.
  • LogBuffer.add is @visibleForTesting: it writes straight to the buffer, reaching neither the console nor events, and applies no tagPrefix. Production code logs through log().
  • LogBuffer.events is never closed, so it never fires a done event, and LogBuffer.instance is a process-lifetime singleton that is never disposed.
  • maxEntries below 1 still keeps the newest entry — eviction never discards the entry currently being added.

License

MIT — see LICENSE.

Libraries

sink_logger
A logger whose single log call fans out to three sinks: the developer console, a bounded in-memory buffer that LogBuffer.export turns into shareable text, and a broadcast stream of LogEvents for forwarders that ship data off-device.