diff --git a/Cargo.lock b/Cargo.lock index 0ae2dd1b468b..d3385cda1dc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,7 +2025,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2659,6 +2659,7 @@ dependencies = [ "etcetera", "fs2", "futures", + "hex", "ignore", "include_dir", "indexmap 2.12.0", diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index b56c1ee61719..cea4cf0565e2 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -111,6 +111,7 @@ shellexpand = "3.1.1" indexmap = "2.12.0" ignore = "0.4.25" which = "8.0.0" +hex = "0.4.3" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose/src/tracing/client_fingerprint.rs b/crates/goose/src/tracing/client_fingerprint.rs new file mode 100644 index 000000000000..4e8066ea5d49 --- /dev/null +++ b/crates/goose/src/tracing/client_fingerprint.rs @@ -0,0 +1,84 @@ +//! Privacy-respecting client fingerprinting for telemetry. +//! +//! Generates a stable, hashed client ID plus OS/arch info for OTLP resource attributes. +//! The raw UUID is stored locally; only the SHA-256 hash is sent. + +use opentelemetry::KeyValue; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::config::Config; + +const CLIENT_ID_CONFIG_KEY: &str = "goose_client_id"; + +/// Get or create the client ID, returning its SHA-256 hash. +pub fn get_client_id_hash() -> String { + let config = Config::global(); + + let client_id: String = match config.get_param(CLIENT_ID_CONFIG_KEY) { + Ok(id) => id, + Err(_) => { + let new_id = Uuid::new_v4().to_string(); + let _ = config.set_param(CLIENT_ID_CONFIG_KEY, &new_id); + new_id + } + }; + + let mut hasher = Sha256::new(); + hasher.update(client_id.as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn get_os_type() -> &'static str { + #[cfg(target_os = "macos")] + return "macos"; + #[cfg(target_os = "linux")] + return "linux"; + #[cfg(target_os = "windows")] + return "windows"; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + return "unknown"; +} + +pub fn get_host_arch() -> &'static str { + #[cfg(target_arch = "x86_64")] + return "x86_64"; + #[cfg(target_arch = "aarch64")] + return "aarch64"; + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + return "unknown"; +} + +/// Create OTLP resource attributes for client fingerprinting. +pub fn create_fingerprint_attributes() -> Vec { + vec![ + KeyValue::new("client.id", get_client_id_hash()), + KeyValue::new("os.type", get_os_type()), + KeyValue::new("host.arch", get_host_arch()), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_id_hash_is_stable() { + let hash1 = get_client_id_hash(); + let hash2 = get_client_id_hash(); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); + } + + #[test] + fn test_os_and_arch() { + assert!(["macos", "linux", "windows", "unknown"].contains(&get_os_type())); + assert!(["x86_64", "aarch64", "unknown"].contains(&get_host_arch())); + } + + #[test] + fn test_fingerprint_attributes() { + let attrs = create_fingerprint_attributes(); + assert_eq!(attrs.len(), 3); + } +} diff --git a/crates/goose/src/tracing/mod.rs b/crates/goose/src/tracing/mod.rs index 8acd9203c7d0..384caeb69cdc 100644 --- a/crates/goose/src/tracing/mod.rs +++ b/crates/goose/src/tracing/mod.rs @@ -1,8 +1,12 @@ +pub mod client_fingerprint; pub mod langfuse_layer; mod observation_layer; pub mod otlp_layer; pub mod rate_limiter; +pub use client_fingerprint::{ + create_fingerprint_attributes, get_client_id_hash, get_host_arch, get_os_type, +}; pub use langfuse_layer::{create_langfuse_observer, LangfuseBatchManager}; pub use observation_layer::{ flatten_metadata, map_level, BatchManager, ObservationLayer, SpanData, SpanTracker, diff --git a/crates/goose/src/tracing/otlp_layer.rs b/crates/goose/src/tracing/otlp_layer.rs index 5a357634d7f7..490f4dc56d99 100644 --- a/crates/goose/src/tracing/otlp_layer.rs +++ b/crates/goose/src/tracing/otlp_layer.rs @@ -10,6 +10,8 @@ use tracing::{Level, Metadata}; use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; use tracing_subscriber::filter::FilterFn; +use super::client_fingerprint::create_fingerprint_attributes; + pub type OtlpTracingLayer = OpenTelemetryLayer; pub type OtlpMetricsLayer = MetricsLayer; @@ -55,12 +57,25 @@ impl OtlpConfig { } } -pub fn init_otlp_tracing(config: &OtlpConfig) -> OtlpResult<()> { - let resource = Resource::new(vec![ +/// Create an OpenTelemetry Resource with service info and client fingerprint. +/// +/// Includes: +/// - service.name, service.version, service.namespace (standard OTLP attributes) +/// - client.id (hashed, privacy-safe unique identifier) +/// - os.type (macos, linux, windows) +/// - host.arch (x86_64, aarch64) +fn create_resource() -> Resource { + let mut attributes = vec![ KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose"), - ]); + ]; + attributes.extend(create_fingerprint_attributes()); + Resource::new(attributes) +} + +pub fn init_otlp_tracing(config: &OtlpConfig) -> OtlpResult<()> { + let resource = create_resource(); let exporter = opentelemetry_otlp::SpanExporter::builder() .with_http() @@ -81,11 +96,7 @@ pub fn init_otlp_tracing(config: &OtlpConfig) -> OtlpResult<()> { } pub fn init_otlp_metrics(config: &OtlpConfig) -> OtlpResult<()> { - let resource = Resource::new(vec![ - KeyValue::new("service.name", "goose"), - KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), - KeyValue::new("service.namespace", "goose"), - ]); + let resource = create_resource(); let exporter = opentelemetry_otlp::MetricExporter::builder() .with_http() @@ -109,12 +120,7 @@ pub fn init_otlp_metrics(config: &OtlpConfig) -> OtlpResult<()> { pub fn create_otlp_tracing_layer() -> OtlpResult { let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?; - - let resource = Resource::new(vec![ - KeyValue::new("service.name", "goose"), - KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), - KeyValue::new("service.namespace", "goose"), - ]); + let resource = create_resource(); let exporter = opentelemetry_otlp::SpanExporter::builder() .with_http() @@ -138,12 +144,7 @@ pub fn create_otlp_tracing_layer() -> OtlpResult { pub fn create_otlp_metrics_layer() -> OtlpResult { let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?; - - let resource = Resource::new(vec![ - KeyValue::new("service.name", "goose"), - KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), - KeyValue::new("service.namespace", "goose"), - ]); + let resource = create_resource(); let exporter = opentelemetry_otlp::MetricExporter::builder() .with_http() @@ -167,12 +168,7 @@ pub fn create_otlp_metrics_layer() -> OtlpResult { pub fn create_otlp_logs_layer() -> OtlpResult> { let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?; - - let resource = Resource::new(vec![ - KeyValue::new("service.name", "goose"), - KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), - KeyValue::new("service.namespace", "goose"), - ]); + let resource = create_resource(); let exporter = opentelemetry_otlp::LogExporter::builder() .with_http()