diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3493b8..97611b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +**Breaking Changes**: + +**Features**: + +- Add support for Profiling feature. ([#479](https://github.com/getsentry/sentry-rust/pull/479)) + +**Internal**: + +**Thank you**: + + ## 0.27.0 **Breaking Changes**: diff --git a/sentry-core/Cargo.toml b/sentry-core/Cargo.toml index ca2fb22d..d110cf8c 100644 --- a/sentry-core/Cargo.toml +++ b/sentry-core/Cargo.toml @@ -26,6 +26,7 @@ client = ["rand"] # and macros actually expand features (and extern crate) where they are used! debug-logs = ["log_"] test = ["client"] +profiling = ["pprof", "build_id", "uuid", "sys-info", "findshlibs"] [dependencies] log_ = { package = "log", version = "0.4.8", optional = true, features = ["std"] } @@ -33,7 +34,14 @@ once_cell = "1" rand = { version = "0.8.1", optional = true } sentry-types = { version = "0.27.0", path = "../sentry-types" } serde = { version = "1.0.104", features = ["derive"] } -serde_json = "1.0.46" +serde_json = { version = "1.0.46" } +uuid = { version = "1.0.0", features = ["v4", "serde"], optional = true } +sys-info = { version = "0.9.1", optional = true } +build_id = { version = "0.2.1", optional = true } +findshlibs = { version = "=0.10.2", optional = true } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +pprof = { version = "0.10.0", optional = true } [dev-dependencies] # Because we re-export all the public API in `sentry`, we actually run all the diff --git a/sentry-core/src/client.rs b/sentry-core/src/client.rs index 50bbea8b..ef44840a 100644 --- a/sentry-core/src/client.rs +++ b/sentry-core/src/client.rs @@ -215,7 +215,7 @@ impl Client { scope.update_session_from_event(&event); } - if !self.sample_should_send() { + if !self.sample_should_send(self.options.sample_rate) { None } else { Some(event) @@ -338,19 +338,9 @@ impl Client { } } - fn sample_should_send(&self) -> bool { - let rate = self.options.sample_rate; - if rate >= 1.0 { - true - } else { - random::() <= rate - } - } - /// Returns a random boolean with a probability defined - /// by the [`ClientOptions`]'s `traces_sample_rate` - pub fn sample_traces_should_send(&self) -> bool { - let rate = self.options.traces_sample_rate; + /// by rate + pub fn sample_should_send(&self, rate: f32) -> bool { if rate >= 1.0 { true } else { diff --git a/sentry-core/src/clientoptions.rs b/sentry-core/src/clientoptions.rs index 72846ece..c2559e0f 100644 --- a/sentry-core/src/clientoptions.rs +++ b/sentry-core/src/clientoptions.rs @@ -74,6 +74,14 @@ pub struct ClientOptions { pub sample_rate: f32, /// The sample rate for tracing transactions. (0.0 - 1.0, defaults to 0.0) pub traces_sample_rate: f32, + /// Enables profiling + pub enable_profiling: bool, + /// The sample rate for profiling a transactions. (0.0 - 1.0, defaults to 0.0) + /// + /// This is dependent on `traces_sample_rate`. The probability of sending a profile + /// is given by `traces_sample_rate * profiles_sample_rate`. + /// If a given transaction is not sent, then the profile won't be sent neither. + pub profiles_sample_rate: f32, /// Maximum number of breadcrumbs. (defaults to 100) pub max_breadcrumbs: usize, /// Attaches stacktraces to messages. @@ -183,6 +191,8 @@ impl fmt::Debug for ClientOptions { .field("environment", &self.environment) .field("sample_rate", &self.sample_rate) .field("traces_sample_rate", &self.traces_sample_rate) + .field("enable_profiling", &self.enable_profiling) + .field("profiles_sample_rate", &self.profiles_sample_rate) .field("max_breadcrumbs", &self.max_breadcrumbs) .field("attach_stacktrace", &self.attach_stacktrace) .field("send_default_pii", &self.send_default_pii) @@ -215,6 +225,8 @@ impl Default for ClientOptions { environment: None, sample_rate: 1.0, traces_sample_rate: 0.0, + enable_profiling: false, + profiles_sample_rate: 0.0, max_breadcrumbs: 100, attach_stacktrace: false, send_default_pii: false, diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs index 93a7cff8..e92d1c0a 100644 --- a/sentry-core/src/lib.rs +++ b/sentry-core/src/lib.rs @@ -92,6 +92,9 @@ pub use crate::client::Client; #[cfg(feature = "test")] pub mod test; +#[cfg(all(feature = "profiling", not(target_os = "windows")))] +mod profiling; + // public api from other crates #[doc(inline)] pub use sentry_types as types; diff --git a/sentry-core/src/performance.rs b/sentry-core/src/performance.rs index 02deb332..40007f85 100644 --- a/sentry-core/src/performance.rs +++ b/sentry-core/src/performance.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use std::sync::Mutex; +#[cfg(all(feature = "profiling", not(target_os = "windows")))] +use crate::profiling; use crate::{protocol, Hub}; #[cfg(feature = "client")] @@ -269,8 +271,10 @@ pub(crate) struct TransactionInner { #[cfg(feature = "client")] client: Option>, sampled: bool, - context: protocol::TraceContext, + pub(crate) context: protocol::TraceContext, pub(crate) transaction: Option>, + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + pub(crate) profiler_guard: Option, } type TransactionArc = Arc>; @@ -297,8 +301,9 @@ impl Transaction { let (sampled, mut transaction) = match client.as_ref() { Some(client) => ( - ctx.sampled - .unwrap_or_else(|| client.sample_traces_should_send()), + ctx.sampled.unwrap_or_else(|| { + client.sample_should_send(client.options().traces_sample_rate) + }), Some(protocol::Transaction { name: Some(ctx.name), ..Default::default() @@ -313,6 +318,14 @@ impl Transaction { transaction = None; client = None; } + // if the transaction was sampled then a profile, linked to the transaction, + // might as well be sampled + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + let profiler_guard = if sampled { + client.as_deref().and_then(profiling::start_profiling) + } else { + None + }; Self { inner: Arc::new(Mutex::new(TransactionInner { @@ -320,6 +333,8 @@ impl Transaction { sampled, context, transaction, + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + profiler_guard, })), } } @@ -339,6 +354,8 @@ impl Transaction { sampled, context, transaction: None, + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + profiler_guard: None, })), } } @@ -404,9 +421,21 @@ impl Transaction { transaction.environment = opts.environment.clone(); transaction.sdk = Some(std::borrow::Cow::Owned(client.sdk_info.clone())); + // if the profiler is running for the given transaction + // then call finish_profiling to return the profile + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + let profile = inner.profiler_guard.take().and_then(|profiler_guard| { + profiling::finish_profiling(&transaction, profiler_guard, inner.context.trace_id) + }); + let mut envelope = protocol::Envelope::new(); envelope.add_item(transaction); + #[cfg(all(feature = "profiling", not(target_os = "windows")))] + if let Some(profile) = profile { + envelope.add_item(profile); + } + client.send_envelope(envelope) } } diff --git a/sentry-core/src/profiling.rs b/sentry-core/src/profiling.rs new file mode 100644 index 00000000..9690b05f --- /dev/null +++ b/sentry-core/src/profiling.rs @@ -0,0 +1,239 @@ +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; + +use findshlibs::{SharedLibrary, SharedLibraryId, TargetSharedLibrary, TARGET_SUPPORTED}; + +use sentry_types::protocol::v7::Profile; +use sentry_types::protocol::v7::{ + DebugImage, DebugMeta, RustFrame, Sample, SampledProfile, SymbolicDebugImage, TraceId, + Transaction, +}; +use sentry_types::{CodeId, DebugId, Uuid}; + +#[cfg(feature = "client")] +use crate::Client; + +static PROFILER_RUNNING: AtomicBool = AtomicBool::new(false); + +pub(crate) struct ProfilerGuard(pprof::ProfilerGuard<'static>); + +impl fmt::Debug for ProfilerGuard { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "[ProfilerGuard]") + } +} + +pub(crate) fn start_profiling(client: &Client) -> Option { + // if profiling is not enabled or the profile was not sampled + // return None immediately + if !client.options().enable_profiling + || !client.sample_should_send(client.options().profiles_sample_rate) + { + return None; + } + + // if no other profile is being collected, then + // start the profiler + if let Ok(false) = + PROFILER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + { + let profile_guard_builder = pprof::ProfilerGuardBuilder::default() + .frequency(100) + .blocklist(&["libc", "libgcc", "pthread", "vdso"]) + .build(); + + match profile_guard_builder { + Ok(guard_builder) => return Some(ProfilerGuard(guard_builder)), + Err(err) => { + sentry_debug!( + "could not start the profiler due to the following error: {:?}", + err + ); + PROFILER_RUNNING.store(false, Ordering::SeqCst); + } + } + } + None +} + +pub(crate) fn finish_profiling( + transaction: &Transaction, + profiler_guard: ProfilerGuard, + trace_id: TraceId, +) -> Option { + let profile = match profiler_guard.0.report().build_unresolved() { + Ok(report) => Some(get_profile_from_report( + &report, + trace_id, + transaction.event_id, + transaction.name.as_ref().unwrap().clone(), + )), + Err(err) => { + sentry_debug!( + "could not build the profile result due to the error: {}", + err + ); + None + } + }; + + PROFILER_RUNNING.store(false, Ordering::SeqCst); + profile +} + +/// Converts an ELF object identifier into a `DebugId`. +/// +/// The identifier data is first truncated or extended to match 16 byte size of +/// Uuids. If the data is declared in little endian, the first three Uuid fields +/// are flipped to match the big endian expected by the breakpad processor. +/// +/// The `DebugId::appendix` field is always `0` for ELF. +fn debug_id_from_build_id(build_id: &[u8]) -> Option { + const UUID_SIZE: usize = 16; + let mut data = [0u8; UUID_SIZE]; + let len = build_id.len().min(UUID_SIZE); + data[0..len].copy_from_slice(&build_id[0..len]); + + #[cfg(target_endian = "little")] + { + // The ELF file targets a little endian architecture. Convert to + // network byte order (big endian) to match the Breakpad processor's + // expectations. For big endian object files, this is not needed. + data[0..4].reverse(); // uuid field 1 + data[4..6].reverse(); // uuid field 2 + data[6..8].reverse(); // uuid field 3 + } + + Uuid::from_slice(&data).map(DebugId::from_uuid).ok() +} + +pub fn debug_images() -> Vec { + let mut images = vec![]; + if !TARGET_SUPPORTED { + return images; + } + + //crate:: ::{CodeId, DebugId, Uuid}; + TargetSharedLibrary::each(|shlib| { + let maybe_debug_id = shlib.debug_id().and_then(|id| match id { + SharedLibraryId::Uuid(bytes) => Some(DebugId::from_uuid(Uuid::from_bytes(bytes))), + SharedLibraryId::GnuBuildId(ref id) => debug_id_from_build_id(id), + SharedLibraryId::PdbSignature(guid, age) => DebugId::from_guid_age(&guid, age).ok(), + _ => None, + }); + + let debug_id = match maybe_debug_id { + Some(debug_id) => debug_id, + None => return, + }; + + let mut name = shlib.name().to_string_lossy().to_string(); + if name.is_empty() { + name = std::env::current_exe() + .map(|x| x.display().to_string()) + .unwrap_or_else(|_| "
".to_string()); + } + + let code_id = shlib.id().map(|id| CodeId::new(format!("{}", id))); + let debug_name = shlib.debug_name().map(|n| n.to_string_lossy().to_string()); + + // For windows, the `virtual_memory_bias` actually returns the real + // `module_base`, which is the address that sentry uses for symbolication. + // Going via the segments means that the `image_addr` would be offset in + // a way that symbolication yields wrong results. + let (image_addr, image_vmaddr) = if cfg!(windows) { + (shlib.virtual_memory_bias().0.into(), 0.into()) + } else { + ( + shlib.actual_load_addr().0.into(), + shlib.stated_load_addr().0.into(), + ) + }; + + images.push( + SymbolicDebugImage { + id: debug_id, + name, + arch: None, + image_addr, + image_size: shlib.len() as u64, + image_vmaddr, + code_id, + debug_file: debug_name, + } + .into(), + ); + }); + + images +} + +fn get_profile_from_report( + rep: &pprof::UnresolvedReport, + trace_id: TraceId, + transaction_id: sentry_types::Uuid, + transaction_name: String, +) -> Profile { + use std::time::SystemTime; + + let mut samples: Vec = Vec::new(); + + for sample in rep.data.keys() { + let frames = sample + .frames + .iter() + .map(|frame| RustFrame { + instruction_addr: format!("{:p}", frame.ip()), + }) + .collect(); + + samples.push(Sample { + frames, + thread_name: String::from_utf8_lossy(&sample.thread_name[0..sample.thread_name_length]) + .into_owned(), + thread_id: sample.thread_id, + nanos_relative_to_start: sample + .sample_timestamp + .duration_since(rep.timing.start_time) + .unwrap() + .as_nanos() as u64, + }); + } + let sampled_profile = SampledProfile { + start_time_nanos: rep + .timing + .start_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64, + start_time_secs: rep + .timing + .start_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(), + duration_nanos: rep.timing.duration.as_nanos() as u64, + samples, + }; + + let profile: Profile = Profile { + duration_ns: sampled_profile.duration_nanos, + debug_meta: DebugMeta { + sdk_info: None, + images: debug_images(), + }, + platform: "rust".to_string(), + architecture: Some(std::env::consts::ARCH.to_string()), + trace_id, + transaction_name, + transaction_id, + profile_id: uuid::Uuid::new_v4(), + sampled_profile, + os_name: sys_info::os_type().unwrap(), + os_version: sys_info::os_release().unwrap(), + version_name: env!("CARGO_PKG_VERSION").to_string(), + version_code: build_id::get().to_simple().to_string(), + }; + + profile +} diff --git a/sentry-types/src/protocol/envelope.rs b/sentry-types/src/protocol/envelope.rs index a2586526..be94c39a 100644 --- a/sentry-types/src/protocol/envelope.rs +++ b/sentry-types/src/protocol/envelope.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use super::{ attachment::AttachmentType, - v7::{Attachment, Event, SessionAggregates, SessionUpdate, Transaction}, + v7::{Attachment, Event, Profile, SessionAggregates, SessionUpdate, Transaction}, }; /// Raised if a envelope cannot be parsed from a given input. @@ -58,6 +58,9 @@ enum EnvelopeItemType { /// An Attachment Item type. #[serde(rename = "attachment")] Attachment, + /// A Profile Item Type + #[serde(rename = "profile")] + Profile, } /// An Envelope Item Header. @@ -104,6 +107,9 @@ pub enum EnvelopeItem { /// See the [Attachment Item documentation](https://develop.sentry.dev/sdk/envelopes/#attachment) /// for more details. Attachment(Attachment), + /// An Profile Item. + /// + Profile(Profile), // TODO: // etc… } @@ -138,6 +144,12 @@ impl From for EnvelopeItem { } } +impl From for EnvelopeItem { + fn from(profile: Profile) -> Self { + EnvelopeItem::Profile(profile) + } +} + /// An Iterator over the items of an Envelope. #[derive(Clone)] pub struct EnvelopeItemIter<'s> { @@ -282,6 +294,7 @@ impl Envelope { writeln!(writer)?; continue; } + EnvelopeItem::Profile(profile) => serde_json::to_writer(&mut item_buf, profile)?, } let item_type = match item { EnvelopeItem::Event(_) => "event", @@ -289,6 +302,7 @@ impl Envelope { EnvelopeItem::SessionAggregates(_) => "sessions", EnvelopeItem::Transaction(_) => "transaction", EnvelopeItem::Attachment(_) => unreachable!(), + EnvelopeItem::Profile(_) => "profile", }; writeln!( writer, @@ -412,6 +426,7 @@ impl Envelope { content_type: header.content_type, ty: header.attachment_type, })), + EnvelopeItemType::Profile => serde_json::from_slice(payload).map(EnvelopeItem::Profile), } .map_err(EnvelopeError::InvalidItemPayload)?; diff --git a/sentry-types/src/protocol/mod.rs b/sentry-types/src/protocol/mod.rs index 72873bd3..3d6ed71e 100644 --- a/sentry-types/src/protocol/mod.rs +++ b/sentry-types/src/protocol/mod.rs @@ -11,4 +11,5 @@ pub use v7 as latest; mod attachment; mod envelope; +mod profile; mod session; diff --git a/sentry-types/src/protocol/profile.rs b/sentry-types/src/protocol/profile.rs new file mode 100644 index 00000000..e8396c87 --- /dev/null +++ b/sentry-types/src/protocol/profile.rs @@ -0,0 +1,77 @@ +use super::v7::{DebugMeta, TraceId}; +use serde::{Deserialize, Serialize, Serializer}; +use uuid::Uuid; + +fn serialize_id(uuid: &Uuid, serializer: S) -> Result { + serializer.serialize_some(&uuid.as_simple()) +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +/// Represents a Symbol +pub struct RustFrame { + /// Raw instruction address + pub instruction_addr: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +/// Represents a Sample +pub struct Sample { + /// List of symbols + pub frames: Vec, + /// The thread name + pub thread_name: String, + /// The thread id + pub thread_id: u64, + /// Nanoseconds elapsed between when the profiler started and when this sample was collected + pub nanos_relative_to_start: u64, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] +/// Represents a collected Profile +pub struct SampledProfile { + /// Collection start time in nanoseconds + pub start_time_nanos: u64, + /// Collection start time in seconds + pub start_time_secs: u64, + /// Collection duration in nanoseconds + pub duration_nanos: u64, + /// List of collected samples + pub samples: Vec, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] +/// Represents a Profile Envelope ItemType +pub struct Profile { + /// Duration in nanoseconds of the Profile + pub duration_ns: u64, + /// List of debug images + pub debug_meta: DebugMeta, + /// Platform is `rust` + pub platform: String, + /// A string describing the architecture of the CPU that is currently in use + /// + #[serde(default, skip_serializing_if = "Option::is_none")] + pub architecture: Option, + /// The trace ID + pub trace_id: TraceId, + /// The name of the transaction this profile belongs to + pub transaction_name: String, + #[serde(serialize_with = "serialize_id")] + /// The ID of the transaction this profile belongs to + pub transaction_id: Uuid, + /// The ID of the event + #[serde(serialize_with = "serialize_id")] + pub profile_id: Uuid, + /// Represents the profile collected + pub sampled_profile: SampledProfile, + /// OS name + #[serde(rename = "device_os_name")] + pub os_name: String, + #[serde(rename = "device_os_version")] + /// OS version + pub os_version: String, + /// Package version + pub version_name: String, + /// Current binary build ID. See + pub version_code: String, +} diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index adf13646..7dc94e26 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -26,6 +26,7 @@ use crate::utils::{ts_rfc3339_opt, ts_seconds_float}; pub use super::attachment::*; pub use super::envelope::*; +pub use super::profile::*; pub use super::session::*; /// An arbitrary (JSON) value. diff --git a/sentry/Cargo.toml b/sentry/Cargo.toml index d96d2ae4..803be63a 100644 --- a/sentry/Cargo.toml +++ b/sentry/Cargo.toml @@ -47,6 +47,7 @@ native-tls = ["reqwest_/default-tls"] rustls = ["reqwest_/rustls-tls"] ureq = ["ureq_/tls", "httpdate"] ureq-native-tls = ["ureq_/native-tls", "httpdate"] +profiling = ["sentry-core/profiling"] [dependencies] sentry-core = { version = "0.27.0", path = "../sentry-core", features = ["client"] } diff --git a/sentry/src/transports/ratelimit.rs b/sentry/src/transports/ratelimit.rs index 0aa36bb7..e419f999 100644 --- a/sentry/src/transports/ratelimit.rs +++ b/sentry/src/transports/ratelimit.rs @@ -12,6 +12,7 @@ pub struct RateLimiter { session: Option, transaction: Option, attachment: Option, + profile: Option, } impl RateLimiter { @@ -56,6 +57,7 @@ impl RateLimiter { "session" => self.session = new_time, "transaction" => self.transaction = new_time, "attachment" => self.attachment = new_time, + "profile" => self.profile = new_time, _ => {} } } @@ -89,6 +91,7 @@ impl RateLimiter { RateLimitingCategory::Session => self.session, RateLimitingCategory::Transaction => self.transaction, RateLimitingCategory::Attachment => self.attachment, + RateLimitingCategory::Profile => self.profile, }?; time_left.duration_since(SystemTime::now()).ok() } @@ -112,6 +115,7 @@ impl RateLimiter { } EnvelopeItem::Transaction(_) => RateLimitingCategory::Transaction, EnvelopeItem::Attachment(_) => RateLimitingCategory::Attachment, + EnvelopeItem::Profile(_) => RateLimitingCategory::Profile, _ => RateLimitingCategory::Any, }) }) @@ -131,6 +135,8 @@ pub enum RateLimitingCategory { Transaction, /// Rate Limit pertaining to Attachments. Attachment, + /// Rate Limit pertaining to Profiles. + Profile, } #[cfg(test)]