From a035e158947c087cf25f8bc17c58b71eed5c1d65 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Sun, 8 Mar 2026 00:21:06 +0100 Subject: [PATCH] Add OpenTelemetry tracing and log export via OTLP Shared telemetry init module in compliance-core (behind `telemetry` feature) sets up OTLP/gRPC export for traces and logs when OTEL_EXPORTER_OTLP_ENDPOINT is set. Falls back to console-only output when unset. Both agent and dashboard now use the shared init. Docker Compose includes an OTel Collector service with a config template for SigNoz, Grafana Tempo/Loki, Jaeger, etc. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 205 ++++++++++++++++++++++++++++++- bin/main.rs | 7 +- compliance-agent/Cargo.toml | 2 +- compliance-agent/src/main.rs | 10 +- compliance-core/Cargo.toml | 16 +++ compliance-core/src/lib.rs | 2 + compliance-core/src/telemetry.rs | 159 ++++++++++++++++++++++++ compliance-dashboard/Cargo.toml | 1 + docker-compose.yml | 15 +++ otel-collector-config.yaml | 52 ++++++++ 10 files changed, 452 insertions(+), 17 deletions(-) create mode 100644 compliance-core/src/telemetry.rs create mode 100644 otel-collector-config.yaml diff --git a/Cargo.lock b/Cargo.lock index a7a9f22..2b96e76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite 0.28.0", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -582,11 +582,18 @@ dependencies = [ "chrono", "hex", "mongodb", + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-otlp", + "opentelemetry_sdk", "secrecy", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", "uuid", ] @@ -1316,7 +1323,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.27.0", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-layer", "tracing", @@ -1607,7 +1614,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.27.0", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tracing", "tracing-futures", @@ -2104,6 +2111,12 @@ dependencies = [ "url", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-net" version = "0.6.0" @@ -3472,7 +3485,7 @@ dependencies = [ "serde_urlencoded", "snafu", "tokio", - "tower", + "tower 0.5.3", "tower-http", "tracing", "url", @@ -3519,6 +3532,98 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e716f864eb23007bdd9dc4aec381e188a1cee28eecf22066772b5fd822b9727d" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "opentelemetry-http" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656" +dependencies = [ + "futures-core", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.9.2", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "ownedbytes" version = "0.7.0" @@ -3752,6 +3857,29 @@ dependencies = [ "version_check", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -4007,6 +4135,7 @@ dependencies = [ "bytes", "cookie", "cookie_store", + "futures-channel", "futures-core", "futures-util", "http", @@ -4030,7 +4159,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5211,6 +5340,52 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -5250,7 +5425,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -5322,6 +5497,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" diff --git a/bin/main.rs b/bin/main.rs index 65fdbcd..d7ce0a6 100644 --- a/bin/main.rs +++ b/bin/main.rs @@ -2,10 +2,9 @@ #[allow(clippy::expect_used)] fn main() { - dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger"); - #[cfg(feature = "web")] { + dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger"); dioxus::web::launch::launch_cfg( compliance_dashboard::App, dioxus::web::Config::new().hydrate(true), @@ -14,6 +13,10 @@ fn main() { #[cfg(feature = "server")] { + dotenvy::dotenv().ok(); + let _telemetry_guard = + compliance_core::telemetry::init_telemetry("compliance-dashboard"); + compliance_dashboard::infrastructure::server_start(compliance_dashboard::App) .map_err(|e| { tracing::error!("Unable to start server: {e}"); diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 7349248..76f1b7d 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" workspace = true [dependencies] -compliance-core = { workspace = true, features = ["mongodb"] } +compliance-core = { workspace = true, features = ["mongodb", "telemetry"] } compliance-graph = { path = "../compliance-graph" } compliance-dast = { path = "../compliance-dast" } serde = { workspace = true } diff --git a/compliance-agent/src/main.rs b/compliance-agent/src/main.rs index f8518bb..c67ac85 100644 --- a/compliance-agent/src/main.rs +++ b/compliance-agent/src/main.rs @@ -1,5 +1,3 @@ -use tracing_subscriber::EnvFilter; - mod agent; mod api; mod config; @@ -15,14 +13,10 @@ mod webhooks; #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .init(); - dotenvy::dotenv().ok(); + let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-agent"); + tracing::info!("Loading configuration..."); let config = config::load_config()?; diff --git a/compliance-core/Cargo.toml b/compliance-core/Cargo.toml index e6e85b6..ed283e5 100644 --- a/compliance-core/Cargo.toml +++ b/compliance-core/Cargo.toml @@ -9,6 +9,15 @@ workspace = true [features] default = ["mongodb"] mongodb = ["dep:mongodb"] +telemetry = [ + "dep:opentelemetry", + "dep:opentelemetry_sdk", + "dep:opentelemetry-otlp", + "dep:opentelemetry-appender-tracing", + "dep:tracing-opentelemetry", + "dep:tracing-subscriber", + "dep:tracing", +] [dependencies] serde = { workspace = true } @@ -21,3 +30,10 @@ uuid = { workspace = true } secrecy = { workspace = true } bson = { version = "2", features = ["chrono-0_4"] } mongodb = { workspace = true, optional = true } +opentelemetry = { version = "0.29", optional = true } +opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"], optional = true } +opentelemetry-otlp = { version = "0.29", features = ["grpc-tonic"], optional = true } +opentelemetry-appender-tracing = { version = "0.29", optional = true } +tracing-opentelemetry = { version = "0.30", optional = true } +tracing-subscriber = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index d492d80..4cfee7e 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -1,6 +1,8 @@ pub mod config; pub mod error; pub mod models; +#[cfg(feature = "telemetry")] +pub mod telemetry; pub mod traits; pub use config::{AgentConfig, DashboardConfig}; diff --git a/compliance-core/src/telemetry.rs b/compliance-core/src/telemetry.rs new file mode 100644 index 0000000..555fe63 --- /dev/null +++ b/compliance-core/src/telemetry.rs @@ -0,0 +1,159 @@ +//! OpenTelemetry initialization for traces and logs. +//! +//! Exports traces and logs via OTLP (gRPC) when `OTEL_EXPORTER_OTLP_ENDPOINT` +//! is set. Always includes a `tracing_subscriber::fmt` layer for console output. +//! +//! Compatible with SigNoz, Grafana Tempo/Loki, Jaeger, and any OTLP-compatible +//! collector. +//! +//! # Environment Variables +//! +//! | Variable | Description | Default | +//! |---|---|---| +//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g. `http://localhost:4317`) | *(disabled)* | +//! | `OTEL_SERVICE_NAME` | Service name for resource | `service_name` param | +//! | `RUST_LOG` / standard `EnvFilter` | Log level filter | `info` | + +use opentelemetry::global; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry::KeyValue; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig}; +use opentelemetry_sdk::{ + logs::SdkLoggerProvider, + trace::SdkTracerProvider, + Resource, +}; +use tracing_opentelemetry::OpenTelemetryLayer; +use tracing_subscriber::{ + layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer as _, +}; + +/// Guard that shuts down OTel providers on drop. +/// +/// Must be held for the lifetime of the application. When dropped, +/// flushes and shuts down the tracer and logger providers. +pub struct TelemetryGuard { + tracer_provider: Option, + logger_provider: Option, +} + +impl Drop for TelemetryGuard { + fn drop(&mut self) { + if let Some(tp) = self.tracer_provider.take() { + if let Err(e) = tp.shutdown() { + eprintln!("Failed to shutdown tracer provider: {e}"); + } + } + if let Some(lp) = self.logger_provider.take() { + if let Err(e) = lp.shutdown() { + eprintln!("Failed to shutdown logger provider: {e}"); + } + } + } +} + +fn build_resource(service_name: &str) -> Resource { + let name = std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| service_name.to_string()); + Resource::builder() + .with_service_name(name) + .with_attributes([KeyValue::new( + "service.version", + env!("CARGO_PKG_VERSION"), + )]) + .build() +} + +/// Initialize telemetry (tracing + logging). +/// +/// If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, traces and logs are exported +/// via OTLP/gRPC. Console fmt output is always enabled. +/// +/// Returns a [`TelemetryGuard`] that must be held alive for the application +/// lifetime. Dropping it triggers a graceful shutdown of OTel providers. +/// +/// # Panics +/// +/// Panics if the tracing subscriber cannot be initialized (e.g. called twice). +pub fn init_telemetry(service_name: &str) -> TelemetryGuard { + let otel_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(); + + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let fmt_layer = tracing_subscriber::fmt::layer(); + + match otel_endpoint { + Some(ref endpoint) => { + let resource = build_resource(service_name); + + // Traces + #[allow(clippy::expect_used)] + let span_exporter = SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint) + .build() + .expect("failed to create OTLP span exporter"); + + let tracer_provider = SdkTracerProvider::builder() + .with_batch_exporter(span_exporter) + .with_resource(resource.clone()) + .build(); + + global::set_tracer_provider(tracer_provider.clone()); + let tracer = tracer_provider.tracer(service_name.to_string()); + let otel_trace_layer = OpenTelemetryLayer::new(tracer); + + // Logs + #[allow(clippy::expect_used)] + let log_exporter = LogExporter::builder() + .with_tonic() + .with_endpoint(endpoint) + .build() + .expect("failed to create OTLP log exporter"); + + let logger_provider = SdkLoggerProvider::builder() + .with_batch_exporter(log_exporter) + .with_resource(resource) + .build(); + + let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider); + + // Filter to prevent telemetry-induced-telemetry loops + let otel_filter = EnvFilter::new("info") + .add_directive("hyper=off".parse().unwrap_or_default()) + .add_directive("tonic=off".parse().unwrap_or_default()) + .add_directive("h2=off".parse().unwrap_or_default()) + .add_directive("reqwest=off".parse().unwrap_or_default()); + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .with(otel_trace_layer) + .with(otel_log_layer.with_filter(otel_filter)) + .init(); + + tracing::info!( + endpoint = endpoint.as_str(), + service = service_name, + "OpenTelemetry OTLP export enabled" + ); + + TelemetryGuard { + tracer_provider: Some(tracer_provider), + logger_provider: Some(logger_provider), + } + } + None => { + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .init(); + + tracing::info!("OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"); + + TelemetryGuard { + tracer_provider: None, + logger_provider: None, + } + } + } +} diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml index 9f1366c..39455d8 100644 --- a/compliance-dashboard/Cargo.toml +++ b/compliance-dashboard/Cargo.toml @@ -18,6 +18,7 @@ server = [ "dioxus/router", "dioxus/fullstack", "compliance-core/mongodb", + "compliance-core/telemetry", "dep:axum", "dep:mongodb", "dep:reqwest", diff --git a/docker-compose.yml b/docker-compose.yml index fa9f801..cb16ab1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: - "3001:3001" - "3002:3002" env_file: .env + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + OTEL_SERVICE_NAME: compliance-agent depends_on: - mongo volumes: @@ -29,6 +32,9 @@ services: ports: - "8080:8080" env_file: .env + environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + OTEL_SERVICE_NAME: compliance-dashboard depends_on: - mongo - agent @@ -43,6 +49,15 @@ services: PREBOOT_CHROME: "true" restart: unless-stopped + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + ports: + - "4317:4317" + - "4318:4318" + volumes: + - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml + restart: unless-stopped + volumes: mongo_data: repos_data: diff --git a/otel-collector-config.yaml b/otel-collector-config.yaml new file mode 100644 index 0000000..1496bd1 --- /dev/null +++ b/otel-collector-config.yaml @@ -0,0 +1,52 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 5s + send_batch_size: 1024 + +exporters: + # Log to stdout for debugging + debug: + verbosity: basic + + # Configure your backend below. Examples: + # + # SigNoz: + # otlp/signoz: + # endpoint: "signoz-otel-collector:4317" + # tls: + # insecure: true + # + # Grafana Tempo (traces): + # otlp/tempo: + # endpoint: "tempo:4317" + # tls: + # insecure: true + # + # Grafana Loki (logs): + # loki: + # endpoint: "http://loki:3100/loki/api/v1/push" + # + # Jaeger: + # otlp/jaeger: + # endpoint: "jaeger:4317" + # tls: + # insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug]