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