diff --git a/Cargo.toml b/Cargo.toml index 2fab67d3..b52366d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,5 +10,6 @@ members = [ "sentry-log", "sentry-panic", "sentry-slog", + "sentry-tracing", "sentry-types", ] diff --git a/README.md b/README.md index 0d7a8888..fc1c8055 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ This workspace contains various crates that provide support for logging events a An integration for the `slog` crate. +- [sentry-tracing](./sentry-tracing) + [![crates.io](https://img.shields.io/crates/v/sentry-tracing.svg)](https://crates.io/crates/sentry-tracing) + [![docs.rs](https://docs.rs/sentry-tracing/badge.svg)](https://docs.rs/sentry-tracing) + + An integration for the `tracing` crate. + - [sentry-types](./sentry-types) [![crates.io](https://img.shields.io/crates/v/sentry-types.svg)](https://crates.io/crates/sentry-types) [![docs.rs](https://docs.rs/sentry-types/badge.svg)](https://docs.rs/sentry-types) diff --git a/sentry-tracing/Cargo.toml b/sentry-tracing/Cargo.toml new file mode 100644 index 00000000..5b15550e --- /dev/null +++ b/sentry-tracing/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sentry-tracing" +version = "0.22.0" +authors = ["Sentry "] +license = "Apache-2.0" +readme = "README.md" +repository = "https://github.com/getsentry/sentry-rust" +homepage = "https://sentry.io/welcome/" +description = """ +Sentry integration for tracing and tracing-subscriber crates. +""" +edition = "2018" + +[dependencies] +sentry-core = { version = "0.22.0", path = "../sentry-core" } +tracing-core = "0.1" +tracing-subscriber = "0.2" + +[dev-dependencies] +log = "0.4" +sentry = { version = "0.22.0", path = "../sentry", default-features = false, features = ["test"] } +tracing = "0.1" diff --git a/sentry-tracing/README.md b/sentry-tracing/README.md new file mode 100644 index 00000000..c38bab5a --- /dev/null +++ b/sentry-tracing/README.md @@ -0,0 +1,60 @@ +

+ + + +

+ +# Sentry Rust SDK: sentry-tracing + +Adds support for automatic Breadcrumb and Event capturing from tracing events, +similar to the `sentry-log` crate. + +The `tracing` crate is supported in two ways. First, events can be captured as +breadcrumbs for later. Secondly, error events can be captured as events to +Sentry. By default, anything above `Info` is recorded as breadcrumb and +anything above `Error` is captured as error event. + +By using this crate in combination with `tracing-subscriber` and its `log` +integration, `sentry-log` does not need to be used, as logs will be ingested +in the tracing system and generate events, thus be relayed to this crate. It +effectively replaces `sentry-log` when tracing is used. + +## Examples + +```rust +use tracing_subscriber::prelude::*; + +tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(sentry_tracing::layer()) + .try_init() + .unwrap(); + +let _sentry = sentry::init(()); + +tracing::info!("Generates a breadcrumb"); +tracing::error!("Generates an event"); +// Also works, since log events are ingested by the tracing system +log::info!("Generates a breadcrumb"); +log::error!("Generates an event"); +``` + +Or one might also set an explicit filter, to customize how to treat log +records: + +```rust +use sentry_tracing::EventFilter; + +let layer = sentry_tracing::layer().filter(|md| match md.level() { + &tracing::Level::ERROR => EventFilter::Event, + _ => EventFilter::Ignore, +}); +``` + +## Resources + +License: Apache-2.0 + +- [Discord](https://discord.gg/ez5KZN7) server for project discussions. +- Follow [@getsentry](https://twitter.com/getsentry) on Twitter for updates + diff --git a/sentry-tracing/src/converters.rs b/sentry-tracing/src/converters.rs new file mode 100644 index 00000000..9bd98ada --- /dev/null +++ b/sentry-tracing/src/converters.rs @@ -0,0 +1,91 @@ +use std::collections::BTreeMap; + +use sentry_core::protocol::{Event, Value}; +use sentry_core::{Breadcrumb, Level}; +use tracing_core::field::{Field, Visit}; + +/// Converts a [`tracing_core::Level`] to a Sentry [`Level`] +pub fn convert_tracing_level(level: &tracing_core::Level) -> Level { + match level { + &tracing_core::Level::TRACE | &tracing_core::Level::DEBUG => Level::Debug, + &tracing_core::Level::INFO => Level::Info, + &tracing_core::Level::WARN => Level::Warning, + &tracing_core::Level::ERROR => Level::Error, + } +} + +/// Extracts the message and metadata from an event +pub fn extract_data(event: &tracing_core::Event) -> (Option, BTreeMap) { + // Find message of the event, if any + let mut data = BTreeMapRecorder::default(); + event.record(&mut data); + let message = data + .0 + .remove("message") + .map(|v| v.as_str().map(|s| s.to_owned())) + .flatten(); + + (message, data.0) +} + +#[derive(Default)] +/// Records all fields of [`tracing_core::Event`] for easy access +struct BTreeMapRecorder(pub BTreeMap); + +impl BTreeMapRecorder { + fn record>(&mut self, field: &Field, value: T) { + self.0.insert(field.name().to_owned(), value.into()); + } +} + +impl Visit for BTreeMapRecorder { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.record(field, format!("{:?}", value)); + } + fn record_i64(&mut self, field: &Field, value: i64) { + self.record(field, value); + } + fn record_u64(&mut self, field: &Field, value: u64) { + self.record(field, value); + } + fn record_bool(&mut self, field: &Field, value: bool) { + self.record(field, value); + } + fn record_str(&mut self, field: &Field, value: &str) { + self.record(field, value); + } +} + +/// Creates a [`Breadcrumb`] from a given [`tracing_core::Event`] +pub fn breadcrumb_from_event(event: &tracing_core::Event) -> Breadcrumb { + let (message, data) = extract_data(&event); + Breadcrumb { + category: Some(event.metadata().target().to_owned()), + ty: "log".into(), + level: convert_tracing_level(event.metadata().level()), + message, + data, + ..Default::default() + } +} + +/// Creates an [`Event`] from a given [`tracing_core::Event`] +pub fn event_from_event(event: &tracing_core::Event) -> Event<'static> { + let (message, extra) = extract_data(&event); + Event { + logger: Some(event.metadata().target().to_owned()), + level: convert_tracing_level(event.metadata().level()), + message, + extra, + ..Default::default() + } +} + +/// Creates an exception [`Event`] from a given [`tracing_core::Event`] +pub fn exception_from_event(event: &tracing_core::Event) -> Event<'static> { + // TODO: Exception records in Sentry need a valid type, value and full stack trace to support + // proper grouping and issue metadata generation. tracing_core::Record does not contain sufficient + // information for this. However, it may contain a serialized error which we can parse to emit + // an exception record. + event_from_event(event) +} diff --git a/sentry-tracing/src/layer.rs b/sentry-tracing/src/layer.rs new file mode 100644 index 00000000..43380628 --- /dev/null +++ b/sentry-tracing/src/layer.rs @@ -0,0 +1,110 @@ +use sentry_core::protocol::Breadcrumb; +use tracing_core::{Event, Level, Metadata, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; + +use crate::converters::*; + +/// The action that Sentry should perform for a [`Metadata`] +#[derive(Debug, Clone, Copy)] +pub enum EventFilter { + /// Ignore the [`Event`] + Ignore, + /// Create a [`Breadcrumb`] from this [`Event`] + Breadcrumb, + /// Create a message [`sentry_core::protocol::Event`] from this [`Event`] + Event, + /// Create an exception [`sentry_core::protocol::Event`] from this [`Event`] + Exception, +} + +/// The type of data Sentry should ingest for a [`Event`] +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum EventMapping { + /// Ignore the [`Event`] + Ignore, + /// Adds the [`Breadcrumb`] to the Sentry scope. + Breadcrumb(Breadcrumb), + /// Captures the [`sentry_core::protocol::Event`] to Sentry. + Event(sentry_core::protocol::Event<'static>), +} + +/// The default event filter. +/// +/// By default, an exception event is captured for `error`, a breadcrumb for +/// `warning` and `info`, and `debug` and `trace` logs are ignored. +pub fn default_filter(metadata: &Metadata) -> EventFilter { + match metadata.level() { + &Level::ERROR => EventFilter::Exception, + &Level::WARN | &Level::INFO => EventFilter::Breadcrumb, + &Level::DEBUG | &Level::TRACE => EventFilter::Ignore, + } +} + +/// Provides a tracing layer that dispatches events to sentry +pub struct SentryLayer { + filter: Box EventFilter + Send + Sync>, + mapper: Option EventMapping + Send + Sync>>, +} + +impl SentryLayer { + /// Sets a custom filter function. + /// + /// The filter classifies how sentry should handle [`Event`]s based + /// on their [`Metadata`]. + pub fn filter(mut self, filter: F) -> Self + where + F: Fn(&Metadata) -> EventFilter + Send + Sync + 'static, + { + self.filter = Box::new(filter); + self + } + + /// Sets a custom mapper function. + /// + /// The mapper is responsible for creating either breadcrumbs or events from + /// [`Event`]s. + pub fn mapper(mut self, mapper: F) -> Self + where + F: Fn(&Event) -> EventMapping + Send + Sync + 'static, + { + self.mapper = Some(Box::new(mapper)); + self + } +} + +impl Default for SentryLayer { + fn default() -> Self { + Self { + filter: Box::new(default_filter), + mapper: None, + } + } +} + +impl Layer for SentryLayer { + fn on_event(&self, event: &Event, _ctx: Context<'_, S>) { + let item = match &self.mapper { + Some(mapper) => mapper(event), + None => match (self.filter)(event.metadata()) { + EventFilter::Ignore => EventMapping::Ignore, + EventFilter::Breadcrumb => EventMapping::Breadcrumb(breadcrumb_from_event(event)), + EventFilter::Event => EventMapping::Event(event_from_event(event)), + EventFilter::Exception => EventMapping::Event(exception_from_event(event)), + }, + }; + + match item { + EventMapping::Event(event) => { + sentry_core::capture_event(event); + } + EventMapping::Breadcrumb(breadcrumb) => sentry_core::add_breadcrumb(breadcrumb), + _ => (), + } + } +} + +/// Creates a default Sentry layer +pub fn layer() -> SentryLayer { + Default::default() +} diff --git a/sentry-tracing/src/lib.rs b/sentry-tracing/src/lib.rs new file mode 100644 index 00000000..adf0bc37 --- /dev/null +++ b/sentry-tracing/src/lib.rs @@ -0,0 +1,54 @@ +//! Adds support for automatic Breadcrumb and Event capturing from tracing +//! events, similar to the `sentry-log` crate. +//! +//! The `tracing` crate is supported in two ways. First, events can be captured +//! as breadcrumbs for later. Secondly, error events can be captured as events +//! to Sentry. By default, anything above `Info` is recorded as breadcrumb and +//! anything above `Error` is captured as error event. +//! +//! By using this crate in combination with `tracing-subscriber` and its `log` +//! integration, `sentry-log` does not need to be used, as logs will be ingested +//! in the tracing system and generate events, thus be relayed to this crate. It +//! effectively replaces `sentry-log` when tracing is used. +//! +//! ## Examples +//! +//! ```rust +//! use tracing_subscriber::prelude::*; +//! +//! tracing_subscriber::registry() +//! .with(tracing_subscriber::fmt::layer()) +//! .with(sentry_tracing::layer()) +//! .try_init() +//! .unwrap(); +//! +//! let _sentry = sentry::init(()); +//! +//! tracing::info!("Generates a breadcrumb"); +//! tracing::error!("Generates an event"); +//! // Also works, since log events are ingested by the tracing system +//! log::info!("Generates a breadcrumb"); +//! log::error!("Generates an event"); +//! ``` +//! +//! Or one might also set an explicit filter, to customize how to treat log +//! records: +//! +//! ```rust +//! use sentry_tracing::EventFilter; +//! +//! let layer = sentry_tracing::layer().filter(|md| match md.level() { +//! &tracing::Level::ERROR => EventFilter::Event, +//! _ => EventFilter::Ignore, +//! }); +//! ``` + +#![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")] +#![warn(missing_docs)] + +mod converters; +mod layer; + +pub use converters::*; +pub use layer::*; diff --git a/sentry/Cargo.toml b/sentry/Cargo.toml index 6c2b2cb2..de42bf4f 100644 --- a/sentry/Cargo.toml +++ b/sentry/Cargo.toml @@ -27,6 +27,7 @@ anyhow = ["sentry-anyhow"] debug-images = ["sentry-debug-images"] log = ["sentry-log"] slog = ["sentry-slog"] +tracing = ["sentry-tracing"] # other features test = ["sentry-core/test"] debug-logs = ["log_", "sentry-core/debug-logs"] @@ -47,6 +48,7 @@ sentry-debug-images = { version = "0.22.0", path = "../sentry-debug-images", opt sentry-log = { version = "0.22.0", path = "../sentry-log", optional = true } sentry-panic = { version = "0.22.0", path = "../sentry-panic", optional = true } sentry-slog = { version = "0.22.0", path = "../sentry-slog", optional = true } +sentry-tracing = { version = "0.22.0", path = "../sentry-tracing", optional = true } log_ = { package = "log", version = "0.4.8", optional = true, features = ["std"] } reqwest_ = { package = "reqwest", version = "0.11", optional = true, features = ["blocking", "json"], default-features = false } curl_ = { package = "curl", version = "0.4.25", optional = true } @@ -59,8 +61,11 @@ tokio = { version = "1.0", features = ["rt"] } sentry-anyhow = { version = "0.22.0", path = "../sentry-anyhow" } sentry-log = { version = "0.22.0", path = "../sentry-log" } sentry-slog = { version = "0.22.0", path = "../sentry-slog" } +sentry-tracing = { version = "0.22.0", path = "../sentry-tracing" } log_ = { package = "log", version = "0.4.8", features = ["std"] } slog_ = { package = "slog", version = "2.5.2" } +tracing_ = { package = "tracing", version = "0.1" } +tracing-subscriber = { version = "0.2", features = ["fmt", "tracing-log"] } actix-web = { version = "3", default-features = false } tokio = { version = "1.0", features = ["macros"] } pretty_env_logger = "0.4.0" diff --git a/sentry/examples/tracing-demo.rs b/sentry/examples/tracing-demo.rs new file mode 100644 index 00000000..bb595f67 --- /dev/null +++ b/sentry/examples/tracing-demo.rs @@ -0,0 +1,20 @@ +use tracing_::{debug, error, info, warn}; +use tracing_subscriber::prelude::*; + +fn main() { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(sentry_tracing::layer()) + .try_init() + .unwrap(); + + let _sentry = sentry::init(sentry::ClientOptions { + release: sentry::release_name!(), + ..Default::default() + }); + + debug!("System is booting"); + info!("System is booting"); + warn!("System is warning"); + error!("Holy shit everything is on fire!"); +} diff --git a/sentry/src/lib.rs b/sentry/src/lib.rs index 9c523a50..7da94890 100644 --- a/sentry/src/lib.rs +++ b/sentry/src/lib.rs @@ -59,6 +59,7 @@ //! * `log`: Enables support for the `log` crate. //! * `env_logger`: Enables support for the `log` crate with additional `env_logger` support. //! * `slog`: Enables support for the `slog` crate. +//! * `tracing`: Enables support for the `tracing` crate. //! * `test`: Enables testing support. //! * `debug-logs`: Uses the `log` crate for internal logging. //! * `reqwest`: Enables the `reqwest` transport, which is currently the default. @@ -157,6 +158,9 @@ pub mod integrations { #[cfg(feature = "slog")] #[doc(inline)] pub use sentry_slog as slog; + #[cfg(feature = "tracing")] + #[doc(inline)] + pub use sentry_tracing as tracing; } #[doc(inline)] diff --git a/sentry/tests/test_tracing.rs b/sentry/tests/test_tracing.rs new file mode 100644 index 00000000..80ffd966 --- /dev/null +++ b/sentry/tests/test_tracing.rs @@ -0,0 +1,56 @@ +#![cfg(feature = "test")] + +use log_ as log; +use tracing_ as tracing; +use tracing_subscriber::prelude::*; + +#[test] +fn test_tracing() { + // Don't configure the fmt layer to avoid logging to test output + tracing_subscriber::registry() + .with(sentry_tracing::layer()) + .try_init() + .unwrap(); + + let events = sentry::test::with_captured_events(|| { + sentry::configure_scope(|scope| { + scope.set_tag("worker", "worker1"); + }); + + tracing::info!("Hello Tracing World!"); + tracing::error!("Shit's on fire yo"); + + log::info!("Hello Logging World!"); + log::error!("Shit's on fire yo"); + }); + + assert_eq!(events.len(), 2); + let mut events = events.into_iter(); + + let event = events.next().unwrap(); + assert_eq!(event.tags["worker"], "worker1"); + assert_eq!(event.level, sentry::Level::Error); + assert_eq!(event.message, Some("Shit's on fire yo".to_owned())); + assert_eq!(event.breadcrumbs.len(), 1); + assert_eq!(event.breadcrumbs[0].level, sentry::Level::Info); + assert_eq!( + event.breadcrumbs[0].message, + Some("Hello Tracing World!".into()) + ); + + let event = events.next().unwrap(); + assert_eq!(event.tags["worker"], "worker1"); + assert_eq!(event.level, sentry::Level::Error); + assert_eq!(event.message, Some("Shit's on fire yo".to_owned())); + assert_eq!(event.breadcrumbs.len(), 2); + assert_eq!(event.breadcrumbs[0].level, sentry::Level::Info); + assert_eq!( + event.breadcrumbs[0].message, + Some("Hello Tracing World!".into()) + ); + assert_eq!(event.breadcrumbs[1].level, sentry::Level::Info); + assert_eq!( + event.breadcrumbs[1].message, + Some("Hello Logging World!".into()) + ); +}