rtb-telemetry 0.7.4

Opt-in anonymous usage telemetry with pluggable sinks. Part of the phpboyscout Rust toolkit.
Documentation
---
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).

## Start here

| If you want to… | Go to |
|---|---|
| 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 — 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

| 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](reference/sinks.md).

!!! warning "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](reference/sinks.md#the-otlphttp-transport-does-not-work-in-073).

## 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"

    ![phpbotscout](https://phpboyscout.uk/images/projects/logo-phpbotscout.png){ 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 }