diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f8f5f85..110d7d512 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 5b674db95..d21cc6baa 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 000000000..293bf3740 --- /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)+) + }; +} diff --git a/sentry/tests/test_basic.rs b/sentry/tests/test_basic.rs index c3b2afd7d..fb80c90db 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"), + } +}