diff --git a/Cargo.lock b/Cargo.lock index 2aeba04..577f7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,6 +767,7 @@ dependencies = [ "dirs", "fuser", "futures", + "http", "inquire", "libc", "mesa-dev", @@ -1533,7 +1534,6 @@ dependencies = [ "prost", "reqwest", "thiserror 2.0.18", - "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25329d0..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" @@ -42,15 +43,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", default-features = false, features = ["http-proto", "trace", "reqwest-blocking-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 = [] } diff --git a/src/app_config.rs b/src/app_config.rs index 36418ef..573e890 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -333,6 +333,35 @@ impl Default for DaemonConfig { } } +/// The Mesa telemetry endpoint. +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)] +#[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] + 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 +379,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 +401,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 +421,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 +435,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(), @@ -459,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/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/main.rs b/src/main.rs index 5d16f85..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,93 +48,128 @@ 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 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 — 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); } 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..."); - // 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") - }); - 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); + } } } 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 }; diff --git a/src/trc.rs b/src/trc.rs index a504362..553fee5 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 { @@ -103,30 +102,76 @@ 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 { - // 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, - }, - 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"), - }, + 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(), + } + } } } } impl Trc { + /// Configure OTLP telemetry endpoints from the application config. + #[must_use] + 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() + .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() + .with_endpoint(endpoint) + .build() + { + Ok(exporter) => { + builder = builder.with_batch_exporter(exporter); + has_exporter = true; + } + Err(e) => { + eprintln!("Failed to create OTLP exporter for {endpoint}: {e}"); + } + } + } + + has_exporter.then(|| 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 +179,16 @@ 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(); + if provider.is_some() { + opentelemetry::global::set_text_map_propagator( + opentelemetry_sdk::propagation::TraceContextPropagator::new(), + ); + } 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 +198,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, }) } }