rtb-telemetry 0.7.5

Opt-in anonymous usage telemetry with pluggable sinks. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Record your first event
description: Build a small binary that asks for consent, records a telemetry event to a file, and stops recording when consent is withdrawn.
---

# Record your first event

By the end of this you'll have a small binary that records one telemetry
event to a file on disk, only when the user has said yes, and stops
recording the moment they change their mind. Everything stays local:
nothing here sends data anywhere.

Allow about fifteen minutes, most of which is the first compile.

## Before you start

You'll need a terminal and a stable Rust toolchain, 1.88 or newer (the
[MSRV note](../reference/cargo-features.md#minimum-supported-rust-version)
says where that floor comes from). No collector, no account, no network
beyond fetching crates.

## Create the project

```bash
cargo new greeter
cd greeter
cargo add rtb-telemetry
cargo add tokio --features full
cargo add miette --features fancy
```

Three dependencies, and only the first is this crate. `tokio` is there
because recording is `async`; `miette` is what the error type reports
through, and it gives `main` a `Result` you can use `?` in.

The first `cargo build` pulls in around fifty crates and takes a minute or
two. Later builds are quick.

## Ask, and store the answer

A real tool would prompt. To keep the moving parts down, this one takes the
answer as a command-line argument and writes it to a consent file.

Put this in `src/main.rs`:

```rust
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use rtb_telemetry::consent::{self, Consent};
use rtb_telemetry::{CollectionPolicy, FileSink, TelemetryContext};

#[tokio::main]
async fn main() -> miette::Result<()> {
    let dir = PathBuf::from("telemetry-demo");
    let consent_path = dir.join("consent.toml");

    // Stand in for the prompt a real tool would show.
    let answer = std::env::args().nth(1).unwrap_or_else(|| "no".to_string());
    match answer.as_str() {
        "yes" => consent::write(&consent_path, &Consent::enabled_now())?,
        "forget" => consent::reset(&consent_path)?,
        _ => consent::write(&consent_path, &Consent::disabled_now())?,
    }

    Ok(())
}
```

`Consent::enabled_now()` and `disabled_now()` stamp the decision with the
current UTC time. `consent::reset` deletes the file, and does not complain
if it was never there.

Nothing records yet. Run it once to prove the consent file appears:

```bash
cargo run -- yes
cat telemetry-demo/consent.toml
```

```toml
version = 1
state = "enabled"
decided_at = "2026-08-02T20:45:18.620886932Z"
```

## Turn the stored answer into a policy

The crate never decides for itself whether to collect. You read the
decision and hand it over as a `CollectionPolicy`. Add this before the
final `Ok(())`:

```rust
    let policy = match consent::read(&consent_path)? {
        Some(record) => CollectionPolicy::from(record.state),
        None => CollectionPolicy::Disabled,
    };
    println!("collection is {policy:?}");
```

`read` returns `Ok(None)` when there's no file (someone who has never been
asked), and that maps to `Disabled`, same as an explicit no. Collection is
opt-in, so "no answer" and "no" behave identically here.

## Build the context

The context is the handle you record through. Add:

```rust
    let sink = Arc::new(FileSink::new(dir.join("events.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(policy)
        .build();
```

`tool` and `tool_version` are required, and `build()` panics with a message
naming the field if you leave one out. The `salt` is required whenever the
policy is `Enabled`; it's mixed into the machine-identity hash so your
tool's identities can't be lined up against another tool's. Use your crate
name plus a version tag, as above, and change the tag if you ever want to
reset every identity.

The file isn't created yet. `FileSink` doesn't touch the disk until the
first event, and it creates the parent directory then.

## Record something

Add the recording call and a flush, before `Ok(())`:

```rust
    println!("Hello!");

    let mut attrs = HashMap::new();
    attrs.insert("command".to_string(), "greet".to_string());
    attrs.insert("outcome".to_string(), "ok".to_string());
    telemetry.record_with_attrs("command.invoke", attrs).await?;
    telemetry.flush().await?;
```

Keep attribute values to a small fixed vocabulary: a command name, an
outcome, a bucket. Attributes are *not* scrubbed for secrets, so a path or
a raw argument does not belong here. (`args` and `err_msg` on the event are
the fields that get scrubbed; see
[Where redaction applies](../explanation/redaction-boundary.md).)

Run it:

```bash
cargo run -- yes
```

```text
collection is Enabled
Hello!
```

## Look at what it wrote

```bash
cat telemetry-demo/events.jsonl
```

```json
{"name":"command.invoke","tool":"greeter","tool_version":"0.1.0","machine_id":"512e9002c834a205357a4b9412da3dadd13494b636bcacf89e190fd354a043d3","timestamp_utc":"2026-08-02T20:44:26.028886899Z","attrs":{"command":"greet","outcome":"ok"}}
```

One line per event. The `machine_id` is a salted SHA-256 of your host's
machine ID. The raw value never leaves the crate, and a different salt
produces a completely different hash. Your value will differ from the one
above; the same machine and salt will produce the same value every run.

## Withdraw consent and watch it go quiet

This is the part worth seeing for yourself:

```bash
cargo run -- no
wc -l telemetry-demo/events.jsonl
```

```text
collection is Disabled
Hello!
1 telemetry-demo/events.jsonl
```

Still one line. The `record_with_attrs` call ran, and did nothing: under
`Disabled` the context returns `Ok(())` before building an event, before
reading your attribute map, and without touching the sink. It doesn't even
derive a machine ID, so a user who hasn't opted in never has their host read.

`cargo run -- forget` deletes the consent file, which puts the tool back to
"never asked".

## What this doesn't cover

The consent file here lives in the working directory, which is fine for a
demo and wrong for a real tool. Put it under the user's config directory.
And `FileSink` never rotates or prunes, so a long-lived tool needs a plan
for the file. Both are covered in the how-to guides.

## Where to go next

- [Persist a user's consent decision]../how-to/persist-a-consent-decision.md: the real version of step two, including where to put the file.
- [Send events to an HTTPS endpoint]../how-to/send-events-to-an-https-endpoint.md or [Export to an OTLP collector]../how-to/export-to-an-otlp-collector.md, when local files aren't the destination.
- [What this does not do]../explanation/what-this-does-not-do.md, worth reading before you design around the crate.