Add Segment-based telemetry module to arc-util

Adds a telemetry library for product analytics with Segment integration.
Includes Track/User wire types, persistent anonymous ID (~/.arc/anonymous_id),
OS/arch/locale context, fire-and-forget sender via tokio::spawn, and
ARC_TELEMETRY env var control (off/errors/all). No CLI or server integration yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 21:47:03 -05:00
parent f0dd4f5053
commit 2b8d6a40a1
8 changed files with 487 additions and 0 deletions

8
Cargo.lock generated
View file

@ -359,12 +359,20 @@ name = "arc-util"
version = "0.1.0"
dependencies = [
"aho-corasick",
"anyhow",
"base64",
"chrono",
"console 0.15.11",
"dirs",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"termimad",
"tokio",
"toml",
"tracing",
"uuid",
]
[[package]]

View file

@ -14,6 +14,15 @@ regex.workspace = true
termimad.workspace = true
aho-corasick.workspace = true
serde_json.workspace = true
serde.workspace = true
reqwest.workspace = true
tokio.workspace = true
uuid.workspace = true
chrono.workspace = true
base64.workspace = true
dirs.workspace = true
tracing.workspace = true
anyhow.workspace = true
[build-dependencies]
toml = "0.8"

View file

@ -1,3 +1,4 @@
pub mod check_report;
pub mod redact;
pub mod telemetry;
pub mod terminal;

View file

@ -0,0 +1,47 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn anonymous_id_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".arc").join("anonymous_id"))
}
pub fn load_or_create_anonymous_id() -> Result<String> {
let path = anonymous_id_path()?;
if let Ok(contents) = fs::read_to_string(&path) {
let id = contents.trim().to_string();
if !id.is_empty() {
return Ok(id);
}
}
let id = Uuid::new_v4().to_string();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
fs::write(&path, &id)
.with_context(|| format!("failed to write anonymous id to {}", path.display()))?;
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_or_create_returns_stable_id() {
// Calls the real function, which uses ~/.arc/anonymous_id
let id1 = load_or_create_anonymous_id().unwrap();
let id2 = load_or_create_anonymous_id().unwrap();
assert_eq!(id1, id2);
// Should be a valid UUID
Uuid::parse_str(&id1).unwrap();
}
}

View file

@ -0,0 +1,75 @@
use serde_json::{json, Value};
pub fn build_context() -> Value {
json!({
"os": { "name": std::env::consts::OS },
"device": { "type": std::env::consts::ARCH },
"locale": current_locale(),
"app": { "name": "arc", "version": env!("CARGO_PKG_VERSION") }
})
}
fn current_locale() -> String {
let lang = std::env::var("LANG").unwrap_or_default();
parse_locale(&lang)
}
fn parse_locale(lang: &str) -> String {
if lang.is_empty() || lang == "C" || lang == "POSIX" {
return "en-US".to_string();
}
// Strip encoding suffix (e.g. ".UTF-8")
let without_encoding = lang.split('.').next().unwrap_or(lang);
// Convert underscore to hyphen (e.g. "en_US" -> "en-US")
without_encoding.replace('_', "-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_has_expected_keys() {
let ctx = build_context();
assert!(ctx.get("os").is_some());
assert!(ctx["os"].get("name").is_some());
assert!(ctx.get("device").is_some());
assert!(ctx["device"].get("type").is_some());
assert!(ctx.get("locale").is_some());
assert!(ctx.get("app").is_some());
assert_eq!(ctx["app"]["name"], "arc");
assert!(ctx["app"].get("version").is_some());
}
#[test]
fn parse_locale_en_us_utf8() {
assert_eq!(parse_locale("en_US.UTF-8"), "en-US");
}
#[test]
fn parse_locale_c() {
assert_eq!(parse_locale("C"), "en-US");
}
#[test]
fn parse_locale_posix() {
assert_eq!(parse_locale("POSIX"), "en-US");
}
#[test]
fn parse_locale_empty() {
assert_eq!(parse_locale(""), "en-US");
}
#[test]
fn parse_locale_no_encoding() {
assert_eq!(parse_locale("fr_FR"), "fr-FR");
}
#[test]
fn parse_locale_with_encoding() {
assert_eq!(parse_locale("de_DE.ISO-8859-1"), "de-DE");
}
}

View file

@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
pub struct Track {
#[serde(flatten)]
pub user: User,
pub event: String,
pub properties: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(rename = "messageId")]
pub message_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum User {
Both {
#[serde(rename = "userId")]
user_id: String,
#[serde(rename = "anonymousId")]
anonymous_id: String,
},
UserId {
#[serde(rename = "userId")]
user_id: String,
},
AnonymousId {
#[serde(rename = "anonymousId")]
anonymous_id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn track_serialization_anonymous() {
let track = Track {
user: User::AnonymousId {
anonymous_id: "abc-123".to_string(),
},
event: "Command Invoked".to_string(),
properties: json!({"command": "run"}),
context: Some(json!({"os": {"name": "macos"}})),
timestamp: Some("2025-01-01T00:00:00Z".to_string()),
message_id: "msg-1".to_string(),
};
let value = serde_json::to_value(&track).unwrap();
assert_eq!(value["anonymousId"], "abc-123");
assert_eq!(value["event"], "Command Invoked");
assert_eq!(value["properties"]["command"], "run");
assert_eq!(value["context"]["os"]["name"], "macos");
assert_eq!(value["timestamp"], "2025-01-01T00:00:00Z");
assert_eq!(value["messageId"], "msg-1");
assert!(value.get("userId").is_none());
}
#[test]
fn track_serialization_user_id() {
let track = Track {
user: User::UserId {
user_id: "user-456".to_string(),
},
event: "test".to_string(),
properties: json!({}),
context: None,
timestamp: None,
message_id: "msg-2".to_string(),
};
let value = serde_json::to_value(&track).unwrap();
assert_eq!(value["userId"], "user-456");
assert!(value.get("anonymousId").is_none());
assert!(value.get("context").is_none());
assert!(value.get("timestamp").is_none());
}
#[test]
fn track_serialization_both() {
let track = Track {
user: User::Both {
user_id: "user-456".to_string(),
anonymous_id: "abc-123".to_string(),
},
event: "test".to_string(),
properties: json!({}),
context: None,
timestamp: None,
message_id: "msg-3".to_string(),
};
let value = serde_json::to_value(&track).unwrap();
assert_eq!(value["userId"], "user-456");
assert_eq!(value["anonymousId"], "abc-123");
}
#[test]
fn track_round_trip() {
let track = Track {
user: User::AnonymousId {
anonymous_id: "abc".to_string(),
},
event: "test".to_string(),
properties: json!({"key": "value"}),
context: Some(json!({"app": {"name": "arc"}})),
timestamp: Some("2025-06-01T12:00:00Z".to_string()),
message_id: "msg-rt".to_string(),
};
let json_str = serde_json::to_string(&track).unwrap();
let deserialized: Track = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.event, "test");
assert_eq!(deserialized.message_id, "msg-rt");
}
}

View file

@ -0,0 +1,154 @@
pub mod anonymous_id;
pub mod context;
pub mod event;
pub mod sender;
use anyhow::Result;
use chrono::Utc;
use serde_json::Value;
use uuid::Uuid;
use event::{Track, User};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TelemetryLevel {
Off,
Errors,
All,
}
pub struct Telemetry {
level: TelemetryLevel,
anonymous_id: String,
context: Value,
}
impl Telemetry {
pub fn new() -> Result<Self> {
let level = telemetry_level();
let anonymous_id = anonymous_id::load_or_create_anonymous_id()?;
let context = context::build_context();
Ok(Self {
level,
anonymous_id,
context,
})
}
pub fn level(&self) -> &TelemetryLevel {
&self.level
}
pub fn should_track(&self, is_error: bool) -> bool {
match self.level {
TelemetryLevel::Off => false,
TelemetryLevel::Errors => is_error,
TelemetryLevel::All => true,
}
}
pub fn build_track(&self, event: &str, properties: Value) -> Track {
Track {
user: User::AnonymousId {
anonymous_id: self.anonymous_id.clone(),
},
event: event.to_string(),
properties,
context: Some(self.context.clone()),
timestamp: Some(Utc::now().to_rfc3339()),
message_id: Uuid::new_v4().to_string(),
}
}
}
pub fn telemetry_level() -> TelemetryLevel {
match std::env::var("ARC_TELEMETRY").as_deref() {
Ok("off") => TelemetryLevel::Off,
Ok("errors") => TelemetryLevel::Errors,
Ok("all") => TelemetryLevel::All,
_ => {
if cfg!(debug_assertions) {
TelemetryLevel::Off
} else {
TelemetryLevel::All
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn telemetry_level_defaults_to_off_in_debug() {
// In test builds (debug_assertions=true), default is Off
std::env::remove_var("ARC_TELEMETRY");
assert_eq!(telemetry_level(), TelemetryLevel::Off);
}
#[test]
fn telemetry_level_parses_env_var() {
std::env::set_var("ARC_TELEMETRY", "all");
assert_eq!(telemetry_level(), TelemetryLevel::All);
std::env::set_var("ARC_TELEMETRY", "errors");
assert_eq!(telemetry_level(), TelemetryLevel::Errors);
std::env::set_var("ARC_TELEMETRY", "off");
assert_eq!(telemetry_level(), TelemetryLevel::Off);
// Clean up
std::env::remove_var("ARC_TELEMETRY");
}
#[test]
fn should_track_respects_level() {
let telemetry = Telemetry {
level: TelemetryLevel::Off,
anonymous_id: "test".to_string(),
context: json!({}),
};
assert!(!telemetry.should_track(false));
assert!(!telemetry.should_track(true));
let telemetry = Telemetry {
level: TelemetryLevel::Errors,
anonymous_id: "test".to_string(),
context: json!({}),
};
assert!(!telemetry.should_track(false));
assert!(telemetry.should_track(true));
let telemetry = Telemetry {
level: TelemetryLevel::All,
anonymous_id: "test".to_string(),
context: json!({}),
};
assert!(telemetry.should_track(false));
assert!(telemetry.should_track(true));
}
#[test]
fn build_track_populates_fields() {
let telemetry = Telemetry {
level: TelemetryLevel::All,
anonymous_id: "anon-123".to_string(),
context: json!({"app": {"name": "arc"}}),
};
let track = telemetry.build_track("Test Event", json!({"key": "value"}));
assert_eq!(track.event, "Test Event");
assert_eq!(track.properties["key"], "value");
assert!(track.context.is_some());
assert!(track.timestamp.is_some());
assert!(!track.message_id.is_empty());
match &track.user {
User::AnonymousId { anonymous_id } => assert_eq!(anonymous_id, "anon-123"),
_ => panic!("expected AnonymousId variant"),
}
}
}

View file

@ -0,0 +1,71 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use super::event::Track;
const SEGMENT_API_URL: &str = "https://api.segment.io/v1/track";
const SEGMENT_WRITE_KEY: Option<&str> = option_env!("SEGMENT_WRITE_KEY");
/// Spawns a fire-and-forget tokio task to send a track event to Segment.
/// No-ops if the SEGMENT_WRITE_KEY was not set at compile time.
pub fn send(track: Track) {
let Some(write_key) = SEGMENT_WRITE_KEY else {
tracing::debug!("telemetry: no SEGMENT_WRITE_KEY, skipping send");
return;
};
tokio::spawn(send_track(track, write_key));
}
async fn send_track(track: Track, write_key: &str) {
let auth = STANDARD.encode(format!("{write_key}:"));
let result = reqwest::Client::new()
.post(SEGMENT_API_URL)
.header("Authorization", format!("Basic {auth}"))
.json(&track)
.send()
.await;
match result {
Ok(resp) if !resp.status().is_success() => {
tracing::warn!(
status = %resp.status(),
"telemetry: segment API returned non-success status"
);
}
Err(err) => {
tracing::warn!(%err, "telemetry: failed to send event to segment");
}
Ok(_) => {
tracing::debug!("telemetry: event sent successfully");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::telemetry::event::User;
use serde_json::json;
#[test]
fn send_noops_without_write_key() {
// SEGMENT_WRITE_KEY is not set at compile time in tests,
// so send() should return immediately without spawning.
let track = Track {
user: User::AnonymousId {
anonymous_id: "test".to_string(),
},
event: "test".to_string(),
properties: json!({}),
context: None,
timestamp: None,
message_id: "msg-test".to_string(),
};
// This should not panic or require a tokio runtime
// because it returns before reaching tokio::spawn
send(track);
}
}