mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-12 08:41:16 +00:00
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:
parent
f0dd4f5053
commit
2b8d6a40a1
8 changed files with 487 additions and 0 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod check_report;
|
||||
pub mod redact;
|
||||
pub mod telemetry;
|
||||
pub mod terminal;
|
||||
|
|
|
|||
47
crates/arc-util/src/telemetry/anonymous_id.rs
Normal file
47
crates/arc-util/src/telemetry/anonymous_id.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
75
crates/arc-util/src/telemetry/context.rs
Normal file
75
crates/arc-util/src/telemetry/context.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
122
crates/arc-util/src/telemetry/event.rs
Normal file
122
crates/arc-util/src/telemetry/event.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
154
crates/arc-util/src/telemetry/mod.rs
Normal file
154
crates/arc-util/src/telemetry/mod.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
71
crates/arc-util/src/telemetry/sender.rs
Normal file
71
crates/arc-util/src/telemetry/sender.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue