relay_profiling/
lib.rs

1//! Profiling protocol and processing for Sentry.
2//!
3//! Profiles are captured during the life of a transaction on Sentry clients and sent to Relay for
4//! ingestion as JSON objects with some metadata. They are sent alongside a transaction (usually in
5//! the same envelope) and are usually big objects (average size around 300KB and it's not unusual
6//! to see several MB). Their size is linked to the amount of code executed during the transaction
7//! (so the duration of the transaction influences the size of the profile).
8//!
9//! It's preferrable to have a profile with a transaction. If a transaction is dropped, the profile
10//! should be dropped as well.
11//!
12//! # Envelope
13//!
14//! ## Transaction Profiling
15//!
16//! To send a profile of a transaction to Relay, the profile is enclosed in an item of type
17//! `profile`:
18//! ```json
19//! {"type": "profile", "size": ...}
20//! { ... }
21//! ```
22//! ## Continuous Profiling
23//!
24//! For continuous profiling, we expect to receive chunks of profile in an item of type
25//! `profile_chunk`:
26//! ```json
27//! {"type": "profile_chunk"}
28//! { ... }
29//! ```
30//!
31//! # Protocol
32//!
33//! Each item type expects a different format.
34//!
35//! For `Profile` item type, we expect the Sample format v1 or Android format.
36//! For `ProfileChunk` item type, we expect the Sample format v2.
37//!
38//! # Ingestion
39//!
40//! Relay will forward those profiles encoded with `msgpack` after unpacking them if needed and push a message on Kafka.
41
42use std::error::Error;
43use std::net::IpAddr;
44use std::time::Duration;
45
46use bytes::Bytes;
47
48use relay_base_schema::project::ProjectId;
49use relay_dynamic_config::GlobalConfig;
50use relay_event_schema::protocol::{Event, EventId};
51use relay_filter::{Filterable, ProjectFiltersConfig};
52use relay_protocol::{Getter, Val};
53use serde::Deserialize;
54use serde_json::Deserializer;
55
56use crate::extract_from_transaction::{extract_transaction_metadata, extract_transaction_tags};
57
58pub use crate::error::ProfileError;
59pub use crate::outcomes::discard_reason;
60
61mod android;
62mod debug_image;
63mod error;
64mod extract_from_transaction;
65mod measurements;
66mod outcomes;
67mod sample;
68mod transaction_metadata;
69mod types;
70mod utils;
71
72const MAX_PROFILE_DURATION: Duration = Duration::from_secs(30);
73/// For continuous profiles, each chunk can be at most 1 minute.
74/// In certain circumstances (e.g. high cpu load) the profiler
75/// the profiler may be stopped slightly after 60, hence here we
76/// give it a bit more room to handle such cases (66 instead of 60)
77const MAX_PROFILE_CHUNK_DURATION: Duration = Duration::from_secs(66);
78
79/// Unique identifier for a profile.
80///
81/// Same format as event IDs.
82pub type ProfileId = EventId;
83
84/// Determines the type/use of a [`ProfileChunk`].
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ProfileType {
87    /// A backend profile.
88    Backend,
89    /// A UI profile.
90    Ui,
91}
92
93impl ProfileType {
94    /// Converts a platform to a [`ProfileType`].
95    ///
96    /// The profile type is currently determined based on the contained profile
97    /// platform. It determines the data category this profile chunk belongs to.
98    ///
99    /// This needs to be synchronized with the implementation in Sentry:
100    /// <https://github.com/getsentry/sentry/blob/ed2e1c8bcd0d633e6f828fcfbeefbbdd98ef3dba/src/sentry/profiles/task.py#L995>
101    pub fn from_platform(platform: &str) -> Self {
102        match platform {
103            "cocoa" | "android" | "javascript" => Self::Ui,
104            _ => Self::Backend,
105        }
106    }
107}
108
109#[derive(Debug, Deserialize)]
110struct MinimalProfile {
111    #[serde(alias = "profile_id", alias = "chunk_id")]
112    event_id: ProfileId,
113    platform: String,
114    release: Option<String>,
115    #[serde(default)]
116    version: sample::Version,
117}
118
119impl Filterable for MinimalProfile {
120    fn release(&self) -> Option<&str> {
121        self.release.as_deref()
122    }
123}
124
125impl Getter for MinimalProfile {
126    fn get_value(&self, path: &str) -> Option<Val<'_>> {
127        match path.strip_prefix("event.")? {
128            "release" => self.release.as_deref().map(|release| release.into()),
129            "platform" => Some(self.platform.as_str().into()),
130            _ => None,
131        }
132    }
133}
134
135fn minimal_profile_from_json(
136    payload: &[u8],
137) -> Result<MinimalProfile, serde_path_to_error::Error<serde_json::Error>> {
138    let d = &mut Deserializer::from_slice(payload);
139    serde_path_to_error::deserialize(d)
140}
141
142pub fn parse_metadata(payload: &[u8], project_id: ProjectId) -> Result<ProfileId, ProfileError> {
143    let profile = match minimal_profile_from_json(payload) {
144        Ok(profile) => profile,
145        Err(err) => {
146            relay_log::debug!(
147                error = &err as &dyn Error,
148                from = "minimal",
149                project_id = project_id.value(),
150            );
151            return Err(ProfileError::InvalidJson(err));
152        }
153    };
154    match profile.version {
155        sample::Version::V1 => {
156            let d = &mut Deserializer::from_slice(payload);
157            let _: sample::v1::ProfileMetadata = match serde_path_to_error::deserialize(d) {
158                Ok(profile) => profile,
159                Err(err) => {
160                    relay_log::debug!(
161                        error = &err as &dyn Error,
162                        from = "metadata",
163                        platform = profile.platform,
164                        project_id = project_id.value(),
165                        "invalid profile",
166                    );
167                    return Err(ProfileError::InvalidJson(err));
168                }
169            };
170        }
171        _ => match profile.platform.as_str() {
172            "android" => {
173                let d = &mut Deserializer::from_slice(payload);
174                let _: android::legacy::ProfileMetadata = match serde_path_to_error::deserialize(d)
175                {
176                    Ok(profile) => profile,
177                    Err(err) => {
178                        relay_log::debug!(
179                            error = &err as &dyn Error,
180                            from = "metadata",
181                            platform = "android",
182                            project_id = project_id.value(),
183                            "invalid profile",
184                        );
185                        return Err(ProfileError::InvalidJson(err));
186                    }
187                };
188            }
189            _ => return Err(ProfileError::PlatformNotSupported),
190        },
191    };
192    Ok(profile.event_id)
193}
194
195pub fn expand_profile(
196    payload: &[u8],
197    event: &Event,
198    client_ip: Option<IpAddr>,
199    filter_settings: &ProjectFiltersConfig,
200    global_config: &GlobalConfig,
201) -> Result<(ProfileId, Vec<u8>), ProfileError> {
202    let profile = match minimal_profile_from_json(payload) {
203        Ok(profile) => profile,
204        Err(err) => {
205            relay_log::debug!(
206                error = &err as &dyn Error,
207                from = "minimal",
208                platform = event.platform.as_str(),
209                project_id = event.project.value().unwrap_or(&0),
210                sdk_name = event.sdk_name(),
211                sdk_version = event.sdk_version(),
212                transaction_id = ?event.id.value(),
213                "invalid profile",
214            );
215            return Err(ProfileError::InvalidJson(err));
216        }
217    };
218
219    if let Err(filter_stat_key) = relay_filter::should_filter(
220        &profile,
221        client_ip,
222        filter_settings,
223        global_config.filters(),
224    ) {
225        return Err(ProfileError::Filtered(filter_stat_key));
226    }
227
228    let transaction_metadata = extract_transaction_metadata(event);
229    let transaction_tags = extract_transaction_tags(event);
230    let processed_payload = match (profile.platform.as_str(), profile.version) {
231        (_, sample::Version::V1) => {
232            sample::v1::parse_sample_profile(payload, transaction_metadata, transaction_tags)
233        }
234        ("android", _) => {
235            android::legacy::parse_android_profile(payload, transaction_metadata, transaction_tags)
236        }
237        (_, _) => return Err(ProfileError::PlatformNotSupported),
238    };
239    match processed_payload {
240        Ok(payload) => Ok((profile.event_id, payload)),
241        Err(err) => match err {
242            ProfileError::InvalidJson(err) => {
243                relay_log::debug!(
244                    error = &err as &dyn Error,
245                    from = "parsing",
246                    platform = profile.platform,
247                    project_id = event.project.value().unwrap_or(&0),
248                    sdk_name = event.sdk_name(),
249                    sdk_version = event.sdk_version(),
250                    transaction_id = ?event.id.value(),
251                    "invalid profile",
252                );
253                Err(ProfileError::InvalidJson(err))
254            }
255            _ => {
256                relay_log::debug!(
257                    error = &err as &dyn Error,
258                    from = "parsing",
259                    platform = profile.platform,
260                    project_id = event.project.value().unwrap_or(&0),
261                    sdk_name = event.sdk_name(),
262                    sdk_version = event.sdk_version(),
263                    transaction_id = ?event.id.value(),
264                    "invalid profile",
265                );
266                Err(err)
267            }
268        },
269    }
270}
271
272/// Intermediate type for all processing on a profile chunk.
273pub struct ProfileChunk {
274    profile: MinimalProfile,
275    payload: Bytes,
276}
277
278impl ProfileChunk {
279    /// Parses a new [`Self`] from raw bytes.
280    pub fn new(payload: Bytes) -> Result<Self, ProfileError> {
281        match minimal_profile_from_json(&payload) {
282            Ok(profile) => Ok(Self { profile, payload }),
283            Err(err) => {
284                relay_log::debug!(
285                    error = &err as &dyn Error,
286                    from = "minimal",
287                    "invalid profile chunk",
288                );
289                Err(ProfileError::InvalidJson(err))
290            }
291        }
292    }
293
294    /// Returns the [`ProfileType`] this chunk belongs to.
295    ///
296    /// This is currently determined from the platform via [`ProfileType::from_platform`].
297    pub fn profile_type(&self) -> ProfileType {
298        ProfileType::from_platform(&self.profile.platform)
299    }
300
301    /// Applies inbound filters to the profile chunk.
302    ///
303    /// The profile needs to be filtered (rejected) when this returns an error.
304    pub fn filter(
305        &self,
306        client_ip: Option<IpAddr>,
307        filter_settings: &ProjectFiltersConfig,
308        global_config: &GlobalConfig,
309    ) -> Result<(), ProfileError> {
310        relay_filter::should_filter(
311            &self.profile,
312            client_ip,
313            filter_settings,
314            global_config.filters(),
315        )
316        .map_err(ProfileError::Filtered)
317    }
318
319    /// Normalizes and 'expands' the profile chunk into its normalized form Sentry expects.
320    pub fn expand(&self) -> Result<Vec<u8>, ProfileError> {
321        match (self.profile.platform.as_str(), self.profile.version) {
322            ("android", _) => android::chunk::parse(&self.payload),
323            (_, sample::Version::V2) => {
324                let mut profile = sample::v2::parse(&self.payload)?;
325                profile.normalize()?;
326                Ok(serde_json::to_vec(&profile)
327                    .map_err(|_| ProfileError::CannotSerializePayload)?)
328            }
329            (_, _) => Err(ProfileError::PlatformNotSupported),
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_minimal_profile_with_version() {
340        let data = r#"{"version":"1","platform":"cocoa","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
341        let profile = minimal_profile_from_json(data.as_bytes());
342        assert!(profile.is_ok());
343        assert_eq!(profile.unwrap().version, sample::Version::V1);
344    }
345
346    #[test]
347    fn test_minimal_profile_without_version() {
348        let data = r#"{"platform":"android","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
349        let profile = minimal_profile_from_json(data.as_bytes());
350        assert!(profile.is_ok());
351        assert_eq!(profile.unwrap().version, sample::Version::Unknown);
352    }
353
354    #[test]
355    fn test_expand_profile_with_version() {
356        let payload = include_bytes!("../tests/fixtures/sample/v1/valid.json");
357        assert!(
358            expand_profile(
359                payload,
360                &Event::default(),
361                None,
362                &ProjectFiltersConfig::default(),
363                &GlobalConfig::default()
364            )
365            .is_ok()
366        );
367    }
368
369    #[test]
370    fn test_expand_profile_with_version_and_segment_id() {
371        let payload = include_bytes!("../tests/fixtures/sample/v1/segment_id.json");
372        assert!(
373            expand_profile(
374                payload,
375                &Event::default(),
376                None,
377                &ProjectFiltersConfig::default(),
378                &GlobalConfig::default()
379            )
380            .is_ok()
381        );
382    }
383
384    #[test]
385    fn test_expand_profile_without_version() {
386        let payload = include_bytes!("../tests/fixtures/android/legacy/roundtrip.json");
387        assert!(
388            expand_profile(
389                payload,
390                &Event::default(),
391                None,
392                &ProjectFiltersConfig::default(),
393                &GlobalConfig::default()
394            )
395            .is_ok()
396        );
397    }
398}