radar_trace
Pure-Dart tracer framework with monotonic spans, log-linear latency histograms, Zone-based async nesting, and a lossless outlier ring. No Flutter dependency — usable in CLI tools, servers, isolates, and Flutter apps alike.
Installation
dependencies:
radar_trace: ^0.3.0
Usage
Synchronous tracing
import 'package:radar_trace/radar_trace.dart';
final tracer = Tracer();
// Wrap a synchronous operation — the span is recorded automatically.
final result = tracer.trace('parse_config', () {
return parseConfig(rawBytes);
});
// With optional category and attributes.
tracer.trace(
'db_query',
() => db.query(sql),
category: 'database',
attributes: {'table': 'users'},
);
Async tracing
final data = await tracer.traceAsync('fetch_user', () async {
return await api.getUser(id);
});
Zone context is propagated across await boundaries, so spans started
inside traceAsync automatically become children of the outer span.
Retrofitting an existing assignment? Pass the type argument
explicitly. Dart infers T from the assignment context, and when the
target variable is type-promoted at the call site that context is the
promoted type — so wrapping a nullable call in a trace can stop
compiling:
User? user;
// ...user is promoted to User here
user = await tracer.traceAsync<User?>('fetch_user', () => api.getUser(id));
// ^^^^^^^ without this, T infers as User and the
// nullable return no longer fits.
Manual start → SpanHandle → stop/fail
// Useful when start and stop are in different callbacks.
final handle = tracer.start('upload_file', category: 'network');
try {
await upload(file);
handle.stop(); // records with SpanStatus.ok
} catch (e) {
handle.fail(e); // records with SpanStatus.error
}
Forgetting to call stop or fail is safe — the span is simply not
recorded. No leak or exception occurs.
fail records the error's runtime type as the error.type attribute
(the constant kErrorTypeAttribute) — the type name only, never the
message, so spans stay content-free. Tracer.trace and traceAsync tag
thrown errors the same way.
Tagging the outcome, not just the start
Some dimensions are only known when the operation ends — a cache hit versus
a miss, how many rows came back. stop takes attributes that merge over
the start-time ones:
final handle = tracer.start('mailbox.open', attributes: {'folder': 'inbox'});
final page = await load();
handle.stop(attributes: {'source': page.fromCache ? 'cache' : 'network'});
// On the failure path, extra attributes come after the error object:
handle.fail(error, {'stage': 'decrypt'});
Attributes are not part of the TraceKey, so tagging at stop time never
splits or reshapes the aggregate series — both outcomes still land in one
mailbox.open histogram. The tags travel with the span to exporters and to
the retained outlier exemplars.
Mirroring spans into your own pipeline
exporters tees every finished span to callbacks of your own, so a host
app can feed its existing logging or export pipeline without subclassing
anything:
final tracer = Tracer(
recorder: TraceRecorder(
exporters: [
(span) => log.info('${span.name} ${span.durationMicros}µs '
'${span.status.name}'),
],
),
);
Exporters run synchronously inside record, so keep them cheap. They see
every span the recorder accepts — including one whose key is later dropped
by maxKeys, since that limit bounds the recorder's key table, not your
pipeline. An exporter that throws is swallowed (recording never breaks) and
counted in TraceRecorder.exporterErrorCount, so a broken pipeline is
visible rather than silent.
Turning the volume down without touching the numbers
exportFilter gates the export path only. Aggregation never sees it, so
quietening a chatty key cannot move a percentile — which is exactly what
lowering sampleRate would do instead:
TraceRecorder(
exporters: [myLogSink],
// Mirror everything except a 30s poll, unless that poll is slow.
exportFilter: (span) =>
span.name != 'net.poll' || span.durationMicros > 500000,
);
The predicate is a closure, so host-side rate limiting works too — hold a
Map<String, int> of the last exported span.startMicros per name and
return false inside the cooldown. Every span still lands in the histogram;
only the log goes quiet. A filter that throws skips the export and counts
into exporterErrorCount.
Reading one interaction, not the average
Aggregates answer "how long does this usually take". They cannot answer "why
did this open take 8 seconds" — for that you need the one interaction's
tree. Give the recorder a slowTraceCapacity and it retains the slowest
complete traces with their full subtree:
final tracer = Tracer(
recorder: TraceRecorder(slowTraceCapacity: 8),
);
// ... later, after the slow interaction happened:
for (final trace in tracer.snapshot().slowTraces) {
print(trace.describe());
}
mailbox.open 8043210µs
+112µs auth.check [net] 402100µs
+402300µs sync.fetch [net] 7600400µs
+402350µs decrypt [crypto] 40200µs
A trace is judged when its root finishes — children finish first, so spans are buffered until then. Everything is bounded and every bound is honest:
| Knob | Default | Meaning |
|---|---|---|
slowTraceCapacity |
0 |
Traces retained. 0 disables capture - the capacity is the switch. |
maxSpansPerTrace |
64 |
Spans buffered per trace. Beyond it the tree is marked truncated, and describe() says so. |
maxInFlightTraces |
64 |
Traces buffered at once. Beyond it the oldest is dropped and counted in TraceSnapshot.traceDropCount. |
Capture is off by default: the per-span cost is a map lookup and a list
append, small but not free. A trace whose root never arrives (a SpanHandle
that outlives it, or is never stopped) is dropped and counted, never
reported as a partial waterfall.
Prefer sampleRate: 1.0 when capture is on. Sampling is decided per span,
so a lower rate makes the retained traces a sample of the population rather
than the true slowest ones.
Sampling is decided before the span starts
sampleRate is applied by TraceRecorder.shouldSample(), which Tracer
calls before it builds anything. A sampled-out span costs one call and
one branch - no SpanId, no Span, no attribute map, no Zone fork - so
sampling actually makes a hot path cheap instead of merely unrecorded.
Two consequences worth knowing:
- A sampled-out span forks no zone, so it is invisible to its descendants.
A sampled-in child under it attaches to the nearest sampled-in ancestor
instead, or becomes its own root when there is none - never to a parent
that was never recorded. A trace therefore holds only sampled-in spans, so
below
sampleRate: 1.0a waterfall can be missing a middle level without being marked truncated. Another reason to keep the rate at 1.0 whenslowTraceCapacityis on. record()does not sample. It takes finished spans, and by then the work has been paid for. If you build spans by hand and want sampling, callshouldSample()yourself first.
Tracer.start returns SpanHandle.inert() when a span is sampled out, so
call sites need no branch of their own.
Reading aggregated statistics
final snap = tracer.snapshot();
for (final entry in snap.stats.entries) {
final key = entry.key; // TraceKey(name, category)
final stats = entry.value; // SpanKeyStatsSnapshot
final hist = stats.histogram;
print('${key.name}: '
'count=${hist.count} '
'p50=${hist.percentile(0.5)}µs '
'p99=${hist.percentile(0.99)}µs '
'max=${hist.max}µs');
}
if (snap.totalDropCount > 0) {
print('${snap.totalDropCount} spans dropped (maxKeys reached)');
}
Beyond the histogram, each SpanKeyStatsSnapshot exposes exact per-key call
metrics (no bucket approximation):
| Field | Type | Meaning |
|---|---|---|
meanMicros |
int |
Exact average execution time (sum ~/ count). |
maxMicros |
int |
Exact slowest single execution time. |
totalMicros |
int |
Exact aggregate cost — sum(durationMicros) across all spans. |
firstStartMicros |
int |
Minimum Span.startMicros observed for this key (0 when count == 0). |
lastStartMicros |
int |
Maximum Span.startMicros observed for this key (0 when count == 0). |
avgInterCallIntervalMicros |
int? |
Average gap between successive calls; null when count < 2. |
callsPerSecond |
double? |
Observed call rate over the window; null when count < 2 (or the window is zero). |
final stats = snap.stats.values.first; // SpanKeyStatsSnapshot
print('mean=${stats.meanMicros}µs '
'max=${stats.maxMicros}µs '
'total=${stats.totalMicros}µs');
print('window: ${stats.firstStartMicros}..${stats.lastStartMicros}µs');
// Null until at least two calls have been recorded.
final rate = stats.callsPerSecond;
final gap = stats.avgInterCallIntervalMicros;
if (rate != null && gap != null) {
print('${rate.toStringAsFixed(1)} calls/s, avg gap ${gap}µs');
}
Latency histograms
LatencyHistogram uses a log-linear bucket scheme (single-unit buckets for
1–7 µs, then 8 linear sub-buckets per power-of-two decade up to 60 s). Every
record call is O(1); percentile is O(buckets) ≈ O(1).
final hist = LatencyHistogram();
hist.record(142); // 142 µs
hist.record(3_800); // 3.8 ms
hist.record(15_200); // 15.2 ms
print('p50: ${hist.percentile(0.5)} µs');
print('p99: ${hist.percentile(0.99)} µs');
print('mean: ${hist.mean?.toStringAsFixed(1)} µs');
Observations above 60 s are counted in dropCount and excluded from
aggregates — never silently lost.
Outlier ring
OutlierRing keeps the N slowest spans in a fixed-capacity ring buffer.
Each completed SpanKeyStats maintains one automatically.
final snap = tracer.snapshot();
final stats = snap.stats.values.first;
for (final span in stats.outliers) {
print('slow span: ${span.name} ${span.durationMicros} µs');
}
Merging snapshots across isolates
A Tracer is single-isolate by design. To see one before/after view of an
app whose work is spread over worker isolates, each isolate snapshots its
own recorder and the host merges them:
// In the worker isolate:
sendPort.send(tracer.snapshot().toJson(includeOutliers: true));
// In the host isolate:
final worker = TraceSnapshot.fromJson(message as Map<String, Object?>);
final combined = TraceSnapshot.merge([tracer.snapshot(), worker]);
Histograms add bucket-wise, so merged percentiles are exactly as accurate
as single-isolate ones — nothing is re-approximated. Counts, totals and
drop counts add; outliers keeps the globally slowest exemplars
(outlierLimit, default 16).
Each isolate's monotonic clock starts independently, so span start times
are offsets from different origins. Every snapshot carries the wall
clock instant its clock started (clockOriginWallMicros), and merge
rebases the later isolates onto the earliest origin before combining — so
the merged window, callsPerSecond and avgInterCallIntervalMicros mean
something. The rebase is only as good as the OS clock between those two
instants.
Two caveats worth knowing:
- Merge disjoint recorders only. Merging two snapshots of the same recorder counts their shared spans twice.
duplicateCountis per-recorder: each one only ever saw its own dedup signatures, so a signature repeated in two isolates is not detected and the merged value is the sum of what each saw.
toJson omits outlier spans unless you ask for them — they are the bulk of
the payload and most readers only want aggregates. The latency distribution
is always included (sparsely, non-empty buckets only), because without it a
restored snapshot could not report percentiles. fromJson reads the format
written from 0.3.0 on and throws FormatException on anything malformed,
including a payload whose bucket layout does not match this build.
It also refuses an older payload rather than half-restoring one. 0.2.0
and earlier emitted no bucket distribution and no clock origin, so a
tolerant reader would report empty percentiles for latencies that were
measured, and would merge against an origin nobody recorded. Refusing with a
FormatException that names the version is the honest failure.
TraceRecorder options
final recorder = TraceRecorder(
enabled: true,
sampleRate: 0.1, // record 10% of spans — decided before the span starts
maxKeys: 512, // cap distinct keys tracked
outlierCapacity: 32, // keep the 32 slowest spans per key
exporters: [myLogSink], // mirror finished spans into your own pipeline
exportFilter: (span) => span.durationMicros > 1000, // export path only
slowTraceCapacity: 8, // keep the 8 slowest interactions in full
maxSpansPerTrace: 64, // per-trace span cap (marks the tree truncated)
maxInFlightTraces: 64, // concurrent trace buffers
);
final tracer = Tracer(recorder: recorder);
Features
- Per-interaction waterfalls —
slowTraceCapacityretains the slowest complete traces with their full span subtree, so "why did this one take 8 seconds" has an answer that aggregates cannot give. Bounded, off by default, and an incomplete tree says it is incomplete. - Span exporters — hand
TraceRecordera list of callbacks and every finished span is mirrored into your own logging or export pipeline. No subclassing; failures are swallowed and counted, never propagated.exportFilterquietens a chatty key on the export path only, so it can never move a percentile. - Sampling that is actually cheap — the draw happens before the span is
built, so a sampled-out span allocates nothing and forks no
Zone. - Outcome tagging —
stop/failtake attributes the caller only learns at the end (cache hit or miss, error class) without splitting the aggregate series, and errors contribute a content-freeerror.type. - Cross-isolate merge —
TraceSnapshot.toJson/fromJsonround-trip the full latency distribution, andmergecombines snapshots from separate isolates onto one timeline via their recorded clock origins. - Duplicate detection — pass an optional
dedupKey(e.g. the call's arguments) totrace/traceAsync/start;SpanKeyStatsSnapshot.duplicateCountreports how many calls repeated a previously-seen signature for a key, distinct from any statistical heuristic. - Zero-throw contract — recording errors never propagate to the host; bodies always receive their result or exception unmodified.
- Zone-based async nesting — parent spans are threaded through
Zonevalues so nestedtrace/traceAsynccalls build a proper call tree acrossawaitboundaries without manual context passing. - Log-linear histograms — ~110 fixed buckets covering 1 µs–60 s with ~12.5 % relative error. O(1) record and percentile.
- Lossless outlier ring — keeps the N slowest exemplars per key so slow outliers are never silently averaged away.
- Honest drop accounting — spans beyond
maxKeysor observations beyond 60 s are counted in explicit drop fields rather than silently discarded. - Pure Dart — no Flutter, no platform channels. Works in isolates, CLIs, servers, and Flutter apps.
Related packages
| Package | Purpose |
|---|---|
flutter_perf_radar |
Flutter facade: frame timing, jank, stall detection, TracedSubtree, overlay badge. Wraps radar_trace. |
radarscope |
Umbrella: one import across the Memory, Performance, and Stability radars. |
flutter_leak_radar |
On-device memory leak detector — heap growth, precise retention, overlay. |
License
MIT — see LICENSE.
Libraries
- radar_trace
- Pure-Dart tracer framework for on-device performance measurement.