From a15af204a92edf4c78731c521a476cb4b7ddd342 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 15:41:12 -0800 Subject: [PATCH 01/10] feat(telemetry): make OTLP dependencies always included --- Cargo.toml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 25329d0..fb72275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,15 +42,14 @@ semver = "1.0" shellexpand = "3.1" inquire = "0.9.2" tracing-indicatif = "0.3.14" -opentelemetry = { version = "0.29", optional = true } -opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"], optional = true } -opentelemetry-otlp = { version = "0.29", features = ["http-proto", "trace", "reqwest-client"], optional = true } -tracing-opentelemetry = { version = "0.30", optional = true } +opentelemetry = { version = "0.29" } +opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.29", features = ["http-proto", "trace", "reqwest-client"] } +tracing-opentelemetry = { version = "0.30" } [features] default = [] staging = [] -__otlp_export = ["opentelemetry", "opentelemetry_sdk", "opentelemetry-otlp", "tracing-opentelemetry"] [build-dependencies] vergen-gitcl = { version = "1", features = [] } From 75efa5929dcf5e2339d1d8de6682d1a44d7f6c7d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 15:47:16 -0800 Subject: [PATCH 02/10] feat(telemetry): add TelemetryConfig to configuration --- src/app_config.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/app_config.rs b/src/app_config.rs index 36418ef..b4d540c 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -333,6 +333,39 @@ impl Default for DaemonConfig { } } +/// The Mesa telemetry endpoint. +#[allow(clippy::allow_attributes)] +#[allow(dead_code)] +const MESA_TELEMETRY_ENDPOINT: &str = "https://telemetry.priv.mesa.dev"; + +/// Telemetry configuration for exporting OpenTelemetry traces. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default)] +pub struct TelemetryConfig { + /// Whether to send telemetry data to Mesa's servers. + pub vendor: bool, + + /// Custom collector URL for forwarding telemetry to the user's own servers. + pub collector_url: Option, +} + +impl TelemetryConfig { + /// Returns the list of OTLP endpoints to export traces to. + #[must_use] + #[allow(clippy::allow_attributes)] + #[allow(dead_code)] + pub fn endpoints(&self) -> Vec { + let mut endpoints = Vec::new(); + if self.vendor { + endpoints.push(MESA_TELEMETRY_ENDPOINT.to_owned()); + } + if let Some(ref url) = self.collector_url { + endpoints.push(url.clone()); + } + endpoints + } +} + /// Application configuration structure. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -350,6 +383,9 @@ pub struct Config { #[serde(default)] pub daemon: DaemonConfig, + #[serde(default)] + pub telemetry: TelemetryConfig, + /// The mount point for the filesystem. #[serde(default = "default_mount_point")] pub mount_point: ExpandedPathBuf, @@ -369,6 +405,7 @@ struct DangerousConfig<'a> { pub organizations: HashMap<&'a str, DangerousOrganizationConfig<'a>>, pub cache: &'a CacheConfig, pub daemon: &'a DaemonConfig, + pub telemetry: &'a TelemetryConfig, pub mount_point: &'a Path, pub uid: u32, pub gid: u32, @@ -388,6 +425,7 @@ impl<'a> From<&'a Config> for DangerousConfig<'a> { .collect(), cache: &config.cache, daemon: &config.daemon, + telemetry: &config.telemetry, mount_point: &config.mount_point, uid: config.uid, gid: config.gid, @@ -401,6 +439,7 @@ impl Default for Config { organizations: default_organizations(), cache: CacheConfig::default(), daemon: DaemonConfig::default(), + telemetry: TelemetryConfig::default(), mount_point: default_mount_point(), uid: current_uid(), gid: current_gid(), @@ -542,3 +581,90 @@ impl Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn telemetry_config_defaults_to_disabled() { + let config: Config = toml::from_str("").unwrap(); + assert!(!config.telemetry.vendor); + assert!(config.telemetry.collector_url.is_none()); + } + + #[test] + fn telemetry_config_parses_vendor_only() { + let config: Config = toml::from_str( + r" + [telemetry] + vendor = true + ", + ) + .unwrap(); + assert!(config.telemetry.vendor); + assert!(config.telemetry.collector_url.is_none()); + } + + #[test] + fn telemetry_config_parses_collector_url_only() { + let config: Config = toml::from_str( + r#" + [telemetry] + collector-url = "https://my-collector.example.com" + "#, + ) + .unwrap(); + assert!(!config.telemetry.vendor); + assert_eq!( + config.telemetry.collector_url.as_deref(), + Some("https://my-collector.example.com") + ); + } + + #[test] + fn telemetry_config_parses_both() { + let config: Config = toml::from_str( + r#" + [telemetry] + vendor = true + collector-url = "https://my-collector.example.com" + "#, + ) + .unwrap(); + assert!(config.telemetry.vendor); + assert_eq!( + config.telemetry.collector_url.as_deref(), + Some("https://my-collector.example.com") + ); + } + + #[test] + fn telemetry_endpoints_empty_when_disabled() { + let config = TelemetryConfig::default(); + assert!(config.endpoints().is_empty()); + } + + #[test] + fn telemetry_endpoints_includes_vendor() { + let config = TelemetryConfig { + vendor: true, + collector_url: None, + }; + let endpoints = config.endpoints(); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0], MESA_TELEMETRY_ENDPOINT); + } + + #[test] + fn telemetry_endpoints_includes_both() { + let config = TelemetryConfig { + vendor: true, + collector_url: Some("https://custom.example.com".to_owned()), + }; + let endpoints = config.endpoints(); + assert_eq!(endpoints.len(), 2); + assert!(endpoints.contains(&MESA_TELEMETRY_ENDPOINT.to_owned())); + assert!(endpoints.contains(&"https://custom.example.com".to_owned())); + } +} From 1f02dc916ad2cb77819228a68b5be3d1c8cc726d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 15:52:16 -0800 Subject: [PATCH 03/10] feat(telemetry): refactor Trc to build OTLP exporters from config --- src/app_config.rs | 6 +-- src/trc.rs | 119 ++++++++++++++++++++++++---------------------- 2 files changed, 63 insertions(+), 62 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index b4d540c..dfe0cec 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -334,9 +334,7 @@ impl Default for DaemonConfig { } /// The Mesa telemetry endpoint. -#[allow(clippy::allow_attributes)] -#[allow(dead_code)] -const MESA_TELEMETRY_ENDPOINT: &str = "https://telemetry.priv.mesa.dev"; +pub const MESA_TELEMETRY_ENDPOINT: &str = "https://telemetry.priv.mesa.dev"; /// Telemetry configuration for exporting OpenTelemetry traces. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -352,8 +350,6 @@ pub struct TelemetryConfig { impl TelemetryConfig { /// Returns the list of OTLP endpoints to export traces to. #[must_use] - #[allow(clippy::allow_attributes)] - #[allow(dead_code)] pub fn endpoints(&self) -> Vec { let mut endpoints = Vec::new(); if self.vendor { diff --git a/src/trc.rs b/src/trc.rs index a504362..3a00397 100644 --- a/src/trc.rs +++ b/src/trc.rs @@ -3,9 +3,8 @@ //! The tracing subscriber is built with a [`reload::Layer`] wrapping the fmt layer so that the //! output format can be switched at runtime (e.g. from pretty mode to ugly mode when daemonizing). -#[cfg(feature = "__otlp_export")] use opentelemetry::trace::TracerProvider as _; -#[cfg(feature = "__otlp_export")] +use opentelemetry_otlp::WithExportConfig as _; use opentelemetry_sdk::Resource; use tracing_indicatif::IndicatifLayer; use tracing_subscriber::{ @@ -16,6 +15,7 @@ use tracing_subscriber::{ util::{SubscriberInitExt as _, TryInitError}, }; +use crate::app_config::TelemetryConfig; use crate::term; /// The type-erased fmt layer that lives inside the reload handle. @@ -43,11 +43,9 @@ impl TrcMode { /// A handle that allows reconfiguring the tracing subscriber at runtime. pub struct TrcHandle { fmt_handle: FmtReloadHandle, - #[cfg(feature = "__otlp_export")] tracer_provider: Option, } -#[cfg(feature = "__otlp_export")] impl Drop for TrcHandle { fn drop(&mut self) { if let Some(provider) = self.tracer_provider.take() @@ -94,6 +92,7 @@ impl TrcHandle { pub struct Trc { mode: TrcMode, env_filter: EnvFilter, + otlp_endpoints: Vec, } impl Default for Trc { @@ -104,29 +103,65 @@ impl Default for Trc { match maybe_env_filter { Ok(env_filter) => Self { - // If the user provided an env_filter, they probably know what they're doing and - // don't want any fancy formatting, spinners or bullshit like that. So we default - // to the ugly mode. mode: TrcMode::Ugly { use_ansi }, env_filter, + otlp_endpoints: Vec::new(), }, Err(_) => Self { - // If the user didn't provide an env_filter, we assume they just want a nice - // out-of-the-box experience, and default to 丑 mode with an info level filter. mode: TrcMode::丑 { use_ansi }, env_filter: EnvFilter::new("info"), + otlp_endpoints: Vec::new(), }, } } } impl Trc { + /// Configure OTLP telemetry endpoints from the application config. + #[must_use] + #[expect( + dead_code, + reason = "used once main.rs wires telemetry config into Trc" + )] + pub fn with_telemetry(mut self, telemetry: &TelemetryConfig) -> Self { + self.otlp_endpoints = telemetry.endpoints(); + self + } + + /// Build the OpenTelemetry tracer provider if any OTLP endpoints are configured. + fn build_otel_provider(&self) -> Option { + if self.otlp_endpoints.is_empty() { + return None; + } + + let resource = Resource::builder_empty() + .with_service_name("git-fs") + .build(); + let mut builder = + opentelemetry_sdk::trace::SdkTracerProvider::builder().with_resource(resource); + + for endpoint in &self.otlp_endpoints { + match opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_endpoint(endpoint) + .build() + { + Ok(exporter) => { + builder = builder.with_batch_exporter(exporter); + } + Err(e) => { + eprintln!("Failed to create OTLP exporter for {endpoint}: {e}"); + } + } + } + + Some(builder.build()) + } + /// Initialize the global tracing subscriber and return a handle for runtime reconfiguration. pub fn init(self) -> Result { let use_ansi = self.mode.use_ansi(); - // Start with a plain ugly-mode layer as a placeholder. In 丑 mode this gets swapped - // out before `try_init` is called so the subscriber never actually uses it. let initial_layer: BoxedFmtLayer = Box::new( tracing_subscriber::fmt::layer() .with_ansi(use_ansi) @@ -134,12 +169,11 @@ impl Trc { ); let (reload_layer, fmt_handle) = reload::Layer::new(initial_layer); - #[cfg(feature = "__otlp_export")] - let mut tracer_provider = None; + let provider = self.build_otel_provider(); match self.mode { TrcMode::丑 { .. } => { - let indicatif_layer = IndicatifLayer::new(); + let indicatif_layer = IndicatifLayer::new().with_max_progress_bars(24, None); let pretty_with_indicatif: BoxedFmtLayer = Box::new( tracing_subscriber::fmt::layer() .with_ansi(use_ansi) @@ -149,66 +183,37 @@ impl Trc { .compact(), ); - // Replace the initial placeholder with the correct writer before init. if let Err(e) = fmt_handle.reload(pretty_with_indicatif) { eprintln!("Failed to configure 丑-mode writer: {e}"); } + let otel_layer = provider + .as_ref() + .map(|p| tracing_opentelemetry::layer().with_tracer(p.tracer("git-fs"))); + tracing_subscriber::registry() .with(reload_layer) + .with(otel_layer) .with(self.env_filter) .with(indicatif_layer) .try_init()?; } TrcMode::Ugly { .. } => { - #[cfg(feature = "__otlp_export")] - { - let exporter = opentelemetry_otlp::SpanExporter::builder() - .with_http() - .build() - .ok(); - - if let Some(exporter) = exporter { - let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() - .with_batch_exporter(exporter) - .with_resource( - Resource::builder_empty() - .with_service_name("git-fs") - .build(), - ) - .build(); - let tracer = provider.tracer("git-fs"); - let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); - - tracing_subscriber::registry() - .with(reload_layer) - .with(otel_layer) - .with(self.env_filter) - .try_init()?; - - tracer_provider = Some(provider); - } else { - tracing_subscriber::registry() - .with(reload_layer) - .with(self.env_filter) - .try_init()?; - } - } + let otel_layer = provider + .as_ref() + .map(|p| tracing_opentelemetry::layer().with_tracer(p.tracer("git-fs"))); - #[cfg(not(feature = "__otlp_export"))] - { - tracing_subscriber::registry() - .with(reload_layer) - .with(self.env_filter) - .try_init()?; - } + tracing_subscriber::registry() + .with(reload_layer) + .with(otel_layer) + .with(self.env_filter) + .try_init()?; } } Ok(TrcHandle { fmt_handle, - #[cfg(feature = "__otlp_export")] - tracer_provider, + tracer_provider: provider, }) } } From 646b596adff77dec0e6c8bc97c116bbd9b0605b8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 15:54:21 -0800 Subject: [PATCH 04/10] feat(telemetry): load config before tracing init for telemetry support --- src/main.rs | 33 +++++++++++++++++++-------------- src/trc.rs | 4 ---- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5d16f85..bcd1c6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,28 +50,35 @@ enum Command { /// Main entry point for the application. fn main() { - let trc_handle = Trc::default().init().unwrap_or_else(|e| { - eprintln!( - "Failed to initialize logging. Without logging, we can't provide any useful error \ - messages, so we have to exit: {e}" - ); - std::process::exit(1); - }); - let args = Args::parse(); + + // Load config first so telemetry settings are available for tracing init. + // Errors use eprintln since tracing isn't initialized yet. let config = Config::load_or_create(args.config_path.as_deref()).unwrap_or_else(|e| { - error!("Failed to load configuration: {e}"); + eprintln!("Failed to load configuration: {e}"); std::process::exit(1); }); if let Err(error_messages) = config.validate() { - error!("Configuration is invalid."); + eprintln!("Configuration is invalid."); for msg in &error_messages { - error!(" - {msg}"); + eprintln!(" - {msg}"); } - std::process::exit(1); } + let trc_handle = Trc::default() + .with_telemetry(&config.telemetry) + .init() + .unwrap_or_else(|e| { + eprintln!( + "Failed to initialize logging. Without logging, we can't provide any useful error \ + messages, so we have to exit: {e}" + ); + std::process::exit(1); + }); + + updates::check_for_updates(); + match args.command.unwrap_or(Command::Run { daemonize: false }) { Command::Run { daemonize } => { if let Err(e) = fuse_check::ensure_fuse() { @@ -81,8 +88,6 @@ fn main() { if daemonize { debug!(config = ?config, "Initializing daemon with configuration..."); - // It is safe to unwrap this Config.validate() guarantees that pid_file's parent - // exists. // Safe: Config.validate() guarantees pid_file's parent exists. let pid_file_parent = config.daemon.pid_file.parent().unwrap_or_else(|| { unreachable!("Config.validate() ensures pid_file has a parent") diff --git a/src/trc.rs b/src/trc.rs index 3a00397..8ac891b 100644 --- a/src/trc.rs +++ b/src/trc.rs @@ -119,10 +119,6 @@ impl Default for Trc { impl Trc { /// Configure OTLP telemetry endpoints from the application config. #[must_use] - #[expect( - dead_code, - reason = "used once main.rs wires telemetry config into Trc" - )] pub fn with_telemetry(mut self, telemetry: &TelemetryConfig) -> Self { self.otlp_endpoints = telemetry.endpoints(); self From c78880117df1dd661b7a87a8d1e2f984e12e650d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 15:55:54 -0800 Subject: [PATCH 05/10] feat(telemetry): add vendor telemetry opt-in to onboarding wizard --- src/onboarding.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/onboarding.rs b/src/onboarding.rs index f7db58e..88b6cb8 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -7,7 +7,7 @@ use inquire::{Confirm, Password, Text, validator::Validation}; use secrecy::SecretString; use crate::{ - app_config::{Config, ExpandedPathBuf, OrganizationConfig}, + app_config::{Config, ExpandedPathBuf, OrganizationConfig, TelemetryConfig}, term::should_use_color, }; @@ -116,9 +116,22 @@ pub fn run_wizard() -> Result { org_keys.push((org_name, SecretString::from(api_key))); } + let enable_vendor_telemetry = Confirm::new( + "Would you like to share anonymous usage data with Mesa to help improve git-fs?", + ) + .with_default(true) + .with_help_message( + "This sends performance telemetry to Mesa's servers. No file contents are shared.", + ) + .prompt()?; + // Build config let mut config = Config { mount_point, + telemetry: TelemetryConfig { + vendor: enable_vendor_telemetry, + collector_url: None, + }, ..defaults }; From 491ab6d8210082a1bba82a62e7b039a33aa0a48c Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 16:02:03 -0800 Subject: [PATCH 06/10] fix(telemetry): address review feedback --- src/app_config.rs | 9 +++++++++ src/main.rs | 4 +++- src/trc.rs | 36 +++++++++++++++++++++++++----------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index dfe0cec..d7dd7e6 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -494,6 +494,15 @@ impl Config { } } + if let Some(ref url) = self.telemetry.collector_url + && !url.starts_with("http://") + && !url.starts_with("https://") + { + errors.push(format!( + "Telemetry collector URL '{url}' must start with http:// or https://." + )); + } + if errors.is_empty() { Ok(()) } else { diff --git a/src/main.rs b/src/main.rs index bcd1c6d..95df203 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,7 +77,9 @@ fn main() { std::process::exit(1); }); - updates::check_for_updates(); + if !config.telemetry.endpoints().is_empty() { + tracing::info!(endpoints = ?config.telemetry.endpoints(), "Telemetry export enabled."); + } match args.command.unwrap_or(Command::Run { daemonize: false }) { Command::Run { daemonize } => { diff --git a/src/trc.rs b/src/trc.rs index 8ac891b..18f018b 100644 --- a/src/trc.rs +++ b/src/trc.rs @@ -102,16 +102,24 @@ impl Default for Trc { EnvFilter::try_from_env("GIT_FS_LOG").or_else(|_| EnvFilter::try_from_default_env()); match maybe_env_filter { - Ok(env_filter) => Self { - mode: TrcMode::Ugly { use_ansi }, - env_filter, - otlp_endpoints: Vec::new(), - }, - Err(_) => Self { - mode: TrcMode::丑 { use_ansi }, - env_filter: EnvFilter::new("info"), - otlp_endpoints: Vec::new(), - }, + Ok(env_filter) => { + // If the user provided an env filter, they probably know what they're doing + // and don't want any fancy formatting or spinners. Default to ugly mode. + Self { + mode: TrcMode::Ugly { use_ansi }, + env_filter, + otlp_endpoints: Vec::new(), + } + } + Err(_) => { + // No env filter provided — give the user a nice out-of-the-box experience + // with compact formatting and progress spinners. + Self { + mode: TrcMode::丑 { use_ansi }, + env_filter: EnvFilter::new("info"), + otlp_endpoints: Vec::new(), + } + } } } } @@ -132,10 +140,15 @@ impl Trc { let resource = Resource::builder_empty() .with_service_name("git-fs") + .with_attribute(opentelemetry::KeyValue::new( + "service.version", + env!("CARGO_PKG_VERSION"), + )) .build(); let mut builder = opentelemetry_sdk::trace::SdkTracerProvider::builder().with_resource(resource); + let mut has_exporter = false; for endpoint in &self.otlp_endpoints { match opentelemetry_otlp::SpanExporter::builder() .with_http() @@ -144,6 +157,7 @@ impl Trc { { Ok(exporter) => { builder = builder.with_batch_exporter(exporter); + has_exporter = true; } Err(e) => { eprintln!("Failed to create OTLP exporter for {endpoint}: {e}"); @@ -151,7 +165,7 @@ impl Trc { } } - Some(builder.build()) + has_exporter.then(|| builder.build()) } /// Initialize the global tracing subscriber and return a handle for runtime reconfiguration. From 9d67e7eb8e56129628347449971ff8cf7409bd79 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 18:47:09 -0800 Subject: [PATCH 07/10] deslop --- src/app_config.rs | 87 ----------------------------------------------- 1 file changed, 87 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index d7dd7e6..21c16e9 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -586,90 +586,3 @@ impl Config { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn telemetry_config_defaults_to_disabled() { - let config: Config = toml::from_str("").unwrap(); - assert!(!config.telemetry.vendor); - assert!(config.telemetry.collector_url.is_none()); - } - - #[test] - fn telemetry_config_parses_vendor_only() { - let config: Config = toml::from_str( - r" - [telemetry] - vendor = true - ", - ) - .unwrap(); - assert!(config.telemetry.vendor); - assert!(config.telemetry.collector_url.is_none()); - } - - #[test] - fn telemetry_config_parses_collector_url_only() { - let config: Config = toml::from_str( - r#" - [telemetry] - collector-url = "https://my-collector.example.com" - "#, - ) - .unwrap(); - assert!(!config.telemetry.vendor); - assert_eq!( - config.telemetry.collector_url.as_deref(), - Some("https://my-collector.example.com") - ); - } - - #[test] - fn telemetry_config_parses_both() { - let config: Config = toml::from_str( - r#" - [telemetry] - vendor = true - collector-url = "https://my-collector.example.com" - "#, - ) - .unwrap(); - assert!(config.telemetry.vendor); - assert_eq!( - config.telemetry.collector_url.as_deref(), - Some("https://my-collector.example.com") - ); - } - - #[test] - fn telemetry_endpoints_empty_when_disabled() { - let config = TelemetryConfig::default(); - assert!(config.endpoints().is_empty()); - } - - #[test] - fn telemetry_endpoints_includes_vendor() { - let config = TelemetryConfig { - vendor: true, - collector_url: None, - }; - let endpoints = config.endpoints(); - assert_eq!(endpoints.len(), 1); - assert_eq!(endpoints[0], MESA_TELEMETRY_ENDPOINT); - } - - #[test] - fn telemetry_endpoints_includes_both() { - let config = TelemetryConfig { - vendor: true, - collector_url: Some("https://custom.example.com".to_owned()), - }; - let endpoints = config.endpoints(); - assert_eq!(endpoints.len(), 2); - assert!(endpoints.contains(&MESA_TELEMETRY_ENDPOINT.to_owned())); - assert!(endpoints.contains(&"https://custom.example.com".to_owned())); - } -} From 28a3f9b67e498e8a8af6ee43a5aa5d11a0492d25 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 20:39:57 -0800 Subject: [PATCH 08/10] fix tracing --- Cargo.lock | 1 - Cargo.toml | 2 +- src/app_config.rs | 2 +- src/trc.rs | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2aeba04..662fca8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1533,7 +1533,6 @@ dependencies = [ "prost", "reqwest", "thiserror 2.0.18", - "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fb72275..72699ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ inquire = "0.9.2" tracing-indicatif = "0.3.14" opentelemetry = { version = "0.29" } opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] } -opentelemetry-otlp = { version = "0.29", features = ["http-proto", "trace", "reqwest-client"] } +opentelemetry-otlp = { version = "0.29", default-features = false, features = ["http-proto", "trace", "reqwest-blocking-client"] } tracing-opentelemetry = { version = "0.30" } [features] diff --git a/src/app_config.rs b/src/app_config.rs index 21c16e9..573e890 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -334,7 +334,7 @@ impl Default for DaemonConfig { } /// The Mesa telemetry endpoint. -pub const MESA_TELEMETRY_ENDPOINT: &str = "https://telemetry.priv.mesa.dev"; +pub const MESA_TELEMETRY_ENDPOINT: &str = "https://telemetry.priv.mesa.dev/v1/traces"; /// Telemetry configuration for exporting OpenTelemetry traces. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/trc.rs b/src/trc.rs index 18f018b..ae7c16c 100644 --- a/src/trc.rs +++ b/src/trc.rs @@ -138,7 +138,7 @@ impl Trc { return None; } - let resource = Resource::builder_empty() + let resource = Resource::builder() .with_service_name("git-fs") .with_attribute(opentelemetry::KeyValue::new( "service.version", From a7edfea8c5b0f600394288bf26c1c4e805928d53 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 11 Feb 2026 21:30:53 -0800 Subject: [PATCH 09/10] Trace mesa requests --- Cargo.lock | 1 + Cargo.toml | 1 + src/fs/mescloud/mod.rs | 56 +++++++++++++++++++++++++++++++++++++++--- src/trc.rs | 5 ++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 662fca8..577f7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,6 +767,7 @@ dependencies = [ "dirs", "fuser", "futures", + "http", "inquire", "libc", "mesa-dev", diff --git a/Cargo.toml b/Cargo.toml index 72699ae..9256d6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ fuser = { version = "0.16.0", features = ["libfuse"] } libc = "0.2" mesa-dev = "1.11.0" num-traits = "0.2" +http = "1" reqwest = { version = "0.12", default-features = false } reqwest-middleware = "0.4" serde_path_to_error = "0.1" diff --git a/src/fs/mescloud/mod.rs b/src/fs/mescloud/mod.rs index 0e32933..a3ce17d 100644 --- a/src/fs/mescloud/mod.rs +++ b/src/fs/mescloud/mod.rs @@ -5,8 +5,10 @@ use std::time::SystemTime; use bytes::Bytes; use mesa_dev::MesaClient; +use opentelemetry::propagation::Injector; use secrecy::ExposeSecret as _; use tracing::{Instrument as _, instrument, trace, warn}; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; use crate::fs::icache::bridge::HashMapBridge; use crate::fs::icache::{AsyncICache, FileTable, IcbResolver}; @@ -37,6 +39,55 @@ use org::OrgFs; pub mod icache; pub mod repo; +struct HeaderInjector<'a>(&'a mut reqwest::header::HeaderMap); + +impl Injector for HeaderInjector<'_> { + fn set(&mut self, key: &str, value: String) { + if let (Ok(name), Ok(val)) = ( + reqwest::header::HeaderName::from_bytes(key.as_bytes()), + reqwest::header::HeaderValue::from_str(&value), + ) { + self.0.insert(name, val); + } + } +} + +/// Middleware that injects W3C `traceparent`/`tracestate` headers from the +/// current `tracing` span into every outgoing HTTP request. +struct OtelPropagationMiddleware; + +#[async_trait::async_trait] +impl reqwest_middleware::Middleware for OtelPropagationMiddleware { + async fn handle( + &self, + mut req: reqwest::Request, + extensions: &mut http::Extensions, + next: reqwest_middleware::Next<'_>, + ) -> reqwest_middleware::Result { + let cx = tracing::Span::current().context(); + opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut HeaderInjector(req.headers_mut())); + }); + tracing::debug!( + traceparent = req.headers().get("traceparent").and_then(|v| v.to_str().ok()), + url = %req.url(), + "outgoing request" + ); + next.run(req, extensions).await + } +} + +fn build_mesa_client(api_key: &str) -> MesaClient { + let client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new()) + .with(OtelPropagationMiddleware) + .build(); + MesaClient::builder() + .with_api_key(api_key) + .with_base_path(MESA_API_BASE_URL) + .with_client(client) + .build() +} + struct MesaResolver { fs_owner: (u32, u32), block_size: u32, @@ -122,10 +173,7 @@ impl MesaFS { inode_to_slot: HashMap::new(), slots: orgs .map(|org_conf| { - let client = MesaClient::builder() - .with_api_key(org_conf.api_key.expose_secret()) - .with_base_path(MESA_API_BASE_URL) - .build(); + let client = build_mesa_client(org_conf.api_key.expose_secret()); let org = OrgFs::new(org_conf.name, client, fs_owner); ChildSlot { inner: org, diff --git a/src/trc.rs b/src/trc.rs index ae7c16c..553fee5 100644 --- a/src/trc.rs +++ b/src/trc.rs @@ -180,6 +180,11 @@ impl Trc { let (reload_layer, fmt_handle) = reload::Layer::new(initial_layer); let provider = self.build_otel_provider(); + if provider.is_some() { + opentelemetry::global::set_text_map_propagator( + opentelemetry_sdk::propagation::TraceContextPropagator::new(), + ); + } match self.mode { TrcMode::丑 { .. } => { From df14dbf72ab1612dfc5fc88fe0dc07a425212ec8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 12 Feb 2026 10:13:09 -0800 Subject: [PATCH 10/10] fix: defer tracing init to after daemon fork for OTLP thread safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTLP BatchSpanProcessor threads created before daemonize::Daemonize::start() do not survive the fork — the child process only retains the main thread. Move tracing initialization (including OTLP exporter setup) to after the fork so exporter threads are created in the daemon child process. --- src/main.rs | 162 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/src/main.rs b/src/main.rs index 95df203..3fbe973 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; -use tracing::{debug, error}; +use tracing::error; mod app_config; mod daemon; @@ -14,7 +14,7 @@ mod trc; mod updates; use crate::app_config::Config; -use crate::trc::Trc; +use crate::trc::{Trc, TrcHandle}; #[derive(Parser)] #[command( @@ -48,12 +48,35 @@ enum Command { Reload, } +/// Initialize tracing with telemetry support. Exits the process on failure. +#[expect( + clippy::exit, + reason = "top-level helper that intentionally terminates the process" +)] +fn init_tracing(config: &Config) -> TrcHandle { + let handle = Trc::default() + .with_telemetry(&config.telemetry) + .init() + .unwrap_or_else(|e| { + eprintln!( + "Failed to initialize logging. Without logging, we can't provide any useful error \ + messages, so we have to exit: {e}" + ); + std::process::exit(1); + }); + + if !config.telemetry.endpoints().is_empty() { + tracing::info!(endpoints = ?config.telemetry.endpoints(), "Telemetry export enabled."); + } + + handle +} + /// Main entry point for the application. fn main() { let args = Args::parse(); - // Load config first so telemetry settings are available for tracing init. - // Errors use eprintln since tracing isn't initialized yet. + // Load config first — errors use eprintln since tracing isn't initialized yet. let config = Config::load_or_create(args.config_path.as_deref()).unwrap_or_else(|e| { eprintln!("Failed to load configuration: {e}"); std::process::exit(1); @@ -66,82 +89,87 @@ fn main() { std::process::exit(1); } - let trc_handle = Trc::default() - .with_telemetry(&config.telemetry) - .init() - .unwrap_or_else(|e| { - eprintln!( - "Failed to initialize logging. Without logging, we can't provide any useful error \ - messages, so we have to exit: {e}" - ); - std::process::exit(1); - }); - - if !config.telemetry.endpoints().is_empty() { - tracing::info!(endpoints = ?config.telemetry.endpoints(), "Telemetry export enabled."); - } - match args.command.unwrap_or(Command::Run { daemonize: false }) { Command::Run { daemonize } => { if let Err(e) = fuse_check::ensure_fuse() { - error!("{e}"); + eprintln!("{e}"); std::process::exit(1); } if daemonize { - debug!(config = ?config, "Initializing daemon with configuration..."); - // Safe: Config.validate() guarantees pid_file's parent exists. - let pid_file_parent = config.daemon.pid_file.parent().unwrap_or_else(|| { - unreachable!("Config.validate() ensures pid_file has a parent") - }); - if let Err(e) = std::fs::create_dir_all(pid_file_parent) { - error!("Failed to create PID file directory: {e}"); - return; + run_daemonized(config); + } else { + let _trc_handle = init_tracing(&config); + if let Err(e) = daemon::spawn(config) { + error!("Daemon failed: {e}"); + std::process::exit(1); } + } + } + Command::Reload => {} + } +} - let log_file = match config.daemon.log.target.open_log_file() { - Ok(f) => f, - Err(e) => { - error!("Failed to open log file: {e}"); - return; - } - }; - - let mut daemonize = daemonize::Daemonize::new() - .pid_file(&config.daemon.pid_file) - .chown_pid_file(true) - .user(config.uid) - .group(config.gid); - - if let Some(file) = log_file { - match file.try_clone() { - Ok(clone) => { - daemonize = daemonize.stdout(file).stderr(clone); - } - Err(e) => { - error!("Failed to clone log file handle: {e}"); - return; - } - } - } +/// Run the daemon in the background. Tracing (including OTLP batch exporter +/// threads) is initialized *after* the fork so the exporter threads are +/// created in the child process and survive daemonization. +#[expect( + clippy::exit, + reason = "top-level helper that intentionally terminates the process" +)] +fn run_daemonized(config: Config) { + // Pre-fork: no tracing yet — OTLP BatchSpanProcessor threads would not + // survive the fork. Use eprintln! for error reporting. + let pid_file_parent = config + .daemon + .pid_file + .parent() + .unwrap_or_else(|| unreachable!("Config.validate() ensures pid_file has a parent")); + if let Err(e) = std::fs::create_dir_all(pid_file_parent) { + eprintln!("Failed to create PID file directory: {e}"); + std::process::exit(1); + } - match daemonize.start() { - Ok(()) => { - trc_handle.reconfigure_for_daemon(config.daemon.log.should_use_color()); - if let Err(e) = daemon::spawn(config) { - error!("Daemon failed: {e}"); - std::process::exit(1); - } - } - Err(e) => { - error!("Failed to spawn the daemon: {e}"); - } - } - } else if let Err(e) = daemon::spawn(config) { + let log_file = match config.daemon.log.target.open_log_file() { + Ok(f) => f, + Err(e) => { + eprintln!("Failed to open log file: {e}"); + std::process::exit(1); + } + }; + + let mut daemonize = daemonize::Daemonize::new() + .pid_file(&config.daemon.pid_file) + .chown_pid_file(true) + .user(config.uid) + .group(config.gid); + + if let Some(file) = log_file { + match file.try_clone() { + Ok(clone) => { + daemonize = daemonize.stdout(file).stderr(clone); + } + Err(e) => { + eprintln!("Failed to clone log file handle: {e}"); + std::process::exit(1); + } + } + } + + match daemonize.start() { + Ok(()) => { + // Post-fork: safe to start OTLP batch exporter threads. + let trc_handle = init_tracing(&config); + trc_handle.reconfigure_for_daemon(config.daemon.log.should_use_color()); + + if let Err(e) = daemon::spawn(config) { error!("Daemon failed: {e}"); std::process::exit(1); } } - Command::Reload => {} + Err(e) => { + eprintln!("Failed to spawn the daemon: {e}"); + std::process::exit(1); + } } }