Repository: getsentry/sentry-rust
Pull request #827 (2025-06-04 17:06:23)

feat(logs): add macro-based API
Adds logging macros.

As described in the doc strings, there are technically 4 variants:
- logging a simple message
- logging a message with format string and args, forwards to `format!`
- the two cases above but with any number of attributes coming before the message and args. The syntax is similar to the one used in `tracing`, but we only support the values that can be converted to a `serde_json::Value`.
Full example:
```rust
warn!(
    error_code = 500,
    user.id = "12345",
    user.email = "test@test.com",
    success = false,
    "Error occurred: {}",
    "bad input"
);
```
Attributes containing a `.` will be nested in the UI.

The biggest difference with this and the `tracing` API is that we don't currently support an arbitrary struct as attribute. The way it works there is that it will work with any type implementing `Display`, calling it to get a string representation of the value, or with the special syntax `?` it will call the `Debug` impl.

Initially I split this into various internal macros but then they would be exported too, so I've refactored it to only export the needed APIs. This leads to some repetition in the various branches.

The spec says the logging API should be available under a `logger namespace`.
However it doesn't look there's a way to re-export the macros only under a specific `mod`, it seems that with `macro_export` they will always be exported at the crate level too (e.g. `sentry::error!`).
So for now I'm just exporting them at top level.

github-actions[bot]: 
<!--
  1 failure:  Please consider a...
  0 warning: 
  1 messages
  1 markdown notices
  DangerID: danger-id-Danger;
-->

<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Fails</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:no_entry_sign:</td>
      <td>Please consider adding a changelog entry for the next release.</td>
    </tr>
  </tbody>
</table>



<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>Do not forget to update <a href="https://github.com/getsentry/sentry-docs">Sentry-docs</a> with your feature once the pull request gets approved.</td>
    </tr>
  </tbody>
</table>

### Instructions and example for changelog

Please add an entry to `CHANGELOG.md` to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

```markdown
## Unreleased

- add macro-based API ([#827](https://github.com/getsentry/sentry-rust/pull/827))
```

If none of the above apply, you can opt out of this check by adding `#skip-changelog` to the PR description.
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against d8be41a32ec2990a229fcc281b0ae7f86bd55567
</p>

codecov-ai[bot]: On it! We are reviewing the PR and will provide feedback shortly.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry-rust/pull/827?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 72.61%. Comparing base [(`ba64c78`)](https://app.codecov.io/gh/getsentry/sentry-rust/commit/ba64c78e7ffe070dadc2140b044ade1f7e1b0839?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) to head [(`27547c4`)](https://app.codecov.io/gh/getsentry/sentry-rust/commit/27547c4d3621e993c0244e512fa34306867b9274?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> Report is 2 commits behind head on lcian/feat/logs-api.

<details><summary>Additional details and impacted files</summary>


```diff
@@                 Coverage Diff                  @@
##           lcian/feat/logs-api     #827   +/-   ##
====================================================
  Coverage                72.61%   72.61%           
====================================================
  Files                       63       63           
  Lines                     7598     7598           
====================================================
  Hits                      5517     5517           
  Misses                    2081     2081           
```

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2f8f5f..110d7d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,11 +17,11 @@
 ### Features
 
 - feat(logs): add log protocol types (#821) by @lcian
-  - Basic types for [Sentry structured logs](https://docs.sentry.io/product/explore/logs/) have been added.
-- feat(logs): add ability to capture and send logs (#823) by @lcian
-  - A method `capture_log` has been added to the `Hub` to enable sending logs.
-  - This is gated behind the `UNSTABLE_logs` feature flag (disabled by default).
-  - Additionally, the new client option `enable_logs` needs to be enabled for logs to be sent to Sentry.
+- feat(logs): add ability to capture and send logs (#823) by @lcian & @Swatinem
+- feat(logs): add macro-based API (#827) by @lcian & @szokeasaurusrex
+  - Support for [Sentry structured logs](https://docs.sentry.io/product/explore/logs/) has been added.
+  - To enable logs, enable the `UNSTABLE_logs` feature of the `sentry` crate and set `enable_logs` to `true` in your client options.
+  - Then, use the `logger_trace!`, `logger_debug!`, `logger_info!`, `logger_warn!`, `logger_error!` and `logger_fatal!` macros to capture logs.
   - Please note that breaking changes could occur until the API is finalized.
 
 ## 0.38.1
diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs
index 5b674db..d21cc6b 100644
--- a/sentry-core/src/lib.rs
+++ b/sentry-core/src/lib.rs
@@ -132,6 +132,8 @@ pub use crate::intodsn::IntoDsn;
 pub use crate::performance::*;
 pub use crate::scope::{Scope, ScopeGuard};
 pub use crate::transport::{Transport, TransportFactory};
+#[cfg(feature = "UNSTABLE_logs")]
+mod logger; // structured logging macros exported with `#[macro_export]`
 
 // client feature
 #[cfg(feature = "client")]
diff --git a/sentry-core/src/logger.rs b/sentry-core/src/logger.rs
new file mode 100644
index 0000000..293bf37
--- /dev/null
+++ b/sentry-core/src/logger.rs
@@ -0,0 +1,333 @@
+//! Macros for Sentry [structured logging](https://docs.sentry.io/product/explore/logs/).
+
+// Helper macro to capture a log at the given level. Should not be used directly.
+#[doc(hidden)]
+#[macro_export]
+macro_rules! logger_log {
+    // Simple message
+    ($level:expr, $msg:literal) => {{
+        let log = $crate::protocol::Log {
+            level: $level,
+            body: $msg.to_owned(),
+            trace_id: None,
+            timestamp: ::std::time::SystemTime::now(),
+            severity_number: None,
+            attributes: $crate::protocol::Map::new(),
+        };
+        $crate::Hub::current().capture_log(log)
+    }};
+
+    // Message with format string and args
+    ($level:expr, $fmt:literal, $($arg:expr),+) => {{
+        let mut attributes = $crate::protocol::Map::new();
+
+        attributes.insert(
+            "sentry.message.template".to_owned(),
+            $crate::protocol::LogAttribute($crate::protocol::Value::from($fmt))
+        );
+        let mut i = 0;
+        $(
+            attributes.insert(
+                format!("sentry.message.parameter.{}", i),
+                $crate::protocol::LogAttribute($crate::protocol::Value::from($arg))
+            );
+            i += 1;
+        )*
+        let _ = i; // avoid triggering the `unused_assignments` lint
+
+        let log = $crate::protocol::Log {
+            level: $level,
+            body: format!($fmt, $($arg),*),
+            trace_id: None,
+            timestamp: ::std::time::SystemTime::now(),
+            severity_number: None,
+            attributes,
+        };
+        $crate::Hub::current().capture_log(log)
+    }};
+
+    // Attributes entrypoint
+    ($level:expr, $($rest:tt)+) => {{
+        let mut attributes = $crate::protocol::Map::new();
+        $crate::logger_log!(@internal attributes, $level, $($rest)+)
+    }};
+
+    // Attributes base case: no more attributes, simple message
+    (@internal $attrs:ident, $level:expr, $msg:literal) => {{
+        let log = $crate::protocol::Log {
+            level: $level,
+            body: $msg.to_owned(),
+            trace_id: None,
+            timestamp: ::std::time::SystemTime::now(),
+            severity_number: None,
+            #[allow(clippy::redundant_field_names)]
+            attributes: $attrs,
+        };
+        $crate::Hub::current().capture_log(log)
+    }};
+
+    // Attributes base case: no more attributes, message with format string and args
+    (@internal $attrs:ident, $level:expr, $fmt:literal, $($arg:expr),+) => {{
+        $attrs.insert(
+            "sentry.message.template".to_owned(),
+            $crate::protocol::LogAttribute($crate::protocol::Value::from($fmt))
+        );
+        let mut i = 0;
+        $(
+            $attrs.insert(
+                format!("sentry.message.parameter.{}", i),
+                $crate::protocol::LogAttribute($crate::protocol::Value::from($arg))
+            );
+            i += 1;
+        )*
+        let _ = i; // avoid triggering the `unused_assignments` lint
+
+        let log = $crate::protocol::Log {
+            level: $level,
+            body: format!($fmt, $($arg),*),
+            trace_id: None,
+            timestamp: ::std::time::SystemTime::now(),
+            severity_number: None,
+            #[allow(clippy::redundant_field_names)]
+            attributes: $attrs,
+        };
+        $crate::Hub::current().capture_log(log)
+    }};
+
+    // Attributes recursive case
+    (@internal $attrs:ident, $level:expr, $($key:ident).+ = $value:expr, $($rest:tt)+) => {{
+        $attrs.insert(
+            stringify!($($key).+).to_owned(),
+            $crate::protocol::LogAttribute($crate::protocol::Value::from($value))
+        );
+        $crate::logger_log!(@internal $attrs, $level, $($rest)+)
+    }};
+}
+
+/// Captures a log at the trace level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_trace;
+///
+/// // Simple message
+/// logger_trace!("Hello world");
+///
+/// // Message with format args
+/// logger_trace!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_trace!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_trace {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Trace, $($arg)+)
+    };
+}
+
+/// Captures a log at the debug level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_debug;
+///
+/// // Simple message
+/// logger_debug!("Hello world");
+///
+/// // Message with format args
+/// logger_debug!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_debug!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_debug {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Debug, $($arg)+)
+    };
+}
+
+/// Captures a log at the info level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_info;
+///
+/// // Simple message
+/// logger_info!("Hello world");
+///
+/// // Message with format args
+/// logger_info!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_info!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_info {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Info, $($arg)+)
+    };
+}
+
+/// Captures a log at the warn level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_warn;
+///
+/// // Simple message
+/// logger_warn!("Hello world");
+///
+/// // Message with format args
+/// logger_warn!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_warn!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_warn {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Warn, $($arg)+)
+    };
+}
+
+/// Captures a log at the error level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_error;
+///
+/// // Simple message
+/// logger_error!("Hello world");
+///
+/// // Message with format args
+/// logger_error!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_error!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_error {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Error, $($arg)+)
+    };
+}
+
+/// Captures a log at the fatal level, with the given message and attributes.
+///
+/// To attach attributes to a log, pass them with the `key = value` syntax before the message.
+/// The message can be a simple string or a format string with its arguments.
+///
+/// The supported attribute keys are all valid Rust identifiers with up to 8 dots.
+/// Using dots will nest multiple attributes under their common prefix in the UI.
+///
+/// The supported attribute values are simple types, such as string, numbers, and boolean.
+///
+/// # Examples
+///
+/// ```
+/// use sentry::logger_fatal;
+///
+/// // Simple message
+/// logger_fatal!("Hello world");
+///
+/// // Message with format args
+/// logger_fatal!("Value is {}", 42);
+///
+/// // Message with format args and attributes
+/// logger_fatal!(
+///     error_code = 500,
+///     user.id = "12345",
+///     user.email = "test@test.com",
+///     success = false,
+///     "Error occurred: {}",
+///     "bad input"
+/// );
+/// ```
+#[macro_export]
+macro_rules! logger_fatal {
+    ($($arg:tt)+) => {
+        $crate::logger_log!($crate::protocol::LogLevel::Fatal, $($arg)+)
+    };
+}


Test patch:
diff --git a/sentry/tests/test_basic.rs b/sentry/tests/test_basic.rs
index c3b2afd..fb80c90 100644
--- a/sentry/tests/test_basic.rs
+++ b/sentry/tests/test_basic.rs
@@ -313,3 +313,268 @@ fn test_basic_capture_log() {
         _ => panic!("expected item container"),
     }
 }
+
+#[cfg(feature = "UNSTABLE_logs")]
+#[test]
+fn test_basic_capture_log_macro_message() {
+    use sentry_core::logger_info;
+
+    let options = sentry::ClientOptions {
+        enable_logs: true,
+        ..Default::default()
+    };
+    let envelopes = sentry::test::with_captured_envelopes_options(
+        || {
+            logger_info!("Hello, world!");
+        },
+        options,
+    );
+    assert_eq!(envelopes.len(), 1);
+    let envelope = envelopes.first().expect("expected envelope");
+    let item = envelope.items().next().expect("expected envelope item");
+    match item {
+        EnvelopeItem::ItemContainer(container) => match container {
+            sentry::protocol::ItemContainer::Logs(logs) => {
+                let log = logs.iter().next().expect("expected log");
+                assert_eq!(sentry_core::protocol::LogLevel::Info, log.level);
+                assert_eq!("Hello, world!", log.body);
+                assert!(log.trace_id.is_some());
+                assert!(log.severity_number.is_none());
+                assert!(log.attributes.contains_key("sentry.sdk.name"));
+                assert!(log.attributes.contains_key("sentry.sdk.version"));
+            }
+            _ => panic!("expected logs"),
+        },
+        _ => panic!("expected item container"),
+    }
+}
+
+#[cfg(feature = "UNSTABLE_logs")]
+#[test]
+fn test_basic_capture_log_macro_message_formatted() {
+    use sentry::protocol::LogAttribute;
+    use sentry_core::logger_warn;
+
+    let options = sentry::ClientOptions {
+        enable_logs: true,
+        ..Default::default()
+    };
+    let envelopes = sentry::test::with_captured_envelopes_options(
+        || {
+            let failed_requests = ["request1", "request2", "request3"];
+            logger_warn!(
+                "Critical system errors detected for user {}, total failures: {}",
+                "test_user",
+                failed_requests.len()
+            );
+        },
+        options,
+    );
+    assert_eq!(envelopes.len(), 1);
+    let envelope = envelopes.first().expect("expected envelope");
+    let item = envelope.items().next().expect("expected envelope item");
+    match item {
+        EnvelopeItem::ItemContainer(container) => match container {
+            sentry::protocol::ItemContainer::Logs(logs) => {
+                let log = logs.iter().next().expect("expected log");
+                assert_eq!(sentry_core::protocol::LogLevel::Warn, log.level);
+                assert_eq!(
+                    "Critical system errors detected for user test_user, total failures: 3",
+                    log.body
+                );
+                assert_eq!(
+                    LogAttribute::from(
+                        "Critical system errors detected for user {}, total failures: {}"
+                    ),
+                    log.attributes
+                        .get("sentry.message.template")
+                        .unwrap()
+                        .clone()
+                );
+                assert_eq!(
+                    LogAttribute::from("test_user"),
+                    log.attributes
+                        .get("sentry.message.parameter.0")
+                        .unwrap()
+                        .clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(3),
+                    log.attributes
+                        .get("sentry.message.parameter.1")
+                        .unwrap()
+                        .clone()
+                );
+                assert!(log.trace_id.is_some());
+                assert!(log.severity_number.is_none());
+                assert!(log.attributes.contains_key("sentry.sdk.name"));
+                assert!(log.attributes.contains_key("sentry.sdk.version"));
+            }
+            _ => panic!("expected logs"),
+        },
+        _ => panic!("expected item container"),
+    }
+}
+
+#[cfg(feature = "UNSTABLE_logs")]
+#[test]
+fn test_basic_capture_log_macro_message_with_attributes() {
+    use sentry::protocol::LogAttribute;
+    use sentry_core::logger_error;
+
+    let options = sentry::ClientOptions {
+        enable_logs: true,
+        ..Default::default()
+    };
+    let envelopes = sentry::test::with_captured_envelopes_options(
+        || {
+            logger_error!(
+                user.id = "12345",
+                user.active = true,
+                request.duration = 150,
+                success = false,
+                "Failed to process request"
+            );
+        },
+        options,
+    );
+    assert_eq!(envelopes.len(), 1);
+    let envelope = envelopes.first().expect("expected envelope");
+    let item = envelope.items().next().expect("expected envelope item");
+    match item {
+        EnvelopeItem::ItemContainer(container) => match container {
+            sentry::protocol::ItemContainer::Logs(logs) => {
+                let log = logs.iter().next().expect("expected log");
+                assert_eq!(sentry_core::protocol::LogLevel::Error, log.level);
+                assert_eq!("Failed to process request", log.body);
+                assert_eq!(None, log.attributes.get("sentry.message.template"));
+                assert!(log.trace_id.is_some());
+                assert!(log.severity_number.is_none());
+                assert!(log.attributes.contains_key("sentry.sdk.name"));
+                assert!(log.attributes.contains_key("sentry.sdk.version"));
+                assert_eq!(
+                    LogAttribute::from("12345"),
+                    log.attributes.get("user.id").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(true),
+                    log.attributes.get("user.active").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(150u64),
+                    log.attributes.get("request.duration").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(false),
+                    log.attributes.get("success").unwrap().clone()
+                );
+            }
+            _ => panic!("expected logs"),
+        },
+        _ => panic!("expected item container"),
+    }
+}
+
+#[cfg(feature = "UNSTABLE_logs")]
+#[test]
+fn test_basic_capture_log_macro_message_formatted_with_attributes() {
+    use sentry::protocol::LogAttribute;
+    use sentry_core::logger_debug;
+
+    let options = sentry::ClientOptions {
+        enable_logs: true,
+        ..Default::default()
+    };
+    let envelopes = sentry::test::with_captured_envelopes_options(
+        || {
+            logger_debug!(
+                hello = "test",
+                operation.name = "database_query",
+                operation.success = true,
+                operation.time_ms = 42,
+                world = 10,
+                "Database query {} completed in {} ms with {} results",
+                "users_by_region",
+                42,
+                15
+            );
+        },
+        options,
+    );
+    assert_eq!(envelopes.len(), 1);
+    let envelope = envelopes.first().expect("expected envelope");
+    let item = envelope.items().next().expect("expected envelope item");
+    match item {
+        EnvelopeItem::ItemContainer(container) => match container {
+            sentry::protocol::ItemContainer::Logs(logs) => {
+                let log = logs.iter().next().expect("expected log");
+                assert_eq!(sentry_core::protocol::LogLevel::Debug, log.level);
+                assert_eq!(
+                    "Database query users_by_region completed in 42 ms with 15 results",
+                    log.body
+                );
+                assert!(log.trace_id.is_some());
+                assert!(log.severity_number.is_none());
+                assert_eq!(
+                    LogAttribute::from("Database query {} completed in {} ms with {} results",),
+                    log.attributes
+                        .get("sentry.message.template")
+                        .unwrap()
+                        .clone()
+                );
+                assert!(log.attributes.contains_key("sentry.sdk.name"));
+                assert!(log.attributes.contains_key("sentry.sdk.version"));
+                assert_eq!(
+                    LogAttribute::from("test"),
+                    log.attributes.get("hello").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from("database_query"),
+                    log.attributes.get("operation.name").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(true),
+                    log.attributes.get("operation.success").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(42u64),
+                    log.attributes.get("operation.time_ms").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(10),
+                    log.attributes.get("world").unwrap().clone()
+                );
+                assert_eq!(
+                    LogAttribute::from("Database query {} completed in {} ms with {} results"),
+                    log.attributes
+                        .get("sentry.message.template")
+                        .unwrap()
+                        .clone()
+                );
+                assert_eq!(
+                    LogAttribute::from("users_by_region"),
+                    log.attributes
+                        .get("sentry.message.parameter.0")
+                        .unwrap()
+                        .clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(42),
+                    log.attributes
+                        .get("sentry.message.parameter.1")
+                        .unwrap()
+                        .clone()
+                );
+                assert_eq!(
+                    LogAttribute::from(15),
+                    log.attributes
+                        .get("sentry.message.parameter.2")
+                        .unwrap()
+                        .clone()
+                );
+            }
+            _ => panic!("expected logs"),
+        },
+        _ => panic!("expected item container"),
+    }
+}