vessel-pty 0.17.5

PTY-based runtime for orchestrating interactive terminal processes over Unix sockets
Documentation
//! Telemetry initialization.
//!
//! Controlled by `OTEL_EXPORTER_OTLP_ENDPOINT` (the standard OTLP env var):
//! - unset → no-op (tracing disabled, zero overhead)
//! - `"stderr"` → JSON spans/events to stderr (non-standard extension)
//! - `"http://..."` → OTLP HTTP export (traces + logs) to the given endpoint
//!
//! ## Distributed tracing
//!
//! If `TRACEPARENT` is set (W3C Trace Context format), spans are created as
//! children of the remote parent. Use [`current_traceparent`] to extract the
//! current span context for propagation to child processes.

use tracing_subscriber::EnvFilter;

/// Opaque guard — dropping it flushes and shuts down the OTLP pipeline.
/// Hold this in `main()` until exit.
pub struct TelemetryGuard {
    #[cfg(feature = "otel")]
    trace_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
    #[cfg(feature = "otel")]
    log_provider: Option<opentelemetry_sdk::logs::SdkLoggerProvider>,
}

impl Drop for TelemetryGuard {
    fn drop(&mut self) {
        #[cfg(feature = "otel")]
        {
            if let Some(provider) = self.trace_provider.take()
                && let Err(e) = provider.shutdown()
            {
                eprintln!("otel trace shutdown error: {e}");
            }
            if let Some(provider) = self.log_provider.take()
                && let Err(e) = provider.shutdown()
            {
                eprintln!("otel log shutdown error: {e}");
            }
        }
    }
}

/// Initialize telemetry based on `OTEL_EXPORTER_OTLP_ENDPOINT`.
///
/// When `verbose` is true, the default filter level is `vessel=debug`;
/// otherwise `vessel=warn`. The `RUST_LOG` env var overrides both.
///
/// Returns a guard that must be held until the program exits.
/// Dropping the guard flushes any pending spans and logs.
#[must_use]
pub fn init(verbose: bool) -> TelemetryGuard {
    let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();

    match endpoint.as_deref() {
        None | Some("") => init_fmt(verbose),
        Some("stderr") => init_stderr(),
        #[cfg(feature = "otel")]
        Some(_) => init_otlp(verbose),
        #[cfg(not(feature = "otel"))]
        Some(_) => {
            eprintln!(
                "warning: OTEL_EXPORTER_OTLP_ENDPOINT set but vessel built without 'otel' feature"
            );
            init_fmt(verbose)
        }
    }
}

/// Default tracing: human-readable fmt to stderr (vessel's existing behavior).
///
/// If `VESSEL_LOG` is set to a file path, logs are also written there (appended).
/// The file log always uses `vessel=info` minimum so server lifecycle events
/// are captured even without `--verbose`.
fn init_fmt(verbose: bool) -> TelemetryGuard {
    let default = if verbose {
        "vessel=debug"
    } else {
        "vessel=warn"
    };
    let stderr_filter =
        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));

    if let Some(log_path) = std::env::var("VESSEL_LOG").ok().filter(|s| !s.is_empty()) {
        // Open log file (append mode, create if missing)
        match std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&log_path)
        {
            Ok(file) => {
                // When logging to a file, bump the filter to at least info so
                // server lifecycle events are always captured.
                let filter = if verbose {
                    EnvFilter::try_from_default_env()
                        .unwrap_or_else(|_| EnvFilter::new("vessel=debug"))
                } else {
                    EnvFilter::try_from_default_env()
                        .unwrap_or_else(|_| EnvFilter::new("vessel=info"))
                };

                let file = std::sync::Mutex::new(file);
                tracing_subscriber::fmt()
                    .with_env_filter(filter)
                    .with_ansi(false)
                    .with_writer(file)
                    .init();
            }
            Err(e) => {
                // Fall back to stderr-only, but warn about it
                eprintln!("warning: VESSEL_LOG={log_path}: {e}");
                tracing_subscriber::fmt()
                    .with_env_filter(stderr_filter)
                    .with_writer(std::io::stderr)
                    .init();
            }
        }
    } else {
        tracing_subscriber::fmt()
            .with_env_filter(stderr_filter)
            .with_writer(std::io::stderr)
            .init();
    }

    TelemetryGuard {
        #[cfg(feature = "otel")]
        trace_provider: None,
        #[cfg(feature = "otel")]
        log_provider: None,
    }
}

/// JSON spans/events to stderr via tracing-subscriber's JSON formatter.
fn init_stderr() -> TelemetryGuard {
    use tracing_subscriber::layer::SubscriberExt as _;
    use tracing_subscriber::util::SubscriberInitExt as _;

    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

    tracing_subscriber::registry()
        .with(filter)
        .with(
            tracing_subscriber::fmt::layer()
                .json()
                .with_writer(std::io::stderr)
                .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE),
        )
        .init();

    TelemetryGuard {
        #[cfg(feature = "otel")]
        trace_provider: None,
        #[cfg(feature = "otel")]
        log_provider: None,
    }
}

/// OTLP HTTP export (traces + logs).
#[cfg(feature = "otel")]
fn init_otlp(verbose: bool) -> TelemetryGuard {
    use opentelemetry::trace::TracerProvider as _;
    use tracing_subscriber::layer::SubscriberExt as _;
    use tracing_subscriber::util::SubscriberInitExt as _;

    // --- Traces ---
    let span_exporter = match opentelemetry_otlp::SpanExporter::builder()
        .with_http()
        .build()
    {
        Ok(e) => e,
        Err(e) => {
            eprintln!("warning: failed to init OTLP span exporter: {e}");
            return init_fmt(verbose);
        }
    };

    let resource = otel_resource();

    let trace_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
        .with_simple_exporter(span_exporter)
        .with_resource(resource.clone())
        .build();

    let tracer = trace_provider.tracer(env!("CARGO_PKG_NAME"));
    let trace_layer = tracing_opentelemetry::layer().with_tracer(tracer);

    // --- Logs ---
    let log_exporter = match opentelemetry_otlp::LogExporter::builder()
        .with_http()
        .build()
    {
        Ok(e) => e,
        Err(e) => {
            eprintln!("warning: failed to init OTLP log exporter: {e}");
            return init_fmt(verbose);
        }
    };

    let log_provider = opentelemetry_sdk::logs::SdkLoggerProvider::builder()
        .with_simple_exporter(log_exporter)
        .with_resource(resource)
        .build();

    let log_layer =
        opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&log_provider);

    // --- Parent context (distributed tracing) ---
    install_parent_context();

    // --- Subscriber ---
    let default = if verbose {
        "vessel=debug"
    } else {
        "vessel=warn"
    };
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));

    tracing_subscriber::registry()
        .with(filter)
        .with(trace_layer)
        .with(log_layer)
        .init();

    TelemetryGuard {
        trace_provider: Some(trace_provider),
        log_provider: Some(log_provider),
    }
}

/// Extract the current span's trace context as a W3C `TRACEPARENT` string.
///
/// Returns `None` if OTEL is not enabled or no valid span context exists.
/// Use this to propagate trace context to child processes via environment
/// variables.
#[cfg(feature = "otel")]
#[must_use]
pub fn current_traceparent() -> Option<String> {
    use opentelemetry::propagation::TextMapPropagator as _;
    use opentelemetry_sdk::propagation::TraceContextPropagator;
    use std::collections::HashMap;

    let propagator = TraceContextPropagator::new();
    let mut carrier: HashMap<String, String> = HashMap::new();
    propagator.inject(&mut carrier);
    carrier.remove("traceparent")
}

/// Stub when otel feature is disabled.
#[cfg(not(feature = "otel"))]
pub fn current_traceparent() -> Option<String> {
    None
}

/// If `TRACEPARENT` is set, parse it and install as the current `OTel` context
/// so that subsequent spans become children of the remote parent.
#[cfg(feature = "otel")]
fn install_parent_context() {
    use opentelemetry::propagation::TextMapPropagator as _;
    use opentelemetry_sdk::propagation::TraceContextPropagator;
    use std::collections::HashMap;

    if let Ok(traceparent) = std::env::var("TRACEPARENT") {
        let mut carrier: HashMap<String, String> = HashMap::new();
        carrier.insert("traceparent".to_string(), traceparent);
        let propagator = TraceContextPropagator::new();
        let cx = propagator.extract(&carrier);
        // Attach as the current context — tracing-opentelemetry's layer will
        // pick this up as the parent for root-level spans.
        let guard = cx.attach();
        // The guard is intentionally leaked: we want this context to remain
        // active for the lifetime of the process.
        std::mem::forget(guard);
    }
}

#[cfg(feature = "otel")]
fn otel_resource() -> opentelemetry_sdk::Resource {
    use opentelemetry::KeyValue;
    opentelemetry_sdk::Resource::builder()
        .with_attribute(KeyValue::new("service.name", env!("CARGO_PKG_NAME")))
        .with_attribute(KeyValue::new("service.version", env!("CARGO_PKG_VERSION")))
        .build()
}