diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b3b97b..d2f8f5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,11 @@ - 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. - - It's possible (but not recommended) to use them to send logs to Sentry by directly constructing an `Envelope` containing an `ItemContainer::Logs` envelope item and sending it through `Client::send_envelope`. - - A high-level API and integrations will come soon. +- 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. + - Please note that breaking changes could occur until the API is finalized. ## 0.38.1 diff --git a/sentry-core/Cargo.toml b/sentry-core/Cargo.toml index 572a3e04..784571a2 100644 --- a/sentry-core/Cargo.toml +++ b/sentry-core/Cargo.toml @@ -24,6 +24,7 @@ default = [] client = ["rand"] test = ["client", "release-health"] release-health = [] +UNSTABLE_logs = [] [dependencies] log = { version = "0.4.8", optional = true, features = ["std"] } diff --git a/sentry-core/src/client.rs b/sentry-core/src/client.rs index 684f7f2a..10fafa97 100644 --- a/sentry-core/src/client.rs +++ b/sentry-core/src/client.rs @@ -5,9 +5,11 @@ use std::panic::RefUnwindSafe; use std::sync::{Arc, RwLock}; use std::time::Duration; -use rand::random; +#[cfg(feature = "UNSTABLE_logs")] +use crate::protocol::EnvelopeItem; #[cfg(feature = "release-health")] -use sentry_types::protocol::v7::SessionUpdate; +use crate::protocol::SessionUpdate; +use rand::random; use sentry_types::random_uuid; use crate::constants::SDK_INFO; @@ -18,6 +20,8 @@ use crate::types::{Dsn, Uuid}; #[cfg(feature = "release-health")] use crate::SessionMode; use crate::{ClientOptions, Envelope, Hub, Integration, Scope, Transport}; +#[cfg(feature = "UNSTABLE_logs")] +use sentry_types::protocol::v7::{Log, LogAttribute}; impl> From for Client { fn from(o: T) -> Client { @@ -363,6 +367,83 @@ impl Client { random::() < rate } } + + /// Captures a log and sends it to Sentry. + #[cfg(feature = "UNSTABLE_logs")] + pub fn capture_log(&self, log: Log, scope: &Scope) { + if !self.options().enable_logs { + return; + } + if let Some(ref transport) = *self.transport.read().unwrap() { + if let Some(log) = self.prepare_log(log, scope) { + let mut envelope = Envelope::new(); + let logs: EnvelopeItem = vec![log].into(); + envelope.add_item(logs); + transport.send_envelope(envelope); + } + } + } + + /// Prepares a log to be sent, setting the `trace_id` and other default attributes, and + /// processing it through `before_send_log`. + #[cfg(feature = "UNSTABLE_logs")] + fn prepare_log(&self, mut log: Log, scope: &Scope) -> Option { + scope.apply_to_log(&mut log, self.options.send_default_pii); + + self.set_log_default_attributes(&mut log); + + if let Some(ref func) = self.options.before_send_log { + log = func(log)?; + } + + Some(log) + } + + #[cfg(feature = "UNSTABLE_logs")] + fn set_log_default_attributes(&self, log: &mut Log) { + if !log.attributes.contains_key("sentry.environment") { + if let Some(environment) = self.options.environment.as_ref() { + log.attributes.insert( + "sentry.sdk.version".to_owned(), + LogAttribute(environment.clone().into()), + ); + } + } + + if !log.attributes.contains_key("sentry.release") { + if let Some(release) = self.options.release.as_ref() { + log.attributes.insert( + "sentry.release".to_owned(), + LogAttribute(release.clone().into()), + ); + } + } + + if !log.attributes.contains_key("sentry.sdk.name") { + log.attributes.insert( + "sentry.sdk.name".to_owned(), + LogAttribute(self.sdk_info.name.to_owned().into()), + ); + } + + if !log.attributes.contains_key("sentry.sdk.version") { + log.attributes.insert( + "sentry.sdk.version".to_owned(), + LogAttribute(self.sdk_info.version.to_owned().into()), + ); + } + + // TODO: set OS (and Rust?) context + + if !log.attributes.contains_key("server.address") { + if let Some(server) = &self.options.server_name { + log.attributes.insert( + "server.address".to_owned(), + LogAttribute(server.clone().into()), + ); + } + } + } } // Make this unwind safe. It's not out of the box because of the diff --git a/sentry-core/src/clientoptions.rs b/sentry-core/src/clientoptions.rs index 70b0fcaa..a741cf43 100644 --- a/sentry-core/src/clientoptions.rs +++ b/sentry-core/src/clientoptions.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::constants::USER_AGENT; use crate::performance::TracesSampler; +#[cfg(feature = "UNSTABLE_logs")] +use crate::protocol::Log; use crate::protocol::{Breadcrumb, Event}; use crate::types::Dsn; use crate::{Integration, IntoDsn, TransportFactory}; @@ -144,6 +146,9 @@ pub struct ClientOptions { pub before_send: Option>>, /// Callback that is executed for each Breadcrumb being added. pub before_breadcrumb: Option>, + /// Callback that is executed for each Log being added. + #[cfg(feature = "UNSTABLE_logs")] + pub before_send_log: Option>, // Transport options /// The transport to use. /// @@ -162,6 +167,12 @@ pub struct ClientOptions { pub https_proxy: Option>, /// The timeout on client drop for draining events on shutdown. pub shutdown_timeout: Duration, + /// Controls the maximum size of an HTTP request body that can be captured when using HTTP + /// server integrations. Needs `send_default_pii` to be enabled to have any effect. + pub max_request_body_size: MaxRequestBodySize, + /// Determines whether captured structured logs should be sent to Sentry (defaults to false). + #[cfg(feature = "UNSTABLE_logs")] + pub enable_logs: bool, // Other options not documented in Unified API /// Disable SSL verification. /// @@ -186,8 +197,6 @@ pub struct ClientOptions { pub trim_backtraces: bool, /// The user agent that should be reported. pub user_agent: Cow<'static, str>, - /// Controls how much of request bodies are captured - pub max_request_body_size: MaxRequestBodySize, } impl ClientOptions { @@ -223,6 +232,12 @@ impl fmt::Debug for ClientOptions { #[derive(Debug)] struct BeforeBreadcrumb; let before_breadcrumb = self.before_breadcrumb.as_ref().map(|_| BeforeBreadcrumb); + #[cfg(feature = "UNSTABLE_logs")] + let before_send_log = { + #[derive(Debug)] + struct BeforeSendLog; + self.before_send_log.as_ref().map(|_| BeforeSendLog) + }; #[derive(Debug)] struct TransportFactory; @@ -264,6 +279,11 @@ impl fmt::Debug for ClientOptions { .field("auto_session_tracking", &self.auto_session_tracking) .field("session_mode", &self.session_mode); + #[cfg(feature = "UNSTABLE_logs")] + debug_struct + .field("enable_logs", &self.enable_logs) + .field("before_send_log", &before_send_log); + debug_struct .field("extra_border_frames", &self.extra_border_frames) .field("trim_backtraces", &self.trim_backtraces) @@ -305,6 +325,10 @@ impl Default for ClientOptions { trim_backtraces: true, user_agent: Cow::Borrowed(USER_AGENT), max_request_body_size: MaxRequestBodySize::Medium, + #[cfg(feature = "UNSTABLE_logs")] + enable_logs: false, + #[cfg(feature = "UNSTABLE_logs")] + before_send_log: None, } } } diff --git a/sentry-core/src/hub.rs b/sentry-core/src/hub.rs index 6bab5263..fe246078 100644 --- a/sentry-core/src/hub.rs +++ b/sentry-core/src/hub.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, RwLock}; -use crate::protocol::{Event, Level, SessionStatus}; +use crate::protocol::{Event, Level, Log, LogAttribute, LogLevel, Map, SessionStatus}; use crate::types::Uuid; use crate::{Integration, IntoBreadcrumbs, Scope, ScopeGuard}; @@ -245,4 +245,14 @@ impl Hub { }) }} } + + /// Captures a structured log. + #[cfg(feature = "UNSTABLE_logs")] + pub fn capture_log(&self, log: Log) { + with_client_impl! {{ + let top = self.inner.with(|stack| stack.top().clone()); + let Some(ref client) = top.client else { return }; + client.capture_log(log, &top.scope); + }} + } } diff --git a/sentry-core/src/scope/noop.rs b/sentry-core/src/scope/noop.rs index f4d47a37..578c1f9b 100644 --- a/sentry-core/src/scope/noop.rs +++ b/sentry-core/src/scope/noop.rs @@ -1,5 +1,7 @@ use std::fmt; +#[cfg(feature = "UNSTABLE_logs")] +use crate::protocol::Log; use crate::protocol::{Context, Event, Level, User, Value}; use crate::TransactionOrSpan; @@ -110,6 +112,13 @@ impl Scope { minimal_unreachable!(); } + /// Applies the contained scoped data to fill a log. + #[cfg(feature = "UNSTABLE_logs")] + pub fn apply_to_log(&self, log: &mut Log) { + let _log = log; + minimal_unreachable!(); + } + /// Set the given [`TransactionOrSpan`] as the active span for this scope. pub fn set_span(&mut self, span: Option) { let _ = span; diff --git a/sentry-core/src/scope/real.rs b/sentry-core/src/scope/real.rs index 47b1e9da..c8147ce0 100644 --- a/sentry-core/src/scope/real.rs +++ b/sentry-core/src/scope/real.rs @@ -5,10 +5,12 @@ use std::fmt; use std::sync::Mutex; use std::sync::{Arc, PoisonError, RwLock}; -use sentry_types::protocol::v7::TraceContext; - use crate::performance::TransactionOrSpan; -use crate::protocol::{Attachment, Breadcrumb, Context, Event, Level, Transaction, User, Value}; +use crate::protocol::{ + Attachment, Breadcrumb, Context, Event, Level, TraceContext, Transaction, User, Value, +}; +#[cfg(feature = "UNSTABLE_logs")] +use crate::protocol::{Log, LogAttribute}; #[cfg(feature = "release-health")] use crate::session::Session; use crate::{Client, SentryTrace, TraceHeader, TraceHeadersIter}; @@ -346,6 +348,59 @@ impl Scope { ); } + /// Applies the contained scoped data to a log, setting the `trace_id` and certain default + /// attributes. + #[cfg(feature = "UNSTABLE_logs")] + pub fn apply_to_log(&self, log: &mut Log, send_default_pii: bool) { + if let Some(span) = self.span.as_ref() { + log.trace_id = Some(span.get_trace_context().trace_id); + } else { + log.trace_id = Some(self.propagation_context.trace_id); + } + + if !log.attributes.contains_key("sentry.trace.parent_span_id") { + if let Some(span) = self.get_span() { + let span_id = match span { + crate::TransactionOrSpan::Transaction(transaction) => { + transaction.get_trace_context().span_id + } + crate::TransactionOrSpan::Span(span) => span.get_span_id(), + }; + log.attributes.insert( + "parent_span_id".to_owned(), + LogAttribute(span_id.to_string().into()), + ); + } + } + + if send_default_pii { + if let Some(user) = self.user.as_ref() { + if !log.attributes.contains_key("user.id") { + if let Some(id) = user.id.as_ref() { + log.attributes + .insert("user.id".to_owned(), LogAttribute(id.to_owned().into())); + } + } + + if !log.attributes.contains_key("user.name") { + if let Some(name) = user.username.as_ref() { + log.attributes + .insert("user.name".to_owned(), LogAttribute(name.to_owned().into())); + } + } + + if !log.attributes.contains_key("user.email") { + if let Some(email) = user.email.as_ref() { + log.attributes.insert( + "user.email".to_owned(), + LogAttribute(email.to_owned().into()), + ); + } + } + } + } + } + /// Set the given [`TransactionOrSpan`] as the active span for this scope. pub fn set_span(&mut self, span: Option) { self.span = Arc::new(span); diff --git a/sentry-types/src/protocol/envelope.rs b/sentry-types/src/protocol/envelope.rs index 006c59ca..43a9c35f 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -1071,7 +1071,7 @@ some content Log { level: protocol::LogLevel::Warn, body: "test".to_owned(), - trace_id: "335e53d614474acc9f89e632b776cc28".parse().unwrap(), + trace_id: Some("335e53d614474acc9f89e632b776cc28".parse().unwrap()), timestamp: timestamp("2022-07-25T14:51:14.296Z"), severity_number: Some(1.try_into().unwrap()), attributes, @@ -1079,7 +1079,7 @@ some content Log { level: protocol::LogLevel::Error, body: "a body".to_owned(), - trace_id: "332253d614472a2c9f89e232b7762c28".parse().unwrap(), + trace_id: Some("332253d614472a2c9f89e232b7762c28".parse().unwrap()), timestamp: timestamp("2021-07-21T14:51:14.296Z"), severity_number: Some(1.try_into().unwrap()), attributes: attributes_2, diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index d984035a..827094bc 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -2127,7 +2127,8 @@ pub struct Log { /// The log body/message (required). pub body: String, /// The ID of the Trace in which this log happened (required). - pub trace_id: TraceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_id: Option, /// The timestamp of the log (required). #[serde(with = "ts_seconds_float")] pub timestamp: SystemTime, diff --git a/sentry/Cargo.toml b/sentry/Cargo.toml index 8e798195..9ab7e315 100644 --- a/sentry/Cargo.toml +++ b/sentry/Cargo.toml @@ -48,6 +48,7 @@ opentelemetry = ["sentry-opentelemetry"] # other features test = ["sentry-core/test"] release-health = ["sentry-core/release-health", "sentry-actix?/release-health"] +UNSTABLE_logs = ["sentry-core/UNSTABLE_logs"] # transports transport = ["reqwest", "native-tls"] reqwest = ["dep:reqwest", "httpdate", "tokio"] diff --git a/sentry/tests/test_basic.rs b/sentry/tests/test_basic.rs index dac87e37..c3b2afd7 100644 --- a/sentry/tests/test_basic.rs +++ b/sentry/tests/test_basic.rs @@ -264,3 +264,52 @@ fn test_panic_scope_pop() { Some("Popped scope guard out of order".into()) ); } + +#[cfg(feature = "UNSTABLE_logs")] +#[test] +fn test_basic_capture_log() { + use std::time::SystemTime; + + use sentry::{protocol::Log, protocol::LogAttribute, protocol::Map, Hub}; + + let options = sentry::ClientOptions { + enable_logs: true, + ..Default::default() + }; + let envelopes = sentry::test::with_captured_envelopes_options( + || { + let mut attributes: Map = Map::new(); + attributes.insert("test".into(), "a string".into()); + let log = Log { + level: sentry::protocol::LogLevel::Warn, + body: "this is a test".into(), + trace_id: None, + timestamp: SystemTime::now(), + severity_number: None, + attributes, + }; + + Hub::current().capture_log(log); + }, + 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::protocol::LogLevel::Warn, log.level); + assert_eq!("this is a test", 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")); + assert!(log.attributes.contains_key("test")); + } + _ => panic!("expected logs"), + }, + _ => panic!("expected item container"), + } +}