diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cf42a0..9ca7db4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,17 @@ jobs: - name: Tests all targets all features run: cargo test --lib --workspace --all-targets --all-features + wasm-build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + targets: wasm32-unknown-unknown + - name: Build cosmian_logger for wasm32-unknown-unknown + run: cargo build --target wasm32-unknown-unknown -p cosmian_logger + cargo-lint: uses: Cosmian/reusable_workflows/.github/workflows/cargo-lint.yml@develop with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cfeece..55e8340 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,7 @@ # pre-commit install # pre-commit install --install-hooks -t commit-msg # pre-commit autoupdate +# rustup target add wasm32-unknown-unknown # # (optional) Creating a virtual environment # ``` @@ -148,3 +149,9 @@ repos: language: system pass_filenames: false always_run: true + - id: wasm-build-cosmian-logger + name: WASM build - cosmian_logger + entry: cargo build --target wasm32-unknown-unknown -p cosmian_logger + language: system + pass_filenames: false + files: ^crate/logger/ diff --git a/crate/logger/Cargo.toml b/crate/logger/Cargo.toml index cb2af14..3ed1620 100644 --- a/crate/logger/Cargo.toml +++ b/crate/logger/Cargo.toml @@ -8,32 +8,33 @@ license.workspace = true [dependencies] opentelemetry = "0.32" +opentelemetry-semantic-conventions = "0.32" +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = [ + "ansi", + "env-filter", + "fmt", + "std" +] } + +# The following crates rely on tokio, tonic, or Unix-specific syscalls that do +# not compile on wasm32. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] opentelemetry-appender-tracing = "0.32" opentelemetry-otlp = { version = "0.32", features = [ "metrics", "logs", "grpc-tonic" ] } -opentelemetry-semantic-conventions = "0.32" opentelemetry_sdk = { version = "0.32", features = [ "metrics", "logs", "rt-tokio" ] } -thiserror = { workspace = true } syslog-tracing = "0.3" -tracing = { workspace = true } -tracing-opentelemetry = { version = "0.33" } -tracing-subscriber = { workspace = true, features = [ - "ansi", - "env-filter", - "fmt", - "std" -] } - -# tracing-appender uses platform-specific file rotation that does not compile on wasm32. -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tracing-appender = { workspace = true } +tracing-opentelemetry = { version = "0.33" } [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crate/logger/src/error.rs b/crate/logger/src/error.rs index be09938..6f2f15e 100644 --- a/crate/logger/src/error.rs +++ b/crate/logger/src/error.rs @@ -1,12 +1,15 @@ /// Errors that can occur while initialising or configuring the logging stack. #[derive(Debug, thiserror::Error)] pub enum LoggingError { + #[cfg(not(target_arch = "wasm32"))] #[error("failed to build OTLP span exporter: {0}")] SpanExporter(#[source] opentelemetry_otlp::ExporterBuildError), + #[cfg(not(target_arch = "wasm32"))] #[error("failed to build OTLP log exporter: {0}")] LogExporter(#[source] opentelemetry_otlp::ExporterBuildError), + #[cfg(not(target_arch = "wasm32"))] #[error("failed to build OTLP metric exporter: {0}")] MetricExporter(#[source] opentelemetry_otlp::ExporterBuildError), diff --git a/crate/logger/src/lib.rs b/crate/logger/src/lib.rs index b1b9bcc..c0caf4b 100644 --- a/crate/logger/src/lib.rs +++ b/crate/logger/src/lib.rs @@ -6,9 +6,13 @@ pub mod types; use std::sync::atomic::{AtomicBool, Ordering}; pub use error::{LoggingError, LoggingResult, ResultExt}; +#[cfg(not(target_arch = "wasm32"))] use opentelemetry::trace::TracerProvider; +#[cfg(not(target_arch = "wasm32"))] use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +#[cfg(not(target_arch = "wasm32"))] use opentelemetry_otlp::WithExportConfig; +#[cfg(not(target_arch = "wasm32"))] use opentelemetry_sdk::{ logs::SdkLoggerProvider, metrics::{PeriodicReader, SdkMeterProvider}, @@ -28,6 +32,7 @@ pub mod reexport { static INITIALIZED: AtomicBool = AtomicBool::new(false); +#[cfg(not(target_arch = "wasm32"))] fn build_resource_simple(service_name: &str) -> Resource { Resource::builder_empty() .with_attributes([opentelemetry::KeyValue::new( @@ -37,6 +42,7 @@ fn build_resource_simple(service_name: &str) -> Resource { .build() } +#[cfg(not(target_arch = "wasm32"))] fn build_resource_with_config(service_name: &str, config: &TelemetryConfig) -> Resource { use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION}; let mut kvs = vec![opentelemetry::KeyValue::new( @@ -55,6 +61,7 @@ fn build_resource_with_config(service_name: &str, config: &TelemetryConfig) -> R Resource::builder_empty().with_attributes(kvs).build() } +#[cfg(not(target_arch = "wasm32"))] fn new_tracer_provider(endpoint: &str, resource: Resource) -> LoggingResult { let exporter = opentelemetry_otlp::SpanExporter::builder() .with_tonic() @@ -71,6 +78,7 @@ fn new_tracer_provider(endpoint: &str, resource: Resource) -> LoggingResult LoggingResult { let exporter = opentelemetry_otlp::LogExporter::builder() .with_tonic() @@ -85,6 +93,7 @@ fn new_logger_provider(endpoint: &str, resource: Resource) -> LoggingResult LoggingResult { let exporter = opentelemetry_otlp::MetricExporter::builder() .with_tonic() @@ -102,6 +111,78 @@ fn new_meter_provider(endpoint: &str, resource: Resource) -> LoggingResult, +} + +impl tracing::field::Visit for FieldsBefore { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write as _; + if field.name() == "message" { + // `format_args!` implements Debug via Display: {:?} gives the raw + // (unquoted) message text — identical to DefaultFields behaviour. + self.message = Some(format!("{value:?}")); + } else { + if !self.fields.is_empty() { + self.fields.push(' '); + } + let _ = write!(self.fields, "{}={value:?}", field.name()); + } + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_owned()); + } else { + // Non-message strings are printed with quotes via the &str Debug impl. + self.record_debug(field, &value); + } + } +} + +impl<'writer> tracing_subscriber::fmt::format::FormatFields<'writer> for FieldsBeforeMessage { + fn format_fields( + &self, + mut writer: tracing_subscriber::fmt::format::Writer<'writer>, + fields: R, + ) -> std::fmt::Result { + let mut v = FieldsBefore { + fields: String::new(), + message: None, + }; + fields.record(&mut v); + if !v.fields.is_empty() { + write!(writer, "{}", v.fields)?; + } + if let Some(msg) = v.message { + if !v.fields.is_empty() { + write!(writer, " ")?; + } + write!(writer, "{msg}")?; + } + Ok(()) + } +} + // ── public API ─────────────────────────────────────────────────────────────── /// Initialise the global tracing subscriber from a [`TracingConfig`]. @@ -117,6 +198,9 @@ pub fn tracing_init(config: &TracingConfig) -> LoggingGuards { // (std::env::set_var is `unsafe`). If `rust_log` is provided, apply it // directly when building the `EnvFilter` below. + // `mut` is required on non-wasm targets where guards fields are populated + // inside the #[cfg(not(target_arch = "wasm32"))] block below. + #[allow(unused_mut)] let mut guards = LoggingGuards::default(); // --- stdout layer --- @@ -131,105 +215,125 @@ pub fn tracing_init(config: &TracingConfig) -> LoggingGuards { .with_line_number(true) .with_file(true) .with_ansi(config.with_ansi_colors) - .compact(), + .compact() + .fmt_fields(FieldsBeforeMessage), ) }; - // --- rolling file layer --- - // tracing-appender uses platform-specific file rotation support and does not - // compile on wasm32. - #[cfg(not(target_arch = "wasm32"))] - let file_layer = config.log_to_file.as_ref().map(|(dir, name)| { - if !dir.exists() { - if let Err(e) = std::fs::create_dir_all(dir) { - eprintln!("Failed to create log directory {}: {e}", dir.display()); - } - } - let appender = tracing_appender::rolling::daily(dir, name); - let (non_blocking, guard) = tracing_appender::non_blocking(appender); - guards._rolling_appender_guard = Some(guard); - tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - .with_ansi(false) - .compact() - }); - - #[cfg(target_arch = "wasm32")] - let file_layer = None; - - // --- syslog layer (Unix only) --- - #[cfg(not(target_os = "windows"))] - let syslog_layer = if config.log_to_syslog { - std::ffi::CString::new(config.service_name.as_str()) - .ok() - .and_then(|id| { - syslog_tracing::Syslog::new(id, Default::default(), syslog_tracing::Facility::User) - }) - .map(|syslog| { - tracing_subscriber::fmt::layer() - .with_writer(syslog) - .with_ansi(false) - .compact() - }) - } else { - None - }; - - // --- OTLP layer --- - let otel_layer = config.otlp.as_ref().and_then(|telemetry| { - let resource = build_resource_with_config(&config.service_name, telemetry); + let filter = config + .rust_log + .as_deref() + .map(EnvFilter::new) + .unwrap_or_else(EnvFilter::from_default_env); - let tracer_provider = match new_tracer_provider(&telemetry.otlp_url, resource.clone()) { - Ok(tp) => tp, - Err(e) => { - eprintln!("Failed to initialise OTLP span exporter: {e}"); - return None; + // ── non-wasm subscriber: file + syslog + OTLP layers ──────────────────── + #[cfg(not(target_arch = "wasm32"))] + { + // --- rolling file layer --- + // tracing-appender uses platform-specific file rotation support and does + // not compile on wasm32. + let file_layer = config.log_to_file.as_ref().map(|(dir, name)| { + if !dir.exists() { + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!("Failed to create log directory {}: {e}", dir.display()); + } } + let appender = tracing_appender::rolling::daily(dir, name); + let (non_blocking, guard) = tracing_appender::non_blocking(appender); + guards._rolling_appender_guard = Some(guard); + tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .compact() + .fmt_fields(FieldsBeforeMessage) + }); + + // --- syslog layer (Unix only) --- + #[cfg(not(target_os = "windows"))] + let syslog_layer = if config.log_to_syslog { + std::ffi::CString::new(config.service_name.as_str()) + .ok() + .and_then(|id| { + syslog_tracing::Syslog::new( + id, + Default::default(), + syslog_tracing::Facility::User, + ) + }) + .map(|syslog| { + tracing_subscriber::fmt::layer() + .with_writer(syslog) + .with_ansi(false) + .compact() + .fmt_fields(FieldsBeforeMessage) + }) + } else { + None }; - let tracer = tracer_provider.tracer(config.service_name.clone()); - opentelemetry::global::set_tracer_provider(tracer_provider.clone()); - guards._tracer_provider = Some(tracer_provider); + // --- OTLP layer --- + let otel_layer = config.otlp.as_ref().and_then(|telemetry| { + let resource = build_resource_with_config(&config.service_name, telemetry); - if telemetry.enable_metering { - match new_meter_provider(&telemetry.otlp_url, resource) { - Ok(mp) => { - opentelemetry::global::set_meter_provider(mp.clone()); - guards._meter_provider = Some(mp); + let tracer_provider = match new_tracer_provider(&telemetry.otlp_url, resource.clone()) { + Ok(tp) => tp, + Err(e) => { + eprintln!("Failed to initialise OTLP span exporter: {e}"); + return None; + } + }; + + let tracer = tracer_provider.tracer(config.service_name.clone()); + opentelemetry::global::set_tracer_provider(tracer_provider.clone()); + guards._tracer_provider = Some(tracer_provider); + + if telemetry.enable_metering { + match new_meter_provider(&telemetry.otlp_url, resource) { + Ok(mp) => { + opentelemetry::global::set_meter_provider(mp.clone()); + guards._meter_provider = Some(mp); + } + Err(e) => eprintln!("Failed to initialise OTLP metric exporter: {e}"), } - Err(e) => eprintln!("Failed to initialise OTLP metric exporter: {e}"), } - } - - Some(tracing_opentelemetry::layer().with_tracer(tracer)) - }); - let filter = config - .rust_log - .as_deref() - .map(EnvFilter::new) - .unwrap_or_else(EnvFilter::from_default_env); + Some(tracing_opentelemetry::layer().with_tracer(tracer)) + }); - let ts = tracing_subscriber::registry() - .with(filter) - .with(stdout_layer) - .with(file_layer) - .with(otel_layer); + let ts = tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(file_layer) + .with(otel_layer); - #[cfg(not(target_os = "windows"))] - let ts = ts.with(syslog_layer); + #[cfg(not(target_os = "windows"))] + let ts = ts.with(syslog_layer); - if let Err(e) = ts.try_init() { - // Best-effort cleanup: avoid leaving background exporters running when - // subscriber init fails. - if let Some(tp) = guards._tracer_provider.take() { - let _ = tp.shutdown(); + if let Err(e) = ts.try_init() { + // Best-effort cleanup: avoid leaving background exporters running when + // subscriber init fails. + if let Some(tp) = guards._tracer_provider.take() { + let _ = tp.shutdown(); + } + if let Some(mp) = guards._meter_provider.take() { + let _ = mp.shutdown(); + } + INITIALIZED.store(false, Ordering::SeqCst); + eprintln!("Failed to initialise tracing: {e}"); } - if let Some(mp) = guards._meter_provider.take() { - let _ = mp.shutdown(); + } + + // ── wasm32 subscriber: stdout only ─────────────────────────────────────── + #[cfg(target_arch = "wasm32")] + { + let ts = tracing_subscriber::registry() + .with(filter) + .with(stdout_layer); + + if let Err(e) = ts.try_init() { + INITIALIZED.store(false, Ordering::SeqCst); + eprintln!("Failed to initialise tracing: {e}"); } - INITIALIZED.store(false, Ordering::SeqCst); - eprintln!("Failed to initialise tracing: {e}"); } guards @@ -255,13 +359,15 @@ pub fn log_init(rust_log: Option<&str>) { /// /// Returns [`TelemetryGuards`] that must be kept alive for the entire process /// lifetime. Call [`TelemetryGuards::shutdown`] before exit to flush buffers. +#[cfg(not(target_arch = "wasm32"))] pub fn init_tracing(default_service_name: &str) -> LoggingResult { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let stdout_layer = tracing_subscriber::fmt::layer() .with_level(true) .with_target(true) - .compact(); + .compact() + .fmt_fields(FieldsBeforeMessage); if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") { let service_name = diff --git a/crate/logger/src/macros.rs b/crate/logger/src/macros.rs index d50a869..308ef6f 100644 --- a/crate/logger/src/macros.rs +++ b/crate/logger/src/macros.rs @@ -21,15 +21,24 @@ macro_rules! __fn_name { // segment to obtain the enclosing function name. fn _f() {} fn _strip(name: &str) -> &str { - // Remove `::_f` suffix - let trimmed = match name.rfind("::") { + // Remove `::_f` suffix added by our inner-function trick. + let mut s = match name.rfind("::") { Some(pos) => &name[..pos], - None => name, + None => return name, }; - // Take last segment - match trimmed.rfind("::") { - Some(pos) => &trimmed[pos + 2..], - None => trimmed, + // Strip trailing anonymous segments: `{{closure}}`, `{{async_fn_body}}`, + // etc. These appear when the macro is called from inside an async fn + // body, which the compiler desugars to a closure/state-machine type. + while s.ends_with("}}") { + s = match s.rfind("::") { + Some(pos) => &s[..pos], + None => return s, + }; + } + // Return the last named segment. + match s.rfind("::") { + Some(pos) => &s[pos + 2..], + None => s, } } _strip(::std::any::type_name_of_val(&_f)) diff --git a/crate/logger/src/tests.rs b/crate/logger/src/tests.rs index 20fb439..a92cd65 100644 --- a/crate/logger/src/tests.rs +++ b/crate/logger/src/tests.rs @@ -72,6 +72,21 @@ mod macros { ); } + /// __fn_name! must resolve to the enclosing async fn name, not + /// `{{closure}}`. + #[tokio::test] + async fn fn_name_inside_async_fn() { + let name = crate::__fn_name!(); + assert!( + !name.contains("{{"), + "fn_name must not contain '{{{{' (got '{name}') — async closure stripping failed" + ); + assert!( + name.contains("fn_name_inside_async_fn"), + "expected 'fn_name_inside_async_fn', got '{name}'" + ); + } + /// Verify that our info!/debug!/warn!/error!/trace! macros expand without /// panicking (they rely on a subscriber being present; if none is installed /// the tracing no-op path is taken instead). diff --git a/crate/logger/src/types.rs b/crate/logger/src/types.rs index c955cb7..e8e9070 100644 --- a/crate/logger/src/types.rs +++ b/crate/logger/src/types.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +#[cfg(not(target_arch = "wasm32"))] use opentelemetry_sdk::{ logs::SdkLoggerProvider, metrics::SdkMeterProvider, trace::SdkTracerProvider, }; @@ -49,22 +50,28 @@ pub struct TracingConfig { /// telemetry and release resources cleanly. #[derive(Default)] pub struct TelemetryGuards { + #[cfg(not(target_arch = "wasm32"))] pub(crate) tracer_provider: Option, + #[cfg(not(target_arch = "wasm32"))] pub(crate) logger_provider: Option, + #[cfg(not(target_arch = "wasm32"))] pub(crate) meter_provider: Option, } impl TelemetryGuards { /// Flush and shut down all active OTLP exporters. pub fn shutdown(self) { - if let Some(tp) = self.tracer_provider { - let _ = tp.shutdown(); - } - if let Some(lp) = self.logger_provider { - let _ = lp.shutdown(); - } - if let Some(mp) = self.meter_provider { - let _ = mp.shutdown(); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(tp) = self.tracer_provider { + let _ = tp.shutdown(); + } + if let Some(lp) = self.logger_provider { + let _ = lp.shutdown(); + } + if let Some(mp) = self.meter_provider { + let _ = mp.shutdown(); + } } } @@ -83,7 +90,9 @@ impl TelemetryGuards { /// The tracing pipeline shuts down cleanly when this value is dropped. #[derive(Default)] pub struct LoggingGuards { + #[cfg(not(target_arch = "wasm32"))] pub(crate) _tracer_provider: Option, + #[cfg(not(target_arch = "wasm32"))] pub(crate) _meter_provider: Option, #[cfg(not(target_arch = "wasm32"))] pub(crate) _rolling_appender_guard: Option, @@ -91,11 +100,14 @@ pub struct LoggingGuards { impl Drop for LoggingGuards { fn drop(&mut self) { - if let Some(tp) = self._tracer_provider.take() { - let _ = tp.shutdown(); - } - if let Some(mp) = self._meter_provider.take() { - let _ = mp.shutdown(); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(tp) = self._tracer_provider.take() { + let _ = tp.shutdown(); + } + if let Some(mp) = self._meter_provider.take() { + let _ = mp.shutdown(); + } } } }