---
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.