From 405a5c124e6d1d27385c0514877974a26ca53aed Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 25 Feb 2026 09:16:43 +0100 Subject: [PATCH 01/28] feat(profiling): Add Perfetto trace format support Add support for ingesting binary Perfetto traces (.pftrace) as profile chunks. The SDK sends an envelope with a ProfileChunk metadata item paired with a ProfileChunkData item containing the raw Perfetto protobuf. Relay decodes the Perfetto trace, extracts CPU profiling samples (PerfSample and StreamingProfilePacket), converts them to the internal Sample v2 format, and forwards both the expanded JSON and the original binary blob to Kafka for downstream processing. Key changes: - New `perfetto` module in relay-profiling for protobuf decoding and conversion to Sample v2 - New `ProfileChunkData` envelope item type for binary profile payloads - Pairing logic to associate ProfileChunk metadata with ProfileChunkData - Raw profile blob preserved through to Kafka for further processing Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + relay-profiling/Cargo.toml | 1 + relay-profiling/protos/README.md | 33 + relay-profiling/protos/perfetto_trace.proto | 120 ++ relay-profiling/src/debug_image.rs | 21 + relay-profiling/src/error.rs | 6 + relay-profiling/src/lib.rs | 61 +- relay-profiling/src/outcomes.rs | 3 + relay-profiling/src/perfetto/mod.rs | 1378 +++++++++++++++++ relay-profiling/src/perfetto/proto.rs | 170 ++ relay-profiling/src/sample/mod.rs | 7 + .../fixtures/android/perfetto/android.pftrace | Bin 0 -> 41620 bytes relay-server/src/envelope/item.rs | 10 +- .../src/processing/profile_chunks/mod.rs | 187 ++- .../src/processing/profile_chunks/process.rs | 52 +- relay-server/src/services/outcome.rs | 1 + relay-server/src/services/processor.rs | 8 +- relay-server/src/services/processor/event.rs | 1 + relay-server/src/services/store.rs | 8 + relay-server/src/utils/rate_limits.rs | 2 + relay-server/src/utils/sizes.rs | 1 + 21 files changed, 2051 insertions(+), 20 deletions(-) create mode 100644 relay-profiling/protos/README.md create mode 100644 relay-profiling/protos/perfetto_trace.proto create mode 100644 relay-profiling/src/perfetto/mod.rs create mode 100644 relay-profiling/src/perfetto/proto.rs create mode 100644 relay-profiling/tests/fixtures/android/perfetto/android.pftrace diff --git a/Cargo.lock b/Cargo.lock index fdf3814eec0..c2214f7208f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4348,6 +4348,7 @@ dependencies = [ "hashbrown 0.15.4", "insta", "itertools 0.14.0", + "prost 0.14.3", "relay-base-schema", "relay-dynamic-config", "relay-event-schema", diff --git a/relay-profiling/Cargo.toml b/relay-profiling/Cargo.toml index 5ecea9ba1ab..5ae1c587f95 100644 --- a/relay-profiling/Cargo.toml +++ b/relay-profiling/Cargo.toml @@ -19,6 +19,7 @@ chrono = { workspace = true } data-encoding = { workspace = true } hashbrown = { workspace = true } itertools = { workspace = true } +prost = { workspace = true } relay-base-schema = { workspace = true } relay-dynamic-config = { workspace = true } relay-event-schema = { workspace = true } diff --git a/relay-profiling/protos/README.md b/relay-profiling/protos/README.md new file mode 100644 index 00000000000..87ca5fac29b --- /dev/null +++ b/relay-profiling/protos/README.md @@ -0,0 +1,33 @@ +# Perfetto Proto Definitions + +`perfetto_trace.proto` contains a minimal subset of the +[Perfetto trace proto definitions](https://github.com/google/perfetto/tree/master/protos/perfetto/trace) +needed to decode profiling data. Field numbers match the upstream definitions. + +The generated Rust code is checked in at `../src/perfetto/proto.rs`. + +## Regenerating + +1. Install protoc: https://github.com/protocolbuffers/protobuf/releases +2. Add to `Cargo.toml` under `[build-dependencies]`: + ```toml + prost-build = { workspace = true } + ``` +3. Create a `build.rs` in the `relay-profiling` crate root: + ```rust + use std::io::Result; + use std::path::PathBuf; + + fn main() -> Result<()> { + let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("protos"); + let proto_file = proto_dir.join("perfetto_trace.proto"); + prost_build::compile_protos(&[&proto_file], &[&proto_dir])?; + Ok(()) + } + ``` +4. Run: `cargo build -p relay-profiling` +5. Copy the output to the checked-in file: + ```sh + cp target/debug/build/relay-profiling-*/out/perfetto.protos.rs relay-profiling/src/perfetto/proto.rs + ``` +6. Remove the `build.rs` and the `prost-build` dependency. diff --git a/relay-profiling/protos/perfetto_trace.proto b/relay-profiling/protos/perfetto_trace.proto new file mode 100644 index 00000000000..4a05bc949d0 --- /dev/null +++ b/relay-profiling/protos/perfetto_trace.proto @@ -0,0 +1,120 @@ +// Minimal subset of the Perfetto trace proto definitions needed to decode +// profiling data. Field numbers match the upstream definitions at +// https://github.com/google/perfetto/tree/master/protos/perfetto/trace + +syntax = "proto2"; +package perfetto.protos; + +message Trace { + repeated TracePacket packet = 1; +} + +message TracePacket { + optional uint64 timestamp = 8; + + oneof optional_trusted_packet_sequence_id { + uint32 trusted_packet_sequence_id = 10; + } + + optional InternedData interned_data = 12; + optional uint32 sequence_flags = 13; + + // Only the oneof variants we care about; prost will skip the rest. + oneof data { + ProcessTree process_tree = 2; + ClockSnapshot clock_snapshot = 6; + StreamingProfilePacket streaming_profile_packet = 54; + TrackDescriptor track_descriptor = 60; + PerfSample perf_sample = 66; + } +} + +// --- process tree ------------------------------------------------------------ + +message ProcessTree { + message Thread { + optional int32 tid = 1; + optional string name = 2; + optional int32 tgid = 3; + } + repeated ProcessTree.Thread threads = 2; +} + +// --- clock sync --------------------------------------------------------------- + +message ClockSnapshot { + message Clock { + optional uint32 clock_id = 1; + optional uint64 timestamp = 2; + } + repeated Clock clocks = 1; + optional uint32 primary_trace_clock = 2; +} + +// --- interned data ----------------------------------------------------------- + +message InternedData { + repeated InternedString function_names = 5; + repeated Frame frames = 6; + repeated Callstack callstacks = 7; + repeated InternedString build_ids = 16; + repeated InternedString mapping_paths = 17; + repeated Mapping mappings = 19; +} + +message InternedString { + optional uint64 iid = 1; + optional bytes str = 2; +} + +// --- profiling common -------------------------------------------------------- + +message Frame { + optional uint64 iid = 1; + optional uint64 function_name_id = 2; + optional uint64 mapping_id = 3; + optional uint64 rel_pc = 4; +} + +message Mapping { + optional uint64 iid = 1; + optional uint64 build_id = 2; + optional uint64 start_offset = 3; + optional uint64 start = 4; + optional uint64 end = 5; + optional uint64 load_bias = 6; + repeated uint64 path_string_ids = 7; + optional uint64 exact_offset = 8; +} + +message Callstack { + optional uint64 iid = 1; + repeated uint64 frame_ids = 2; +} + +// --- profiling packets ------------------------------------------------------- + +message PerfSample { + optional uint32 cpu = 1; + optional uint32 pid = 2; + optional uint32 tid = 3; + optional uint64 callstack_iid = 4; +} + +message StreamingProfilePacket { + repeated uint64 callstack_iid = 1; + repeated int64 timestamp_delta_us = 2; +} + +// --- track descriptors ------------------------------------------------------- + +message TrackDescriptor { + optional uint64 uuid = 1; + optional ThreadDescriptor thread = 4; +} + +message ThreadDescriptor { + optional int32 pid = 1; + optional int32 tid = 2; + optional string thread_name = 5; +} diff --git a/relay-profiling/src/debug_image.rs b/relay-profiling/src/debug_image.rs index 52674cfc18f..cee227ce407 100644 --- a/relay-profiling/src/debug_image.rs +++ b/relay-profiling/src/debug_image.rs @@ -42,6 +42,27 @@ pub struct DebugImage { uuid: Option, } +impl DebugImage { + /// Creates a native (ELF/Symbolic) debug image from Perfetto mapping data. + pub fn native_image( + code_file: String, + debug_id: DebugId, + image_addr: u64, + image_vmaddr: u64, + image_size: u64, + ) -> Self { + Self { + code_file: Some(code_file.into()), + debug_id: Some(debug_id), + image_type: ImageType::Symbolic, + image_addr: Some(Addr(image_addr)), + image_vmaddr: Some(Addr(image_vmaddr)), + image_size, + uuid: None, + } + } +} + pub fn get_proguard_image(uuid: &str) -> Result { Ok(DebugImage { code_file: None, diff --git a/relay-profiling/src/error.rs b/relay-profiling/src/error.rs index 7bc716195b1..b0dcf06491a 100644 --- a/relay-profiling/src/error.rs +++ b/relay-profiling/src/error.rs @@ -40,6 +40,12 @@ pub enum ProfileError { DurationIsTooLong, #[error("duration is zero")] DurationIsZero, + #[error("invalid protobuf")] + InvalidProtobuf, + #[error("no profile samples in trace")] + NoProfileSamplesInTrace, + #[error("missing clock snapshot in perfetto trace")] + MissingClockSnapshot, #[error("filtered profile")] Filtered(FilterStatKey), #[error(transparent)] diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 38cfe4f4ad1..780e8910443 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -39,6 +39,7 @@ //! //! Relay will forward those profiles encoded with `msgpack` after unpacking them if needed and push a message on Kafka. +use std::collections::BTreeMap; use std::error::Error; use std::net::IpAddr; use std::time::Duration; @@ -63,6 +64,7 @@ mod error; mod extract_from_transaction; mod measurements; mod outcomes; +mod perfetto; mod sample; mod transaction_metadata; mod types; @@ -99,7 +101,7 @@ impl ProfileType { /// pub fn from_platform(platform: &str) -> Self { match platform { - "cocoa" | "android" | "javascript" => Self::Ui, + "cocoa" | "android" | "javascript" | "perfetto" => Self::Ui, _ => Self::Backend, } } @@ -335,6 +337,31 @@ impl ProfileChunk { } } +/// Expands a binary Perfetto trace into a Sample v2 profile chunk. +/// +/// Decodes the protobuf trace, converts it into the internal [`sample::v2`] format, +/// merges the provided JSON `metadata_json` (containing platform, environment, etc.), +/// and returns the serialized JSON profile chunk ready for ingestion. +pub fn expand_perfetto( + perfetto_bytes: &[u8], + metadata_json: &[u8], +) -> Result, ProfileError> { + let d = &mut Deserializer::from_slice(metadata_json); + let metadata: sample::v2::ProfileMetadata = + serde_path_to_error::deserialize(d).map_err(ProfileError::InvalidJson)?; + + let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; + let mut chunk = sample::v2::ProfileChunk { + measurements: BTreeMap::new(), + metadata, + profile: profile_data, + }; + chunk.metadata.debug_meta.images.extend(debug_images); + chunk.normalize()?; + + serde_json::to_vec(&chunk).map_err(|_| ProfileError::CannotSerializePayload) +} + #[cfg(test)] mod tests { use super::*; @@ -399,4 +426,36 @@ mod tests { .is_ok() ); } + + #[test] + fn test_expand_perfetto() { + let perfetto_bytes = include_bytes!("../tests/fixtures/android/perfetto/android.pftrace"); + + let metadata_json = serde_json::json!({ + "version": "2", + "chunk_id": "0432a0a4c25f4697bf9f0a2fcbe6a814", + "profiler_id": "4d229f1d3807421ba62a5f8bc295d836", + "platform": "perfetto", + "client_sdk": {"name": "sentry-android", "version": "1.0"}, + }); + let metadata_bytes = serde_json::to_vec(&metadata_json).unwrap(); + + let result = expand_perfetto(perfetto_bytes, &metadata_bytes); + assert!(result.is_ok(), "expand_perfetto failed: {result:?}"); + + let output: sample::v2::ProfileChunk = serde_json::from_slice(&result.unwrap()).unwrap(); + assert_eq!(output.metadata.platform, "perfetto"); + assert!(!output.profile.samples.is_empty()); + assert!(!output.profile.frames.is_empty()); + assert!( + !output.metadata.debug_meta.images.is_empty(), + "expected debug images from native mappings in the fixture" + ); + } + + #[test] + fn test_expand_perfetto_invalid_metadata() { + let result = expand_perfetto(b"", b"not json"); + assert!(result.is_err()); + } } diff --git a/relay-profiling/src/outcomes.rs b/relay-profiling/src/outcomes.rs index e5cdb6fb0a2..e6149bbdf93 100644 --- a/relay-profiling/src/outcomes.rs +++ b/relay-profiling/src/outcomes.rs @@ -20,5 +20,8 @@ pub fn discard_reason(err: &ProfileError) -> &'static str { ProfileError::DurationIsZero => "profiling_duration_is_zero", ProfileError::Filtered(_) => "profiling_filtered", ProfileError::InvalidBuildID(_) => "invalid_build_id", + ProfileError::InvalidProtobuf => "profiling_invalid_protobuf", + ProfileError::NoProfileSamplesInTrace => "profiling_no_profile_samples_in_trace", + ProfileError::MissingClockSnapshot => "profiling_missing_clock_snapshot", } } diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs new file mode 100644 index 00000000000..e0082478ce5 --- /dev/null +++ b/relay-profiling/src/perfetto/mod.rs @@ -0,0 +1,1378 @@ +//! Perfetto trace format conversion to Sample v2. +//! +//! Handles both `PerfSample` (CPU profiling via `perf_event_open`) and +//! `StreamingProfilePacket` (in-process stack sampling) packet types. + +use std::collections::BTreeMap; + +use data_encoding::HEXLOWER; +use hashbrown::{HashMap, HashSet}; +use prost::Message; +use relay_event_schema::protocol::{Addr, DebugId}; +use relay_protocol::FiniteF64; + +use crate::debug_image::DebugImage; +use crate::error::ProfileError; +use crate::sample::v2::{ProfileData, Sample}; +use crate::sample::{Frame, ThreadMetadata}; + +mod proto; + +use proto::trace_packet::Data; + +/// Maximum number of raw samples we collect from a Perfetto trace before +/// bailing out. At 100 Hz across multiple threads, a 66-second chunk +/// produces at most ~6 600 samples per thread; 100 000 provides generous +/// headroom while bounding memory usage against adversarial input. +const MAX_SAMPLES: usize = 100_000; + +/// See . +const SEQ_INCREMENTAL_STATE_CLEARED: u32 = 1; + +/// Perfetto builtin clock IDs. +/// See . +const CLOCK_REALTIME: u32 = 1; +const CLOCK_BOOTTIME: u32 = 6; + +fn has_incremental_state_cleared(packet: &proto::TracePacket) -> bool { + packet + .sequence_flags + .is_some_and(|f| f & SEQ_INCREMENTAL_STATE_CLEARED != 0) +} + +fn trusted_packet_sequence_id(packet: &proto::TracePacket) -> u32 { + match packet.optional_trusted_packet_sequence_id { + Some(proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(id)) => { + id + } + None => 0, + } +} + +fn extract_clock_offset(cs: &proto::ClockSnapshot) -> Option { + let mut boottime_ns: Option = None; + let mut realtime_ns: Option = None; + + for clock in &cs.clocks { + match clock.clock_id { + Some(CLOCK_BOOTTIME) => boottime_ns = clock.timestamp, + Some(CLOCK_REALTIME) => realtime_ns = clock.timestamp, + _ => {} + } + } + + match (realtime_ns, boottime_ns) { + (Some(rt), Some(bt)) => Some(rt as i128 - bt as i128), + _ => None, + } +} + +/// Per-sequence interned data tables, mirroring Perfetto's incremental state. +/// +/// Perfetto traces use interned IDs to avoid repeating large strings and +/// structures in every packet. Each trusted packet sequence maintains its +/// own set of intern tables that can be cleared on state resets. +#[derive(Default)] +struct InternTables { + strings: HashMap, + frames: HashMap, + callstacks: HashMap, + mappings: HashMap, +} + +impl InternTables { + fn clear(&mut self) { + self.strings.clear(); + self.frames.clear(); + self.callstacks.clear(); + self.mappings.clear(); + } + + fn merge(&mut self, data: &proto::InternedData) { + for s in data.function_names.iter().chain(data.mapping_paths.iter()) { + if let Some(iid) = s.iid { + let value = s + .r#str + .as_deref() + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or("") + .to_owned(); + self.strings.insert(iid, value); + } + } + // Build IDs are raw bytes in Perfetto traces; normalize to hex for later lookup. + for s in &data.build_ids { + if let Some(iid) = s.iid { + let value = match s.r#str.as_deref() { + Some(bytes) if !bytes.is_empty() => HEXLOWER.encode(bytes), + _ => String::new(), + }; + self.strings.insert(iid, value); + } + } + for f in &data.frames { + if let Some(iid) = f.iid { + self.frames.insert(iid, *f); + } + } + for c in &data.callstacks { + if let Some(iid) = c.iid { + self.callstacks.insert(iid, c.clone()); + } + } + for m in &data.mappings { + if let Some(iid) = m.iid { + self.mappings.insert(iid, m.clone()); + } + } + } +} + +/// Deduplication key for resolved stack frames. +/// +/// Two Perfetto frames that resolve to the same function, module, package, +/// and instruction address are considered identical and share a single index +/// in the output frame list. +#[derive(Hash, Eq, PartialEq)] +struct FrameKey { + function: Option, + module: Option, + package: Option, + instruction_addr: Option, +} + +/// Converts a Perfetto binary trace into Sample v2 [`ProfileData`] and debug images. +pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), ProfileError> { + let trace = proto::Trace::decode(perfetto_bytes).map_err(|_| ProfileError::InvalidProtobuf)?; + + let mut tables_by_seq: HashMap = HashMap::new(); + let mut thread_meta: BTreeMap = BTreeMap::new(); + // (timestamp_ns, tid, callstack_iid, sequence_id) + let mut raw_samples: Vec<(u64, u32, u64, u32)> = Vec::new(); + let mut clock_offset_ns: Option = None; + + for packet in &trace.packet { + let seq_id = trusted_packet_sequence_id(packet); + + if has_incremental_state_cleared(packet) { + tables_by_seq.entry(seq_id).or_default().clear(); + } + + if let Some(ref interned) = packet.interned_data { + tables_by_seq.entry(seq_id).or_default().merge(interned); + } + + match &packet.data { + Some(Data::ClockSnapshot(cs)) => { + if clock_offset_ns.is_none() { + clock_offset_ns = extract_clock_offset(cs); + } + } + Some(Data::ProcessTree(pt)) => { + for thread in &pt.threads { + if let Some(tid) = thread.tid { + let tid_str = tid.to_string(); + thread_meta + .entry(tid_str) + .or_insert_with(|| ThreadMetadata { + name: thread.name.clone(), + priority: None, + }); + } + } + } + Some(Data::TrackDescriptor(td)) => { + if let Some(ref thread) = td.thread + && let Some(tid) = thread.tid + { + let tid_str = tid.to_string(); + thread_meta + .entry(tid_str) + .or_insert_with(|| ThreadMetadata { + name: thread.thread_name.clone(), + priority: None, + }); + } + } + Some(Data::PerfSample(ps)) => { + if let Some(callstack_iid) = ps.callstack_iid { + let ts = packet.timestamp.unwrap_or(0); + let tid = ps.tid.unwrap_or(0); + raw_samples.push((ts, tid, callstack_iid, seq_id)); + } + } + Some(Data::StreamingProfilePacket(spp)) => { + let mut ts = packet.timestamp.unwrap_or(0); + for (i, &cs_iid) in spp.callstack_iid.iter().enumerate() { + if i > 0 + && let Some(&delta) = spp.timestamp_delta_us.get(i) + { + // `delta` is i64 (can be negative for out-of-order samples). + // Casting to u64 wraps negative values, which is correct because + // `wrapping_add` of a wrapped negative value subtracts as expected. + ts = ts.wrapping_add((delta * 1000) as u64); + } + raw_samples.push((ts, 0, cs_iid, seq_id)); + } + } + None => {} + } + + if raw_samples.len() > MAX_SAMPLES { + return Err(ProfileError::ExceedSizeLimit); + } + } + + if raw_samples.is_empty() { + return Err(ProfileError::NoProfileSamplesInTrace); + } + + let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::MissingClockSnapshot)?; + + raw_samples.sort_by_key(|s| s.0); + + let empty_tables = InternTables::default(); + let mut frame_index: HashMap = HashMap::new(); + let mut frames: Vec = Vec::new(); + let mut stack_index: HashMap, usize> = HashMap::new(); + let mut stacks: Vec> = Vec::new(); + let mut samples: Vec = Vec::new(); + let mut referenced_mappings: HashSet<(u32, u64)> = HashSet::new(); + + for &(ts_ns, tid, cs_iid, seq_id) in &raw_samples { + let tables = tables_by_seq.get(&seq_id).unwrap_or(&empty_tables); + + let Some(callstack) = tables.callstacks.get(&cs_iid) else { + continue; + }; + + let mut resolved_frame_indices: Vec = Vec::with_capacity(callstack.frame_ids.len()); + + for &frame_iid in &callstack.frame_ids { + let Some(pf) = tables.frames.get(&frame_iid) else { + continue; + }; + + let function_name = pf + .function_name_id + .and_then(|id| tables.strings.get(&id)) + .cloned(); + + if let Some(mid) = pf.mapping_id { + referenced_mappings.insert((seq_id, mid)); + } + + let (key, frame) = build_frame(function_name, pf, tables); + + let idx = if let Some(&existing) = frame_index.get(&key) { + existing + } else { + let idx = frames.len(); + frame_index.insert(key, idx); + frames.push(frame); + idx + }; + + resolved_frame_indices.push(idx); + } + + // Perfetto stacks are root-first, Sample v2 is leaf-first. + resolved_frame_indices.reverse(); + + let stack_id = if let Some(&existing) = stack_index.get(&resolved_frame_indices) { + existing + } else { + let id = stacks.len(); + stack_index.insert(resolved_frame_indices.clone(), id); + stacks.push(resolved_frame_indices); + id + }; + + // Compute absolute timestamp in integer nanoseconds first, then convert + // to f64 seconds once to avoid precision loss from adding large floats. + let abs_ns = ts_ns as i128 + clock_offset_ns; + let ts_secs = abs_ns as f64 / 1_000_000_000.0; + let ts_secs = (ts_secs * 1000.0).round() / 1000.0; + + if let Some(ts) = FiniteF64::new(ts_secs) { + samples.push(Sample { + timestamp: ts, + stack_id, + thread_id: tid.to_string(), + }); + } + } + + if samples.is_empty() { + return Err(ProfileError::NoProfileSamplesInTrace); + } + + // Build debug images from referenced native mappings. + let mut debug_images: Vec = Vec::new(); + let mut seen_images: HashSet<(String, u64)> = HashSet::new(); + + for &(seq_id, mapping_id) in &referenced_mappings { + let Some(tables) = tables_by_seq.get(&seq_id) else { + continue; + }; + let Some(mapping) = tables.mappings.get(&mapping_id) else { + continue; + }; + + let code_file = { + let parts: Vec<&str> = mapping + .path_string_ids + .iter() + .filter_map(|id| tables.strings.get(id).map(|s| s.as_str())) + .collect(); + if parts.is_empty() { + continue; + } + parts.join("/") + }; + + if is_java_mapping(&code_file) { + continue; + } + + let image_addr = mapping.start.unwrap_or(0); + + if !seen_images.insert((code_file.clone(), image_addr)) { + continue; + } + + let debug_id = mapping + .build_id + .and_then(|bid| tables.strings.get(&bid)) + .and_then(|hex_str| build_id_to_debug_id(hex_str)); + + let Some(debug_id) = debug_id else { + continue; + }; + + let image_size = mapping.end.unwrap_or(0).saturating_sub(image_addr); + let image_vmaddr = mapping.load_bias.unwrap_or(0); + + debug_images.push(DebugImage::native_image( + code_file, + debug_id, + image_addr, + image_vmaddr, + image_size, + )); + } + + Ok(( + ProfileData { + samples, + stacks, + frames, + thread_metadata: thread_meta, + }, + debug_images, + )) +} + +/// Resolves a Perfetto frame into a [`FrameKey`] and a Sample v2 [`Frame`]. +/// +/// Java frames (identified by mapping path) have their fully-qualified name +/// split into module and function. Native frames compute an absolute +/// instruction address from `rel_pc` and the mapping start address. +fn build_frame( + function_name: Option, + pf: &proto::Frame, + tables: &InternTables, +) -> (FrameKey, Frame) { + let mapping = pf.mapping_id.and_then(|mid| tables.mappings.get(&mid)); + + let mapping_path = mapping.and_then(|m| { + let parts: Vec<&str> = m + .path_string_ids + .iter() + .filter_map(|id| tables.strings.get(id).map(|s| s.as_str())) + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } + }); + + let is_java = mapping_path.as_deref().is_some_and(is_java_mapping); + + if is_java { + // For Java frames, split "com.example.MyClass.myMethod" into + // module="com.example.MyClass" and function="myMethod". + let (module, function) = match &function_name { + Some(name) => match name.rsplit_once('.') { + Some((class, method)) => (Some(class.to_owned()), Some(method.to_owned())), + None => (None, Some(name.clone())), + }, + None => (None, None), + }; + + let key = FrameKey { + function: function.clone(), + module: module.clone(), + package: mapping_path.clone(), + instruction_addr: None, + }; + + let frame = Frame { + function, + module, + package: mapping_path, + platform: Some("java".to_owned()), + ..Default::default() + }; + + (key, frame) + } else { + let instruction_addr = match (pf.rel_pc, mapping) { + (Some(rel_pc), Some(m)) => Some(rel_pc.wrapping_add(m.start.unwrap_or(0))), + (Some(rel_pc), None) => Some(rel_pc), + (None, _) => None, + }; + + let key = FrameKey { + function: function_name.clone(), + module: None, + package: mapping_path.clone(), + instruction_addr, + }; + + let frame = Frame { + function: function_name, + package: mapping_path, + instruction_addr: instruction_addr.map(Addr), + platform: Some("native".to_owned()), + ..Default::default() + }; + + (key, frame) + } +} + +/// Returns `true` if the mapping path indicates a JVM/ART runtime mapping. +fn is_java_mapping(path: &str) -> bool { + const JVM_EXTENSIONS: &[&str] = &[".oat", ".odex", ".vdex", ".jar", ".dex"]; + + if path.contains("dalvik-jit-code-cache") { + return true; + } + JVM_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) +} + +/// Converts a hex-encoded ELF build ID string into a Sentry [`DebugId`]. +/// +/// The first 16 bytes of the build ID are interpreted as a little-endian UUID +/// (byte-swapping the time_low, time_mid, and time_hi_and_version fields). +/// If the build ID is shorter than 16 bytes it is zero-padded on the right. +fn build_id_to_debug_id(hex_str: &str) -> Option { + let bytes = HEXLOWER.decode(hex_str.as_bytes()).ok()?; + if bytes.is_empty() { + return None; + } + + let mut buf = [0u8; 16]; + let len = bytes.len().min(16); + buf[..len].copy_from_slice(&bytes[..len]); + + // Swap from little-endian ELF byte order to UUID mixed-endian format. + // time_low (bytes 0..4): reverse + buf[..4].reverse(); + // time_mid (bytes 4..6): reverse + buf[4..6].reverse(); + // time_hi_and_version (bytes 6..8): reverse + buf[6..8].reverse(); + + let uuid = uuid::Uuid::from_bytes(buf); + uuid.to_string().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_BOOTTIME_NS: u64 = 1_000_000_000; + const TEST_REALTIME_NS: u64 = 1_700_000_001_000_000_000; + + fn make_clock_snapshot_packet() -> proto::TracePacket { + proto::TracePacket { + timestamp: None, + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: None, + data: Some(Data::ClockSnapshot(proto::ClockSnapshot { + clocks: vec![ + proto::clock_snapshot::Clock { + clock_id: Some(CLOCK_BOOTTIME), + timestamp: Some(TEST_BOOTTIME_NS), + }, + proto::clock_snapshot::Clock { + clock_id: Some(CLOCK_REALTIME), + timestamp: Some(TEST_REALTIME_NS), + }, + ], + primary_trace_clock: Some(CLOCK_BOOTTIME), + })), + } + } + + fn make_interned_string(iid: u64, value: &[u8]) -> proto::InternedString { + proto::InternedString { + iid: Some(iid), + r#str: Some(value.to_vec()), + } + } + + fn make_frame(iid: u64, function_name_id: u64) -> proto::Frame { + proto::Frame { + iid: Some(iid), + function_name_id: Some(function_name_id), + mapping_id: None, + rel_pc: None, + } + } + + fn make_perf_sample_packet( + timestamp: u64, + seq_id: u32, + tid: u32, + callstack_iid: u64, + ) -> proto::TracePacket { + proto::TracePacket { + timestamp: Some(timestamp), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId( + seq_id, + ), + ), + data: Some(Data::PerfSample(proto::PerfSample { + cpu: None, + pid: None, + tid: Some(tid), + callstack_iid: Some(callstack_iid), + })), + } + } + + fn make_interned_data_packet( + seq_id: u32, + clear_state: bool, + interned_data: proto::InternedData, + ) -> proto::TracePacket { + proto::TracePacket { + timestamp: None, + interned_data: Some(interned_data), + sequence_flags: if clear_state { + Some(SEQ_INCREMENTAL_STATE_CLEARED) + } else { + None + }, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId( + seq_id, + ), + ), + data: None, + } + } + + fn build_minimal_trace() -> Vec { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![ + make_interned_string(1, b"main"), + make_interned_string(2, b"foo"), + ], + frames: vec![ + proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: None, + rel_pc: Some(0x1000), + }, + proto::Frame { + iid: Some(2), + function_name_id: Some(2), + mapping_id: None, + rel_pc: Some(0x2000), + }, + ], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1, 2], // root-first: main -> foo + }], + ..Default::default() + }, + ), + // Thread descriptor. + proto::TracePacket { + timestamp: None, + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::TrackDescriptor(proto::TrackDescriptor { + uuid: None, + thread: Some(proto::ThreadDescriptor { + pid: Some(100), + tid: Some(42), + thread_name: Some("main-thread".to_owned()), + }), + })), + }, + make_perf_sample_packet(1_000_000_000, 1, 42, 1), + make_perf_sample_packet(1_010_000_000, 1, 42, 1), + ], + }; + trace.encode_to_vec() + } + + #[test] + fn test_convert_minimal_trace() { + let bytes = build_minimal_trace(); + let result = convert(&bytes); + assert!(result.is_ok(), "conversion failed: {result:?}"); + + let (data, _images) = result.unwrap(); + + assert_eq!(data.samples.len(), 2); + assert_eq!(data.samples[0].thread_id, "42"); + assert_eq!(data.frames.len(), 2); + + assert_eq!(data.stacks.len(), 1); + let stack = &data.stacks[0]; + assert_eq!(stack.len(), 2); + + // Leaf-first order: foo, then main. + assert_eq!(data.frames[stack[0]].function.as_deref(), Some("foo")); + assert_eq!(data.frames[stack[1]].function.as_deref(), Some("main")); + + assert!(data.thread_metadata.contains_key("42")); + assert_eq!( + data.thread_metadata["42"].name.as_deref(), + Some("main-thread") + ); + } + + #[test] + fn test_convert_empty_trace() { + let trace = proto::Trace { packet: vec![] }; + let bytes = trace.encode_to_vec(); + let result = convert(&bytes); + assert!(matches!(result, Err(ProfileError::NoProfileSamplesInTrace))); + } + + #[test] + fn test_convert_invalid_protobuf() { + let result = convert(b"not a valid protobuf"); + assert!(matches!(result, Err(ProfileError::InvalidProtobuf))); + } + + #[test] + fn test_convert_missing_clock_snapshot() { + let trace = proto::Trace { + packet: vec![ + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let result = convert(&bytes); + assert!(matches!(result, Err(ProfileError::MissingClockSnapshot))); + } + + #[test] + fn test_streaming_profile_packet() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func_a")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(10), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + proto::TracePacket { + timestamp: Some(2_000_000_000), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::StreamingProfilePacket( + proto::StreamingProfilePacket { + callstack_iid: vec![10, 10], + timestamp_delta_us: vec![0, 10_000], // 0, +10ms + }, + )), + }, + ], + }; + let bytes = trace.encode_to_vec(); + let result = convert(&bytes); + assert!(result.is_ok(), "conversion failed: {result:?}"); + + let (data, _images) = result.unwrap(); + assert_eq!(data.samples.len(), 2); + // Timestamps are rebased using ClockSnapshot: offset = REALTIME - BOOTTIME. + let duration = data.samples[1].timestamp.to_f64() - data.samples[0].timestamp.to_f64(); + assert!( + (duration - 0.01).abs() < 0.001, + "expected ~10ms delta, got {duration}" + ); + // First sample at 2.0s boottime -> 2.0 + (REALTIME - BOOTTIME)/1e9 in Unix seconds. + let expected_offset = (TEST_REALTIME_NS as f64 - TEST_BOOTTIME_NS as f64) / 1e9; + let expected_ts = 2.0 + expected_offset; + assert!((data.samples[0].timestamp.to_f64() - expected_ts).abs() < 0.001); + } + + #[test] + fn test_mapping_resolution() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![ + make_interned_string(1, b"my_func"), + make_interned_string(10, b"libfoo.so"), + ], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: Some(1), + rel_pc: Some(0x100), + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + mappings: vec![proto::Mapping { + iid: Some(1), + build_id: None, + start: Some(0x7000), + end: Some(0x8000), + load_bias: None, + path_string_ids: vec![10], + ..Default::default() + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, images) = convert(&bytes).unwrap(); + + assert_eq!(data.frames.len(), 1); + let frame = &data.frames[0]; + assert_eq!(frame.platform.as_deref(), Some("native")); + assert_eq!(frame.function.as_deref(), Some("my_func")); + assert_eq!(frame.instruction_addr, Some(Addr(0x7100))); // rel_pc + start + assert_eq!(frame.package.as_deref(), Some("libfoo.so")); + assert!(frame.module.is_none()); + // No build_id on the mapping, so no debug images. + assert!(images.is_empty()); + } + + #[test] + fn test_incremental_state_reset() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"old_func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + // State reset replaces everything. + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"new_func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + // After reset, "old_func" should be gone; only "new_func" remains. + assert_eq!(data.frames.len(), 1); + assert_eq!(data.frames[0].function.as_deref(), Some("new_func")); + } + + #[test] + fn test_convert_android_pftrace() { + let bytes = include_bytes!("../../tests/fixtures/android/perfetto/android.pftrace"); + + let result = convert(bytes.as_slice()); + assert!(result.is_ok(), "conversion failed: {result:?}"); + + let (data, images) = result.unwrap(); + assert!(!data.samples.is_empty(), "expected samples"); + assert!(!data.frames.is_empty(), "expected frames"); + assert!(!data.stacks.is_empty(), "expected stacks"); + + // All samples must reference valid stacks. + for sample in &data.samples { + assert!( + sample.stack_id < data.stacks.len(), + "sample references out-of-bounds stack_id {}", + sample.stack_id + ); + } + + // All stacks must reference valid frames. + for stack in &data.stacks { + for &frame_idx in stack { + assert!( + frame_idx < data.frames.len(), + "stack references out-of-bounds frame index {frame_idx}", + ); + } + } + + let java_count = data + .frames + .iter() + .filter(|f| f.platform.as_deref() == Some("java")) + .count(); + let native_count = data + .frames + .iter() + .filter(|f| f.platform.as_deref() == Some("native")) + .count(); + assert!(java_count > 0, "expected java frames"); + assert!(native_count > 0, "expected native frames"); + + assert!( + !images.is_empty(), + "expected debug images from native mappings" + ); + } + + #[test] + fn test_frame_deduplication() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"shared")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: None, + rel_pc: Some(0x100), + }], + // Two different callstacks referencing the same frame. + callstacks: vec![ + proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }, + proto::Callstack { + iid: Some(2), + frame_ids: vec![1], + }, + ], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 2), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + // Same frame referenced from two callstacks should be deduplicated. + assert_eq!(data.frames.len(), 1); + assert_eq!(data.stacks.len(), 1); // Same single-frame stack, also deduped. + assert_eq!(data.samples.len(), 2); + } + + #[test] + fn test_is_java_mapping() { + // JVM mappings. + assert!(is_java_mapping("system/framework/arm64/boot-framework.oat")); + assert!(is_java_mapping("data/app/.../oat/arm64/base.odex")); + assert!(is_java_mapping("base.vdex")); + assert!(is_java_mapping("system/framework/framework.jar")); + assert!(is_java_mapping("classes.dex")); + assert!(is_java_mapping("[anon_shmem:dalvik-jit-code-cache]")); + + // Native mappings. + assert!(!is_java_mapping("libc.so")); + assert!(!is_java_mapping("libhwui.so")); + assert!(!is_java_mapping("apex/com.android.art/lib64/libart.so")); + assert!(!is_java_mapping("app_process64")); + } + + #[test] + fn test_java_frame_splitting() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"android.view.View.draw")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: Some(1), + rel_pc: Some(0x100), + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + mappings: vec![proto::Mapping { + iid: Some(1), + start: Some(0x1000), + path_string_ids: vec![10], + ..Default::default() + }], + mapping_paths: vec![make_interned_string(10, b"boot-framework.oat")], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.frames.len(), 1); + let frame = &data.frames[0]; + assert_eq!(frame.platform.as_deref(), Some("java")); + assert_eq!(frame.module.as_deref(), Some("android.view.View")); + assert_eq!(frame.function.as_deref(), Some("draw")); + assert_eq!(frame.package.as_deref(), Some("boot-framework.oat")); + assert!(frame.instruction_addr.is_none()); + } + + #[test] + fn test_native_frame() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"__epoll_pwait")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: Some(1), + rel_pc: Some(0x100), + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + mappings: vec![proto::Mapping { + iid: Some(1), + start: Some(0x7000), + path_string_ids: vec![10], + ..Default::default() + }], + mapping_paths: vec![make_interned_string(10, b"libc.so")], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.frames.len(), 1); + let frame = &data.frames[0]; + assert_eq!(frame.platform.as_deref(), Some("native")); + assert_eq!(frame.function.as_deref(), Some("__epoll_pwait")); + assert_eq!(frame.package.as_deref(), Some("libc.so")); + assert_eq!(frame.instruction_addr, Some(Addr(0x7100))); // rel_pc + start + assert!(frame.module.is_none()); + } + + #[test] + fn test_build_id_to_debug_id() { + // 20-byte ELF build ID (common for GNU build IDs). + let debug_id = build_id_to_debug_id("b03e4a7f5e884c8da04b05fa32cc4cbd69faff51").unwrap(); + // First 16 bytes: b0 3e 4a 7f 5e 88 4c 8d a0 4b 05 fa 32 cc 4c bd + // After LE→UUID swap: + // time_low (0..4) reversed: 7f4a3eb0 + // time_mid (4..6) reversed: 885e + // time_hi (6..8) reversed: 8d4c + // rest (8..16) unchanged: a04b05fa32cc4cbd + assert_eq!(debug_id.to_string(), "7f4a3eb0-885e-8d4c-a04b-05fa32cc4cbd"); + } + + #[test] + fn test_build_id_to_debug_id_short() { + // Build ID shorter than 16 bytes → zero-padded. + let debug_id = build_id_to_debug_id("aabbccdd").unwrap(); + // Bytes: aa bb cc dd 00 00 00 00 00 00 00 00 00 00 00 00 + // After swap: ddccbbaa-0000-0000-0000-000000000000 + assert_eq!(debug_id.to_string(), "ddccbbaa-0000-0000-0000-000000000000"); + } + + #[test] + fn test_build_id_to_debug_id_empty() { + assert!(build_id_to_debug_id("").is_none()); + } + + #[test] + fn test_mapping_with_build_id() { + // Raw 20-byte ELF build ID (as it appears in Perfetto traces). + let build_id_raw: &[u8] = &[ + 0xb0, 0x3e, 0x4a, 0x7f, 0x5e, 0x88, 0x4c, 0x8d, 0xa0, 0x4b, 0x05, 0xfa, 0x32, 0xcc, + 0x4c, 0xbd, 0x69, 0xfa, 0xff, 0x51, + ]; + + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"native_func")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: Some(1), + rel_pc: Some(0x200), + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + mappings: vec![proto::Mapping { + iid: Some(1), + build_id: Some(20), + start: Some(0x7000_0000), + end: Some(0x7001_0000), + load_bias: Some(0x1000), + path_string_ids: vec![10], + start_offset: None, + exact_offset: None, + }], + mapping_paths: vec![make_interned_string(10, b"libexample.so")], + build_ids: vec![make_interned_string(20, build_id_raw)], + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, images) = convert(&bytes).unwrap(); + + assert_eq!(data.frames.len(), 1); + assert_eq!(images.len(), 1); + + let img_json = serde_json::to_value(&images[0]).unwrap(); + assert_eq!(img_json["code_file"], "libexample.so"); + assert_eq!(img_json["debug_id"], "7f4a3eb0-885e-8d4c-a04b-05fa32cc4cbd"); + assert_eq!(img_json["image_addr"], "0x70000000"); + assert_eq!(img_json["image_vmaddr"], "0x1000"); + assert_eq!(img_json["image_size"], 0x10000); + assert_eq!(img_json["type"], "symbolic"); + } + + #[test] + fn test_process_tree_thread_names() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + // ProcessTree with thread names. + proto::TracePacket { + timestamp: None, + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: None, + data: Some(proto::trace_packet::Data::ProcessTree(proto::ProcessTree { + threads: vec![ + proto::process_tree::Thread { + tid: Some(42), + name: Some("main".to_owned()), + tgid: Some(42), + }, + proto::process_tree::Thread { + tid: Some(43), + name: Some("RenderThread".to_owned()), + tgid: Some(42), + }, + ], + })), + }, + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"doWork")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: None, + rel_pc: None, + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 42, 1), + make_perf_sample_packet(1_010_000_000, 1, 43, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.thread_metadata.len(), 2); + assert_eq!( + data.thread_metadata + .get("42") + .and_then(|m| m.name.as_deref()), + Some("main"), + ); + assert_eq!( + data.thread_metadata + .get("43") + .and_then(|m| m.name.as_deref()), + Some("RenderThread"), + ); + } + + #[test] + fn test_exceeds_max_samples() { + let mut packets = vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + ]; + for i in 0..=MAX_SAMPLES as u64 { + packets.push(make_perf_sample_packet(1_000_000_000 + i * 1_000, 1, 1, 1)); + } + let trace = proto::Trace { packet: packets }; + let bytes = trace.encode_to_vec(); + let result = convert(&bytes); + assert!( + matches!(result, Err(ProfileError::ExceedSizeLimit)), + "expected ExceedSizeLimit, got {result:?}" + ); + } + + #[test] + fn test_negative_timestamp_delta() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func_a")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(10), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + proto::TracePacket { + timestamp: Some(3_000_000_000), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::StreamingProfilePacket( + proto::StreamingProfilePacket { + callstack_iid: vec![10, 10, 10], + timestamp_delta_us: vec![0, 20_000, -5_000], // 0, +20ms, -5ms + }, + )), + }, + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.samples.len(), 3); + // After sorting: sample at 3.0s, then 3.0+0.015=3.015s, then 3.0+0.020=3.020s + let t0 = data.samples[0].timestamp.to_f64(); + let t1 = data.samples[1].timestamp.to_f64(); + let t2 = data.samples[2].timestamp.to_f64(); + assert!(t0 < t1 && t1 < t2, "expected sorted timestamps: {t0}, {t1}, {t2}"); + // The gap between t1 and t2 should be ~5ms (the -5ms sample comes before the +20ms one). + let gap = t2 - t1; + assert!((gap - 0.005).abs() < 0.001, "expected ~5ms gap, got {gap}"); + } + + #[test] + fn test_multi_sequence_traces() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + // Sequence 1: has "alpha" function. + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"alpha")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + // Sequence 2: reuses iid=1 but for "beta" function. + make_interned_data_packet( + 2, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"beta")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), // seq 1 -> alpha + make_perf_sample_packet(1_010_000_000, 2, 2, 1), // seq 2 -> beta + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.samples.len(), 2); + // Should have two distinct frames from the two sequences. + assert_eq!(data.frames.len(), 2); + let frame_names: Vec<_> = data + .frames + .iter() + .map(|f| f.function.as_deref().unwrap_or("")) + .collect(); + assert!(frame_names.contains(&"alpha"), "expected alpha frame"); + assert!(frame_names.contains(&"beta"), "expected beta frame"); + } + + #[test] + fn test_empty_callstack() { + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![ + proto::Callstack { + iid: Some(1), + frame_ids: vec![], // empty callstack + }, + proto::Callstack { + iid: Some(2), + frame_ids: vec![1], // valid callstack + }, + ], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), // empty callstack + make_perf_sample_packet(1_010_000_000, 1, 1, 2), // valid callstack + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + // Both samples are emitted, but the empty one produces a deduplicated empty stack. + assert_eq!(data.samples.len(), 2); + // The valid callstack should produce one frame. + assert_eq!(data.frames.len(), 1); + assert_eq!(data.frames[0].function.as_deref(), Some("func")); + } +} diff --git a/relay-profiling/src/perfetto/proto.rs b/relay-profiling/src/perfetto/proto.rs new file mode 100644 index 00000000000..72fc9882d9d --- /dev/null +++ b/relay-profiling/src/perfetto/proto.rs @@ -0,0 +1,170 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Trace { + #[prost(message, repeated, tag = "1")] + pub packet: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TracePacket { + #[prost(uint64, optional, tag = "8")] + pub timestamp: ::core::option::Option, + #[prost(message, optional, tag = "12")] + pub interned_data: ::core::option::Option, + #[prost(uint32, optional, tag = "13")] + pub sequence_flags: ::core::option::Option, + #[prost(oneof = "trace_packet::OptionalTrustedPacketSequenceId", tags = "10")] + pub optional_trusted_packet_sequence_id: + ::core::option::Option, + /// Only the oneof variants we care about; prost will skip the rest. + #[prost(oneof = "trace_packet::Data", tags = "2, 6, 54, 60, 66")] + pub data: ::core::option::Option, +} +/// Nested message and enum types in `TracePacket`. +pub mod trace_packet { + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum OptionalTrustedPacketSequenceId { + #[prost(uint32, tag = "10")] + TrustedPacketSequenceId(u32), + } + /// Only the oneof variants we care about; prost will skip the rest. + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Data { + #[prost(message, tag = "2")] + ProcessTree(super::ProcessTree), + #[prost(message, tag = "6")] + ClockSnapshot(super::ClockSnapshot), + #[prost(message, tag = "54")] + StreamingProfilePacket(super::StreamingProfilePacket), + #[prost(message, tag = "60")] + TrackDescriptor(super::TrackDescriptor), + #[prost(message, tag = "66")] + PerfSample(super::PerfSample), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ProcessTree { + #[prost(message, repeated, tag = "2")] + pub threads: ::prost::alloc::vec::Vec, +} +/// Nested message and enum types in `ProcessTree`. +pub mod process_tree { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct Thread { + #[prost(int32, optional, tag = "1")] + pub tid: ::core::option::Option, + #[prost(string, optional, tag = "2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int32, optional, tag = "3")] + pub tgid: ::core::option::Option, + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ClockSnapshot { + #[prost(message, repeated, tag = "1")] + pub clocks: ::prost::alloc::vec::Vec, + #[prost(uint32, optional, tag = "2")] + pub primary_trace_clock: ::core::option::Option, +} +/// Nested message and enum types in `ClockSnapshot`. +pub mod clock_snapshot { + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] + pub struct Clock { + #[prost(uint32, optional, tag = "1")] + pub clock_id: ::core::option::Option, + #[prost(uint64, optional, tag = "2")] + pub timestamp: ::core::option::Option, + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct InternedData { + #[prost(message, repeated, tag = "5")] + pub function_names: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "6")] + pub frames: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "7")] + pub callstacks: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "16")] + pub build_ids: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "17")] + pub mapping_paths: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "19")] + pub mappings: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct InternedString { + #[prost(uint64, optional, tag = "1")] + pub iid: ::core::option::Option, + #[prost(bytes = "vec", optional, tag = "2")] + pub str: ::core::option::Option<::prost::alloc::vec::Vec>, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Frame { + #[prost(uint64, optional, tag = "1")] + pub iid: ::core::option::Option, + #[prost(uint64, optional, tag = "2")] + pub function_name_id: ::core::option::Option, + #[prost(uint64, optional, tag = "3")] + pub mapping_id: ::core::option::Option, + #[prost(uint64, optional, tag = "4")] + pub rel_pc: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Mapping { + #[prost(uint64, optional, tag = "1")] + pub iid: ::core::option::Option, + #[prost(uint64, optional, tag = "2")] + pub build_id: ::core::option::Option, + #[prost(uint64, optional, tag = "3")] + pub start_offset: ::core::option::Option, + #[prost(uint64, optional, tag = "4")] + pub start: ::core::option::Option, + #[prost(uint64, optional, tag = "5")] + pub end: ::core::option::Option, + #[prost(uint64, optional, tag = "6")] + pub load_bias: ::core::option::Option, + #[prost(uint64, repeated, packed = "false", tag = "7")] + pub path_string_ids: ::prost::alloc::vec::Vec, + #[prost(uint64, optional, tag = "8")] + pub exact_offset: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Callstack { + #[prost(uint64, optional, tag = "1")] + pub iid: ::core::option::Option, + #[prost(uint64, repeated, packed = "false", tag = "2")] + pub frame_ids: ::prost::alloc::vec::Vec, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PerfSample { + #[prost(uint32, optional, tag = "1")] + pub cpu: ::core::option::Option, + #[prost(uint32, optional, tag = "2")] + pub pid: ::core::option::Option, + #[prost(uint32, optional, tag = "3")] + pub tid: ::core::option::Option, + #[prost(uint64, optional, tag = "4")] + pub callstack_iid: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StreamingProfilePacket { + #[prost(uint64, repeated, packed = "false", tag = "1")] + pub callstack_iid: ::prost::alloc::vec::Vec, + #[prost(int64, repeated, packed = "false", tag = "2")] + pub timestamp_delta_us: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TrackDescriptor { + #[prost(uint64, optional, tag = "1")] + pub uuid: ::core::option::Option, + #[prost(message, optional, tag = "4")] + pub thread: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ThreadDescriptor { + #[prost(int32, optional, tag = "1")] + pub pid: ::core::option::Option, + #[prost(int32, optional, tag = "2")] + pub tid: ::core::option::Option, + #[prost(string, optional, tag = "5")] + pub thread_name: ::core::option::Option<::prost::alloc::string::String>, +} diff --git a/relay-profiling/src/sample/mod.rs b/relay-profiling/src/sample/mod.rs index d3d1b0ba335..dd36828aebe 100644 --- a/relay-profiling/src/sample/mod.rs +++ b/relay-profiling/src/sample/mod.rs @@ -70,6 +70,13 @@ pub struct Frame { #[serde(skip_serializing_if = "Option::is_none")] pub module: Option, + /// The 'package' the frame was contained in. + /// + /// For native frames this is the dynamic library path (e.g. `libc.so`). + /// For Java frames this is the container (e.g. `boot-framework.oat`). + #[serde(skip_serializing_if = "Option::is_none")] + pub package: Option, + /// Which platform this frame is from. /// /// This can override the platform for a single frame. Otherwise, the platform of the event is diff --git a/relay-profiling/tests/fixtures/android/perfetto/android.pftrace b/relay-profiling/tests/fixtures/android/perfetto/android.pftrace new file mode 100644 index 0000000000000000000000000000000000000000..e04c92c172804d139d8e3051b9bbf05f08b262e4 GIT binary patch literal 41620 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 dny%3kF^Ae*mh98a@C3 literal 0 HcmV?d00001 diff --git a/relay-server/src/envelope/item.rs b/relay-server/src/envelope/item.rs index c34854bd19f..03853665866 100644 --- a/relay-server/src/envelope/item.rs +++ b/relay-server/src/envelope/item.rs @@ -165,6 +165,7 @@ impl Item { Some(ProfileType::Ui) => smallvec![(DataCategory::ProfileChunkUi, item_count)], None => smallvec![], }, + ItemType::ProfileChunkData => smallvec![], ItemType::Integration => match self.integration() { Some(Integration::Logs(LogsIntegration::OtelV1 { .. })) => smallvec![ (DataCategory::LogByte, self.len().max(1)), @@ -586,7 +587,8 @@ impl Item { | ItemType::Nel | ItemType::Log | ItemType::TraceMetric - | ItemType::ProfileChunk => false, + | ItemType::ProfileChunk + | ItemType::ProfileChunkData => false, // For now integrations can not create events, we may need to revisit this in the // future and break down different types of integrations here, similar to attachments. @@ -625,6 +627,7 @@ impl Item { ItemType::Log => false, ItemType::TraceMetric => false, ItemType::ProfileChunk => false, + ItemType::ProfileChunkData => false, ItemType::Integration => false, // Since this Relay cannot interpret the semantics of this item, it does not know @@ -721,6 +724,8 @@ pub enum ItemType { UserReportV2, /// ProfileChunk is a chunk of a profiling session. ProfileChunk, + /// Binary profile payload (e.g. Perfetto trace) accompanying a ProfileChunk. + ProfileChunkData, /// Integrations are a vendor specific set of endpoints providing integrations with external /// systems, standards and vendors. /// @@ -780,6 +785,7 @@ impl ItemType { Self::TraceMetric => "trace_metric", Self::Span => "span", Self::ProfileChunk => "profile_chunk", + Self::ProfileChunkData => "profile_chunk_data", Self::Integration => "integration", Self::Unknown(_) => "unknown", } @@ -840,6 +846,7 @@ impl ItemType { ItemType::Span => true, ItemType::UserReportV2 => false, ItemType::ProfileChunk => true, + ItemType::ProfileChunkData => false, ItemType::Integration => false, ItemType::Unknown(_) => true, } @@ -884,6 +891,7 @@ impl std::str::FromStr for ItemType { // "profile_chunk_ui" is to be treated as an alias for `ProfileChunk` // because Android 8.10.0 and 8.11.0 is sending it as the item type. "profile_chunk_ui" => Self::ProfileChunk, + "profile_chunk_data" => Self::ProfileChunkData, "integration" => Self::Integration, other => Self::Unknown(other.to_owned()), }) diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 7b004865871..e33e97bc1b2 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -4,7 +4,7 @@ use relay_profiling::ProfileType; use relay_quotas::{DataCategory, RateLimits}; use crate::Envelope; -use crate::envelope::{EnvelopeHeaders, Item, ItemType, Items}; +use crate::envelope::{ContentType, EnvelopeHeaders, Item, ItemType, Items}; use crate::managed::{Counted, Managed, ManagedEnvelope, ManagedResult as _, Quantities, Rejected}; use crate::processing::{self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter}; use crate::services::outcome::{DiscardReason, Outcome}; @@ -80,11 +80,22 @@ impl processing::Processor for ProfileChunksProcessor { &self, envelope: &mut ManagedEnvelope, ) -> Option> { - let profile_chunks = envelope + let items = envelope .envelope_mut() - .take_items_by(|item| matches!(*item.ty(), ItemType::ProfileChunk)) + .take_items_by(|item| { + matches!( + *item.ty(), + ItemType::ProfileChunk | ItemType::ProfileChunkData + ) + }) .into_vec(); + if items.is_empty() { + return None; + } + + let profile_chunks = pair_profile_chunks(items); + if profile_chunks.is_empty() { return None; } @@ -123,8 +134,18 @@ impl Forward for ProfileChunkOutput { _: processing::ForwardContext<'_>, ) -> Result>, Rejected<()>> { let Self(profile_chunks) = self; - Ok(profile_chunks - .map(|pc, _| Envelope::from_parts(pc.headers, Items::from_vec(pc.profile_chunks)))) + Ok(profile_chunks.map(|pc, _| { + let mut items: Vec = Vec::new(); + for ppc in pc.profile_chunks { + items.push(ppc.item); + if let Some(raw) = ppc.raw_profile { + let mut data_item = Item::new(ItemType::ProfileChunkData); + data_item.set_payload(ContentType::OctetStream, raw); + items.push(data_item); + } + } + Envelope::from_parts(pc.headers, Items::from_vec(items)) + })) } #[cfg(feature = "processing")] @@ -138,11 +159,12 @@ impl Forward for ProfileChunkOutput { let Self(profile_chunks) = self; let retention_days = ctx.event_retention().standard; - for item in profile_chunks.split(|pc| pc.profile_chunks) { - s.store(item.map(|item, _| StoreProfileChunk { + for ppc in profile_chunks.split(|pc| pc.profile_chunks) { + s.store(ppc.map(|ppc, _| StoreProfileChunk { retention_days, - payload: item.payload(), - quantities: item.quantities(), + payload: ppc.item.payload(), + quantities: ppc.item.quantities(), + raw_profile: ppc.raw_profile, })); } @@ -150,13 +172,26 @@ impl Forward for ProfileChunkOutput { } } +#[derive(Debug)] +pub struct ProcessedProfileChunk { + pub item: Item, + /// Raw binary profile blob. The `platform` field describes the format, e.g. Perfetto. + pub raw_profile: Option, +} + +impl Counted for ProcessedProfileChunk { + fn quantities(&self) -> Quantities { + self.item.quantities() + } +} + /// Serialized profile chunks extracted from an envelope. #[derive(Debug)] pub struct SerializedProfileChunks { /// Original envelope headers. pub headers: EnvelopeHeaders, /// List of serialized profile chunk items. - pub profile_chunks: Vec, + pub profile_chunks: Vec, } impl Counted for SerializedProfileChunks { @@ -165,7 +200,7 @@ impl Counted for SerializedProfileChunks { let mut backend = 0; for pc in &self.profile_chunks { - match pc.profile_type() { + match pc.item.profile_type() { Some(ProfileType::Ui) => ui += 1, Some(ProfileType::Backend) => backend += 1, None => {} @@ -187,3 +222,133 @@ impl Counted for SerializedProfileChunks { impl CountRateLimited for Managed { type Error = Error; } + +/// Pairs `ProfileChunk` items with their optional `ProfileChunkData` companions. +/// +/// Expects items ordered as they appear in the envelope: each `ProfileChunk` may be +/// followed by a `ProfileChunkData` item containing the raw binary profile. +fn pair_profile_chunks(items: Vec) -> Vec { + let mut metadata_item: Option = None; + let mut binary_item: Option = None; + let mut profile_chunks: Vec = Vec::new(); + + for item in items { + match item.ty() { + ItemType::ProfileChunkData => { + binary_item = Some(item); + } + _ => { + if let Some(meta) = metadata_item.take() { + profile_chunks.push(ProcessedProfileChunk { + item: meta, + raw_profile: binary_item.take().map(|i| i.payload()), + }); + } + metadata_item = Some(item); + } + } + } + if let Some(meta) = metadata_item.take() { + profile_chunks.push(ProcessedProfileChunk { + item: meta, + raw_profile: binary_item.take().map(|i| i.payload()), + }); + } + + profile_chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_chunk_item(payload: &[u8]) -> Item { + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::Json, bytes::Bytes::copy_from_slice(payload)); + item + } + + fn make_data_item(payload: &[u8]) -> Item { + let mut item = Item::new(ItemType::ProfileChunkData); + item.set_payload( + ContentType::OctetStream, + bytes::Bytes::copy_from_slice(payload), + ); + item + } + + #[test] + fn test_pair_single_chunk_without_data() { + let items = vec![make_chunk_item(b"meta1")]; + let result = pair_profile_chunks(items); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].item.payload().as_ref(), b"meta1"); + assert!(result[0].raw_profile.is_none()); + } + + #[test] + fn test_pair_chunk_with_data() { + let items = vec![make_chunk_item(b"meta1"), make_data_item(b"binary1")]; + let result = pair_profile_chunks(items); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].item.payload().as_ref(), b"meta1"); + assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); + } + + #[test] + fn test_pair_multiple_chunks_mixed() { + let items = vec![ + make_chunk_item(b"meta1"), + make_data_item(b"binary1"), + make_chunk_item(b"meta2"), + ]; + let result = pair_profile_chunks(items); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].item.payload().as_ref(), b"meta1"); + assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); + assert_eq!(result[1].item.payload().as_ref(), b"meta2"); + assert!(result[1].raw_profile.is_none()); + } + + #[test] + fn test_pair_multiple_chunks_each_with_data() { + let items = vec![ + make_chunk_item(b"meta1"), + make_data_item(b"binary1"), + make_chunk_item(b"meta2"), + make_data_item(b"binary2"), + ]; + let result = pair_profile_chunks(items); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); + assert_eq!(result[1].raw_profile.as_deref(), Some(b"binary2".as_ref())); + } + + #[test] + fn test_pair_data_before_chunk_is_associated() { + let items = vec![make_data_item(b"binary1"), make_chunk_item(b"meta1")]; + let result = pair_profile_chunks(items); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].item.payload().as_ref(), b"meta1"); + assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); + } + + #[test] + fn test_pair_only_data_produces_nothing() { + let items = vec![make_data_item(b"orphan")]; + let result = pair_profile_chunks(items); + + assert!(result.is_empty()); + } + + #[test] + fn test_pair_empty_items() { + let result = pair_profile_chunks(vec![]); + assert!(result.is_empty()); + } +} diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index 023343d6453..ef830548f38 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -21,7 +21,49 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte profile_chunks.retain( |pc| &mut pc.profile_chunks, - |item, records| -> Result<()> { + |ppc, records| -> Result<()> { + let item = &mut ppc.item; + + if let Some(ref raw_profile) = ppc.raw_profile { + let expanded = relay_profiling::expand_perfetto(raw_profile, &item.payload())?; + if expanded.len() > ctx.config.max_profile_size() { + return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); + } + + let expanded = bytes::Bytes::from(expanded); + let pc = relay_profiling::ProfileChunk::new(expanded.clone())?; + + if item + .profile_type() + .is_some_and(|pt| pt != pc.profile_type()) + { + return Err(relay_profiling::ProfileError::InvalidProfileType.into()); + } + + if item.profile_type().is_none() { + relay_statsd::metric!( + counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, + sdk = sdk + ); + item.set_profile_type(pc.profile_type()); + match pc.profile_type() { + ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), + ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), + } + } + + pc.filter(client_ip, filter_settings, ctx.global_config)?; + + *item = { + let mut new_item = Item::new(ItemType::ProfileChunk); + new_item.set_profile_type(pc.profile_type()); + new_item.set_payload(ContentType::Json, expanded); + new_item + }; + + return Ok(()); + } + let pc = relay_profiling::ProfileChunk::new(item.payload())?; // Validate the item inferred profile type with the one from the payload, @@ -64,10 +106,10 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte } *item = { - let mut item = Item::new(ItemType::ProfileChunk); - item.set_profile_type(pc.profile_type()); - item.set_payload(ContentType::Json, expanded); - item + let mut new_item = Item::new(ItemType::ProfileChunk); + new_item.set_profile_type(pc.profile_type()); + new_item.set_payload(ContentType::Json, expanded); + new_item }; Ok(()) diff --git a/relay-server/src/services/outcome.rs b/relay-server/src/services/outcome.rs index cc7ac4c47f8..0d1f872d050 100644 --- a/relay-server/src/services/outcome.rs +++ b/relay-server/src/services/outcome.rs @@ -716,6 +716,7 @@ impl From<&ItemType> for DiscardItemType { ItemType::Span => Self::Span, ItemType::UserReportV2 => Self::UserReportV2, ItemType::ProfileChunk => Self::ProfileChunk, + ItemType::ProfileChunkData => Self::ProfileChunk, ItemType::Integration => Self::Integration, ItemType::Unknown(_) => Self::Unknown, } diff --git a/relay-server/src/services/processor.rs b/relay-server/src/services/processor.rs index ae88896d6bb..55fdcdf1f5b 100644 --- a/relay-server/src/services/processor.rs +++ b/relay-server/src/services/processor.rs @@ -355,8 +355,12 @@ impl ProcessingGroup { } // Extract profile chunks. - let profile_chunk_items = - envelope.take_items_by(|item| matches!(item.ty(), &ItemType::ProfileChunk)); + let profile_chunk_items = envelope.take_items_by(|item| { + matches!( + item.ty(), + &ItemType::ProfileChunk | &ItemType::ProfileChunkData + ) + }); if !profile_chunk_items.is_empty() { grouped_envelopes.push(( ProcessingGroup::ProfileChunk, diff --git a/relay-server/src/services/processor/event.rs b/relay-server/src/services/processor/event.rs index c756fd92654..b68333e9c36 100644 --- a/relay-server/src/services/processor/event.rs +++ b/relay-server/src/services/processor/event.rs @@ -196,6 +196,7 @@ fn is_duplicate(item: &Item, processing_enabled: bool) -> bool { ItemType::TraceMetric => false, ItemType::Span => false, ItemType::ProfileChunk => false, + ItemType::ProfileChunkData => false, ItemType::Integration => false, // Without knowing more, `Unknown` items are allowed to be repeated diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index 0ec49f1df03..5735e5fbd94 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -154,6 +154,11 @@ pub struct StoreProfileChunk { /// /// Quantities are different for backend and ui profile chunks. pub quantities: Quantities, + /// Raw binary profile blob. The `platform` field describes the format, e.g. Perfetto. + /// + /// Sent alongside the expanded JSON payload because the expansion only extracts a + /// minimum of information; the raw profile is preserved for further processing downstream. + pub raw_profile: Option, } impl Counted for StoreProfileChunk { @@ -722,6 +727,7 @@ impl StoreService { scoping.project_id.to_string(), )]), payload: message.payload, + raw_profile: message.raw_profile, }; self.produce(KafkaTopic::Profiles, KafkaMessage::ProfileChunk(message)) @@ -1515,6 +1521,8 @@ struct ProfileChunkKafkaMessage { #[serde(skip)] headers: BTreeMap, payload: Bytes, + #[serde(skip_serializing_if = "Option::is_none")] + raw_profile: Option, } /// An enum over all possible ingest messages. diff --git a/relay-server/src/utils/rate_limits.rs b/relay-server/src/utils/rate_limits.rs index 3475bf34835..a38a70b6dab 100644 --- a/relay-server/src/utils/rate_limits.rs +++ b/relay-server/src/utils/rate_limits.rs @@ -137,6 +137,7 @@ fn infer_event_category(item: &Item) -> Option { ItemType::TraceMetric => None, ItemType::Span => None, ItemType::ProfileChunk => None, + ItemType::ProfileChunkData => None, ItemType::Integration => None, ItemType::Unknown(_) => None, } @@ -690,6 +691,7 @@ impl Enforcement { | ItemType::MetricBuckets | ItemType::ClientReport | ItemType::UserReportV2 // This is an event type. + | ItemType::ProfileChunkData | ItemType::Unknown(_) => true, } } diff --git a/relay-server/src/utils/sizes.rs b/relay-server/src/utils/sizes.rs index 6afb1c7a20a..1357e50a4cc 100644 --- a/relay-server/src/utils/sizes.rs +++ b/relay-server/src/utils/sizes.rs @@ -81,6 +81,7 @@ pub fn check_envelope_size_limits( None => NO_LIMIT, }, ItemType::ProfileChunk => config.max_profile_size(), + ItemType::ProfileChunkData => config.max_profile_size(), ItemType::Unknown(_) => NO_LIMIT, }; From 16872769c7536c9571a18a8555fa150ce2e037fd Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 25 Feb 2026 09:20:08 +0100 Subject: [PATCH 02/28] style(profiling): Format Perfetto module with cargo fmt Co-Authored-By: Claude Opus 4.6 --- relay-profiling/src/perfetto/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index e0082478ce5..0234fc4382b 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -1280,7 +1280,10 @@ mod tests { let t0 = data.samples[0].timestamp.to_f64(); let t1 = data.samples[1].timestamp.to_f64(); let t2 = data.samples[2].timestamp.to_f64(); - assert!(t0 < t1 && t1 < t2, "expected sorted timestamps: {t0}, {t1}, {t2}"); + assert!( + t0 < t1 && t1 < t2, + "expected sorted timestamps: {t0}, {t1}, {t2}" + ); // The gap between t1 and t2 should be ~5ms (the -5ms sample comes before the +20ms one). let gap = t2 - t1; assert!((gap - 0.005).abs() < 0.001, "expected ~5ms gap, got {gap}"); From 2cbf111a465440004e7086ac2507e0b956e167c1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 25 Feb 2026 12:55:22 +0100 Subject: [PATCH 03/28] fix(profiling): Remove private intra-doc link in expand_perfetto Co-Authored-By: Claude Opus 4.6 --- relay-profiling/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 780e8910443..191d4584053 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -339,7 +339,7 @@ impl ProfileChunk { /// Expands a binary Perfetto trace into a Sample v2 profile chunk. /// -/// Decodes the protobuf trace, converts it into the internal [`sample::v2`] format, +/// Decodes the protobuf trace, converts it into the internal Sample v2 format, /// merges the provided JSON `metadata_json` (containing platform, environment, etc.), /// and returns the serialized JSON profile chunk ready for ingestion. pub fn expand_perfetto( From e67fa5887258c84f643a9b0ef532bec7f4b8c9b1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Mar 2026 14:41:14 +0100 Subject: [PATCH 04/28] Extend profile chunk to support blob attachment via meta_length --- relay-profiling/src/error.rs | 6 - relay-profiling/src/lib.rs | 7 +- relay-profiling/src/outcomes.rs | 3 - relay-profiling/src/perfetto/mod.rs | 15 +- relay-profiling/src/sample/v2.rs | 2 + relay-server/src/envelope/item.rs | 10 +- .../src/processing/profile_chunks/mod.rs | 242 +++++++----------- .../src/processing/profile_chunks/process.rs | 130 +++++++--- relay-server/src/services/outcome.rs | 1 - relay-server/src/services/processor.rs | 8 +- relay-server/src/services/processor/event.rs | 1 - relay-server/src/services/store.rs | 7 +- relay-server/src/utils/rate_limits.rs | 2 - relay-server/src/utils/sizes.rs | 1 - 14 files changed, 204 insertions(+), 231 deletions(-) diff --git a/relay-profiling/src/error.rs b/relay-profiling/src/error.rs index b0dcf06491a..7bc716195b1 100644 --- a/relay-profiling/src/error.rs +++ b/relay-profiling/src/error.rs @@ -40,12 +40,6 @@ pub enum ProfileError { DurationIsTooLong, #[error("duration is zero")] DurationIsZero, - #[error("invalid protobuf")] - InvalidProtobuf, - #[error("no profile samples in trace")] - NoProfileSamplesInTrace, - #[error("missing clock snapshot in perfetto trace")] - MissingClockSnapshot, #[error("filtered profile")] Filtered(FilterStatKey), #[error(transparent)] diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 8e2f4cef547..8069ec4b3c2 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -101,7 +101,7 @@ impl ProfileType { /// pub fn from_platform(platform: &str) -> Self { match platform { - "cocoa" | "android" | "javascript" | "perfetto" => Self::Ui, + "cocoa" | "android" | "javascript" => Self::Ui, _ => Self::Backend, } } @@ -453,7 +453,8 @@ mod tests { "version": "2", "chunk_id": "0432a0a4c25f4697bf9f0a2fcbe6a814", "profiler_id": "4d229f1d3807421ba62a5f8bc295d836", - "platform": "perfetto", + "platform": "android", + "content_type": "perfetto", "client_sdk": {"name": "sentry-android", "version": "1.0"}, }); let metadata_bytes = serde_json::to_vec(&metadata_json).unwrap(); @@ -462,7 +463,7 @@ mod tests { assert!(result.is_ok(), "expand_perfetto failed: {result:?}"); let output: sample::v2::ProfileChunk = serde_json::from_slice(&result.unwrap()).unwrap(); - assert_eq!(output.metadata.platform, "perfetto"); + assert_eq!(output.metadata.platform, "android"); assert!(!output.profile.samples.is_empty()); assert!(!output.profile.frames.is_empty()); assert!( diff --git a/relay-profiling/src/outcomes.rs b/relay-profiling/src/outcomes.rs index e6149bbdf93..e5cdb6fb0a2 100644 --- a/relay-profiling/src/outcomes.rs +++ b/relay-profiling/src/outcomes.rs @@ -20,8 +20,5 @@ pub fn discard_reason(err: &ProfileError) -> &'static str { ProfileError::DurationIsZero => "profiling_duration_is_zero", ProfileError::Filtered(_) => "profiling_filtered", ProfileError::InvalidBuildID(_) => "invalid_build_id", - ProfileError::InvalidProtobuf => "profiling_invalid_protobuf", - ProfileError::NoProfileSamplesInTrace => "profiling_no_profile_samples_in_trace", - ProfileError::MissingClockSnapshot => "profiling_missing_clock_snapshot", } } diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 0234fc4382b..19edb2ee2f4 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -143,7 +143,8 @@ struct FrameKey { /// Converts a Perfetto binary trace into Sample v2 [`ProfileData`] and debug images. pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), ProfileError> { - let trace = proto::Trace::decode(perfetto_bytes).map_err(|_| ProfileError::InvalidProtobuf)?; + let trace = + proto::Trace::decode(perfetto_bytes).map_err(|_| ProfileError::InvalidSampledProfile)?; let mut tables_by_seq: HashMap = HashMap::new(); let mut thread_meta: BTreeMap = BTreeMap::new(); @@ -224,10 +225,10 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } if raw_samples.is_empty() { - return Err(ProfileError::NoProfileSamplesInTrace); + return Err(ProfileError::NotEnoughSamples); } - let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::MissingClockSnapshot)?; + let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::InvalidSampledProfile)?; raw_samples.sort_by_key(|s| s.0); @@ -304,7 +305,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } if samples.is_empty() { - return Err(ProfileError::NoProfileSamplesInTrace); + return Err(ProfileError::NotEnoughSamples); } // Build debug images from referenced native mappings. @@ -670,13 +671,13 @@ mod tests { let trace = proto::Trace { packet: vec![] }; let bytes = trace.encode_to_vec(); let result = convert(&bytes); - assert!(matches!(result, Err(ProfileError::NoProfileSamplesInTrace))); + assert!(matches!(result, Err(ProfileError::NotEnoughSamples))); } #[test] fn test_convert_invalid_protobuf() { let result = convert(b"not a valid protobuf"); - assert!(matches!(result, Err(ProfileError::InvalidProtobuf))); + assert!(matches!(result, Err(ProfileError::InvalidSampledProfile))); } #[test] @@ -702,7 +703,7 @@ mod tests { }; let bytes = trace.encode_to_vec(); let result = convert(&bytes); - assert!(matches!(result, Err(ProfileError::MissingClockSnapshot))); + assert!(matches!(result, Err(ProfileError::InvalidSampledProfile))); } #[test] diff --git a/relay-profiling/src/sample/v2.rs b/relay-profiling/src/sample/v2.rs index a5b0d2119b4..efd4c9a5734 100644 --- a/relay-profiling/src/sample/v2.rs +++ b/relay-profiling/src/sample/v2.rs @@ -36,6 +36,8 @@ pub struct ProfileMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub environment: Option, pub platform: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, pub release: Option, pub client_sdk: ClientSdk, diff --git a/relay-server/src/envelope/item.rs b/relay-server/src/envelope/item.rs index 50754e6b847..a2443f1f57d 100644 --- a/relay-server/src/envelope/item.rs +++ b/relay-server/src/envelope/item.rs @@ -173,7 +173,6 @@ impl Item { (DataCategory::Span, item_count), (DataCategory::SpanIndexed, item_count), ], - ItemType::ProfileChunkData => smallvec![], ItemType::Integration => match self.integration() { Some(Integration::Logs(LogsIntegration::OtelV1 { .. })) => smallvec![ (DataCategory::LogByte, self.len().max(1)), @@ -662,8 +661,7 @@ impl Item { | ItemType::Nel | ItemType::Log | ItemType::TraceMetric - | ItemType::ProfileChunk - | ItemType::ProfileChunkData => false, + | ItemType::ProfileChunk => false, // For now integrations can not create events, we may need to revisit this in the // future and break down different types of integrations here, similar to attachments. @@ -702,7 +700,6 @@ impl Item { ItemType::Log => false, ItemType::TraceMetric => false, ItemType::ProfileChunk => false, - ItemType::ProfileChunkData => false, ItemType::Integration => false, // Since this Relay cannot interpret the semantics of this item, it does not know @@ -799,8 +796,6 @@ pub enum ItemType { UserReportV2, /// ProfileChunk is a chunk of a profiling session. ProfileChunk, - /// Binary profile payload (e.g. Perfetto trace) accompanying a ProfileChunk. - ProfileChunkData, /// Integrations are a vendor specific set of endpoints providing integrations with external /// systems, standards and vendors. /// @@ -860,7 +855,6 @@ impl ItemType { Self::TraceMetric => "trace_metric", Self::Span => "span", Self::ProfileChunk => "profile_chunk", - Self::ProfileChunkData => "profile_chunk_data", Self::Integration => "integration", Self::Unknown(_) => "unknown", } @@ -921,7 +915,6 @@ impl ItemType { ItemType::Span => true, ItemType::UserReportV2 => false, ItemType::ProfileChunk => true, - ItemType::ProfileChunkData => false, ItemType::Integration => false, ItemType::Unknown(_) => true, } @@ -966,7 +959,6 @@ impl std::str::FromStr for ItemType { // "profile_chunk_ui" is to be treated as an alias for `ProfileChunk` // because Android 8.10.0 and 8.11.0 is sending it as the item type. "profile_chunk_ui" => Self::ProfileChunk, - "profile_chunk_data" => Self::ProfileChunkData, "integration" => Self::Integration, other => Self::Unknown(other.to_owned()), }) diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 69c4fc46975..7409405a2fe 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -4,7 +4,7 @@ use relay_profiling::ProfileType; use relay_quotas::{DataCategory, RateLimits}; use crate::Envelope; -use crate::envelope::{ContentType, EnvelopeHeaders, Item, ItemType, Items}; +use crate::envelope::{EnvelopeHeaders, Item, ItemType, Items}; use crate::managed::{Counted, Managed, ManagedEnvelope, ManagedResult as _, Quantities, Rejected}; use crate::processing::{self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter}; use crate::services::outcome::{DiscardReason, Outcome}; @@ -80,22 +80,11 @@ impl processing::Processor for ProfileChunksProcessor { &self, envelope: &mut ManagedEnvelope, ) -> Option> { - let items = envelope + let profile_chunks = envelope .envelope_mut() - .take_items_by(|item| { - matches!( - *item.ty(), - ItemType::ProfileChunk | ItemType::ProfileChunkData - ) - }) + .take_items_by(|item| matches!(*item.ty(), ItemType::ProfileChunk)) .into_vec(); - if items.is_empty() { - return None; - } - - let profile_chunks = pair_profile_chunks(items); - if profile_chunks.is_empty() { return None; } @@ -134,18 +123,8 @@ impl Forward for ProfileChunkOutput { _: processing::ForwardContext<'_>, ) -> Result>, Rejected<()>> { let Self(profile_chunks) = self; - Ok(profile_chunks.map(|pc, _| { - let mut items: Vec = Vec::new(); - for ppc in pc.profile_chunks { - items.push(ppc.item); - if let Some(raw) = ppc.raw_profile { - let mut data_item = Item::new(ItemType::ProfileChunkData); - data_item.set_payload(ContentType::OctetStream, raw); - items.push(data_item); - } - } - Envelope::from_parts(pc.headers, Items::from_vec(items)) - })) + Ok(profile_chunks + .map(|pc, _| Envelope::from_parts(pc.headers, Items::from_vec(pc.profile_chunks)))) } #[cfg(feature = "processing")] @@ -159,12 +138,15 @@ impl Forward for ProfileChunkOutput { let Self(profile_chunks) = self; let retention_days = ctx.event_retention().standard; - for ppc in profile_chunks.split(|pc| pc.profile_chunks) { - s.send_to_store(ppc.map(|ppc, _| StoreProfileChunk { + for item in profile_chunks.split(|pc| pc.profile_chunks) { + let (kafka_payload, raw_profile, raw_profile_content_type) = split_item_payload(&item); + + s.send_to_store(item.map(|item, _| StoreProfileChunk { retention_days, - payload: ppc.item.payload(), - quantities: ppc.item.quantities(), - raw_profile: ppc.raw_profile, + payload: kafka_payload, + quantities: item.quantities(), + raw_profile, + raw_profile_content_type, })); } @@ -172,17 +154,40 @@ impl Forward for ProfileChunkOutput { } } -#[derive(Debug)] -pub struct ProcessedProfileChunk { - pub item: Item, - /// Raw binary profile blob. The `platform` field describes the format, e.g. Perfetto. - pub raw_profile: Option, -} - -impl Counted for ProcessedProfileChunk { - fn quantities(&self) -> Quantities { - self.item.quantities() +/// Splits a profile chunk item payload into its constituent parts. +/// +/// For compound items (those with a `meta_length` header), the payload is +/// `[expanded JSON][raw binary]`. Returns `(kafka_payload, raw_profile, content_type)`. +/// +/// For plain items, returns `(full_payload, None, None)`. +#[cfg_attr(not(feature = "processing"), allow(dead_code))] +fn split_item_payload(item: &Item) -> (bytes::Bytes, Option, Option) { + let payload = item.payload(); + + let Some(meta_length) = item.meta_length() else { + return (payload, None, None); + }; + + let meta_length = meta_length as usize; + let Some((meta, body)) = payload.split_at_checked(meta_length) else { + return (payload, None, None); + }; + + if body.is_empty() { + return (payload.slice_ref(meta), None, None); } + + // After processing, the meta portion is the expanded JSON payload. + // The content_type is read from the expanded JSON's `content_type` field. + let content_type = serde_json::from_slice::(meta) + .ok() + .and_then(|v| v.get("content_type")?.as_str().map(|s| s.to_owned())); + + ( + payload.slice_ref(meta), + Some(payload.slice_ref(body)), + content_type, + ) } /// Serialized profile chunks extracted from an envelope. @@ -191,7 +196,7 @@ pub struct SerializedProfileChunks { /// Original envelope headers. pub headers: EnvelopeHeaders, /// List of serialized profile chunk items. - pub profile_chunks: Vec, + pub profile_chunks: Vec, } impl Counted for SerializedProfileChunks { @@ -200,7 +205,7 @@ impl Counted for SerializedProfileChunks { let mut backend = 0; for pc in &self.profile_chunks { - match pc.item.profile_type() { + match pc.profile_type() { Some(ProfileType::Ui) => ui += 1, Some(ProfileType::Backend) => backend += 1, None => {} @@ -223,132 +228,71 @@ impl CountRateLimited for Managed { type Error = Error; } -/// Pairs `ProfileChunk` items with their optional `ProfileChunkData` companions. -/// -/// Expects items ordered as they appear in the envelope: each `ProfileChunk` may be -/// followed by a `ProfileChunkData` item containing the raw binary profile. -fn pair_profile_chunks(items: Vec) -> Vec { - let mut metadata_item: Option = None; - let mut binary_item: Option = None; - let mut profile_chunks: Vec = Vec::new(); - - for item in items { - match item.ty() { - ItemType::ProfileChunkData => { - binary_item = Some(item); - } - _ => { - if let Some(meta) = metadata_item.take() { - profile_chunks.push(ProcessedProfileChunk { - item: meta, - raw_profile: binary_item.take().map(|i| i.payload()), - }); - } - metadata_item = Some(item); - } - } - } - if let Some(meta) = metadata_item.take() { - profile_chunks.push(ProcessedProfileChunk { - item: meta, - raw_profile: binary_item.take().map(|i| i.payload()), - }); - } - - profile_chunks -} - #[cfg(test)] mod tests { + use crate::envelope::ContentType; + use super::*; - fn make_chunk_item(payload: &[u8]) -> Item { + fn make_chunk_item(meta: &[u8]) -> Item { let mut item = Item::new(ItemType::ProfileChunk); - item.set_payload(ContentType::Json, bytes::Bytes::copy_from_slice(payload)); - item - } - - fn make_data_item(payload: &[u8]) -> Item { - let mut item = Item::new(ItemType::ProfileChunkData); - item.set_payload( - ContentType::OctetStream, - bytes::Bytes::copy_from_slice(payload), - ); + item.set_payload(ContentType::Json, bytes::Bytes::copy_from_slice(meta)); item } - #[test] - fn test_pair_single_chunk_without_data() { - let items = vec![make_chunk_item(b"meta1")]; - let result = pair_profile_chunks(items); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].item.payload().as_ref(), b"meta1"); - assert!(result[0].raw_profile.is_none()); - } + fn make_compound_item(meta: &[u8], body: &[u8]) -> Item { + let meta_length = meta.len(); + let mut payload = bytes::BytesMut::with_capacity(meta_length + body.len()); + payload.extend_from_slice(meta); + payload.extend_from_slice(body); - #[test] - fn test_pair_chunk_with_data() { - let items = vec![make_chunk_item(b"meta1"), make_data_item(b"binary1")]; - let result = pair_profile_chunks(items); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].item.payload().as_ref(), b"meta1"); - assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); - } - - #[test] - fn test_pair_multiple_chunks_mixed() { - let items = vec![ - make_chunk_item(b"meta1"), - make_data_item(b"binary1"), - make_chunk_item(b"meta2"), - ]; - let result = pair_profile_chunks(items); - - assert_eq!(result.len(), 2); - assert_eq!(result[0].item.payload().as_ref(), b"meta1"); - assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); - assert_eq!(result[1].item.payload().as_ref(), b"meta2"); - assert!(result[1].raw_profile.is_none()); + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::OctetStream, payload.freeze()); + item.set_meta_length(meta_length as u32); + item } #[test] - fn test_pair_multiple_chunks_each_with_data() { - let items = vec![ - make_chunk_item(b"meta1"), - make_data_item(b"binary1"), - make_chunk_item(b"meta2"), - make_data_item(b"binary2"), - ]; - let result = pair_profile_chunks(items); - - assert_eq!(result.len(), 2); - assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); - assert_eq!(result[1].raw_profile.as_deref(), Some(b"binary2".as_ref())); + fn test_split_plain_chunk() { + let item = make_chunk_item(b"{}"); + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), b"{}"); + assert!(raw.is_none()); + assert!(ct.is_none()); } #[test] - fn test_pair_data_before_chunk_is_associated() { - let items = vec![make_data_item(b"binary1"), make_chunk_item(b"meta1")]; - let result = pair_profile_chunks(items); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].item.payload().as_ref(), b"meta1"); - assert_eq!(result[0].raw_profile.as_deref(), Some(b"binary1".as_ref())); + fn test_split_compound_chunk() { + let meta = br#"{"content_type":"perfetto"}"#; + let body = b"binary-data"; + let item = make_compound_item(meta, body); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), meta.as_ref()); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + assert_eq!(ct.as_deref(), Some("perfetto")); } #[test] - fn test_pair_only_data_produces_nothing() { - let items = vec![make_data_item(b"orphan")]; - let result = pair_profile_chunks(items); - - assert!(result.is_empty()); + fn test_split_compound_no_content_type() { + let meta = b"{}"; + let body = b"binary-data"; + let item = make_compound_item(meta, body); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), b"{}"); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + assert!(ct.is_none()); } #[test] - fn test_pair_empty_items() { - let result = pair_profile_chunks(vec![]); - assert!(result.is_empty()); + fn test_split_compound_empty_body() { + let meta = br#"{"content_type":"perfetto"}"#; + let item = make_compound_item(meta, b""); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), meta.as_ref()); + assert!(raw.is_none()); + assert!(ct.is_none()); } } diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index 2e15ac2366b..a7d369ab35e 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -21,47 +21,17 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte profile_chunks.retain( |pc| &mut pc.profile_chunks, - |ppc, records| -> Result<()> { - let item = &mut ppc.item; - - if let Some(ref raw_profile) = ppc.raw_profile { - let expanded = relay_profiling::expand_perfetto(raw_profile, &item.payload())?; - if expanded.len() > ctx.config.max_profile_size() { - return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); - } - - let expanded = bytes::Bytes::from(expanded); - let pc = relay_profiling::ProfileChunk::new(expanded.clone())?; - - if item - .profile_type() - .is_some_and(|pt| pt != pc.profile_type()) - { - return Err(relay_profiling::ProfileError::InvalidProfileType.into()); - } - - if item.profile_type().is_none() { - relay_statsd::metric!( - counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, - sdk = sdk - ); - item.set_profile_type(pc.profile_type()); - match pc.profile_type() { - ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), - ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), - } - } - - pc.filter(client_ip, filter_settings, ctx.global_config)?; - - *item = { - let mut new_item = Item::new(ItemType::ProfileChunk); - new_item.set_profile_type(pc.profile_type()); - new_item.set_payload(ContentType::Json, expanded); - new_item - }; - - return Ok(()); + |item, records| -> Result<()> { + if let Some(meta_length) = item.meta_length() { + return process_compound_item( + item, + meta_length, + sdk, + client_ip, + filter_settings, + ctx, + records, + ); } let pc = relay_profiling::ProfileChunk::new(item.payload())?; @@ -108,7 +78,7 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte *item = { let mut new_item = Item::new(ItemType::ProfileChunk); - new_item.set_profile_type(pc.profile_type()); + new_item.set_platform(pc.platform().to_owned()); new_item.set_payload(ContentType::Json, expanded); new_item }; @@ -117,3 +87,79 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte }, ); } + +/// Processes a compound profile chunk item (JSON metadata + binary blob). +/// +/// The item payload is `[JSON metadata bytes][binary blob bytes]`, split at `meta_length`. +/// After expansion, the item is rebuilt with `[expanded JSON][raw binary]` and an updated +/// `meta_length`, so that `forward_store` can still extract the raw profile. +fn process_compound_item( + item: &mut Item, + meta_length: u32, + sdk: &str, + client_ip: Option, + filter_settings: &relay_filter::ProjectFiltersConfig, + ctx: Context<'_>, + records: &mut crate::managed::RecordKeeper, +) -> Result<()> { + let payload = item.payload(); + let meta_length = meta_length as usize; + + let Some((meta_json, raw_profile)) = payload.split_at_checked(meta_length) else { + return Err(relay_profiling::ProfileError::InvalidSampledProfile.into()); + }; + + let content_type = serde_json::from_slice::(meta_json) + .ok() + .and_then(|v| v.get("content_type")?.as_str().map(|s| s.to_owned())); + + match content_type.as_deref() { + Some("perfetto") => {} + _ => return Err(relay_profiling::ProfileError::PlatformNotSupported.into()), + } + + let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; + if expanded.len() > ctx.config.max_profile_size() { + return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); + } + + let expanded = bytes::Bytes::from(expanded); + let pc = relay_profiling::ProfileChunk::new(expanded.clone())?; + + if item + .profile_type() + .is_some_and(|pt| pt != pc.profile_type()) + { + return Err(relay_profiling::ProfileError::InvalidProfileType.into()); + } + + if item.profile_type().is_none() { + relay_statsd::metric!( + counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, + sdk = sdk + ); + item.set_platform(pc.platform().to_owned()); + match pc.profile_type() { + ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), + ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), + } + } + + pc.filter(client_ip, filter_settings, ctx.global_config)?; + + // Rebuild the compound payload: [expanded JSON][raw binary]. + // This preserves the raw profile for downstream extraction in forward_store. + let mut compound = bytes::BytesMut::with_capacity(expanded.len() + raw_profile.len()); + compound.extend_from_slice(&expanded); + compound.extend_from_slice(raw_profile); + + *item = { + let mut new_item = Item::new(ItemType::ProfileChunk); + new_item.set_platform(pc.platform().to_owned()); + new_item.set_payload(ContentType::Json, compound.freeze()); + new_item.set_meta_length(expanded.len() as u32); + new_item + }; + + Ok(()) +} diff --git a/relay-server/src/services/outcome.rs b/relay-server/src/services/outcome.rs index c0fc8e603a3..914e2e8a167 100644 --- a/relay-server/src/services/outcome.rs +++ b/relay-server/src/services/outcome.rs @@ -708,7 +708,6 @@ impl From<&ItemType> for DiscardItemType { ItemType::Span => Self::Span, ItemType::UserReportV2 => Self::UserReportV2, ItemType::ProfileChunk => Self::ProfileChunk, - ItemType::ProfileChunkData => Self::ProfileChunk, ItemType::Integration => Self::Integration, ItemType::Unknown(_) => Self::Unknown, } diff --git a/relay-server/src/services/processor.rs b/relay-server/src/services/processor.rs index bab062721bf..d0d6c33cc27 100644 --- a/relay-server/src/services/processor.rs +++ b/relay-server/src/services/processor.rs @@ -355,12 +355,8 @@ impl ProcessingGroup { } // Extract profile chunks. - let profile_chunk_items = envelope.take_items_by(|item| { - matches!( - item.ty(), - &ItemType::ProfileChunk | &ItemType::ProfileChunkData - ) - }); + let profile_chunk_items = + envelope.take_items_by(|item| matches!(item.ty(), &ItemType::ProfileChunk)); if !profile_chunk_items.is_empty() { grouped_envelopes.push(( ProcessingGroup::ProfileChunk, diff --git a/relay-server/src/services/processor/event.rs b/relay-server/src/services/processor/event.rs index 4548b12b2fa..1f5fd0a67c9 100644 --- a/relay-server/src/services/processor/event.rs +++ b/relay-server/src/services/processor/event.rs @@ -196,7 +196,6 @@ fn is_duplicate(item: &Item, processing_enabled: bool) -> bool { ItemType::TraceMetric => false, ItemType::Span => false, ItemType::ProfileChunk => false, - ItemType::ProfileChunkData => false, ItemType::Integration => false, // Without knowing more, `Unknown` items are allowed to be repeated diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index 94f61ae746f..b1b8fb771b1 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -148,11 +148,13 @@ pub struct StoreProfileChunk { /// /// Quantities are different for backend and ui profile chunks. pub quantities: Quantities, - /// Raw binary profile blob. The `platform` field describes the format, e.g. Perfetto. + /// Raw binary profile blob (e.g. Perfetto trace). /// /// Sent alongside the expanded JSON payload because the expansion only extracts a /// minimum of information; the raw profile is preserved for further processing downstream. pub raw_profile: Option, + /// Content type of `raw_profile` (e.g. `"perfetto"`). + pub raw_profile_content_type: Option, } impl Counted for StoreProfileChunk { @@ -711,6 +713,7 @@ impl StoreService { )]), payload: message.payload, raw_profile: message.raw_profile, + raw_profile_content_type: message.raw_profile_content_type, }; self.produce(KafkaTopic::Profiles, KafkaMessage::ProfileChunk(message)) @@ -1415,6 +1418,8 @@ struct ProfileChunkKafkaMessage { payload: Bytes, #[serde(skip_serializing_if = "Option::is_none")] raw_profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + raw_profile_content_type: Option, } /// An enum over all possible ingest messages. diff --git a/relay-server/src/utils/rate_limits.rs b/relay-server/src/utils/rate_limits.rs index 4a0bb41f03c..5ab3091194b 100644 --- a/relay-server/src/utils/rate_limits.rs +++ b/relay-server/src/utils/rate_limits.rs @@ -138,7 +138,6 @@ fn infer_event_category(item: &Item) -> Option { ItemType::TraceMetric => None, ItemType::Span => None, ItemType::ProfileChunk => None, - ItemType::ProfileChunkData => None, ItemType::Integration => None, ItemType::Unknown(_) => None, } @@ -752,7 +751,6 @@ impl Enforcement { | ItemType::MetricBuckets | ItemType::ClientReport | ItemType::UserReportV2 // This is an event type. - | ItemType::ProfileChunkData | ItemType::Unknown(_) => true, } } diff --git a/relay-server/src/utils/sizes.rs b/relay-server/src/utils/sizes.rs index d347d983fbe..ce591ea2496 100644 --- a/relay-server/src/utils/sizes.rs +++ b/relay-server/src/utils/sizes.rs @@ -81,7 +81,6 @@ pub fn check_envelope_size_limits( None => NO_LIMIT, }, ItemType::ProfileChunk => config.max_profile_size(), - ItemType::ProfileChunkData => config.max_profile_size(), ItemType::Unknown(_) => NO_LIMIT, }; From c4fa16e44783fbc393f04162e784f14beab4a077 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 10 Mar 2026 07:38:06 +0100 Subject: [PATCH 05/28] Improve code generation script and docs --- relay-profiling/protos/README.md | 29 ++++--------- relay-profiling/protos/generate.sh | 59 +++++++++++++++++++++++++++ relay-profiling/src/perfetto/proto.rs | 14 +++++++ 3 files changed, 80 insertions(+), 22 deletions(-) create mode 100755 relay-profiling/protos/generate.sh diff --git a/relay-profiling/protos/README.md b/relay-profiling/protos/README.md index 87ca5fac29b..02536946ca4 100644 --- a/relay-profiling/protos/README.md +++ b/relay-profiling/protos/README.md @@ -8,26 +8,11 @@ The generated Rust code is checked in at `../src/perfetto/proto.rs`. ## Regenerating -1. Install protoc: https://github.com/protocolbuffers/protobuf/releases -2. Add to `Cargo.toml` under `[build-dependencies]`: - ```toml - prost-build = { workspace = true } - ``` -3. Create a `build.rs` in the `relay-profiling` crate root: - ```rust - use std::io::Result; - use std::path::PathBuf; +Prerequisites: +- `protoc`: https://github.com/protocolbuffers/protobuf/releases (or `brew install protobuf`) +- `protoc-gen-prost`: `cargo install protoc-gen-prost` - fn main() -> Result<()> { - let proto_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("protos"); - let proto_file = proto_dir.join("perfetto_trace.proto"); - prost_build::compile_protos(&[&proto_file], &[&proto_dir])?; - Ok(()) - } - ``` -4. Run: `cargo build -p relay-profiling` -5. Copy the output to the checked-in file: - ```sh - cp target/debug/build/relay-profiling-*/out/perfetto.protos.rs relay-profiling/src/perfetto/proto.rs - ``` -6. Remove the `build.rs` and the `prost-build` dependency. +Then run: +```sh +./relay-profiling/protos/generate.sh +``` diff --git a/relay-profiling/protos/generate.sh b/relay-profiling/protos/generate.sh new file mode 100755 index 00000000000..dbfce8e5f41 --- /dev/null +++ b/relay-profiling/protos/generate.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Regenerates the checked-in Rust protobuf bindings for Perfetto trace types +# using protoc with the protoc-gen-prost plugin. +# +# Usage: +# ./relay-profiling/protos/generate.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROTO_FILE="$SCRIPT_DIR/perfetto_trace.proto" +OUTPUT_FILE="$SCRIPT_DIR/../src/perfetto/proto.rs" + +if ! command -v protoc &>/dev/null; then + echo "error: protoc is not installed." >&2 + echo " Install it from https://github.com/protocolbuffers/protobuf/releases" >&2 + echo " or: brew install protobuf" >&2 + exit 1 +fi +echo "Using protoc: $(command -v protoc) ($(protoc --version))" + +if ! command -v protoc-gen-prost &>/dev/null; then + echo "error: protoc-gen-prost is not installed." >&2 + echo " Install it with: cargo install protoc-gen-prost" >&2 + exit 1 +fi +echo "Using protoc-gen-prost: $(command -v protoc-gen-prost)" + +if [[ ! -f "$PROTO_FILE" ]]; then + echo "error: proto file not found at $PROTO_FILE" >&2 + exit 1 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +echo "Generating Rust bindings..." +protoc \ + --prost_out="$TMPDIR" \ + --proto_path="$SCRIPT_DIR" \ + "$PROTO_FILE" + +# protoc-gen-prost mirrors the proto package path in the output directory. +GENERATED=$(find "$TMPDIR" -name '*.rs' -type f | head -1) + +if [[ -z "$GENERATED" || ! -f "$GENERATED" ]]; then + echo "error: no generated .rs file found in $TMPDIR" >&2 + exit 1 +fi + +if [[ ! -s "$GENERATED" ]]; then + echo "error: generated file is empty" >&2 + exit 1 +fi + +cp "$GENERATED" "$OUTPUT_FILE" +echo "Updated $OUTPUT_FILE" +echo "Done." diff --git a/relay-profiling/src/perfetto/proto.rs b/relay-profiling/src/perfetto/proto.rs index 72fc9882d9d..1f3c651e1b0 100644 --- a/relay-profiling/src/perfetto/proto.rs +++ b/relay-profiling/src/perfetto/proto.rs @@ -1,3 +1,4 @@ +// @generated // This file is @generated by prost-build. #[derive(Clone, PartialEq, ::prost::Message)] pub struct Trace { @@ -41,6 +42,8 @@ pub mod trace_packet { PerfSample(super::PerfSample), } } +// --- process tree ------------------------------------------------------------ + #[derive(Clone, PartialEq, ::prost::Message)] pub struct ProcessTree { #[prost(message, repeated, tag = "2")] @@ -58,6 +61,8 @@ pub mod process_tree { pub tgid: ::core::option::Option, } } +// --- clock sync --------------------------------------------------------------- + #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClockSnapshot { #[prost(message, repeated, tag = "1")] @@ -75,6 +80,8 @@ pub mod clock_snapshot { pub timestamp: ::core::option::Option, } } +// --- interned data ----------------------------------------------------------- + #[derive(Clone, PartialEq, ::prost::Message)] pub struct InternedData { #[prost(message, repeated, tag = "5")] @@ -97,6 +104,8 @@ pub struct InternedString { #[prost(bytes = "vec", optional, tag = "2")] pub str: ::core::option::Option<::prost::alloc::vec::Vec>, } +// --- profiling common -------------------------------------------------------- + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct Frame { #[prost(uint64, optional, tag = "1")] @@ -134,6 +143,8 @@ pub struct Callstack { #[prost(uint64, repeated, packed = "false", tag = "2")] pub frame_ids: ::prost::alloc::vec::Vec, } +// --- profiling packets ------------------------------------------------------- + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct PerfSample { #[prost(uint32, optional, tag = "1")] @@ -152,6 +163,8 @@ pub struct StreamingProfilePacket { #[prost(int64, repeated, packed = "false", tag = "2")] pub timestamp_delta_us: ::prost::alloc::vec::Vec, } +// --- track descriptors ------------------------------------------------------- + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct TrackDescriptor { #[prost(uint64, optional, tag = "1")] @@ -168,3 +181,4 @@ pub struct ThreadDescriptor { #[prost(string, optional, tag = "5")] pub thread_name: ::core::option::Option<::prost::alloc::string::String>, } +// @@protoc_insertion_point(module) From 1e6df1f163a830fd03a43145e70009cc85a3176e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 10 Mar 2026 07:38:33 +0100 Subject: [PATCH 06/28] Guard perfetto processing by feature flag --- relay-dynamic-config/src/feature.rs | 8 ++++++++ relay-server/src/processing/profile_chunks/process.rs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/relay-dynamic-config/src/feature.rs b/relay-dynamic-config/src/feature.rs index fce6b8dd61e..7e10d8978c4 100644 --- a/relay-dynamic-config/src/feature.rs +++ b/relay-dynamic-config/src/feature.rs @@ -86,6 +86,14 @@ pub enum Feature { /// Serialized as `organizations:continuous-profiling-beta-ingest`. #[serde(rename = "organizations:continuous-profiling-beta-ingest")] ContinuousProfilingBetaIngest, + /// Enable Perfetto binary trace processing for continuous profiling. + /// + /// When enabled, compound profile chunk items with `content_type: "perfetto"` are + /// expanded from binary Perfetto format into the Sample v2 JSON format. + /// + /// Serialized as `organizations:continuous-profiling-perfetto`. + #[serde(rename = "organizations:continuous-profiling-perfetto")] + ContinuousProfilingPerfetto, /// Enable log ingestion for our log product (this is not internal logging). /// /// Serialized as `organizations:ourlogs-ingestion`. diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index a7d369ab35e..d7be6354761 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -1,3 +1,4 @@ +use relay_dynamic_config::Feature; use relay_profiling::ProfileType; use relay_quotas::DataCategory; @@ -118,6 +119,10 @@ fn process_compound_item( _ => return Err(relay_profiling::ProfileError::PlatformNotSupported.into()), } + if ctx.should_filter(Feature::ContinuousProfilingPerfetto) { + return Err(relay_profiling::ProfileError::PlatformNotSupported.into()); + } + let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; if expanded.len() > ctx.config.max_profile_size() { return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); From 0a58b244780bb4c73aa551ee7725b20c3c5f3b4f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 18 Mar 2026 09:35:39 +0100 Subject: [PATCH 07/28] Address some more PR comments, add missing tests --- relay-dynamic-config/src/feature.rs | 14 ++ relay-profiling/src/lib.rs | 106 ++++++++- .../src/processing/profile_chunks/mod.rs | 42 ++++ .../src/processing/profile_chunks/process.rs | 210 +++++++++++++++++- 4 files changed, 355 insertions(+), 17 deletions(-) diff --git a/relay-dynamic-config/src/feature.rs b/relay-dynamic-config/src/feature.rs index 7e10d8978c4..6f8caae4a49 100644 --- a/relay-dynamic-config/src/feature.rs +++ b/relay-dynamic-config/src/feature.rs @@ -211,4 +211,18 @@ mod tests { r#"["organizations:session-replay"]"# ); } + + #[test] + fn test_continuous_profiling_perfetto_serde() { + // Verify the serialized name matches what Sentry's backend sends. + let serialized = serde_json::to_string(&Feature::ContinuousProfilingPerfetto).unwrap(); + assert_eq!( + serialized, + r#""organizations:continuous-profiling-perfetto""# + ); + + let deserialized: Feature = + serde_json::from_str(r#""organizations:continuous-profiling-perfetto""#).unwrap(); + assert_eq!(deserialized, Feature::ContinuousProfilingPerfetto); + } } diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 8069ec4b3c2..8d89fe266e2 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -355,19 +355,73 @@ impl ProfileChunk { } } +/// The result of expanding a binary Perfetto trace via [`expand_perfetto`]. +/// +/// Carries the serialized Sample v2 JSON payload together with the profile +/// metadata needed downstream (platform, profile type, inbound filtering) so +/// that callers do **not** need to deserialize the payload a second time. +#[derive(Debug)] +pub struct ExpandedPerfettoChunk { + /// Serialized Sample v2 JSON payload, ready for ingestion. + pub payload: Vec, + /// Platform string extracted from the metadata (e.g. `"android"`). + pub platform: String, + /// Release string from the metadata, used for inbound filtering. + release: Option, +} + +impl ExpandedPerfettoChunk { + /// Returns the [`ProfileType`] derived from the platform. + pub fn profile_type(&self) -> ProfileType { + ProfileType::from_platform(&self.platform) + } + + /// Applies inbound filters to the profile chunk. + pub fn filter( + &self, + client_ip: Option, + filter_settings: &ProjectFiltersConfig, + global_config: &GlobalConfig, + ) -> Result<(), ProfileError> { + relay_filter::should_filter(self, client_ip, filter_settings, global_config.filters()) + .map_err(ProfileError::Filtered) + } +} + +impl Filterable for ExpandedPerfettoChunk { + fn release(&self) -> Option<&str> { + self.release.as_deref() + } +} + +impl Getter for ExpandedPerfettoChunk { + fn get_value(&self, path: &str) -> Option> { + match path.strip_prefix("event.")? { + "release" => self.release.as_deref().map(|r| r.into()), + "platform" => Some(self.platform.as_str().into()), + _ => None, + } + } +} + /// Expands a binary Perfetto trace into a Sample v2 profile chunk. /// /// Decodes the protobuf trace, converts it into the internal Sample v2 format, /// merges the provided JSON `metadata_json` (containing platform, environment, etc.), -/// and returns the serialized JSON profile chunk ready for ingestion. +/// and returns an [`ExpandedPerfettoChunk`] with the serialized JSON payload plus +/// the profile metadata needed for downstream processing (platform, profile type, +/// inbound filtering) — avoiding a second JSON deserialization pass in callers. pub fn expand_perfetto( perfetto_bytes: &[u8], metadata_json: &[u8], -) -> Result, ProfileError> { +) -> Result { let d = &mut Deserializer::from_slice(metadata_json); let metadata: sample::v2::ProfileMetadata = serde_path_to_error::deserialize(d).map_err(ProfileError::InvalidJson)?; + let platform = metadata.platform.clone(); + let release = metadata.release.clone(); + let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; let mut chunk = sample::v2::ProfileChunk { measurements: BTreeMap::new(), @@ -377,7 +431,12 @@ pub fn expand_perfetto( chunk.metadata.debug_meta.images.extend(debug_images); chunk.normalize()?; - serde_json::to_vec(&chunk).map_err(|_| ProfileError::CannotSerializePayload) + let payload = serde_json::to_vec(&chunk).map_err(|_| ProfileError::CannotSerializePayload)?; + Ok(ExpandedPerfettoChunk { + payload, + platform, + release, + }) } #[cfg(test)] @@ -462,8 +521,12 @@ mod tests { let result = expand_perfetto(perfetto_bytes, &metadata_bytes); assert!(result.is_ok(), "expand_perfetto failed: {result:?}"); - let output: sample::v2::ProfileChunk = serde_json::from_slice(&result.unwrap()).unwrap(); - assert_eq!(output.metadata.platform, "android"); + let expanded = result.unwrap(); + assert_eq!(expanded.platform, "android"); + assert_eq!(expanded.profile_type(), ProfileType::Ui); + + let output: sample::v2::ProfileChunk = serde_json::from_slice(&expanded.payload).unwrap(); + assert_eq!(output.metadata.platform, expanded.platform); assert!(!output.profile.samples.is_empty()); assert!(!output.profile.frames.is_empty()); assert!( @@ -477,4 +540,37 @@ mod tests { let result = expand_perfetto(b"", b"not json"); assert!(result.is_err()); } + + #[test] + fn test_expand_perfetto_empty_trace() { + // Valid metadata but no profiling samples in the binary → should fail. + let metadata_bytes = serde_json::to_vec(&serde_json::json!({ + "version": "2", + "chunk_id": "0432a0a4c25f4697bf9f0a2fcbe6a814", + "profiler_id": "4d229f1d3807421ba62a5f8bc295d836", + "platform": "android", + "content_type": "perfetto", + "client_sdk": {"name": "sentry-android", "version": "1.0"}, + })) + .unwrap(); + let result = expand_perfetto(b"", &metadata_bytes); + assert!(result.is_err()); + } + + #[test] + fn test_expand_perfetto_missing_required_field() { + // metadata is missing the required `chunk_id` field → deserialization error. + let metadata_bytes = serde_json::to_vec(&serde_json::json!({ + "version": "2", + "profiler_id": "4d229f1d3807421ba62a5f8bc295d836", + "platform": "android", + "client_sdk": {"name": "sentry-android", "version": "1.0"}, + })) + .unwrap(); + let result = expand_perfetto(b"", &metadata_bytes); + assert!( + matches!(result, Err(ProfileError::InvalidJson(_))), + "expected InvalidJson, got {result:?}" + ); + } } diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 7409405a2fe..2c8b9ca3470 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -295,4 +295,46 @@ mod tests { assert!(raw.is_none()); assert!(ct.is_none()); } + + #[test] + fn test_split_compound_meta_length_exceeds_payload() { + // meta_length is set to more bytes than the payload actually contains. + // split_at_checked returns None, so we fall back to the full payload with no split. + let body = b"binary-data"; + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::OctetStream, bytes::Bytes::from(body.as_ref())); + item.set_meta_length(body.len() as u32 + 100); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), body.as_ref()); + assert!(raw.is_none()); + assert!(ct.is_none()); + } + + #[test] + fn test_split_compound_invalid_json_meta() { + // meta portion is not valid JSON; content_type should be None. + let meta = b"not valid json {{{{"; + let body = b"binary-data"; + let item = make_compound_item(meta, body); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), meta.as_ref()); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + assert!(ct.is_none()); + } + + #[test] + fn test_split_compound_zero_meta_length() { + // meta_length = 0: meta slice is empty, entire payload is treated as body. + let body = b"binary-data"; + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::OctetStream, bytes::Bytes::from(body.as_ref())); + item.set_meta_length(0); + + let (payload, raw, ct) = split_item_payload(&item); + assert_eq!(payload.as_ref(), b""); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + assert!(ct.is_none()); + } } diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index d7be6354761..9a787a3d3c6 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -124,16 +124,14 @@ fn process_compound_item( } let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; - if expanded.len() > ctx.config.max_profile_size() { + + if expanded.payload.len() > ctx.config.max_profile_size() { return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); } - let expanded = bytes::Bytes::from(expanded); - let pc = relay_profiling::ProfileChunk::new(expanded.clone())?; - if item .profile_type() - .is_some_and(|pt| pt != pc.profile_type()) + .is_some_and(|pt| pt != expanded.profile_type()) { return Err(relay_profiling::ProfileError::InvalidProfileType.into()); } @@ -143,28 +141,216 @@ fn process_compound_item( counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, sdk = sdk ); - item.set_platform(pc.platform().to_owned()); - match pc.profile_type() { + item.set_platform(expanded.platform.clone()); + match expanded.profile_type() { ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), } } - pc.filter(client_ip, filter_settings, ctx.global_config)?; + expanded.filter(client_ip, filter_settings, ctx.global_config)?; // Rebuild the compound payload: [expanded JSON][raw binary]. // This preserves the raw profile for downstream extraction in forward_store. - let mut compound = bytes::BytesMut::with_capacity(expanded.len() + raw_profile.len()); - compound.extend_from_slice(&expanded); + let platform = expanded.platform; + let expanded_payload = bytes::Bytes::from(expanded.payload); + let mut compound = bytes::BytesMut::with_capacity(expanded_payload.len() + raw_profile.len()); + compound.extend_from_slice(&expanded_payload); compound.extend_from_slice(raw_profile); *item = { let mut new_item = Item::new(ItemType::ProfileChunk); - new_item.set_platform(pc.platform().to_owned()); + new_item.set_platform(platform); new_item.set_payload(ContentType::Json, compound.freeze()); - new_item.set_meta_length(expanded.len() as u32); + new_item.set_meta_length(expanded_payload.len() as u32); new_item }; Ok(()) } + +#[cfg(test)] +mod tests { + use relay_dynamic_config::{Feature, FeatureSet, ProjectConfig}; + + use super::*; + use crate::Envelope; + use crate::envelope::ContentType; + use crate::extractors::RequestMeta; + use crate::managed::Managed; + use crate::processing::Context; + use crate::processing::profile_chunks::SerializedProfileChunks; + use crate::services::projects::project::ProjectInfo; + + const PERFETTO_FIXTURE: &[u8] = include_bytes!( + "../../../../relay-profiling/tests/fixtures/android/perfetto/android.pftrace" + ); + + fn perfetto_meta() -> Vec { + serde_json::json!({ + "version": "2", + "chunk_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "profiler_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "platform": "android", + "content_type": "perfetto", + "client_sdk": {"name": "sentry-android", "version": "1.0"}, + }) + .to_string() + .into_bytes() + } + + fn make_compound_item(meta: &[u8], body: &[u8]) -> Item { + let meta_length = meta.len() as u32; + let mut payload = bytes::BytesMut::new(); + payload.extend_from_slice(meta); + payload.extend_from_slice(body); + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::OctetStream, payload.freeze()); + item.set_meta_length(meta_length); + item + } + + fn make_chunks( + items: Vec, + ) -> ( + Managed, + crate::managed::ManagedTestHandle, + ) { + let dsn = "https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42" + .parse() + .unwrap(); + let envelope = Envelope::from_request(None, RequestMeta::new(dsn)); + let headers = envelope.headers().clone(); + Managed::for_test(SerializedProfileChunks { + headers, + profile_chunks: items, + }) + .build() + } + + /// Runs `process_compound_item` for the single item in `managed` and returns the + /// inner [`SerializedProfileChunks`] after processing, consuming the managed value. + fn run(managed: &mut Managed, ctx: Context<'_>) { + let sdk = ""; + let client_ip = None; + let filter_settings = Default::default(); + managed.retain( + |pc| &mut pc.profile_chunks, + |item, records| -> Result<()> { + let meta_length = item.meta_length().unwrap_or(0); + process_compound_item( + item, + meta_length, + sdk, + client_ip, + &filter_settings, + ctx, + records, + ) + }, + ); + } + + #[test] + fn test_process_compound_unknown_content_type() { + // content_type is not "perfetto" → item is dropped immediately. + let meta = serde_json::json!({ + "version": "2", + "chunk_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "profiler_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "platform": "android", + "content_type": "unknown", + "client_sdk": {"name": "sentry-android", "version": "1.0"}, + }) + .to_string() + .into_bytes(); + let item = make_compound_item(&meta, PERFETTO_FIXTURE); + let (mut managed, _handle) = make_chunks(vec![item]); + + run(&mut managed, Context::for_test()); + + let chunks = managed.accept(|c| c); + assert!(chunks.profile_chunks.is_empty(), "item should be dropped"); + } + + #[test] + fn test_process_compound_feature_flag_disabled() { + // The ContinuousProfilingPerfetto feature is absent → item is dropped. + // Default Context::for_test() uses relay mode = Managed with an empty feature set. + let meta = perfetto_meta(); + let item = make_compound_item(&meta, PERFETTO_FIXTURE); + let (mut managed, _handle) = make_chunks(vec![item]); + + run(&mut managed, Context::for_test()); + + let chunks = managed.accept(|c| c); + assert!( + chunks.profile_chunks.is_empty(), + "item should be dropped when feature flag is absent" + ); + } + + #[test] + fn test_process_compound_meta_length_out_of_bounds() { + // meta_length header is larger than the actual payload → InvalidSampledProfile. + let body = b"some bytes"; + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::OctetStream, bytes::Bytes::from(body.as_ref())); + item.set_meta_length(body.len() as u32 + 100); + let (mut managed, _handle) = make_chunks(vec![item]); + + run(&mut managed, Context::for_test()); + + let chunks = managed.accept(|c| c); + assert!( + chunks.profile_chunks.is_empty(), + "item should be dropped on out-of-bounds meta_length" + ); + } + + #[test] + fn test_process_compound_success() { + // Happy path: valid Perfetto trace + feature enabled → compound payload rebuilt. + let meta = perfetto_meta(); + let item = make_compound_item(&meta, PERFETTO_FIXTURE); + let (mut managed, _handle) = make_chunks(vec![item]); + + let ctx = Context { + project_info: &ProjectInfo { + config: ProjectConfig { + features: FeatureSet::from_iter([ + Feature::ContinuousProfiling, + Feature::ContinuousProfilingPerfetto, + ]), + ..Default::default() + }, + ..Default::default() + }, + ..Context::for_test() + }; + + run(&mut managed, ctx); + + let mut chunks = managed.accept(|c| c); + assert_eq!(chunks.profile_chunks.len(), 1, "item should be retained"); + + let item = chunks.profile_chunks.remove(0); + + // The rebuilt item must carry a meta_length pointing to the expanded JSON. + let meta_length = item + .meta_length() + .expect("rebuilt item must have meta_length"); + assert!(meta_length > 0); + + // The first meta_length bytes must be valid JSON (the expanded Sample v2 profile). + let payload = item.payload(); + let (json_part, raw_part) = payload.split_at(meta_length as usize); + assert!( + serde_json::from_slice::(json_part).is_ok(), + "first meta_length bytes must be valid JSON" + ); + + // The raw binary is the original Perfetto trace preserved verbatim. + assert_eq!(raw_part, PERFETTO_FIXTURE); + } +} From 49f3bd96211ae79bc4019547560d93ebbf41f0e3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 23 Mar 2026 09:56:45 +0100 Subject: [PATCH 08/28] feat(profiling): Use separate intern tables per Perfetto field and infer main thread Separate the shared `strings` intern table into distinct `function_names`, `mapping_paths`, and `build_ids` tables matching the Perfetto spec where each InternedData field has its own ID namespace. Also infer the main thread name when tid equals pid and no explicit name is provided. Co-Authored-By: Claude --- relay-profiling/src/perfetto/mod.rs | 286 ++++++++++++++++++++++++++-- 1 file changed, 273 insertions(+), 13 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 19edb2ee2f4..eff423b9f48 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -72,9 +72,16 @@ fn extract_clock_offset(cs: &proto::ClockSnapshot) -> Option { /// Perfetto traces use interned IDs to avoid repeating large strings and /// structures in every packet. Each trusted packet sequence maintains its /// own set of intern tables that can be cleared on state resets. +/// +/// Per the Perfetto spec, each `InternedData` field constructs its **own** +/// interning index — IDs are scoped per field, not shared across string types. +/// See . #[derive(Default)] struct InternTables { - strings: HashMap, + function_names: HashMap, + mapping_paths: HashMap, + /// Build IDs stored as hex-encoded strings (normalized from raw bytes). + build_ids: HashMap, frames: HashMap, callstacks: HashMap, mappings: HashMap, @@ -82,14 +89,27 @@ struct InternTables { impl InternTables { fn clear(&mut self) { - self.strings.clear(); + self.function_names.clear(); + self.mapping_paths.clear(); + self.build_ids.clear(); self.frames.clear(); self.callstacks.clear(); self.mappings.clear(); } fn merge(&mut self, data: &proto::InternedData) { - for s in data.function_names.iter().chain(data.mapping_paths.iter()) { + for s in &data.function_names { + if let Some(iid) = s.iid { + let value = s + .r#str + .as_deref() + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or("") + .to_owned(); + self.function_names.insert(iid, value); + } + } + for s in &data.mapping_paths { if let Some(iid) = s.iid { let value = s .r#str @@ -97,7 +117,7 @@ impl InternTables { .and_then(|b| std::str::from_utf8(b).ok()) .unwrap_or("") .to_owned(); - self.strings.insert(iid, value); + self.mapping_paths.insert(iid, value); } } // Build IDs are raw bytes in Perfetto traces; normalize to hex for later lookup. @@ -107,7 +127,7 @@ impl InternTables { Some(bytes) if !bytes.is_empty() => HEXLOWER.encode(bytes), _ => String::new(), }; - self.strings.insert(iid, value); + self.build_ids.insert(iid, value); } } for f in &data.frames { @@ -151,6 +171,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), // (timestamp_ns, tid, callstack_iid, sequence_id) let mut raw_samples: Vec<(u64, u32, u64, u32)> = Vec::new(); let mut clock_offset_ns: Option = None; + let mut observed_pid: Option = None; for packet in &trace.packet { let seq_id = trusted_packet_sequence_id(packet); @@ -199,6 +220,9 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), if let Some(callstack_iid) = ps.callstack_iid { let ts = packet.timestamp.unwrap_or(0); let tid = ps.tid.unwrap_or(0); + if observed_pid.is_none() { + observed_pid = ps.pid; + } raw_samples.push((ts, tid, callstack_iid, seq_id)); } } @@ -228,6 +252,19 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), return Err(ProfileError::NotEnoughSamples); } + // On Android/Linux the main thread's tid equals the process pid. + // If the trace didn't include a ProcessTree or TrackDescriptor with a name + // for that thread, label it "main" so the UI can identify it. + if let Some(pid) = observed_pid { + let main_tid = pid.to_string(); + thread_meta + .entry(main_tid) + .or_insert_with(|| ThreadMetadata { + name: Some("main".to_owned()), + priority: None, + }); + } + let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::InvalidSampledProfile)?; raw_samples.sort_by_key(|s| s.0); @@ -256,7 +293,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let function_name = pf .function_name_id - .and_then(|id| tables.strings.get(&id)) + .and_then(|id| tables.function_names.get(&id)) .cloned(); if let Some(mid) = pf.mapping_id { @@ -324,7 +361,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let parts: Vec<&str> = mapping .path_string_ids .iter() - .filter_map(|id| tables.strings.get(id).map(|s| s.as_str())) + .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) .collect(); if parts.is_empty() { continue; @@ -344,7 +381,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let debug_id = mapping .build_id - .and_then(|bid| tables.strings.get(&bid)) + .and_then(|bid| tables.build_ids.get(&bid)) .and_then(|hex_str| build_id_to_debug_id(hex_str)); let Some(debug_id) = debug_id else { @@ -390,7 +427,7 @@ fn build_frame( let parts: Vec<&str> = m .path_string_ids .iter() - .filter_map(|id| tables.strings.get(id).map(|s| s.as_str())) + .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) .collect(); if parts.is_empty() { None @@ -560,6 +597,31 @@ mod tests { } } + fn make_perf_sample_packet_with_pid( + timestamp: u64, + seq_id: u32, + pid: u32, + tid: u32, + callstack_iid: u64, + ) -> proto::TracePacket { + proto::TracePacket { + timestamp: Some(timestamp), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId( + seq_id, + ), + ), + data: Some(Data::PerfSample(proto::PerfSample { + cpu: None, + pid: Some(pid), + tid: Some(tid), + callstack_iid: Some(callstack_iid), + })), + } + } + fn make_interned_data_packet( seq_id: u32, clear_state: bool, @@ -767,10 +829,7 @@ mod tests { 1, true, proto::InternedData { - function_names: vec![ - make_interned_string(1, b"my_func"), - make_interned_string(10, b"libfoo.so"), - ], + function_names: vec![make_interned_string(1, b"my_func")], frames: vec![proto::Frame { iid: Some(1), function_name_id: Some(1), @@ -790,6 +849,7 @@ mod tests { path_string_ids: vec![10], ..Default::default() }], + mapping_paths: vec![make_interned_string(10, b"libfoo.so")], ..Default::default() }, ), @@ -811,6 +871,51 @@ mod tests { assert!(images.is_empty()); } + #[test] + fn test_separate_interning_namespaces() { + // Perfetto uses separate ID namespaces per InternedData field. + // function_names iid=1 and mapping_paths iid=1 must NOT collide. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"my_func")], + mapping_paths: vec![make_interned_string(1, b"libfoo.so")], + frames: vec![proto::Frame { + iid: Some(1), + function_name_id: Some(1), + mapping_id: Some(1), + rel_pc: Some(0x100), + }], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + mappings: vec![proto::Mapping { + iid: Some(1), + start: Some(0x7000), + path_string_ids: vec![1], + ..Default::default() + }], + ..Default::default() + }, + ), + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.frames.len(), 1); + let frame = &data.frames[0]; + // Both use iid=1 but must resolve independently. + assert_eq!(frame.function.as_deref(), Some("my_func")); + assert_eq!(frame.package.as_deref(), Some("libfoo.so")); + } + #[test] fn test_incremental_state_reset() { let trace = proto::Trace { @@ -903,6 +1008,30 @@ mod tests { !images.is_empty(), "expected debug images from native mappings" ); + + // The fixture contains samples from multiple threads. + let thread_ids: std::collections::BTreeSet<&str> = + data.samples.iter().map(|s| s.thread_id.as_str()).collect(); + assert!( + thread_ids.len() > 1, + "expected samples from multiple threads, got: {thread_ids:?}" + ); + + // The fixture has no ProcessTree/TrackDescriptor, but the main thread + // (tid == pid) should still be labeled "main" via pid-based inference. + assert!( + !data.thread_metadata.is_empty(), + "expected main thread metadata from pid inference" + ); + // The lowest tid in PerfSample traces is typically the main thread (tid == pid). + let main_tid = thread_ids.iter().next().unwrap(); + assert_eq!( + data.thread_metadata + .get(*main_tid) + .and_then(|m| m.name.as_deref()), + Some("main"), + "expected main thread to be labeled via pid inference" + ); } #[test] @@ -1209,6 +1338,137 @@ mod tests { ); } + #[test] + fn test_main_thread_inferred_from_pid() { + // When no ProcessTree/TrackDescriptor provides a thread name, the main + // thread (tid == pid) should be labeled "main" automatically. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"doWork")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet_with_pid(1_000_000_000, 1, 100, 100, 1), // main thread + make_perf_sample_packet_with_pid(1_010_000_000, 1, 100, 101, 1), // worker thread + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + // Main thread (tid == pid == 100) should be labeled "main". + assert_eq!( + data.thread_metadata + .get("100") + .and_then(|m| m.name.as_deref()), + Some("main"), + ); + // Worker thread (tid 101) should have no metadata since no name source exists. + assert!(data.thread_metadata.get("101").is_none()); + } + + #[test] + fn test_main_thread_not_overwritten_by_pid_inference() { + // If a ProcessTree already provides a name for the main thread, + // pid-based inference must NOT overwrite it. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + proto::TracePacket { + timestamp: None, + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: None, + data: Some(proto::trace_packet::Data::ProcessTree(proto::ProcessTree { + threads: vec![proto::process_tree::Thread { + tid: Some(100), + name: Some("ui-thread".to_owned()), + tgid: Some(100), + }], + })), + }, + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"doWork")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + make_perf_sample_packet_with_pid(1_000_000_000, 1, 100, 100, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + // The ProcessTree name "ui-thread" must be preserved, not replaced with "main". + assert_eq!( + data.thread_metadata + .get("100") + .and_then(|m| m.name.as_deref()), + Some("ui-thread"), + ); + } + + #[test] + fn test_main_thread_no_pid_for_streaming_packets() { + // StreamingProfilePacket doesn't carry a pid, so no main thread inference + // should occur. thread_metadata should be empty. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + proto::TracePacket { + timestamp: Some(2_000_000_000), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::StreamingProfilePacket( + proto::StreamingProfilePacket { + callstack_iid: vec![1], + timestamp_delta_us: vec![0], + }, + )), + }, + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert!( + data.thread_metadata.is_empty(), + "expected no thread metadata for streaming packets without ProcessTree" + ); + } + #[test] fn test_exceeds_max_samples() { let mut packets = vec![ From 5c6e4575f340bfc96e9135b28f1043aeb670874d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 23 Mar 2026 10:27:42 +0100 Subject: [PATCH 09/28] fix(profiling): Fix clippy lint and add changelog entry Replace `get().is_none()` with `!contains_key()` to satisfy clippy and add a CHANGELOG entry for the Perfetto interning changes. Co-Authored-By: Claude --- CHANGELOG.md | 1 + relay-profiling/src/perfetto/mod.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98edfc4fa76..82467bbc786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ **Features**: +- Use separate intern tables per Perfetto field and infer main thread from pid. ([#5659](https://github.com/getsentry/relay/pull/5659)) - Set `sentry.segment.id` and `sentry.segment.name` attributes on OTLP segment spans. ([#5748](https://github.com/getsentry/relay/pull/5748)) - Envelope buffer: Add option to disable flush-to-disk on shutdown. ([#5751](https://github.com/getsentry/relay/pull/5751)) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index eff423b9f48..97dffd510fa 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -1373,7 +1373,7 @@ mod tests { Some("main"), ); // Worker thread (tid 101) should have no metadata since no name source exists. - assert!(data.thread_metadata.get("101").is_none()); + assert!(!data.thread_metadata.contains_key("101")); } #[test] From 2f784756c40c184db83b1bed6a45a8d38a7955c3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 27 Mar 2026 08:51:19 +0100 Subject: [PATCH 10/28] fix(profiling): Apply first timestamp delta in StreamingProfilePacket The first delta in timestamp_delta_us was skipped due to an i > 0 guard, but per the Perfetto spec the first sample's timestamp should be TracePacket.timestamp + timestamp_delta_us[0]. Update tests to use non-zero first deltas to verify the fix. Co-Authored-By: Claude --- relay-profiling/src/perfetto/mod.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 97dffd510fa..3d5ce93e43c 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -229,9 +229,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), Some(Data::StreamingProfilePacket(spp)) => { let mut ts = packet.timestamp.unwrap_or(0); for (i, &cs_iid) in spp.callstack_iid.iter().enumerate() { - if i > 0 - && let Some(&delta) = spp.timestamp_delta_us.get(i) - { + if let Some(&delta) = spp.timestamp_delta_us.get(i) { // `delta` is i64 (can be negative for out-of-order samples). // Casting to u64 wraps negative values, which is correct because // `wrapping_add` of a wrapped negative value subtracts as expected. @@ -796,7 +794,7 @@ mod tests { data: Some(Data::StreamingProfilePacket( proto::StreamingProfilePacket { callstack_iid: vec![10, 10], - timestamp_delta_us: vec![0, 10_000], // 0, +10ms + timestamp_delta_us: vec![5_000, 10_000], // +5ms, +10ms }, )), }, @@ -812,12 +810,17 @@ mod tests { let duration = data.samples[1].timestamp.to_f64() - data.samples[0].timestamp.to_f64(); assert!( (duration - 0.01).abs() < 0.001, - "expected ~10ms delta, got {duration}" + "expected ~10ms delta between samples, got {duration}" ); - // First sample at 2.0s boottime -> 2.0 + (REALTIME - BOOTTIME)/1e9 in Unix seconds. + // First sample: base timestamp 2.0s + first delta 5ms = 2.005s boottime, + // then rebased with clock offset. let expected_offset = (TEST_REALTIME_NS as f64 - TEST_BOOTTIME_NS as f64) / 1e9; - let expected_ts = 2.0 + expected_offset; - assert!((data.samples[0].timestamp.to_f64() - expected_ts).abs() < 0.001); + let expected_ts = 2.005 + expected_offset; + assert!( + (data.samples[0].timestamp.to_f64() - expected_ts).abs() < 0.001, + "expected first sample at ~{expected_ts}, got {}", + data.samples[0].timestamp.to_f64() + ); } #[test] @@ -1527,7 +1530,7 @@ mod tests { data: Some(Data::StreamingProfilePacket( proto::StreamingProfilePacket { callstack_iid: vec![10, 10, 10], - timestamp_delta_us: vec![0, 20_000, -5_000], // 0, +20ms, -5ms + timestamp_delta_us: vec![1_000, 20_000, -5_000], // +1ms, +20ms, -5ms }, )), }, @@ -1537,7 +1540,7 @@ mod tests { let (data, _images) = convert(&bytes).unwrap(); assert_eq!(data.samples.len(), 3); - // After sorting: sample at 3.0s, then 3.0+0.015=3.015s, then 3.0+0.020=3.020s + // After sorting: sample at 3.001s, then 3.001+0.015=3.016s, then 3.001+0.020=3.021s let t0 = data.samples[0].timestamp.to_f64(); let t1 = data.samples[1].timestamp.to_f64(); let t2 = data.samples[2].timestamp.to_f64(); From 6e5f11c7c89b13b9f751caa4e49dffb6ace698e9 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 1 Apr 2026 08:59:01 +0200 Subject: [PATCH 11/28] fix(profiling): Resolve thread ID for StreamingProfilePacket via TrackDescriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamingProfilePacket samples were all assigned tid=0 because the code never resolved the trusted_packet_sequence_id → TrackDescriptor → ThreadDescriptor chain. This collapsed multi-thread profiles into a single thread. Now we build a seq_id→tid mapping from TrackDescriptor packets and use it when processing StreamingProfilePacket samples. Co-Authored-By: Claude Opus 4.6 (1M context) --- relay-profiling/src/perfetto/mod.rs | 83 ++++++++++++++++++- relay-server/src/envelope/content_type.rs | 4 +- .../src/processing/profile_chunks/mod.rs | 2 + .../src/processing/profile_chunks/process.rs | 10 ++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 3d5ce93e43c..f7ec72e6dd7 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use data_encoding::HEXLOWER; use hashbrown::{HashMap, HashSet}; use prost::Message; + use relay_event_schema::protocol::{Addr, DebugId}; use relay_protocol::FiniteF64; @@ -76,7 +77,7 @@ fn extract_clock_offset(cs: &proto::ClockSnapshot) -> Option { /// Per the Perfetto spec, each `InternedData` field constructs its **own** /// interning index — IDs are scoped per field, not shared across string types. /// See . -#[derive(Default)] +#[derive(Debug, Default)] struct InternTables { function_names: HashMap, mapping_paths: HashMap, @@ -153,7 +154,7 @@ impl InternTables { /// Two Perfetto frames that resolve to the same function, module, package, /// and instruction address are considered identical and share a single index /// in the output frame list. -#[derive(Hash, Eq, PartialEq)] +#[derive(Debug, PartialEq, Eq, Hash)] struct FrameKey { function: Option, module: Option, @@ -172,6 +173,9 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let mut raw_samples: Vec<(u64, u32, u64, u32)> = Vec::new(); let mut clock_offset_ns: Option = None; let mut observed_pid: Option = None; + // Maps trusted_packet_sequence_id → tid for StreamingProfilePacket, + // resolved via the TrackDescriptor → ThreadDescriptor chain. + let mut seq_id_to_tid: HashMap = HashMap::new(); for packet in &trace.packet { let seq_id = trusted_packet_sequence_id(packet); @@ -214,6 +218,11 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), name: thread.thread_name.clone(), priority: None, }); + // Associate this packet sequence with the thread so that + // StreamingProfilePacket samples can resolve their tid. + if seq_id != 0 { + seq_id_to_tid.entry(seq_id).or_insert(tid as u32); + } } } Some(Data::PerfSample(ps)) => { @@ -227,6 +236,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } } Some(Data::StreamingProfilePacket(spp)) => { + let tid = seq_id_to_tid.get(&seq_id).copied().unwrap_or(0); let mut ts = packet.timestamp.unwrap_or(0); for (i, &cs_iid) in spp.callstack_iid.iter().enumerate() { if let Some(&delta) = spp.timestamp_delta_us.get(i) { @@ -235,7 +245,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), // `wrapping_add` of a wrapped negative value subtracts as expected. ts = ts.wrapping_add((delta * 1000) as u64); } - raw_samples.push((ts, 0, cs_iid, seq_id)); + raw_samples.push((ts, tid, cs_iid, seq_id)); } } None => {} @@ -1605,6 +1615,73 @@ mod tests { assert!(frame_names.contains(&"beta"), "expected beta frame"); } + #[test] + fn test_streaming_profile_resolves_tid_from_track_descriptor() { + // When a TrackDescriptor with a ThreadDescriptor is present for the same + // trusted_packet_sequence_id, StreamingProfilePacket samples should + // resolve the thread ID from that descriptor instead of defaulting to 0. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"func_a")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(10), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + // TrackDescriptor associating seq_id=1 with tid=42. + proto::TracePacket { + timestamp: None, + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::TrackDescriptor(proto::TrackDescriptor { + uuid: None, + thread: Some(proto::ThreadDescriptor { + pid: Some(100), + tid: Some(42), + thread_name: Some("worker".to_owned()), + }), + })), + }, + // StreamingProfilePacket on seq_id=1 should get tid=42. + proto::TracePacket { + timestamp: Some(2_000_000_000), + interned_data: None, + sequence_flags: None, + optional_trusted_packet_sequence_id: Some( + proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), + ), + data: Some(Data::StreamingProfilePacket( + proto::StreamingProfilePacket { + callstack_iid: vec![10], + timestamp_delta_us: vec![0], + }, + )), + }, + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.samples.len(), 1); + assert_eq!( + data.samples[0].thread_id, "42", + "StreamingProfilePacket should resolve tid from TrackDescriptor" + ); + assert!(data.thread_metadata.contains_key("42")); + assert_eq!(data.thread_metadata["42"].name.as_deref(), Some("worker")); + } + #[test] fn test_empty_callstack() { let trace = proto::Trace { diff --git a/relay-server/src/envelope/content_type.rs b/relay-server/src/envelope/content_type.rs index fe8b09859ca..5f42170d0e0 100644 --- a/relay-server/src/envelope/content_type.rs +++ b/relay-server/src/envelope/content_type.rs @@ -191,10 +191,12 @@ relay_common::impl_str_de!(ContentType, "a content type string"); #[cfg(test)] mod tests { + use similar_asserts::assert_eq; + use super::*; #[test] - fn attachment_ref_roundtrip() { + fn test_attachment_ref_roundtrip() { let canonical_name = "application/vnd.sentry.attachment-ref+json"; let ct = ContentType::from_str(canonical_name).unwrap(); assert_eq!(ct, ContentType::AttachmentRef); diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 870e5005cc7..b44777b0250 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -227,6 +227,8 @@ impl CountRateLimited for Managed { #[cfg(test)] mod tests { + use similar_asserts::assert_eq; + use crate::envelope::ContentType; use super::*; diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index 9a787a3d3c6..ea54017287a 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use relay_dynamic_config::Feature; use relay_profiling::ProfileType; use relay_quotas::DataCategory; @@ -5,7 +7,7 @@ use relay_quotas::DataCategory; use crate::envelope::{ContentType, Item, ItemType}; use crate::processing::Context; use crate::processing::Managed; -use crate::processing::profile_chunks::{Result, SerializedProfileChunks}; +use crate::processing::profile_chunks::{Error, Result, SerializedProfileChunks}; use crate::statsd::RelayCounters; use crate::utils; @@ -98,7 +100,7 @@ fn process_compound_item( item: &mut Item, meta_length: u32, sdk: &str, - client_ip: Option, + client_ip: Option, filter_settings: &relay_filter::ProjectFiltersConfig, ctx: Context<'_>, records: &mut crate::managed::RecordKeeper, @@ -120,7 +122,7 @@ fn process_compound_item( } if ctx.should_filter(Feature::ContinuousProfilingPerfetto) { - return Err(relay_profiling::ProfileError::PlatformNotSupported.into()); + return Err(Error::FilterFeatureFlag); } let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; @@ -171,6 +173,8 @@ fn process_compound_item( #[cfg(test)] mod tests { + use similar_asserts::assert_eq; + use relay_dynamic_config::{Feature, FeatureSet, ProjectConfig}; use super::*; From 253120d58abfee1679ab70740cfcdd09f5008e1d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 1 Apr 2026 09:28:03 +0200 Subject: [PATCH 12/28] fix(profiling): Resolve Perfetto samples eagerly to survive incremental state resets The two-pass architecture resolved all samples against the final state of the intern tables. If a trace contained an incremental state reset that reused an interning ID, samples collected before the reset would silently resolve to the wrong function names. This merges the two passes into a single pass that resolves callstacks immediately using the current intern table state, and extracts debug images inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- relay-profiling/src/perfetto/mod.rs | 340 +++++++++++++++++++--------- 1 file changed, 227 insertions(+), 113 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index f7ec72e6dd7..1ee35064ffd 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -169,14 +169,26 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let mut tables_by_seq: HashMap = HashMap::new(); let mut thread_meta: BTreeMap = BTreeMap::new(); - // (timestamp_ns, tid, callstack_iid, sequence_id) - let mut raw_samples: Vec<(u64, u32, u64, u32)> = Vec::new(); let mut clock_offset_ns: Option = None; let mut observed_pid: Option = None; // Maps trusted_packet_sequence_id → tid for StreamingProfilePacket, // resolved via the TrackDescriptor → ThreadDescriptor chain. let mut seq_id_to_tid: HashMap = HashMap::new(); + // Samples are resolved eagerly during packet iteration (single-pass) so + // that incremental state resets don't cause earlier samples to be resolved + // against a post-reset intern table. We collect (ts_ns, tid, stack_id) + // tuples and apply clock offset + sorting after the loop. + let mut frame_index: HashMap = HashMap::new(); + let mut frames: Vec = Vec::new(); + let mut stack_index: HashMap, usize> = HashMap::new(); + let mut stacks: Vec> = Vec::new(); + // (timestamp_ns, tid, stack_id) + let mut resolved_samples: Vec<(u64, u32, usize)> = Vec::new(); + let mut sample_count: usize = 0; + let mut debug_images: Vec = Vec::new(); + let mut seen_images: HashSet<(String, u64)> = HashSet::new(); + for packet in &trace.packet { let seq_id = trusted_packet_sequence_id(packet); @@ -232,7 +244,20 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), if observed_pid.is_none() { observed_pid = ps.pid; } - raw_samples.push((ts, tid, callstack_iid, seq_id)); + sample_count += 1; + if let Some(stack_id) = resolve_callstack( + callstack_iid, + seq_id, + &tables_by_seq, + &mut frame_index, + &mut frames, + &mut stack_index, + &mut stacks, + &mut debug_images, + &mut seen_images, + ) { + resolved_samples.push((ts, tid, stack_id)); + } } } Some(Data::StreamingProfilePacket(spp)) => { @@ -245,18 +270,31 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), // `wrapping_add` of a wrapped negative value subtracts as expected. ts = ts.wrapping_add((delta * 1000) as u64); } - raw_samples.push((ts, tid, cs_iid, seq_id)); + sample_count += 1; + if let Some(stack_id) = resolve_callstack( + cs_iid, + seq_id, + &tables_by_seq, + &mut frame_index, + &mut frames, + &mut stack_index, + &mut stacks, + &mut debug_images, + &mut seen_images, + ) { + resolved_samples.push((ts, tid, stack_id)); + } } } None => {} } - if raw_samples.len() > MAX_SAMPLES { + if sample_count > MAX_SAMPLES { return Err(ProfileError::ExceedSizeLimit); } } - if raw_samples.is_empty() { + if resolved_samples.is_empty() { return Err(ProfileError::NotEnoughSamples); } @@ -275,65 +313,10 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::InvalidSampledProfile)?; - raw_samples.sort_by_key(|s| s.0); + resolved_samples.sort_by_key(|s| s.0); - let empty_tables = InternTables::default(); - let mut frame_index: HashMap = HashMap::new(); - let mut frames: Vec = Vec::new(); - let mut stack_index: HashMap, usize> = HashMap::new(); - let mut stacks: Vec> = Vec::new(); let mut samples: Vec = Vec::new(); - let mut referenced_mappings: HashSet<(u32, u64)> = HashSet::new(); - - for &(ts_ns, tid, cs_iid, seq_id) in &raw_samples { - let tables = tables_by_seq.get(&seq_id).unwrap_or(&empty_tables); - - let Some(callstack) = tables.callstacks.get(&cs_iid) else { - continue; - }; - - let mut resolved_frame_indices: Vec = Vec::with_capacity(callstack.frame_ids.len()); - - for &frame_iid in &callstack.frame_ids { - let Some(pf) = tables.frames.get(&frame_iid) else { - continue; - }; - - let function_name = pf - .function_name_id - .and_then(|id| tables.function_names.get(&id)) - .cloned(); - - if let Some(mid) = pf.mapping_id { - referenced_mappings.insert((seq_id, mid)); - } - - let (key, frame) = build_frame(function_name, pf, tables); - - let idx = if let Some(&existing) = frame_index.get(&key) { - existing - } else { - let idx = frames.len(); - frame_index.insert(key, idx); - frames.push(frame); - idx - }; - - resolved_frame_indices.push(idx); - } - - // Perfetto stacks are root-first, Sample v2 is leaf-first. - resolved_frame_indices.reverse(); - - let stack_id = if let Some(&existing) = stack_index.get(&resolved_frame_indices) { - existing - } else { - let id = stacks.len(); - stack_index.insert(resolved_frame_indices.clone(), id); - stacks.push(resolved_frame_indices); - id - }; - + for &(ts_ns, tid, stack_id) in &resolved_samples { // Compute absolute timestamp in integer nanoseconds first, then convert // to f64 seconds once to avoid precision loss from adding large floats. let abs_ns = ts_ns as i128 + clock_offset_ns; @@ -353,70 +336,136 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), return Err(ProfileError::NotEnoughSamples); } - // Build debug images from referenced native mappings. - let mut debug_images: Vec = Vec::new(); - let mut seen_images: HashSet<(String, u64)> = HashSet::new(); + Ok(( + ProfileData { + samples, + stacks, + frames, + thread_metadata: thread_meta, + }, + debug_images, + )) +} - for &(seq_id, mapping_id) in &referenced_mappings { - let Some(tables) = tables_by_seq.get(&seq_id) else { - continue; - }; - let Some(mapping) = tables.mappings.get(&mapping_id) else { - continue; - }; +/// Resolves a callstack iid against the current intern tables, deduplicating +/// frames and stacks, and collecting debug images for native mappings. +/// +/// Returns `Some(stack_id)` if the callstack was resolved, or `None` if the +/// callstack iid was not found in the tables. +#[allow(clippy::too_many_arguments)] +fn resolve_callstack( + cs_iid: u64, + seq_id: u32, + tables_by_seq: &HashMap, + frame_index: &mut HashMap, + frames: &mut Vec, + stack_index: &mut HashMap, usize>, + stacks: &mut Vec>, + debug_images: &mut Vec, + seen_images: &mut HashSet<(String, u64)>, +) -> Option { + let empty_tables = InternTables::default(); + let tables = tables_by_seq.get(&seq_id).unwrap_or(&empty_tables); - let code_file = { - let parts: Vec<&str> = mapping - .path_string_ids - .iter() - .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) - .collect(); - if parts.is_empty() { - continue; - } - parts.join("/") - }; + let callstack = tables.callstacks.get(&cs_iid)?; - if is_java_mapping(&code_file) { + let mut resolved_frame_indices: Vec = Vec::with_capacity(callstack.frame_ids.len()); + + for &frame_iid in &callstack.frame_ids { + let Some(pf) = tables.frames.get(&frame_iid) else { continue; - } + }; - let image_addr = mapping.start.unwrap_or(0); + let function_name = pf + .function_name_id + .and_then(|id| tables.function_names.get(&id)) + .cloned(); - if !seen_images.insert((code_file.clone(), image_addr)) { - continue; + if let Some(mid) = pf.mapping_id { + collect_debug_image(mid, tables, debug_images, seen_images); } - let debug_id = mapping - .build_id - .and_then(|bid| tables.build_ids.get(&bid)) - .and_then(|hex_str| build_id_to_debug_id(hex_str)); + let (key, frame) = build_frame(function_name, pf, tables); - let Some(debug_id) = debug_id else { - continue; + let idx = if let Some(&existing) = frame_index.get(&key) { + existing + } else { + let idx = frames.len(); + frame_index.insert(key, idx); + frames.push(frame); + idx }; - let image_size = mapping.end.unwrap_or(0).saturating_sub(image_addr); - let image_vmaddr = mapping.load_bias.unwrap_or(0); + resolved_frame_indices.push(idx); + } + + // Perfetto stacks are root-first, Sample v2 is leaf-first. + resolved_frame_indices.reverse(); + + let stack_id = if let Some(&existing) = stack_index.get(&resolved_frame_indices) { + existing + } else { + let id = stacks.len(); + stack_index.insert(resolved_frame_indices.clone(), id); + stacks.push(resolved_frame_indices); + id + }; + + Some(stack_id) +} + +/// Collects a debug image from a native mapping if not already seen. +fn collect_debug_image( + mapping_id: u64, + tables: &InternTables, + debug_images: &mut Vec, + seen_images: &mut HashSet<(String, u64)>, +) { + let Some(mapping) = tables.mappings.get(&mapping_id) else { + return; + }; + + let code_file = { + let parts: Vec<&str> = mapping + .path_string_ids + .iter() + .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) + .collect(); + if parts.is_empty() { + return; + } + parts.join("/") + }; - debug_images.push(DebugImage::native_image( - code_file, - debug_id, - image_addr, - image_vmaddr, - image_size, - )); + if is_java_mapping(&code_file) { + return; } - Ok(( - ProfileData { - samples, - stacks, - frames, - thread_metadata: thread_meta, - }, - debug_images, - )) + let image_addr = mapping.start.unwrap_or(0); + + if !seen_images.insert((code_file.clone(), image_addr)) { + return; + } + + let debug_id = mapping + .build_id + .and_then(|bid| tables.build_ids.get(&bid)) + .and_then(|hex_str| build_id_to_debug_id(hex_str)); + + let Some(debug_id) = debug_id else { + return; + }; + + let image_size = mapping.end.unwrap_or(0).saturating_sub(image_addr); + let image_vmaddr = mapping.load_bias.unwrap_or(0); + + debug_images.push(DebugImage::native_image( + code_file, + debug_id, + image_addr, + image_vmaddr, + image_size, + )); } /// Resolves a Perfetto frame into a [`FrameKey`] and a Sample v2 [`Frame`]. @@ -973,6 +1022,71 @@ mod tests { assert_eq!(data.frames[0].function.as_deref(), Some("new_func")); } + #[test] + fn test_incremental_state_reset_with_samples_before_and_after() { + // Samples collected before an incremental state reset must resolve + // against the pre-reset intern tables, not the post-reset ones. + // This catches the two-pass bug where deferred resolution would use + // the final (post-reset) table state for all samples. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + // Pre-reset: iid 1 = "old_func". + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"old_func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + // Sample BEFORE reset — should resolve to "old_func". + make_perf_sample_packet(1_000_000_000, 1, 1, 1), + // State reset: iid 1 now = "new_func". + make_interned_data_packet( + 1, + true, + proto::InternedData { + function_names: vec![make_interned_string(1, b"new_func")], + frames: vec![make_frame(1, 1)], + callstacks: vec![proto::Callstack { + iid: Some(1), + frame_ids: vec![1], + }], + ..Default::default() + }, + ), + // Sample AFTER reset — should resolve to "new_func". + make_perf_sample_packet(1_010_000_000, 1, 1, 1), + ], + }; + let bytes = trace.encode_to_vec(); + let (data, _images) = convert(&bytes).unwrap(); + + assert_eq!(data.samples.len(), 2); + // Both functions must be present — the pre-reset sample must NOT + // silently resolve to "new_func". + assert_eq!(data.frames.len(), 2); + let frame_names: Vec<_> = data + .frames + .iter() + .map(|f| f.function.as_deref().unwrap_or("")) + .collect(); + assert!( + frame_names.contains(&"old_func"), + "expected old_func from pre-reset sample, got: {frame_names:?}" + ); + assert!( + frame_names.contains(&"new_func"), + "expected new_func from post-reset sample, got: {frame_names:?}" + ); + } + #[test] fn test_convert_android_pftrace() { let bytes = include_bytes!("../../tests/fixtures/android/perfetto/android.pftrace"); From ff63cc33d14c0b4f75a05e9d98329ad0197580f1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 1 Apr 2026 10:36:21 +0200 Subject: [PATCH 13/28] fix(profiling): Avoid redundant full JSON parse to extract content_type process_compound_item was parsing the entire metadata JSON into a serde_json::Value tree just to read the content_type field, and split_item_payload was doing the same on the expanded JSON (potentially hundreds of KB). Instead, surface content_type from the already- deserialized ProfileMetadata via ExpandedPerfettoChunk, and hardcode "perfetto" in split_item_payload since compound items are always validated as perfetto by process_compound_item. Co-Authored-By: Claude Opus 4.6 (1M context) --- relay-profiling/src/lib.rs | 4 +++ .../src/processing/profile_chunks/mod.rs | 25 +++++++++++-------- .../src/processing/profile_chunks/process.rs | 8 ++---- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 8d89fe266e2..4e08775a510 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -368,6 +368,8 @@ pub struct ExpandedPerfettoChunk { pub platform: String, /// Release string from the metadata, used for inbound filtering. release: Option, + /// Content type of the original binary payload (e.g. `"perfetto"`). + pub content_type: Option, } impl ExpandedPerfettoChunk { @@ -421,6 +423,7 @@ pub fn expand_perfetto( let platform = metadata.platform.clone(); let release = metadata.release.clone(); + let content_type = metadata.content_type.clone(); let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; let mut chunk = sample::v2::ProfileChunk { @@ -436,6 +439,7 @@ pub fn expand_perfetto( payload, platform, release, + content_type, }) } diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index b44777b0250..08c2186baf2 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -174,11 +174,11 @@ fn split_item_payload(item: &Item) -> (bytes::Bytes, Option, Optio return (payload.slice_ref(meta), None, None); } - // After processing, the meta portion is the expanded JSON payload. - // The content_type is read from the expanded JSON's `content_type` field. - let content_type = serde_json::from_slice::(meta) - .ok() - .and_then(|v| v.get("content_type")?.as_str().map(|s| s.to_owned())); + // Compound profile chunks are only created by `process_compound_item`, + // which validates the content type as "perfetto". The content_type is + // also present in the expanded JSON metadata, but we avoid re-parsing + // the full payload (potentially hundreds of KB) just for this field. + let content_type = Some("perfetto".to_owned()); ( payload.slice_ref(meta), @@ -273,7 +273,10 @@ mod tests { } #[test] - fn test_split_compound_no_content_type() { + fn test_split_compound_content_type_always_perfetto() { + // Compound items only reach split_item_payload after process_compound_item + // validates content_type == "perfetto", so it's always "perfetto" for any + // compound item with a non-empty body. let meta = b"{}"; let body = b"binary-data"; let item = make_compound_item(meta, body); @@ -281,7 +284,7 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), b"{}"); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); + assert_eq!(ct.as_deref(), Some("perfetto")); } #[test] @@ -312,7 +315,9 @@ mod tests { #[test] fn test_split_compound_invalid_json_meta() { - // meta portion is not valid JSON; content_type should be None. + // Even with invalid JSON in the meta portion, split_item_payload still + // returns "perfetto" because compound items are always perfetto + // (validated by process_compound_item before reaching this point). let meta = b"not valid json {{{{"; let body = b"binary-data"; let item = make_compound_item(meta, body); @@ -320,7 +325,7 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), meta.as_ref()); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); + assert_eq!(ct.as_deref(), Some("perfetto")); } #[test] @@ -334,6 +339,6 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), b""); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); + assert_eq!(ct.as_deref(), Some("perfetto")); } } diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index ea54017287a..a5f9f606a41 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -112,11 +112,9 @@ fn process_compound_item( return Err(relay_profiling::ProfileError::InvalidSampledProfile.into()); }; - let content_type = serde_json::from_slice::(meta_json) - .ok() - .and_then(|v| v.get("content_type")?.as_str().map(|s| s.to_owned())); + let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; - match content_type.as_deref() { + match expanded.content_type.as_deref() { Some("perfetto") => {} _ => return Err(relay_profiling::ProfileError::PlatformNotSupported.into()), } @@ -125,8 +123,6 @@ fn process_compound_item( return Err(Error::FilterFeatureFlag); } - let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; - if expanded.payload.len() > ctx.config.max_profile_size() { return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); } From 1c1dad6054a0e62847aa3ff17e84dd01f0a054e2 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 1 Apr 2026 10:40:16 +0200 Subject: [PATCH 14/28] fix(profiling): Check content_type before calling expand_perfetto The previous commit moved expand_perfetto before the content_type check, which would waste work on non-perfetto payloads and produce confusing errors. Restore the early content_type check using a minimal serde struct that only deserializes the one field, and remove the unused content_type field from ExpandedPerfettoChunk. Co-Authored-By: Claude Opus 4.6 (1M context) --- relay-profiling/src/lib.rs | 4 ---- .../src/processing/profile_chunks/process.rs | 14 +++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 4e08775a510..8d89fe266e2 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -368,8 +368,6 @@ pub struct ExpandedPerfettoChunk { pub platform: String, /// Release string from the metadata, used for inbound filtering. release: Option, - /// Content type of the original binary payload (e.g. `"perfetto"`). - pub content_type: Option, } impl ExpandedPerfettoChunk { @@ -423,7 +421,6 @@ pub fn expand_perfetto( let platform = metadata.platform.clone(); let release = metadata.release.clone(); - let content_type = metadata.content_type.clone(); let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; let mut chunk = sample::v2::ProfileChunk { @@ -439,7 +436,6 @@ pub fn expand_perfetto( payload, platform, release, - content_type, }) } diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index a5f9f606a41..d00a64ef302 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -112,9 +112,15 @@ fn process_compound_item( return Err(relay_profiling::ProfileError::InvalidSampledProfile.into()); }; - let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; - - match expanded.content_type.as_deref() { + #[derive(serde::Deserialize)] + struct ContentTypeProbe { + content_type: Option, + } + match serde_json::from_slice::(meta_json) + .ok() + .and_then(|v| v.content_type) + .as_deref() + { Some("perfetto") => {} _ => return Err(relay_profiling::ProfileError::PlatformNotSupported.into()), } @@ -123,6 +129,8 @@ fn process_compound_item( return Err(Error::FilterFeatureFlag); } + let expanded = relay_profiling::expand_perfetto(raw_profile, meta_json)?; + if expanded.payload.len() > ctx.config.max_profile_size() { return Err(relay_profiling::ProfileError::ExceedSizeLimit.into()); } From 1e14dc6910a15f6bcf98c5caddbf95667c438c2a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 1 Apr 2026 10:52:45 +0200 Subject: [PATCH 15/28] fix(profiling): Read content_type from metadata and remove dead write Replace the hard-coded "perfetto" content type in split_item_payload with a lightweight deserialization that reads only the content_type field from the metadata JSON. This avoids a silent coupling that would produce incorrect values if compound items support other formats. Also remove a dead item.set_platform() call that wrote to an item immediately replaced by a new one. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/processing/profile_chunks/mod.rs | 30 +++++++++---------- .../src/processing/profile_chunks/process.rs | 1 - 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 08c2186baf2..92ee1a3b2d6 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -174,11 +174,16 @@ fn split_item_payload(item: &Item) -> (bytes::Bytes, Option, Optio return (payload.slice_ref(meta), None, None); } - // Compound profile chunks are only created by `process_compound_item`, - // which validates the content type as "perfetto". The content_type is - // also present in the expanded JSON metadata, but we avoid re-parsing - // the full payload (potentially hundreds of KB) just for this field. - let content_type = Some("perfetto".to_owned()); + // Extract content_type from the expanded JSON metadata using a minimal + // deserializer that only reads this single field, skipping the bulk of the + // payload (frames, stacks, samples, etc.). + #[derive(serde::Deserialize)] + struct ContentTypeProbe { + content_type: Option, + } + let content_type = serde_json::from_slice::(meta) + .ok() + .and_then(|v| v.content_type); ( payload.slice_ref(meta), @@ -273,10 +278,7 @@ mod tests { } #[test] - fn test_split_compound_content_type_always_perfetto() { - // Compound items only reach split_item_payload after process_compound_item - // validates content_type == "perfetto", so it's always "perfetto" for any - // compound item with a non-empty body. + fn test_split_compound_no_content_type() { let meta = b"{}"; let body = b"binary-data"; let item = make_compound_item(meta, body); @@ -284,7 +286,7 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), b"{}"); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert_eq!(ct.as_deref(), Some("perfetto")); + assert!(ct.is_none()); } #[test] @@ -315,9 +317,7 @@ mod tests { #[test] fn test_split_compound_invalid_json_meta() { - // Even with invalid JSON in the meta portion, split_item_payload still - // returns "perfetto" because compound items are always perfetto - // (validated by process_compound_item before reaching this point). + // meta portion is not valid JSON; content_type should be None. let meta = b"not valid json {{{{"; let body = b"binary-data"; let item = make_compound_item(meta, body); @@ -325,7 +325,7 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), meta.as_ref()); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert_eq!(ct.as_deref(), Some("perfetto")); + assert!(ct.is_none()); } #[test] @@ -339,6 +339,6 @@ mod tests { let (payload, raw, ct) = split_item_payload(&item); assert_eq!(payload.as_ref(), b""); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert_eq!(ct.as_deref(), Some("perfetto")); + assert!(ct.is_none()); } } diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index d00a64ef302..a99896b1074 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -147,7 +147,6 @@ fn process_compound_item( counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, sdk = sdk ); - item.set_platform(expanded.platform.clone()); match expanded.profile_type() { ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), From 6e8987bc138823b377d7255a1bdddb105489bb22 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 15 Apr 2026 09:44:39 +0200 Subject: [PATCH 16/28] Address PR feedback --- CHANGELOG.md | 2 +- relay-profiling/src/debug_image.rs | 37 +-- relay-profiling/src/lib.rs | 2 +- relay-profiling/src/perfetto/mod.rs | 237 +++++++++--------- relay-profiling/src/sample/v2.rs | 3 +- .../src/processing/profile_chunks/mod.rs | 81 ++---- relay-server/src/services/store.rs | 14 +- 7 files changed, 156 insertions(+), 220 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfefe39583..7a8fadf62a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ **Features**: -- Use separate intern tables per Perfetto field and infer main thread from pid. ([#5659](https://github.com/getsentry/relay/pull/5659)) +- Add Perfetto trace format support for continuous profiling: convert binary Perfetto traces into Sample v2 format, handle both `PerfSample` and `StreamingProfilePacket` packet types, resolve Java and native frames, extract debug images from mappings, and forward the raw binary profile alongside the expanded JSON payload via compound items. ([#5659](https://github.com/getsentry/relay/pull/5659)) - Set `sentry.segment.id` and `sentry.segment.name` attributes on OTLP segment spans. ([#5748](https://github.com/getsentry/relay/pull/5748)) - Envelope buffer: Add option to disable flush-to-disk on shutdown. ([#5751](https://github.com/getsentry/relay/pull/5751)) - Allow configuring Objectstore client auth parameters. ([#5720](https://github.com/getsentry/relay/pull/5720)) diff --git a/relay-profiling/src/debug_image.rs b/relay-profiling/src/debug_image.rs index cee227ce407..59996ade7dc 100644 --- a/relay-profiling/src/debug_image.rs +++ b/relay-profiling/src/debug_image.rs @@ -8,7 +8,7 @@ use crate::utils; #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(rename_all = "lowercase")] -enum ImageType { +pub enum ImageType { MachO, Symbolic, Sourcemap, @@ -19,48 +19,27 @@ enum ImageType { #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct DebugImage { #[serde(skip_serializing_if = "Option::is_none", alias = "name")] - code_file: Option, + pub code_file: Option, #[serde(skip_serializing_if = "Option::is_none", alias = "id")] - debug_id: Option, + pub debug_id: Option, #[serde(rename = "type")] - image_type: ImageType, + pub image_type: ImageType, #[serde(skip_serializing_if = "Option::is_none")] - image_addr: Option, + pub image_addr: Option, #[serde(skip_serializing_if = "Option::is_none")] - image_vmaddr: Option, + pub image_vmaddr: Option, #[serde( default, deserialize_with = "utils::deserialize_number_from_string", skip_serializing_if = "utils::is_zero" )] - image_size: u64, + pub image_size: u64, #[serde(skip_serializing_if = "Option::is_none", alias = "build_id")] - uuid: Option, -} - -impl DebugImage { - /// Creates a native (ELF/Symbolic) debug image from Perfetto mapping data. - pub fn native_image( - code_file: String, - debug_id: DebugId, - image_addr: u64, - image_vmaddr: u64, - image_size: u64, - ) -> Self { - Self { - code_file: Some(code_file.into()), - debug_id: Some(debug_id), - image_type: ImageType::Symbolic, - image_addr: Some(Addr(image_addr)), - image_vmaddr: Some(Addr(image_vmaddr)), - image_size, - uuid: None, - } - } + pub uuid: Option, } pub fn get_proguard_image(uuid: &str) -> Result { diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 8d89fe266e2..fc28beeacf2 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -367,7 +367,7 @@ pub struct ExpandedPerfettoChunk { /// Platform string extracted from the metadata (e.g. `"android"`). pub platform: String, /// Release string from the metadata, used for inbound filtering. - release: Option, + pub release: Option, } impl ExpandedPerfettoChunk { diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 1ee35064ffd..87c01c3c7f1 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -12,7 +12,7 @@ use prost::Message; use relay_event_schema::protocol::{Addr, DebugId}; use relay_protocol::FiniteF64; -use crate::debug_image::DebugImage; +use crate::debug_image::{DebugImage, ImageType}; use crate::error::ProfileError; use crate::sample::v2::{ProfileData, Sample}; use crate::sample::{Frame, ThreadMetadata}; @@ -79,6 +79,7 @@ fn extract_clock_offset(cs: &proto::ClockSnapshot) -> Option { /// See . #[derive(Debug, Default)] struct InternTables { + // HashMap over BTreeMap: these tables can grow large (one entry per interned symbol). function_names: HashMap, mapping_paths: HashMap, /// Build IDs stored as hex-encoded strings (normalized from raw bytes). @@ -89,17 +90,8 @@ struct InternTables { } impl InternTables { - fn clear(&mut self) { - self.function_names.clear(); - self.mapping_paths.clear(); - self.build_ids.clear(); - self.frames.clear(); - self.callstacks.clear(); - self.mappings.clear(); - } - - fn merge(&mut self, data: &proto::InternedData) { - for s in &data.function_names { + fn merge(&mut self, data: proto::InternedData) { + for s in data.function_names { if let Some(iid) = s.iid { let value = s .r#str @@ -110,7 +102,7 @@ impl InternTables { self.function_names.insert(iid, value); } } - for s in &data.mapping_paths { + for s in data.mapping_paths { if let Some(iid) = s.iid { let value = s .r#str @@ -122,7 +114,7 @@ impl InternTables { } } // Build IDs are raw bytes in Perfetto traces; normalize to hex for later lookup. - for s in &data.build_ids { + for s in data.build_ids { if let Some(iid) = s.iid { let value = match s.r#str.as_deref() { Some(bytes) if !bytes.is_empty() => HEXLOWER.encode(bytes), @@ -131,19 +123,19 @@ impl InternTables { self.build_ids.insert(iid, value); } } - for f in &data.frames { + for f in data.frames { if let Some(iid) = f.iid { - self.frames.insert(iid, *f); + self.frames.insert(iid, f); } } - for c in &data.callstacks { + for c in data.callstacks { if let Some(iid) = c.iid { - self.callstacks.insert(iid, c.clone()); + self.callstacks.insert(iid, c); } } - for m in &data.mappings { + for m in data.mappings { if let Some(iid) = m.iid { - self.mappings.insert(iid, m.clone()); + self.mappings.insert(iid, m); } } } @@ -162,76 +154,85 @@ struct FrameKey { instruction_addr: Option, } +/// Mutable context for callstack resolution, collecting frames, stacks, +/// and debug images during a single conversion pass. +#[derive(Default)] +struct ResolveContext { + // HashMap/HashSet over BTreeMap/BTreeSet: these dedup indexes can grow large. + frame_index: HashMap, + frames: Vec, + stack_index: HashMap, usize>, + stacks: Vec>, + debug_images: Vec, + seen_images: HashSet<(String, u64)>, +} + /// Converts a Perfetto binary trace into Sample v2 [`ProfileData`] and debug images. pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), ProfileError> { let trace = proto::Trace::decode(perfetto_bytes).map_err(|_| ProfileError::InvalidSampledProfile)?; - let mut tables_by_seq: HashMap = HashMap::new(); - let mut thread_meta: BTreeMap = BTreeMap::new(); + let mut tables_by_seq: BTreeMap = BTreeMap::new(); + let mut thread_meta: BTreeMap = BTreeMap::new(); let mut clock_offset_ns: Option = None; let mut observed_pid: Option = None; // Maps trusted_packet_sequence_id → tid for StreamingProfilePacket, // resolved via the TrackDescriptor → ThreadDescriptor chain. - let mut seq_id_to_tid: HashMap = HashMap::new(); + let mut seq_id_to_tid: BTreeMap = BTreeMap::new(); // Samples are resolved eagerly during packet iteration (single-pass) so // that incremental state resets don't cause earlier samples to be resolved // against a post-reset intern table. We collect (ts_ns, tid, stack_id) // tuples and apply clock offset + sorting after the loop. - let mut frame_index: HashMap = HashMap::new(); - let mut frames: Vec = Vec::new(); - let mut stack_index: HashMap, usize> = HashMap::new(); - let mut stacks: Vec> = Vec::new(); + let mut ctx = ResolveContext::default(); // (timestamp_ns, tid, stack_id) let mut resolved_samples: Vec<(u64, u32, usize)> = Vec::new(); let mut sample_count: usize = 0; - let mut debug_images: Vec = Vec::new(); - let mut seen_images: HashSet<(String, u64)> = HashSet::new(); - for packet in &trace.packet { - let seq_id = trusted_packet_sequence_id(packet); + for packet in trace.packet { + let seq_id = trusted_packet_sequence_id(&packet); - if has_incremental_state_cleared(packet) { - tables_by_seq.entry(seq_id).or_default().clear(); + if has_incremental_state_cleared(&packet) && tables_by_seq.contains_key(&seq_id) { + tables_by_seq.insert(seq_id, InternTables::default()); } - if let Some(ref interned) = packet.interned_data { + if let Some(interned) = packet.interned_data { tables_by_seq.entry(seq_id).or_default().merge(interned); } - match &packet.data { + match packet.data { Some(Data::ClockSnapshot(cs)) => { if clock_offset_ns.is_none() { - clock_offset_ns = extract_clock_offset(cs); + clock_offset_ns = extract_clock_offset(&cs); } } Some(Data::ProcessTree(pt)) => { - for thread in &pt.threads { + for thread in pt.threads { if let Some(tid) = thread.tid { - let tid_str = tid.to_string(); thread_meta - .entry(tid_str) + .entry(tid as u32) .or_insert_with(|| ThreadMetadata { - name: thread.name.clone(), + name: thread.name, priority: None, }); } } } Some(Data::TrackDescriptor(td)) => { - if let Some(ref thread) = td.thread + if let Some(thread) = td.thread && let Some(tid) = thread.tid { - let tid_str = tid.to_string(); thread_meta - .entry(tid_str) + .entry(tid as u32) .or_insert_with(|| ThreadMetadata { - name: thread.thread_name.clone(), + name: thread.thread_name, priority: None, }); // Associate this packet sequence with the thread so that // StreamingProfilePacket samples can resolve their tid. + // By producer convention (not a protocol guarantee), + // TrackDescriptor is emitted before data packets on the + // same sequence. If violated, samples fall back to tid 0. if seq_id != 0 { seq_id_to_tid.entry(seq_id).or_insert(tid as u32); } @@ -245,17 +246,9 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), observed_pid = ps.pid; } sample_count += 1; - if let Some(stack_id) = resolve_callstack( - callstack_iid, - seq_id, - &tables_by_seq, - &mut frame_index, - &mut frames, - &mut stack_index, - &mut stacks, - &mut debug_images, - &mut seen_images, - ) { + if let Some(stack_id) = + resolve_callstack(callstack_iid, seq_id, &tables_by_seq, &mut ctx) + { resolved_samples.push((ts, tid, stack_id)); } } @@ -271,17 +264,9 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), ts = ts.wrapping_add((delta * 1000) as u64); } sample_count += 1; - if let Some(stack_id) = resolve_callstack( - cs_iid, - seq_id, - &tables_by_seq, - &mut frame_index, - &mut frames, - &mut stack_index, - &mut stacks, - &mut debug_images, - &mut seen_images, - ) { + if let Some(stack_id) = + resolve_callstack(cs_iid, seq_id, &tables_by_seq, &mut ctx) + { resolved_samples.push((ts, tid, stack_id)); } } @@ -302,13 +287,10 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), // If the trace didn't include a ProcessTree or TrackDescriptor with a name // for that thread, label it "main" so the UI can identify it. if let Some(pid) = observed_pid { - let main_tid = pid.to_string(); - thread_meta - .entry(main_tid) - .or_insert_with(|| ThreadMetadata { - name: Some("main".to_owned()), - priority: None, - }); + thread_meta.entry(pid).or_insert_with(|| ThreadMetadata { + name: Some("main".to_owned()), + priority: None, + }); } let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::InvalidSampledProfile)?; @@ -336,14 +318,20 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), return Err(ProfileError::NotEnoughSamples); } + // Convert u32 thread keys to String for the output format. + let thread_metadata = thread_meta + .into_iter() + .map(|(tid, meta)| (tid.to_string(), meta)) + .collect(); + Ok(( ProfileData { samples, - stacks, - frames, - thread_metadata: thread_meta, + stacks: ctx.stacks, + frames: ctx.frames, + thread_metadata, }, - debug_images, + ctx.debug_images, )) } @@ -352,17 +340,11 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), /// /// Returns `Some(stack_id)` if the callstack was resolved, or `None` if the /// callstack iid was not found in the tables. -#[allow(clippy::too_many_arguments)] fn resolve_callstack( cs_iid: u64, seq_id: u32, - tables_by_seq: &HashMap, - frame_index: &mut HashMap, - frames: &mut Vec, - stack_index: &mut HashMap, usize>, - stacks: &mut Vec>, - debug_images: &mut Vec, - seen_images: &mut HashSet<(String, u64)>, + tables_by_seq: &BTreeMap, + ctx: &mut ResolveContext, ) -> Option { let empty_tables = InternTables::default(); let tables = tables_by_seq.get(&seq_id).unwrap_or(&empty_tables); @@ -381,18 +363,20 @@ fn resolve_callstack( .and_then(|id| tables.function_names.get(&id)) .cloned(); - if let Some(mid) = pf.mapping_id { - collect_debug_image(mid, tables, debug_images, seen_images); + if let Some(mid) = pf.mapping_id + && let Some(image) = collect_debug_image(mid, tables, &mut ctx.seen_images) + { + ctx.debug_images.push(image); } let (key, frame) = build_frame(function_name, pf, tables); - let idx = if let Some(&existing) = frame_index.get(&key) { + let idx = if let Some(&existing) = ctx.frame_index.get(&key) { existing } else { - let idx = frames.len(); - frame_index.insert(key, idx); - frames.push(frame); + let idx = ctx.frames.len(); + ctx.frame_index.insert(key, idx); + ctx.frames.push(frame); idx }; @@ -402,28 +386,29 @@ fn resolve_callstack( // Perfetto stacks are root-first, Sample v2 is leaf-first. resolved_frame_indices.reverse(); - let stack_id = if let Some(&existing) = stack_index.get(&resolved_frame_indices) { + let stack_id = if let Some(&existing) = ctx.stack_index.get(&resolved_frame_indices) { existing } else { - let id = stacks.len(); - stack_index.insert(resolved_frame_indices.clone(), id); - stacks.push(resolved_frame_indices); + let id = ctx.stacks.len(); + ctx.stack_index.insert(resolved_frame_indices.clone(), id); + ctx.stacks.push(resolved_frame_indices); id }; Some(stack_id) } -/// Collects a debug image from a native mapping if not already seen. +/// Builds a debug image from a native mapping if not already seen. +/// +/// Returns `Some(DebugImage)` for new native mappings with a valid build ID, +/// or `None` if the mapping is missing, Java-only, already seen, or lacks +/// a valid debug ID. fn collect_debug_image( mapping_id: u64, tables: &InternTables, - debug_images: &mut Vec, seen_images: &mut HashSet<(String, u64)>, -) { - let Some(mapping) = tables.mappings.get(&mapping_id) else { - return; - }; +) -> Option { + let mapping = tables.mappings.get(&mapping_id)?; let code_file = { let parts: Vec<&str> = mapping @@ -432,40 +417,41 @@ fn collect_debug_image( .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) .collect(); if parts.is_empty() { - return; + return None; } parts.join("/") }; if is_java_mapping(&code_file) { - return; + return None; } let image_addr = mapping.start.unwrap_or(0); - if !seen_images.insert((code_file.clone(), image_addr)) { - return; - } - let debug_id = mapping .build_id .and_then(|bid| tables.build_ids.get(&bid)) - .and_then(|hex_str| build_id_to_debug_id(hex_str)); + .and_then(|hex_str| build_id_to_debug_id(hex_str))?; - let Some(debug_id) = debug_id else { - return; - }; + // Insert into dedup set only after validating we have a valid debug_id, + // so that a mapping first seen without a build_id doesn't block a later + // valid encounter from a different packet sequence. + if !seen_images.insert((code_file.clone(), image_addr)) { + return None; + } let image_size = mapping.end.unwrap_or(0).saturating_sub(image_addr); let image_vmaddr = mapping.load_bias.unwrap_or(0); - debug_images.push(DebugImage::native_image( - code_file, - debug_id, - image_addr, - image_vmaddr, + Some(DebugImage { + code_file: Some(code_file.into()), + debug_id: Some(debug_id), + image_type: ImageType::Symbolic, + image_addr: Some(Addr(image_addr)), + image_vmaddr: Some(Addr(image_vmaddr)), image_size, - )); + uuid: None, + }) } /// Resolves a Perfetto frame into a [`FrameKey`] and a Sample v2 [`Frame`]. @@ -1390,13 +1376,16 @@ mod tests { assert_eq!(data.frames.len(), 1); assert_eq!(images.len(), 1); - let img_json = serde_json::to_value(&images[0]).unwrap(); - assert_eq!(img_json["code_file"], "libexample.so"); - assert_eq!(img_json["debug_id"], "7f4a3eb0-885e-8d4c-a04b-05fa32cc4cbd"); - assert_eq!(img_json["image_addr"], "0x70000000"); - assert_eq!(img_json["image_vmaddr"], "0x1000"); - assert_eq!(img_json["image_size"], 0x10000); - assert_eq!(img_json["type"], "symbolic"); + insta::assert_json_snapshot!(images[0], @r###" + { + "code_file": "libexample.so", + "debug_id": "7f4a3eb0-885e-8d4c-a04b-05fa32cc4cbd", + "type": "symbolic", + "image_addr": "0x70000000", + "image_vmaddr": "0x1000", + "image_size": 65536 + } + "###); } #[test] diff --git a/relay-profiling/src/sample/v2.rs b/relay-profiling/src/sample/v2.rs index efd4c9a5734..fa1b33e6381 100644 --- a/relay-profiling/src/sample/v2.rs +++ b/relay-profiling/src/sample/v2.rs @@ -36,8 +36,9 @@ pub struct ProfileMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub environment: Option, pub platform: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none")] pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub release: Option, pub client_sdk: ClientSdk, diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index 92ee1a3b2d6..d1c69d39a5c 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -130,20 +130,24 @@ impl Forward for ProfileChunkOutput { s: processing::forward::StoreHandle<'_>, ctx: processing::ForwardContext<'_>, ) -> Result<(), Rejected<()>> { - use crate::services::store::StoreProfileChunk; + use crate::services::store::{RawProfileContentType, StoreProfileChunk}; let Self(profile_chunks) = self; let retention_days = ctx.event_retention().standard; for item in profile_chunks.split(|pc| pc.profile_chunks) { - let (kafka_payload, raw_profile, raw_profile_content_type) = split_item_payload(&item); + let (kafka_payload, raw_profile) = split_item_payload(&item); s.send_to_store(item.map(|item, _| StoreProfileChunk { retention_days, payload: kafka_payload, quantities: item.quantities(), + raw_profile_content_type: if raw_profile.is_some() { + Some(RawProfileContentType::Perfetto) + } else { + None + }, raw_profile, - raw_profile_content_type, })); } @@ -154,42 +158,27 @@ impl Forward for ProfileChunkOutput { /// Splits a profile chunk item payload into its constituent parts. /// /// For compound items (those with a `meta_length` header), the payload is -/// `[expanded JSON][raw binary]`. Returns `(kafka_payload, raw_profile, content_type)`. +/// `[expanded JSON][raw binary]`. Returns `(kafka_payload, raw_profile)`. /// -/// For plain items, returns `(full_payload, None, None)`. -#[cfg_attr(not(feature = "processing"), allow(dead_code))] -fn split_item_payload(item: &Item) -> (bytes::Bytes, Option, Option) { +/// For plain items, returns `(full_payload, None)`. +#[cfg(any(feature = "processing", test))] +fn split_item_payload(item: &Item) -> (bytes::Bytes, Option) { let payload = item.payload(); let Some(meta_length) = item.meta_length() else { - return (payload, None, None); + return (payload, None); }; let meta_length = meta_length as usize; let Some((meta, body)) = payload.split_at_checked(meta_length) else { - return (payload, None, None); + return (payload, None); }; if body.is_empty() { - return (payload.slice_ref(meta), None, None); + return (payload.slice_ref(meta), None); } - // Extract content_type from the expanded JSON metadata using a minimal - // deserializer that only reads this single field, skipping the bulk of the - // payload (frames, stacks, samples, etc.). - #[derive(serde::Deserialize)] - struct ContentTypeProbe { - content_type: Option, - } - let content_type = serde_json::from_slice::(meta) - .ok() - .and_then(|v| v.content_type); - - ( - payload.slice_ref(meta), - Some(payload.slice_ref(body)), - content_type, - ) + (payload.slice_ref(meta), Some(payload.slice_ref(body))) } /// Serialized profile chunks extracted from an envelope. @@ -259,10 +248,9 @@ mod tests { #[test] fn test_split_plain_chunk() { let item = make_chunk_item(b"{}"); - let (payload, raw, ct) = split_item_payload(&item); + let (payload, raw) = split_item_payload(&item); assert_eq!(payload.as_ref(), b"{}"); assert!(raw.is_none()); - assert!(ct.is_none()); } #[test] @@ -271,22 +259,9 @@ mod tests { let body = b"binary-data"; let item = make_compound_item(meta, body); - let (payload, raw, ct) = split_item_payload(&item); + let (payload, raw) = split_item_payload(&item); assert_eq!(payload.as_ref(), meta.as_ref()); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert_eq!(ct.as_deref(), Some("perfetto")); - } - - #[test] - fn test_split_compound_no_content_type() { - let meta = b"{}"; - let body = b"binary-data"; - let item = make_compound_item(meta, body); - - let (payload, raw, ct) = split_item_payload(&item); - assert_eq!(payload.as_ref(), b"{}"); - assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); } #[test] @@ -294,10 +269,9 @@ mod tests { let meta = br#"{"content_type":"perfetto"}"#; let item = make_compound_item(meta, b""); - let (payload, raw, ct) = split_item_payload(&item); + let (payload, raw) = split_item_payload(&item); assert_eq!(payload.as_ref(), meta.as_ref()); assert!(raw.is_none()); - assert!(ct.is_none()); } #[test] @@ -309,23 +283,9 @@ mod tests { item.set_payload(ContentType::OctetStream, bytes::Bytes::from(body.as_ref())); item.set_meta_length(body.len() as u32 + 100); - let (payload, raw, ct) = split_item_payload(&item); + let (payload, raw) = split_item_payload(&item); assert_eq!(payload.as_ref(), body.as_ref()); assert!(raw.is_none()); - assert!(ct.is_none()); - } - - #[test] - fn test_split_compound_invalid_json_meta() { - // meta portion is not valid JSON; content_type should be None. - let meta = b"not valid json {{{{"; - let body = b"binary-data"; - let item = make_compound_item(meta, body); - - let (payload, raw, ct) = split_item_payload(&item); - assert_eq!(payload.as_ref(), meta.as_ref()); - assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); } #[test] @@ -336,9 +296,8 @@ mod tests { item.set_payload(ContentType::OctetStream, bytes::Bytes::from(body.as_ref())); item.set_meta_length(0); - let (payload, raw, ct) = split_item_payload(&item); + let (payload, raw) = split_item_payload(&item); assert_eq!(payload.as_ref(), b""); assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); - assert!(ct.is_none()); } } diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index b543e40241b..368a3046489 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -147,6 +147,14 @@ impl Counted for StoreSpanV2 { } } +/// Content type of a raw binary profile blob sent alongside the expanded JSON payload. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RawProfileContentType { + /// Perfetto binary trace format. + Perfetto, +} + /// Publishes a singular profile chunk to Kafka. #[derive(Debug)] pub struct StoreProfileChunk { @@ -163,8 +171,8 @@ pub struct StoreProfileChunk { /// Sent alongside the expanded JSON payload because the expansion only extracts a /// minimum of information; the raw profile is preserved for further processing downstream. pub raw_profile: Option, - /// Content type of `raw_profile` (e.g. `"perfetto"`). - pub raw_profile_content_type: Option, + /// Content type of `raw_profile`. + pub raw_profile_content_type: Option, } impl Counted for StoreProfileChunk { @@ -1669,7 +1677,7 @@ struct ProfileChunkKafkaMessage { #[serde(skip_serializing_if = "Option::is_none")] raw_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] - raw_profile_content_type: Option, + raw_profile_content_type: Option, } /// An enum over all possible ingest messages. From f3f0496b7624b760795a7d44f797cac4bb1bf4c9 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 28 Apr 2026 15:36:54 +0200 Subject: [PATCH 17/28] Add e2e tests, remove packets which weren't need for Android profiling --- relay-profiling/protos/perfetto_trace.proto | 32 - relay-profiling/src/perfetto/mod.rs | 571 ++----- relay-profiling/src/perfetto/proto.rs | 52 +- .../android/perfetto/profile_chunk.envelope | 1369 +++++++++++++++++ .../test_profile_chunks_perfetto.py | 114 ++ 5 files changed, 1580 insertions(+), 558 deletions(-) create mode 100644 relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope create mode 100644 tests/integration/test_profile_chunks_perfetto.py diff --git a/relay-profiling/protos/perfetto_trace.proto b/relay-profiling/protos/perfetto_trace.proto index 4a05bc949d0..0570527b2cd 100644 --- a/relay-profiling/protos/perfetto_trace.proto +++ b/relay-profiling/protos/perfetto_trace.proto @@ -21,25 +21,11 @@ message TracePacket { // Only the oneof variants we care about; prost will skip the rest. oneof data { - ProcessTree process_tree = 2; ClockSnapshot clock_snapshot = 6; - StreamingProfilePacket streaming_profile_packet = 54; - TrackDescriptor track_descriptor = 60; PerfSample perf_sample = 66; } } -// --- process tree ------------------------------------------------------------ - -message ProcessTree { - message Thread { - optional int32 tid = 1; - optional string name = 2; - optional int32 tgid = 3; - } - repeated ProcessTree.Thread threads = 2; -} - // --- clock sync --------------------------------------------------------------- message ClockSnapshot { @@ -100,21 +86,3 @@ message PerfSample { optional uint32 tid = 3; optional uint64 callstack_iid = 4; } - -message StreamingProfilePacket { - repeated uint64 callstack_iid = 1; - repeated int64 timestamp_delta_us = 2; -} - -// --- track descriptors ------------------------------------------------------- - -message TrackDescriptor { - optional uint64 uuid = 1; - optional ThreadDescriptor thread = 4; -} - -message ThreadDescriptor { - optional int32 pid = 1; - optional int32 tid = 2; - optional string thread_name = 5; -} diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 87c01c3c7f1..d45af38ad7e 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -1,7 +1,7 @@ //! Perfetto trace format conversion to Sample v2. //! -//! Handles both `PerfSample` (CPU profiling via `perf_event_open`) and -//! `StreamingProfilePacket` (in-process stack sampling) packet types. +//! Decodes Android Perfetto traces (`PerfSample` packets + `ClockSnapshot` + +//! interned data) into the Sample v2 profile format. use std::collections::BTreeMap; @@ -176,16 +176,12 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let mut thread_meta: BTreeMap = BTreeMap::new(); let mut clock_offset_ns: Option = None; let mut observed_pid: Option = None; - // Maps trusted_packet_sequence_id → tid for StreamingProfilePacket, - // resolved via the TrackDescriptor → ThreadDescriptor chain. - let mut seq_id_to_tid: BTreeMap = BTreeMap::new(); // Samples are resolved eagerly during packet iteration (single-pass) so // that incremental state resets don't cause earlier samples to be resolved // against a post-reset intern table. We collect (ts_ns, tid, stack_id) // tuples and apply clock offset + sorting after the loop. let mut ctx = ResolveContext::default(); - // (timestamp_ns, tid, stack_id) let mut resolved_samples: Vec<(u64, u32, usize)> = Vec::new(); let mut sample_count: usize = 0; @@ -206,38 +202,6 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), clock_offset_ns = extract_clock_offset(&cs); } } - Some(Data::ProcessTree(pt)) => { - for thread in pt.threads { - if let Some(tid) = thread.tid { - thread_meta - .entry(tid as u32) - .or_insert_with(|| ThreadMetadata { - name: thread.name, - priority: None, - }); - } - } - } - Some(Data::TrackDescriptor(td)) => { - if let Some(thread) = td.thread - && let Some(tid) = thread.tid - { - thread_meta - .entry(tid as u32) - .or_insert_with(|| ThreadMetadata { - name: thread.thread_name, - priority: None, - }); - // Associate this packet sequence with the thread so that - // StreamingProfilePacket samples can resolve their tid. - // By producer convention (not a protocol guarantee), - // TrackDescriptor is emitted before data packets on the - // same sequence. If violated, samples fall back to tid 0. - if seq_id != 0 { - seq_id_to_tid.entry(seq_id).or_insert(tid as u32); - } - } - } Some(Data::PerfSample(ps)) => { if let Some(callstack_iid) = ps.callstack_iid { let ts = packet.timestamp.unwrap_or(0); @@ -253,24 +217,6 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } } } - Some(Data::StreamingProfilePacket(spp)) => { - let tid = seq_id_to_tid.get(&seq_id).copied().unwrap_or(0); - let mut ts = packet.timestamp.unwrap_or(0); - for (i, &cs_iid) in spp.callstack_iid.iter().enumerate() { - if let Some(&delta) = spp.timestamp_delta_us.get(i) { - // `delta` is i64 (can be negative for out-of-order samples). - // Casting to u64 wraps negative values, which is correct because - // `wrapping_add` of a wrapped negative value subtracts as expected. - ts = ts.wrapping_add((delta * 1000) as u64); - } - sample_count += 1; - if let Some(stack_id) = - resolve_callstack(cs_iid, seq_id, &tables_by_seq, &mut ctx) - { - resolved_samples.push((ts, tid, stack_id)); - } - } - } None => {} } @@ -284,8 +230,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } // On Android/Linux the main thread's tid equals the process pid. - // If the trace didn't include a ProcessTree or TrackDescriptor with a name - // for that thread, label it "main" so the UI can identify it. + // Label it "main" so the UI can identify it. if let Some(pid) = observed_pid { thread_meta.entry(pid).or_insert_with(|| ThreadMetadata { name: Some("main".to_owned()), @@ -298,7 +243,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), resolved_samples.sort_by_key(|s| s.0); let mut samples: Vec = Vec::new(); - for &(ts_ns, tid, stack_id) in &resolved_samples { + for (ts_ns, tid, stack_id) in resolved_samples { // Compute absolute timestamp in integer nanoseconds first, then convert // to f64 seconds once to avoid precision loss from adding large floats. let abs_ns = ts_ns as i128 + clock_offset_ns; @@ -410,17 +355,7 @@ fn collect_debug_image( ) -> Option { let mapping = tables.mappings.get(&mapping_id)?; - let code_file = { - let parts: Vec<&str> = mapping - .path_string_ids - .iter() - .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) - .collect(); - if parts.is_empty() { - return None; - } - parts.join("/") - }; + let code_file = resolve_mapping_path(mapping, tables)?; if is_java_mapping(&code_file) { return None; @@ -466,18 +401,7 @@ fn build_frame( ) -> (FrameKey, Frame) { let mapping = pf.mapping_id.and_then(|mid| tables.mappings.get(&mid)); - let mapping_path = mapping.and_then(|m| { - let parts: Vec<&str> = m - .path_string_ids - .iter() - .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) - .collect(); - if parts.is_empty() { - None - } else { - Some(parts.join("/")) - } - }); + let mapping_path = mapping.and_then(|m| resolve_mapping_path(m, tables)); let is_java = mapping_path.as_deref().is_some_and(is_java_mapping); @@ -534,6 +458,22 @@ fn build_frame( } } +/// Joins a mapping's interned path segments with `/` into a single file path. +/// +/// Returns `None` if the mapping has no resolvable path segments. +fn resolve_mapping_path(mapping: &proto::Mapping, tables: &InternTables) -> Option { + let parts: Vec<&str> = mapping + .path_string_ids + .iter() + .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + /// Returns `true` if the mapping path indicates a JVM/ART runtime mapping. fn is_java_mapping(path: &str) -> bool { const JVM_EXTENSIONS: &[&str] = &[".oat", ".odex", ".vdex", ".jar", ".dex"]; @@ -720,25 +660,9 @@ mod tests { ..Default::default() }, ), - // Thread descriptor. - proto::TracePacket { - timestamp: None, - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::TrackDescriptor(proto::TrackDescriptor { - uuid: None, - thread: Some(proto::ThreadDescriptor { - pid: Some(100), - tid: Some(42), - thread_name: Some("main-thread".to_owned()), - }), - })), - }, - make_perf_sample_packet(1_000_000_000, 1, 42, 1), - make_perf_sample_packet(1_010_000_000, 1, 42, 1), + // pid == tid so the main-thread fallback names the thread. + make_perf_sample_packet_with_pid(1_000_000_000, 1, 42, 42, 1), + make_perf_sample_packet_with_pid(1_010_000_000, 1, 42, 42, 1), ], }; trace.encode_to_vec() @@ -752,23 +676,45 @@ mod tests { let (data, _images) = result.unwrap(); - assert_eq!(data.samples.len(), 2); - assert_eq!(data.samples[0].thread_id, "42"); - assert_eq!(data.frames.len(), 2); - - assert_eq!(data.stacks.len(), 1); - let stack = &data.stacks[0]; - assert_eq!(stack.len(), 2); - - // Leaf-first order: foo, then main. - assert_eq!(data.frames[stack[0]].function.as_deref(), Some("foo")); - assert_eq!(data.frames[stack[1]].function.as_deref(), Some("main")); - - assert!(data.thread_metadata.contains_key("42")); - assert_eq!( - data.thread_metadata["42"].name.as_deref(), - Some("main-thread") - ); + insta::assert_json_snapshot!(data, @r###" + { + "samples": [ + { + "timestamp": 1700000001.0, + "stack_id": 0, + "thread_id": "42" + }, + { + "timestamp": 1700000001.01, + "stack_id": 0, + "thread_id": "42" + } + ], + "stacks": [ + [ + 1, + 0 + ] + ], + "frames": [ + { + "function": "main", + "instruction_addr": "0x1000", + "platform": "native" + }, + { + "function": "foo", + "instruction_addr": "0x2000", + "platform": "native" + } + ], + "thread_metadata": { + "42": { + "name": "main" + } + } + } + "###); } #[test] @@ -811,63 +757,6 @@ mod tests { assert!(matches!(result, Err(ProfileError::InvalidSampledProfile))); } - #[test] - fn test_streaming_profile_packet() { - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"func_a")], - frames: vec![make_frame(1, 1)], - callstacks: vec![proto::Callstack { - iid: Some(10), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - proto::TracePacket { - timestamp: Some(2_000_000_000), - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::StreamingProfilePacket( - proto::StreamingProfilePacket { - callstack_iid: vec![10, 10], - timestamp_delta_us: vec![5_000, 10_000], // +5ms, +10ms - }, - )), - }, - ], - }; - let bytes = trace.encode_to_vec(); - let result = convert(&bytes); - assert!(result.is_ok(), "conversion failed: {result:?}"); - - let (data, _images) = result.unwrap(); - assert_eq!(data.samples.len(), 2); - // Timestamps are rebased using ClockSnapshot: offset = REALTIME - BOOTTIME. - let duration = data.samples[1].timestamp.to_f64() - data.samples[0].timestamp.to_f64(); - assert!( - (duration - 0.01).abs() < 0.001, - "expected ~10ms delta between samples, got {duration}" - ); - // First sample: base timestamp 2.0s + first delta 5ms = 2.005s boottime, - // then rebased with clock offset. - let expected_offset = (TEST_REALTIME_NS as f64 - TEST_BOOTTIME_NS as f64) / 1e9; - let expected_ts = 2.005 + expected_offset; - assert!( - (data.samples[0].timestamp.to_f64() - expected_ts).abs() < 0.001, - "expected first sample at ~{expected_ts}, got {}", - data.samples[0].timestamp.to_f64() - ); - } - #[test] fn test_mapping_resolution() { let trace = proto::Trace { @@ -908,13 +797,16 @@ mod tests { let bytes = trace.encode_to_vec(); let (data, images) = convert(&bytes).unwrap(); - assert_eq!(data.frames.len(), 1); - let frame = &data.frames[0]; - assert_eq!(frame.platform.as_deref(), Some("native")); - assert_eq!(frame.function.as_deref(), Some("my_func")); - assert_eq!(frame.instruction_addr, Some(Addr(0x7100))); // rel_pc + start - assert_eq!(frame.package.as_deref(), Some("libfoo.so")); - assert!(frame.module.is_none()); + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "my_func", + "instruction_addr": "0x7100", + "package": "libfoo.so", + "platform": "native" + } + ] + "###); // No build_id on the mapping, so no debug images. assert!(images.is_empty()); } @@ -1244,13 +1136,16 @@ mod tests { let bytes = trace.encode_to_vec(); let (data, _images) = convert(&bytes).unwrap(); - assert_eq!(data.frames.len(), 1); - let frame = &data.frames[0]; - assert_eq!(frame.platform.as_deref(), Some("java")); - assert_eq!(frame.module.as_deref(), Some("android.view.View")); - assert_eq!(frame.function.as_deref(), Some("draw")); - assert_eq!(frame.package.as_deref(), Some("boot-framework.oat")); - assert!(frame.instruction_addr.is_none()); + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "draw", + "module": "android.view.View", + "package": "boot-framework.oat", + "platform": "java" + } + ] + "###); } #[test] @@ -1290,13 +1185,16 @@ mod tests { let bytes = trace.encode_to_vec(); let (data, _images) = convert(&bytes).unwrap(); - assert_eq!(data.frames.len(), 1); - let frame = &data.frames[0]; - assert_eq!(frame.platform.as_deref(), Some("native")); - assert_eq!(frame.function.as_deref(), Some("__epoll_pwait")); - assert_eq!(frame.package.as_deref(), Some("libc.so")); - assert_eq!(frame.instruction_addr, Some(Addr(0x7100))); // rel_pc + start - assert!(frame.module.is_none()); + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "__epoll_pwait", + "instruction_addr": "0x7100", + "package": "libc.so", + "platform": "native" + } + ] + "###); } #[test] @@ -1388,76 +1286,10 @@ mod tests { "###); } - #[test] - fn test_process_tree_thread_names() { - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - // ProcessTree with thread names. - proto::TracePacket { - timestamp: None, - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: None, - data: Some(proto::trace_packet::Data::ProcessTree(proto::ProcessTree { - threads: vec![ - proto::process_tree::Thread { - tid: Some(42), - name: Some("main".to_owned()), - tgid: Some(42), - }, - proto::process_tree::Thread { - tid: Some(43), - name: Some("RenderThread".to_owned()), - tgid: Some(42), - }, - ], - })), - }, - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"doWork")], - frames: vec![proto::Frame { - iid: Some(1), - function_name_id: Some(1), - mapping_id: None, - rel_pc: None, - }], - callstacks: vec![proto::Callstack { - iid: Some(1), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - make_perf_sample_packet(1_000_000_000, 1, 42, 1), - make_perf_sample_packet(1_010_000_000, 1, 43, 1), - ], - }; - let bytes = trace.encode_to_vec(); - let (data, _images) = convert(&bytes).unwrap(); - - assert_eq!(data.thread_metadata.len(), 2); - assert_eq!( - data.thread_metadata - .get("42") - .and_then(|m| m.name.as_deref()), - Some("main"), - ); - assert_eq!( - data.thread_metadata - .get("43") - .and_then(|m| m.name.as_deref()), - Some("RenderThread"), - ); - } - #[test] fn test_main_thread_inferred_from_pid() { - // When no ProcessTree/TrackDescriptor provides a thread name, the main - // thread (tid == pid) should be labeled "main" automatically. + // The main thread (tid == pid) is labeled "main" automatically; + // worker threads carry no name source and remain unnamed. let trace = proto::Trace { packet: vec![ make_clock_snapshot_packet(), @@ -1492,99 +1324,6 @@ mod tests { assert!(!data.thread_metadata.contains_key("101")); } - #[test] - fn test_main_thread_not_overwritten_by_pid_inference() { - // If a ProcessTree already provides a name for the main thread, - // pid-based inference must NOT overwrite it. - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - proto::TracePacket { - timestamp: None, - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: None, - data: Some(proto::trace_packet::Data::ProcessTree(proto::ProcessTree { - threads: vec![proto::process_tree::Thread { - tid: Some(100), - name: Some("ui-thread".to_owned()), - tgid: Some(100), - }], - })), - }, - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"doWork")], - frames: vec![make_frame(1, 1)], - callstacks: vec![proto::Callstack { - iid: Some(1), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - make_perf_sample_packet_with_pid(1_000_000_000, 1, 100, 100, 1), - ], - }; - let bytes = trace.encode_to_vec(); - let (data, _images) = convert(&bytes).unwrap(); - - // The ProcessTree name "ui-thread" must be preserved, not replaced with "main". - assert_eq!( - data.thread_metadata - .get("100") - .and_then(|m| m.name.as_deref()), - Some("ui-thread"), - ); - } - - #[test] - fn test_main_thread_no_pid_for_streaming_packets() { - // StreamingProfilePacket doesn't carry a pid, so no main thread inference - // should occur. thread_metadata should be empty. - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"func")], - frames: vec![make_frame(1, 1)], - callstacks: vec![proto::Callstack { - iid: Some(1), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - proto::TracePacket { - timestamp: Some(2_000_000_000), - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::StreamingProfilePacket( - proto::StreamingProfilePacket { - callstack_iid: vec![1], - timestamp_delta_us: vec![0], - }, - )), - }, - ], - }; - let bytes = trace.encode_to_vec(); - let (data, _images) = convert(&bytes).unwrap(); - - assert!( - data.thread_metadata.is_empty(), - "expected no thread metadata for streaming packets without ProcessTree" - ); - } - #[test] fn test_exceeds_max_samples() { let mut packets = vec![ @@ -1615,57 +1354,6 @@ mod tests { ); } - #[test] - fn test_negative_timestamp_delta() { - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"func_a")], - frames: vec![make_frame(1, 1)], - callstacks: vec![proto::Callstack { - iid: Some(10), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - proto::TracePacket { - timestamp: Some(3_000_000_000), - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::StreamingProfilePacket( - proto::StreamingProfilePacket { - callstack_iid: vec![10, 10, 10], - timestamp_delta_us: vec![1_000, 20_000, -5_000], // +1ms, +20ms, -5ms - }, - )), - }, - ], - }; - let bytes = trace.encode_to_vec(); - let (data, _images) = convert(&bytes).unwrap(); - - assert_eq!(data.samples.len(), 3); - // After sorting: sample at 3.001s, then 3.001+0.015=3.016s, then 3.001+0.020=3.021s - let t0 = data.samples[0].timestamp.to_f64(); - let t1 = data.samples[1].timestamp.to_f64(); - let t2 = data.samples[2].timestamp.to_f64(); - assert!( - t0 < t1 && t1 < t2, - "expected sorted timestamps: {t0}, {t1}, {t2}" - ); - // The gap between t1 and t2 should be ~5ms (the -5ms sample comes before the +20ms one). - let gap = t2 - t1; - assert!((gap - 0.005).abs() < 0.001, "expected ~5ms gap, got {gap}"); - } - #[test] fn test_multi_sequence_traces() { let trace = proto::Trace { @@ -1718,73 +1406,6 @@ mod tests { assert!(frame_names.contains(&"beta"), "expected beta frame"); } - #[test] - fn test_streaming_profile_resolves_tid_from_track_descriptor() { - // When a TrackDescriptor with a ThreadDescriptor is present for the same - // trusted_packet_sequence_id, StreamingProfilePacket samples should - // resolve the thread ID from that descriptor instead of defaulting to 0. - let trace = proto::Trace { - packet: vec![ - make_clock_snapshot_packet(), - make_interned_data_packet( - 1, - true, - proto::InternedData { - function_names: vec![make_interned_string(1, b"func_a")], - frames: vec![make_frame(1, 1)], - callstacks: vec![proto::Callstack { - iid: Some(10), - frame_ids: vec![1], - }], - ..Default::default() - }, - ), - // TrackDescriptor associating seq_id=1 with tid=42. - proto::TracePacket { - timestamp: None, - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::TrackDescriptor(proto::TrackDescriptor { - uuid: None, - thread: Some(proto::ThreadDescriptor { - pid: Some(100), - tid: Some(42), - thread_name: Some("worker".to_owned()), - }), - })), - }, - // StreamingProfilePacket on seq_id=1 should get tid=42. - proto::TracePacket { - timestamp: Some(2_000_000_000), - interned_data: None, - sequence_flags: None, - optional_trusted_packet_sequence_id: Some( - proto::trace_packet::OptionalTrustedPacketSequenceId::TrustedPacketSequenceId(1), - ), - data: Some(Data::StreamingProfilePacket( - proto::StreamingProfilePacket { - callstack_iid: vec![10], - timestamp_delta_us: vec![0], - }, - )), - }, - ], - }; - let bytes = trace.encode_to_vec(); - let (data, _images) = convert(&bytes).unwrap(); - - assert_eq!(data.samples.len(), 1); - assert_eq!( - data.samples[0].thread_id, "42", - "StreamingProfilePacket should resolve tid from TrackDescriptor" - ); - assert!(data.thread_metadata.contains_key("42")); - assert_eq!(data.thread_metadata["42"].name.as_deref(), Some("worker")); - } - #[test] fn test_empty_callstack() { let trace = proto::Trace { diff --git a/relay-profiling/src/perfetto/proto.rs b/relay-profiling/src/perfetto/proto.rs index 1f3c651e1b0..85be856e313 100644 --- a/relay-profiling/src/perfetto/proto.rs +++ b/relay-profiling/src/perfetto/proto.rs @@ -17,7 +17,7 @@ pub struct TracePacket { pub optional_trusted_packet_sequence_id: ::core::option::Option, /// Only the oneof variants we care about; prost will skip the rest. - #[prost(oneof = "trace_packet::Data", tags = "2, 6, 54, 60, 66")] + #[prost(oneof = "trace_packet::Data", tags = "6, 66")] pub data: ::core::option::Option, } /// Nested message and enum types in `TracePacket`. @@ -30,37 +30,12 @@ pub mod trace_packet { /// Only the oneof variants we care about; prost will skip the rest. #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Data { - #[prost(message, tag = "2")] - ProcessTree(super::ProcessTree), #[prost(message, tag = "6")] ClockSnapshot(super::ClockSnapshot), - #[prost(message, tag = "54")] - StreamingProfilePacket(super::StreamingProfilePacket), - #[prost(message, tag = "60")] - TrackDescriptor(super::TrackDescriptor), #[prost(message, tag = "66")] PerfSample(super::PerfSample), } } -// --- process tree ------------------------------------------------------------ - -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ProcessTree { - #[prost(message, repeated, tag = "2")] - pub threads: ::prost::alloc::vec::Vec, -} -/// Nested message and enum types in `ProcessTree`. -pub mod process_tree { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct Thread { - #[prost(int32, optional, tag = "1")] - pub tid: ::core::option::Option, - #[prost(string, optional, tag = "2")] - pub name: ::core::option::Option<::prost::alloc::string::String>, - #[prost(int32, optional, tag = "3")] - pub tgid: ::core::option::Option, - } -} // --- clock sync --------------------------------------------------------------- #[derive(Clone, PartialEq, ::prost::Message)] @@ -156,29 +131,4 @@ pub struct PerfSample { #[prost(uint64, optional, tag = "4")] pub callstack_iid: ::core::option::Option, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct StreamingProfilePacket { - #[prost(uint64, repeated, packed = "false", tag = "1")] - pub callstack_iid: ::prost::alloc::vec::Vec, - #[prost(int64, repeated, packed = "false", tag = "2")] - pub timestamp_delta_us: ::prost::alloc::vec::Vec, -} -// --- track descriptors ------------------------------------------------------- - -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct TrackDescriptor { - #[prost(uint64, optional, tag = "1")] - pub uuid: ::core::option::Option, - #[prost(message, optional, tag = "4")] - pub thread: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ThreadDescriptor { - #[prost(int32, optional, tag = "1")] - pub pid: ::core::option::Option, - #[prost(int32, optional, tag = "2")] - pub tid: ::core::option::Option, - #[prost(string, optional, tag = "5")] - pub thread_name: ::core::option::Option<::prost::alloc::string::String>, -} // @@protoc_insertion_point(module) diff --git a/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope b/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope new file mode 100644 index 00000000000..5b1c765bdeb --- /dev/null +++ b/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope @@ -0,0 +1,1369 @@ +{"event_id":"c3b09c0608844f558eaf6e65df6b9cdf","sdk":{"name":"sentry.java.android","version":"8.38.0","packages":[{"name":"maven:io.sentry:sentry","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-core","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-fragment","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-timber","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-replay","version":"8.38.0"},{"name":"maven:io.sentry:sentry-spotlight","version":"8.38.0"},{"name":"maven:io.sentry:sentry-compose","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-ndk","version":"8.38.0"}],"integrations":["Screenshot","ViewHierarchy","UncaughtExceptionHandler","ShutdownHook","Spotlight","SendCachedEnvelope","Ndk","Tombstone","AppLifecycle","AnrV2","AnrProfiling","ActivityLifecycle","ActivityBreadcrumbs","UserInteraction","FeedbackShake","FragmentLifecycle","Timber","AppComponentsBreadcrumbs","NetworkBreadcrumbs","AutoInit","EnvelopeFileObserver","SystemEventsBreadcrumbs"]}} +{"content_type":"application/octet-stream","filename":"profile_sentry-profiling_2026-04-28-08-33-40.perfetto-stack-sample","type":"profile_chunk","platform":"android","meta_length":7739,"length":104991} +{"profiler_id":"814b081c638b4ad982ae351547bfe499","chunk_id":"c3b09c0608844f558eaf6e65df6b9cdf","client_sdk":{"name":"sentry.java.android","version":"8.38.0","packages":[{"name":"maven:io.sentry:sentry","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-core","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-fragment","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-timber","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-replay","version":"8.38.0"},{"name":"maven:io.sentry:sentry-spotlight","version":"8.38.0"},{"name":"maven:io.sentry:sentry-compose","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-ndk","version":"8.38.0"}],"integrations":["Screenshot","ViewHierarchy","UncaughtExceptionHandler","ShutdownHook","Spotlight","SendCachedEnvelope","Ndk","Tombstone","AppLifecycle","AnrV2","AnrProfiling","ActivityLifecycle","ActivityBreadcrumbs","UserInteraction","FeedbackShake","FragmentLifecycle","Timber","AppComponentsBreadcrumbs","NetworkBreadcrumbs","AutoInit","EnvelopeFileObserver","SystemEventsBreadcrumbs"]},"measurements":{"memory_native_footprint":{"unit":"byte","values":[{"value":3.6631152E7,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":3.6636E7,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":3.6598336E7,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":3.6600496E7,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":3.6601984E7,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":3.6604128E7,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":3.6606272E7,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":3.6608416E7,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":3.6614672E7,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":3.6616816E7,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":3.661896E7,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":3.6621104E7,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":3.6623248E7,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":3.6625392E7,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":3.6627536E7,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":3.662968E7,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":3.6631824E7,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":3.6672752E7,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":3.6748144E7,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":3.6754304E7,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"frozen_frame_renders":{"unit":"nanosecond","values":[{"value":7.49833322E8,"elapsed_since_start_ns":"71057630775779","timestamp":1777358020.888000}]},"cpu_usage":{"unit":"percent","values":[{"value":56.20094079375762,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":47.72786092177692,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":52.289708049827254,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":50.050196342916244,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":52.620478795841386,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":49.83694994597027,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":52.61821576681683,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":50.00733407561553,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":52.31104830862539,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":50.08750688152257,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":52.61428295786996,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":49.84011689221911,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":50.07609463188072,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":52.764437950744615,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":49.7033742388127,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":52.63426105211958,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":49.806191656715804,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":52.611141035437356,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":32.55163503106803,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":2.50511253386361,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"memory_footprint":{"unit":"byte","values":[{"value":1.18884E7,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":1.2003504E7,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":1.2056752E7,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":1.211E7,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":1.213048E7,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":1.215096E7,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":1.22124E7,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":1.223288E7,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":1.2286128E7,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":1.2339376E7,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":1.2359856E7,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":1.2421296E7,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":1.2441776E7,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":1.2495024E7,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":1.2515504E7,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":1.2535984E7,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":1.2597424E7,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":1.2617904E7,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":1.2892512E7,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":1.294576E7,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"screen_frame_rates":{"unit":"hz","values":[{"value":60.000003814697266,"elapsed_since_start_ns":"71057630775779","timestamp":1777358020.888000}]}},"platform":"android","release":"io.sentry.samples.android@8.38.0+2","environment":"debug","version":"2","content_type":"perfetto","timestamp":1777358020.855000} +U2N + + +  + +巜 +  + + + +NP +U2N + + +  + +巜 +  + + + +NP +NPGzvB32mWy +oNPg + 9 +7 + +linux.perf(zd X +io.sentry.samples.android :p +NP +恀"Perfetto v46.0 (N/A)8x +m +Linux+#1 SMP PREEMPT Tue Jun 18 20:50:32 UTC 2024"aarch64.6.6.30-android15-8-gdd9c02ccfe27-ab11987101-4k0 @Ngoogle/sdk_gphone64_arm64/emu64a:15/AE3A.240806.036/12592187:user/release-keys(#JranchuNP +@ᎤNP +@NP +@ލNP +@NP( +*@h b +d NPu +!b*hNPu +h@b&"[anon_shmem:dalvik-jit-code-cache]@ (08*9+5io.sentry.samples.android.ProfilingActivity.fibonacci2 + ؊2 + 2 + ܊apex com.android.art lib64   libart.so +ܹ+\?h*`B}#@ (0 +88 88 *art_quick_invoke_stub2  *uq_ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE2  *OK_ZN3art11interpreter20ExecuteSwitchImplCppILb0EEEvPNS0_17SwitchImplContextE2   ȴ*ExecuteSwitchImplAsm2    data!io.sentry.samples.android" +code_cache #.overlay $base.apk% classes19.dex&@ (08 8 8!8"8#8$8%*A*=io.sentry.samples.android.ProfilingActivity.runMathOperations2 +* *'_ZN3art11interpreterL7ExecuteEPNS_6ThreadERKNS_20CodeItemDataAccessorERNS_11ShadowFrameENS_6JValueEbb.__uniq.112435418011751916792819755956732575238.llvm.28456970603708385182 ' 2  *S)Oio.sentry.samples.android.ProfilingActivity.onCreate$lambda$9$lambda$8$lambda$72 +) *V(Rio.sentry.samples.android.ProfilingActivity.$r8$lambda$X9B0bkXYlGwrLTB23XqGg4LznRs2 +( Н*M&Iio.sentry.samples.android.ProfilingActivity$$ExternalSyntheticLambda5.run2 +& *artQuickToInterpreterBridge2  + *#art_quick_to_interpreter_bridge2   *73java.util.concurrent.Executors$RunnableAdapter.call2 + T*'#java.util.concurrent.FutureTask.run2 + T*51java.util.concurrent.ThreadPoolExecutor.runWorker2 + ܐT*62java.util.concurrent.ThreadPoolExecutor$Worker.run2 + T [anon:dalvik- javalibcore-oj.jar-transformed]"@ (0888 88*java.lang.Thread.run2   a*;7_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc2  *_ZN3art9ArtMethod14InvokeInstanceILc86ETpTncJEEENS_6detail12ShortyTraitsIXT_EE4TypeEPNS_6ThreadENS_6ObjPtrINS_6mirror6ObjectEEEDpNS3_IXT0_EE4TypeE2  *%!_ZN3art6Thread14CreateCallbackEPv2  ͏*/ +_ZN3art6Thread24CreateCallbackWithUffdGcEPv2   com.android.runtime +bionic libc.so4~dl&$@ (088888* _ZL15__pthread_startPv2 +  Ԇ*__start_thread2 + :t:  +     +        tu(0 :NPu +h@鸆b2 + :|M  +     +        tu(0 MNPu +h@̽b2 + ؋:vX  +     +        tu(0 XNPu +h@މb2 + :rh  +     +        tu(0ڌ hNPu +h@븆bz:xv  +     +        tu(0 vNPu +h@Ĕνb:}  +     +        tu(0ç NPu +h@ІbꞀ*2 P *=Z9_ZN3art3jit12JitCodeCache14GetJniStubCodeEPNS_9ArtMethodE2 OZ *BY>_ZN3art12StackVisitor9WalkStackILNS0_16CountTransitionsE0EEEvb2 NY *<X8_ZN3art24JniDecodeReferenceResultEP8_jobjectPNS_6ThreadE2 MX Ի*Wart_jni_trampoline2 +LW *Vjava.lang.Object.clone2 KV N*Ujava.util.TimeZone.clone2 +JU `*"Tjava.util.SimpleTimeZone.clone2 +IT `*"Sjava.util.TimeZone.getTimeZone2 +HS `* Rart_quick_invoke_static_stub2 GR 2 F <com.android.conscrypt= conscrypt.jar @ (088<88=*Q7com.android.org.conscrypt.OpenSSLX509Certificate.toDate2 +EQ *;P7com.android.org.conscrypt.OpenSSLX509Certificate.2 +DP *KOGcom.android.org.conscrypt.OpenSSLX509Certificate.fromX509DerInputStream2 +CO *TNPcom.android.org.conscrypt.OpenSSLX509CertificateFactory$1.fromX509DerInputStream2 +BN 2 +AN *OMKcom.android.org.conscrypt.OpenSSLX509CertificateFactory$Parser.generateItem2 +@M *ULQcom.android.org.conscrypt.OpenSSLX509CertificateFactory.engineGenerateCertificate2 +?L *=K9java.security.cert.CertificateFactory.generateCertificate2 >K *<J8com.android.org.conscrypt.SSLUtils.decodeX509Certificate2 +=J *AI=com.android.org.conscrypt.SSLUtils.decodeX509CertificateChain2 +<I *;H7com.android.org.conscrypt.NativeSsl.getPeerCertificates2 +;H *FGBcom.android.org.conscrypt.ActiveSession.onPeerCertificateAvailable2 +:G *7F3com.android.org.conscrypt.ConscryptEngine.handshake2 +9F *E~_ZN3art11interpreter33ArtInterpreterToInterpreterBridgeEPNS_6ThreadERKNS_20CodeItemDataAccessorEPNS_11ShadowFrameEPNS_6JValueE2 8E *uDq_ZN3art11interpreter6DoCallILb1EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE2 7D 2 6 *2B.com.android.org.conscrypt.ConscryptEngine.wrap2 +5B Ԅ* Cjavax.net.ssl.SSLEngine.wrap2 4C 2 +3B *QAMcom.android.org.conscrypt.ConscryptEngineSocket$SSLOutputStream.writeInternal2 +2A Ⱥ*Z@Vcom.android.org.conscrypt.ConscryptEngineSocket$SSLOutputStream.-$$Nest$mwriteInternal2 +1@ ̶*??;com.android.org.conscrypt.ConscryptEngineSocket.doHandshake2 +0? *B>>com.android.org.conscrypt.ConscryptEngineSocket.startHandshake2 +/> *<;8com.android.okhttp.internal.io.RealConnection.connectTls2 .; *?:;com.android.okhttp.internal.io.RealConnection.connectSocket2 +-: x*995com.android.okhttp.internal.io.RealConnection.connect2 +,9 ̡x*D8@com.android.okhttp.internal.http.StreamAllocation.findConnection2 ++8 u*K7Gcom.android.okhttp.internal.http.StreamAllocation.findHealthyConnection2 +*7 u*?6;com.android.okhttp.internal.http.StreamAllocation.newStream2 +)6 u*753com.android.okhttp.internal.http.HttpEngine.connect2 +(5 Ȭu*;47com.android.okhttp.internal.http.HttpEngine.sendRequest2 +'4 t*A3=com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute2 +&3 s*A2=com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect2 +%2 o*H1Dcom.android.okhttp.internal.huc.DelegatingHttpsURLConnection.connect2 $1 *B0>com.android.okhttp.internal.huc.HttpsURLConnectionImpl.connect2 #0 ̥@ (0*7/3io.sentry.transport.HttpConnection.createConnection2 +"/ *+.'io.sentry.transport.HttpConnection.send2 +!. *?-;io.sentry.transport.AsyncHttpTransport$EnvelopeSender.flush2 + - *=,9io.sentry.transport.AsyncHttpTransport$EnvelopeSender.run2 +, :  +     +     !  "  #$%&'()*+,-.  +/  0  1  2  3  4  +5 6789  :  ;  <  =  >  +?  @  A  B  C  D  E  FGHIJKLMNOPtt(0ΐ! NPu + h@膔bȠ +|vendor}libGLESv2_enc.so{UrF0X3]z@  @ (0{8|88}**~&_ZN10GL2Encoder17s_glActiveTextureEPvj2 +l~  +\system` +libhwui.so_G +k)hKq-! +@ (0_8\88`*,z(_ZN7GrGLGpu24bindTextureToScratchUnitEji2 kz + Բ*RyN_ZN7GrGLGpu13onWritePixelsEP9GrSurface7SkIRect11GrColorTypeS3_PK10GrMipLevelib2 jy + ȯ*NxJ_ZN5GrGpu11writePixelsEP9GrSurface7SkIRect11GrColorTypeS3_PK10GrMipLevelib2 ix + *`w\_ZNK18GrResourceProvider11writePixelsE5sk_spI9GrTextureE11GrColorType7SkISizePK10GrMipLeveli2 hw + ؼ*偀v_ZN18GrResourceProvider13createTextureE7SkISizeRK15GrBackendFormat13GrTextureType11GrColorTypeN5skgpu10RenderableEiNS6_8BudgetedENS6_9MipmappedENS6_9ProtectedEPK10GrMipLevelNSt3__117basic_string_viewIcNSE_11char_traitsIcEEEE2 gv + ܱ*؁u_ZZN15GrProxyProvider30createNonMippedProxyFromBitmapERK8SkBitmap12SkBackingFitN5skgpu8BudgetedEENK3$_0clEP18GrResourceProviderRKN14GrSurfaceProxy15LazySurfaceDescE.__uniq.2522539992798261353922680713117104824422 fu + ܥ*FtB_ZN18GrSurfaceProxyPriv19doLazyInstantiationEP18GrResourceProvider2 et + *ise_ZN15GrProxyProvider21createProxyFromBitmapERK8SkBitmapN5skgpu9MipmappedE12SkBackingFitNS3_8BudgetedE2 ds + *r_ZL14make_bmp_proxyP15GrProxyProviderRK8SkBitmap11GrColorTypeN5skgpu9MipmappedE12SkBackingFitNS5_8BudgetedE.__uniq.200737704020540266365245535337625923746.llvm.47135207145919736612 cr + *q_Z27GrMakeCachedBitmapProxyViewP18GrRecordingContextRK8SkBitmapNSt3__117basic_string_viewIcNS4_11char_traitsIcEEEEN5skgpu9MipmappedE2 bq + ԛ*p}_ZN5skgpu6ganesh19AsFragmentProcessorEP18GrRecordingContextPK7SkImage17SkSamplingOptionsPK10SkTileModeRK8SkMatrixPK6SkRectSF_2 ap + *o_ZN5skgpu6ganesh6Device15drawEdgeAAImageEPK7SkImageRK6SkRectS7_PK7SkPointN8SkCanvas11QuadAAFlagsERK8SkMatrixRK17SkSamplingOptionsRK7SkPaintNSB_17SrcRectConstraintESF_10SkTileMode2 `o + Ы*n_ZN5skgpu6ganesh6Device19drawImageQuadDirectEPK7SkImageRK6SkRectS7_PK7SkPointN8SkCanvas11QuadAAFlagsEPK8SkMatrixRK17SkSamplingOptionsRK7SkPaintNSB_17SrcRectConstraintE2 _n + *m{_ZN5skgpu6ganesh6Device13drawImageRectEPK7SkImagePK6SkRectRS6_RK17SkSamplingOptionsRK7SkPaintN8SkCanvas17SrcRectConstraintE2 ^m + *olk_ZN8SkCanvas16onDrawImageRect2EPK7SkImageRK6SkRectS5_RK17SkSamplingOptionsPK7SkPaintNS_17SrcRectConstraintE2 ]l + *SkO_ZN7android10uirenderer14VectorDrawable4Tree4drawEP8SkCanvasRK6SkRectRK7SkPaint2 \k + *ViR_ZNK7android10uirenderer12skiapipeline18RenderNodeDrawable11drawContentEP8SkCanvas2 Zi + *OhK_ZN7android10uirenderer12skiapipeline18RenderNodeDrawable6onDrawEP8SkCanvas2 Yh + *.j*_ZN10SkDrawable4drawEP8SkCanvasPK8SkMatrix2 [j + *g_ZN7android10uirenderer12skiapipeline12SkiaPipeline15renderFrameImplERK6SkRectRKNSt3__16vectorINS_2spINS0_10RenderNodeEEENS6_9allocatorISA_EEEEbRKNS0_4RectEP8SkCanvasRK8SkMatrix2 Xg + *Ӂf_ZN7android10uirenderer12skiapipeline12SkiaPipeline11renderFrameERKNS0_16LayerUpdateQueueERK6SkRectRKNSt3__16vectorINS_2spINS0_10RenderNodeEEENS9_9allocatorISD_EEEEbRKNS0_4RectE5sk_spI9SkSurfaceERK8SkMatrix2 Wf + *‚e_ZN7android10uirenderer12skiapipeline18SkiaOpenGLPipeline4drawERKNS0_12renderthread5FrameERK6SkRectS9_RKNS0_13LightGeometryEPNS0_16LayerUpdateQueueERKNS0_4RectEbRKNS0_9LightInfoERKNSt3__16vectorINS_2spINS0_10RenderNodeEEENSL_9allocatorISP_EEEEPNS0_19FrameInfoVisualizerERKNS3_26HardwareBufferRenderParamsERNSL_5mutexE2 Ve + *?d;_ZN7android10uirenderer12renderthread13CanvasContext4drawEb2 Ud + *[cW_ZN7android10uirenderer12renderthread13CanvasContext14prepareAndDrawEPNS0_10RenderNodeE2 Tc + *QbM_ZN7android10uirenderer12renderthread12RenderThread22dispatchFrameCallbacksEv2 Sb + *EaA_ZN7android10uirenderer12renderthread12RenderThread10threadLoopEv2 Ra + 䯸] libutils.so[%ܾ(aY*-  @ (0[8\88]*&^"_ZN7android6Thread11_threadLoopEPv2 +Q^ :gQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghijkltt(0ڜ9 NPu +h@†b{:y  +     +        tu(0 NPu +h@Òdžby:w  +     +        tu(0 NPu +h@ˆb2 m+ Љ:y  +     +        mtu(0 NPu +h@Іb:  +     +        mtu(0Հ! NPu +h@Նbu:s  +     +        mtu(0& NPu +h@Ǚچb2 n+ :u  +     +        ntu(0* NPu +h@ކby:w  +     +        tu(0/ NPu +h@㆔bs:q  +     +        mtu(0ҧ4 NPu +h@膔by:w  +     +        mtu(09 NPu +h@É톔b2 o+ :u  +     +        otu(0= NPu + h@ކb* java.util.Locale.getDefault2  Z*#java.util.Calendar.getInstance2 ~ a*+&io.sentry.DateUtils.getCurrentDateTime2 } *(#io.sentry.SentryNanotimeDate.2 | Մ classes7.dex'@ (08 8 8!8"8#8$8*idio.sentry.android.core.PerfettoContinuousProfiler$ChunkMeasurementCollector$1.onFrameMetricCollected2 {  classes16.dex' @ ̛(08 8 8!8"8#8$8*io.sentry.android.core.internal.util.SentryFrameMetricsCollector.lambda$new$2$io-sentry-android-core-internal-util-SentryFrameMetricsCollector2 z *wrio.sentry.android.core.internal.util.SentryFrameMetricsCollector$$ExternalSyntheticLambda4.onFrameMetricsAvailable2 y *>9android.view.FrameMetricsObserver.onFrameMetricsAvailable2 x м*KFandroid.graphics.HardwareRendererObserver.lambda$notifyDataAvailable$02 w *UPandroid.graphics.HardwareRendererObserver.$r8$lambda$PeqK8_uy-Wp8rbu7N1ihQlz9qD42 v *LGandroid.graphics.HardwareRendererObserver$$ExternalSyntheticLambda0.run2 u س*&!android.os.Handler.handleCallback2 t ̋*'"android.os.Handler.dispatchMessage2 s *android.os.Looper.loopOnce2 r *android.os.Looper.loop2 q   framework framework.jar" @  ಝ(08\88*!android.os.HandlerThread.run2 p ܫy:[  +p  FGqrstuvwx  +y  z 678{  |}~tt(0/ NPu +h@톔bЅ* unlinkat2  /* remove2  libjavacore.so1i8$@ (088 88*3._ZL12Linux_removeP7_JNIEnvP8_jobjectP8_jstring2  2 W *#libcore.io.ForwardingOs.remove2  *#libcore.io.BlockGuardOs.remove2  !@ (08\88*0+android.app.ActivityThread$AndroidOs.remove2  r*"java.io.UnixFileSystem.delete2  *java.io.File.delete2  @ (0**%io.sentry.cache.EnvelopeCache.discard2  :t  +     +         +  tt(0о> NPu +h@†by:w  +     +        mtu(0 NPu +h@Ւdžb{:y  +     +        otu(0 NPu +h@Зˆby:w  +     +        tu(0 NPu +h@Іby:w  +     +        tu(0! NPu +h@يՆb:  +     +        tu(0ڹ& NPu +h@چby:w  +     +        tu(0* NPu +h@ώ߆by:w  +     +        otu(0/ NPu +h@㆔by:w  +     +        tu(0ѵ4 NPu +h@膔b{:y  +     +        otu(09 NPu +h@톔b{:y  +     +        tu(0> NPu +h@돩blibOpenglSystemCommon.so!BO˧$"@8 (08|88*'"_ZN14QemuPipeStream11allocBufferEm2  9*)$_ZN9gfxstream5guest8IOStream5allocEm2  ش9*/*_ZN12_GLOBAL__N_119glActiveTexture_encEPvj2  2 ~ :qQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghijktt(0ψQ NPu + h@㔘b* read2  /*qemu_pipe_read2  9*72_ZN14QemuPipeStream24commitBufferAndReadFullyEmPvm2  9*94_ZN12_GLOBAL__N_124glBufferDataSyncAEMU_encEPvjlPKvj2  *d__ZZL17invalidate_bufferP7GrGLGpujjjmENKUlvE_clEv.__uniq.1474696467244232247310157921930280442822  + *WR_ZL17invalidate_bufferP7GrGLGpujjjm.__uniq.1474696467244232247310157921930280442822  + *)$_ZN10GrGLBuffer12onUpdateDataEPKvmmb2  + Ա*_ZN16GrDrawingManager5flushE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *_ZN16GrDrawingManager13flushSurfacesE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *_ZN19GrDirectContextPriv13flushSurfacesE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *?:_ZN15GrDirectContext14flushAndSubmitEP9SkSurface9GrSyncCpu2  + 2 e + :5QRSTUtt(0h NPu +h@b{:y  +     +        otu(0B NPu +h@b}:{  +     +        otu(0G NPu +h@b{:y  +     +        mtu(0L NPu +h@Ĭb{:y  +     +        tu(0ˈQ NPu +h@厅by:w  +     +        mtu(0U NPu +h@񉇔bw:u  +     +        mtu(0Z NPu +h@ӎby:w  +     +        tu(0ɯ_ NPu +h@b2 + ȉ:x  +     +        tu(0d NPu +h@ؙby:w  +     +        otu(0h NPu +h@bu:s  +     +        tu(0m NPu +h@bw:u  +     +        tu(0B NPu +h@݉b:}  +     +        otu(0޸G NPu +h@b2 + :z  +     +        tu(0L NPu +h@by:w  +     +        mtu(0Q NPu +h@摅by:w  +     +        tu(0U NPu +h@󉇔b{:y  +     +        tu(0Z NPu +h@֎bs:q  +     +        tu(0ů_ NPu +h@by:w  +     +        tu(0d NPu +h@ɜb}:{  +     +        tu(0h NPu +h@bw:u  +     +        ntu(0m NPu +ph@ڀbE2  9:5QRSTUtt(0 NPu +h@LJbՄ*.)_ZThn8_NK14GrOpFlushState12atlasManagerEv2  + ܂*e`_ZN6sktext3gpu11GlyphVector24regenerateAtlasForGaneshEiiN5skgpu10MaskFormatEiP16GrMeshDrawTarget2  + *je_ZNKSt3__18functionIFNS_5tupleIJbiEEEPN6sktext3gpu11GlyphVectorEiiN5skgpu10MaskFormatEiEEclES6_iiS8_i2  + *FA_ZN5skgpu6ganesh11AtlasTextOp14onPrepareDrawsEP16GrMeshDrawTarget2  + 俷*94_ZN5skgpu6ganesh7OpsTask9onPrepareEP14GrOpFlushState2  + *0+_ZN12GrRenderTask7prepareEP14GrOpFlushState2  + 2  + :2QRSTUtt(0˘ NPu +h@ܡbu:s  +     +        mtu(0r NPu +h@쿦bw:u  +     +        tu(0w NPu +h@ܡbw:u  +     +        mtu(0{ NPu +h@㩅bq:o  +     +        tu(0 NPu +h@洇by:w  +     +        tu(0ڡ… NPu +h@ȹbw:u  +     +        ntu(0ޤ NPu +h@b}:{  +     +        otu(0ٳ NPu +h@Çbu:s  +     +        tu(0 NPu +h@LJb{:y  +     +        tu(0՘ NPu +h@̇b{:y  +     +        tu(0׃ NPu + h@b libui.sog}$"@ + (08\88*"_ZN7android5Fence9getStatusEv2  ԉ  libgui.so RhI+][Ī͚"@$ (08\88*fa_ZN7android12ConsumerBase21addReleaseFenceLockedEiNS_2spINS_13GraphicBufferEEERKNS1_INS_5FenceEEE2  1*[V_ZN7android18BufferItemConsumer13releaseBufferERKNS_10BufferItemERKNS_2spINS_5FenceEEE2  1*_ZN7androidL26releaseBufferCallbackThunkENS_2wpINS_16BLASTBufferQueueEEERKNS_17ReleaseCallbackIdERKNS_2spINS_5FenceEEENSt3__18optionalIjEE.__uniq.454506313120748354298817219915909741302  1*߁_ZNSt3__18__invokeB8nn180000IRPFvN7android2wpINS1_16BLASTBufferQueueEEERKNS1_17ReleaseCallbackIdERKNS1_2spINS1_5FenceEEENS_8optionalIjEEEJRS4_S7_SC_SE_EEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSJ_DpOSK_2  /*ZU_ZN7android28TransactionCompletedListener22onTransactionCompletedENS_13ListenerStatsE2  1*PK_ZN7android30BnTransactionCompletedListener10onTransactEjRKNS_6ParcelEPS1_j2  * libbinder.so12B՗x<"@ (08\88*2-_ZN7android14IPCThreadState14executeCommandEi2  *2-_ZN7android14IPCThreadState14joinThreadPoolEb2  **%_ZN7android10PoolThread10threadLoopEv2  libandroid_runtime.so~*kzE#޾}%'"@4 ǃ(08\88*4/_ZN7android14AndroidRuntime15javaThreadShellEPv2  ::*Qtt(0 NPu +h@Πᡇbs:q  +     +        tu(0кr NPu +h@æbw:u  +     +        mtu(0w NPu +h@b}:{  +     +        tu(0{ NPu +h@ꈰb}:{  +     +        tu(0Ӛ NPu +h@촇bu:s  +     +        otu(0Ņ NPu +h@˹b{:y  +     +        tu(0פ NPu +h@b}:{  +     +        mtu(0减 NPu +h@Çbw:u  +     +        tu(0ѫ NPu +h@LJby:w  +     +        tu(0ƁҘ NPu +h@̇by:w  +     +        mtu(0􇮝 NPu ++h@btt(0ͣ NPu +h@чby:w   +     +        tu(0 NPu +h@זևb:}   +     +        tu(0 NPu +h@ڇbw:u   +     +        tu(0ډի NPu +h@߇bu:s   +     +        ntu(0ָ NPu +h@䇔by:w   +     +        tu(0 NPu +h@釔by:w   +     +        mtu(0 NPu +h@Ȃb:   +     +        mtu(0޾ NPu +h@bu:s   +     +        tu(0 NPu +h@bw:u   +     +        tu(0Ѥ NPu +h@bw:u   +     +        mtu(0ȅ NPu +h@чby:w   +     +        tu(0 NPu +h@יևb{:y   +     +        tu(0և NPu +h@ڇbv:t +  +     +        tu(0ի +NPu +h@߇by:w +  +     +        tu(0Ϸ +NPu +h@䇔bk:i +  +     +        ntu(0噵 +NPu +h@釔bu:s +  +     +        tu(0 +NPu +h@b{:y +  +     +        mtu(0޾ +NPu +h@b2 + :x +  +     +        tu(0 +NPu +h@bq:o +  +     +        mtu(0 +NPu +h@b}:{ +  +     +        otu(0ȅ +NPu ++h@ݯbtt(0䞏 NPu +h@֝b*java.util.Locale.hashCode2  */*java.util.concurrent.ConcurrentHashMap.get2  *(#java.util.Calendar.setWeekCountData2  a*java.util.Calendar.2  a*'"java.util.GregorianCalendar.2  Ԉa*&!java.util.Calendar.createCalendar2  a2  a:l +  +p  FGqrstuvwx  +y  z 678{  |}tt(0̟ +NPu +h@ޛb* +write2  ȭ/*qemu_pipe_write2  9*qemu_pipe_write_fully2  9*:5_ZN9gfxstream5guest8IOStream12uploadPixelsEPviiijjPKv2  "*94_ZN12_GLOBAL__N_119glTexSubImage2D_encEPvjiiiiijjPKv2  *50_ZN10GL2Encoder17s_glTexSubImage2DEPvjiiiiijjPKv2  egllibGLESv2_emulation.sopZw%@ (08|888*glTexSubImage2D2  *C>_ZN7GrGLGpu13uploadTexDataE7SkISizej7SkIRectjjmPK10GrMipLeveli2  + *e`_ZN7GrGLGpu22uploadColorTypeTexDataE10GrGLFormat11GrColorType7SkISizej7SkIRectS1_PK10GrMipLeveli2  + :| +QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 +NPu +h@􌁈bu:s +  +     +        ntu(0 +NPu +h@by:w +  +     +        tu(0 +NPu +h@Њbw:u +  +     +        mtu(0ά +NPu +h@ܳb:   +     +        tu(0 NPu +h@by:w   +     +        tu(0٥ NPu +h@bk:i   +     +        ntu(0 NPu +h@ٝb{:y   +     +        mtu(0 NPu +h@b:}   +     +        mtu(0 NPu +h@Ϡby:w   +     +        otu(0 NPu +h@bw:u   +     +        ntu(0Ǩ NPu +h@Îbu:s   +     +        otu(0 NPu +h@ը񅈔b{:y   +     +        otu(0 NPu +h@ӊb{:y   +     +        tu(0֬ NPu +h@ʷby:w   +     +        tu(0 NPu +h@Ʈb{:y   +     +        mtu(0 NPu +h@by:w   +     +        tu(0 NPu +h@ܝb}:{   +     +        tu(0 NPu +h@bs:q   +     +        otu(0Я NPu +h@bm:k   +     +        mtu(0 NPu +h@bw:u   +     +        mtu(0̦ NPu +h@b2  :y QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 NPu +h@ֈbl2 u + Ф:\ QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdett(0ҧ NPu +h@㰈by:w   +     +        mtu(0 NPu +h@ŵbs:q   +     +        ntu(0塆 NPu +h@혨bx:v   +     +        tu(0 NPu +h@by:w   +     +        mtu(0 NPu +h@Èb{:y   +     +        tu(0Ȕ NPu +h@Ȉby:w   +     +        tu(0 NPu +h@ʼ͈bx:v   +     +        tu(0č NPu +h@҈b:   +     +        mtu(0 NPu +h@ֈb}:{   +     +        tu(0ӧ NPu +h@ۈbw:u   +     +        tu(0ʴ NPu +h@氈bs:q   +     +        tu(0 NPu +h@ȵb{:y   +     +        tu(0 NPu +h@ꪺb{:y   +     +        tu(0 NPu +h@by:w   +     +        tu(0Ь NPu +h@Èb}:{   +     +        tu(0ɔ NPu +h@Ȉby:w   +     +        tu(0͘ NPu +h@͈bu:s   +     +        tu(0ȍ NPu +h@҈by:w   +     +        otu(0 NPu +h@جֈb~:|  +     +        tu(0ԧ NPu +h@ۈbw:u  +     +        tu(0ܴ NPu +h@blibOpenglCodecCommon.sobsCQ"@ (08|88*94_ZN9gfxstream5guest13GLClientState13setPixelStoreEji2  2  + :jQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0𐾿 NPu +h@ʆbr*% _ZN10GL2Encoder12s_glGetErrorEPv2  2  + Ⱥ:,QRSTUtt(0 NPu +h@by:w  +     +        tu(0 NPu +h@刔bw:u  +     +        tu(0 NPu +h@鈔bw:u  +     +        mtu(0ۺ NPu +h@bw:u  +     +        tu(0 NPu +h@֠b{:y  +     +        tu(0ߧ NPu +h@bw:u  +     +        mtu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@끉b{:y  +     +        tu(0 NPu +h@Άbr:p  +     +        tu(0 NPu +h@bw:u  +     +        mtu(0؊ NPu +h@bs:q  +     +        mtu(0 NPu +h@刔b:}  +     +        mtu(0 NPu +h@ꈔby:w  +     +        tu(0ۺ NPu +h@b{:y  +     +        tu(0 NPu +h@by:w  +     +        tu(0ۨ NPu +h@ȭbu:s  +     +        tu(0݂ NPu +h@Ջbw:u  +     +        otu(0φ NPu +h@b}:{  +     +        tu(0 NPu +h@ׇԆb}:{  +     +        mtu(0 NPu +h@ٲbu:s  +     +        mtu(0 NPu +h@񔉔bl*__memset_aarch642  Է2  П:7  +p  FGqrstuvwx tt(0 NPu +h@b**%_ZNK12SkImage_Base15isTextureBackedEv2  + ߗ*_ZN5skgpu6ganesh6Device20drawAsTiledImageRectEP8SkCanvasPK7SkImagePK6SkRectRS8_RK17SkSamplingOptionsRK7SkPaintNS2_17SrcRectConstraintE2  + 2 l + *lg_ZNK7android10uirenderer4$_27clEPKvP8SkCanvasRK8SkMatrix.__uniq.1508489786452546026330485181743555618392  + :WQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0Օ NPu ++h@btt(0 +NPu +h@b:}  +     +        tu(0þ NPu +h@by:w  +     +        tu(0 NPu +h@Њיbu:s  +     +        ntu(0 NPu +h@﹞bu:s  +     +        mtu(0 NPu +h@団b{:y  +     +        ntu(0 NPu +h@b{:y  +     +        tu(0 NPu +h@ଉbe:c  +     +        tu(0 NPu +h@±bu:s  +     +        tu(0 NPu +h@b}:{  +     +        mtu(0ں NPu +h@܇by:w  +     +        mtu(0 NPu +h@啐b{:y  +     +        tu(0 NPu +h@b:  +     +        mtu(0 NPu +h@ٙb{:y  +     +        ntu(0 NPu +h@bs:q  +     +        mtu(0ڕ NPu +h@՞bw:u  +     +        mtu(0 NPu +h@뀨bo:m  +     +        tu(0ϛ NPu +h@֥㬉b{:y  +     +        mtu(0Լ NPu +h@űbs:q  +     +        mtu(0 NPu +h@bv:t  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +uh@ĉbJ2  ::  +p  FGqrstuvwx  +y tt(0࿨ NPu + h@ɍΉb*c^_ZN5scudo9AllocatorINS_19AndroidNormalConfigEXadL_Z21scudo_malloc_postinitEEE10reallocateEPvmm2  * scudo_realloc2  * realloc2  *2-_ZN21SkAnalyticEdgeBuilder7addLineEPK7SkPoint2  + *72_ZN13SkEdgeBuilder10buildEdgesERK6SkPathPK7SkIRect2  + ؂*?:_ZN6SkScan11AAAFillPathERK6SkPathP9SkBlitterRK7SkIRectS7_b2  + *B=_ZN6SkScan12AntiFillPathERK6SkPathRK12SkRasterClipP9SkBlitter2  + *JE_ZNK10SkDrawBase8drawPathERK6SkPathRK7SkPaintPK8SkMatrixbbP9SkBlitter2  + ƕ*61_ZN14SkBitmapDevice8drawPathERK6SkPathRK7SkPaintb2  + 訄*1,_ZN8SkCanvas10onDrawPathERK6SkPathRK7SkPaint2  + *.)_ZN8SkCanvas8drawPathERK6SkPathRK7SkPaint2  + *FA_ZN7android10uirenderer14VectorDrawable8FullPath4drawEP8SkCanvasb2  + *C>_ZN7android10uirenderer14VectorDrawable5Group4drawEP8SkCanvasb2  + *RM_ZN7android10uirenderer14VectorDrawable4Tree17updateBitmapCacheERNS_6BitmapEb2  + 2 k + ج:uQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu ++h@ά剔btt(0ض NPu +h@꿉b{:y  +     +        tu(0ۆƐ NPu +h@ĉbo:m  +     +        mtu(0֨ NPu +h@Ԯɉby:w  +     +        mtu(0ڊ NPu +h@Ήbw:u  +     +        tu(0 NPu +h@҉b}:{  +     +        tu(0ϰϣ NPu +h@׉b}:{  +     +        mtu(0ﱨ NPu +h@܉bm:k  +     +        otu(0 NPu +h@ቔbx:v  +     +        tu(0 NPu +h@剔b:  +     +        ntu(0ٶ NPu +h@ꉔby:w  +     +        mtu(0 NPu +h@쿉by:w  +     +        otu(0˙Ɛ NPu +h@ĉby:w  +     +        tu(0Ĩ NPu +h@Ԧɉb{:y  +     +        tu(0Ԋ NPu +h@㺕Ήb2 + :~  +     +        tu(0 NPu +h@ߘ҉by:w  +     +        mtu(0ϣ NPu +h@׉by:w  +     +        mtu(0 NPu +h@܉bw:u  +     +        otu(0 NPu +h@ߑቔbq:o  +     +        ntu(0 NPu +h@剔bu:s  +     +        tu(0ض NPu +h@ꉔbz:x  +     +        tu(0 NPu +h@ˎb*_ZNSt3__110__function6__funcIZN7GrGLGpu25createRenderTargetObjectsERKN11GrGLTexture4DescEiPN16GrGLRenderTarget3IDsEE3$_0NS_9allocatorISA_EEFvvEE7destroyEv.__uniq.111230615403708898952873255848304878871.0de7bae3ebde0cf49b23739a6271b5112  + ԫ2 s + :[QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abctt(0 NPu +h@Еb區*SN_ZN5skgpu6ganesh11AtlasTextOp8finalizeERK6GrCapsPK13GrAppliedClip11GrClampType2  + Я*킀_ZNSt3__18__invokeB8ne180000IRZN5skgpu6ganesh18SurfaceDrawContext16drawGlyphRunListEP8SkCanvasPK6GrClipRK8SkMatrixRKN6sktext12GlyphRunListE18SkStrikeDeviceInfoRK7SkPaintE3$_1JPKNSC_3gpu11AtlasSubRunE7SkPointSJ_5sk_spI8SkRefCntENSM_12RendererDataEEEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSV_DpOSW_.__uniq.1269637790681306725664032031755105794962  + ǻ*|_ZNKSt3__18functionIFvPKN6sktext3gpu11AtlasSubRunE7SkPointRK7SkPaint5sk_spI8SkRefCntENS2_12RendererDataEEEclES5_S6_S9_SC_SD_2  + *܁_ZNK12_GLOBAL__N_116DirectMaskSubRun4drawEP8SkCanvas7SkPointRK7SkPaint5sk_spI8SkRefCntERKNSt3__18functionIFvPKN6sktext3gpu11AtlasSubRunES3_S6_S9_NSD_12RendererDataEEEE.__uniq.1742939675488946967577203259347195027942  + *_ZNK6sktext3gpu15SubRunContainer4drawEP8SkCanvas7SkPointRK7SkPaintPK8SkRefCntRKNSt3__18functionIFvPKNS0_11AtlasSubRunES4_S7_5sk_spIS8_ENS0_12RendererDataEEEE2  + *䁀_ZN6sktext3gpu25TextBlobRedrawCoordinator16drawGlyphRunListEP8SkCanvasRK8SkMatrixRKNS_12GlyphRunListERK7SkPaint18SkStrikeDeviceInfoRKNSt3__18functionIFvPKNS0_11AtlasSubRunE7SkPointSC_5sk_spI8SkRefCntENS0_12RendererDataEEEE2  + *^Y_ZN5skgpu6ganesh6Device18onDrawGlyphRunListEP8SkCanvasRKN6sktext12GlyphRunListERK7SkPaint2  + Ѓ*ID_ZN8SkCanvas18onDrawGlyphRunListERKN6sktext12GlyphRunListERK7SkPaint2  + *<7_ZN8SkCanvas14onDrawTextBlobEPK10SkTextBlobffRK7SkPaint2  + *:5_ZN8SkCanvas12drawTextBlobEPK10SkTextBlobffRK7SkPaint2  + :iQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu +h@b:  +     +        mtu(0 NPu +h@b:}  +     +        mtu(0 NPu +h@bt:r  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +h@ʂbw:u  +     +        mtu(0 NPu +h@❭b{:y  +     +        tu(0 NPu +h@bw:u  +     +        tu(0 NPu +h@󐊔bw:u  +     +        mtu(0 NPu +h@Օbu:s  +     +        tu(0 NPu +h@Զbo:m  +     +        tu(0ے NPu +h@bw:u  +     +        mtu(0 NPu +h@b}:{  +     +        tu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@b2 + :v  +     +        tu(0 NPu +h@ςby:w  +     +        ntu(0 NPu +h@􀰇bs:q  +     +        mtu(0Ű NPu +h@b{:y  +     +        mtu(0 NPu +h@bw:u  +     +        mtu(0ӯ NPu +h@֕bs:q  +     +        ntu(0 NPu +h@b}:{  +     +        mtu(0ޒ NPu +h@b*1,_ZN3art27BuildGenericJniFrameVisitor5VisitEv2  *!artQuickGenericJniTrampoline2  *% art_quick_generic_jni_trampoline2  * java.lang.ref.Reference.get2  ́2  :<  +p  FGqrstuvwtt(0 NPu +h@ۼbŁ*)$_ZN9gfxstream5guest8IOStream5flushEv2  92  :|QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 NPu +h@¨Ŋb*^Y_ZN7android10uirenderer10RenderNode15prepareTreeImplERNS0_12TreeObserverERNS0_8TreeInfoEb2  + *_ZNSt3__110__function6__funcIZN7android10uirenderer10RenderNode15prepareTreeImplERNS3_12TreeObserverERNS3_8TreeInfoEbE3$_0NS_9allocatorIS9_EEFvPS4_S6_S8_bEEclEOSC_S6_S8_Ob.__uniq.10397782060659495822194741288103189803.203475ed07c5ffb5392ca8a2a4b019a12  + 2  + *E@_ZN7android10uirenderer10RenderNode11prepareTreeERNS0_8TreeInfoE2  + *ID_ZN7android10uirenderer14RootRenderNode11prepareTreeERNS0_8TreeInfoE2  + *kf_ZN7android10uirenderer12renderthread13CanvasContext11prepareTreeERNS0_8TreeInfoEPllPNS0_10RenderNodeE2  + 2 c + :=QRStt(0 NPu +h@Öby:w  +     +        otu(0י NPu +h@by:w  +     +        tu(0 NPu +h@ݨb{:y  +     +        tu(0ܹ NPu +h@܇by:w  +     +        otu(0 NPu +h@Ңb:}  +     +        tu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@绊by:w  +     +        tu(0Ì NPu +h@bw:u  +     +        mtu(0泥 NPu +h@檬Ŋbw:u  +     +        tu(0̮ NPu +h@ʊb|:z  +     +        tu(0 NPu +h@ܜb{:y  +     +        tu(0΋ NPu +h@by:w  +     +        ntu(0 NPu +h@ਊbw:u  +     +        tu(0 NPu +h@ƭbs:q  +     +        tu(0 NPu +h@능by:w  +     +        tu(0 NPu +h@b{:y  +     +        mtu(0 NPu +h@黊by:w  +     +        tu(0Ì NPu +h@ьb:}  +     +        mtu(0 NPu +h@ޮŊby:w  +     +        mtu(0 NPu +h@ʊbw:u  +     +        mtu(0 NPu +h@݊b*_ZN12SkRasterClipC1ERKS_2  + * _ZN17SkRasterClipStackC2Eii2  + *:5_ZN14SkBitmapDeviceC1ERK8SkBitmapRK14SkSurfacePropsPv2  + *|w_ZN8SkCanvasC2ERK8SkBitmapNSt3__110unique_ptrI23SkRasterHandleAllocatorNS3_14default_deleteIS5_EEEEPvPK14SkSurfaceProps2  + 2  + :WQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu ++h@btt(0 NPu +h@Ίb{:y  +     +        mtu(0̟ NPu +h@ӊbq:o  +     +        ntu(0 NPu +h@؊b{:y  +     +        mtu(0  NPu +h@݊bu:s  +     +        tu(0 NPu +h@ѱኔb:  +     +        tu(0۷ֲ NPu +h@抔b{:y  +     +        otu(0 NPu +h@는bw:u  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +h@ۄbw:u  +     +        tu(0 NPu +h@by:w  +     +        mtu(0 NPu +h@Ίbw:u  +     +        otu(0̟ NPu +h@ӊbq:o  +     +        tu(0 NPu +h@؊b}:{  +     +        tu(0 NPu +h@݊bo:m  +     +        tu(0 NPu +h@ኔby:w  +     +        ntu(0ֲ NPu +h@抔b{:y  +     +        mtu(0λ NPu +h@는bq:o  +     +        mtu(0њ NPu +h@̣bx:v  +     +        tu(0 NPu +h@b|:z  +     +        tu(0 NPu +h@bw:u  +     +        mtu(0 NPu +h@댋bЄ*_Z_ZNK19GrFragmentProcessor19visitTextureEffectsERKNSt3__18functionIFvRK15GrTextureEffectEEE2  + *_ZNK12_GLOBAL__N_114FillRectOpImpl12visitProxiesERKNSt3__18functionIFvP14GrSurfaceProxyN5skgpu9MipmappedEEEE.__uniq.296536696735338101786510625415314891244.82f04894e2d02c5f3d5f5eaed87a27c22  + А*_ZN5skgpu6ganesh18SurfaceDrawContext9addDrawOpEPK6GrClipNSt3__110unique_ptrI4GrOpNS5_14default_deleteIS7_EEEERKNS5_8functionIFvPS7_jEEE2  + 2 o + :YQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_tt(0 NPu ++h@פbtt(0틷 NPu +h@b{:y  +     +        otu(0ǔ NPu +h@߰b:  +     +        mtu(0 NPu +h@쌈b}:{  +     +        mtu(0 NPu +h@by:w  +     +        mtu(0 NPu +h@ёbw:u  +     +        tu(0ɭ NPu +h@b{:y  +     +        ntu(0 NPu +h@㖛by:w  +     +        tu(0 NPu +h@bw:u  +     +        otu(0 NPu +h@ۤbu:s  +     +        mtu(0 NPu +h@bm:k  +     +        otu(0 NPu +h@b냀2  @ (0*&!io.sentry.util.FileUtils.readText2  *A9android.view.ViewRootImpl$ViewPostImeInputStage.onProcess2  ɻ*1,android.view.ViewRootImpl$InputStage.deliver2  *94android.view.ViewRootImpl$InputStage.onDeliverToNext2  *1,android.view.ViewRootImpl$InputStage.forward2  *61android.view.ViewRootImpl$AsyncInputStage.forward2  */*android.view.ViewRootImpl$InputStage.apply2  *4/android.view.ViewRootImpl$AsyncInputStage.apply2  2  *0+android.view.ViewRootImpl.deliverInputEvent2  Һ*3.android.view.ViewRootImpl.doProcessInputEvents2  *0+android.view.ViewRootImpl.enqueueInputEvent2  쩺*D?android.view.ViewRootImpl$WindowInputEventReceiver.onInputEvent2  *72android.view.InputEventReceiver.dispatchInputEvent2  *_ZN3art35InvokeVirtualOrInterfaceWithVarArgsIPNS_9ArtMethodEEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_St9__va_list2  *TO_ZN3art3JNIILb1EE15CallVoidMethodVEP7_JNIEnvP8_jobjectP10_jmethodIDSt9__va_list2  *_ZN3art12_GLOBAL__N_18CheckJNI11CallMethodVEPKcP7_JNIEnvP8_jobjectP7_jclassP10_jmethodIDSt9__va_listNS_9Primitive4TypeENS_10InvokeTypeE.__uniq.990339783528046273134915519602290474282  *_ZN3art12_GLOBAL__N_18CheckJNI15CallVoidMethodVEP7_JNIEnvP8_jobjectP10_jmethodIDSt9__va_list.__uniq.99033978352804627313491551960229047428.llvm.101458139733301615442  *94_ZN7_JNIEnv14CallVoidMethodEP8_jobjectP10_jmethodIDz2  4*GB_ZN7android24NativeInputEventReceiver13consumeEventsEP7_JNIEnvblPb2  U*<7_ZN7android24NativeInputEventReceiver11handleEventEiiPv2  U**%_ZN7android6Looper8pollOnceEiPiS1_PPv2  *OJ_ZN7androidL38android_os_MessageQueue_nativePollOnceEP7_JNIEnvP8_jobjectli2  `2 W !*!android.os.MessageQueue.next2  2  2  *$android.app.ActivityThread.main2  u*ni_ZN3art12InvokeMethodILNS_11PointerSizeE8EEEP8_jobjectRKNS_33ScopedObjectAccessAlreadyRunnableES3_S3_S3_m2  *rm_ZN3artL13Method_invokeEP7_JNIEnvP8_jobjectS3_P13_jobjectArray.__uniq.1657535210259653690657081520636215062772  2  "@ (08\88*@;com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run2  t +arm64boot-framework.oatc2_9 i͠s#@{ (֐08\888*,'com.android.internal.os.ZygoteInit.main2  2  *{v_ZN3art17InvokeWithVarArgsIP10_jmethodIDEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_St9__va_list2  *YT_ZN3art3JNIILb1EE21CallStaticVoidMethodVEP7_JNIEnvP7_jclassP10_jmethodIDSt9__va_list2  *>9_ZN7_JNIEnv20CallStaticVoidMethodEP7_jclassP10_jmethodIDz2  5*FA_ZN7android14AndroidRuntime5startEPKcRKNS_6VectorINS_7String8EEEb2  :bin app_process64Bk1oȆ2 #@ +( +08\88* main2  * __libc_init2  ̥:G  +  G  +    FG  +      Ntt(0㋡ NPu +h@ٌbӁ*4/_ZN12_GLOBAL__N_119glBufferSubData_encEPvjllPKv2  2  + *?:_ZN7android10uirenderer12renderthread13DrawFrameTask3runEv2  + :.QRUtt(0Ѫ NPu +h@שbu:s  +     +        tu(0Ն NPu +h@㒋Œby:w  +     +        ntu(0 NPu +h@ݳƌby:w  +     +        mtu(0Ɨ NPu +h@ˌby:w  +     +        mtu(0 NPu +h@ʲЌb}:{  +     +        mtu(0 NPu +h@ŔՌbs:q  +     +        ntu(0 NPu +h@Ăٌby:w  +     +        otu(0Ӫ NPu +h@ތb{:y  +     +        tu(0Ȳ NPu +h@㌔bx:v  +     +        tu(0 NPu +h@ń茔b{:y  +     +        tu(0 NPu +jh@b?2  + :.QRUtt(0 NPu + +h@b*2-_ZN3art11HExpressionILm2EE15GetInputRecordsEv2  *a\_ZN3art19SsaLivenessAnalysis24RecursivelyProcessInputsEPNS_12HInstructionES2_PNS_9BitVectorE2  2  *+&_ZN3art19SsaLivenessAnalysis7AnalyzeEv2  *_ZN3artL17AllocateRegistersEPNS_6HGraphEPNS_13CodeGeneratorEPNS_12PassObserverEPNS_23OptimizingCompilerStatsE.__uniq.812043013689377915893235274964569191162  *_ZNK3art18OptimizingCompiler10TryCompileEPNS_14ArenaAllocatorEPNS_10ArenaStackERKNS_18DexCompilationUnitEPNS_9ArtMethodENS_15CompilationKindEPNS_24VariableSizedHandleScopeE2  *_ZN3art18OptimizingCompiler10JitCompileEPNS_6ThreadEPNS_3jit12JitCodeCacheEPNS3_15JitMemoryRegionEPNS_9ArtMethodENS_15CompilationKindEPNS3_9JitLoggerE2  Ѣ*to_ZN3art3jit11JitCompiler13CompileMethodEPNS_6ThreadEPNS0_15JitMemoryRegionEPNS_9ArtMethodENS_15CompilationKindE2  Ǐ*]X_ZN3art3jit3Jit21CompileMethodInternalEPNS_9ArtMethodEPNS_6ThreadENS_15CompilationKindEb2  *1,_ZN3art3jit14JitCompileTask3RunEPNS_6ThreadE2  *$_ZN3art16ThreadPoolWorker3RunEv2  **%_ZN3art16ThreadPoolWorker8CallbackEPv2  :+tt(0 NPu +h@ʉbʂ* __epoll_pwait2  /2  2  *art_quick_osr_stub2  *YT_ZN3art3jit3Jit25MaybeDoOnStackReplacementEPNS_6ThreadEPNS_9ArtMethodEjiPNS_6JValueE2  2  :iG  +  G  +   tt(0ͩ NPu +h@쌔b{:y  +     +        tu(0ٽ NPu +h@b:  +     +        ntu(0ͼ NPu +h@bw:u  +     +        ntu(0 NPu + h@b*50_ZL16Linux_writeBytesP7_JNIEnvP8_jobjectS2_S2_ii2  2 W R*libcore.io.Linux.write2  *"libcore.io.ForwardingOs.write2  *"libcore.io.BlockGuardOs.write2  *libcore.io.IoBridge.write2  *#java.io.FileOutputStream.write2  *(#sun.nio.cs.StreamEncoder.writeBytes2  *-(sun.nio.cs.StreamEncoder.implFlushBuffer2  *'"sun.nio.cs.StreamEncoder.implFlush2  *#sun.nio.cs.StreamEncoder.flush2  *% java.io.OutputStreamWriter.flush2  *!java.io.BufferedWriter.flush2  *'"io.sentry.JsonSerializer.serialize2  +*% io.sentry.cache.CacheUtils.store2  *2-io.sentry.cache.PersistingScopeObserver.store2  2  *gbio.sentry.cache.PersistingScopeObserver.lambda$setTrace$10$io-sentry-cache-PersistingScopeObserver2  *JEio.sentry.cache.PersistingScopeObserver$$ExternalSyntheticLambda3.run2  *niio.sentry.cache.PersistingScopeObserver.lambda$serializeToDisk$13$io-sentry-cache-PersistingScopeObserver2  *JEio.sentry.cache.PersistingScopeObserver$$ExternalSyntheticLambda9.run2   core-oj.jar!@ (088 88*MHjava.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run2  2  2  :́   +           +                tt(0Ƶ NPu +h@崀팔bs:q   +     +        mtu(0ٽ NPu +h@bw:u   +     +        tu(0Έ NPu +h@bw:u   +     +        ntu(0 NPu +h@ϧb‡lib_renderControl_enc.so ϺOX0"!@ (08|88*83_ZN12_GLOBAL__N_119rcCreateSyncKHR_encEPvjPijiPmS2_2 ! libEGL_emulation.soUjYuc]K% @ (08|888*#_ZL16createNativeSyncjPKiibiPi2  *-(_ZN20egl_window_surface_t11swapBuffersEv2  *eglSwapBuffers2  č libEGL.so7}lw"@ (08\88*:5_ZN7android31eglSwapBuffersWithDamageKHRImplEPvS0_Pii2  * eglSwapBuffersWithDamageKHR2  *ZU_ZN7android10uirenderer12renderthread10EglManager11swapBuffersERKNS1_5FrameERK6SkRect2  + *_ZN7android10uirenderer12skiapipeline18SkiaOpenGLPipeline11swapBuffersERKNS0_12renderthread5FrameERNS3_15IRenderPipeline10DrawResultERK6SkRectPNS0_9FrameInfoEPb2  + 2 d + :2 QRtt(0 NPu +lh@bA2  :1 QRUtt(0 NPu ++h@ڍbtt(0ʱ NPu +@NP  +NP~ +` pP(#8  @`     * !(08@HP`hpx diff --git a/tests/integration/test_profile_chunks_perfetto.py b/tests/integration/test_profile_chunks_perfetto.py new file mode 100644 index 00000000000..2ce715589a9 --- /dev/null +++ b/tests/integration/test_profile_chunks_perfetto.py @@ -0,0 +1,114 @@ +import json +from pathlib import Path + +from sentry_sdk.envelope import Envelope + +RELAY_ROOT = Path(__file__).parent.parent.parent + +TEST_CONFIG = { + "outcomes": { + "emit_outcomes": True, + "batch_size": 1, + "batch_interval": 1, + "aggregator": { + "bucket_interval": 1, + "flush_interval": 1, + }, + }, + "aggregator": { + "bucket_interval": 1, + "initial_delay": 0, + }, +} + +PERFETTO_ENVELOPE_FIXTURE = ( + RELAY_ROOT + / "relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope" +) + + +def test_perfetto_profile_chunk_end_to_end( + mini_sentry, + relay_with_processing, + outcomes_consumer, + profiles_consumer, +): + """ + Ingests a real Perfetto `profile_chunk` envelope end-to-end and verifies + that Relay decodes the binary Perfetto trace into a Sample v2 profile + that is forwarded to the profiles consumer. + + The fixture envelope was captured from the Android SDK and contains a + single `profile_chunk` item whose payload is `[JSON metadata][perfetto + binary]` concatenated, delimited by the `meta_length` item header. + """ + profiles_consumer = profiles_consumer() + outcomes_consumer = outcomes_consumer(timeout=2) + + project_id = 42 + project_config = mini_sentry.add_full_project_config(project_id)["config"] + project_config.setdefault("features", []).extend( + [ + "organizations:continuous-profiling", + "organizations:continuous-profiling-perfetto", + ] + ) + + upstream = relay_with_processing(TEST_CONFIG) + + with open(PERFETTO_ENVELOPE_FIXTURE, "rb") as f: + envelope = Envelope.deserialize_from(f) + + upstream.send_envelope(project_id, envelope) + + # Successful ingestion emits no outcomes from Relay (profile_duration is + # emitted later in Sentry itself). + outcomes_consumer.assert_empty() + + profile, headers = profiles_consumer.get_profile() + assert headers == [("project_id", b"42")] + + payload = json.loads(profile["payload"]) + + assert { + k: payload[k] for k in ("version", "platform", "chunk_id", "profiler_id") + } == { + "version": "2", + "platform": "android", + "chunk_id": "c3b09c0608844f558eaf6e65df6b9cdf", + "profiler_id": "814b081c638b4ad982ae351547bfe499", + } + assert payload["client_sdk"]["name"] == "sentry.java.android" + + profile_data = payload["profile"] + assert len(profile_data["samples"]) == 398 + assert len(profile_data["stacks"]) == 52 + assert len(profile_data["frames"]) == 358 + assert len(payload["debug_meta"]["images"]) == 17 + + samples = profile_data["samples"] + timestamps = [s["timestamp"] for s in samples] + assert timestamps == sorted(timestamps) + assert abs((timestamps[-1] - timestamps[0]) - 1.96) < 0.01 + + num_stacks = len(profile_data["stacks"]) + num_frames = len(profile_data["frames"]) + for sample in samples: + assert 0 <= sample["stack_id"] < num_stacks + assert isinstance(sample["thread_id"], str) + + for stack in profile_data["stacks"]: + for frame_id in stack: + assert 0 <= frame_id < num_frames + + frames = profile_data["frames"] + assert sum(1 for f in frames if f.get("function")) >= 350 + assert any(f.get("function", "").startswith("io.sentry.") for f in frames) + + sample_thread_ids = {s["thread_id"] for s in samples} + assert len(sample_thread_ids) == 6 + thread_metadata = profile_data["thread_metadata"] + assert any(meta.get("name") == "main" for meta in thread_metadata.values()) + for tid, meta in thread_metadata.items(): + assert isinstance(tid, str) + assert "name" in meta and isinstance(meta["name"], str) From e1c0bac1e96dfb644d10dc9ded3a495000ffc679 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 28 Apr 2026 16:31:14 +0200 Subject: [PATCH 18/28] Ensure profile meta data is not dropped --- relay-profiling/src/lib.rs | 13 ++++--------- relay-profiling/src/sample/v2.rs | 1 + 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index fc28beeacf2..7332fd66c43 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -39,7 +39,6 @@ //! //! Relay will forward those profiles encoded with `msgpack` after unpacking them if needed and push a message on Kafka. -use std::collections::BTreeMap; use std::error::Error; use std::net::IpAddr; use std::time::Duration; @@ -416,18 +415,14 @@ pub fn expand_perfetto( metadata_json: &[u8], ) -> Result { let d = &mut Deserializer::from_slice(metadata_json); - let metadata: sample::v2::ProfileMetadata = + let mut chunk: sample::v2::ProfileChunk = serde_path_to_error::deserialize(d).map_err(ProfileError::InvalidJson)?; - let platform = metadata.platform.clone(); - let release = metadata.release.clone(); + let platform = chunk.metadata.platform.clone(); + let release = chunk.metadata.release.clone(); let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; - let mut chunk = sample::v2::ProfileChunk { - measurements: BTreeMap::new(), - metadata, - profile: profile_data, - }; + chunk.profile = profile_data; chunk.metadata.debug_meta.images.extend(debug_images); chunk.normalize()?; diff --git a/relay-profiling/src/sample/v2.rs b/relay-profiling/src/sample/v2.rs index fa1b33e6381..6116a9e04cb 100644 --- a/relay-profiling/src/sample/v2.rs +++ b/relay-profiling/src/sample/v2.rs @@ -67,6 +67,7 @@ pub struct ProfileChunk { /// be at the top-level of the object. #[serde(flatten)] pub metadata: ProfileMetadata, + #[serde(default)] pub profile: ProfileData, } From 34acd48e864b37ef3b22bff85c003dc0aa16411e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 28 Apr 2026 17:27:14 +0200 Subject: [PATCH 19/28] Fix lint errors --- relay-profiling/src/perfetto/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index d45af38ad7e..88935578d94 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -197,10 +197,8 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } match packet.data { - Some(Data::ClockSnapshot(cs)) => { - if clock_offset_ns.is_none() { - clock_offset_ns = extract_clock_offset(&cs); - } + Some(Data::ClockSnapshot(cs)) if clock_offset_ns.is_none() => { + clock_offset_ns = extract_clock_offset(&cs); } Some(Data::PerfSample(ps)) => { if let Some(callstack_iid) = ps.callstack_iid { @@ -217,7 +215,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), } } } - None => {} + _ => {} } if sample_count > MAX_SAMPLES { From 723876235d17f9501a761b708ebf89ef9c661294 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 29 Apr 2026 10:47:40 +0200 Subject: [PATCH 20/28] Address PR feedback --- relay-profiling/src/sample/v2.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/relay-profiling/src/sample/v2.rs b/relay-profiling/src/sample/v2.rs index 6116a9e04cb..b91302624db 100644 --- a/relay-profiling/src/sample/v2.rs +++ b/relay-profiling/src/sample/v2.rs @@ -38,7 +38,6 @@ pub struct ProfileMetadata { pub platform: String, #[serde(skip_serializing_if = "Option::is_none")] pub content_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub release: Option, pub client_sdk: ClientSdk, From db555e68ad45debd66f46d28e84aa6952b7498b7 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 30 Apr 2026 09:42:14 +0200 Subject: [PATCH 21/28] Update Changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88fa25f66f..afc2f079140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Implement client/sdk controlled ingestion settings for v2 span containers. ([#5881](https://github.com/getsentry/relay/pull/5881)) - Implement client/sdk controlled ingestion settings for v2 log containers. ([#5887](https://github.com/getsentry/relay/pull/5887)) - Update several `gen_ai` attributes to their latest representation. ([#5798](https://github.com/getsentry/relay/pull/5798)) +- Add Perfetto trace format support for continuous profiling via compound envelope items. ([#5659](https://github.com/getsentry/relay/pull/5659)) **Bug Fixes**: @@ -73,7 +74,6 @@ - Merge `gen_ai.request.messages` into `gen_ai.input.messages` and `gen_ai.response.text` into `gen_ai.output.messages`. ([#5813](https://github.com/getsentry/relay/pull/5813)) - Extract `http.query` and `url.query` attributes from `query_string` in transactions' request context. ([#5784](https://github.com/getsentry/relay/pull/5784)) - Add `ModelMetadata` global config with context size. ([#5831](https://github.com/getsentry/relay/pull/5831)) -- Add Perfetto trace format support for continuous profiling via compound envelope items. ([#5659](https://github.com/getsentry/relay/pull/5659)) **Internal**: From 75b8fee06085117a4420bce4c7770d341d1670ab Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 07:37:26 +0200 Subject: [PATCH 22/28] Simplify python tests, don't overwrite timeouts --- tests/integration/test_profile_chunks_perfetto.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/integration/test_profile_chunks_perfetto.py b/tests/integration/test_profile_chunks_perfetto.py index 2ce715589a9..6bc25384e90 100644 --- a/tests/integration/test_profile_chunks_perfetto.py +++ b/tests/integration/test_profile_chunks_perfetto.py @@ -8,16 +8,6 @@ TEST_CONFIG = { "outcomes": { "emit_outcomes": True, - "batch_size": 1, - "batch_interval": 1, - "aggregator": { - "bucket_interval": 1, - "flush_interval": 1, - }, - }, - "aggregator": { - "bucket_interval": 1, - "initial_delay": 0, }, } @@ -43,7 +33,7 @@ def test_perfetto_profile_chunk_end_to_end( binary]` concatenated, delimited by the `meta_length` item header. """ profiles_consumer = profiles_consumer() - outcomes_consumer = outcomes_consumer(timeout=2) + outcomes_consumer = outcomes_consumer() project_id = 42 project_config = mini_sentry.add_full_project_config(project_id)["config"] From ad44bf36ec1674bf5f1953a8f3022a265d0758c4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 07:50:04 +0200 Subject: [PATCH 23/28] Make comments doc friendly --- relay-profiling/src/perfetto/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 88935578d94..fad8334d9fc 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -30,9 +30,13 @@ const MAX_SAMPLES: usize = 100_000; /// See . const SEQ_INCREMENTAL_STATE_CLEARED: u32 = 1; -/// Perfetto builtin clock IDs. +/// Perfetto builtin real time clock ID. +/// /// See . const CLOCK_REALTIME: u32 = 1; +/// Perfetto builtin boot time clock ID. +/// +/// See . const CLOCK_BOOTTIME: u32 = 6; fn has_incremental_state_cleared(packet: &proto::TracePacket) -> bool { From 338c60113fc3721aa5ff5095d128850c47caa140 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 08:50:07 +0200 Subject: [PATCH 24/28] Improve ownership handling, introduce helper function for intern_strings --- relay-profiling/src/perfetto/mod.rs | 75 +++++++++++------------------ 1 file changed, 29 insertions(+), 46 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index fad8334d9fc..5d0c7c3f416 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use data_encoding::HEXLOWER; use hashbrown::{HashMap, HashSet}; +use itertools::Itertools; use prost::Message; use relay_event_schema::protocol::{Addr, DebugId}; @@ -95,56 +96,38 @@ struct InternTables { impl InternTables { fn merge(&mut self, data: proto::InternedData) { - for s in data.function_names { - if let Some(iid) = s.iid { - let value = s - .r#str - .as_deref() - .and_then(|b| std::str::from_utf8(b).ok()) - .unwrap_or("") - .to_owned(); - self.function_names.insert(iid, value); - } - } - for s in data.mapping_paths { - if let Some(iid) = s.iid { - let value = s - .r#str - .as_deref() - .and_then(|b| std::str::from_utf8(b).ok()) - .unwrap_or("") - .to_owned(); - self.mapping_paths.insert(iid, value); - } - } + let proto::InternedData { + function_names, + mapping_paths, + build_ids, + frames, + callstacks, + mappings, + } = data; + + self.function_names.extend(intern_strings(function_names)); + self.mapping_paths.extend(intern_strings(mapping_paths)); // Build IDs are raw bytes in Perfetto traces; normalize to hex for later lookup. - for s in data.build_ids { - if let Some(iid) = s.iid { - let value = match s.r#str.as_deref() { - Some(bytes) if !bytes.is_empty() => HEXLOWER.encode(bytes), - _ => String::new(), - }; - self.build_ids.insert(iid, value); - } - } - for f in data.frames { - if let Some(iid) = f.iid { - self.frames.insert(iid, f); - } - } - for c in data.callstacks { - if let Some(iid) = c.iid { - self.callstacks.insert(iid, c); - } - } - for m in data.mappings { - if let Some(iid) = m.iid { - self.mappings.insert(iid, m); - } - } + self.build_ids.extend( + build_ids + .into_iter() + .filter_map(|is| Some((is.iid?, HEXLOWER.encode(&is.r#str?)))), + ); + self.frames + .extend(frames.into_iter().filter_map(|f| Some((f.iid?, f)))); + self.callstacks + .extend(callstacks.into_iter().filter_map(|c| Some((c.iid?, c)))); + self.mappings + .extend(mappings.into_iter().filter_map(|m| Some((m.iid?, m)))); } } +fn intern_strings(strings: Vec) -> impl Iterator { + strings + .into_iter() + .filter_map(|is| Some((is.iid?, String::from_utf8(is.r#str?).ok()?))) +} + /// Deduplication key for resolved stack frames. /// /// Two Perfetto frames that resolve to the same function, module, package, From afb382f74ab2e9cf43bec02d537f80dbf61ffe13 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 09:47:52 +0200 Subject: [PATCH 25/28] Use uuid::Uuid::from_bytes_le, avoid uncessary hex/string roundtrip --- relay-profiling/src/perfetto/mod.rs | 50 ++++++++++++----------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index 5d0c7c3f416..d73033753c2 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -5,7 +5,6 @@ use std::collections::BTreeMap; -use data_encoding::HEXLOWER; use hashbrown::{HashMap, HashSet}; use itertools::Itertools; use prost::Message; @@ -87,8 +86,7 @@ struct InternTables { // HashMap over BTreeMap: these tables can grow large (one entry per interned symbol). function_names: HashMap, mapping_paths: HashMap, - /// Build IDs stored as hex-encoded strings (normalized from raw bytes). - build_ids: HashMap, + build_ids: HashMap>, frames: HashMap, callstacks: HashMap, mappings: HashMap, @@ -107,11 +105,10 @@ impl InternTables { self.function_names.extend(intern_strings(function_names)); self.mapping_paths.extend(intern_strings(mapping_paths)); - // Build IDs are raw bytes in Perfetto traces; normalize to hex for later lookup. self.build_ids.extend( build_ids .into_iter() - .filter_map(|is| Some((is.iid?, HEXLOWER.encode(&is.r#str?)))), + .filter_map(|is| Some((is.iid?, is.r#str?))), ); self.frames .extend(frames.into_iter().filter_map(|f| Some((f.iid?, f)))); @@ -351,7 +348,7 @@ fn collect_debug_image( let debug_id = mapping .build_id .and_then(|bid| tables.build_ids.get(&bid)) - .and_then(|hex_str| build_id_to_debug_id(hex_str))?; + .and_then(|bytes| build_id_to_debug_id(bytes))?; // Insert into dedup set only after validating we have a valid debug_id, // so that a mapping first seen without a build_id doesn't block a later @@ -469,31 +466,21 @@ fn is_java_mapping(path: &str) -> bool { JVM_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) } -/// Converts a hex-encoded ELF build ID string into a Sentry [`DebugId`]. +/// Converts a raw ELF build ID into a Sentry [`DebugId`]. /// -/// The first 16 bytes of the build ID are interpreted as a little-endian UUID -/// (byte-swapping the time_low, time_mid, and time_hi_and_version fields). +/// The first 16 bytes of the build ID are interpreted as a little-endian UUID. /// If the build ID is shorter than 16 bytes it is zero-padded on the right. -fn build_id_to_debug_id(hex_str: &str) -> Option { - let bytes = HEXLOWER.decode(hex_str.as_bytes()).ok()?; - if bytes.is_empty() { +fn build_id_to_debug_id(raw: &[u8]) -> Option { + if raw.is_empty() { return None; } let mut buf = [0u8; 16]; - let len = bytes.len().min(16); - buf[..len].copy_from_slice(&bytes[..len]); - - // Swap from little-endian ELF byte order to UUID mixed-endian format. - // time_low (bytes 0..4): reverse - buf[..4].reverse(); - // time_mid (bytes 4..6): reverse - buf[4..6].reverse(); - // time_hi_and_version (bytes 6..8): reverse - buf[6..8].reverse(); - - let uuid = uuid::Uuid::from_bytes(buf); - uuid.to_string().parse().ok() + let len = raw.len().min(16); + buf[..len].copy_from_slice(&raw[..len]); + + let uuid = uuid::Uuid::from_bytes_le(buf); + Some(DebugId::from(uuid)) } #[cfg(test)] @@ -1185,9 +1172,12 @@ mod tests { #[test] fn test_build_id_to_debug_id() { // 20-byte ELF build ID (common for GNU build IDs). - let debug_id = build_id_to_debug_id("b03e4a7f5e884c8da04b05fa32cc4cbd69faff51").unwrap(); - // First 16 bytes: b0 3e 4a 7f 5e 88 4c 8d a0 4b 05 fa 32 cc 4c bd - // After LE→UUID swap: + let raw = &[ + 0xb0, 0x3e, 0x4a, 0x7f, 0x5e, 0x88, 0x4c, 0x8d, 0xa0, 0x4b, 0x05, 0xfa, 0x32, 0xcc, + 0x4c, 0xbd, 0x69, 0xfa, 0xff, 0x51, + ]; + let debug_id = build_id_to_debug_id(raw).unwrap(); + // First 16 bytes interpreted as little-endian UUID: // time_low (0..4) reversed: 7f4a3eb0 // time_mid (4..6) reversed: 885e // time_hi (6..8) reversed: 8d4c @@ -1198,7 +1188,7 @@ mod tests { #[test] fn test_build_id_to_debug_id_short() { // Build ID shorter than 16 bytes → zero-padded. - let debug_id = build_id_to_debug_id("aabbccdd").unwrap(); + let debug_id = build_id_to_debug_id(&[0xaa, 0xbb, 0xcc, 0xdd]).unwrap(); // Bytes: aa bb cc dd 00 00 00 00 00 00 00 00 00 00 00 00 // After swap: ddccbbaa-0000-0000-0000-000000000000 assert_eq!(debug_id.to_string(), "ddccbbaa-0000-0000-0000-000000000000"); @@ -1206,7 +1196,7 @@ mod tests { #[test] fn test_build_id_to_debug_id_empty() { - assert!(build_id_to_debug_id("").is_none()); + assert!(build_id_to_debug_id(&[]).is_none()); } #[test] From 09906737a319220ab41d73806d52cf6a07cc6d7e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 09:48:19 +0200 Subject: [PATCH 26/28] Apply style suggestions --- relay-profiling/src/perfetto/mod.rs | 45 +++++++++++------------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/relay-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs index d73033753c2..babbf7e37ad 100644 --- a/relay-profiling/src/perfetto/mod.rs +++ b/relay-profiling/src/perfetto/mod.rs @@ -168,6 +168,7 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), let mut ctx = ResolveContext::default(); let mut resolved_samples: Vec<(u64, u32, usize)> = Vec::new(); let mut sample_count: usize = 0; + let empty_tables = InternTables::default(); for packet in trace.packet { let seq_id = trusted_packet_sequence_id(&packet); @@ -192,9 +193,11 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), observed_pid = ps.pid; } sample_count += 1; - if let Some(stack_id) = - resolve_callstack(callstack_iid, seq_id, &tables_by_seq, &mut ctx) - { + if let Some(stack_id) = resolve_callstack( + callstack_iid, + tables_by_seq.get(&seq_id).unwrap_or(&empty_tables), + &mut ctx, + ) { resolved_samples.push((ts, tid, stack_id)); } } @@ -269,13 +272,9 @@ pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), /// callstack iid was not found in the tables. fn resolve_callstack( cs_iid: u64, - seq_id: u32, - tables_by_seq: &BTreeMap, + tables: &InternTables, ctx: &mut ResolveContext, ) -> Option { - let empty_tables = InternTables::default(); - let tables = tables_by_seq.get(&seq_id).unwrap_or(&empty_tables); - let callstack = tables.callstacks.get(&cs_iid)?; let mut resolved_frame_indices: Vec = Vec::with_capacity(callstack.frame_ids.len()); @@ -298,14 +297,11 @@ fn resolve_callstack( let (key, frame) = build_frame(function_name, pf, tables); - let idx = if let Some(&existing) = ctx.frame_index.get(&key) { - existing - } else { - let idx = ctx.frames.len(); - ctx.frame_index.insert(key, idx); + let idx = *ctx.frame_index.entry(key).or_insert_with(|| { + let next_idx = ctx.frames.len(); ctx.frames.push(frame); - idx - }; + next_idx + }); resolved_frame_indices.push(idx); } @@ -390,10 +386,10 @@ fn build_frame( if is_java { // For Java frames, split "com.example.MyClass.myMethod" into // module="com.example.MyClass" and function="myMethod". - let (module, function) = match &function_name { + let (module, function) = match function_name { Some(name) => match name.rsplit_once('.') { Some((class, method)) => (Some(class.to_owned()), Some(method.to_owned())), - None => (None, Some(name.clone())), + None => (None, Some(name)), }, None => (None, None), }; @@ -444,16 +440,12 @@ fn build_frame( /// /// Returns `None` if the mapping has no resolvable path segments. fn resolve_mapping_path(mapping: &proto::Mapping, tables: &InternTables) -> Option { - let parts: Vec<&str> = mapping + let path = mapping .path_string_ids .iter() .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) - .collect(); - if parts.is_empty() { - None - } else { - Some(parts.join("/")) - } + .join("/"); + if path.is_empty() { None } else { Some(path) } } /// Returns `true` if the mapping path indicates a JVM/ART runtime mapping. @@ -643,10 +635,7 @@ mod tests { #[test] fn test_convert_minimal_trace() { let bytes = build_minimal_trace(); - let result = convert(&bytes); - assert!(result.is_ok(), "conversion failed: {result:?}"); - - let (data, _images) = result.unwrap(); + let (data, _images) = convert(&bytes).unwrap(); insta::assert_json_snapshot!(data, @r###" { From aecdb7cf7e72f154a1debf1d2baa18dca18e85ad Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 10:04:44 +0200 Subject: [PATCH 27/28] Add doc for new pub struct DebugImage --- relay-profiling/src/debug_image.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/relay-profiling/src/debug_image.rs b/relay-profiling/src/debug_image.rs index 59996ade7dc..faf2058bebf 100644 --- a/relay-profiling/src/debug_image.rs +++ b/relay-profiling/src/debug_image.rs @@ -6,6 +6,7 @@ use uuid::{Error as UuidError, Uuid}; use crate::utils; +/// The type of a debug image. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(rename_all = "lowercase")] pub enum ImageType { @@ -16,28 +17,32 @@ pub enum ImageType { Jvm, } +/// A debug information image referenced by a profile. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct DebugImage { + /// Path or name of the code file (e.g. shared library or executable). #[serde(skip_serializing_if = "Option::is_none", alias = "name")] pub code_file: Option, + /// Debug identifier for symbolication. #[serde(skip_serializing_if = "Option::is_none", alias = "id")] pub debug_id: Option, + /// The type of debug image (e.g. `symbolic`, `proguard`). #[serde(rename = "type")] pub image_type: ImageType, - + /// Start address of the image in virtual memory. #[serde(skip_serializing_if = "Option::is_none")] pub image_addr: Option, - + /// Preferred load address of the image in virtual memory. #[serde(skip_serializing_if = "Option::is_none")] pub image_vmaddr: Option, - + /// Size of the image in bytes. #[serde( default, deserialize_with = "utils::deserialize_number_from_string", skip_serializing_if = "utils::is_zero" )] pub image_size: u64, - + /// Optional UUID, used as the build ID for proguard images. #[serde(skip_serializing_if = "Option::is_none", alias = "build_id")] pub uuid: Option, } From ac8dc49dc25b38c988e3c97a78cf68d007f0631e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 8 May 2026 10:35:20 +0200 Subject: [PATCH 28/28] Fix unneeded rename --- relay-server/src/envelope/content_type.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay-server/src/envelope/content_type.rs b/relay-server/src/envelope/content_type.rs index 75c9be75a70..7cadac64853 100644 --- a/relay-server/src/envelope/content_type.rs +++ b/relay-server/src/envelope/content_type.rs @@ -197,7 +197,7 @@ mod tests { use super::*; #[test] - fn test_attachment_ref_roundtrip() { + fn attachment_ref_roundtrip() { let canonical_name = "application/vnd.sentry.attachment-ref+json"; let ct = ContentType::from_str(canonical_name).unwrap(); assert_eq!(ct, ContentType::AttachmentRef);