---
title: rtb-telemetry
description: Opt-in anonymous usage telemetry with pluggable sinks, salted machine identity, and persisted consent.
---
# `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](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).
## 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
`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`.
### Persisted consent (`consent` module)
The user's decision is persisted to `<config_dir>/<tool>/consent.toml`:
```toml
version = 1
state = "enabled" # or "disabled" or "unset"
decided_at = "2026-05-08T12:34:56Z"
```
```rust
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.
Decisions are timestamped in RFC 3339 (UTC).
## Machine identity
`MachineId::derive(salt)` returns `sha256(salt || machine_uid)`
hex-encoded — the raw machine ID never leaves this crate. When the OS
exposes no machine ID (sandboxed container, WASI), it falls back to a
random UUID. Salt uniqueness per tool is the author's responsibility;
the recommended pattern is
```rust
.salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
```
Rotating `.v1` → `.v2` invalidates every previously-recorded machine
identity — the intended reset flow.
## 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.
## Sinks
| `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()`).
## Redaction wiring
Every built-in sink calls `Event::redacted()` before serialisation,
which runs [`rtb-redact`](https://redact.rust.phpboyscout.uk)
automatically over `Event::args` and `Event::err_msg` — URL userinfo,
credential query parameters, provider key prefixes, and long opaque
tokens are stripped before an event leaves the process.
!!! warning "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.
## Usage
```rust
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 reference:
[docs.rs/rtb-telemetry](https://docs.rs/rtb-telemetry).
## Design record
The authoritative contracts are the crate's specs, retained in the
rust-tool-base spec series:
- [v0.1 — opt-in events, salted machine ID, three sinks](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-04-22-rtb-telemetry-v0.1.md)
- [HTTP + OTLP sinks (`remote-sinks`)](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-04-24-rtb-telemetry-http-otlp-sinks.md)
Related engineering-standards rules:
[§1.4 filesystem concurrency](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/engineering-standards.md)
(the FileSink write-serialisation rule) and §4.6 (the safe-attribute
set for telemetry events).