Skip to content

rtb-telemetry

Opt-in anonymous usage telemetry for CLI tools. Ships TelemetryContext — the handle tool code records events through — plus the TelemetrySink trait, three always-available sinks (NoopSink, MemorySink, FileSink), and two remote sinks (HttpSink, OtlpSink) behind the remote-sinks Cargo feature.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.

Start here

If you want to… Go to
See it working, from an empty directory Record your first event
Do one specific thing How-to guides
Look up a default, a field or a failure mode Reference
Understand why it behaves this way Explanation
Know what it will not do What this does not do
  • Author compile-in. A tool enables telemetry support by depending on this crate. No dependency, no telemetry code in the binary.
  • User runtime-enable. Collection happens only when the TelemetryContext is built with CollectionPolicy::Enabled. The default is Disabled — no events, no machine-ID derivation, no sink calls. A Disabled context's record() returns Ok(()) without even building an Event.

The reasoning, and why the two levels are not collapsed into one, is in Two-level opt-in.

The user's decision is persisted to a TOML file whose path the tool chooses — by convention <config_dir>/<tool>/consent.toml:

version = 1
state = "enabled"   # or "disabled" or "unset"
decided_at = "2026-08-02T20:45:18.620886932Z"
use rtb_telemetry::consent::{self, Consent};
use rtb_telemetry::CollectionPolicy;

// Read on startup. Missing file → Ok(None) → opt-in default.
let policy = match consent::read(&path)? {
    Some(c) => c.state.into(),             // ConsentState → CollectionPolicy
    None    => CollectionPolicy::Disabled,
};

// Write on `telemetry enable` / `disable`; wipe on `telemetry reset`.
consent::write(&path, &Consent::enabled_now())?;
consent::reset(&path)?;   // idempotent

Consent carries an explicit schema version (currently 1) so a future format change is non-breaking — read rejects unknown versions. Full format and error behaviour: the consent file.

Machine identity

MachineId::derive(salt) returns sha256(salt || machine_uid) hex-encoded — the raw machine ID never leaves this crate. Salt uniqueness per tool is the author's responsibility; the recommended pattern is

.salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))

Rotating .v1.v2 invalidates every previously-recorded machine identity — the intended reset flow. On a host with no readable machine ID the identity is not stable; that case, and what it does to your numbers, is covered in Machine identity.

Events

Each Event carries the event name (e.g. command.invoke), the tool's name + version, the salted machine ID, an RFC-3339 UTC timestamp, optional args / err_msg strings, and a caller-supplied HashMap<String, String> of attrs. Field-by-field, including the JSON written on the wire: Event.

Sinks

Sink Feature Backing Use case
NoopSink always Disabled-policy default; no allocation, no I/O.
MemorySink always Vec<Event> in memory Test fixtures; .snapshot(), .len(), .is_empty().
FileSink always Newline-delimited JSON on disk Local audit trail; creates parent dirs; serialises concurrent writes so JSONL lines never interleave.
HttpSink remote-sinks reqwest JSON POST Ship events to an HTTPS collection endpoint (optional bearer token, insecure endpoints refused by default).
OtlpSink remote-sinks OTLP/gRPC (tonic) or OTLP/HTTP Export events as OpenTelemetry log records.

Custom sinks implement the async TelemetrySink trait (emit(&Event) + optional flush()). Every field, default and failure mode: Sinks.

OTLP/HTTP does not work in 0.7.3

An OTLP endpoint that routes to the HTTP/protobuf transport panics inside the exporter thread and exports nothing. Use grpc://, grpcs:// or a :4317 endpoint — details.

Redaction wiring

The three sinks that write outside the process — FileSink, HttpSink and OtlpSink — call Event::redacted() before serialisation, which runs rtb-redact over Event::args and Event::err_msg: URL userinfo, credential query parameters, provider key prefixes, JWTs, PEM blocks and long opaque tokens are stripped before an event leaves the process. MemorySink and NoopSink do not redact, because neither lets the event out.

Callers own attr redaction

Event::attrs values are not auto-redacted — anything in the map ships verbatim to the sink. Tool authors MUST NOT pass raw command-line arguments, home-directory paths, user-sourced error messages, secrets, or free-form user strings as attrs. Safe attrs: command name, enumerated outcome (ok/error/cancelled), duration bucket, framework-supplied version string. Route anything free-form through rtb_redact::string yourself, or put it in args / err_msg where redaction is automatic.

Why the boundary sits there: Where redaction applies.

Usage

use rtb_telemetry::{CollectionPolicy, FileSink, TelemetryContext};
use std::sync::Arc;

let sink = Arc::new(FileSink::new(data_dir.join("mytool/telemetry.jsonl")));
let telemetry = TelemetryContext::builder()
    .tool(env!("CARGO_PKG_NAME"))
    .tool_version(env!("CARGO_PKG_VERSION"))
    .salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
    .sink(sink)
    .policy(CollectionPolicy::Enabled)
    .build();

telemetry.record("command.invoke").await?;

Full API listing: docs.rs/rtb-telemetry.

Further reading

The blog carries a curated route through this subject: Rust, and what survived the port collects everything written about it, ordered so you can start at the beginning rather than newest-first.

Ask phpbotscout

phpbotscout

He answers questions about the projects over on the Discord, citing the docs where they already cover it, and offering to raise an issue where they don't. Bring a bug, an idea, or a questionable engineering decision.

Join the Discord