Skip to content

rtb-telemetry

rtb-telemetry is the framework's usage-analytics layer. It ships TelemetryContext — the handle tool code records events through — plus the TelemetrySink trait and three built-in sinks: NoopSink, MemorySink, and FileSink (newline-delimited JSON).

Opt-in at two levels: tool authors enable compile-time support by depending on this crate; users enable runtime collection by constructing a TelemetryContext with CollectionPolicy::Enabled. Default is Disabled — no events, no machine ID derivation, no sink calls.

Overview

Events carry:

  • the event name (e.g. command.invoke),
  • the owning tool's name + version,
  • a salted SHA-256 of machine_uid::get() (per-tool salt; raw ID never leaves this crate),
  • an RFC-3339 UTC timestamp,
  • a caller-supplied HashMap<String, String> of attrs.

Sinks consume &Event asynchronously. OTLP, HTTP, and the rtb-cli telemetry subcommand land in v0.2.

Design rationale

  • Two-level opt-in. Author compile-in via Cargo dep; user runtime-enable via CollectionPolicy. Disabled is the default at every layer.
  • Salted SHA-256, never raw machine ID. The machine ID from machine-uid is hashed with a tool-specific salt before being stamped on an event. Salt uniqueness per tool is the author's responsibility — see TelemetryContextBuilder::salt for the recommended concat!(CARGO_PKG_NAME, ".telemetry.v1") pattern.
  • Disabled is a cheap short-circuit. A Disabled context's record() returns Ok(()) without building an Event or touching the sink. Machine ID is not derived.
  • FileSink serialises concurrent writes. O_APPEND on POSIX is atomic only up to PIPE_BUF (4 KiB on Linux). FileSink holds an Arc<tokio::sync::Mutex<()>> so concurrent emits never interleave JSONL at the byte level.

Core types

TelemetryContext

#[derive(Clone)]
pub struct TelemetryContext {
    // Arc-shared tool name, version, machine_id, sink; Copy-cheap policy.
}

impl TelemetryContext {
    pub fn builder() -> TelemetryContextBuilder;
    pub fn policy(&self) -> CollectionPolicy;

    pub async fn record(&self, event_name: &str) -> Result<(), TelemetryError>;
    pub async fn record_with_attrs(
        &self,
        event_name: &str,
        attrs: HashMap<String, String>,
    ) -> Result<(), TelemetryError>;
    pub async fn flush(&self) -> Result<(), TelemetryError>;
}

TelemetryContextBuilder

#[must_use]
#[derive(Default)]
pub struct TelemetryContextBuilder { /* ... */ }

impl TelemetryContextBuilder {
    pub fn tool(self, name: impl Into<String>) -> Self;           // required
    pub fn tool_version(self, v: impl Into<String>) -> Self;      // required
    pub fn salt(self, salt: impl Into<String>) -> Self;           // required when Enabled
    pub fn sink(self, sink: Arc<dyn TelemetrySink>) -> Self;      // defaults to NoopSink
    pub const fn policy(self, policy: CollectionPolicy) -> Self;  // defaults to Disabled
    pub fn build(self) -> TelemetryContext;                       // panics on missing required
}

Salt pattern

.salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))

Rotating the .v1.v2 tag invalidates every previously- recorded machine identity — the intended reset flow. Two tools using a literal "default" will collide on the same host; the crate relies on author discipline here rather than enforcing uniqueness.

CollectionPolicy

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CollectionPolicy {
    #[default]
    Disabled,
    Enabled,
}

Event

#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Event {
    pub name: String,
    pub tool: String,
    pub tool_version: String,
    pub machine_id: String,          // hex SHA-256
    pub timestamp_utc: String,       // RFC 3339
    pub attrs: HashMap<String, String>,
}

TelemetrySink

#[async_trait::async_trait]
pub trait TelemetrySink: Send + Sync + 'static {
    async fn emit(&self, event: &Event) -> Result<(), TelemetryError>;
    async fn flush(&self) -> Result<(), TelemetryError> { Ok(()) }
}
Sink Backing Use case
NoopSink /dev/null Disabled-policy default; no allocation, no I/O.
MemorySink Arc<Mutex<Vec<Event>>> Test fixtures; .snapshot(), .len(), .is_empty().
FileSink Newline-delimited JSON on disk Local audit trail; creates parent dirs; serialises concurrent writes.

MachineId

pub struct MachineId;

impl MachineId {
    /// sha256(salt || machine_uid::get()) hex-encoded.
    /// Falls back to a random Uuid when the OS doesn't expose
    /// a machine ID (sandboxed container, WASI).
    pub fn derive(salt: &str) -> String;
}

TelemetryError

#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum TelemetryError {
    #[error("sink I/O error: {0}")]
    #[diagnostic(code(rtb::telemetry::io))]
    Io(#[from] std::io::Error),

    #[error("serialisation error: {0}")]
    #[diagnostic(code(rtb::telemetry::serde))]
    Serde(String),
}

Backs rtb-cli's v0.4 telemetry status / enable / disable / reset subtree. The file lives at <config_dir>/<tool>/consent.toml:

version = 1
state = "enabled"   # or "disabled" or "unset"
decided_at = "2026-05-08T12:34:56Z"
use rtb_telemetry::consent::{self, Consent, ConsentState};

// Read on startup. Missing file → Ok(None) → policy fallback.
let path = config_dir.join("mytool/consent.toml");
let policy = match consent::read(&path)? {
    Some(c) => c.state.into(),         // ConsentState → CollectionPolicy
    None    => CollectionPolicy::Disabled, // opt-in default
};

// Write on `telemetry enable` / `disable`.
consent::write(&path, &Consent::enabled_now())?;

// Wipe on `telemetry reset` — idempotent.
consent::reset(&path)?;

Consent carries an explicit schema version (currently 1) so a future format change is non-breaking — read rejects unknown versions with a TelemetryError::Serde. Decisions are timestamped in RFC 3339 / ISO 8601 (UTC).

The CLI-side wiring lives in rtb-cli — the v0.4 telemetry subtree (status / enable / disable / reset) reads and writes this file directly. rtb-telemetry ships the file primitives; rtb-cli does the path resolution (ProjectDirs::config_dir()) and the user-facing flow.

API surface

Item Kind Since
TelemetryContext, TelemetryContextBuilder structs 0.1.0
CollectionPolicy { Disabled, Enabled } enum 0.1.0
Event struct 0.1.0
TelemetrySink async trait 0.1.0
NoopSink, MemorySink, FileSink structs 0.1.0
MachineId::derive fn 0.1.0
TelemetryError::{Io, Serde, Http, Otlp} enum 0.1.0 / 0.2.0
consent::{Consent, ConsentState, read, write, reset} module 0.4.0
From<ConsentState> for CollectionPolicy impl 0.4.0

Usage patterns

Minimal — opt-in enabled, file sink

use rtb_telemetry::{CollectionPolicy, FileSink, TelemetryContext};
use std::sync::Arc;

let sink = Arc::new(FileSink::new(dirs::data_dir().unwrap().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?;

Tests — MemorySink snapshot

use rtb_telemetry::MemorySink;
use std::sync::Arc;

let sink = Arc::new(MemorySink::new());
let telemetry = TelemetryContext::builder()
    .tool("mytool")
    .tool_version("1.0.0")
    .salt("mytool.test")
    .sink(sink.clone())
    .policy(CollectionPolicy::Enabled)
    .build();

telemetry.record("thing.happened").await?;
assert_eq!(sink.snapshot()[0].name, "thing.happened");

Privacy

Callers own attr redaction

v0.1 does not automatically redact Event::attrs values. Anything in the map ships verbatim to the sink. Tool authors MUST NOT pass:

  • Raw command-line arguments (may contain --api-key=…).
  • File paths under the user's home directory.
  • Error messages or panic payloads sourced from user input.
  • Secrets (any kind).
  • Free-form user-supplied strings.

Safe attrs: command name, enumerated outcome (ok/error/cancelled), duration bucket, framework-supplied version string.

A follow-up rtb-redact crate (v0.2) will ship a canonical redaction helper. Until then, discipline at call sites is mandatory.

Deferred to v0.2+

  • HTTP sink (reqwest POST to a downstream endpoint).
  • OTLP sink (opentelemetry-otlp + tracing-opentelemetry).
  • telemetry CLI subcommand in rtb-cli: enable, disable, status, reset (clears the machine ID cache).
  • Batching + retry sinks. v0.1 emits synchronously per event.
  • Automatic attr redaction via rtb-redact.
  • Event schema versioning.

Consumers

Direct consumers in v0.1: none — this crate exists to be wired by downstream tools. rtb-cli will wire the telemetry subcommand in v0.2.

Testing

18 acceptance criteria across:

  • 13 unit tests (tests/unit.rs) — T1–T13 including insta JSON snapshot and a concurrent-write test that proves 64 parallel emits with 2 KiB attrs produce valid JSONL.
  • 6 Gherkin scenarios (tests/features/telemetry.feature).

Spec and status