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 |
Consent model: opt-in at two levels¶
- 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
TelemetryContextis built withCollectionPolicy::Enabled. The default isDisabled— no events, no machine-ID derivation, no sink calls. ADisabledcontext'srecord()returnsOk(())without even building anEvent.
The reasoning, and why the two levels are not collapsed into one, is in Two-level opt-in.
Persisted consent (consent module)¶
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
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

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.