From 405a5c124e6d1d27385c0514877974a26ca53aed Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 25 Feb 2026 09:16:43 +0100 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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),