radar_trace 0.3.0 copy "radar_trace: ^0.3.0" to clipboard
radar_trace: ^0.3.0 copied to clipboard

Pure-Dart tracer framework with monotonic spans, log-linear latency histograms, Zone-based async nesting, and a lossless outlier ring.

radar_trace #

pub.dev License: MIT

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.0 a waterfall can be missing a middle level without being marked truncated. Another reason to keep the rate at 1.0 when slowTraceCapacity is 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, call shouldSample() 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.
  • duplicateCount is 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 waterfallsslowTraceCapacity retains 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 TraceRecorder a 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. exportFilter quietens 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 taggingstop/fail take attributes the caller only learns at the end (cache hit or miss, error class) without splitting the aggregate series, and errors contribute a content-free error.type.
  • Cross-isolate mergeTraceSnapshot.toJson/fromJson round-trip the full latency distribution, and merge combines snapshots from separate isolates onto one timeline via their recorded clock origins.
  • Duplicate detection — pass an optional dedupKey (e.g. the call's arguments) to trace/traceAsync/start; SpanKeyStatsSnapshot.duplicateCount reports 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 Zone values so nested trace/traceAsync calls build a proper call tree across await boundaries 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 maxKeys or 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.

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.

0
likes
160
points
121
downloads

Documentation

API reference

Publisher

verified publishertp9imka.dev

Weekly Downloads

Pure-Dart tracer framework with monotonic spans, log-linear latency histograms, Zone-based async nesting, and a lossless outlier ring.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#tracing #profiling #observability #performance #dart

License

MIT (license)

Dependencies

meta

More

Packages that depend on radar_trace