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;
47use url::Url;
48
49use relay_base_schema::project::ProjectId;
50use relay_dynamic_config::GlobalConfig;
51use relay_event_schema::protocol::{Csp, Event, EventId, Exception, LogEntry, Values};
52use relay_filter::{Filterable, ProjectFiltersConfig};
53use relay_protocol::{Getter, Val};
54use serde::Deserialize;
55use serde_json::Deserializer;
56
57use crate::extract_from_transaction::{extract_transaction_metadata, extract_transaction_tags};
58
59pub use crate::error::ProfileError;
60pub use crate::outcomes::discard_reason;
61
62mod android;
63mod debug_image;
64mod error;
65mod extract_from_transaction;
66mod measurements;
67mod outcomes;
68mod sample;
69mod transaction_metadata;
70mod types;
71mod utils;
72
73const MAX_PROFILE_DURATION: Duration = Duration::from_secs(30);
74/// For continuous profiles, each chunk can be at most 1 minute.
75/// In certain circumstances (e.g. high cpu load) the profiler
76/// the profiler may be stopped slightly after 60, hence here we
77/// give it a bit more room to handle such cases (66 instead of 60)
78const MAX_PROFILE_CHUNK_DURATION: Duration = Duration::from_secs(66);
79
80/// Unique identifier for a profile.
81///
82/// Same format as event IDs.
83pub type ProfileId = EventId;
84
85/// Determines the type/use of a [`ProfileChunk`].
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ProfileType {
88    /// A backend profile.
89    Backend,
90    /// A UI profile.
91    Ui,
92}
93
94impl ProfileType {
95    /// Converts a platform to a [`ProfileType`].
96    ///
97    /// The profile type is currently determined based on the contained profile
98    /// platform. It determines the data category this profile chunk belongs to.
99    ///
100    /// This needs to be synchronized with the implementation in Sentry:
101    /// <https://github.com/getsentry/sentry/blob/ed2e1c8bcd0d633e6f828fcfbeefbbdd98ef3dba/src/sentry/profiles/task.py#L995>
102    pub fn from_platform(platform: &str) -> Self {
103        match platform {
104            "cocoa" | "android" | "javascript" => Self::Ui,
105            _ => Self::Backend,
106        }
107    }
108}
109
110#[derive(Debug, Deserialize)]
111struct MinimalProfile {
112    #[serde(alias = "profile_id", alias = "chunk_id")]
113    event_id: ProfileId,
114    platform: String,
115    release: Option<String>,
116    #[serde(default)]
117    version: sample::Version,
118}
119
120impl Filterable for MinimalProfile {
121    fn csp(&self) -> Option<&Csp> {
122        None
123    }
124
125    fn exceptions(&self) -> Option<&Values<Exception>> {
126        None
127    }
128
129    fn ip_addr(&self) -> Option<&str> {
130        None
131    }
132
133    fn logentry(&self) -> Option<&LogEntry> {
134        None
135    }
136
137    fn release(&self) -> Option<&str> {
138        self.release.as_deref()
139    }
140
141    fn transaction(&self) -> Option<&str> {
142        None
143    }
144
145    fn url(&self) -> Option<Url> {
146        None
147    }
148
149    fn user_agent(&self) -> Option<&str> {
150        None
151    }
152
153    fn header(&self, _: &str) -> Option<&str> {
154        None
155    }
156}
157
158impl Getter for MinimalProfile {
159    fn get_value(&self, path: &str) -> Option<Val<'_>> {
160        match path.strip_prefix("event.")? {
161            "release" => self.release.as_deref().map(|release| release.into()),
162            "platform" => Some(self.platform.as_str().into()),
163            _ => None,
164        }
165    }
166}
167
168fn minimal_profile_from_json(
169    payload: &[u8],
170) -> Result<MinimalProfile, serde_path_to_error::Error<serde_json::Error>> {
171    let d = &mut Deserializer::from_slice(payload);
172    serde_path_to_error::deserialize(d)
173}
174
175pub fn parse_metadata(payload: &[u8], project_id: ProjectId) -> Result<ProfileId, ProfileError> {
176    let profile = match minimal_profile_from_json(payload) {
177        Ok(profile) => profile,
178        Err(err) => {
179            relay_log::warn!(
180                error = &err as &dyn Error,
181                from = "minimal",
182                project_id = project_id.value(),
183            );
184            return Err(ProfileError::InvalidJson(err));
185        }
186    };
187    match profile.version {
188        sample::Version::V1 => {
189            let d = &mut Deserializer::from_slice(payload);
190            let _: sample::v1::ProfileMetadata = match serde_path_to_error::deserialize(d) {
191                Ok(profile) => profile,
192                Err(err) => {
193                    relay_log::warn!(
194                        error = &err as &dyn Error,
195                        from = "metadata",
196                        platform = profile.platform,
197                        project_id = project_id.value(),
198                        "invalid profile",
199                    );
200                    return Err(ProfileError::InvalidJson(err));
201                }
202            };
203        }
204        _ => match profile.platform.as_str() {
205            "android" => {
206                let d = &mut Deserializer::from_slice(payload);
207                let _: android::legacy::ProfileMetadata = match serde_path_to_error::deserialize(d)
208                {
209                    Ok(profile) => profile,
210                    Err(err) => {
211                        relay_log::debug!(
212                            error = &err as &dyn Error,
213                            from = "metadata",
214                            platform = "android",
215                            project_id = project_id.value(),
216                            "invalid profile",
217                        );
218                        return Err(ProfileError::InvalidJson(err));
219                    }
220                };
221            }
222            _ => return Err(ProfileError::PlatformNotSupported),
223        },
224    };
225    Ok(profile.event_id)
226}
227
228pub fn expand_profile(
229    payload: &[u8],
230    event: &Event,
231    client_ip: Option<IpAddr>,
232    filter_settings: &ProjectFiltersConfig,
233    global_config: &GlobalConfig,
234) -> Result<(ProfileId, Vec<u8>), ProfileError> {
235    let profile = match minimal_profile_from_json(payload) {
236        Ok(profile) => profile,
237        Err(err) => {
238            relay_log::warn!(
239                error = &err as &dyn Error,
240                from = "minimal",
241                platform = event.platform.as_str(),
242                project_id = event.project.value().unwrap_or(&0),
243                sdk_name = event.sdk_name(),
244                sdk_version = event.sdk_version(),
245                transaction_id = ?event.id.value(),
246                "invalid profile",
247            );
248            return Err(ProfileError::InvalidJson(err));
249        }
250    };
251
252    if let Err(filter_stat_key) = relay_filter::should_filter(
253        &profile,
254        client_ip,
255        filter_settings,
256        global_config.filters(),
257    ) {
258        return Err(ProfileError::Filtered(filter_stat_key));
259    }
260
261    let transaction_metadata = extract_transaction_metadata(event);
262    let transaction_tags = extract_transaction_tags(event);
263    let processed_payload = match (profile.platform.as_str(), profile.version) {
264        (_, sample::Version::V1) => {
265            sample::v1::parse_sample_profile(payload, transaction_metadata, transaction_tags)
266        }
267        ("android", _) => {
268            android::legacy::parse_android_profile(payload, transaction_metadata, transaction_tags)
269        }
270        (_, _) => return Err(ProfileError::PlatformNotSupported),
271    };
272    match processed_payload {
273        Ok(payload) => Ok((profile.event_id, payload)),
274        Err(err) => match err {
275            ProfileError::InvalidJson(err) => {
276                relay_log::warn!(
277                    error = &err as &dyn Error,
278                    from = "parsing",
279                    platform = profile.platform,
280                    project_id = event.project.value().unwrap_or(&0),
281                    sdk_name = event.sdk_name(),
282                    sdk_version = event.sdk_version(),
283                    transaction_id = ?event.id.value(),
284                    "invalid profile",
285                );
286                Err(ProfileError::InvalidJson(err))
287            }
288            _ => {
289                relay_log::debug!(
290                    error = &err as &dyn Error,
291                    from = "parsing",
292                    platform = profile.platform,
293                    project_id = event.project.value().unwrap_or(&0),
294                    sdk_name = event.sdk_name(),
295                    sdk_version = event.sdk_version(),
296                    transaction_id = ?event.id.value(),
297                    "invalid profile",
298                );
299                Err(err)
300            }
301        },
302    }
303}
304
305/// Intermediate type for all processing on a profile chunk.
306pub struct ProfileChunk {
307    profile: MinimalProfile,
308    payload: Bytes,
309}
310
311impl ProfileChunk {
312    /// Parses a new [`Self`] from raw bytes.
313    pub fn new(payload: Bytes) -> Result<Self, ProfileError> {
314        match minimal_profile_from_json(&payload) {
315            Ok(profile) => Ok(Self { profile, payload }),
316            Err(err) => {
317                relay_log::debug!(
318                    error = &err as &dyn Error,
319                    from = "minimal",
320                    "invalid profile chunk",
321                );
322                Err(ProfileError::InvalidJson(err))
323            }
324        }
325    }
326
327    /// Returns the [`ProfileType`] this chunk belongs to.
328    ///
329    /// This is currently determined from the platform via [`ProfileType::from_platform`].
330    pub fn profile_type(&self) -> ProfileType {
331        ProfileType::from_platform(&self.profile.platform)
332    }
333
334    /// Applies inbound filters to the profile chunk.
335    ///
336    /// The profile needs to be filtered (rejected) when this returns an error.
337    pub fn filter(
338        &self,
339        client_ip: Option<IpAddr>,
340        filter_settings: &ProjectFiltersConfig,
341        global_config: &GlobalConfig,
342    ) -> Result<(), ProfileError> {
343        relay_filter::should_filter(
344            &self.profile,
345            client_ip,
346            filter_settings,
347            global_config.filters(),
348        )
349        .map_err(ProfileError::Filtered)
350    }
351
352    /// Normalizes and 'expands' the profile chunk into its normalized form Sentry expects.
353    pub fn expand(&self) -> Result<Vec<u8>, ProfileError> {
354        match (self.profile.platform.as_str(), self.profile.version) {
355            ("android", _) => android::chunk::parse(&self.payload),
356            (_, sample::Version::V2) => {
357                let mut profile = sample::v2::parse(&self.payload)?;
358                profile.normalize()?;
359                Ok(serde_json::to_vec(&profile)
360                    .map_err(|_| ProfileError::CannotSerializePayload)?)
361            }
362            (_, _) => Err(ProfileError::PlatformNotSupported),
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_minimal_profile_with_version() {
373        let data = r#"{"version":"1","platform":"cocoa","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
374        let profile = minimal_profile_from_json(data.as_bytes());
375        assert!(profile.is_ok());
376        assert_eq!(profile.unwrap().version, sample::Version::V1);
377    }
378
379    #[test]
380    fn test_minimal_profile_without_version() {
381        let data = r#"{"platform":"android","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
382        let profile = minimal_profile_from_json(data.as_bytes());
383        assert!(profile.is_ok());
384        assert_eq!(profile.unwrap().version, sample::Version::Unknown);
385    }
386
387    #[test]
388    fn test_expand_profile_with_version() {
389        let payload = include_bytes!("../tests/fixtures/sample/v1/valid.json");
390        assert!(
391            expand_profile(
392                payload,
393                &Event::default(),
394                None,
395                &ProjectFiltersConfig::default(),
396                &GlobalConfig::default()
397            )
398            .is_ok()
399        );
400    }
401
402    #[test]
403    fn test_expand_profile_with_version_and_segment_id() {
404        let payload = include_bytes!("../tests/fixtures/sample/v1/segment_id.json");
405        assert!(
406            expand_profile(
407                payload,
408                &Event::default(),
409                None,
410                &ProjectFiltersConfig::default(),
411                &GlobalConfig::default()
412            )
413            .is_ok()
414        );
415    }
416
417    #[test]
418    fn test_expand_profile_without_version() {
419        let payload = include_bytes!("../tests/fixtures/android/legacy/roundtrip.json");
420        assert!(
421            expand_profile(
422                payload,
423                &Event::default(),
424                None,
425                &ProjectFiltersConfig::default(),
426                &GlobalConfig::default()
427            )
428            .is_ok()
429        );
430    }
431}