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        && let Some(ref mut transaction) = profile.metadata.transaction
336    {
337        transaction_name.clone_into(&mut transaction.name)
338    }
339
340    // Do not replace the release if we're passing one already.
341    if profile.metadata.release.is_none()
342        && let Some(release) = transaction_metadata.get("release")
343    {
344        profile.metadata.release = Some(release.to_owned());
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        && let Some(transaction_metadata) = profile.metadata.transaction.as_mut()
359    {
360        transaction_metadata.segment_id = Some(segment_id);
361    }
362
363    profile.metadata.client_sdk = match (
364        transaction_metadata.get("client_sdk.name"),
365        transaction_metadata.get("client_sdk.version"),
366    ) {
367        (Some(name), Some(version)) => Some(ClientSdk {
368            name: name.to_owned(),
369            version: version.to_owned(),
370        }),
371        _ => default_client_sdk(profile.metadata.platform.as_str()),
372    };
373    profile.metadata.transaction_metadata = transaction_metadata;
374    profile.metadata.transaction_tags = transaction_tags;
375
376    serde_json::to_vec(&profile).map_err(|_| ProfileError::CannotSerializePayload)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::time::Duration;
383
384    #[test]
385    fn test_roundtrip() {
386        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
387        let profile = parse_profile(payload);
388        assert!(profile.is_ok());
389        let data = serde_json::to_vec(&profile.unwrap());
390        assert!(parse_profile(&data.unwrap()[..]).is_ok());
391    }
392
393    #[test]
394    fn test_expand() {
395        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
396        let profile = parse_sample_profile(payload, BTreeMap::new(), BTreeMap::new());
397        assert!(profile.is_ok());
398    }
399
400    fn generate_profile() -> ProfilingEvent {
401        ProfilingEvent {
402            measurements: None,
403            metadata: ProfileMetadata {
404                debug_meta: Option::None,
405                version: Version::V1,
406                timestamp: Utc::now(),
407                runtime: Option::None,
408                device: DeviceMetadata {
409                    architecture: "arm64e".to_owned(),
410                    is_emulator: Some(true),
411                    locale: Some("en_US".to_owned()),
412                    manufacturer: Some("Apple".to_owned()),
413                    model: Some("iPhome11,3".to_owned()),
414                },
415                os: OSMetadata {
416                    build_number: Some("H3110".to_owned()),
417                    name: "iOS".to_owned(),
418                    version: "16.0".to_owned(),
419                },
420                environment: "testing".to_owned(),
421                platform: "cocoa".to_owned(),
422                event_id: EventId::new(),
423                transaction: Option::None,
424                transactions: Vec::new(),
425                release: Some("1.0".to_owned()),
426                dist: "9999".to_owned(),
427                transaction_metadata: BTreeMap::new(),
428                transaction_tags: BTreeMap::new(),
429                client_sdk: None,
430            },
431            profile: SampleProfile {
432                queue_metadata: Some(HashMap::new()),
433                samples: Vec::new(),
434                stacks: Vec::new(),
435                frames: Vec::new(),
436                thread_metadata: Some(HashMap::new()),
437            },
438        }
439    }
440
441    #[test]
442    fn test_filter_samples() {
443        let mut profile = generate_profile();
444
445        profile.profile.stacks.push(Vec::new());
446        profile.profile.samples.extend(vec![
447            Sample {
448                stack_id: 0,
449                queue_address: Some("0xdeadbeef".to_owned()),
450                elapsed_since_start_ns: 1,
451                thread_id: 1,
452            },
453            Sample {
454                stack_id: 0,
455                queue_address: Some("0xdeadbeef".to_owned()),
456                elapsed_since_start_ns: 1,
457                thread_id: 1,
458            },
459            Sample {
460                stack_id: 0,
461                queue_address: Some("0xdeadbeef".to_owned()),
462                elapsed_since_start_ns: 1,
463                thread_id: 2,
464            },
465            Sample {
466                stack_id: 0,
467                queue_address: Some("0xdeadbeef".to_owned()),
468                elapsed_since_start_ns: 1,
469                thread_id: 3,
470            },
471        ]);
472
473        profile.profile.remove_single_samples_per_thread();
474
475        assert!(profile.profile.samples.len() == 2);
476    }
477
478    #[test]
479    fn test_parse_profile_with_all_samples_filtered() {
480        let mut profile = generate_profile();
481
482        profile.profile.stacks.push(Vec::new());
483        profile.profile.samples.extend(vec![
484            Sample {
485                stack_id: 0,
486                queue_address: Some("0xdeadbeef".to_owned()),
487                elapsed_since_start_ns: 1,
488                thread_id: 1,
489            },
490            Sample {
491                stack_id: 0,
492                queue_address: Some("0xdeadbeef".to_owned()),
493                elapsed_since_start_ns: 1,
494                thread_id: 2,
495            },
496            Sample {
497                stack_id: 0,
498                queue_address: Some("0xdeadbeef".to_owned()),
499                elapsed_since_start_ns: 1,
500                thread_id: 3,
501            },
502            Sample {
503                stack_id: 0,
504                queue_address: Some("0xdeadbeef".to_owned()),
505                elapsed_since_start_ns: 1,
506                thread_id: 4,
507            },
508        ]);
509
510        let payload = serde_json::to_vec(&profile).unwrap();
511        assert!(parse_profile(&payload[..]).is_err());
512    }
513
514    #[test]
515    fn test_expand_with_samples_inclusive() {
516        let mut profile = generate_profile();
517
518        profile.profile.frames.push(Frame {
519            ..Default::default()
520        });
521        profile.metadata.transaction = Some(TransactionMetadata {
522            active_thread_id: 1,
523            id: EventId::new(),
524            name: "blah".to_owned(),
525            relative_cpu_end_ms: 0,
526            relative_cpu_start_ms: 0,
527            relative_end_ns: 30,
528            relative_start_ns: 10,
529            trace_id: EventId::new(),
530            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
531        });
532        profile.profile.stacks.push(vec![0]);
533        profile.profile.samples.extend([
534            Sample {
535                stack_id: 0,
536                queue_address: Some("0xdeadbeef".to_owned()),
537                elapsed_since_start_ns: 10,
538                thread_id: 1,
539            },
540            Sample {
541                stack_id: 0,
542                queue_address: Some("0xdeadbeef".to_owned()),
543                elapsed_since_start_ns: 20,
544                thread_id: 1,
545            },
546            Sample {
547                stack_id: 0,
548                queue_address: Some("0xdeadbeef".to_owned()),
549                elapsed_since_start_ns: 30,
550                thread_id: 1,
551            },
552            Sample {
553                stack_id: 0,
554                queue_address: Some("0xdeadbeef".to_owned()),
555                elapsed_since_start_ns: 40,
556                thread_id: 1,
557            },
558        ]);
559
560        let payload = serde_json::to_vec(&profile).unwrap();
561        let profile = parse_profile(&payload[..]).unwrap();
562
563        assert_eq!(profile.profile.samples.len(), 3);
564    }
565
566    #[test]
567    fn test_expand_with_all_samples_outside_transaction() {
568        let mut profile = generate_profile();
569
570        profile.profile.frames.push(Frame {
571            ..Default::default()
572        });
573        profile.metadata.transaction = Some(TransactionMetadata {
574            active_thread_id: 1,
575            id: EventId::new(),
576            name: "blah".to_owned(),
577            relative_cpu_end_ms: 0,
578            relative_cpu_start_ms: 0,
579            relative_end_ns: 100,
580            relative_start_ns: 50,
581            trace_id: EventId::new(),
582            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
583        });
584        profile.profile.stacks.push(vec![0]);
585        profile.profile.samples.extend(vec![
586            Sample {
587                stack_id: 0,
588                queue_address: Some("0xdeadbeef".to_owned()),
589                elapsed_since_start_ns: 10,
590                thread_id: 1,
591            },
592            Sample {
593                stack_id: 0,
594                queue_address: Some("0xdeadbeef".to_owned()),
595                elapsed_since_start_ns: 20,
596                thread_id: 1,
597            },
598            Sample {
599                stack_id: 0,
600                queue_address: Some("0xdeadbeef".to_owned()),
601                elapsed_since_start_ns: 30,
602                thread_id: 1,
603            },
604            Sample {
605                stack_id: 0,
606                queue_address: Some("0xdeadbeef".to_owned()),
607                elapsed_since_start_ns: 40,
608                thread_id: 1,
609            },
610        ]);
611
612        let payload = serde_json::to_vec(&profile).unwrap();
613        let data = parse_sample_profile(&payload[..], BTreeMap::new(), BTreeMap::new());
614
615        assert!(data.is_err());
616    }
617
618    #[test]
619    fn test_copying_transaction() {
620        let mut profile = generate_profile();
621        let transaction = TransactionMetadata {
622            active_thread_id: 1,
623            id: EventId::new(),
624            name: "blah".to_owned(),
625            relative_cpu_end_ms: 0,
626            relative_cpu_start_ms: 0,
627            relative_end_ns: 100,
628            relative_start_ns: 0,
629            trace_id: EventId::new(),
630            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
631        };
632
633        profile.metadata.transactions.push(transaction.clone());
634        profile.profile.frames.push(Frame {
635            ..Default::default()
636        });
637        profile.profile.stacks.push(vec![0]);
638        profile.profile.samples.extend(vec![
639            Sample {
640                stack_id: 0,
641                queue_address: Some("0xdeadbeef".to_owned()),
642                elapsed_since_start_ns: 10,
643                thread_id: 1,
644            },
645            Sample {
646                stack_id: 0,
647                queue_address: Some("0xdeadbeef".to_owned()),
648                elapsed_since_start_ns: 20,
649                thread_id: 1,
650            },
651            Sample {
652                stack_id: 0,
653                queue_address: Some("0xdeadbeef".to_owned()),
654                elapsed_since_start_ns: 30,
655                thread_id: 1,
656            },
657            Sample {
658                stack_id: 0,
659                queue_address: Some("0xdeadbeef".to_owned()),
660                elapsed_since_start_ns: 40,
661                thread_id: 1,
662            },
663        ]);
664
665        let payload = serde_json::to_vec(&profile).unwrap();
666        let profile = parse_profile(&payload[..]).unwrap();
667
668        assert_eq!(Some(transaction), profile.metadata.transaction);
669        assert!(profile.metadata.transactions.is_empty());
670    }
671
672    #[test]
673    fn test_parse_with_no_transaction() {
674        let profile = generate_profile();
675        let payload = serde_json::to_vec(&profile).unwrap();
676        assert!(parse_profile(&payload[..]).is_err());
677    }
678
679    #[test]
680    fn test_profile_remove_idle_samples_at_start_and_end() {
681        let mut profile = generate_profile();
682        let transaction = TransactionMetadata {
683            active_thread_id: 1,
684            id: EventId::new(),
685            name: "blah".to_owned(),
686            relative_cpu_end_ms: 0,
687            relative_cpu_start_ms: 0,
688            relative_end_ns: 100,
689            relative_start_ns: 0,
690            trace_id: EventId::new(),
691            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
692        };
693
694        profile.metadata.transaction = Some(transaction);
695        profile.profile.frames.push(Frame {
696            ..Default::default()
697        });
698        profile.profile.stacks = vec![vec![0], vec![]];
699        profile.profile.samples = vec![
700            Sample {
701                stack_id: 0,
702                queue_address: Some("0xdeadbeef".to_owned()),
703                elapsed_since_start_ns: 40,
704                thread_id: 2,
705            },
706            Sample {
707                stack_id: 1,
708                queue_address: Some("0xdeadbeef".to_owned()),
709                elapsed_since_start_ns: 50,
710                thread_id: 2,
711            },
712            Sample {
713                stack_id: 1,
714                queue_address: Some("0xdeadbeef".to_owned()),
715                elapsed_since_start_ns: 10,
716                thread_id: 1,
717            },
718            Sample {
719                stack_id: 1,
720                queue_address: Some("0xdeadbeef".to_owned()),
721                elapsed_since_start_ns: 20,
722                thread_id: 1,
723            },
724            Sample {
725                stack_id: 1,
726                queue_address: Some("0xdeadbeef".to_owned()),
727                elapsed_since_start_ns: 30,
728                thread_id: 1,
729            },
730            Sample {
731                stack_id: 0,
732                queue_address: Some("0xdeadbeef".to_owned()),
733                elapsed_since_start_ns: 40,
734                thread_id: 1,
735            },
736            Sample {
737                stack_id: 1,
738                queue_address: Some("0xdeadbeef".to_owned()),
739                elapsed_since_start_ns: 50,
740                thread_id: 1,
741            },
742            Sample {
743                stack_id: 0,
744                queue_address: Some("0xdeadbeef".to_owned()),
745                elapsed_since_start_ns: 60,
746                thread_id: 1,
747            },
748            Sample {
749                stack_id: 1,
750                queue_address: Some("0xdeadbeef".to_owned()),
751                elapsed_since_start_ns: 70,
752                thread_id: 1,
753            },
754            Sample {
755                stack_id: 1,
756                queue_address: Some("0xdeadbeef".to_owned()),
757                elapsed_since_start_ns: 90,
758                thread_id: 1,
759            },
760            Sample {
761                stack_id: 1,
762                queue_address: Some("0xdeadbeef".to_owned()),
763                elapsed_since_start_ns: 80,
764                thread_id: 3,
765            },
766            Sample {
767                stack_id: 1,
768                queue_address: Some("0xdeadbeef".to_owned()),
769                elapsed_since_start_ns: 90,
770                thread_id: 3,
771            },
772            Sample {
773                stack_id: 0,
774                queue_address: Some("0xdeadbeef".to_owned()),
775                elapsed_since_start_ns: 60,
776                thread_id: 2,
777            },
778        ];
779
780        profile.profile.remove_idle_samples_at_the_edge();
781
782        let mut sample_count_by_thread_id: HashMap<u64, u32> = HashMap::new();
783
784        for sample in &profile.profile.samples {
785            *sample_count_by_thread_id
786                .entry(sample.thread_id)
787                .or_default() += 1;
788        }
789
790        assert_eq!(sample_count_by_thread_id, HashMap::from([(1, 3), (2, 3),]));
791    }
792
793    #[test]
794    fn test_profile_cleanup_metadata() {
795        let mut profile = generate_profile();
796        let transaction = TransactionMetadata {
797            active_thread_id: 1,
798            id: EventId::new(),
799            name: "blah".to_owned(),
800            relative_cpu_end_ms: 0,
801            relative_cpu_start_ms: 0,
802            relative_end_ns: 100,
803            relative_start_ns: 0,
804            trace_id: EventId::new(),
805            segment_id: Some("bd2eb23da2beb459".parse().unwrap()),
806        };
807
808        profile.metadata.transaction = Some(transaction);
809        profile.profile.frames.push(Frame {
810            ..Default::default()
811        });
812        profile.profile.stacks = vec![vec![0]];
813
814        let mut thread_metadata: HashMap<String, ThreadMetadata> = HashMap::new();
815
816        thread_metadata.insert(
817            "1".to_owned(),
818            ThreadMetadata {
819                name: Some("".to_owned()),
820                priority: Some(1),
821            },
822        );
823        thread_metadata.insert(
824            "2".to_owned(),
825            ThreadMetadata {
826                name: Some("".to_owned()),
827                priority: Some(1),
828            },
829        );
830        thread_metadata.insert(
831            "3".to_owned(),
832            ThreadMetadata {
833                name: Some("".to_owned()),
834                priority: Some(1),
835            },
836        );
837        thread_metadata.insert(
838            "4".to_owned(),
839            ThreadMetadata {
840                name: Some("".to_owned()),
841                priority: Some(1),
842            },
843        );
844
845        let mut queue_metadata: HashMap<String, QueueMetadata> = HashMap::new();
846
847        queue_metadata.insert(
848            "0xdeadbeef".to_owned(),
849            QueueMetadata {
850                label: "com.apple.main-thread".to_owned(),
851            },
852        );
853
854        queue_metadata.insert(
855            "0x123456789".to_owned(),
856            QueueMetadata {
857                label: "some-label".to_owned(),
858            },
859        );
860
861        profile.profile.thread_metadata = Some(thread_metadata);
862        profile.profile.queue_metadata = Some(queue_metadata);
863        profile.profile.samples.extend(vec![
864            Sample {
865                stack_id: 0,
866                queue_address: Some("0xdeadbeef".to_owned()),
867                elapsed_since_start_ns: 10,
868                thread_id: 1,
869            },
870            Sample {
871                stack_id: 0,
872                queue_address: Some("0xdeadbeef".to_owned()),
873                elapsed_since_start_ns: 20,
874                thread_id: 2,
875            },
876        ]);
877
878        profile.profile.remove_unreferenced_threads();
879        profile.profile.remove_unreferenced_queues();
880
881        assert_eq!(profile.profile.thread_metadata.unwrap().len(), 2);
882        assert_eq!(profile.profile.queue_metadata.unwrap().len(), 1);
883    }
884
885    #[test]
886    fn test_extract_transaction_tags() {
887        let transaction_metadata = BTreeMap::from([(
888            "transaction".to_owned(),
889            "some-random-transaction".to_owned(),
890        )]);
891
892        let payload = include_bytes!("../../tests/fixtures/sample/v1/valid.json");
893        let profile_json = parse_sample_profile(payload, transaction_metadata, BTreeMap::new());
894        assert!(profile_json.is_ok());
895
896        let payload = profile_json.unwrap();
897        let d = &mut serde_json::Deserializer::from_slice(&payload[..]);
898        let output: ProfilingEvent = serde_path_to_error::deserialize(d)
899            .map_err(ProfileError::InvalidJson)
900            .unwrap();
901        assert_eq!(
902            output.metadata.transaction.unwrap().name,
903            "some-random-transaction".to_owned()
904        );
905    }
906
907    #[test]
908    fn test_keep_profile_under_max_duration() {
909        let mut profile = generate_profile();
910        profile.profile.samples.extend(vec![
911            Sample {
912                stack_id: 0,
913                queue_address: Some("0xdeadbeef".to_owned()),
914                elapsed_since_start_ns: 10,
915                thread_id: 1,
916            },
917            Sample {
918                stack_id: 0,
919                queue_address: Some("0xdeadbeef".to_owned()),
920                elapsed_since_start_ns: (MAX_PROFILE_DURATION - Duration::from_secs(1)).as_nanos()
921                    as u64,
922                thread_id: 2,
923            },
924        ]);
925
926        assert!(!profile.profile.is_above_max_duration());
927    }
928
929    #[test]
930    fn test_reject_profile_over_max_duration() {
931        let mut profile = generate_profile();
932        profile.profile.samples.extend(vec![
933            Sample {
934                stack_id: 0,
935                queue_address: Some("0xdeadbeef".to_owned()),
936                elapsed_since_start_ns: 10,
937                thread_id: 1,
938            },
939            Sample {
940                stack_id: 0,
941                queue_address: Some("0xdeadbeef".to_owned()),
942                elapsed_since_start_ns: (MAX_PROFILE_DURATION + Duration::from_secs(1)).as_nanos()
943                    as u64,
944                thread_id: 2,
945            },
946        ]);
947
948        assert!(profile.profile.is_above_max_duration());
949    }
950
951    #[test]
952    fn test_accept_null_or_empty_release() {
953        let payload = r#"{
954            "version":"1",
955            "device":{
956                "architecture":"arm64e",
957                "is_emulator":true,
958                "locale":"en_US",
959                "manufacturer":"Apple",
960                "model":"iPhome11,3"
961            },
962            "os":{
963                "name":"iOS",
964                "version":"16.0",
965                "build_number":"H3110"
966            },
967            "environment":"testing",
968            "event_id":"961d6b96017644db895eafd391682003",
969            "platform":"cocoa",
970            "release":null,
971            "timestamp":"2023-11-01T15:27:15.081230Z",
972            "transaction":{
973                "active_thread_id": 1,
974                "id":"9789498b-6970-4dda-b2a1-f9cb91d1a445",
975                "name":"blah",
976                "trace_id":"809ff2c0-e185-4c21-8f21-6a6fef009352",
977                "segment_id":"bd2eb23da2beb459"
978            },
979            "dist":"9999",
980            "profile":{
981                "samples":[
982                    {
983                        "stack_id":0,
984                        "elapsed_since_start_ns":1,
985                        "thread_id":1
986                    },
987                    {
988                        "stack_id":0,
989                        "elapsed_since_start_ns":2,
990                        "thread_id":1
991                    }
992                ],
993                "stacks":[[0]],
994                "frames":[{
995                    "function":"main"
996                }],
997                "thread_metadata":{},
998                "queue_metadata":{}
999            }
1000        }"#;
1001        let profile = parse_profile(payload.as_bytes());
1002        assert!(profile.is_ok());
1003        assert_eq!(profile.unwrap().metadata.release, None);
1004    }
1005}