Repository: getsentry/sentry-rust
Pull request #851 (2025-06-23 10:58:47)

feat(log): support kv feature of log
Support for capturing key-value pairs from `log` with the `kv` feature as log attributes.
Also captures them on breadcrumbs and events.

Close https://github.com/getsentry/sentry-rust/issues/844


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72fb129..bc69788 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 ed2ba21..6df1071 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 baf7d46..f7a4356 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<String, Value>,
+}
+
+impl AttributeVisitor {
+    fn record<T: Into<Value>>(&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<String, LogAttribute> = BTreeMap::new();
+    let visitor = extract_record_attributes(record);
+
+    let mut attributes: BTreeMap<String, LogAttribute> = 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()),


Test patch:
diff --git a/sentry/tests/test_log_logs.rs b/sentry/tests/test_log_logs.rs
index adaa299..1d631a5 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()