Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

### Breaking changes

- feat(log): support combined LogFilters and RecordMappings ([#914](https://github.com/getsentry/sentry-rust/pull/914)) by @lcian
- `sentry::integrations::log::LogFilter` has been changed to a `bitflags` struct.
- It's now possible to map a `log` record to multiple items in Sentry by combining multiple log filters in the filter, e.g. `log::Level::ERROR => LogFilter::Event | LogFilter::Log`.
- It's also possible to use `sentry::integrations::log::RecordMapping::Combined` to map a `log` record to multiple items in Sentry.

### Behavioral changes

- ref(log): send logs by default when logs feature flag is enabled ([#915](https://github.com/getsentry/sentry-rust/pull/915))
- If the `logs` feature flag is enabled, the default Sentry `log` logger now sends logs for all events at or above INFO.

## 0.43.0

### Breaking changes
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sentry-log/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ logs = ["sentry-core/logs"]
[dependencies]
sentry-core = { version = "0.43.0", path = "../sentry-core" }
log = { version = "0.4.8", features = ["std", "kv"] }
bitflags = "2.0.0"

[dev-dependencies]
sentry = { path = "../sentry", default-features = false, features = ["test"] }
Expand Down
28 changes: 21 additions & 7 deletions sentry-log/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,10 @@
//! - Records can be captured as traditional [logs](https://docs.sentry.io/product/explore/logs/)
//! Logs can be viewed and queried in the Logs explorer.
//!
//! By default anything above `Info` is recorded as a breadcrumb and
//! anything above `Error` is captured as error event.
//!
//! To capture records as Sentry logs:
//! 1. Enable the `logs` feature of the `sentry` crate.
//! 2. Initialize the SDK with `enable_logs: true` in your client options.
//! 3. Set up a custom filter (see below) to map records to logs (`LogFilter::Log`) based on criteria such as severity.
//! By default anything at or above `Info` is recorded as a breadcrumb and
//! anything at or above `Error` is captured as error event.
//! Additionally, if the `sentry` crate is used with the `logs` feature flag, anything at or above `Info`
//! is captured as a [Structured Log](https://docs.sentry.io/product/explore/logs/).
//!
//! # Examples
//!
Expand Down Expand Up @@ -46,6 +43,23 @@
//! _ => LogFilter::Ignore,
//! });
//! ```
//!
//! # Sending multiple items to Sentry
//!
//! To map a log record to multiple items in Sentry, you can combine multiple log filters
//! using the bitwise or operator:
//!
//! ```
//! use sentry_log::LogFilter;
//!
//! let logger = sentry_log::SentryLogger::new().filter(|md| match md.level() {
//! log::Level::Error => LogFilter::Event | LogFilter::Log,
//! log::Level::Warn => LogFilter::Breadcrumb | LogFilter::Log,
//! _ => LogFilter::Ignore,
//! });
//! ```
//!
//! If you're using a custom record mapper instead of a filter, use `RecordMapping::Combined`.

#![doc(html_favicon_url = "https://sentry-brand.storage.googleapis.com/favicon.ico")]
#![doc(html_logo_url = "https://sentry-brand.storage.googleapis.com/sentry-glyph-black.png")]
Expand Down
115 changes: 84 additions & 31 deletions sentry-log/src/logger.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
use log::Record;
use sentry_core::protocol::{Breadcrumb, Event};

use bitflags::bitflags;

#[cfg(feature = "logs")]
use crate::converters::log_from_record;
use crate::converters::{breadcrumb_from_record, event_from_record, exception_from_record};

/// The action that Sentry should perform for a [`log::Metadata`].
#[derive(Debug)]
pub enum LogFilter {
/// Ignore the [`Record`].
Ignore,
/// Create a [`Breadcrumb`] from this [`Record`].
Breadcrumb,
/// Create a message [`Event`] from this [`Record`].
Event,
/// Create an exception [`Event`] from this [`Record`].
Exception,
/// Create a [`sentry_core::protocol::Log`] from this [`Record`].
#[cfg(feature = "logs")]
Log,
bitflags! {
/// The action that Sentry should perform for a [`log::Metadata`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogFilter: u32 {
/// Ignore the [`Record`].
const Ignore = 0b0000;
/// Create a [`Breadcrumb`] from this [`Record`].
const Breadcrumb = 0b0001;
/// Create a message [`Event`] from this [`Record`].
const Event = 0b0010;
/// Create an exception [`Event`] from this [`Record`].
const Exception = 0b0100;
/// Create a [`sentry_core::protocol::Log`] from this [`Record`].
#[cfg(feature = "logs")]
const Log = 0b1000;
}
}

/// The type of Data Sentry should ingest for a [`log::Record`].
Expand All @@ -34,6 +38,29 @@ pub enum RecordMapping {
/// Captures the [`sentry_core::protocol::Log`] to Sentry.
#[cfg(feature = "logs")]
Log(sentry_core::protocol::Log),
/// Captures multiple items to Sentry.
/// Nesting multiple `RecordMapping::Combined` is not supported and will cause the mappings to
/// be ignored.
Combined(CombinedRecordMapping),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SentryLogger Fails to Handle Combined RecordMappings

The SentryLogger::log method doesn't handle the new RecordMapping::Combined variant. This means any mappings wrapped within a Combined variant are silently ignored, preventing logs from being processed and sent to Sentry.

Fix in Cursor Fix in Web

}

/// A list of record mappings.
#[derive(Debug)]
pub struct CombinedRecordMapping(Vec<RecordMapping>);

impl From<RecordMapping> for CombinedRecordMapping {
fn from(value: RecordMapping) -> Self {
match value {
RecordMapping::Combined(combined) => combined,
_ => CombinedRecordMapping(vec![value]),
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Combined Mapping Unwraps Nested Variants

The From<RecordMapping> implementation for CombinedRecordMapping unwraps RecordMapping::Combined variants. This contradicts the RecordMapping::Combined documentation, which states that nested Combined mappings are not supported and should be ignored, potentially leading to unexpected processing.

Fix in Cursor Fix in Web


impl From<Vec<RecordMapping>> for CombinedRecordMapping {
fn from(value: Vec<RecordMapping>) -> Self {
Self(value)
}
}

/// The default log filter.
Expand All @@ -42,7 +69,13 @@ pub enum RecordMapping {
/// `warning` and `info`, and `debug` and `trace` logs are ignored.
pub fn default_filter(metadata: &log::Metadata) -> LogFilter {
match metadata.level() {
#[cfg(feature = "logs")]
log::Level::Error => LogFilter::Exception | LogFilter::Log,
#[cfg(not(feature = "logs"))]
log::Level::Error => LogFilter::Exception,
#[cfg(feature = "logs")]
log::Level::Warn | log::Level::Info => LogFilter::Breadcrumb | LogFilter::Log,
#[cfg(not(feature = "logs"))]
log::Level::Warn | log::Level::Info => LogFilter::Breadcrumb,
log::Level::Debug | log::Level::Trace => LogFilter::Ignore,
}
Expand Down Expand Up @@ -132,30 +165,50 @@ impl<L: log::Log> SentryLogger<L> {

impl<L: log::Log> log::Log for SentryLogger<L> {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
self.dest.enabled(metadata) || !matches!((self.filter)(metadata), LogFilter::Ignore)
self.dest.enabled(metadata) || !((self.filter)(metadata) == LogFilter::Ignore)
}

fn log(&self, record: &log::Record<'_>) {
let item: RecordMapping = match &self.mapper {
let items: RecordMapping = match &self.mapper {
Some(mapper) => mapper(record),
None => match (self.filter)(record.metadata()) {
LogFilter::Ignore => RecordMapping::Ignore,
LogFilter::Breadcrumb => RecordMapping::Breadcrumb(breadcrumb_from_record(record)),
LogFilter::Event => RecordMapping::Event(event_from_record(record)),
LogFilter::Exception => RecordMapping::Event(exception_from_record(record)),
None => {
let filter = (self.filter)(record.metadata());
let mut items = vec![];
if filter.contains(LogFilter::Breadcrumb) {
items.push(RecordMapping::Breadcrumb(breadcrumb_from_record(record)));
}
if filter.contains(LogFilter::Event) {
items.push(RecordMapping::Event(event_from_record(record)));
}
if filter.contains(LogFilter::Exception) {
items.push(RecordMapping::Event(exception_from_record(record)));
}
#[cfg(feature = "logs")]
LogFilter::Log => RecordMapping::Log(log_from_record(record)),
},
if filter.contains(LogFilter::Log) {
items.push(RecordMapping::Log(log_from_record(record)));
}
RecordMapping::Combined(CombinedRecordMapping(items))
}
};

match item {
RecordMapping::Ignore => {}
RecordMapping::Breadcrumb(b) => sentry_core::add_breadcrumb(b),
RecordMapping::Event(e) => {
sentry_core::capture_event(e);
let items = CombinedRecordMapping::from(items);

for item in items.0 {
match item {
RecordMapping::Ignore => {}
RecordMapping::Breadcrumb(breadcrumb) => sentry_core::add_breadcrumb(breadcrumb),
RecordMapping::Event(event) => {
sentry_core::capture_event(event);
}
#[cfg(feature = "logs")]
RecordMapping::Log(log) => {
sentry_core::Hub::with_active(|hub| hub.capture_log(log))
}
RecordMapping::Combined(_) => {
sentry_core::sentry_debug!(
"[SentryLogger] found nested CombinedEventMapping, ignoring"
)
}
}
#[cfg(feature = "logs")]
RecordMapping::Log(log) => sentry_core::Hub::with_active(|hub| hub.capture_log(log)),
}

self.dest.log(record)
Expand Down
37 changes: 37 additions & 0 deletions sentry/tests/test_log_combined_filters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#![cfg(feature = "test")]

// Test `log` integration with combined `LogFilter`s.
// This must be in a separate file because `log::set_boxed_logger` can only be called once.

#[test]
fn test_log_combined_filters() {
let logger = sentry_log::SentryLogger::new().filter(|md| match md.level() {
log::Level::Error => sentry_log::LogFilter::Breadcrumb | sentry_log::LogFilter::Event,
log::Level::Warn => sentry_log::LogFilter::Event,
_ => sentry_log::LogFilter::Ignore,
});

log::set_boxed_logger(Box::new(logger))
.map(|()| log::set_max_level(log::LevelFilter::Trace))
.unwrap();

let events = sentry::test::with_captured_events(|| {
log::error!("Both a breadcrumb and an event");
log::warn!("An event");
log::trace!("Ignored");
});

assert_eq!(events.len(), 2);

assert_eq!(
events[0].message,
Some("Both a breadcrumb and an event".to_owned())
);

assert_eq!(events[1].message, Some("An event".to_owned()));
assert_eq!(events[1].breadcrumbs.len(), 1);
assert_eq!(
events[1].breadcrumbs[0].message,
Some("Both a breadcrumb and an event".into())
);
}