diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fb129c..bc697884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- feat(log): support kv feature of log (#851) by @lcian + - Attributes added to a `log` record using the `kv` feature are now recorded as attributes on the log sent to Sentry. + ## 0.41.0 ### Breaking changes diff --git a/sentry-log/Cargo.toml b/sentry-log/Cargo.toml index ed2ba216..6df10711 100644 --- a/sentry-log/Cargo.toml +++ b/sentry-log/Cargo.toml @@ -18,7 +18,7 @@ logs = ["sentry-core/logs"] [dependencies] sentry-core = { version = "0.41.0", path = "../sentry-core" } -log = { version = "0.4.8", features = ["std"] } +log = { version = "0.4.8", features = ["std", "kv"] } [dev-dependencies] sentry = { path = "../sentry", default-features = false, features = ["test"] } diff --git a/sentry-log/src/converters.rs b/sentry-log/src/converters.rs index baf7d464..f7a4356e 100644 --- a/sentry-log/src/converters.rs +++ b/sentry-log/src/converters.rs @@ -1,9 +1,10 @@ -use sentry_core::protocol::Event; +use sentry_core::protocol::{Event, Value}; #[cfg(feature = "logs")] use sentry_core::protocol::{Log, LogAttribute, LogLevel}; use sentry_core::{Breadcrumb, Level}; +use std::collections::BTreeMap; #[cfg(feature = "logs")] -use std::{collections::BTreeMap, time::SystemTime}; +use std::time::SystemTime; /// Converts a [`log::Level`] to a Sentry [`Level`], used for [`Event`] and [`Breadcrumb`]. pub fn convert_log_level(level: log::Level) -> Level { @@ -27,23 +28,97 @@ pub fn convert_log_level_to_sentry_log_level(level: log::Level) -> LogLevel { } } +/// Visitor to extract key-value pairs from log records +#[derive(Default)] +struct AttributeVisitor { + json_values: BTreeMap, +} + +impl AttributeVisitor { + fn record>(&mut self, key: &str, value: T) { + self.json_values.insert(key.to_owned(), value.into()); + } +} + +impl log::kv::VisitSource<'_> for AttributeVisitor { + fn visit_pair( + &mut self, + key: log::kv::Key, + value: log::kv::Value, + ) -> Result<(), log::kv::Error> { + let key = key.as_str(); + + if let Some(value) = value.to_borrowed_str() { + self.record(key, value); + } else if let Some(value) = value.to_u64() { + self.record(key, value); + } else if let Some(value) = value.to_f64() { + self.record(key, value); + } else if let Some(value) = value.to_bool() { + self.record(key, value); + } else { + self.record(key, format!("{:?}", value)); + }; + + Ok(()) + } +} + +fn extract_record_attributes(record: &log::Record<'_>) -> AttributeVisitor { + let mut visitor = AttributeVisitor::default(); + let _ = record.key_values().visit(&mut visitor); + visitor +} + /// Creates a [`Breadcrumb`] from a given [`log::Record`]. pub fn breadcrumb_from_record(record: &log::Record<'_>) -> Breadcrumb { + let visitor = extract_record_attributes(record); + Breadcrumb { ty: "log".into(), level: convert_log_level(record.level()), category: Some(record.target().into()), message: Some(record.args().to_string()), + data: visitor.json_values, ..Default::default() } } /// Creates an [`Event`] from a given [`log::Record`]. pub fn event_from_record(record: &log::Record<'_>) -> Event<'static> { + let visitor = extract_record_attributes(record); + let attributes = visitor.json_values; + + let mut contexts = BTreeMap::new(); + + let mut metadata_map = BTreeMap::new(); + metadata_map.insert("logger.target".into(), record.target().into()); + if let Some(module_path) = record.module_path() { + metadata_map.insert("logger.module_path".into(), module_path.into()); + } + if let Some(file) = record.file() { + metadata_map.insert("logger.file".into(), file.into()); + } + if let Some(line) = record.line() { + metadata_map.insert("logger.line".into(), line.into()); + } + contexts.insert( + "Rust Log Metadata".to_string(), + sentry_core::protocol::Context::Other(metadata_map), + ); + + if !attributes.is_empty() { + contexts.insert( + "Rust Log Attributes".to_string(), + sentry_core::protocol::Context::Other(attributes), + ); + } + Event { logger: Some(record.target().into()), level: convert_log_level(record.level()), message: Some(record.args().to_string()), + contexts, ..Default::default() } } @@ -60,7 +135,13 @@ pub fn exception_from_record(record: &log::Record<'_>) -> Event<'static> { /// Creates a [`Log`] from a given [`log::Record`]. #[cfg(feature = "logs")] pub fn log_from_record(record: &log::Record<'_>) -> Log { - let mut attributes: BTreeMap = BTreeMap::new(); + let visitor = extract_record_attributes(record); + + let mut attributes: BTreeMap = visitor + .json_values + .into_iter() + .map(|(key, val)| (key, val.into())) + .collect(); attributes.insert("logger.target".into(), record.target().into()); if let Some(module_path) = record.module_path() { @@ -75,8 +156,6 @@ pub fn log_from_record(record: &log::Record<'_>) -> Log { attributes.insert("sentry.origin".into(), "auto.logger.log".into()); - // TODO: support the `kv` feature and store key value pairs as attributes - Log { level: convert_log_level_to_sentry_log_level(record.level()), body: format!("{}", record.args()), diff --git a/sentry/tests/test_log_logs.rs b/sentry/tests/test_log_logs.rs index adaa2993..1d631a5b 100644 --- a/sentry/tests/test_log_logs.rs +++ b/sentry/tests/test_log_logs.rs @@ -18,7 +18,7 @@ fn test_log_logs() { let envelopes = sentry::test::with_captured_envelopes_options( || { - log::info!("This is a log"); + log::info!(user_id = 42, request_id = "abc123"; "This is a log"); }, options, ); @@ -37,6 +37,14 @@ fn test_log_logs() { .find(|log| log.level == sentry::protocol::LogLevel::Info) .expect("expected info log"); assert_eq!(info_log.body, "This is a log"); + assert_eq!( + info_log.attributes.get("user_id").unwrap().clone(), + 42.into() + ); + assert_eq!( + info_log.attributes.get("request_id").unwrap().clone(), + "abc123".into() + ); assert_eq!( info_log.attributes.get("logger.target").unwrap().clone(), "test_log_logs".into()