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-uidis hashed with a tool-specific salt before being stamped on an event. Salt uniqueness per tool is the author's responsibility — seeTelemetryContextBuilder::saltfor the recommendedconcat!(CARGO_PKG_NAME, ".telemetry.v1")pattern. - Disabled is a cheap short-circuit. A
Disabledcontext'srecord()returnsOk(())without building anEventor touching the sink. Machine ID is not derived. - FileSink serialises concurrent writes.
O_APPENDon POSIX is atomic only up toPIPE_BUF(4 KiB on Linux).FileSinkholds anArc<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
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),
}
Persisted consent (consent::*, since 0.4.0)¶
Backs rtb-cli's v0.4 telemetry status / enable / disable / reset
subtree. The file lives at <config_dir>/<tool>/consent.toml:
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 (
reqwestPOST to a downstream endpoint). - OTLP sink (
opentelemetry-otlp+tracing-opentelemetry). telemetryCLI subcommand inrtb-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¶
- Status:
IMPLEMENTEDsince 0.1.0. - Spec:
docs/development/specs/2026-04-22-rtb-telemetry-v0.1.md. - Source:
crates/rtb-telemetry/.
Related¶
- Engineering Standards §1.4 — FileSink concurrency rule.
- Engineering Standards §4.6 — safe attrs list.