Skip to main content

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