1use 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
54 queue_metadata: Option<HashMap<String, QueueMetadata>>,
55}
56
57impl SampleProfile {
58 pub fn normalize(&mut self, platform: &str, architecture: &str) -> Result<(), ProfileError> {
66 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 ("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 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 .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 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 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 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 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 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}