relay_profiling/sample/
v1.rs

1//! Sample Format V1
2//!
3//! This is a way to send samples collected at regular intervals from our SDKs. We are expecting
4//! one profile per transaction at most and as such, we collect transaction metadata and add it to
5//! the profile in Relay.
6//!
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::ops::Range;
9
10use chrono::{DateTime, Utc};
11use itertools::Itertools;
12use relay_event_schema::protocol::EventId;
13use serde::{Deserialize, Serialize};
14
15use crate::MAX_PROFILE_DURATION;
16use crate::error::ProfileError;
17use crate::measurements::LegacyMeasurement;
18use crate::sample::{DebugMeta, Frame, ThreadMetadata, Version};
19use crate::transaction_metadata::TransactionMetadata;
20use crate::types::ClientSdk;
21use crate::utils::{default_client_sdk, deserialize_number_from_string, string_is_null_or_empty};
22
23const MAX_PROFILE_DURATION_NS: u64 = MAX_PROFILE_DURATION.as_nanos() as u64;
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
26struct Sample {
27    stack_id: usize,
28    #[serde(deserialize_with = "deserialize_number_from_string")]
29    thread_id: u64,
30    #[serde(deserialize_with = "deserialize_number_from_string")]
31    elapsed_since_start_ns: u64,
32
33    // cocoa only
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    queue_address: Option<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39struct QueueMetadata {
40    label: String,
41}
42
43#[derive(Debug, Serialize, Deserialize, Clone)]
44pub struct SampleProfile {
45    samples: Vec<Sample>,
46    stacks: Vec<Vec<usize>>,
47    frames: Vec<Frame>,
48
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    thread_metadata: Option<HashMap<String, ThreadMetadata>>,
51
52    // cocoa only
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    queue_metadata: Option<HashMap<String, QueueMetadata>>,
55}
56
57impl SampleProfile {
58    /// Ensures valid profiler or returns an error.
59    ///
60    /// Mutates the profile. Removes invalid samples and threads.
61    /// Throws an error if the profile is malformed.
62    /// Removes extra metadata that are not referenced in the samples.
63    ///
64    /// profile.normalize("cocoa", "arm64e")
65    pub fn normalize(&mut self, platform: &str, architecture: &str) -> Result<(), ProfileError> {
66        // Clean samples before running the checks.
67        self.remove_idle_samples_at_the_edge();
68        self.remove_single_samples_per_thread();
69
70        if self.samples.is_empty() {
71            return Err(ProfileError::NotEnoughSamples);
72        }
73
74        if !self.all_stacks_referenced_by_samples_exist() {
75            return Err(ProfileError::MalformedSamples);
76        }
77
78        if !self.all_frames_referenced_by_stacks_exist() {
79            return Err(ProfileError::MalformedStacks);
80        }
81
82        if self.is_above_max_duration() {
83            return Err(ProfileError::DurationIsTooLong);
84        }
85
86        self.strip_pointer_authentication_code(platform, architecture);
87        self.remove_unreferenced_threads();
88        self.remove_unreferenced_queues();
89
90        Ok(())
91    }
92
93    fn strip_pointer_authentication_code(&mut self, platform: &str, architecture: &str) {
94        let addr = match (platform, architecture) {
95            // https://github.com/microsoft/plcrashreporter/blob/748087386cfc517936315c107f722b146b0ad1ab/Source/PLCrashAsyncThread_arm.c#L84
96            ("cocoa", "arm64") | ("cocoa", "arm64e") => 0x0000000FFFFFFFFF,
97            _ => return,
98        };
99        for frame in &mut self.frames {
100            frame.strip_pointer_authentication_code(addr);
101        }
102    }
103
104    fn remove_idle_samples_at_the_edge(&mut self) {
105        let mut active_ranges: HashMap<u64, Range<usize>> = HashMap::new();
106
107        for (i, sample) in self.samples.iter().enumerate() {
108            if self
109                .stacks
110                .get(sample.stack_id)
111                .is_none_or(|stack| stack.is_empty())
112            {
113                continue;
114            }
115
116            active_ranges
117                .entry(sample.thread_id)
118                .and_modify(|range| range.end = i + 1)
119                .or_insert(i..i + 1);
120        }
121
122        self.samples = self
123            .samples
124            .drain(..)
125            .enumerate()
126            .filter(|(i, sample)| {
127                active_ranges
128                    .get(&sample.thread_id)
129                    .is_some_and(|range| range.contains(i))
130            })
131            .map(|(_, sample)| sample)
132            .collect();
133    }
134
135    /// Removes a sample when it's the only sample on its thread
136    fn remove_single_samples_per_thread(&mut self) {
137        let sample_count_by_thread_id = &self
138            .samples
139            .iter()
140            .counts_by(|sample| sample.thread_id)
141            // Only keep data from threads with more than 1 sample so we can calculate a duration
142            .into_iter()
143            .filter(|(_, count)| *count > 1)
144            .collect::<HashMap<_, _>>();
145
146        self.samples
147            .retain(|sample| sample_count_by_thread_id.contains_key(&sample.thread_id));
148    }
149
150    /// Checks that all stacks referenced by the samples exist in the stacks.
151    fn all_stacks_referenced_by_samples_exist(&self) -> bool {
152        self.samples
153            .iter()
154            .all(|sample| self.stacks.get(sample.stack_id).is_some())
155    }
156
157    /// Checks that all frames referenced by the stacks exist in the frames.
158    fn all_frames_referenced_by_stacks_exist(&self) -> bool {
159        self.stacks.iter().all(|stack| {
160            stack
161                .iter()
162                .all(|frame_id| self.frames.get(*frame_id).is_some())
163        })
164    }
165
166    /// Checks if the last sample was recorded within the max profile duration.
167    fn is_above_max_duration(&self) -> bool {
168        self.samples
169            .last()
170            .is_some_and(|sample| sample.elapsed_since_start_ns > MAX_PROFILE_DURATION_NS)
171    }
172
173    fn remove_unreferenced_threads(&mut self) {
174        if let Some(thread_metadata) = &mut self.thread_metadata {
175            let thread_ids = self
176                .samples
177                .iter()
178                .map(|sample| sample.thread_id.to_string())
179                .collect::<HashSet<_>>();
180            thread_metadata.retain(|thread_id, _| thread_ids.contains(thread_id));
181        }
182    }
183
184    fn remove_unreferenced_queues(&mut self) {
185        if let Some(queue_metadata) = &mut self.queue_metadata {
186            let queue_addresses = self
187                .samples
188                .iter()
189                .filter_map(|sample| sample.queue_address.as_ref())
190                .collect::<HashSet<_>>();
191            queue_metadata.retain(|queue_address, _| queue_addresses.contains(&queue_address));
192        }
193    }
194}
195
196#[derive(Debug, Serialize, Deserialize, Clone)]
197struct OSMetadata {
198    name: String,
199    version: String,
200
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    build_number: Option<String>,
203}
204
205#[derive(Debug, Serialize, Deserialize, Clone)]
206struct RuntimeMetadata {
207    name: String,
208    version: String,
209}
210
211#[derive(Debug, Serialize, Deserialize, Clone)]
212struct DeviceMetadata {
213    architecture: String,
214
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    is_emulator: Option<bool>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    locale: Option<String>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    manufacturer: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    model: Option<String>,
223}
224
225#[derive(Debug, Serialize, Deserialize, Clone)]
226pub struct ProfileMetadata {
227    version: Version,
228
229    #[serde(skip_serializing_if = "Option::is_none")]
230    debug_meta: Option<DebugMeta>,
231
232    device: DeviceMetadata,
233    os: OSMetadata,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    runtime: Option<RuntimeMetadata>,
236
237    #[serde(default, skip_serializing_if = "String::is_empty")]
238    environment: String,
239    #[serde(alias = "profile_id")]
240    event_id: EventId,
241    platform: String,
242    timestamp: DateTime<Utc>,
243
244    #[serde(default, skip_serializing_if = "string_is_null_or_empty")]
245    release: Option<String>,
246    #[serde(default, skip_serializing_if = "String::is_empty")]
247    dist: String,
248
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    transactions: Vec<TransactionMetadata>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    transaction: Option<TransactionMetadata>,
253
254    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255    transaction_metadata: BTreeMap<String, String>,
256
257    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
258    transaction_tags: BTreeMap<String, String>,
259
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub client_sdk: Option<ClientSdk>,
262}
263
264#[derive(Debug, Serialize, Deserialize, Clone)]
265pub struct ProfilingEvent {
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    measurements: Option<HashMap<String, LegacyMeasurement>>,
268    #[serde(flatten)]
269    metadata: ProfileMetadata,
270    profile: SampleProfile,
271}
272
273impl ProfilingEvent {
274    fn valid(&self) -> bool {
275        match self.metadata.platform.as_str() {
276            "cocoa" => {
277                self.metadata.os.build_number.is_some()
278                    && self.metadata.device.is_emulator.is_some()
279                    && self.metadata.device.locale.is_some()
280                    && self.metadata.device.manufacturer.is_some()
281                    && self.metadata.device.model.is_some()
282            }
283            _ => true,
284        }
285    }
286}
287
288fn parse_profile(payload: &[u8]) -> Result<ProfilingEvent, ProfileError> {
289    let d = &mut serde_json::Deserializer::from_slice(payload);
290    let mut profile: ProfilingEvent =
291        serde_path_to_error::deserialize(d).map_err(ProfileError::InvalidJson)?;
292
293    if !profile.valid() {
294        return Err(ProfileError::MissingProfileMetadata);
295    }
296
297    if profile.metadata.transaction.is_none() {
298        profile.metadata.transaction = profile.metadata.transactions.drain(..).next();
299    }
300
301    let transaction = profile
302        .metadata
303        .transaction
304        .as_ref()
305        .ok_or(ProfileError::NoTransactionAssociated)?;
306
307    if !transaction.valid() {
308        return Err(ProfileError::InvalidTransactionMetadata);
309    }
310
311    // This is to be compatible with older SDKs
312    if transaction.relative_end_ns > 0 {
313        profile.profile.samples.retain(|sample| {
314            (transaction.relative_start_ns..=transaction.relative_end_ns)
315                .contains(&sample.elapsed_since_start_ns)
316        });
317    }
318
319    profile.profile.normalize(
320        profile.metadata.platform.as_str(),
321        profile.metadata.device.architecture.as_str(),
322    )?;
323
324    Ok(profile)
325}
326
327pub fn parse_sample_profile(
328    payload: &[u8],
329    transaction_metadata: BTreeMap<String, String>,
330    transaction_tags: BTreeMap<String, String>,
331) -> Result<Vec<u8>, ProfileError> {
332    let mut profile = parse_profile(payload)?;
333
334    if let Some(transaction_name) = transaction_metadata.get("transaction") {
335        if let Some(ref mut transaction) = profile.metadata.transaction {
336            transaction_name.clone_into(&mut transaction.name)
337        }
338    }
339
340    // Do not replace the release if we're passing one already.
341    if profile.metadata.release.is_none() {
342        if let Some(release) = transaction_metadata.get("release") {
343            profile.metadata.release = Some(release.to_owned());
344        }
345    }
346
347    if let Some(dist) = transaction_metadata.get("dist") {
348        dist.clone_into(&mut profile.metadata.dist);
349    }
350
351    if let Some(environment) = transaction_metadata.get("environment") {
352        environment.clone_into(&mut profile.metadata.environment);
353    }
354
355    if let Some(segment_id) = transaction_metadata
356        .get("segment_id")
357        .and_then(|segment_id| segment_id.parse().ok())
358    {
359        if let Some(transaction_metadata) = profile.metadata.transaction.as_mut() {
360            transaction_metadata.segment_id = Some(segment_id);
361        }
362    }
363
364    profile.metadata.client_sdk = match (
365        transaction_metadata.get("client_sdk.name"),
366        transaction_metadata.get("client_sdk.version"),
367    ) {
368        (Some(name), Some(version)) => Some(ClientSdk {
369            name: name.to_owned(),
370            version: version.to_owned(),
371        }),
372        _ => default_client_sdk(profile.metadata.platform.as_str()),
373    };
374    profile.metadata.transaction_metadata = transaction_metadata;
375    profile.metadata.transaction_tags = transaction_tags;
376
377    serde_json::to_vec(&profile).map_err(|_| ProfileError::CannotSerializePayload)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use std::time::Duration;
384
385    #[test]
386    fn test_roundtrip() {
387        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
388        let profile = parse_profile(payload);
389        assert!(profile.is_ok());
390        let data = serde_json::to_vec(&profile.unwrap());
391        assert!(parse_profile(&data.unwrap()[..]).is_ok());
392    }
393
394    #[test]
395    fn test_expand() {
396        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
397        let profile = parse_sample_profile(payload, BTreeMap::new(), BTreeMap::new());
398        assert!(profile.is_ok());
399    }
400
401    fn generate_profile() -> ProfilingEvent {
402        ProfilingEvent {
403            measurements: None,
404            metadata: ProfileMetadata {
405                debug_meta: Option::None,
406                version: Version::V1,
407                timestamp: Utc::now(),
408                runtime: Option::None,
409                device: DeviceMetadata {
410                    architecture: "arm64e".to_owned(),
411                    is_emulator: Some(true),
412                    locale: Some("en_US".to_owned()),
413                    manufacturer: Some("Apple".to_owned()),
414                    model: Some("iPhome11,3".to_owned()),
415                },
416                os: OSMetadata {
417                    build_number: Some("H3110".to_owned()),
418                    name: "iOS".to_owned(),
419                    version: "16.0".to_owned(),
420                },
421                environment: "testing".to_owned(),
422                platform: "cocoa".to_owned(),
423                event_id: EventId::new(),
424                transaction: Option::None,
425                transactions: Vec::new(),
426                release: Some("1.0".to_owned()),
427                dist: "9999".to_owned(),
428                transaction_metadata: BTreeMap::new(),
429                transaction_tags: BTreeMap::new(),
430                client_sdk: None,
431            },
432            profile: SampleProfile {
433                queue_metadata: Some(HashMap::new()),
434                samples: Vec::new(),
435                stacks: Vec::new(),
436                frames: Vec::new(),
437                thread_metadata: Some(HashMap::new()),
438            },
439        }
440    }
441
442    #[test]
443    fn test_filter_samples() {
444        let mut profile = generate_profile();
445
446        profile.profile.stacks.push(Vec::new());
447        profile.profile.samples.extend(vec![
448            Sample {
449                stack_id: 0,
450                queue_address: Some("0xdeadbeef".to_owned()),
451                elapsed_since_start_ns: 1,
452                thread_id: 1,
453            },
454            Sample {
455                stack_id: 0,
456                queue_address: Some("0xdeadbeef".to_owned()),
457                elapsed_since_start_ns: 1,
458                thread_id: 1,
459            },
460            Sample {
461                stack_id: 0,
462                queue_address: Some("0xdeadbeef".to_owned()),
463                elapsed_since_start_ns: 1,
464                thread_id: 2,
465            },
466            Sample {
467                stack_id: 0,
468                queue_address: Some("0xdeadbeef".to_owned()),
469                elapsed_since_start_ns: 1,
470                thread_id: 3,
471            },
472        ]);
473
474        profile.profile.remove_single_samples_per_thread();
475
476        assert!(profile.profile.samples.len() == 2);
477    }
478
479    #[test]
480    fn test_parse_profile_with_all_samples_filtered() {
481        let mut profile = generate_profile();
482
483        profile.profile.stacks.push(Vec::new());
484        profile.profile.samples.extend(vec![
485            Sample {
486                stack_id: 0,
487                queue_address: Some("0xdeadbeef".to_owned()),
488                elapsed_since_start_ns: 1,
489                thread_id: 1,
490            },
491            Sample {
492                stack_id: 0,
493                queue_address: Some("0xdeadbeef".to_owned()),
494                elapsed_since_start_ns: 1,
495                thread_id: 2,
496            },
497            Sample {
498                stack_id: 0,
499                queue_address: Some("0xdeadbeef".to_owned()),
500                elapsed_since_start_ns: 1,
501                thread_id: 3,
502            },
503            Sample {
504                stack_id: 0,
505                queue_address: Some("0xdeadbeef".to_owned()),
506                elapsed_since_start_ns: 1,
507                thread_id: 4,
508            },
509        ]);
510
511        let payload = serde_json::to_vec(&profile).unwrap();
512        assert!(parse_profile(&payload[..]).is_err());
513    }
514
515    #[test]
516    fn test_expand_with_samples_inclusive() {
517        let mut profile = generate_profile();
518
519        profile.profile.frames.push(Frame {
520            ..Default::default()
521        });
522        profile.metadata.transaction = Some(TransactionMetadata {
523            active_thread_id: 1,
524            id: EventId::new(),
525            name: "blah".to_owned(),
526            relative_cpu_end_ms: 0,
527            relative_cpu_start_ms: 0,
528            relative_end_ns: 30,
529            relative_start_ns: 10,
530            trace_id: EventId::new(),
531            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
532        });
533        profile.profile.stacks.push(vec![0]);
534        profile.profile.samples.extend([
535            Sample {
536                stack_id: 0,
537                queue_address: Some("0xdeadbeef".to_owned()),
538                elapsed_since_start_ns: 10,
539                thread_id: 1,
540            },
541            Sample {
542                stack_id: 0,
543                queue_address: Some("0xdeadbeef".to_owned()),
544                elapsed_since_start_ns: 20,
545                thread_id: 1,
546            },
547            Sample {
548                stack_id: 0,
549                queue_address: Some("0xdeadbeef".to_owned()),
550                elapsed_since_start_ns: 30,
551                thread_id: 1,
552            },
553            Sample {
554                stack_id: 0,
555                queue_address: Some("0xdeadbeef".to_owned()),
556                elapsed_since_start_ns: 40,
557                thread_id: 1,
558            },
559        ]);
560
561        let payload = serde_json::to_vec(&profile).unwrap();
562        let profile = parse_profile(&payload[..]).unwrap();
563
564        assert_eq!(profile.profile.samples.len(), 3);
565    }
566
567    #[test]
568    fn test_expand_with_all_samples_outside_transaction() {
569        let mut profile = generate_profile();
570
571        profile.profile.frames.push(Frame {
572            ..Default::default()
573        });
574        profile.metadata.transaction = Some(TransactionMetadata {
575            active_thread_id: 1,
576            id: EventId::new(),
577            name: "blah".to_owned(),
578            relative_cpu_end_ms: 0,
579            relative_cpu_start_ms: 0,
580            relative_end_ns: 100,
581            relative_start_ns: 50,
582            trace_id: EventId::new(),
583            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
584        });
585        profile.profile.stacks.push(vec![0]);
586        profile.profile.samples.extend(vec![
587            Sample {
588                stack_id: 0,
589                queue_address: Some("0xdeadbeef".to_owned()),
590                elapsed_since_start_ns: 10,
591                thread_id: 1,
592            },
593            Sample {
594                stack_id: 0,
595                queue_address: Some("0xdeadbeef".to_owned()),
596                elapsed_since_start_ns: 20,
597                thread_id: 1,
598            },
599            Sample {
600                stack_id: 0,
601                queue_address: Some("0xdeadbeef".to_owned()),
602                elapsed_since_start_ns: 30,
603                thread_id: 1,
604            },
605            Sample {
606                stack_id: 0,
607                queue_address: Some("0xdeadbeef".to_owned()),
608                elapsed_since_start_ns: 40,
609                thread_id: 1,
610            },
611        ]);
612
613        let payload = serde_json::to_vec(&profile).unwrap();
614        let data = parse_sample_profile(&payload[..], BTreeMap::new(), BTreeMap::new());
615
616        assert!(data.is_err());
617    }
618
619    #[test]
620    fn test_copying_transaction() {
621        let mut profile = generate_profile();
622        let transaction = TransactionMetadata {
623            active_thread_id: 1,
624            id: EventId::new(),
625            name: "blah".to_owned(),
626            relative_cpu_end_ms: 0,
627            relative_cpu_start_ms: 0,
628            relative_end_ns: 100,
629            relative_start_ns: 0,
630            trace_id: EventId::new(),
631            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
632        };
633
634        profile.metadata.transactions.push(transaction.clone());
635        profile.profile.frames.push(Frame {
636            ..Default::default()
637        });
638        profile.profile.stacks.push(vec![0]);
639        profile.profile.samples.extend(vec![
640            Sample {
641                stack_id: 0,
642                queue_address: Some("0xdeadbeef".to_owned()),
643                elapsed_since_start_ns: 10,
644                thread_id: 1,
645            },
646            Sample {
647                stack_id: 0,
648                queue_address: Some("0xdeadbeef".to_owned()),
649                elapsed_since_start_ns: 20,
650                thread_id: 1,
651            },
652            Sample {
653                stack_id: 0,
654                queue_address: Some("0xdeadbeef".to_owned()),
655                elapsed_since_start_ns: 30,
656                thread_id: 1,
657            },
658            Sample {
659                stack_id: 0,
660                queue_address: Some("0xdeadbeef".to_owned()),
661                elapsed_since_start_ns: 40,
662                thread_id: 1,
663            },
664        ]);
665
666        let payload = serde_json::to_vec(&profile).unwrap();
667        let profile = parse_profile(&payload[..]).unwrap();
668
669        assert_eq!(Some(transaction), profile.metadata.transaction);
670        assert!(profile.metadata.transactions.is_empty());
671    }
672
673    #[test]
674    fn test_parse_with_no_transaction() {
675        let profile = generate_profile();
676        let payload = serde_json::to_vec(&profile).unwrap();
677        assert!(parse_profile(&payload[..]).is_err());
678    }
679
680    #[test]
681    fn test_profile_remove_idle_samples_at_start_and_end() {
682        let mut profile = generate_profile();
683        let transaction = TransactionMetadata {
684            active_thread_id: 1,
685            id: EventId::new(),
686            name: "blah".to_owned(),
687            relative_cpu_end_ms: 0,
688            relative_cpu_start_ms: 0,
689            relative_end_ns: 100,
690            relative_start_ns: 0,
691            trace_id: EventId::new(),
692            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
693        };
694
695        profile.metadata.transaction = Some(transaction);
696        profile.profile.frames.push(Frame {
697            ..Default::default()
698        });
699        profile.profile.stacks = vec![vec![0], vec![]];
700        profile.profile.samples = vec![
701            Sample {
702                stack_id: 0,
703                queue_address: Some("0xdeadbeef".to_owned()),
704                elapsed_since_start_ns: 40,
705                thread_id: 2,
706            },
707            Sample {
708                stack_id: 1,
709                queue_address: Some("0xdeadbeef".to_owned()),
710                elapsed_since_start_ns: 50,
711                thread_id: 2,
712            },
713            Sample {
714                stack_id: 1,
715                queue_address: Some("0xdeadbeef".to_owned()),
716                elapsed_since_start_ns: 10,
717                thread_id: 1,
718            },
719            Sample {
720                stack_id: 1,
721                queue_address: Some("0xdeadbeef".to_owned()),
722                elapsed_since_start_ns: 20,
723                thread_id: 1,
724            },
725            Sample {
726                stack_id: 1,
727                queue_address: Some("0xdeadbeef".to_owned()),
728                elapsed_since_start_ns: 30,
729                thread_id: 1,
730            },
731            Sample {
732                stack_id: 0,
733                queue_address: Some("0xdeadbeef".to_owned()),
734                elapsed_since_start_ns: 40,
735                thread_id: 1,
736            },
737            Sample {
738                stack_id: 1,
739                queue_address: Some("0xdeadbeef".to_owned()),
740                elapsed_since_start_ns: 50,
741                thread_id: 1,
742            },
743            Sample {
744                stack_id: 0,
745                queue_address: Some("0xdeadbeef".to_owned()),
746                elapsed_since_start_ns: 60,
747                thread_id: 1,
748            },
749            Sample {
750                stack_id: 1,
751                queue_address: Some("0xdeadbeef".to_owned()),
752                elapsed_since_start_ns: 70,
753                thread_id: 1,
754            },
755            Sample {
756                stack_id: 1,
757                queue_address: Some("0xdeadbeef".to_owned()),
758                elapsed_since_start_ns: 90,
759                thread_id: 1,
760            },
761            Sample {
762                stack_id: 1,
763                queue_address: Some("0xdeadbeef".to_owned()),
764                elapsed_since_start_ns: 80,
765                thread_id: 3,
766            },
767            Sample {
768                stack_id: 1,
769                queue_address: Some("0xdeadbeef".to_owned()),
770                elapsed_since_start_ns: 90,
771                thread_id: 3,
772            },
773            Sample {
774                stack_id: 0,
775                queue_address: Some("0xdeadbeef".to_owned()),
776                elapsed_since_start_ns: 60,
777                thread_id: 2,
778            },
779        ];
780
781        profile.profile.remove_idle_samples_at_the_edge();
782
783        let mut sample_count_by_thread_id: HashMap<u64, u32> = HashMap::new();
784
785        for sample in &profile.profile.samples {
786            *sample_count_by_thread_id
787                .entry(sample.thread_id)
788                .or_default() += 1;
789        }
790
791        assert_eq!(sample_count_by_thread_id, HashMap::from([(1, 3), (2, 3),]));
792    }
793
794    #[test]
795    fn test_profile_cleanup_metadata() {
796        let mut profile = generate_profile();
797        let transaction = TransactionMetadata {
798            active_thread_id: 1,
799            id: EventId::new(),
800            name: "blah".to_owned(),
801            relative_cpu_end_ms: 0,
802            relative_cpu_start_ms: 0,
803            relative_end_ns: 100,
804            relative_start_ns: 0,
805            trace_id: EventId::new(),
806            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
807        };
808
809        profile.metadata.transaction = Some(transaction);
810        profile.profile.frames.push(Frame {
811            ..Default::default()
812        });
813        profile.profile.stacks = vec![vec![0]];
814
815        let mut thread_metadata: HashMap<String, ThreadMetadata> = HashMap::new();
816
817        thread_metadata.insert(
818            "1".to_owned(),
819            ThreadMetadata {
820                name: Some("".to_owned()),
821                priority: Some(1),
822            },
823        );
824        thread_metadata.insert(
825            "2".to_owned(),
826            ThreadMetadata {
827                name: Some("".to_owned()),
828                priority: Some(1),
829            },
830        );
831        thread_metadata.insert(
832            "3".to_owned(),
833            ThreadMetadata {
834                name: Some("".to_owned()),
835                priority: Some(1),
836            },
837        );
838        thread_metadata.insert(
839            "4".to_owned(),
840            ThreadMetadata {
841                name: Some("".to_owned()),
842                priority: Some(1),
843            },
844        );
845
846        let mut queue_metadata: HashMap<String, QueueMetadata> = HashMap::new();
847
848        queue_metadata.insert(
849            "0xdeadbeef".to_owned(),
850            QueueMetadata {
851                label: "com.apple.main-thread".to_owned(),
852            },
853        );
854
855        queue_metadata.insert(
856            "0x123456789".to_owned(),
857            QueueMetadata {
858                label: "some-label".to_owned(),
859            },
860        );
861
862        profile.profile.thread_metadata = Some(thread_metadata);
863        profile.profile.queue_metadata = Some(queue_metadata);
864        profile.profile.samples.extend(vec![
865            Sample {
866                stack_id: 0,
867                queue_address: Some("0xdeadbeef".to_owned()),
868                elapsed_since_start_ns: 10,
869                thread_id: 1,
870            },
871            Sample {
872                stack_id: 0,
873                queue_address: Some("0xdeadbeef".to_owned()),
874                elapsed_since_start_ns: 20,
875                thread_id: 2,
876            },
877        ]);
878
879        profile.profile.remove_unreferenced_threads();
880        profile.profile.remove_unreferenced_queues();
881
882        assert_eq!(profile.profile.thread_metadata.unwrap().len(), 2);
883        assert_eq!(profile.profile.queue_metadata.unwrap().len(), 1);
884    }
885
886    #[test]
887    fn test_extract_transaction_tags() {
888        let transaction_metadata = BTreeMap::from([(
889            "transaction".to_owned(),
890            "some-random-transaction".to_owned(),
891        )]);
892
893        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
894        let profile_json = parse_sample_profile(payload, transaction_metadata, BTreeMap::new());
895        assert!(profile_json.is_ok());
896
897        let payload = profile_json.unwrap();
898        let d = &mut serde_json::Deserializer::from_slice(&payload[..]);
899        let output: ProfilingEvent = serde_path_to_error::deserialize(d)
900            .map_err(ProfileError::InvalidJson)
901            .unwrap();
902        assert_eq!(
903            output.metadata.transaction.unwrap().name,
904            "some-random-transaction".to_owned()
905        );
906    }
907
908    #[test]
909    fn test_keep_profile_under_max_duration() {
910        let mut profile = generate_profile();
911        profile.profile.samples.extend(vec![
912            Sample {
913                stack_id: 0,
914                queue_address: Some("0xdeadbeef".to_owned()),
915                elapsed_since_start_ns: 10,
916                thread_id: 1,
917            },
918            Sample {
919                stack_id: 0,
920                queue_address: Some("0xdeadbeef".to_owned()),
921                elapsed_since_start_ns: (MAX_PROFILE_DURATION - Duration::from_secs(1)).as_nanos()
922                    as u64,
923                thread_id: 2,
924            },
925        ]);
926
927        assert!(!profile.profile.is_above_max_duration());
928    }
929
930    #[test]
931    fn test_reject_profile_over_max_duration() {
932        let mut profile = generate_profile();
933        profile.profile.samples.extend(vec![
934            Sample {
935                stack_id: 0,
936                queue_address: Some("0xdeadbeef".to_owned()),
937                elapsed_since_start_ns: 10,
938                thread_id: 1,
939            },
940            Sample {
941                stack_id: 0,
942                queue_address: Some("0xdeadbeef".to_owned()),
943                elapsed_since_start_ns: (MAX_PROFILE_DURATION + Duration::from_secs(1)).as_nanos()
944                    as u64,
945                thread_id: 2,
946            },
947        ]);
948
949        assert!(profile.profile.is_above_max_duration());
950    }
951
952    #[test]
953    fn test_accept_null_or_empty_release() {
954        let payload = r#"{
955            "version":"1",
956            "device":{
957                "architecture":"arm64e",
958                "is_emulator":true,
959                "locale":"en_US",
960                "manufacturer":"Apple",
961                "model":"iPhome11,3"
962            },
963            "os":{
964                "name":"iOS",
965                "version":"16.0",
966                "build_number":"H3110"
967            },
968            "environment":"testing",
969            "event_id":"961d6b96017644db895eafd391682003",
970            "platform":"cocoa",
971            "release":null,
972            "timestamp":"2023-11-01T15:27:15.081230Z",
973            "transaction":{
974                "active_thread_id": 1,
975                "id":"9789498b-6970-4dda-b2a1-f9cb91d1a445",
976                "name":"blah",
977                "trace_id":"809ff2c0-e185-4c21-8f21-6a6fef009352",
978                "segment_id":"bd2eb23da2beb459"
979            },
980            "dist":"9999",
981            "profile":{
982                "samples":[
983                    {
984                        "stack_id":0,
985                        "elapsed_since_start_ns":1,
986                        "thread_id":1
987                    },
988                    {
989                        "stack_id":0,
990                        "elapsed_since_start_ns":2,
991                        "thread_id":1
992                    }
993                ],
994                "stacks":[[0]],
995                "frames":[{
996                    "function":"main"
997                }],
998                "thread_metadata":{},
999                "queue_metadata":{}
1000            }
1001        }"#;
1002        let profile = parse_profile(payload.as_bytes());
1003        assert!(profile.is_ok());
1004        assert_eq!(profile.unwrap().metadata.release, None);
1005    }
1006}