---
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), 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).
It was extracted from
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).
## Start here
| See it working, from an empty directory | [Record your first event](tutorials/record-your-first-event.md) |
| Do one specific thing | [How-to guides](how-to/index.md) |
| Look up a default, a field or a failure mode | [Reference](reference/index.md) |
| Understand why it behaves this way | [Explanation](explanation/index.md) |
| Know what it will *not* do | [What this does not do](explanation/what-this-does-not-do.md) |
## 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`.
The reasoning, and why the two levels are not collapsed into one, is in
[Two-level opt-in](explanation/two-level-opt-in.md).
### 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`:
```toml
version = 1
state = "enabled" # or "disabled" or "unset"
decided_at = "2026-08-02T20:45:18.620886932Z"
```
```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.
Full format and error behaviour: [the consent file](reference/consent-file.md).
## 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
```rust
.salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
```
Rotating `.v1` → `.v2` invalidates every previously-recorded machine
identity, which is 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](explanation/machine-identity.md).
## 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](reference/event.md).
## 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()`). Every field, default and failure
mode: [Sinks](reference/sinks.md).
!!! warning "OTLP/HTTP does not work"
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](reference/sinks.md#the-otlphttp-transport-panics)).
## Redaction wiring
The three sinks that write outside the process (`FileSink`, `HttpSink`
and `OtlpSink`) call `Event::redacted()` before serialisation, which runs
[`rtb-redact`](https://redact.rust.phpboyscout.uk) 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.
!!! 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.
Why the boundary sits there:
[Where redaction applies](explanation/redaction-boundary.md).
## 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 listing:
[docs.rs/rtb-telemetry](https://docs.rs/rtb-telemetry).
## Further reading
The blog carries a curated route through this subject: **[Rust, and what survived the port](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.
!!! tip "Ask phpbotscout"
{ width="84" align=left style="border-radius:10px;margin-right:1rem" }
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](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }