diff --git a/CHANGELOG.md b/CHANGELOG.md index 6160dfa..2c70688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Add `span_exporter()` helper to build a span exporter directly in [#20](https://github.com/pydantic/logfire-rust/pull/20) - Fix `set_resource` not being called on span processors added with `with_additional_span_processor()` in [#20](https://github.com/pydantic/logfire-rust/pull/20) +- Add `MetricsOptions` for configuring additional metrics exporters in [#21](https://github.com/pydantic/logfire-rust/pull/21) ## [v0.1.0] (2025-03-13) diff --git a/Cargo.toml b/Cargo.toml index b6bd773..09e0985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,9 +35,11 @@ nu-ansi-term = "0.50.1" chrono = "0.4.39" [dev-dependencies] +async-trait = "0.1.88" insta = "1.42.1" opentelemetry_sdk = { version = "0.28", default-features = false, features = ["testing"] } regex = "1.11.1" +tokio = {version = "1.44.1", features = ["test-util"] } ulid = "1.2.0" [features] diff --git a/src/bridges/tracing.rs b/src/bridges/tracing.rs index e8d659a..72aa88f 100644 --- a/src/bridges/tracing.rs +++ b/src/bridges/tracing.rs @@ -233,14 +233,15 @@ mod tests { use crate::{ config::{AdvancedOptions, ConsoleOptions, Target}, set_local_logfire, - tests::{DeterministicExporter, DeterministicIdGenerator}, + test_utils::{DeterministicExporter, DeterministicIdGenerator}, }; #[test] fn test_tracing_bridge() { let exporter = InMemorySpanExporterBuilder::new().build(); - let config = crate::configure() + let handler = crate::configure() + .local() .send_to_logfire(false) .with_additional_span_processor(SimpleSpanProcessor::new(Box::new( DeterministicExporter::new(exporter.clone(), file!(), line!()), @@ -249,11 +250,13 @@ mod tests { .with_default_level_filter(LevelFilter::TRACE) .with_advanced_options( AdvancedOptions::default().with_id_generator(DeterministicIdGenerator::new()), - ); + ) + .finish() + .unwrap(); - let guard = set_local_logfire(config).unwrap(); + let guard = set_local_logfire(handler); - tracing::subscriber::with_default(guard.subscriber.clone(), || { + tracing::subscriber::with_default(guard.subscriber().clone(), || { tracing::info!("root event"); // FIXME: this event is not emitted tracing::info!(name: "root event with value", field_value = 1); // FIXME: this event is not emitted @@ -283,7 +286,7 @@ mod tests { }, parent_span_id: 0000000000000000, span_kind: Internal, - name: "event src/bridges/tracing.rs:257", + name: "event src/bridges/tracing.rs:260", start_time: SystemTime { tv_sec: 0, tv_nsec: 0, @@ -336,7 +339,7 @@ mod tests { "code.lineno", ), value: I64( - 11, + 13, ), }, KeyValue { @@ -444,7 +447,7 @@ mod tests { "code.lineno", ), value: I64( - 12, + 14, ), }, KeyValue { @@ -524,7 +527,7 @@ mod tests { "code.lineno", ), value: I64( - 14, + 16, ), }, KeyValue { @@ -630,7 +633,7 @@ mod tests { "code.lineno", ), value: I64( - 15, + 17, ), }, KeyValue { @@ -746,7 +749,7 @@ mod tests { "code.lineno", ), value: I64( - 15, + 17, ), }, KeyValue { @@ -868,7 +871,7 @@ mod tests { "code.lineno", ), value: I64( - 16, + 18, ), }, KeyValue { @@ -984,7 +987,7 @@ mod tests { "code.lineno", ), value: I64( - 16, + 18, ), }, KeyValue { @@ -1106,7 +1109,7 @@ mod tests { "code.lineno", ), value: I64( - 17, + 19, ), }, KeyValue { @@ -1222,7 +1225,7 @@ mod tests { "code.lineno", ), value: I64( - 17, + 19, ), }, KeyValue { @@ -1344,7 +1347,7 @@ mod tests { "code.lineno", ), value: I64( - 14, + 16, ), }, KeyValue { @@ -1455,7 +1458,7 @@ mod tests { "code.lineno", ), value: I64( - 265, + 268, ), }, ], @@ -1521,7 +1524,7 @@ mod tests { "code.lineno", ), value: I64( - 266, + 269, ), }, ], @@ -1555,15 +1558,18 @@ mod tests { ..ConsoleOptions::default() }; - let config = crate::configure() + let handler = crate::configure() + .local() .send_to_logfire(false) .console_options(console_options.clone()) .install_panic_handler() - .with_default_level_filter(LevelFilter::TRACE); + .with_default_level_filter(LevelFilter::TRACE) + .finish() + .unwrap(); - let guard = set_local_logfire(config).unwrap(); + let guard = crate::set_local_logfire(handler); - tracing::subscriber::with_default(guard.subscriber.clone(), || { + tracing::subscriber::with_default(guard.subscriber().clone(), || { tracing::info!("root event"); tracing::info!(name: "root event with value", field_value = 1); diff --git a/src/config.rs b/src/config.rs index 7d0656b..695746f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,10 @@ use std::{ sync::{Arc, Mutex}, }; -use opentelemetry_sdk::trace::{IdGenerator, SpanProcessor}; +use opentelemetry_sdk::{ + metrics::reader::MetricReader, + trace::{IdGenerator, SpanProcessor}, +}; use tracing::Level; use crate::ConfigureError; @@ -160,6 +163,8 @@ pub struct AdvancedOptions { pub(crate) base_url: String, /// Generator for trace and span IDs. pub(crate) id_generator: Option, + /// Resource to override default resource detection. + pub(crate) resource: Option, // // // TODO: arguments below supported by Python @@ -176,6 +181,7 @@ impl Default for AdvancedOptions { AdvancedOptions { base_url: "https://logfire-api.pydantic.dev".to_string(), id_generator: None, + resource: None, } } } @@ -197,6 +203,32 @@ impl AdvancedOptions { self.id_generator = Some(BoxedIdGenerator::new(Box::new(generator))); self } + + /// Set the resource; overrides default resource detection. + #[must_use] + pub fn with_resource(mut self, resource: opentelemetry_sdk::Resource) -> Self { + self.resource = Some(resource); + self + } +} + +/// Configuration of metrics. +/// +/// This only has one option for now, but it's a place to add more related options in the future. +#[derive(Default)] +pub struct MetricsOptions { + /// Sequence of metric readers to be used in addition to the default which exports metrics to Logfire's API. + pub(crate) additional_readers: Vec, +} + +impl MetricsOptions { + /// Add a metric reader to the list of additional readers. + #[must_use] + pub fn with_additional_reader(mut self, reader: T) -> Self { + self.additional_readers + .push(BoxedMetricReader::new(Box::new(reader))); + self + } } /// Wrapper around a `SpanProcessor` to use in `additional_span_processors`. @@ -251,6 +283,44 @@ impl IdGenerator for BoxedIdGenerator { } } +/// Wrapper around a `MetricReader` to use in `additional_readers`. +#[derive(Debug)] +pub(crate) struct BoxedMetricReader(Box); + +impl BoxedMetricReader { + pub fn new(reader: Box) -> Self { + BoxedMetricReader(reader) + } +} + +impl MetricReader for BoxedMetricReader { + fn register_pipeline(&self, pipeline: std::sync::Weak) { + self.0.register_pipeline(pipeline); + } + + fn collect( + &self, + rm: &mut opentelemetry_sdk::metrics::data::ResourceMetrics, + ) -> opentelemetry_sdk::metrics::MetricResult<()> { + self.0.collect(rm) + } + + fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.0.force_flush() + } + + fn shutdown(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.0.shutdown() + } + + fn temporality( + &self, + kind: opentelemetry_sdk::metrics::InstrumentKind, + ) -> opentelemetry_sdk::metrics::Temporality { + self.0.temporality(kind) + } +} + #[cfg(test)] mod tests { use crate::config::SendToLogfire; diff --git a/src/exporters.rs b/src/exporters.rs index 5a16fde..e2952d6 100644 --- a/src/exporters.rs +++ b/src/exporters.rs @@ -27,7 +27,7 @@ macro_rules! feature_required { }}; } -/// Build a [`SpanExporter`][opentelemetry::trace::SpanExporter] for passing to +/// Build a [`SpanExporter`][opentelemetry_sdk::trace::SpanExporter] for passing to /// [`with_additional_span_processor()`][crate::LogfireConfigBuilder::with_additional_span_processor]. /// /// This uses `OTEL_EXPORTER_OTLP_PROTOCOL` and `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` environment @@ -98,8 +98,21 @@ pub fn span_exporter( Ok(RemovePendingSpansExporter::new(span_exporter)) } -// TODO: make this public? -pub(crate) fn metric_exporter( +/// Build a [`PushMetricExporter`][opentelemetry_sdk::metrics::exporter::PushMetricExporter] for passing to +/// [`with_metrics_options()`][crate::LogfireConfigBuilder::with_metrics_options]. +/// +/// This uses `OTEL_EXPORTER_OTLP_PROTOCOL` and `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` environment +/// variables to determine the protocol to use (or otherwise defaults to [`Protocol::HttpBinary`]). +/// +/// # Errors +/// +/// Returns an error if the protocol specified by the env var is not supported or if the required feature is not enabled for +/// the given protocol. +/// +/// Returns an error if the endpoint is not a valid URI. +/// +/// Returns an error if any headers are not valid HTTP headers. +pub fn metric_exporter( endpoint: &str, headers: Option>, ) -> Result, ConfigureError> { diff --git a/src/internal/exporters/console.rs b/src/internal/exporters/console.rs index 709bf57..f5dd23f 100644 --- a/src/internal/exporters/console.rs +++ b/src/internal/exporters/console.rs @@ -267,7 +267,7 @@ mod tests { config::{ConsoleOptions, Target}, internal::exporters::console::{ConsoleWriter, SimpleConsoleSpanExporter}, set_local_logfire, - tests::DeterministicExporter, + test_utils::DeterministicExporter, }; #[test] @@ -279,7 +279,8 @@ mod tests { ..ConsoleOptions::default() }; - let config = crate::configure() + let handler = crate::configure() + .local() .send_to_logfire(false) .with_additional_span_processor(SimpleSpanProcessor::new(Box::new( DeterministicExporter::new( @@ -289,12 +290,14 @@ mod tests { ), ))) .install_panic_handler() - .with_default_level_filter(LevelFilter::TRACE); + .with_default_level_filter(LevelFilter::TRACE) + .finish() + .unwrap(); - let guard = set_local_logfire(config).unwrap(); + let guard = set_local_logfire(handler); std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - tracing::subscriber::with_default(guard.subscriber.clone(), || { + tracing::subscriber::with_default(guard.subscriber().clone(), || { let root = crate::span!("root span").entered(); let _ = crate::span!("hello world span").entered(); let _ = crate::span!(level: Level::DEBUG, "debug span"); @@ -316,7 +319,7 @@ mod tests { 1970-01-01T00:00:03.000000Z DEBUG logfire::internal::exporters::console::tests debug span 1970-01-01T00:00:05.000000Z DEBUG logfire::internal::exporters::console::tests debug span with explicit parent 1970-01-01T00:00:07.000000Z INFO logfire::internal::exporters::console::tests hello world log - 1970-01-01T00:00:08.000000Z ERROR logfire panic: oh no! location=src/internal/exporters/console.rs:303:17, backtrace=disabled backtrace + 1970-01-01T00:00:08.000000Z ERROR logfire panic: oh no! location=src/internal/exporters/console.rs:306:17, backtrace=disabled backtrace "#); } } diff --git a/src/internal/exporters/remove_pending.rs b/src/internal/exporters/remove_pending.rs index df420da..df06705 100644 --- a/src/internal/exporters/remove_pending.rs +++ b/src/internal/exporters/remove_pending.rs @@ -66,8 +66,7 @@ mod tests { use crate::config::AdvancedOptions; use crate::set_local_logfire; - use crate::tests::DeterministicExporter; - use crate::tests::DeterministicIdGenerator; + use crate::test_utils::{DeterministicExporter, DeterministicIdGenerator}; use super::*; @@ -81,9 +80,9 @@ mod tests { fn test_remove_pending_spans() { let exporter = InMemorySpanExporterBuilder::new().build(); - let config = crate::configure() + let guard = crate::configure() + .local() .send_to_logfire(false) - .install_panic_handler() .with_additional_span_processor( BatchSpanProcessor::builder(DeterministicExporter::new( RemovePendingSpansExporter(exporter.clone()), @@ -102,11 +101,13 @@ mod tests { .with_default_level_filter(LevelFilter::TRACE) .with_advanced_options( AdvancedOptions::default().with_id_generator(DeterministicIdGenerator::new()), - ); + ) + .finish() + .unwrap(); - let guard = set_local_logfire(config).unwrap(); + let guard = set_local_logfire(guard); - tracing::subscriber::with_default(guard.subscriber.clone(), || { + tracing::subscriber::with_default(guard.subscriber(), || { let _root = crate::span!("root span").entered(); let _hello = crate::span!("hello world span").entered(); let _debug = crate::span!(level: Level::DEBUG, "debug span").entered(); diff --git a/src/lib.rs b/src/lib.rs index 9e3908d..d2de002 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,9 +102,6 @@ use std::panic::PanicHookInfo; use std::sync::{Arc, Once}; use std::{backtrace::Backtrace, env::VarError, str::FromStr, sync::OnceLock, time::Duration}; -use bridges::tracing::LogfireTracingLayer; -use config::{AdvancedOptions, BoxedSpanProcessor, ConsoleOptions, SendToLogfire}; -use internal::exporters::console::{ConsoleWriter, SimpleConsoleSpanExporter}; use opentelemetry::trace::TracerProvider; use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider}; use opentelemetry_sdk::trace::{ @@ -116,6 +113,12 @@ use tracing::Subscriber; use tracing::level_filters::LevelFilter; use tracing_subscriber::layer::SubscriberExt; +use crate::bridges::tracing::LogfireTracingLayer; +use crate::config::{ + AdvancedOptions, BoxedSpanProcessor, ConsoleOptions, MetricsOptions, SendToLogfire, +}; +use crate::internal::exporters::console::{ConsoleWriter, SimpleConsoleSpanExporter}; + mod bridges; pub mod config; pub mod exporters; @@ -234,11 +237,14 @@ impl FromStr for ConsoleMode { #[must_use = "call `.finish()` to complete logfire configuration."] pub fn configure() -> LogfireConfigBuilder { LogfireConfigBuilder { + local: false, send_to_logfire: None, token: None, console_options: None, additional_span_processors: Vec::new(), advanced: None, + metrics: None, + enable_metrics: true, install_panic_handler: false, default_level_filter: None, } @@ -246,8 +252,7 @@ pub fn configure() -> LogfireConfigBuilder { /// Builder for logfire configuration, returned from [`logfire::configure()`][configure]. pub struct LogfireConfigBuilder { - // TODO: support all options supported by the Python SDK - // local: bool, + local: bool, send_to_logfire: Option, token: Option, // service_name: Option, @@ -263,13 +268,14 @@ pub struct LogfireConfigBuilder { // tracer_provider: Option, // TODO: advanced Python options not yet supported by the Rust SDK - // metrics: MetricsOptions | Literal[False] | None = None, // scrubbing: ScrubbingOptions | Literal[False] | None = None, // inspect_arguments: bool | None = None, // sampling: SamplingOptions | None = None, // code_source: CodeSource | None = None, // distributed_tracing: bool | None = None, advanced: Option, + metrics: Option, + enable_metrics: bool, // Rust specific options install_panic_handler: bool, @@ -277,6 +283,16 @@ pub struct LogfireConfigBuilder { } impl LogfireConfigBuilder { + /// Call to configure Logfire for local use only. + /// + /// This prevents the configured `Logfire` from setting global `tracing`, `log` and `opentelemetry` state. + #[doc(hidden)] + #[must_use] + pub fn local(mut self) -> Self { + self.local = true; + self + } + /// Call to install a hook to log panics. /// /// Any existing panic hook will be preserved and called after the logfire panic hook. @@ -355,6 +371,22 @@ impl LogfireConfigBuilder { self } + /// Configure [metrics options](crate::config::MetricsOptions). + #[must_use] + pub fn with_metrics_options(mut self, metrics: MetricsOptions) -> Self { + self.metrics = Some(metrics); + self + } + + /// Whether to enable metrics. + /// + /// If set to false, this will override [`with_metrics_options`][Self::with_metrics_options]. + #[must_use] + pub fn enable_metrics(mut self, enable: bool) -> Self { + self.enable_metrics = enable; + self + } + /// Finish configuring Logfire. /// /// Because this configures global state for the opentelemetry SDK, this can typically only ever be called once per program. @@ -364,52 +396,43 @@ impl LogfireConfigBuilder { /// See [`ConfigureError`] for possible errors. pub fn finish(self) -> Result { let LogfireParts { + local, tracer, subscriber, tracer_provider, - base_url, - http_headers, - send_to_logfire, + meter_provider, + .. } = self.build_parts(None)?; - tracing::subscriber::set_global_default(subscriber)?; - let logger = bridges::log::LogfireLogger::init(tracer.inner.clone()); - log::set_logger(logger)?; - log::set_max_level(logger.max_level()); + if !local { + tracing::subscriber::set_global_default(subscriber.clone())?; + let logger = bridges::log::LogfireLogger::init(tracer.inner.clone()); + log::set_logger(logger)?; + log::set_max_level(logger.max_level()); - GLOBAL_TRACER - .set(tracer) - .map_err(|_| ConfigureError::AlreadyConfigured)?; + GLOBAL_TRACER + .set(tracer.clone()) + .map_err(|_| ConfigureError::AlreadyConfigured)?; - let propagator = opentelemetry::propagation::TextMapCompositePropagator::new(vec![ - Box::new(opentelemetry_sdk::propagation::TraceContextPropagator::new()), - Box::new(opentelemetry_sdk::propagation::BaggagePropagator::new()), - ]); - opentelemetry::global::set_text_map_propagator(propagator); - - // setup metrics only if sending to logfire - let meter_provider = if send_to_logfire { - let metric_reader = - PeriodicReader::builder(exporters::metric_exporter(&base_url, http_headers)?) - .build(); - - let meter_provider = SdkMeterProvider::builder() - .with_reader(metric_reader) - .build(); + let propagator = opentelemetry::propagation::TextMapCompositePropagator::new(vec![ + Box::new(opentelemetry_sdk::propagation::TraceContextPropagator::new()), + Box::new(opentelemetry_sdk::propagation::BaggagePropagator::new()), + ]); + opentelemetry::global::set_text_map_propagator(propagator); + println!("setting meter provider"); opentelemetry::global::set_meter_provider(meter_provider.clone()); - - Some(meter_provider) - } else { - None - }; + } Ok(ShutdownHandler { tracer_provider, + tracer, + subscriber, meter_provider, }) } + #[expect(clippy::too_many_lines)] fn build_parts( self, env: Option<&HashMap>, @@ -444,6 +467,10 @@ impl LogfireConfigBuilder { tracer_provider_builder.with_id_generator(UlidIdGenerator::new()); }; + if let Some(resource) = advanced_options.resource.clone() { + tracer_provider_builder = tracer_provider_builder.with_resource(resource); + } + let mut http_headers: Option> = None; if send_to_logfire { @@ -512,11 +539,36 @@ impl LogfireConfigBuilder { ) .with(LogfireTracingLayer(tracer.clone())); + let mut meter_provider_builder = SdkMeterProvider::builder(); + + if send_to_logfire && self.enable_metrics { + let metric_reader = PeriodicReader::builder(exporters::metric_exporter( + &advanced_options.base_url, + http_headers, + )?) + .build(); + + meter_provider_builder = meter_provider_builder.with_reader(metric_reader); + }; + + if let Some(metrics) = self.metrics.filter(|_| self.enable_metrics) { + for reader in metrics.additional_readers { + meter_provider_builder = meter_provider_builder.with_reader(reader); + } + } + + if let Some(resource) = advanced_options.resource { + meter_provider_builder = meter_provider_builder.with_resource(resource); + } + + let meter_provider = meter_provider_builder.build(); + if self.install_panic_handler { install_panic_handler(); } Ok(LogfireParts { + local: self.local, tracer: LogfireTracer { inner: tracer, handle_panics: self.install_panic_handler, @@ -524,8 +576,8 @@ impl LogfireConfigBuilder { }, subscriber: Arc::new(subscriber), tracer_provider, - base_url: advanced_options.base_url, - http_headers, + meter_provider, + #[cfg(test)] send_to_logfire, }) } @@ -535,11 +587,13 @@ impl LogfireConfigBuilder { /// /// Calling `.shutdown()` will flush the logfire exporters and make further /// logfire calls into no-ops. -#[derive(Debug, Clone)] +#[derive(Clone)] #[must_use = "this should be kept alive until logging should be stopped"] pub struct ShutdownHandler { tracer_provider: SdkTracerProvider, - meter_provider: Option, + tracer: LogfireTracer, + subscriber: Arc, + meter_provider: SdkMeterProvider, } impl ShutdownHandler { @@ -555,21 +609,20 @@ impl ShutdownHandler { self.tracer_provider .shutdown() .map_err(|e| ConfigureError::Other(e.into()))?; - if let Some(meter_provider) = &self.meter_provider { - meter_provider - .shutdown() - .map_err(|e| ConfigureError::Other(e.into()))?; - } + self.meter_provider + .shutdown() + .map_err(|e| ConfigureError::Other(e.into()))?; Ok(()) } } struct LogfireParts { + local: bool, tracer: LogfireTracer, subscriber: Arc, tracer_provider: SdkTracerProvider, - base_url: String, - http_headers: Option>, + meter_provider: SdkMeterProvider, + #[cfg(test)] send_to_logfire: bool, } @@ -626,6 +679,8 @@ fn get_optional_env( } } } + +#[derive(Clone)] struct LogfireTracer { inner: Tracer, handle_panics: bool, @@ -661,17 +716,28 @@ fn try_with_logfire_tracer(f: impl FnOnce(&LogfireTracer) -> R) -> Option /// Helper for installing a logfire guard locally to a thread. /// /// This is a bit of a mess, it's only implemented far enough to make tests pass... -#[cfg(test)] -struct LocalLogfireGuard { +#[doc(hidden)] +pub struct LocalLogfireGuard { prior: Option, - /// Tracing subscriber which can be used to subscribe tracing - subscriber: Arc, /// Shutdown handler #[allow(dead_code)] shutdown_handler: ShutdownHandler, } -#[cfg(test)] +impl LocalLogfireGuard { + /// Get the current tracer. + #[must_use] + pub fn subscriber(&self) -> Arc { + self.shutdown_handler.subscriber.clone() + } + + /// Ge the current meter provider + #[must_use] + pub fn meter_provider(&self) -> &SdkMeterProvider { + &self.shutdown_handler.meter_provider + } +} + impl Drop for LocalLogfireGuard { fn drop(&mut self) { // FIXME: if drop order is not consistent with creation order, does this create strange @@ -682,1588 +748,27 @@ impl Drop for LocalLogfireGuard { } } -#[cfg(test)] -#[expect(clippy::needless_pass_by_value)] // might consume in the future, leave it for now -fn set_local_logfire(config: LogfireConfigBuilder) -> Result { - let LogfireParts { - tracer, - subscriber, - tracer_provider, - .. - } = config.build_parts(None)?; - - let prior = LOCAL_TRACER.with_borrow_mut(|local_logfire| local_logfire.replace(tracer)); +#[doc(hidden)] // used in tests +#[must_use] +pub fn set_local_logfire(shutdown_handler: ShutdownHandler) -> LocalLogfireGuard { + let prior = LOCAL_TRACER + .with_borrow_mut(|local_logfire| local_logfire.replace(shutdown_handler.tracer.clone())); // TODO: logs?? // TODO: metrics?? - Ok(LocalLogfireGuard { + LocalLogfireGuard { prior, - subscriber, - shutdown_handler: ShutdownHandler { - tracer_provider, - meter_provider: None, - }, - }) + shutdown_handler, + } } #[cfg(test)] -mod tests { - use std::{ - collections::{HashMap, hash_map::Entry}, - future::Future, - pin::Pin, - sync::atomic::{AtomicU64, Ordering}, - time::SystemTime, - }; - - use insta::assert_debug_snapshot; - use opentelemetry::{ - Value, - trace::{SpanId, TraceId}, - }; - use opentelemetry_sdk::{ - error::OTelSdkResult, - trace::{IdGenerator, InMemorySpanExporterBuilder, SpanData, SpanExporter}, - }; - use tracing::Level; - - use super::*; - - #[derive(Debug)] - pub struct DeterministicIdGenerator { - next_trace_id: AtomicU64, - next_span_id: AtomicU64, - } - - impl IdGenerator for DeterministicIdGenerator { - fn new_trace_id(&self) -> opentelemetry::trace::TraceId { - TraceId::from_u128(self.next_trace_id.fetch_add(1, Ordering::Relaxed).into()) - } - - fn new_span_id(&self) -> opentelemetry::trace::SpanId { - SpanId::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed)) - } - } - - impl DeterministicIdGenerator { - pub fn new() -> Self { - // start at OxF0 because 0 is reserved for invalid IDs, - // and if we have a couple of bytes used, it's a more interesting check of - // the hex formatting - Self { - next_trace_id: 0xF0.into(), - next_span_id: 0xF0.into(), - } - } - } - - #[derive(Debug)] - pub struct DeterministicExporter { - exporter: Inner, - next_timestamp: u64, - timestamp_remap: HashMap, - // Information used to adjust line number to help minimise test churn - file: &'static str, - line_offset: u32, - } - - impl SpanExporter for DeterministicExporter { - fn export( - &mut self, - mut batch: Vec, - ) -> Pin + Send>> { - for span in &mut batch { - // By remapping timestamps to deterministic values, we should find that - // - pending spans have the same start time as their real span - // - pending spans also have the same end as start - span.start_time = self.remap_timestamp(span.start_time); - span.end_time = self.remap_timestamp(span.end_time); - - let mut remap_line = false; - - for attr in &mut span.attributes { - // thread info is not deterministic - // nor are timings - if attr.key.as_str() == "thread.id" - || attr.key.as_str() == "busy_ns" - || attr.key.as_str() == "idle_ns" - { - attr.value = 0.into(); - } - - // to minimize churn on tests, remap line numbers in the test to be relative to - // the test function - if attr.key.as_str() == "code.filepath" && attr.value.as_str() == self.file { - remap_line = true; - } - } - - if remap_line { - for attr in &mut span.attributes { - if attr.key.as_str() == "code.lineno" { - if let Value::I64(line) = &mut attr.value { - *line -= i64::from(self.line_offset); - } - } - - // panic location - if attr.key.as_str() == "location" { - let string_value = attr.value.as_str(); - let mut parts = string_value.splitn(3, ':'); - let file = parts.next().unwrap(); - let line = parts.next().unwrap().parse::().unwrap() - - i64::from(self.line_offset); - let column = parts.next().unwrap(); - attr.value = format!("{file}:{line}:{column}").into(); - } - } - } - - for event in &mut span.events.events { - event.timestamp = self.remap_timestamp(event.timestamp); - } - } - self.exporter.export(batch) - } - } - - impl DeterministicExporter { - /// Create deterministic exporter, feeding it current file and line. - pub fn new(exporter: Inner, file: &'static str, line_offset: u32) -> Self { - Self { - exporter, - next_timestamp: 0, - timestamp_remap: HashMap::new(), - file, - line_offset, - } - } - - fn remap_timestamp(&mut self, from: SystemTime) -> SystemTime { - match self.timestamp_remap.entry(from) { - Entry::Occupied(entry) => *entry.get(), - Entry::Vacant(entry) => { - let new_timestamp = SystemTime::UNIX_EPOCH - + std::time::Duration::from_secs(self.next_timestamp); - self.next_timestamp += 1; - *entry.insert(new_timestamp) - } - } - } - } +mod test_utils; - #[expect(clippy::too_many_lines)] - #[test] - fn test_basic_span() { - let exporter = InMemorySpanExporterBuilder::new().build(); - - let config = crate::configure() - .send_to_logfire(false) - .with_additional_span_processor(SimpleSpanProcessor::new(Box::new( - DeterministicExporter::new(exporter.clone(), file!(), line!()), - ))) - .install_panic_handler() - .with_default_level_filter(LevelFilter::TRACE) - .with_advanced_options( - AdvancedOptions::default().with_id_generator(DeterministicIdGenerator::new()), - ); - - let guard = set_local_logfire(config).unwrap(); - - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - tracing::subscriber::with_default(guard.subscriber.clone(), || { - let root = span!("root span").entered(); - let _ = span!("hello world span").entered(); - let _ = span!(level: Level::DEBUG, "debug span"); - let _ = - span!(parent: &root, level: Level::DEBUG, "debug span with explicit parent"); - info!("hello world log"); - panic!("oh no!"); - }); - })) - .unwrap_err(); - - let spans = exporter.get_finished_spans().unwrap(); - assert_debug_snapshot!(spans, @r#" - [ - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f1, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "root span", - start_time: SystemTime { - tv_sec: 0, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 0, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 12, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "root span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 9, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "pending_span", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f3, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f2, - span_kind: Internal, - name: "hello world span", - start_time: SystemTime { - tv_sec: 1, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 1, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 13, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "hello world span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 9, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "pending_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.pending_parent_id", - ), - value: String( - Owned( - "00000000000000f0", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f2, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "hello world span", - start_time: SystemTime { - tv_sec: 1, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 2, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 13, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "hello world span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 9, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "span", - ), - ), - }, - KeyValue { - key: Static( - "busy_ns", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "idle_ns", - ), - value: I64( - 0, - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f5, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f4, - span_kind: Internal, - name: "debug span", - start_time: SystemTime { - tv_sec: 3, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 3, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 14, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "debug span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 5, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "pending_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.pending_parent_id", - ), - value: String( - Owned( - "00000000000000f0", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f4, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "debug span", - start_time: SystemTime { - tv_sec: 3, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 4, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 14, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "debug span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 5, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "span", - ), - ), - }, - KeyValue { - key: Static( - "busy_ns", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "idle_ns", - ), - value: I64( - 0, - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f7, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f6, - span_kind: Internal, - name: "debug span with explicit parent", - start_time: SystemTime { - tv_sec: 5, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 5, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 16, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "debug span with explicit parent", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 5, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "pending_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.pending_parent_id", - ), - value: String( - Owned( - "00000000000000f0", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f6, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "debug span with explicit parent", - start_time: SystemTime { - tv_sec: 5, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 6, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 16, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "debug span with explicit parent", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 5, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "span", - ), - ), - }, - KeyValue { - key: Static( - "busy_ns", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "idle_ns", - ), - value: I64( - 0, - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f8, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "hello world log", - start_time: SystemTime { - tv_sec: 7, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 7, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "hello world log", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 9, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "log", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Static( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 17, - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f9, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 00000000000000f0, - span_kind: Internal, - name: "panic: {message}", - start_time: SystemTime { - tv_sec: 8, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 8, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "location", - ), - value: String( - Owned( - "src/lib.rs:18:17", - ), - ), - }, - KeyValue { - key: Static( - "backtrace", - ), - value: String( - Owned( - "disabled backtrace", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "panic: oh no!", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 17, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "log", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Static( - "{\"type\":\"object\",\"properties\":{\"location\":{},\"backtrace\":{}}}", - ), - ), - }, - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - -271, - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire", - ), - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - SpanData { - span_context: SpanContext { - trace_id: 000000000000000000000000000000f0, - span_id: 00000000000000f0, - trace_flags: TraceFlags( - 1, - ), - is_remote: false, - trace_state: TraceState( - None, - ), - }, - parent_span_id: 0000000000000000, - span_kind: Internal, - name: "root span", - start_time: SystemTime { - tv_sec: 0, - tv_nsec: 0, - }, - end_time: SystemTime { - tv_sec: 9, - tv_nsec: 0, - }, - attributes: [ - KeyValue { - key: Static( - "code.filepath", - ), - value: String( - Static( - "src/lib.rs", - ), - ), - }, - KeyValue { - key: Static( - "code.namespace", - ), - value: String( - Static( - "logfire::tests", - ), - ), - }, - KeyValue { - key: Static( - "code.lineno", - ), - value: I64( - 12, - ), - }, - KeyValue { - key: Static( - "thread.id", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "thread.name", - ), - value: String( - Owned( - "tests::test_basic_span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.msg", - ), - value: String( - Owned( - "root span", - ), - ), - }, - KeyValue { - key: Static( - "logfire.json_schema", - ), - value: String( - Owned( - "{\"type\":\"object\",\"properties\":{}}", - ), - ), - }, - KeyValue { - key: Static( - "logfire.level_num", - ), - value: I64( - 9, - ), - }, - KeyValue { - key: Static( - "logfire.span_type", - ), - value: String( - Static( - "span", - ), - ), - }, - KeyValue { - key: Static( - "busy_ns", - ), - value: I64( - 0, - ), - }, - KeyValue { - key: Static( - "idle_ns", - ), - value: I64( - 0, - ), - }, - ], - dropped_attributes_count: 0, - events: SpanEvents { - events: [], - dropped_count: 0, - }, - links: SpanLinks { - links: [], - dropped_count: 0, - }, - status: Unset, - instrumentation_scope: InstrumentationScope { - name: "logfire", - version: None, - schema_url: None, - attributes: [], - }, - }, - ] - "#); - } +#[cfg(test)] +mod tests { + use crate::{ConfigureError, config::SendToLogfire}; #[test] fn test_send_to_logfire() { diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 0000000..aba5e31 --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,210 @@ +#![allow(dead_code)] // used by lib and test suites individually + +use std::{ + collections::{HashMap, hash_map::Entry}, + future::Future, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::SystemTime, +}; + +use async_trait::async_trait; +use opentelemetry::{ + Value, + trace::{SpanId, TraceId}, +}; +use opentelemetry_sdk::{ + error::OTelSdkResult, + metrics::{ + Temporality, + data::{ResourceMetrics, Sum}, + exporter::PushMetricExporter, + }, + trace::{IdGenerator, SpanData, SpanExporter}, +}; + +#[derive(Debug)] +pub struct DeterministicIdGenerator { + next_trace_id: AtomicU64, + next_span_id: AtomicU64, +} + +impl IdGenerator for DeterministicIdGenerator { + fn new_trace_id(&self) -> opentelemetry::trace::TraceId { + TraceId::from_u128(self.next_trace_id.fetch_add(1, Ordering::Relaxed).into()) + } + + fn new_span_id(&self) -> opentelemetry::trace::SpanId { + SpanId::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed)) + } +} + +impl DeterministicIdGenerator { + pub fn new() -> Self { + // start at OxF0 because 0 is reserved for invalid IDs, + // and if we have a couple of bytes used, it's a more interesting check of + // the hex formatting + Self { + next_trace_id: 0xF0.into(), + next_span_id: 0xF0.into(), + } + } +} + +#[derive(Debug)] +pub struct DeterministicExporter { + exporter: Inner, + timestamp_remap: Arc>, + // Information used to adjust line number to help minimise test churn + file: &'static str, + line_offset: u32, +} + +impl DeterministicExporter { + pub fn inner(&self) -> &Inner { + &self.exporter + } +} + +impl SpanExporter for DeterministicExporter { + fn export( + &mut self, + mut batch: Vec, + ) -> Pin + Send>> { + for span in &mut batch { + // By remapping timestamps to deterministic values, we should find that + // - pending spans have the same start time as their real span + // - pending spans also have the same end as start + span.start_time = self.remap_timestamp(span.start_time); + span.end_time = self.remap_timestamp(span.end_time); + + let mut remap_line = false; + + for attr in &mut span.attributes { + // thread info is not deterministic + // nor are timings + if attr.key.as_str() == "thread.id" + || attr.key.as_str() == "busy_ns" + || attr.key.as_str() == "idle_ns" + { + attr.value = 0.into(); + } + + // to minimize churn on tests, remap line numbers in the test to be relative to + // the test function + if attr.key.as_str() == "code.filepath" && attr.value.as_str() == self.file { + remap_line = true; + } + } + + if remap_line { + for attr in &mut span.attributes { + if attr.key.as_str() == "code.lineno" { + if let Value::I64(line) = &mut attr.value { + *line -= i64::from(self.line_offset); + } + } + + // panic location + if attr.key.as_str() == "location" { + let string_value = attr.value.as_str(); + let mut parts = string_value.splitn(3, ':'); + let file = parts.next().unwrap(); + let line = parts.next().unwrap().parse::().unwrap() + - i64::from(self.line_offset); + let column = parts.next().unwrap(); + attr.value = format!("{file}:{line}:{column}").into(); + } + } + } + + for event in &mut span.events.events { + event.timestamp = self.remap_timestamp(event.timestamp); + } + } + self.exporter.export(batch) + } +} + +#[async_trait] +impl PushMetricExporter for DeterministicExporter { + async fn export(&self, metrics: &mut ResourceMetrics) -> OTelSdkResult { + let timestamp_remap = self.timestamp_remap.clone(); + for scope in &mut metrics.scope_metrics { + for metric in &mut scope.metrics { + if let Some(sum) = (*metric.data).as_mut().downcast_mut::>() { + sum.start_time = timestamp_remap + .lock() + .unwrap() + .remap_timestamp(sum.start_time); + sum.time = timestamp_remap.lock().unwrap().remap_timestamp(sum.time); + + for data_point in &mut sum.data_points { + data_point + .attributes + .sort_by_cached_key(|kv| kv.key.to_string()); + } + } + } + } + self.exporter.export(metrics).await + } + + async fn force_flush(&self) -> OTelSdkResult { + self.exporter.force_flush().await + } + + fn shutdown(&self) -> OTelSdkResult { + self.exporter.shutdown() + } + + fn temporality(&self) -> Temporality { + self.exporter.temporality() + } +} + +impl DeterministicExporter { + /// Create deterministic exporter, feeding it current file and line. + pub fn new(exporter: Inner, file: &'static str, line_offset: u32) -> Self { + Self { + exporter, + timestamp_remap: Arc::new(Mutex::new(TimestampRemapper::new())), + file, + line_offset, + } + } + + fn remap_timestamp(&mut self, from: SystemTime) -> SystemTime { + self.timestamp_remap.lock().unwrap().remap_timestamp(from) + } +} + +#[derive(Debug)] +struct TimestampRemapper { + next_timestamp: u64, + timestamp_remap: HashMap, +} + +impl TimestampRemapper { + fn new() -> Self { + Self { + next_timestamp: 0, + timestamp_remap: HashMap::new(), + } + } + + fn remap_timestamp(&mut self, from: SystemTime) -> SystemTime { + match self.timestamp_remap.entry(from) { + Entry::Occupied(entry) => *entry.get(), + Entry::Vacant(entry) => { + let new_timestamp = + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.next_timestamp); + self.next_timestamp += 1; + *entry.insert(new_timestamp) + } + } + } +} diff --git a/tests/test_basic_exports.rs b/tests/test_basic_exports.rs new file mode 100644 index 0000000..0c7132f --- /dev/null +++ b/tests/test_basic_exports.rs @@ -0,0 +1,1646 @@ +//! Basic snapshot tests for data produced by the logfire APIs. + +use std::sync::Arc; + +use insta::assert_debug_snapshot; +use opentelemetry_sdk::{ + Resource, + metrics::{ + InMemoryMetricExporterBuilder, ManualReader, + data::ResourceMetrics, + exporter::{self}, + reader::MetricReader, + }, + trace::{InMemorySpanExporterBuilder, SimpleSpanProcessor}, +}; +use tracing::{Level, level_filters::LevelFilter}; + +use logfire::{ + config::{AdvancedOptions, MetricsOptions}, + info, span, +}; + +#[path = "../src/test_utils.rs"] +mod test_utils; + +use test_utils::{DeterministicExporter, DeterministicIdGenerator}; + +#[expect(clippy::too_many_lines)] +#[test] +fn test_basic_span() { + let exporter = InMemorySpanExporterBuilder::new().build(); + + let handler = logfire::configure() + .local() + .send_to_logfire(false) + .with_additional_span_processor(SimpleSpanProcessor::new(Box::new( + DeterministicExporter::new(exporter.clone(), file!(), line!()), + ))) + .install_panic_handler() + .with_default_level_filter(LevelFilter::TRACE) + .with_advanced_options( + AdvancedOptions::default().with_id_generator(DeterministicIdGenerator::new()), + ) + .finish() + .unwrap(); + + let guard = logfire::set_local_logfire(handler); + + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::subscriber::with_default(guard.subscriber(), || { + let root = span!("root span").entered(); + let _ = span!("hello world span").entered(); + let _ = span!(level: Level::DEBUG, "debug span"); + let _ = span!(parent: &root, level: Level::DEBUG, "debug span with explicit parent"); + info!("hello world log"); + panic!("oh no!"); + }); + })) + .unwrap_err(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_debug_snapshot!(spans, @r#" + [ + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f1, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "root span", + start_time: SystemTime { + tv_sec: 0, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 0, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 14, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "root span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 9, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "pending_span", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f3, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f2, + span_kind: Internal, + name: "hello world span", + start_time: SystemTime { + tv_sec: 1, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 1, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 15, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "hello world span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 9, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "pending_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.pending_parent_id", + ), + value: String( + Owned( + "00000000000000f0", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f2, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "hello world span", + start_time: SystemTime { + tv_sec: 1, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 2, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 15, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "hello world span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 9, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "span", + ), + ), + }, + KeyValue { + key: Static( + "busy_ns", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "idle_ns", + ), + value: I64( + 0, + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f5, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f4, + span_kind: Internal, + name: "debug span", + start_time: SystemTime { + tv_sec: 3, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 3, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 16, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "debug span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 5, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "pending_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.pending_parent_id", + ), + value: String( + Owned( + "00000000000000f0", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f4, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "debug span", + start_time: SystemTime { + tv_sec: 3, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 4, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 16, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "debug span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 5, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "span", + ), + ), + }, + KeyValue { + key: Static( + "busy_ns", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "idle_ns", + ), + value: I64( + 0, + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f7, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f6, + span_kind: Internal, + name: "debug span with explicit parent", + start_time: SystemTime { + tv_sec: 5, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 5, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 17, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "debug span with explicit parent", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 5, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "pending_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.pending_parent_id", + ), + value: String( + Owned( + "00000000000000f0", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f6, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "debug span with explicit parent", + start_time: SystemTime { + tv_sec: 5, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 6, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 17, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "debug span with explicit parent", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 5, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "span", + ), + ), + }, + KeyValue { + key: Static( + "busy_ns", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "idle_ns", + ), + value: I64( + 0, + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f8, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "hello world log", + start_time: SystemTime { + tv_sec: 7, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 7, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "hello world log", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 9, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "log", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Static( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 18, + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f9, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 00000000000000f0, + span_kind: Internal, + name: "panic: {message}", + start_time: SystemTime { + tv_sec: 8, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 8, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "location", + ), + value: String( + Owned( + "tests/test_basic_exports.rs:56:13", + ), + ), + }, + KeyValue { + key: Static( + "backtrace", + ), + value: String( + Owned( + "disabled backtrace", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "panic: oh no!", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 17, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "log", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Static( + "{\"type\":\"object\",\"properties\":{\"location\":{},\"backtrace\":{}}}", + ), + ), + }, + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "src/lib.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 646, + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "logfire", + ), + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + SpanData { + span_context: SpanContext { + trace_id: 000000000000000000000000000000f0, + span_id: 00000000000000f0, + trace_flags: TraceFlags( + 1, + ), + is_remote: false, + trace_state: TraceState( + None, + ), + }, + parent_span_id: 0000000000000000, + span_kind: Internal, + name: "root span", + start_time: SystemTime { + tv_sec: 0, + tv_nsec: 0, + }, + end_time: SystemTime { + tv_sec: 9, + tv_nsec: 0, + }, + attributes: [ + KeyValue { + key: Static( + "code.filepath", + ), + value: String( + Static( + "tests/test_basic_exports.rs", + ), + ), + }, + KeyValue { + key: Static( + "code.namespace", + ), + value: String( + Static( + "test_basic_exports", + ), + ), + }, + KeyValue { + key: Static( + "code.lineno", + ), + value: I64( + 14, + ), + }, + KeyValue { + key: Static( + "thread.id", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "thread.name", + ), + value: String( + Owned( + "test_basic_span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.msg", + ), + value: String( + Owned( + "root span", + ), + ), + }, + KeyValue { + key: Static( + "logfire.json_schema", + ), + value: String( + Owned( + "{\"type\":\"object\",\"properties\":{}}", + ), + ), + }, + KeyValue { + key: Static( + "logfire.level_num", + ), + value: I64( + 9, + ), + }, + KeyValue { + key: Static( + "logfire.span_type", + ), + value: String( + Static( + "span", + ), + ), + }, + KeyValue { + key: Static( + "busy_ns", + ), + value: I64( + 0, + ), + }, + KeyValue { + key: Static( + "idle_ns", + ), + value: I64( + 0, + ), + }, + ], + dropped_attributes_count: 0, + events: SpanEvents { + events: [], + dropped_count: 0, + }, + links: SpanLinks { + links: [], + dropped_count: 0, + }, + status: Unset, + instrumentation_scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + }, + ] + "#); +} + +#[derive(Clone, Debug)] +struct SharedManualReader { + reader: Arc, +} + +impl SharedManualReader { + fn new(reader: ManualReader) -> Self { + Self { + reader: Arc::new(reader), + } + } + + async fn export(&self, exporter: &mut dyn exporter::PushMetricExporter) { + let mut metrics = ResourceMetrics { + resource: Resource::builder_empty().build(), + scope_metrics: Vec::new(), + }; + dbg!(&metrics); + self.reader.collect(&mut metrics).unwrap(); + dbg!(&metrics); + exporter.export(&mut metrics).await.unwrap(); + } +} + +impl MetricReader for SharedManualReader { + fn register_pipeline(&self, pipeline: std::sync::Weak) { + self.reader.register_pipeline(pipeline); + } + + fn collect( + &self, + rm: &mut opentelemetry_sdk::metrics::data::ResourceMetrics, + ) -> opentelemetry_sdk::metrics::MetricResult<()> { + self.reader.collect(rm) + } + + fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.reader.force_flush() + } + + fn shutdown(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.reader.shutdown() + } + + fn temporality( + &self, + kind: opentelemetry_sdk::metrics::InstrumentKind, + ) -> opentelemetry_sdk::metrics::Temporality { + self.reader.temporality(kind) + } +} + +#[tokio::test] +async fn test_basic_metrics() { + let mut exporter = DeterministicExporter::new( + InMemoryMetricExporterBuilder::new().build(), + file!(), + line!(), + ); + + let reader = SharedManualReader::new( + ManualReader::builder() + .with_temporality(opentelemetry_sdk::metrics::Temporality::Delta) + .build(), + ); + + let handler = logfire::configure() + .send_to_logfire(false) + .with_metrics_options(MetricsOptions::default().with_additional_reader(reader.clone())) + .with_advanced_options( + AdvancedOptions::default() + .with_resource(Resource::builder_empty().with_service_name("test").build()), + ) + .finish() + .unwrap(); + + let guard = logfire::set_local_logfire(handler.clone()); + + use opentelemetry::metrics::MeterProvider; + + let counter = guard + .meter_provider() + .meter("logfire") + .u64_counter("basic_counter") + .build(); + + counter.add(1, &[]); + reader.export(&mut exporter).await; + + counter.add(2, &[]); + reader.export(&mut exporter).await; + + handler.shutdown().unwrap(); + + let metrics = exporter.inner().get_finished_metrics().unwrap(); + + assert_debug_snapshot!(metrics, @r#" + [ + ResourceMetrics { + resource: Resource { + inner: ResourceInner { + attrs: { + Static( + "service.name", + ): String( + Static( + "test", + ), + ), + }, + schema_url: None, + }, + }, + scope_metrics: [ + ScopeMetrics { + scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + metrics: [ + Metric { + name: "basic_counter", + description: "", + unit: "", + data: Sum { + data_points: [ + SumDataPoint { + attributes: [], + value: 1, + exemplars: [], + }, + ], + start_time: SystemTime { + tv_sec: 0, + tv_nsec: 0, + }, + time: SystemTime { + tv_sec: 1, + tv_nsec: 0, + }, + temporality: Delta, + is_monotonic: true, + }, + }, + ], + }, + ], + }, + ResourceMetrics { + resource: Resource { + inner: ResourceInner { + attrs: { + Static( + "service.name", + ): String( + Static( + "test", + ), + ), + }, + schema_url: None, + }, + }, + scope_metrics: [ + ScopeMetrics { + scope: InstrumentationScope { + name: "logfire", + version: None, + schema_url: None, + attributes: [], + }, + metrics: [ + Metric { + name: "basic_counter", + description: "", + unit: "", + data: Sum { + data_points: [ + SumDataPoint { + attributes: [], + value: 2, + exemplars: [], + }, + ], + start_time: SystemTime { + tv_sec: 1, + tv_nsec: 0, + }, + time: SystemTime { + tv_sec: 2, + tv_nsec: 0, + }, + temporality: Delta, + is_monotonic: true, + }, + }, + ], + }, + ], + }, + ] + "#) +}