diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5a445b..d3dcc476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### New Features - Added a `Envelope::into_items` method, which returns an iterator over owned [`EnvelopeItem`s](https://docs.rs/sentry/0.46.2/sentry/protocol/enum.EnvelopeItem.html) in the [`Envelope`](https://docs.rs/sentry/0.46.2/sentry/struct.Envelope.html) ([#983](https://github.com/getsentry/sentry-rust/pull/983)). +- Add SDK protocol support for sending trace metric envelope items ([#1022](https://github.com/getsentry/sentry-rust/pull/1022)). +- Add `TraceMetric` and `TraceMetricType` types representing [trace metrics](https://develop.sentry.dev/sdk/telemetry/metrics/) ([#1026](https://github.com/getsentry/sentry-rust/pull/1026)). +- Add trace metric capture and batching in `sentry-core`. Metrics can be captured via `Hub::capture_metric` and are batched and sent as `trace_metric` envelope items. Controlled by the `metrics` feature flag and `ClientOptions::enable_metrics` ([#1026](https://github.com/getsentry/sentry-rust/pull/1026)). - Expose transport utilities ([#949](https://github.com/getsentry/sentry-rust/pull/949)) ### Fixes diff --git a/sentry-core/Cargo.toml b/sentry-core/Cargo.toml index 237a2b72..ef8c0355 100644 --- a/sentry-core/Cargo.toml +++ b/sentry-core/Cargo.toml @@ -25,6 +25,7 @@ client = ["rand"] test = ["client", "release-health"] release-health = [] logs = [] +metrics = [] [dependencies] log = { version = "0.4.8", optional = true, features = ["std"] } diff --git a/sentry-core/src/batcher.rs b/sentry-core/src/batcher.rs index dfc28bf5..441ed84d 100644 --- a/sentry-core/src/batcher.rs +++ b/sentry-core/src/batcher.rs @@ -8,6 +8,8 @@ use crate::client::TransportArc; use crate::protocol::EnvelopeItem; use crate::Envelope; use sentry_types::protocol::v7::Log; +#[cfg(feature = "metrics")] +use sentry_types::protocol::v7::TraceMetric; // Flush when there's 100 items in the buffer const MAX_ITEMS: usize = 100; @@ -40,6 +42,11 @@ impl Batch for Log { const TYPE_NAME: &str = "logs"; } +#[cfg(feature = "metrics")] +impl Batch for TraceMetric { + const TYPE_NAME: &str = "metrics"; +} + /// Accumulates items in the queue and submits them through the transport when one of the flushing /// conditions is met. pub(crate) struct Batcher { @@ -154,7 +161,7 @@ impl Drop for Batcher { } } -#[cfg(all(test, feature = "test"))] +#[cfg(all(test, feature = "test", feature = "logs"))] mod tests { use crate::logger_info; use crate::test; diff --git a/sentry-core/src/client.rs b/sentry-core/src/client.rs index 003d3edc..f80b021c 100644 --- a/sentry-core/src/client.rs +++ b/sentry-core/src/client.rs @@ -1,6 +1,6 @@ use std::any::TypeId; use std::borrow::Cow; -#[cfg(feature = "logs")] +#[cfg(any(feature = "logs", feature = "metrics"))] use std::collections::BTreeMap; use std::fmt; use std::panic::RefUnwindSafe; @@ -12,7 +12,7 @@ use crate::protocol::SessionUpdate; use rand::random; use sentry_types::random_uuid; -#[cfg(feature = "logs")] +#[cfg(any(feature = "logs", feature = "metrics"))] use crate::batcher::Batcher; use crate::constants::SDK_INFO; use crate::protocol::{ClientSdkInfo, Event}; @@ -24,6 +24,10 @@ use crate::SessionMode; use crate::{ClientOptions, Envelope, Hub, Integration, Scope, Transport}; #[cfg(feature = "logs")] use sentry_types::protocol::v7::Context; +#[cfg(all(feature = "metrics", not(feature = "logs")))] +use sentry_types::protocol::v7::LogAttribute; +#[cfg(feature = "metrics")] +use sentry_types::protocol::v7::TraceMetric; #[cfg(feature = "logs")] use sentry_types::protocol::v7::{Log, LogAttribute}; @@ -59,8 +63,12 @@ pub struct Client { session_flusher: RwLock>, #[cfg(feature = "logs")] logs_batcher: RwLock>>, + #[cfg(feature = "metrics")] + metrics_batcher: RwLock>>, #[cfg(feature = "logs")] default_log_attributes: Option>, + #[cfg(feature = "metrics")] + default_metric_attributes: Option>, integrations: Vec<(TypeId, Arc)>, pub(crate) sdk_info: ClientSdkInfo, } @@ -91,6 +99,13 @@ impl Clone for Client { None }); + #[cfg(feature = "metrics")] + let metrics_batcher = RwLock::new(if self.options.enable_metrics { + Some(Batcher::new(transport.clone())) + } else { + None + }); + Client { options: self.options.clone(), transport, @@ -98,8 +113,12 @@ impl Clone for Client { session_flusher, #[cfg(feature = "logs")] logs_batcher, + #[cfg(feature = "metrics")] + metrics_batcher, #[cfg(feature = "logs")] default_log_attributes: self.default_log_attributes.clone(), + #[cfg(feature = "metrics")] + default_metric_attributes: self.default_metric_attributes.clone(), integrations: self.integrations.clone(), sdk_info: self.sdk_info.clone(), } @@ -176,6 +195,13 @@ impl Client { None }); + #[cfg(feature = "metrics")] + let metrics_batcher = RwLock::new(if options.enable_metrics { + Some(Batcher::new(transport.clone())) + } else { + None + }); + #[allow(unused_mut)] let mut client = Client { options, @@ -184,8 +210,12 @@ impl Client { session_flusher, #[cfg(feature = "logs")] logs_batcher, + #[cfg(feature = "metrics")] + metrics_batcher, #[cfg(feature = "logs")] default_log_attributes: None, + #[cfg(feature = "metrics")] + default_metric_attributes: None, integrations, sdk_info, }; @@ -193,6 +223,9 @@ impl Client { #[cfg(feature = "logs")] client.cache_default_log_attributes(); + #[cfg(feature = "metrics")] + client.cache_default_metric_attributes(); + client } @@ -247,6 +280,35 @@ impl Client { self.default_log_attributes = Some(attributes); } + #[cfg(feature = "metrics")] + fn cache_default_metric_attributes(&mut self) { + let mut attributes = BTreeMap::new(); + + if let Some(environment) = self.options.environment.as_ref() { + attributes.insert("sentry.environment".to_owned(), environment.clone().into()); + } + + if let Some(release) = self.options.release.as_ref() { + attributes.insert("sentry.release".to_owned(), release.clone().into()); + } + + attributes.insert( + "sentry.sdk.name".to_owned(), + self.sdk_info.name.to_owned().into(), + ); + + attributes.insert( + "sentry.sdk.version".to_owned(), + self.sdk_info.version.to_owned().into(), + ); + + if let Some(server) = &self.options.server_name { + attributes.insert("server.address".to_owned(), server.clone().into()); + } + + self.default_metric_attributes = Some(attributes); + } + pub(crate) fn get_integration(&self) -> Option<&I> where I: Integration, @@ -420,6 +482,10 @@ impl Client { if let Some(ref batcher) = *self.logs_batcher.read().unwrap() { batcher.flush(); } + #[cfg(feature = "metrics")] + if let Some(ref batcher) = *self.metrics_batcher.read().unwrap() { + batcher.flush(); + } if let Some(ref transport) = *self.transport.read().unwrap() { transport.flush(timeout.unwrap_or(self.options.shutdown_timeout)) } else { @@ -439,6 +505,8 @@ impl Client { drop(self.session_flusher.write().unwrap().take()); #[cfg(feature = "logs")] drop(self.logs_batcher.write().unwrap().take()); + #[cfg(feature = "metrics")] + drop(self.metrics_batcher.write().unwrap().take()); let transport_opt = self.transport.write().unwrap().take(); if let Some(transport) = transport_opt { sentry_debug!("client close; request transport to shut down"); @@ -493,6 +561,41 @@ impl Client { Some(log) } + + /// Captures a trace metric and sends it to Sentry. + #[cfg(feature = "metrics")] + pub fn capture_metric(&self, metric: TraceMetric, scope: &Scope) { + if !self.options.enable_metrics { + return; + } + if let Some(metric) = self.prepare_metric(metric, scope) { + if let Some(ref batcher) = *self.metrics_batcher.read().unwrap() { + batcher.enqueue(metric); + } + } + } + + /// Prepares a metric to be sent, setting the `trace_id` and other default attributes, and + /// processing it through `before_send_metric`. + #[cfg(feature = "metrics")] + fn prepare_metric(&self, mut metric: TraceMetric, scope: &Scope) -> Option { + scope.apply_to_metric(&mut metric, self.options.send_default_pii); + + if let Some(default_attributes) = self.default_metric_attributes.as_ref() { + for (key, val) in default_attributes.iter() { + metric + .attributes + .entry(key.to_owned()) + .or_insert(val.clone()); + } + } + + if let Some(ref func) = self.options.before_send_metric { + metric = func(metric)?; + } + + Some(metric) + } } // Make this unwind safe. It's not out of the box because of the diff --git a/sentry-core/src/clientoptions.rs b/sentry-core/src/clientoptions.rs index 02d5d5a9..62112127 100644 --- a/sentry-core/src/clientoptions.rs +++ b/sentry-core/src/clientoptions.rs @@ -7,6 +7,8 @@ use crate::constants::USER_AGENT; use crate::performance::TracesSampler; #[cfg(feature = "logs")] use crate::protocol::Log; +#[cfg(feature = "metrics")] +use crate::protocol::TraceMetric; use crate::protocol::{Breadcrumb, Event}; use crate::types::Dsn; use crate::{Integration, IntoDsn, TransportFactory}; @@ -172,6 +174,12 @@ pub struct ClientOptions { /// Determines whether captured structured logs should be sent to Sentry (defaults to false). #[cfg(feature = "logs")] pub enable_logs: bool, + /// Determines whether captured trace metrics should be sent to Sentry (defaults to true). + #[cfg(feature = "metrics")] + pub enable_metrics: bool, + /// Callback that is executed for each TraceMetric before sending. + #[cfg(feature = "metrics")] + pub before_send_metric: Option>, // Other options not documented in Unified API /// Disable SSL verification. /// @@ -278,6 +286,18 @@ impl fmt::Debug for ClientOptions { .field("enable_logs", &self.enable_logs) .field("before_send_log", &before_send_log); + #[cfg(feature = "metrics")] + { + let before_send_metric = { + #[derive(Debug)] + struct BeforeSendMetric; + self.before_send_metric.as_ref().map(|_| BeforeSendMetric) + }; + debug_struct + .field("enable_metrics", &self.enable_metrics) + .field("before_send_metric", &before_send_metric); + } + debug_struct.field("user_agent", &self.user_agent).finish() } } @@ -317,6 +337,10 @@ impl Default for ClientOptions { enable_logs: true, #[cfg(feature = "logs")] before_send_log: None, + #[cfg(feature = "metrics")] + enable_metrics: false, + #[cfg(feature = "metrics")] + before_send_metric: None, } } } diff --git a/sentry-core/src/hub.rs b/sentry-core/src/hub.rs index ebd70b0c..955170ee 100644 --- a/sentry-core/src/hub.rs +++ b/sentry-core/src/hub.rs @@ -4,6 +4,8 @@ use std::sync::{Arc, RwLock}; +#[cfg(feature = "metrics")] +use crate::protocol::TraceMetric; use crate::protocol::{Event, Level, Log, LogAttribute, LogLevel, Map, SessionStatus}; use crate::types::Uuid; use crate::{Integration, IntoBreadcrumbs, Scope, ScopeGuard}; @@ -255,4 +257,14 @@ impl Hub { client.capture_log(log, &top.scope); }} } + + /// Captures a trace metric. + #[cfg(feature = "metrics")] + pub fn capture_metric(&self, metric: TraceMetric) { + with_client_impl! {{ + let top = self.inner.with(|stack| stack.top().clone()); + let Some(ref client) = top.client else { return }; + client.capture_metric(metric, &top.scope); + }} + } } diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs index 6a2a1b5d..6a6c7c89 100644 --- a/sentry-core/src/lib.rs +++ b/sentry-core/src/lib.rs @@ -136,7 +136,7 @@ pub use crate::transport::{Transport, TransportFactory}; mod logger; // structured logging macros exported with `#[macro_export]` // client feature -#[cfg(all(feature = "client", feature = "logs"))] +#[cfg(all(feature = "client", any(feature = "logs", feature = "metrics")))] mod batcher; #[cfg(feature = "client")] mod client; diff --git a/sentry-core/src/performance.rs b/sentry-core/src/performance.rs index 5416746e..303b3b53 100644 --- a/sentry-core/src/performance.rs +++ b/sentry-core/src/performance.rs @@ -586,6 +586,13 @@ impl TransactionOrSpan { TransactionOrSpan::Span(span) => span.finish(), } } + + pub(crate) fn span_id(&self) -> SpanId { + match self { + TransactionOrSpan::Transaction(transaction) => transaction.get_trace_context().span_id, + TransactionOrSpan::Span(span) => span.get_span_id(), + } + } } #[derive(Debug)] diff --git a/sentry-core/src/scope/noop.rs b/sentry-core/src/scope/noop.rs index fc62120b..af3df0b5 100644 --- a/sentry-core/src/scope/noop.rs +++ b/sentry-core/src/scope/noop.rs @@ -2,6 +2,8 @@ use std::fmt; #[cfg(feature = "logs")] use crate::protocol::Log; +#[cfg(feature = "metrics")] +use crate::protocol::TraceMetric; use crate::protocol::{Context, Event, Level, User, Value}; use crate::TransactionOrSpan; @@ -119,6 +121,14 @@ impl Scope { minimal_unreachable!(); } + /// Applies the contained scoped data to fill a trace metric. + #[cfg(feature = "metrics")] + pub fn apply_to_metric(&self, metric: &mut TraceMetric, send_default_pii: bool) { + let _metric = metric; + let _send_default_pii = send_default_pii; + minimal_unreachable!(); + } + /// Set the given [`TransactionOrSpan`] as the active span for this scope. pub fn set_span(&mut self, span: Option) { let _ = span; diff --git a/sentry-core/src/scope/real.rs b/sentry-core/src/scope/real.rs index 590f3921..3b0cc5da 100644 --- a/sentry-core/src/scope/real.rs +++ b/sentry-core/src/scope/real.rs @@ -6,11 +6,15 @@ use std::sync::Mutex; use std::sync::{Arc, PoisonError, RwLock}; use crate::performance::TransactionOrSpan; +#[cfg(feature = "logs")] +use crate::protocol::Log; +#[cfg(any(feature = "logs", feature = "metrics"))] +use crate::protocol::LogAttribute; +#[cfg(feature = "metrics")] +use crate::protocol::TraceMetric; use crate::protocol::{ Attachment, Breadcrumb, Context, Event, Level, TraceContext, Transaction, User, Value, }; -#[cfg(feature = "logs")] -use crate::protocol::{Log, LogAttribute}; #[cfg(feature = "release-health")] use crate::session::Session; use crate::{Client, SentryTrace, TraceHeader, TraceHeadersIter}; @@ -399,6 +403,33 @@ impl Scope { } } + /// Applies the contained scoped data to a trace metric, setting the `trace_id`, `span_id`, + /// and certain default attributes. User PII attributes are only attached when + /// `send_default_pii` is `true`. + #[cfg(feature = "metrics")] + pub fn apply_to_metric(&self, metric: &mut TraceMetric, send_default_pii: bool) { + metric.trace_id = match self.span.as_ref().as_ref() { + Some(span) => span.get_trace_context().trace_id, + None => self.propagation_context.trace_id, + }; + + metric.span_id = metric + .span_id + .or_else(|| self.get_span().map(|ts| ts.span_id())); + + if !send_default_pii { + return; + } + + let Some(user) = self.user.as_ref() else { + return; + }; + + metric.insert_attribute("user.id", user.id.as_deref()); + metric.insert_attribute("user.name", user.username.as_deref()); + metric.insert_attribute("user.email", user.email.as_deref()); + } + /// Set the given [`TransactionOrSpan`] as the active span for this scope. pub fn set_span(&mut self, span: Option) { self.span = Arc::new(span); @@ -444,3 +475,22 @@ impl Scope { } } } + +#[cfg(feature = "metrics")] +trait TraceMetricExt { + fn insert_attribute(&mut self, key: K, value: V) + where + K: Into, + V: Into; +} + +#[cfg(feature = "metrics")] +impl TraceMetricExt for TraceMetric { + fn insert_attribute(&mut self, key: K, value: V) + where + K: Into, + V: Into, + { + self.attributes.insert(key, LogAttribute(value.into())); + } +} diff --git a/sentry-core/tests/metrics.rs b/sentry-core/tests/metrics.rs new file mode 100644 index 00000000..b9827e34 --- /dev/null +++ b/sentry-core/tests/metrics.rs @@ -0,0 +1,524 @@ +#![cfg(all(feature = "test", feature = "metrics"))] + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use sentry::protocol::{LogAttribute, TraceMetricType, User}; +use sentry_core::protocol::{EnvelopeItem, ItemContainer, Value}; +use sentry_core::test; +use sentry_core::{ClientOptions, Hub}; +use sentry_types::protocol::v7::TraceMetric; + +/// Test that metreics are sent when metrics are enabled. +#[test] +fn sent_when_enabled() { + let options = ClientOptions { + enable_metrics: true, + ..Default::default() + }; + + let mut envelopes = + test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + + assert_eq!(envelopes.len(), 1, "expected exactly one envelope"); + + let envelope = envelopes.pop().unwrap(); + + let mut items = envelope.into_items(); + let Some(item) = items.next() else { + panic!("Expected at least one item"); + }; + + assert!(items.next().is_none(), "Expected only one item"); + + let EnvelopeItem::ItemContainer(ItemContainer::TraceMetrics(mut metrics)) = item else { + panic!("Envelope item has unexpected structure"); + }; + + assert_eq!(metrics.len(), 1, "Expected exactly one metric"); + + let metric = metrics.pop().unwrap(); + assert!(matches!(metric, TraceMetric { + r#type: TraceMetricType::Counter, + name, + value: 1.0, + .. + } if name == "test")); +} + +/// Test that metrics are disabled (not sent) when disabled in the +/// [`ClientOptions`]. +#[test] +fn metrics_disabled_by_default() { + // Metrics are disabled by default. + let options: ClientOptions = Default::default(); + + let envelopes = test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + assert!( + envelopes.is_empty(), + "no envelopes should be captured when metrics disabled" + ) +} + +/// Test that no metrics are captured by a no-op call with +/// metrics enabled +#[test] +fn noop_sends_nothing() { + let options = ClientOptions { + enable_metrics: true, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options(|| (), options); + + assert!(envelopes.is_empty(), "no-op should not capture metrics"); +} + +/// Test that 100 metrics are sent in a single envelope. +#[test] +fn test_metrics_batching_at_limit() { + let options = ClientOptions { + enable_metrics: true, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options( + || { + (0..100) + .map(|i| format!("metric.{i}")) + .for_each(capture_test_metric); + }, + options, + ); + + let envelope = envelopes + .into_only_item() + .expect("expected exactly one envelope"); + let item = envelope + .into_items() + .into_only_item() + .expect("expected exactly one item"); + let metrics = item + .into_metrics() + .expect("the envelope item is not a metrics item"); + + assert_eq!(metrics.len(), 100, "expected 100 metrics"); + + let metric_names: HashSet<_> = metrics + .into_iter() + .inspect(|metric| assert_eq!(metric.value, 1.0, "metric had unexpected value")) + .inspect(|metric| { + assert_eq!( + metric.r#type, + TraceMetricType::Counter, + "metric had unexpected type" + ) + }) + .map(|metric| metric.name) + .collect(); + + (0..100) + .map(|i| format!("metric.{i}")) + .for_each(|metric_name| { + assert!( + metric_names.contains(&metric_name), + "expected metric {metric_name} was not captured" + ) + }); +} + +/// Test that 101 envelopes are sent in two separate envelopes +#[test] +fn test_metrics_batching_over_limit() { + let options = ClientOptions { + enable_metrics: true, + ..Default::default() + }; + + let mut envelopes = test::with_captured_envelopes_options( + || { + (0..101) + .map(|i| format!("metric.{i}")) + .for_each(capture_test_metric); + }, + options, + ) + .into_iter(); + let envelope1 = envelopes.next().expect("expected a first envelope"); + let envelope2 = envelopes.next().expect("expected a second envelope"); + assert!(envelopes.next().is_none(), "expected exactly two envelopes"); + + let item1 = envelope1 + .into_items() + .into_only_item() + .expect("expected exactly one item in the first envelope"); + let metrics1 = item1 + .into_metrics() + .expect("the first envelope item is not a metrics item"); + + assert_eq!(metrics1.len(), 100, "expected 100 metrics"); + + let first_metric_names: HashSet<_> = metrics1 + .into_iter() + .inspect(|metric| assert_eq!(metric.value, 1.0, "metric had unexpected value")) + .inspect(|metric| { + assert_eq!( + metric.r#type, + TraceMetricType::Counter, + "metric had unexpected type" + ) + }) + .map(|metric| metric.name) + .collect(); + + (0..100) + .map(|i| format!("metric.{i}")) + .for_each(|metric_name| { + assert!( + first_metric_names.contains(&metric_name), + "expected metric {metric_name} was not captured in the first envelope" + ) + }); + + let item2 = envelope2 + .into_items() + .into_only_item() + .expect("expected exactly one item in the second envelope"); + let metrics2 = item2 + .into_metrics() + .expect("the second envelope item is not a metrics item"); + let metric2 = metrics2 + .into_only_item() + .expect("expected exactly one metric in the second envelope"); + + assert!( + matches!(metric2, TraceMetric { + r#type: TraceMetricType::Counter, + name, + value: 1.0, + .. + } if name == "metric.100"), + "unexpected metric captured" + ) +} + +/// Returns a new [`TraceMetric`] with [type `Counter`](TraceMetricType), +/// the provided name, and a value of `1.0`. The other fields are unspecified. +fn test_metric(name: S) -> TraceMetric +where + S: Into, +{ + TraceMetric::new(TraceMetricType::Counter, name, 1.0, Default::default()) +} + +/// Helper function to capture a metric, returned by `test_metric` on the current Hub. +fn capture_test_metric(name: S) +where + S: Into, +{ + Hub::current().capture_metric(test_metric(name)) +} + +/// Exention trait for iterators allowing conversion to only item. +trait IntoOnlyElementExt { + type Item; + + /// Convert the iterator to the only item, erroring if the + /// iterator does not contain exactly one item. + fn into_only_item(self) -> Result; +} + +impl IntoOnlyElementExt for I +where + I: IntoIterator, +{ + type Item = I::Item; + + fn into_only_item(self) -> Result { + let mut iter = self.into_iter(); + let rv = iter.next().context("iterator was empty")?; + + match iter.next() { + Some(_) => anyhow::bail!("iterator had more than one item"), + None => Ok(rv), + } + } +} + +trait IntoMetricsExt { + /// Attempt to convert the provided value to a trace metric, + /// returning None if the conversion is not possible. + fn into_metrics(self) -> Option>; +} + +impl IntoMetricsExt for EnvelopeItem { + fn into_metrics(self) -> Option> { + match self { + EnvelopeItem::ItemContainer(ItemContainer::TraceMetrics(metrics)) => Some(metrics), + _ => None, + } + } +} + +/// Helper to extract the single metric from captured envelopes. +fn extract_single_metric(envelopes: Vec) -> TraceMetric { + let envelope = envelopes.into_only_item().expect("expected one envelope"); + let item = envelope + .into_items() + .into_only_item() + .expect("expected one item"); + let mut metrics = item.into_metrics().expect("expected metrics item"); + assert_eq!(metrics.len(), 1, "expected exactly one metric"); + metrics.pop().unwrap() +} + +/// Test that trace_id is set from the propagation context when no span is active. +#[test] +fn trace_id_from_propagation_context() { + let options = ClientOptions { + enable_metrics: true, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + let metric = extract_single_metric(envelopes); + + // trace_id should be non-zero (set from propagation context) + assert_ne!( + metric.trace_id, + Default::default(), + "trace_id should be set from propagation context" + ); +} + +/// Test that default SDK attributes are attached to metrics. +#[test] +fn default_attributes_attached() { + let options = ClientOptions { + enable_metrics: true, + environment: Some("test-env".into()), + release: Some("1.0.0".into()), + server_name: Some("test-server".into()), + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + let metric = extract_single_metric(envelopes); + + assert_eq!( + metric.attributes.get("sentry.environment"), + Some(&LogAttribute(Value::from("test-env"))), + ); + assert_eq!( + metric.attributes.get("sentry.release"), + Some(&LogAttribute(Value::from("1.0.0"))), + ); + assert!( + metric.attributes.contains_key("sentry.sdk.name"), + "sentry.sdk.name should be present" + ); + assert!( + metric.attributes.contains_key("sentry.sdk.version"), + "sentry.sdk.version should be present" + ); + assert_eq!( + metric.attributes.get("server.address"), + Some(&LogAttribute(Value::from("test-server"))), + ); +} + +/// Test that explicitly set metric attributes are not overwritten by defaults. +#[test] +fn default_attributes_do_not_overwrite_explicit() { + let options = ClientOptions { + enable_metrics: true, + environment: Some("default-env".into()), + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options( + || { + let mut metric = test_metric("test"); + metric.attributes.insert( + "sentry.environment".to_owned(), + LogAttribute(Value::from("custom-env")), + ); + Hub::current().capture_metric(metric); + }, + options, + ); + let metric = extract_single_metric(envelopes); + + assert_eq!( + metric.attributes.get("sentry.environment"), + Some(&LogAttribute(Value::from("custom-env"))), + "explicitly set attribute should not be overwritten" + ); +} + +/// Test that user attributes are NOT attached when `send_default_pii` is false. +#[test] +fn user_attributes_absent_without_send_default_pii() { + let options = ClientOptions { + enable_metrics: true, + send_default_pii: false, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options( + || { + sentry_core::configure_scope(|scope| { + scope.set_user(Some(User { + id: Some("uid-123".into()), + username: Some("testuser".into()), + email: Some("test@example.com".into()), + ..Default::default() + })); + }); + capture_test_metric("test"); + }, + options, + ); + let metric = extract_single_metric(envelopes); + + assert!( + !metric.attributes.contains_key("user.id"), + "user.id should not be set when send_default_pii is false" + ); + assert!( + !metric.attributes.contains_key("user.name"), + "user.name should not be set when send_default_pii is false" + ); + assert!( + !metric.attributes.contains_key("user.email"), + "user.email should not be set when send_default_pii is false" + ); +} + +/// Test that user attributes ARE attached when `send_default_pii` is true. +#[test] +fn user_attributes_present_with_send_default_pii() { + let options = ClientOptions { + enable_metrics: true, + send_default_pii: true, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options( + || { + sentry_core::configure_scope(|scope| { + scope.set_user(Some(User { + id: Some("uid-123".into()), + username: Some("testuser".into()), + email: Some("test@example.com".into()), + ..Default::default() + })); + }); + capture_test_metric("test"); + }, + options, + ); + let metric = extract_single_metric(envelopes); + + assert_eq!( + metric.attributes.get("user.id"), + Some(&LogAttribute(Value::from("uid-123"))), + ); + assert_eq!( + metric.attributes.get("user.name"), + Some(&LogAttribute(Value::from("testuser"))), + ); + assert_eq!( + metric.attributes.get("user.email"), + Some(&LogAttribute(Value::from("test@example.com"))), + ); +} + +/// Test that explicitly set user attributes on the metric are not overwritten +/// by scope user data, even when `send_default_pii` is true. +#[test] +fn user_attributes_do_not_overwrite_explicit() { + let options = ClientOptions { + enable_metrics: true, + send_default_pii: true, + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options( + || { + sentry_core::configure_scope(|scope| { + scope.set_user(Some(User { + id: Some("scope-uid".into()), + username: Some("scope-user".into()), + email: Some("scope@example.com".into()), + ..Default::default() + })); + }); + let mut metric = test_metric("test"); + metric.attributes.insert( + "user.id".to_owned(), + LogAttribute(Value::from("explicit-uid")), + ); + Hub::current().capture_metric(metric); + }, + options, + ); + let metric = extract_single_metric(envelopes); + + assert_eq!( + metric.attributes.get("user.id"), + Some(&LogAttribute(Value::from("explicit-uid"))), + "explicitly set user.id should not be overwritten" + ); + // Non-explicit user attributes should still come from scope + assert_eq!( + metric.attributes.get("user.name"), + Some(&LogAttribute(Value::from("scope-user"))), + ); + assert_eq!( + metric.attributes.get("user.email"), + Some(&LogAttribute(Value::from("scope@example.com"))), + ); +} + +/// Test that `before_send_metric` can filter out metrics. +#[test] +fn before_send_metric_can_drop() { + let options = ClientOptions { + enable_metrics: true, + before_send_metric: Some(Arc::new(|_| None)), + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + assert!( + envelopes.is_empty(), + "metric should be dropped by before_send_metric" + ); +} + +/// Test that `before_send_metric` can modify metrics. +#[test] +fn before_send_metric_can_modify() { + let options = ClientOptions { + enable_metrics: true, + before_send_metric: Some(Arc::new(|mut metric| { + metric.attributes.insert( + "added_by_callback".to_owned(), + LogAttribute(Value::from("yes")), + ); + Some(metric) + })), + ..Default::default() + }; + + let envelopes = test::with_captured_envelopes_options(|| capture_test_metric("test"), options); + let metric = extract_single_metric(envelopes); + + assert_eq!( + metric.attributes.get("added_by_callback"), + Some(&LogAttribute(Value::from("yes"))), + ); +} diff --git a/sentry-types/src/protocol/envelope.rs b/sentry-types/src/protocol/envelope.rs index 33807d35..3e4ac4e7 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -11,7 +11,7 @@ use super::v7 as protocol; use protocol::{ Attachment, AttachmentType, ClientSdkInfo, DynamicSamplingContext, Event, Log, MonitorCheckIn, - SessionAggregates, SessionUpdate, Transaction, + SessionAggregates, SessionUpdate, TraceMetric, Transaction, }; /// Raised if a envelope cannot be parsed from a given input. @@ -127,6 +127,9 @@ enum EnvelopeItemType { /// A container of Log items. #[serde(rename = "log")] LogsContainer, + /// A container of TraceMetric items. + #[serde(rename = "trace_metric")] + TraceMetricsContainer, } /// An Envelope Item Header. @@ -192,6 +195,8 @@ pub enum EnvelopeItem { pub enum ItemContainer { /// A list of logs. Logs(Vec), + /// A list of trace metrics. + TraceMetrics(Vec), } #[allow(clippy::len_without_is_empty, reason = "is_empty is not needed")] @@ -200,6 +205,7 @@ impl ItemContainer { pub fn len(&self) -> usize { match self { Self::Logs(logs) => logs.len(), + Self::TraceMetrics(metrics) => metrics.len(), } } @@ -207,6 +213,7 @@ impl ItemContainer { pub fn ty(&self) -> &'static str { match self { Self::Logs(_) => "log", + Self::TraceMetrics(_) => "trace_metric", } } @@ -214,6 +221,7 @@ impl ItemContainer { pub fn content_type(&self) -> &'static str { match self { Self::Logs(_) => "application/vnd.sentry.items.log+json", + Self::TraceMetrics(_) => "application/vnd.sentry.items.trace-metric+json", } } } @@ -235,6 +243,12 @@ struct ItemsSerdeWrapper<'a, T: Clone> { items: Cow<'a, [T]>, } +impl From> for ItemContainer { + fn from(metrics: Vec) -> Self { + Self::TraceMetrics(metrics) + } +} + impl From> for EnvelopeItem { fn from(event: Event<'static>) -> Self { EnvelopeItem::Event(event) @@ -283,6 +297,12 @@ impl From> for EnvelopeItem { } } +impl From> for EnvelopeItem { + fn from(metrics: Vec) -> Self { + EnvelopeItem::ItemContainer(metrics.into()) + } +} + /// An Iterator over the items of an Envelope. #[derive(Clone)] pub struct EnvelopeItemIter<'s> { @@ -506,6 +526,12 @@ impl Envelope { let wrapper = ItemsSerdeWrapper { items: logs.into() }; serde_json::to_writer(&mut item_buf, &wrapper)? } + ItemContainer::TraceMetrics(metrics) => { + let wrapper = ItemsSerdeWrapper { + items: metrics.into(), + }; + serde_json::to_writer(&mut item_buf, &wrapper)? + } }, EnvelopeItem::Raw => { continue; @@ -677,6 +703,11 @@ impl Envelope { serde_json::from_slice::>(payload) .map(|x| EnvelopeItem::ItemContainer(ItemContainer::Logs(x.items.into()))) } + EnvelopeItemType::TraceMetricsContainer => { + serde_json::from_slice::>(payload).map(|x| { + EnvelopeItem::ItemContainer(ItemContainer::TraceMetrics(x.items.into())) + }) + } } .map_err(EnvelopeError::InvalidItemPayload)?; @@ -708,6 +739,7 @@ mod test { use std::time::{Duration, SystemTime}; use protocol::Map; + use serde_json::Value; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; @@ -1121,6 +1153,49 @@ some content assert_eq!(expected, serialized.as_bytes()); } + #[test] + fn test_trace_metric_container_header() { + let metrics: EnvelopeItem = vec![TraceMetric { + r#type: protocol::TraceMetricType::Counter, + name: "api.requests".into(), + value: 1.0, + timestamp: timestamp("2026-03-02T13:36:02.000Z"), + trace_id: "335e53d614474acc9f89e632b776cc28".parse().unwrap(), + span_id: None, + unit: None, + attributes: Map::new(), + }] + .into(); + + let mut envelope = Envelope::new(); + envelope.add_item(metrics); + + let expected = [ + serde_json::json!({}), + serde_json::json!({ + "type": "trace_metric", + "item_count": 1, + "content_type": "application/vnd.sentry.items.trace-metric+json" + }), + serde_json::json!({ + "items": [{ + "type": "counter", + "name": "api.requests", + "value": 1.0, + "timestamp": 1772458562, + "trace_id": "335e53d614474acc9f89e632b776cc28" + }] + }), + ]; + + let serialized = to_str(envelope); + let actual = serialized + .lines() + .map(|line| serde_json::from_str::(line).expect("envelope has invalid JSON")); + + assert!(actual.eq(expected.into_iter())); + } + // Test all possible item types in a single envelope #[test] fn test_deserialize_serialized() { @@ -1197,12 +1272,27 @@ some content ] .into(); + let mut metric_attributes = Map::new(); + metric_attributes.insert("route".into(), "/users".into()); + let trace_metrics: EnvelopeItem = vec![TraceMetric { + r#type: protocol::TraceMetricType::Distribution, + name: "response.time".to_owned(), + value: 123.4, + timestamp: timestamp("2022-07-26T14:51:14.296Z"), + trace_id: "335e53d614474acc9f89e632b776cc28".parse().unwrap(), + span_id: Some("d42cee9fc3e74f5c".parse().unwrap()), + unit: Some("millisecond".to_owned()), + attributes: metric_attributes, + }] + .into(); + let mut envelope: Envelope = Envelope::new(); envelope.add_item(event); envelope.add_item(transaction); envelope.add_item(session); envelope.add_item(attachment); envelope.add_item(logs); + envelope.add_item(trace_metrics); let serialized = to_str(envelope); let deserialized = Envelope::from_slice(serialized.as_bytes()).unwrap(); diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index b2f8ee99..4285e308 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -2368,6 +2368,65 @@ impl<'de> Deserialize<'de> for LogAttribute { } } +/// The type of a [trace metric](https://develop.sentry.dev/sdk/telemetry/metrics/). +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum TraceMetricType { + /// A counter metric that only increments. + Counter, + /// A gauge metric that can go up and down. + Gauge, + /// A distribution metric for statistical spread measurements. + Distribution, +} + +/// A single [trace metric](https://develop.sentry.dev/sdk/telemetry/metrics/). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct TraceMetric { + /// The metric type. + pub r#type: TraceMetricType, + /// The metric name. Uses dot separators for hierarchy. + pub name: String, + /// The numeric value. + pub value: f64, + /// The timestamp when recorded. + #[serde(with = "ts_seconds_float")] + pub timestamp: SystemTime, + /// The trace ID this metric is associated with. + pub trace_id: TraceId, + /// The span ID of the active span, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span_id: Option, + /// The measurement unit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, + /// Additional key-value attributes. + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub attributes: Map, +} + +impl TraceMetric { + /// Create a new [`TraceMetric`] with provided values and the current time + /// as the timestamp. Other values set to their [`Default`] values. + pub fn new(r#type: TraceMetricType, name: S, value: f64, trace_id: TraceId) -> Self + where + S: Into, + { + Self { + r#type, + name: name.into(), + value, + timestamp: SystemTime::now(), + trace_id, + span_id: Default::default(), + unit: Default::default(), + attributes: Default::default(), + } + } +} + /// An ID that identifies an organization in the Sentry backend. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] pub struct OrganizationId(u64);