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 stable Rust toolchain and a terminal — 1.85 or newer, because
cargo new scaffolds an edition-2024 project. The crate itself supports
1.82 and up if you're pinned to an older edition. No collector, no account,
no network beyond fetching crates.
Create the project¶
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:
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:
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(()):
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. Opt-in is the
default, so "no answer" and "no" behave identically here.
Build the context¶
The context is the handle you record through. Add:
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 — 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(()):
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.)
Run it:
Look at what it wrote¶
{"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:
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 — 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 — the real version of step two, including where to put the file.
- Send events to an HTTPS endpoint or Export to an OTLP collector — when local files aren't the destination.
- What this does not do — worth reading before you design around the crate.