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 kind: ProfileType,
146}
147
148pub fn parse_metadata(payload: &[u8]) -> Result<ProfileMetadata, ProfileError> {
149    let profile = match minimal_profile_from_json(payload) {
150        Ok(profile) => profile,
151        Err(err) => {
152            relay_log::debug!(error = &err as &dyn Error, from = "minimal",);
153            return Err(ProfileError::InvalidJson(err));
154        }
155    };
156    match profile.version {
157        sample::Version::V1 => {
158            let d = &mut Deserializer::from_slice(payload);
159            let _: sample::v1::ProfileMetadata = match serde_path_to_error::deserialize(d) {
160                Ok(profile) => profile,
161                Err(err) => {
162                    relay_log::debug!(
163                        error = &err as &dyn Error,
164                        from = "metadata",
165                        platform = profile.platform,
166                        "invalid profile",
167                    );
168                    return Err(ProfileError::InvalidJson(err));
169                }
170            };
171        }
172        _ => match profile.platform.as_str() {
173            "android" => {
174                let d = &mut Deserializer::from_slice(payload);
175                let _: android::legacy::ProfileMetadata = match serde_path_to_error::deserialize(d)
176                {
177                    Ok(profile) => profile,
178                    Err(err) => {
179                        relay_log::debug!(
180                            error = &err as &dyn Error,
181                            from = "metadata",
182                            platform = "android",
183                            "invalid profile",
184                        );
185                        return Err(ProfileError::InvalidJson(err));
186                    }
187                };
188            }
189            _ => return Err(ProfileError::PlatformNotSupported),
190        },
191    };
192
193    Ok(ProfileMetadata {
194        id: profile.event_id,
195        kind: ProfileType::from_platform(&profile.platform),
196    })
197}
198
199pub fn expand_profile(
200    payload: &[u8],
201    event: &Event,
202    client_ip: Option<IpAddr>,
203    filter_settings: &ProjectFiltersConfig,
204    global_config: &GlobalConfig,
205) -> Result<(ProfileId, Vec<u8>), ProfileError> {
206    let profile = match minimal_profile_from_json(payload) {
207        Ok(profile) => profile,
208        Err(err) => {
209            relay_log::debug!(
210                error = &err as &dyn Error,
211                from = "minimal",
212                platform = event.platform.as_str(),
213                project_id = event.project.value().unwrap_or(&0),
214                sdk_name = event.sdk_name(),
215                sdk_version = event.sdk_version(),
216                transaction_id = ?event.id.value(),
217                "invalid profile",
218            );
219            return Err(ProfileError::InvalidJson(err));
220        }
221    };
222
223    if let Err(filter_stat_key) = relay_filter::should_filter(
224        &profile,
225        client_ip,
226        filter_settings,
227        global_config.filters(),
228    ) {
229        return Err(ProfileError::Filtered(filter_stat_key));
230    }
231
232    let transaction_metadata = extract_transaction_metadata(event);
233    let transaction_tags = extract_transaction_tags(event);
234    let processed_payload = match (profile.platform.as_str(), profile.version) {
235        (_, sample::Version::V1) => {
236            sample::v1::parse_sample_profile(payload, transaction_metadata, transaction_tags)
237        }
238        ("android", _) => {
239            android::legacy::parse_android_profile(payload, transaction_metadata, transaction_tags)
240        }
241        (_, _) => return Err(ProfileError::PlatformNotSupported),
242    };
243    match processed_payload {
244        Ok(payload) => Ok((profile.event_id, payload)),
245        Err(err) => match err {
246            ProfileError::InvalidJson(err) => {
247                relay_log::debug!(
248                    error = &err as &dyn Error,
249                    from = "parsing",
250                    platform = profile.platform,
251                    project_id = event.project.value().unwrap_or(&0),
252                    sdk_name = event.sdk_name(),
253                    sdk_version = event.sdk_version(),
254                    transaction_id = ?event.id.value(),
255                    "invalid profile",
256                );
257                Err(ProfileError::InvalidJson(err))
258            }
259            _ => {
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(err)
271            }
272        },
273    }
274}
275
276/// Intermediate type for all processing on a profile chunk.
277pub struct ProfileChunk {
278    profile: MinimalProfile,
279    payload: Bytes,
280}
281
282impl ProfileChunk {
283    /// Parses a new [`Self`] from raw bytes.
284    pub fn new(payload: Bytes) -> Result<Self, ProfileError> {
285        match minimal_profile_from_json(&payload) {
286            Ok(profile) => Ok(Self { profile, payload }),
287            Err(err) => {
288                relay_log::debug!(
289                    error = &err as &dyn Error,
290                    from = "minimal",
291                    "invalid profile chunk",
292                );
293                Err(ProfileError::InvalidJson(err))
294            }
295        }
296    }
297
298    /// Returns the [`ProfileType`] this chunk belongs to.
299    ///
300    /// This is currently determined from the platform via [`ProfileType::from_platform`].
301    pub fn profile_type(&self) -> ProfileType {
302        ProfileType::from_platform(&self.profile.platform)
303    }
304
305    /// Applies inbound filters to the profile chunk.
306    ///
307    /// The profile needs to be filtered (rejected) when this returns an error.
308    pub fn filter(
309        &self,
310        client_ip: Option<IpAddr>,
311        filter_settings: &ProjectFiltersConfig,
312        global_config: &GlobalConfig,
313    ) -> Result<(), ProfileError> {
314        relay_filter::should_filter(
315            &self.profile,
316            client_ip,
317            filter_settings,
318            global_config.filters(),
319        )
320        .map_err(ProfileError::Filtered)
321    }
322
323    /// Normalizes and 'expands' the profile chunk into its normalized form Sentry expects.
324    pub fn expand(&self) -> Result<Vec<u8>, ProfileError> {
325        match (self.profile.platform.as_str(), self.profile.version) {
326            ("android", _) => android::chunk::parse(&self.payload),
327            (_, sample::Version::V2) => {
328                let mut profile = sample::v2::parse(&self.payload)?;
329                profile.normalize()?;
330                Ok(serde_json::to_vec(&profile)
331                    .map_err(|_| ProfileError::CannotSerializePayload)?)
332            }
333            (_, _) => Err(ProfileError::PlatformNotSupported),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_minimal_profile_with_version() {
344        let data = r#"{"version":"1","platform":"cocoa","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
345        let profile = minimal_profile_from_json(data.as_bytes());
346        assert!(profile.is_ok());
347        assert_eq!(profile.unwrap().version, sample::Version::V1);
348    }
349
350    #[test]
351    fn test_minimal_profile_without_version() {
352        let data = r#"{"platform":"android","event_id":"751fff80-a266-467b-a6f5-eeeef65f4f84"}"#;
353        let profile = minimal_profile_from_json(data.as_bytes());
354        assert!(profile.is_ok());
355        assert_eq!(profile.unwrap().version, sample::Version::Unknown);
356    }
357
358    #[test]
359    fn test_expand_profile_with_version() {
360        let payload = include_bytes!("../tests/fixtures/sample/v1/valid.json");
361        assert!(
362            expand_profile(
363                payload,
364                &Event::default(),
365                None,
366                &ProjectFiltersConfig::default(),
367                &GlobalConfig::default()
368            )
369            .is_ok()
370        );
371    }
372
373    #[test]
374    fn test_expand_profile_with_version_and_segment_id() {
375        let payload = include_bytes!("../tests/fixtures/sample/v1/segment_id.json");
376        assert!(
377            expand_profile(
378                payload,
379                &Event::default(),
380                None,
381                &ProjectFiltersConfig::default(),
382                &GlobalConfig::default()
383            )
384            .is_ok()
385        );
386    }
387
388    #[test]
389    fn test_expand_profile_without_version() {
390        let payload = include_bytes!("../tests/fixtures/android/legacy/roundtrip.json");
391        assert!(
392            expand_profile(
393                payload,
394                &Event::default(),
395                None,
396                &ProjectFiltersConfig::default(),
397                &GlobalConfig::default()
398            )
399            .is_ok()
400        );
401    }
402}