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.
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
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:
bufferedMessagemust narrowmessage, never widen it. Despite its name it is not device-only — with nosanitizedMessagealongside it, it is exactly what subscribers publish off the device. Detail that must stay on the device belongs inmessage, withsanitizedMessage: ''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: '');
errorandstackTracebypass redaction entirely. They are appended to the buffered line and carried on the event untouched, so neitherbufferedMessagenorsanitizedMessagecan hide a secret that an exception'stoString()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.publishableMessageand neverevent.message, or the whole three-tier split collapses into one line; - do not forward
errorandstackTracehere if your app already callsrecordError, 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 — acatchthat 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 calledlisten, out of reach of any guard around thelog()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
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
configureLoggerin 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
maxPayloadBytescounts 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.addis@visibleForTesting: it writes straight to the buffer, reaching neither the console norevents, and applies notagPrefix. Production code logs throughlog().LogBuffer.eventsis never closed, so it never fires a done event, andLogBuffer.instanceis a process-lifetime singleton that is never disposed.maxEntriesbelow 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.