diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea726c1a08..afc2f079140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Implement client/sdk controlled ingestion settings for v2 span containers. ([#5881](https://github.com/getsentry/relay/pull/5881)) - Implement client/sdk controlled ingestion settings for v2 log containers. ([#5887](https://github.com/getsentry/relay/pull/5887)) - Update several `gen_ai` attributes to their latest representation. ([#5798](https://github.com/getsentry/relay/pull/5798)) +- Add Perfetto trace format support for continuous profiling via compound envelope items. ([#5659](https://github.com/getsentry/relay/pull/5659)) **Bug Fixes**: diff --git a/Cargo.lock b/Cargo.lock index de6da6a01f1..32c0458fce7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,6 +4578,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-dynamic-config/src/feature.rs b/relay-dynamic-config/src/feature.rs index 6a9e44f6072..11a9c9bd8ba 100644 --- a/relay-dynamic-config/src/feature.rs +++ b/relay-dynamic-config/src/feature.rs @@ -64,6 +64,14 @@ pub enum Feature { /// Serialized as `organizations:continuous-profiling`. #[serde(rename = "organizations:continuous-profiling")] ContinuousProfiling, + /// 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`. @@ -194,4 +202,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/Cargo.toml b/relay-profiling/Cargo.toml index 60761e60496..889a852201f 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..02536946ca4 --- /dev/null +++ b/relay-profiling/protos/README.md @@ -0,0 +1,18 @@ +# 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 + +Prerequisites: +- `protoc`: https://github.com/protocolbuffers/protobuf/releases (or `brew install protobuf`) +- `protoc-gen-prost`: `cargo install protoc-gen-prost` + +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/protos/perfetto_trace.proto b/relay-profiling/protos/perfetto_trace.proto new file mode 100644 index 00000000000..0570527b2cd --- /dev/null +++ b/relay-profiling/protos/perfetto_trace.proto @@ -0,0 +1,88 @@ +// 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 { + ClockSnapshot clock_snapshot = 6; + PerfSample perf_sample = 66; + } +} + +// --- 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; +} diff --git a/relay-profiling/src/debug_image.rs b/relay-profiling/src/debug_image.rs index 52674cfc18f..faf2058bebf 100644 --- a/relay-profiling/src/debug_image.rs +++ b/relay-profiling/src/debug_image.rs @@ -6,9 +6,10 @@ use uuid::{Error as UuidError, Uuid}; use crate::utils; +/// The type of a debug image. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(rename_all = "lowercase")] -enum ImageType { +pub enum ImageType { MachO, Symbolic, Sourcemap, @@ -16,30 +17,34 @@ enum ImageType { Jvm, } +/// A debug information image referenced by a profile. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct DebugImage { + /// Path or name of the code file (e.g. shared library or executable). #[serde(skip_serializing_if = "Option::is_none", alias = "name")] - code_file: Option, + pub code_file: Option, + /// Debug identifier for symbolication. #[serde(skip_serializing_if = "Option::is_none", alias = "id")] - debug_id: Option, + pub debug_id: Option, + /// The type of debug image (e.g. `symbolic`, `proguard`). #[serde(rename = "type")] - image_type: ImageType, - + pub image_type: ImageType, + /// Start address of the image in virtual memory. #[serde(skip_serializing_if = "Option::is_none")] - image_addr: Option, - + pub image_addr: Option, + /// Preferred load address of the image in virtual memory. #[serde(skip_serializing_if = "Option::is_none")] - image_vmaddr: Option, - + pub image_vmaddr: Option, + /// Size of the image in bytes. #[serde( default, deserialize_with = "utils::deserialize_number_from_string", skip_serializing_if = "utils::is_zero" )] - image_size: u64, - + pub image_size: u64, + /// Optional UUID, used as the build ID for proguard images. #[serde(skip_serializing_if = "Option::is_none", alias = "build_id")] - uuid: Option, + pub uuid: Option, } pub fn get_proguard_image(uuid: &str) -> Result { diff --git a/relay-profiling/src/lib.rs b/relay-profiling/src/lib.rs index 2368a787b01..7332fd66c43 100644 --- a/relay-profiling/src/lib.rs +++ b/relay-profiling/src/lib.rs @@ -63,6 +63,7 @@ mod error; mod extract_from_transaction; mod measurements; mod outcomes; +mod perfetto; mod sample; mod transaction_metadata; mod types; @@ -353,6 +354,86 @@ 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. + pub 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 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 { + let d = &mut Deserializer::from_slice(metadata_json); + let mut chunk: sample::v2::ProfileChunk = + serde_path_to_error::deserialize(d).map_err(ProfileError::InvalidJson)?; + + let platform = chunk.metadata.platform.clone(); + let release = chunk.metadata.release.clone(); + + let (profile_data, debug_images) = perfetto::convert(perfetto_bytes)?; + chunk.profile = profile_data; + chunk.metadata.debug_meta.images.extend(debug_images); + chunk.normalize()?; + + let payload = serde_json::to_vec(&chunk).map_err(|_| ProfileError::CannotSerializePayload)?; + Ok(ExpandedPerfettoChunk { + payload, + platform, + release, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -417,4 +498,74 @@ 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": "android", + "content_type": "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 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!( + !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()); + } + + #[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-profiling/src/perfetto/mod.rs b/relay-profiling/src/perfetto/mod.rs new file mode 100644 index 00000000000..babbf7e37ad --- /dev/null +++ b/relay-profiling/src/perfetto/mod.rs @@ -0,0 +1,1410 @@ +//! Perfetto trace format conversion to Sample v2. +//! +//! Decodes Android Perfetto traces (`PerfSample` packets + `ClockSnapshot` + +//! interned data) into the Sample v2 profile format. + +use std::collections::BTreeMap; + +use hashbrown::{HashMap, HashSet}; +use itertools::Itertools; +use prost::Message; + +use relay_event_schema::protocol::{Addr, DebugId}; +use relay_protocol::FiniteF64; + +use crate::debug_image::{DebugImage, ImageType}; +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 real time clock ID. +/// +/// See . +const CLOCK_REALTIME: u32 = 1; +/// Perfetto builtin boot time clock ID. +/// +/// See . +const CLOCK_BOOTTIME: u32 = 6; + +fn has_incremental_state_cleared(packet: &proto::TracePacket) -> bool { + 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. +/// +/// Per the Perfetto spec, each `InternedData` field constructs its **own** +/// interning index — IDs are scoped per field, not shared across string types. +/// See . +#[derive(Debug, Default)] +struct InternTables { + // HashMap over BTreeMap: these tables can grow large (one entry per interned symbol). + function_names: HashMap, + mapping_paths: HashMap, + build_ids: HashMap>, + frames: HashMap, + callstacks: HashMap, + mappings: HashMap, +} + +impl InternTables { + fn merge(&mut self, data: proto::InternedData) { + let proto::InternedData { + function_names, + mapping_paths, + build_ids, + frames, + callstacks, + mappings, + } = data; + + self.function_names.extend(intern_strings(function_names)); + self.mapping_paths.extend(intern_strings(mapping_paths)); + self.build_ids.extend( + build_ids + .into_iter() + .filter_map(|is| Some((is.iid?, is.r#str?))), + ); + self.frames + .extend(frames.into_iter().filter_map(|f| Some((f.iid?, f)))); + self.callstacks + .extend(callstacks.into_iter().filter_map(|c| Some((c.iid?, c)))); + self.mappings + .extend(mappings.into_iter().filter_map(|m| Some((m.iid?, m)))); + } +} + +fn intern_strings(strings: Vec) -> impl Iterator { + strings + .into_iter() + .filter_map(|is| Some((is.iid?, String::from_utf8(is.r#str?).ok()?))) +} + +/// Deduplication key for resolved stack frames. +/// +/// Two Perfetto frames that resolve to the same function, module, package, +/// and instruction address are considered identical and share a single index +/// in the output frame list. +#[derive(Debug, PartialEq, Eq, Hash)] +struct FrameKey { + function: Option, + module: Option, + package: Option, + instruction_addr: Option, +} + +/// Mutable context for callstack resolution, collecting frames, stacks, +/// and debug images during a single conversion pass. +#[derive(Default)] +struct ResolveContext { + // HashMap/HashSet over BTreeMap/BTreeSet: these dedup indexes can grow large. + frame_index: HashMap, + frames: Vec, + stack_index: HashMap, usize>, + stacks: Vec>, + debug_images: Vec, + seen_images: HashSet<(String, u64)>, +} + +/// Converts a Perfetto binary trace into Sample v2 [`ProfileData`] and debug images. +pub fn convert(perfetto_bytes: &[u8]) -> Result<(ProfileData, Vec), ProfileError> { + let trace = + proto::Trace::decode(perfetto_bytes).map_err(|_| ProfileError::InvalidSampledProfile)?; + + let mut tables_by_seq: BTreeMap = BTreeMap::new(); + let mut thread_meta: BTreeMap = BTreeMap::new(); + let mut clock_offset_ns: Option = None; + let mut observed_pid: Option = None; + + // Samples are resolved eagerly during packet iteration (single-pass) so + // that incremental state resets don't cause earlier samples to be resolved + // against a post-reset intern table. We collect (ts_ns, tid, stack_id) + // tuples and apply clock offset + sorting after the loop. + let mut ctx = ResolveContext::default(); + let mut resolved_samples: Vec<(u64, u32, usize)> = Vec::new(); + let mut sample_count: usize = 0; + let empty_tables = InternTables::default(); + + for packet in trace.packet { + let seq_id = trusted_packet_sequence_id(&packet); + + if has_incremental_state_cleared(&packet) && tables_by_seq.contains_key(&seq_id) { + tables_by_seq.insert(seq_id, InternTables::default()); + } + + if let Some(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::PerfSample(ps)) => { + 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; + } + sample_count += 1; + if let Some(stack_id) = resolve_callstack( + callstack_iid, + tables_by_seq.get(&seq_id).unwrap_or(&empty_tables), + &mut ctx, + ) { + resolved_samples.push((ts, tid, stack_id)); + } + } + } + _ => {} + } + + if sample_count > MAX_SAMPLES { + return Err(ProfileError::ExceedSizeLimit); + } + } + + if resolved_samples.is_empty() { + return Err(ProfileError::NotEnoughSamples); + } + + // On Android/Linux the main thread's tid equals the process pid. + // Label it "main" so the UI can identify it. + if let Some(pid) = observed_pid { + thread_meta.entry(pid).or_insert_with(|| ThreadMetadata { + name: Some("main".to_owned()), + priority: None, + }); + } + + let clock_offset_ns = clock_offset_ns.ok_or(ProfileError::InvalidSampledProfile)?; + + resolved_samples.sort_by_key(|s| s.0); + + let mut samples: Vec = Vec::new(); + 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; + 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::NotEnoughSamples); + } + + // Convert u32 thread keys to String for the output format. + let thread_metadata = thread_meta + .into_iter() + .map(|(tid, meta)| (tid.to_string(), meta)) + .collect(); + + Ok(( + ProfileData { + samples, + stacks: ctx.stacks, + frames: ctx.frames, + thread_metadata, + }, + ctx.debug_images, + )) +} + +/// 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. +fn resolve_callstack( + cs_iid: u64, + tables: &InternTables, + ctx: &mut ResolveContext, +) -> Option { + let callstack = tables.callstacks.get(&cs_iid)?; + + 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 + && let Some(image) = collect_debug_image(mid, tables, &mut ctx.seen_images) + { + ctx.debug_images.push(image); + } + + let (key, frame) = build_frame(function_name, pf, tables); + + let idx = *ctx.frame_index.entry(key).or_insert_with(|| { + let next_idx = ctx.frames.len(); + ctx.frames.push(frame); + next_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) = ctx.stack_index.get(&resolved_frame_indices) { + existing + } else { + let id = ctx.stacks.len(); + ctx.stack_index.insert(resolved_frame_indices.clone(), id); + ctx.stacks.push(resolved_frame_indices); + id + }; + + Some(stack_id) +} + +/// Builds a debug image from a native mapping if not already seen. +/// +/// Returns `Some(DebugImage)` for new native mappings with a valid build ID, +/// or `None` if the mapping is missing, Java-only, already seen, or lacks +/// a valid debug ID. +fn collect_debug_image( + mapping_id: u64, + tables: &InternTables, + seen_images: &mut HashSet<(String, u64)>, +) -> Option { + let mapping = tables.mappings.get(&mapping_id)?; + + let code_file = resolve_mapping_path(mapping, tables)?; + + if is_java_mapping(&code_file) { + return None; + } + + let image_addr = mapping.start.unwrap_or(0); + + let debug_id = mapping + .build_id + .and_then(|bid| tables.build_ids.get(&bid)) + .and_then(|bytes| build_id_to_debug_id(bytes))?; + + // Insert into dedup set only after validating we have a valid debug_id, + // so that a mapping first seen without a build_id doesn't block a later + // valid encounter from a different packet sequence. + if !seen_images.insert((code_file.clone(), image_addr)) { + return None; + } + + let image_size = mapping.end.unwrap_or(0).saturating_sub(image_addr); + let image_vmaddr = mapping.load_bias.unwrap_or(0); + + Some(DebugImage { + code_file: Some(code_file.into()), + debug_id: Some(debug_id), + image_type: ImageType::Symbolic, + image_addr: Some(Addr(image_addr)), + image_vmaddr: Some(Addr(image_vmaddr)), + image_size, + uuid: None, + }) +} + +/// Resolves a Perfetto frame into a [`FrameKey`] and a Sample v2 [`Frame`]. +/// +/// 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| resolve_mapping_path(m, tables)); + + 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)), + }, + 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) + } +} + +/// Joins a mapping's interned path segments with `/` into a single file path. +/// +/// Returns `None` if the mapping has no resolvable path segments. +fn resolve_mapping_path(mapping: &proto::Mapping, tables: &InternTables) -> Option { + let path = mapping + .path_string_ids + .iter() + .filter_map(|id| tables.mapping_paths.get(id).map(|s| s.as_str())) + .join("/"); + if path.is_empty() { None } else { Some(path) } +} + +/// 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 raw ELF build ID into a Sentry [`DebugId`]. +/// +/// The first 16 bytes of the build ID are interpreted as a little-endian UUID. +/// If the build ID is shorter than 16 bytes it is zero-padded on the right. +fn build_id_to_debug_id(raw: &[u8]) -> Option { + if raw.is_empty() { + return None; + } + + let mut buf = [0u8; 16]; + let len = raw.len().min(16); + buf[..len].copy_from_slice(&raw[..len]); + + let uuid = uuid::Uuid::from_bytes_le(buf); + Some(DebugId::from(uuid)) +} + +#[cfg(test)] +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_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, + 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() + }, + ), + // pid == tid so the main-thread fallback names the thread. + make_perf_sample_packet_with_pid(1_000_000_000, 1, 42, 42, 1), + make_perf_sample_packet_with_pid(1_010_000_000, 1, 42, 42, 1), + ], + }; + trace.encode_to_vec() + } + + #[test] + fn test_convert_minimal_trace() { + let bytes = build_minimal_trace(); + let (data, _images) = convert(&bytes).unwrap(); + + insta::assert_json_snapshot!(data, @r###" + { + "samples": [ + { + "timestamp": 1700000001.0, + "stack_id": 0, + "thread_id": "42" + }, + { + "timestamp": 1700000001.01, + "stack_id": 0, + "thread_id": "42" + } + ], + "stacks": [ + [ + 1, + 0 + ] + ], + "frames": [ + { + "function": "main", + "instruction_addr": "0x1000", + "platform": "native" + }, + { + "function": "foo", + "instruction_addr": "0x2000", + "platform": "native" + } + ], + "thread_metadata": { + "42": { + "name": "main" + } + } + } + "###); + } + + #[test] + 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::NotEnoughSamples))); + } + + #[test] + fn test_convert_invalid_protobuf() { + let result = convert(b"not a valid protobuf"); + assert!(matches!(result, Err(ProfileError::InvalidSampledProfile))); + } + + #[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::InvalidSampledProfile))); + } + + #[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")], + 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() + }], + mapping_paths: vec![make_interned_string(10, b"libfoo.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(); + + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "my_func", + "instruction_addr": "0x7100", + "package": "libfoo.so", + "platform": "native" + } + ] + "###); + // No build_id on the mapping, so no debug images. + assert!(images.is_empty()); + } + + #[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 { + 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_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"); + + 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" + ); + + // 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] + 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(); + + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "draw", + "module": "android.view.View", + "package": "boot-framework.oat", + "platform": "java" + } + ] + "###); + } + + #[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(); + + insta::assert_json_snapshot!(data.frames, @r###" + [ + { + "function": "__epoll_pwait", + "instruction_addr": "0x7100", + "package": "libc.so", + "platform": "native" + } + ] + "###); + } + + #[test] + fn test_build_id_to_debug_id() { + // 20-byte ELF build ID (common for GNU build IDs). + let raw = &[ + 0xb0, 0x3e, 0x4a, 0x7f, 0x5e, 0x88, 0x4c, 0x8d, 0xa0, 0x4b, 0x05, 0xfa, 0x32, 0xcc, + 0x4c, 0xbd, 0x69, 0xfa, 0xff, 0x51, + ]; + let debug_id = build_id_to_debug_id(raw).unwrap(); + // First 16 bytes interpreted as little-endian UUID: + // time_low (0..4) reversed: 7f4a3eb0 + // time_mid (4..6) reversed: 885e + // time_hi (6..8) reversed: 8d4c + // 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(&[0xaa, 0xbb, 0xcc, 0xdd]).unwrap(); + // Bytes: aa bb cc dd 00 00 00 00 00 00 00 00 00 00 00 00 + // After swap: ddccbbaa-0000-0000-0000-000000000000 + assert_eq!(debug_id.to_string(), "ddccbbaa-0000-0000-0000-000000000000"); + } + + #[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); + + insta::assert_json_snapshot!(images[0], @r###" + { + "code_file": "libexample.so", + "debug_id": "7f4a3eb0-885e-8d4c-a04b-05fa32cc4cbd", + "type": "symbolic", + "image_addr": "0x70000000", + "image_vmaddr": "0x1000", + "image_size": 65536 + } + "###); + } + + #[test] + fn test_main_thread_inferred_from_pid() { + // The main thread (tid == pid) is labeled "main" automatically; + // worker threads carry no name source and remain unnamed. + let trace = proto::Trace { + packet: vec![ + make_clock_snapshot_packet(), + 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.contains_key("101")); + } + + #[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_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..85be856e313 --- /dev/null +++ b/relay-profiling/src/perfetto/proto.rs @@ -0,0 +1,134 @@ +// @generated +// 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 = "6, 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 = "6")] + ClockSnapshot(super::ClockSnapshot), + #[prost(message, tag = "66")] + PerfSample(super::PerfSample), + } +} +// --- clock sync --------------------------------------------------------------- + +#[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, + } +} +// --- interned data ----------------------------------------------------------- + +#[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>, +} +// --- profiling common -------------------------------------------------------- + +#[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, +} +// --- profiling packets ------------------------------------------------------- + +#[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, +} +// @@protoc_insertion_point(module) 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/src/sample/v2.rs b/relay-profiling/src/sample/v2.rs index a5b0d2119b4..b91302624db 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(skip_serializing_if = "Option::is_none")] + pub content_type: Option, pub release: Option, pub client_sdk: ClientSdk, @@ -64,6 +66,7 @@ pub struct ProfileChunk { /// be at the top-level of the object. #[serde(flatten)] pub metadata: ProfileMetadata, + #[serde(default)] pub profile: ProfileData, } 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 00000000000..e04c92c1728 Binary files /dev/null and b/relay-profiling/tests/fixtures/android/perfetto/android.pftrace differ diff --git a/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope b/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope new file mode 100644 index 00000000000..5b1c765bdeb --- /dev/null +++ b/relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope @@ -0,0 +1,1369 @@ +{"event_id":"c3b09c0608844f558eaf6e65df6b9cdf","sdk":{"name":"sentry.java.android","version":"8.38.0","packages":[{"name":"maven:io.sentry:sentry","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-core","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-fragment","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-timber","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-replay","version":"8.38.0"},{"name":"maven:io.sentry:sentry-spotlight","version":"8.38.0"},{"name":"maven:io.sentry:sentry-compose","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-ndk","version":"8.38.0"}],"integrations":["Screenshot","ViewHierarchy","UncaughtExceptionHandler","ShutdownHook","Spotlight","SendCachedEnvelope","Ndk","Tombstone","AppLifecycle","AnrV2","AnrProfiling","ActivityLifecycle","ActivityBreadcrumbs","UserInteraction","FeedbackShake","FragmentLifecycle","Timber","AppComponentsBreadcrumbs","NetworkBreadcrumbs","AutoInit","EnvelopeFileObserver","SystemEventsBreadcrumbs"]}} +{"content_type":"application/octet-stream","filename":"profile_sentry-profiling_2026-04-28-08-33-40.perfetto-stack-sample","type":"profile_chunk","platform":"android","meta_length":7739,"length":104991} +{"profiler_id":"814b081c638b4ad982ae351547bfe499","chunk_id":"c3b09c0608844f558eaf6e65df6b9cdf","client_sdk":{"name":"sentry.java.android","version":"8.38.0","packages":[{"name":"maven:io.sentry:sentry","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-core","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-fragment","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-timber","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-replay","version":"8.38.0"},{"name":"maven:io.sentry:sentry-spotlight","version":"8.38.0"},{"name":"maven:io.sentry:sentry-compose","version":"8.38.0"},{"name":"maven:io.sentry:sentry-android-ndk","version":"8.38.0"}],"integrations":["Screenshot","ViewHierarchy","UncaughtExceptionHandler","ShutdownHook","Spotlight","SendCachedEnvelope","Ndk","Tombstone","AppLifecycle","AnrV2","AnrProfiling","ActivityLifecycle","ActivityBreadcrumbs","UserInteraction","FeedbackShake","FragmentLifecycle","Timber","AppComponentsBreadcrumbs","NetworkBreadcrumbs","AutoInit","EnvelopeFileObserver","SystemEventsBreadcrumbs"]},"measurements":{"memory_native_footprint":{"unit":"byte","values":[{"value":3.6631152E7,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":3.6636E7,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":3.6598336E7,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":3.6600496E7,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":3.6601984E7,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":3.6604128E7,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":3.6606272E7,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":3.6608416E7,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":3.6614672E7,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":3.6616816E7,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":3.661896E7,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":3.6621104E7,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":3.6623248E7,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":3.6625392E7,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":3.6627536E7,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":3.662968E7,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":3.6631824E7,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":3.6672752E7,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":3.6748144E7,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":3.6754304E7,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"frozen_frame_renders":{"unit":"nanosecond","values":[{"value":7.49833322E8,"elapsed_since_start_ns":"71057630775779","timestamp":1777358020.888000}]},"cpu_usage":{"unit":"percent","values":[{"value":56.20094079375762,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":47.72786092177692,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":52.289708049827254,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":50.050196342916244,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":52.620478795841386,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":49.83694994597027,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":52.61821576681683,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":50.00733407561553,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":52.31104830862539,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":50.08750688152257,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":52.61428295786996,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":49.84011689221911,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":50.07609463188072,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":52.764437950744615,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":49.7033742388127,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":52.63426105211958,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":49.806191656715804,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":52.611141035437356,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":32.55163503106803,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":2.50511253386361,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"memory_footprint":{"unit":"byte","values":[{"value":1.18884E7,"elapsed_since_start_ns":"1777358020895000000","timestamp":1777358020.895000},{"value":1.2003504E7,"elapsed_since_start_ns":"1777358020994000000","timestamp":1777358020.994000},{"value":1.2056752E7,"elapsed_since_start_ns":"1777358021094000000","timestamp":1777358021.094000},{"value":1.211E7,"elapsed_since_start_ns":"1777358021194000000","timestamp":1777358021.193999},{"value":1.213048E7,"elapsed_since_start_ns":"1777358021294000000","timestamp":1777358021.294000},{"value":1.215096E7,"elapsed_since_start_ns":"1777358021394000000","timestamp":1777358021.393999},{"value":1.22124E7,"elapsed_since_start_ns":"1777358021494000000","timestamp":1777358021.494000},{"value":1.223288E7,"elapsed_since_start_ns":"1777358021594000000","timestamp":1777358021.593999},{"value":1.2286128E7,"elapsed_since_start_ns":"1777358021695000000","timestamp":1777358021.695000},{"value":1.2339376E7,"elapsed_since_start_ns":"1777358021794000000","timestamp":1777358021.794000},{"value":1.2359856E7,"elapsed_since_start_ns":"1777358021894000000","timestamp":1777358021.894000},{"value":1.2421296E7,"elapsed_since_start_ns":"1777358021995000000","timestamp":1777358021.995000},{"value":1.2441776E7,"elapsed_since_start_ns":"1777358022094000000","timestamp":1777358022.094000},{"value":1.2495024E7,"elapsed_since_start_ns":"1777358022194000000","timestamp":1777358022.193999},{"value":1.2515504E7,"elapsed_since_start_ns":"1777358022294000000","timestamp":1777358022.294000},{"value":1.2535984E7,"elapsed_since_start_ns":"1777358022394000000","timestamp":1777358022.393999},{"value":1.2597424E7,"elapsed_since_start_ns":"1777358022495000000","timestamp":1777358022.495000},{"value":1.2617904E7,"elapsed_since_start_ns":"1777358022594000000","timestamp":1777358022.593999},{"value":1.2892512E7,"elapsed_since_start_ns":"1777358022694000000","timestamp":1777358022.694000},{"value":1.294576E7,"elapsed_since_start_ns":"1777358022794000000","timestamp":1777358022.794000}]},"screen_frame_rates":{"unit":"hz","values":[{"value":60.000003814697266,"elapsed_since_start_ns":"71057630775779","timestamp":1777358020.888000}]}},"platform":"android","release":"io.sentry.samples.android@8.38.0+2","environment":"debug","version":"2","content_type":"perfetto","timestamp":1777358020.855000} +U2N + + +  + +巜 +  + + + +NP +U2N + + +  + +巜 +  + + + +NP +NPGzvB32mWy +oNPg + 9 +7 + +linux.perf(zd X +io.sentry.samples.android :p +NP +恀"Perfetto v46.0 (N/A)8x +m +Linux+#1 SMP PREEMPT Tue Jun 18 20:50:32 UTC 2024"aarch64.6.6.30-android15-8-gdd9c02ccfe27-ab11987101-4k0 @Ngoogle/sdk_gphone64_arm64/emu64a:15/AE3A.240806.036/12592187:user/release-keys(#JranchuNP +@ᎤNP +@NP +@ލNP +@NP( +*@h b +d NPu +!b*hNPu +h@b&"[anon_shmem:dalvik-jit-code-cache]@ (08*9+5io.sentry.samples.android.ProfilingActivity.fibonacci2 + ؊2 + 2 + ܊apex com.android.art lib64   libart.so +ܹ+\?h*`B}#@ (0 +88 88 *art_quick_invoke_stub2  *uq_ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE2  *OK_ZN3art11interpreter20ExecuteSwitchImplCppILb0EEEvPNS0_17SwitchImplContextE2   ȴ*ExecuteSwitchImplAsm2    data!io.sentry.samples.android" +code_cache #.overlay $base.apk% classes19.dex&@ (08 8 8!8"8#8$8%*A*=io.sentry.samples.android.ProfilingActivity.runMathOperations2 +* *'_ZN3art11interpreterL7ExecuteEPNS_6ThreadERKNS_20CodeItemDataAccessorERNS_11ShadowFrameENS_6JValueEbb.__uniq.112435418011751916792819755956732575238.llvm.28456970603708385182 ' 2  *S)Oio.sentry.samples.android.ProfilingActivity.onCreate$lambda$9$lambda$8$lambda$72 +) *V(Rio.sentry.samples.android.ProfilingActivity.$r8$lambda$X9B0bkXYlGwrLTB23XqGg4LznRs2 +( Н*M&Iio.sentry.samples.android.ProfilingActivity$$ExternalSyntheticLambda5.run2 +& *artQuickToInterpreterBridge2  + *#art_quick_to_interpreter_bridge2   *73java.util.concurrent.Executors$RunnableAdapter.call2 + T*'#java.util.concurrent.FutureTask.run2 + T*51java.util.concurrent.ThreadPoolExecutor.runWorker2 + ܐT*62java.util.concurrent.ThreadPoolExecutor$Worker.run2 + T [anon:dalvik- javalibcore-oj.jar-transformed]"@ (0888 88*java.lang.Thread.run2   a*;7_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc2  *_ZN3art9ArtMethod14InvokeInstanceILc86ETpTncJEEENS_6detail12ShortyTraitsIXT_EE4TypeEPNS_6ThreadENS_6ObjPtrINS_6mirror6ObjectEEEDpNS3_IXT0_EE4TypeE2  *%!_ZN3art6Thread14CreateCallbackEPv2  ͏*/ +_ZN3art6Thread24CreateCallbackWithUffdGcEPv2   com.android.runtime +bionic libc.so4~dl&$@ (088888* _ZL15__pthread_startPv2 +  Ԇ*__start_thread2 + :t:  +     +        tu(0 :NPu +h@鸆b2 + :|M  +     +        tu(0 MNPu +h@̽b2 + ؋:vX  +     +        tu(0 XNPu +h@މb2 + :rh  +     +        tu(0ڌ hNPu +h@븆bz:xv  +     +        tu(0 vNPu +h@Ĕνb:}  +     +        tu(0ç NPu +h@ІbꞀ*2 P *=Z9_ZN3art3jit12JitCodeCache14GetJniStubCodeEPNS_9ArtMethodE2 OZ *BY>_ZN3art12StackVisitor9WalkStackILNS0_16CountTransitionsE0EEEvb2 NY *<X8_ZN3art24JniDecodeReferenceResultEP8_jobjectPNS_6ThreadE2 MX Ի*Wart_jni_trampoline2 +LW *Vjava.lang.Object.clone2 KV N*Ujava.util.TimeZone.clone2 +JU `*"Tjava.util.SimpleTimeZone.clone2 +IT `*"Sjava.util.TimeZone.getTimeZone2 +HS `* Rart_quick_invoke_static_stub2 GR 2 F <com.android.conscrypt= conscrypt.jar @ (088<88=*Q7com.android.org.conscrypt.OpenSSLX509Certificate.toDate2 +EQ *;P7com.android.org.conscrypt.OpenSSLX509Certificate.2 +DP *KOGcom.android.org.conscrypt.OpenSSLX509Certificate.fromX509DerInputStream2 +CO *TNPcom.android.org.conscrypt.OpenSSLX509CertificateFactory$1.fromX509DerInputStream2 +BN 2 +AN *OMKcom.android.org.conscrypt.OpenSSLX509CertificateFactory$Parser.generateItem2 +@M *ULQcom.android.org.conscrypt.OpenSSLX509CertificateFactory.engineGenerateCertificate2 +?L *=K9java.security.cert.CertificateFactory.generateCertificate2 >K *<J8com.android.org.conscrypt.SSLUtils.decodeX509Certificate2 +=J *AI=com.android.org.conscrypt.SSLUtils.decodeX509CertificateChain2 +<I *;H7com.android.org.conscrypt.NativeSsl.getPeerCertificates2 +;H *FGBcom.android.org.conscrypt.ActiveSession.onPeerCertificateAvailable2 +:G *7F3com.android.org.conscrypt.ConscryptEngine.handshake2 +9F *E~_ZN3art11interpreter33ArtInterpreterToInterpreterBridgeEPNS_6ThreadERKNS_20CodeItemDataAccessorEPNS_11ShadowFrameEPNS_6JValueE2 8E *uDq_ZN3art11interpreter6DoCallILb1EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE2 7D 2 6 *2B.com.android.org.conscrypt.ConscryptEngine.wrap2 +5B Ԅ* Cjavax.net.ssl.SSLEngine.wrap2 4C 2 +3B *QAMcom.android.org.conscrypt.ConscryptEngineSocket$SSLOutputStream.writeInternal2 +2A Ⱥ*Z@Vcom.android.org.conscrypt.ConscryptEngineSocket$SSLOutputStream.-$$Nest$mwriteInternal2 +1@ ̶*??;com.android.org.conscrypt.ConscryptEngineSocket.doHandshake2 +0? *B>>com.android.org.conscrypt.ConscryptEngineSocket.startHandshake2 +/> *<;8com.android.okhttp.internal.io.RealConnection.connectTls2 .; *?:;com.android.okhttp.internal.io.RealConnection.connectSocket2 +-: x*995com.android.okhttp.internal.io.RealConnection.connect2 +,9 ̡x*D8@com.android.okhttp.internal.http.StreamAllocation.findConnection2 ++8 u*K7Gcom.android.okhttp.internal.http.StreamAllocation.findHealthyConnection2 +*7 u*?6;com.android.okhttp.internal.http.StreamAllocation.newStream2 +)6 u*753com.android.okhttp.internal.http.HttpEngine.connect2 +(5 Ȭu*;47com.android.okhttp.internal.http.HttpEngine.sendRequest2 +'4 t*A3=com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute2 +&3 s*A2=com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect2 +%2 o*H1Dcom.android.okhttp.internal.huc.DelegatingHttpsURLConnection.connect2 $1 *B0>com.android.okhttp.internal.huc.HttpsURLConnectionImpl.connect2 #0 ̥@ (0*7/3io.sentry.transport.HttpConnection.createConnection2 +"/ *+.'io.sentry.transport.HttpConnection.send2 +!. *?-;io.sentry.transport.AsyncHttpTransport$EnvelopeSender.flush2 + - *=,9io.sentry.transport.AsyncHttpTransport$EnvelopeSender.run2 +, :  +     +     !  "  #$%&'()*+,-.  +/  0  1  2  3  4  +5 6789  :  ;  <  =  >  +?  @  A  B  C  D  E  FGHIJKLMNOPtt(0ΐ! NPu + h@膔bȠ +|vendor}libGLESv2_enc.so{UrF0X3]z@  @ (0{8|88}**~&_ZN10GL2Encoder17s_glActiveTextureEPvj2 +l~  +\system` +libhwui.so_G +k)hKq-! +@ (0_8\88`*,z(_ZN7GrGLGpu24bindTextureToScratchUnitEji2 kz + Բ*RyN_ZN7GrGLGpu13onWritePixelsEP9GrSurface7SkIRect11GrColorTypeS3_PK10GrMipLevelib2 jy + ȯ*NxJ_ZN5GrGpu11writePixelsEP9GrSurface7SkIRect11GrColorTypeS3_PK10GrMipLevelib2 ix + *`w\_ZNK18GrResourceProvider11writePixelsE5sk_spI9GrTextureE11GrColorType7SkISizePK10GrMipLeveli2 hw + ؼ*偀v_ZN18GrResourceProvider13createTextureE7SkISizeRK15GrBackendFormat13GrTextureType11GrColorTypeN5skgpu10RenderableEiNS6_8BudgetedENS6_9MipmappedENS6_9ProtectedEPK10GrMipLevelNSt3__117basic_string_viewIcNSE_11char_traitsIcEEEE2 gv + ܱ*؁u_ZZN15GrProxyProvider30createNonMippedProxyFromBitmapERK8SkBitmap12SkBackingFitN5skgpu8BudgetedEENK3$_0clEP18GrResourceProviderRKN14GrSurfaceProxy15LazySurfaceDescE.__uniq.2522539992798261353922680713117104824422 fu + ܥ*FtB_ZN18GrSurfaceProxyPriv19doLazyInstantiationEP18GrResourceProvider2 et + *ise_ZN15GrProxyProvider21createProxyFromBitmapERK8SkBitmapN5skgpu9MipmappedE12SkBackingFitNS3_8BudgetedE2 ds + *r_ZL14make_bmp_proxyP15GrProxyProviderRK8SkBitmap11GrColorTypeN5skgpu9MipmappedE12SkBackingFitNS5_8BudgetedE.__uniq.200737704020540266365245535337625923746.llvm.47135207145919736612 cr + *q_Z27GrMakeCachedBitmapProxyViewP18GrRecordingContextRK8SkBitmapNSt3__117basic_string_viewIcNS4_11char_traitsIcEEEEN5skgpu9MipmappedE2 bq + ԛ*p}_ZN5skgpu6ganesh19AsFragmentProcessorEP18GrRecordingContextPK7SkImage17SkSamplingOptionsPK10SkTileModeRK8SkMatrixPK6SkRectSF_2 ap + *o_ZN5skgpu6ganesh6Device15drawEdgeAAImageEPK7SkImageRK6SkRectS7_PK7SkPointN8SkCanvas11QuadAAFlagsERK8SkMatrixRK17SkSamplingOptionsRK7SkPaintNSB_17SrcRectConstraintESF_10SkTileMode2 `o + Ы*n_ZN5skgpu6ganesh6Device19drawImageQuadDirectEPK7SkImageRK6SkRectS7_PK7SkPointN8SkCanvas11QuadAAFlagsEPK8SkMatrixRK17SkSamplingOptionsRK7SkPaintNSB_17SrcRectConstraintE2 _n + *m{_ZN5skgpu6ganesh6Device13drawImageRectEPK7SkImagePK6SkRectRS6_RK17SkSamplingOptionsRK7SkPaintN8SkCanvas17SrcRectConstraintE2 ^m + *olk_ZN8SkCanvas16onDrawImageRect2EPK7SkImageRK6SkRectS5_RK17SkSamplingOptionsPK7SkPaintNS_17SrcRectConstraintE2 ]l + *SkO_ZN7android10uirenderer14VectorDrawable4Tree4drawEP8SkCanvasRK6SkRectRK7SkPaint2 \k + *ViR_ZNK7android10uirenderer12skiapipeline18RenderNodeDrawable11drawContentEP8SkCanvas2 Zi + *OhK_ZN7android10uirenderer12skiapipeline18RenderNodeDrawable6onDrawEP8SkCanvas2 Yh + *.j*_ZN10SkDrawable4drawEP8SkCanvasPK8SkMatrix2 [j + *g_ZN7android10uirenderer12skiapipeline12SkiaPipeline15renderFrameImplERK6SkRectRKNSt3__16vectorINS_2spINS0_10RenderNodeEEENS6_9allocatorISA_EEEEbRKNS0_4RectEP8SkCanvasRK8SkMatrix2 Xg + *Ӂf_ZN7android10uirenderer12skiapipeline12SkiaPipeline11renderFrameERKNS0_16LayerUpdateQueueERK6SkRectRKNSt3__16vectorINS_2spINS0_10RenderNodeEEENS9_9allocatorISD_EEEEbRKNS0_4RectE5sk_spI9SkSurfaceERK8SkMatrix2 Wf + *‚e_ZN7android10uirenderer12skiapipeline18SkiaOpenGLPipeline4drawERKNS0_12renderthread5FrameERK6SkRectS9_RKNS0_13LightGeometryEPNS0_16LayerUpdateQueueERKNS0_4RectEbRKNS0_9LightInfoERKNSt3__16vectorINS_2spINS0_10RenderNodeEEENSL_9allocatorISP_EEEEPNS0_19FrameInfoVisualizerERKNS3_26HardwareBufferRenderParamsERNSL_5mutexE2 Ve + *?d;_ZN7android10uirenderer12renderthread13CanvasContext4drawEb2 Ud + *[cW_ZN7android10uirenderer12renderthread13CanvasContext14prepareAndDrawEPNS0_10RenderNodeE2 Tc + *QbM_ZN7android10uirenderer12renderthread12RenderThread22dispatchFrameCallbacksEv2 Sb + *EaA_ZN7android10uirenderer12renderthread12RenderThread10threadLoopEv2 Ra + 䯸] libutils.so[%ܾ(aY*-  @ (0[8\88]*&^"_ZN7android6Thread11_threadLoopEPv2 +Q^ :gQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghijkltt(0ڜ9 NPu +h@†b{:y  +     +        tu(0 NPu +h@Òdžby:w  +     +        tu(0 NPu +h@ˆb2 m+ Љ:y  +     +        mtu(0 NPu +h@Іb:  +     +        mtu(0Հ! NPu +h@Նbu:s  +     +        mtu(0& NPu +h@Ǚچb2 n+ :u  +     +        ntu(0* NPu +h@ކby:w  +     +        tu(0/ NPu +h@㆔bs:q  +     +        mtu(0ҧ4 NPu +h@膔by:w  +     +        mtu(09 NPu +h@É톔b2 o+ :u  +     +        otu(0= NPu + h@ކb* java.util.Locale.getDefault2  Z*#java.util.Calendar.getInstance2 ~ a*+&io.sentry.DateUtils.getCurrentDateTime2 } *(#io.sentry.SentryNanotimeDate.2 | Մ classes7.dex'@ (08 8 8!8"8#8$8*idio.sentry.android.core.PerfettoContinuousProfiler$ChunkMeasurementCollector$1.onFrameMetricCollected2 {  classes16.dex' @ ̛(08 8 8!8"8#8$8*io.sentry.android.core.internal.util.SentryFrameMetricsCollector.lambda$new$2$io-sentry-android-core-internal-util-SentryFrameMetricsCollector2 z *wrio.sentry.android.core.internal.util.SentryFrameMetricsCollector$$ExternalSyntheticLambda4.onFrameMetricsAvailable2 y *>9android.view.FrameMetricsObserver.onFrameMetricsAvailable2 x м*KFandroid.graphics.HardwareRendererObserver.lambda$notifyDataAvailable$02 w *UPandroid.graphics.HardwareRendererObserver.$r8$lambda$PeqK8_uy-Wp8rbu7N1ihQlz9qD42 v *LGandroid.graphics.HardwareRendererObserver$$ExternalSyntheticLambda0.run2 u س*&!android.os.Handler.handleCallback2 t ̋*'"android.os.Handler.dispatchMessage2 s *android.os.Looper.loopOnce2 r *android.os.Looper.loop2 q   framework framework.jar" @  ಝ(08\88*!android.os.HandlerThread.run2 p ܫy:[  +p  FGqrstuvwx  +y  z 678{  |}~tt(0/ NPu +h@톔bЅ* unlinkat2  /* remove2  libjavacore.so1i8$@ (088 88*3._ZL12Linux_removeP7_JNIEnvP8_jobjectP8_jstring2  2 W *#libcore.io.ForwardingOs.remove2  *#libcore.io.BlockGuardOs.remove2  !@ (08\88*0+android.app.ActivityThread$AndroidOs.remove2  r*"java.io.UnixFileSystem.delete2  *java.io.File.delete2  @ (0**%io.sentry.cache.EnvelopeCache.discard2  :t  +     +         +  tt(0о> NPu +h@†by:w  +     +        mtu(0 NPu +h@Ւdžb{:y  +     +        otu(0 NPu +h@Зˆby:w  +     +        tu(0 NPu +h@Іby:w  +     +        tu(0! NPu +h@يՆb:  +     +        tu(0ڹ& NPu +h@چby:w  +     +        tu(0* NPu +h@ώ߆by:w  +     +        otu(0/ NPu +h@㆔by:w  +     +        tu(0ѵ4 NPu +h@膔b{:y  +     +        otu(09 NPu +h@톔b{:y  +     +        tu(0> NPu +h@돩blibOpenglSystemCommon.so!BO˧$"@8 (08|88*'"_ZN14QemuPipeStream11allocBufferEm2  9*)$_ZN9gfxstream5guest8IOStream5allocEm2  ش9*/*_ZN12_GLOBAL__N_119glActiveTexture_encEPvj2  2 ~ :qQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghijktt(0ψQ NPu + h@㔘b* read2  /*qemu_pipe_read2  9*72_ZN14QemuPipeStream24commitBufferAndReadFullyEmPvm2  9*94_ZN12_GLOBAL__N_124glBufferDataSyncAEMU_encEPvjlPKvj2  *d__ZZL17invalidate_bufferP7GrGLGpujjjmENKUlvE_clEv.__uniq.1474696467244232247310157921930280442822  + *WR_ZL17invalidate_bufferP7GrGLGpujjjm.__uniq.1474696467244232247310157921930280442822  + *)$_ZN10GrGLBuffer12onUpdateDataEPKvmmb2  + Ա*_ZN16GrDrawingManager5flushE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *_ZN16GrDrawingManager13flushSurfacesE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *_ZN19GrDirectContextPriv13flushSurfacesE6SkSpanIP14GrSurfaceProxyEN10SkSurfaces20BackendSurfaceAccessERK11GrFlushInfoPKN5skgpu19MutableTextureStateE2  + *?:_ZN15GrDirectContext14flushAndSubmitEP9SkSurface9GrSyncCpu2  + 2 e + :5QRSTUtt(0h NPu +h@b{:y  +     +        otu(0B NPu +h@b}:{  +     +        otu(0G NPu +h@b{:y  +     +        mtu(0L NPu +h@Ĭb{:y  +     +        tu(0ˈQ NPu +h@厅by:w  +     +        mtu(0U NPu +h@񉇔bw:u  +     +        mtu(0Z NPu +h@ӎby:w  +     +        tu(0ɯ_ NPu +h@b2 + ȉ:x  +     +        tu(0d NPu +h@ؙby:w  +     +        otu(0h NPu +h@bu:s  +     +        tu(0m NPu +h@bw:u  +     +        tu(0B NPu +h@݉b:}  +     +        otu(0޸G NPu +h@b2 + :z  +     +        tu(0L NPu +h@by:w  +     +        mtu(0Q NPu +h@摅by:w  +     +        tu(0U NPu +h@󉇔b{:y  +     +        tu(0Z NPu +h@֎bs:q  +     +        tu(0ů_ NPu +h@by:w  +     +        tu(0d NPu +h@ɜb}:{  +     +        tu(0h NPu +h@bw:u  +     +        ntu(0m NPu +ph@ڀbE2  9:5QRSTUtt(0 NPu +h@LJbՄ*.)_ZThn8_NK14GrOpFlushState12atlasManagerEv2  + ܂*e`_ZN6sktext3gpu11GlyphVector24regenerateAtlasForGaneshEiiN5skgpu10MaskFormatEiP16GrMeshDrawTarget2  + *je_ZNKSt3__18functionIFNS_5tupleIJbiEEEPN6sktext3gpu11GlyphVectorEiiN5skgpu10MaskFormatEiEEclES6_iiS8_i2  + *FA_ZN5skgpu6ganesh11AtlasTextOp14onPrepareDrawsEP16GrMeshDrawTarget2  + 俷*94_ZN5skgpu6ganesh7OpsTask9onPrepareEP14GrOpFlushState2  + *0+_ZN12GrRenderTask7prepareEP14GrOpFlushState2  + 2  + :2QRSTUtt(0˘ NPu +h@ܡbu:s  +     +        mtu(0r NPu +h@쿦bw:u  +     +        tu(0w NPu +h@ܡbw:u  +     +        mtu(0{ NPu +h@㩅bq:o  +     +        tu(0 NPu +h@洇by:w  +     +        tu(0ڡ… NPu +h@ȹbw:u  +     +        ntu(0ޤ NPu +h@b}:{  +     +        otu(0ٳ NPu +h@Çbu:s  +     +        tu(0 NPu +h@LJb{:y  +     +        tu(0՘ NPu +h@̇b{:y  +     +        tu(0׃ NPu + h@b libui.sog}$"@ + (08\88*"_ZN7android5Fence9getStatusEv2  ԉ  libgui.so RhI+][Ī͚"@$ (08\88*fa_ZN7android12ConsumerBase21addReleaseFenceLockedEiNS_2spINS_13GraphicBufferEEERKNS1_INS_5FenceEEE2  1*[V_ZN7android18BufferItemConsumer13releaseBufferERKNS_10BufferItemERKNS_2spINS_5FenceEEE2  1*_ZN7androidL26releaseBufferCallbackThunkENS_2wpINS_16BLASTBufferQueueEEERKNS_17ReleaseCallbackIdERKNS_2spINS_5FenceEEENSt3__18optionalIjEE.__uniq.454506313120748354298817219915909741302  1*߁_ZNSt3__18__invokeB8nn180000IRPFvN7android2wpINS1_16BLASTBufferQueueEEERKNS1_17ReleaseCallbackIdERKNS1_2spINS1_5FenceEEENS_8optionalIjEEEJRS4_S7_SC_SE_EEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSJ_DpOSK_2  /*ZU_ZN7android28TransactionCompletedListener22onTransactionCompletedENS_13ListenerStatsE2  1*PK_ZN7android30BnTransactionCompletedListener10onTransactEjRKNS_6ParcelEPS1_j2  * libbinder.so12B՗x<"@ (08\88*2-_ZN7android14IPCThreadState14executeCommandEi2  *2-_ZN7android14IPCThreadState14joinThreadPoolEb2  **%_ZN7android10PoolThread10threadLoopEv2  libandroid_runtime.so~*kzE#޾}%'"@4 ǃ(08\88*4/_ZN7android14AndroidRuntime15javaThreadShellEPv2  ::*Qtt(0 NPu +h@Πᡇbs:q  +     +        tu(0кr NPu +h@æbw:u  +     +        mtu(0w NPu +h@b}:{  +     +        tu(0{ NPu +h@ꈰb}:{  +     +        tu(0Ӛ NPu +h@촇bu:s  +     +        otu(0Ņ NPu +h@˹b{:y  +     +        tu(0פ NPu +h@b}:{  +     +        mtu(0减 NPu +h@Çbw:u  +     +        tu(0ѫ NPu +h@LJby:w  +     +        tu(0ƁҘ NPu +h@̇by:w  +     +        mtu(0􇮝 NPu ++h@btt(0ͣ NPu +h@чby:w   +     +        tu(0 NPu +h@זևb:}   +     +        tu(0 NPu +h@ڇbw:u   +     +        tu(0ډի NPu +h@߇bu:s   +     +        ntu(0ָ NPu +h@䇔by:w   +     +        tu(0 NPu +h@釔by:w   +     +        mtu(0 NPu +h@Ȃb:   +     +        mtu(0޾ NPu +h@bu:s   +     +        tu(0 NPu +h@bw:u   +     +        tu(0Ѥ NPu +h@bw:u   +     +        mtu(0ȅ NPu +h@чby:w   +     +        tu(0 NPu +h@יևb{:y   +     +        tu(0և NPu +h@ڇbv:t +  +     +        tu(0ի +NPu +h@߇by:w +  +     +        tu(0Ϸ +NPu +h@䇔bk:i +  +     +        ntu(0噵 +NPu +h@釔bu:s +  +     +        tu(0 +NPu +h@b{:y +  +     +        mtu(0޾ +NPu +h@b2 + :x +  +     +        tu(0 +NPu +h@bq:o +  +     +        mtu(0 +NPu +h@b}:{ +  +     +        otu(0ȅ +NPu ++h@ݯbtt(0䞏 NPu +h@֝b*java.util.Locale.hashCode2  */*java.util.concurrent.ConcurrentHashMap.get2  *(#java.util.Calendar.setWeekCountData2  a*java.util.Calendar.2  a*'"java.util.GregorianCalendar.2  Ԉa*&!java.util.Calendar.createCalendar2  a2  a:l +  +p  FGqrstuvwx  +y  z 678{  |}tt(0̟ +NPu +h@ޛb* +write2  ȭ/*qemu_pipe_write2  9*qemu_pipe_write_fully2  9*:5_ZN9gfxstream5guest8IOStream12uploadPixelsEPviiijjPKv2  "*94_ZN12_GLOBAL__N_119glTexSubImage2D_encEPvjiiiiijjPKv2  *50_ZN10GL2Encoder17s_glTexSubImage2DEPvjiiiiijjPKv2  egllibGLESv2_emulation.sopZw%@ (08|888*glTexSubImage2D2  *C>_ZN7GrGLGpu13uploadTexDataE7SkISizej7SkIRectjjmPK10GrMipLeveli2  + *e`_ZN7GrGLGpu22uploadColorTypeTexDataE10GrGLFormat11GrColorType7SkISizej7SkIRectS1_PK10GrMipLeveli2  + :| +QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 +NPu +h@􌁈bu:s +  +     +        ntu(0 +NPu +h@by:w +  +     +        tu(0 +NPu +h@Њbw:u +  +     +        mtu(0ά +NPu +h@ܳb:   +     +        tu(0 NPu +h@by:w   +     +        tu(0٥ NPu +h@bk:i   +     +        ntu(0 NPu +h@ٝb{:y   +     +        mtu(0 NPu +h@b:}   +     +        mtu(0 NPu +h@Ϡby:w   +     +        otu(0 NPu +h@bw:u   +     +        ntu(0Ǩ NPu +h@Îbu:s   +     +        otu(0 NPu +h@ը񅈔b{:y   +     +        otu(0 NPu +h@ӊb{:y   +     +        tu(0֬ NPu +h@ʷby:w   +     +        tu(0 NPu +h@Ʈb{:y   +     +        mtu(0 NPu +h@by:w   +     +        tu(0 NPu +h@ܝb}:{   +     +        tu(0 NPu +h@bs:q   +     +        otu(0Я NPu +h@bm:k   +     +        mtu(0 NPu +h@bw:u   +     +        mtu(0̦ NPu +h@b2  :y QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 NPu +h@ֈbl2 u + Ф:\ QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdett(0ҧ NPu +h@㰈by:w   +     +        mtu(0 NPu +h@ŵbs:q   +     +        ntu(0塆 NPu +h@혨bx:v   +     +        tu(0 NPu +h@by:w   +     +        mtu(0 NPu +h@Èb{:y   +     +        tu(0Ȕ NPu +h@Ȉby:w   +     +        tu(0 NPu +h@ʼ͈bx:v   +     +        tu(0č NPu +h@҈b:   +     +        mtu(0 NPu +h@ֈb}:{   +     +        tu(0ӧ NPu +h@ۈbw:u   +     +        tu(0ʴ NPu +h@氈bs:q   +     +        tu(0 NPu +h@ȵb{:y   +     +        tu(0 NPu +h@ꪺb{:y   +     +        tu(0 NPu +h@by:w   +     +        tu(0Ь NPu +h@Èb}:{   +     +        tu(0ɔ NPu +h@Ȉby:w   +     +        tu(0͘ NPu +h@͈bu:s   +     +        tu(0ȍ NPu +h@҈by:w   +     +        otu(0 NPu +h@جֈb~:|  +     +        tu(0ԧ NPu +h@ۈbw:u  +     +        tu(0ܴ NPu +h@blibOpenglCodecCommon.sobsCQ"@ (08|88*94_ZN9gfxstream5guest13GLClientState13setPixelStoreEji2  2  + :jQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0𐾿 NPu +h@ʆbr*% _ZN10GL2Encoder12s_glGetErrorEPv2  2  + Ⱥ:,QRSTUtt(0 NPu +h@by:w  +     +        tu(0 NPu +h@刔bw:u  +     +        tu(0 NPu +h@鈔bw:u  +     +        mtu(0ۺ NPu +h@bw:u  +     +        tu(0 NPu +h@֠b{:y  +     +        tu(0ߧ NPu +h@bw:u  +     +        mtu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@끉b{:y  +     +        tu(0 NPu +h@Άbr:p  +     +        tu(0 NPu +h@bw:u  +     +        mtu(0؊ NPu +h@bs:q  +     +        mtu(0 NPu +h@刔b:}  +     +        mtu(0 NPu +h@ꈔby:w  +     +        tu(0ۺ NPu +h@b{:y  +     +        tu(0 NPu +h@by:w  +     +        tu(0ۨ NPu +h@ȭbu:s  +     +        tu(0݂ NPu +h@Ջbw:u  +     +        otu(0φ NPu +h@b}:{  +     +        tu(0 NPu +h@ׇԆb}:{  +     +        mtu(0 NPu +h@ٲbu:s  +     +        mtu(0 NPu +h@񔉔bl*__memset_aarch642  Է2  П:7  +p  FGqrstuvwx tt(0 NPu +h@b**%_ZNK12SkImage_Base15isTextureBackedEv2  + ߗ*_ZN5skgpu6ganesh6Device20drawAsTiledImageRectEP8SkCanvasPK7SkImagePK6SkRectRS8_RK17SkSamplingOptionsRK7SkPaintNS2_17SrcRectConstraintE2  + 2 l + *lg_ZNK7android10uirenderer4$_27clEPKvP8SkCanvasRK8SkMatrix.__uniq.1508489786452546026330485181743555618392  + :WQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0Օ NPu ++h@btt(0 +NPu +h@b:}  +     +        tu(0þ NPu +h@by:w  +     +        tu(0 NPu +h@Њיbu:s  +     +        ntu(0 NPu +h@﹞bu:s  +     +        mtu(0 NPu +h@団b{:y  +     +        ntu(0 NPu +h@b{:y  +     +        tu(0 NPu +h@ଉbe:c  +     +        tu(0 NPu +h@±bu:s  +     +        tu(0 NPu +h@b}:{  +     +        mtu(0ں NPu +h@܇by:w  +     +        mtu(0 NPu +h@啐b{:y  +     +        tu(0 NPu +h@b:  +     +        mtu(0 NPu +h@ٙb{:y  +     +        ntu(0 NPu +h@bs:q  +     +        mtu(0ڕ NPu +h@՞bw:u  +     +        mtu(0 NPu +h@뀨bo:m  +     +        tu(0ϛ NPu +h@֥㬉b{:y  +     +        mtu(0Լ NPu +h@űbs:q  +     +        mtu(0 NPu +h@bv:t  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +uh@ĉbJ2  ::  +p  FGqrstuvwx  +y tt(0࿨ NPu + h@ɍΉb*c^_ZN5scudo9AllocatorINS_19AndroidNormalConfigEXadL_Z21scudo_malloc_postinitEEE10reallocateEPvmm2  * scudo_realloc2  * realloc2  *2-_ZN21SkAnalyticEdgeBuilder7addLineEPK7SkPoint2  + *72_ZN13SkEdgeBuilder10buildEdgesERK6SkPathPK7SkIRect2  + ؂*?:_ZN6SkScan11AAAFillPathERK6SkPathP9SkBlitterRK7SkIRectS7_b2  + *B=_ZN6SkScan12AntiFillPathERK6SkPathRK12SkRasterClipP9SkBlitter2  + *JE_ZNK10SkDrawBase8drawPathERK6SkPathRK7SkPaintPK8SkMatrixbbP9SkBlitter2  + ƕ*61_ZN14SkBitmapDevice8drawPathERK6SkPathRK7SkPaintb2  + 訄*1,_ZN8SkCanvas10onDrawPathERK6SkPathRK7SkPaint2  + *.)_ZN8SkCanvas8drawPathERK6SkPathRK7SkPaint2  + *FA_ZN7android10uirenderer14VectorDrawable8FullPath4drawEP8SkCanvasb2  + *C>_ZN7android10uirenderer14VectorDrawable5Group4drawEP8SkCanvasb2  + *RM_ZN7android10uirenderer14VectorDrawable4Tree17updateBitmapCacheERNS_6BitmapEb2  + 2 k + ج:uQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu ++h@ά剔btt(0ض NPu +h@꿉b{:y  +     +        tu(0ۆƐ NPu +h@ĉbo:m  +     +        mtu(0֨ NPu +h@Ԯɉby:w  +     +        mtu(0ڊ NPu +h@Ήbw:u  +     +        tu(0 NPu +h@҉b}:{  +     +        tu(0ϰϣ NPu +h@׉b}:{  +     +        mtu(0ﱨ NPu +h@܉bm:k  +     +        otu(0 NPu +h@ቔbx:v  +     +        tu(0 NPu +h@剔b:  +     +        ntu(0ٶ NPu +h@ꉔby:w  +     +        mtu(0 NPu +h@쿉by:w  +     +        otu(0˙Ɛ NPu +h@ĉby:w  +     +        tu(0Ĩ NPu +h@Ԧɉb{:y  +     +        tu(0Ԋ NPu +h@㺕Ήb2 + :~  +     +        tu(0 NPu +h@ߘ҉by:w  +     +        mtu(0ϣ NPu +h@׉by:w  +     +        mtu(0 NPu +h@܉bw:u  +     +        otu(0 NPu +h@ߑቔbq:o  +     +        ntu(0 NPu +h@剔bu:s  +     +        tu(0ض NPu +h@ꉔbz:x  +     +        tu(0 NPu +h@ˎb*_ZNSt3__110__function6__funcIZN7GrGLGpu25createRenderTargetObjectsERKN11GrGLTexture4DescEiPN16GrGLRenderTarget3IDsEE3$_0NS_9allocatorISA_EEFvvEE7destroyEv.__uniq.111230615403708898952873255848304878871.0de7bae3ebde0cf49b23739a6271b5112  + ԫ2 s + :[QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abctt(0 NPu +h@Еb區*SN_ZN5skgpu6ganesh11AtlasTextOp8finalizeERK6GrCapsPK13GrAppliedClip11GrClampType2  + Я*킀_ZNSt3__18__invokeB8ne180000IRZN5skgpu6ganesh18SurfaceDrawContext16drawGlyphRunListEP8SkCanvasPK6GrClipRK8SkMatrixRKN6sktext12GlyphRunListE18SkStrikeDeviceInfoRK7SkPaintE3$_1JPKNSC_3gpu11AtlasSubRunE7SkPointSJ_5sk_spI8SkRefCntENSM_12RendererDataEEEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSV_DpOSW_.__uniq.1269637790681306725664032031755105794962  + ǻ*|_ZNKSt3__18functionIFvPKN6sktext3gpu11AtlasSubRunE7SkPointRK7SkPaint5sk_spI8SkRefCntENS2_12RendererDataEEEclES5_S6_S9_SC_SD_2  + *܁_ZNK12_GLOBAL__N_116DirectMaskSubRun4drawEP8SkCanvas7SkPointRK7SkPaint5sk_spI8SkRefCntERKNSt3__18functionIFvPKN6sktext3gpu11AtlasSubRunES3_S6_S9_NSD_12RendererDataEEEE.__uniq.1742939675488946967577203259347195027942  + *_ZNK6sktext3gpu15SubRunContainer4drawEP8SkCanvas7SkPointRK7SkPaintPK8SkRefCntRKNSt3__18functionIFvPKNS0_11AtlasSubRunES4_S7_5sk_spIS8_ENS0_12RendererDataEEEE2  + *䁀_ZN6sktext3gpu25TextBlobRedrawCoordinator16drawGlyphRunListEP8SkCanvasRK8SkMatrixRKNS_12GlyphRunListERK7SkPaint18SkStrikeDeviceInfoRKNSt3__18functionIFvPKNS0_11AtlasSubRunE7SkPointSC_5sk_spI8SkRefCntENS0_12RendererDataEEEE2  + *^Y_ZN5skgpu6ganesh6Device18onDrawGlyphRunListEP8SkCanvasRKN6sktext12GlyphRunListERK7SkPaint2  + Ѓ*ID_ZN8SkCanvas18onDrawGlyphRunListERKN6sktext12GlyphRunListERK7SkPaint2  + *<7_ZN8SkCanvas14onDrawTextBlobEPK10SkTextBlobffRK7SkPaint2  + *:5_ZN8SkCanvas12drawTextBlobEPK10SkTextBlobffRK7SkPaint2  + :iQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu +h@b:  +     +        mtu(0 NPu +h@b:}  +     +        mtu(0 NPu +h@bt:r  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +h@ʂbw:u  +     +        mtu(0 NPu +h@❭b{:y  +     +        tu(0 NPu +h@bw:u  +     +        tu(0 NPu +h@󐊔bw:u  +     +        mtu(0 NPu +h@Օbu:s  +     +        tu(0 NPu +h@Զbo:m  +     +        tu(0ے NPu +h@bw:u  +     +        mtu(0 NPu +h@b}:{  +     +        tu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@b2 + :v  +     +        tu(0 NPu +h@ςby:w  +     +        ntu(0 NPu +h@􀰇bs:q  +     +        mtu(0Ű NPu +h@b{:y  +     +        mtu(0 NPu +h@bw:u  +     +        mtu(0ӯ NPu +h@֕bs:q  +     +        ntu(0 NPu +h@b}:{  +     +        mtu(0ޒ NPu +h@b*1,_ZN3art27BuildGenericJniFrameVisitor5VisitEv2  *!artQuickGenericJniTrampoline2  *% art_quick_generic_jni_trampoline2  * java.lang.ref.Reference.get2  ́2  :<  +p  FGqrstuvwtt(0 NPu +h@ۼbŁ*)$_ZN9gfxstream5guest8IOStream5flushEv2  92  :|QRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_`abcdefghitt(0 NPu +h@¨Ŋb*^Y_ZN7android10uirenderer10RenderNode15prepareTreeImplERNS0_12TreeObserverERNS0_8TreeInfoEb2  + *_ZNSt3__110__function6__funcIZN7android10uirenderer10RenderNode15prepareTreeImplERNS3_12TreeObserverERNS3_8TreeInfoEbE3$_0NS_9allocatorIS9_EEFvPS4_S6_S8_bEEclEOSC_S6_S8_Ob.__uniq.10397782060659495822194741288103189803.203475ed07c5ffb5392ca8a2a4b019a12  + 2  + *E@_ZN7android10uirenderer10RenderNode11prepareTreeERNS0_8TreeInfoE2  + *ID_ZN7android10uirenderer14RootRenderNode11prepareTreeERNS0_8TreeInfoE2  + *kf_ZN7android10uirenderer12renderthread13CanvasContext11prepareTreeERNS0_8TreeInfoEPllPNS0_10RenderNodeE2  + 2 c + :=QRStt(0 NPu +h@Öby:w  +     +        otu(0י NPu +h@by:w  +     +        tu(0 NPu +h@ݨb{:y  +     +        tu(0ܹ NPu +h@܇by:w  +     +        otu(0 NPu +h@Ңb:}  +     +        tu(0 NPu +h@bu:s  +     +        tu(0 NPu +h@绊by:w  +     +        tu(0Ì NPu +h@bw:u  +     +        mtu(0泥 NPu +h@檬Ŋbw:u  +     +        tu(0̮ NPu +h@ʊb|:z  +     +        tu(0 NPu +h@ܜb{:y  +     +        tu(0΋ NPu +h@by:w  +     +        ntu(0 NPu +h@ਊbw:u  +     +        tu(0 NPu +h@ƭbs:q  +     +        tu(0 NPu +h@능by:w  +     +        tu(0 NPu +h@b{:y  +     +        mtu(0 NPu +h@黊by:w  +     +        tu(0Ì NPu +h@ьb:}  +     +        mtu(0 NPu +h@ޮŊby:w  +     +        mtu(0 NPu +h@ʊbw:u  +     +        mtu(0 NPu +h@݊b*_ZN12SkRasterClipC1ERKS_2  + * _ZN17SkRasterClipStackC2Eii2  + *:5_ZN14SkBitmapDeviceC1ERK8SkBitmapRK14SkSurfacePropsPv2  + *|w_ZN8SkCanvasC2ERK8SkBitmapNSt3__110unique_ptrI23SkRasterHandleAllocatorNS3_14default_deleteIS5_EEEEPvPK14SkSurfaceProps2  + 2  + :WQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZtt(0 NPu ++h@btt(0 NPu +h@Ίb{:y  +     +        mtu(0̟ NPu +h@ӊbq:o  +     +        ntu(0 NPu +h@؊b{:y  +     +        mtu(0  NPu +h@݊bu:s  +     +        tu(0 NPu +h@ѱኔb:  +     +        tu(0۷ֲ NPu +h@抔b{:y  +     +        otu(0 NPu +h@는bw:u  +     +        tu(0 NPu +h@by:w  +     +        tu(0 NPu +h@ۄbw:u  +     +        tu(0 NPu +h@by:w  +     +        mtu(0 NPu +h@Ίbw:u  +     +        otu(0̟ NPu +h@ӊbq:o  +     +        tu(0 NPu +h@؊b}:{  +     +        tu(0 NPu +h@݊bo:m  +     +        tu(0 NPu +h@ኔby:w  +     +        ntu(0ֲ NPu +h@抔b{:y  +     +        mtu(0λ NPu +h@는bq:o  +     +        mtu(0њ NPu +h@̣bx:v  +     +        tu(0 NPu +h@b|:z  +     +        tu(0 NPu +h@bw:u  +     +        mtu(0 NPu +h@댋bЄ*_Z_ZNK19GrFragmentProcessor19visitTextureEffectsERKNSt3__18functionIFvRK15GrTextureEffectEEE2  + *_ZNK12_GLOBAL__N_114FillRectOpImpl12visitProxiesERKNSt3__18functionIFvP14GrSurfaceProxyN5skgpu9MipmappedEEEE.__uniq.296536696735338101786510625415314891244.82f04894e2d02c5f3d5f5eaed87a27c22  + А*_ZN5skgpu6ganesh18SurfaceDrawContext9addDrawOpEPK6GrClipNSt3__110unique_ptrI4GrOpNS5_14default_deleteIS7_EEEERKNS5_8functionIFvPS7_jEEE2  + 2 o + :YQRSTUVWXYZ[YZ[YZ[YZ[YZ[YZ[YZ[YZ\]^_tt(0 NPu ++h@פbtt(0틷 NPu +h@b{:y  +     +        otu(0ǔ NPu +h@߰b:  +     +        mtu(0 NPu +h@쌈b}:{  +     +        mtu(0 NPu +h@by:w  +     +        mtu(0 NPu +h@ёbw:u  +     +        tu(0ɭ NPu +h@b{:y  +     +        ntu(0 NPu +h@㖛by:w  +     +        tu(0 NPu +h@bw:u  +     +        otu(0 NPu +h@ۤbu:s  +     +        mtu(0 NPu +h@bm:k  +     +        otu(0 NPu +h@b냀2  @ (0*&!io.sentry.util.FileUtils.readText2  *A9android.view.ViewRootImpl$ViewPostImeInputStage.onProcess2  ɻ*1,android.view.ViewRootImpl$InputStage.deliver2  *94android.view.ViewRootImpl$InputStage.onDeliverToNext2  *1,android.view.ViewRootImpl$InputStage.forward2  *61android.view.ViewRootImpl$AsyncInputStage.forward2  */*android.view.ViewRootImpl$InputStage.apply2  *4/android.view.ViewRootImpl$AsyncInputStage.apply2  2  *0+android.view.ViewRootImpl.deliverInputEvent2  Һ*3.android.view.ViewRootImpl.doProcessInputEvents2  *0+android.view.ViewRootImpl.enqueueInputEvent2  쩺*D?android.view.ViewRootImpl$WindowInputEventReceiver.onInputEvent2  *72android.view.InputEventReceiver.dispatchInputEvent2  *_ZN3art35InvokeVirtualOrInterfaceWithVarArgsIPNS_9ArtMethodEEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_St9__va_list2  *TO_ZN3art3JNIILb1EE15CallVoidMethodVEP7_JNIEnvP8_jobjectP10_jmethodIDSt9__va_list2  *_ZN3art12_GLOBAL__N_18CheckJNI11CallMethodVEPKcP7_JNIEnvP8_jobjectP7_jclassP10_jmethodIDSt9__va_listNS_9Primitive4TypeENS_10InvokeTypeE.__uniq.990339783528046273134915519602290474282  *_ZN3art12_GLOBAL__N_18CheckJNI15CallVoidMethodVEP7_JNIEnvP8_jobjectP10_jmethodIDSt9__va_list.__uniq.99033978352804627313491551960229047428.llvm.101458139733301615442  *94_ZN7_JNIEnv14CallVoidMethodEP8_jobjectP10_jmethodIDz2  4*GB_ZN7android24NativeInputEventReceiver13consumeEventsEP7_JNIEnvblPb2  U*<7_ZN7android24NativeInputEventReceiver11handleEventEiiPv2  U**%_ZN7android6Looper8pollOnceEiPiS1_PPv2  *OJ_ZN7androidL38android_os_MessageQueue_nativePollOnceEP7_JNIEnvP8_jobjectli2  `2 W !*!android.os.MessageQueue.next2  2  2  *$android.app.ActivityThread.main2  u*ni_ZN3art12InvokeMethodILNS_11PointerSizeE8EEEP8_jobjectRKNS_33ScopedObjectAccessAlreadyRunnableES3_S3_S3_m2  *rm_ZN3artL13Method_invokeEP7_JNIEnvP8_jobjectS3_P13_jobjectArray.__uniq.1657535210259653690657081520636215062772  2  "@ (08\88*@;com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run2  t +arm64boot-framework.oatc2_9 i͠s#@{ (֐08\888*,'com.android.internal.os.ZygoteInit.main2  2  *{v_ZN3art17InvokeWithVarArgsIP10_jmethodIDEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_St9__va_list2  *YT_ZN3art3JNIILb1EE21CallStaticVoidMethodVEP7_JNIEnvP7_jclassP10_jmethodIDSt9__va_list2  *>9_ZN7_JNIEnv20CallStaticVoidMethodEP7_jclassP10_jmethodIDz2  5*FA_ZN7android14AndroidRuntime5startEPKcRKNS_6VectorINS_7String8EEEb2  :bin app_process64Bk1oȆ2 #@ +( +08\88* main2  * __libc_init2  ̥:G  +  G  +    FG  +      Ntt(0㋡ NPu +h@ٌbӁ*4/_ZN12_GLOBAL__N_119glBufferSubData_encEPvjllPKv2  2  + *?:_ZN7android10uirenderer12renderthread13DrawFrameTask3runEv2  + :.QRUtt(0Ѫ NPu +h@שbu:s  +     +        tu(0Ն NPu +h@㒋Œby:w  +     +        ntu(0 NPu +h@ݳƌby:w  +     +        mtu(0Ɨ NPu +h@ˌby:w  +     +        mtu(0 NPu +h@ʲЌb}:{  +     +        mtu(0 NPu +h@ŔՌbs:q  +     +        ntu(0 NPu +h@Ăٌby:w  +     +        otu(0Ӫ NPu +h@ތb{:y  +     +        tu(0Ȳ NPu +h@㌔bx:v  +     +        tu(0 NPu +h@ń茔b{:y  +     +        tu(0 NPu +jh@b?2  + :.QRUtt(0 NPu + +h@b*2-_ZN3art11HExpressionILm2EE15GetInputRecordsEv2  *a\_ZN3art19SsaLivenessAnalysis24RecursivelyProcessInputsEPNS_12HInstructionES2_PNS_9BitVectorE2  2  *+&_ZN3art19SsaLivenessAnalysis7AnalyzeEv2  *_ZN3artL17AllocateRegistersEPNS_6HGraphEPNS_13CodeGeneratorEPNS_12PassObserverEPNS_23OptimizingCompilerStatsE.__uniq.812043013689377915893235274964569191162  *_ZNK3art18OptimizingCompiler10TryCompileEPNS_14ArenaAllocatorEPNS_10ArenaStackERKNS_18DexCompilationUnitEPNS_9ArtMethodENS_15CompilationKindEPNS_24VariableSizedHandleScopeE2  *_ZN3art18OptimizingCompiler10JitCompileEPNS_6ThreadEPNS_3jit12JitCodeCacheEPNS3_15JitMemoryRegionEPNS_9ArtMethodENS_15CompilationKindEPNS3_9JitLoggerE2  Ѣ*to_ZN3art3jit11JitCompiler13CompileMethodEPNS_6ThreadEPNS0_15JitMemoryRegionEPNS_9ArtMethodENS_15CompilationKindE2  Ǐ*]X_ZN3art3jit3Jit21CompileMethodInternalEPNS_9ArtMethodEPNS_6ThreadENS_15CompilationKindEb2  *1,_ZN3art3jit14JitCompileTask3RunEPNS_6ThreadE2  *$_ZN3art16ThreadPoolWorker3RunEv2  **%_ZN3art16ThreadPoolWorker8CallbackEPv2  :+tt(0 NPu +h@ʉbʂ* __epoll_pwait2  /2  2  *art_quick_osr_stub2  *YT_ZN3art3jit3Jit25MaybeDoOnStackReplacementEPNS_6ThreadEPNS_9ArtMethodEjiPNS_6JValueE2  2  :iG  +  G  +   tt(0ͩ NPu +h@쌔b{:y  +     +        tu(0ٽ NPu +h@b:  +     +        ntu(0ͼ NPu +h@bw:u  +     +        ntu(0 NPu + h@b*50_ZL16Linux_writeBytesP7_JNIEnvP8_jobjectS2_S2_ii2  2 W R*libcore.io.Linux.write2  *"libcore.io.ForwardingOs.write2  *"libcore.io.BlockGuardOs.write2  *libcore.io.IoBridge.write2  *#java.io.FileOutputStream.write2  *(#sun.nio.cs.StreamEncoder.writeBytes2  *-(sun.nio.cs.StreamEncoder.implFlushBuffer2  *'"sun.nio.cs.StreamEncoder.implFlush2  *#sun.nio.cs.StreamEncoder.flush2  *% java.io.OutputStreamWriter.flush2  *!java.io.BufferedWriter.flush2  *'"io.sentry.JsonSerializer.serialize2  +*% io.sentry.cache.CacheUtils.store2  *2-io.sentry.cache.PersistingScopeObserver.store2  2  *gbio.sentry.cache.PersistingScopeObserver.lambda$setTrace$10$io-sentry-cache-PersistingScopeObserver2  *JEio.sentry.cache.PersistingScopeObserver$$ExternalSyntheticLambda3.run2  *niio.sentry.cache.PersistingScopeObserver.lambda$serializeToDisk$13$io-sentry-cache-PersistingScopeObserver2  *JEio.sentry.cache.PersistingScopeObserver$$ExternalSyntheticLambda9.run2   core-oj.jar!@ (088 88*MHjava.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run2  2  2  :́   +           +                tt(0Ƶ NPu +h@崀팔bs:q   +     +        mtu(0ٽ NPu +h@bw:u   +     +        tu(0Έ NPu +h@bw:u   +     +        ntu(0 NPu +h@ϧb‡lib_renderControl_enc.so ϺOX0"!@ (08|88*83_ZN12_GLOBAL__N_119rcCreateSyncKHR_encEPvjPijiPmS2_2 ! libEGL_emulation.soUjYuc]K% @ (08|888*#_ZL16createNativeSyncjPKiibiPi2  *-(_ZN20egl_window_surface_t11swapBuffersEv2  *eglSwapBuffers2  č libEGL.so7}lw"@ (08\88*:5_ZN7android31eglSwapBuffersWithDamageKHRImplEPvS0_Pii2  * eglSwapBuffersWithDamageKHR2  *ZU_ZN7android10uirenderer12renderthread10EglManager11swapBuffersERKNS1_5FrameERK6SkRect2  + *_ZN7android10uirenderer12skiapipeline18SkiaOpenGLPipeline11swapBuffersERKNS0_12renderthread5FrameERNS3_15IRenderPipeline10DrawResultERK6SkRectPNS0_9FrameInfoEPb2  + 2 d + :2 QRtt(0 NPu +lh@bA2  :1 QRUtt(0 NPu ++h@ڍbtt(0ʱ NPu +@NP  +NP~ +` pP(#8  @`     * !(08@HP`hpx diff --git a/relay-server/src/envelope/content_type.rs b/relay-server/src/envelope/content_type.rs index 2b78898f30d..7cadac64853 100644 --- a/relay-server/src/envelope/content_type.rs +++ b/relay-server/src/envelope/content_type.rs @@ -192,6 +192,8 @@ relay_common::impl_str_de!(ContentType, "a content type string"); #[cfg(test)] mod tests { + use similar_asserts::assert_eq; + use super::*; #[test] diff --git a/relay-server/src/processing/profile_chunks/mod.rs b/relay-server/src/processing/profile_chunks/mod.rs index aa9ce5aa20d..d1c69d39a5c 100644 --- a/relay-server/src/processing/profile_chunks/mod.rs +++ b/relay-server/src/processing/profile_chunks/mod.rs @@ -130,16 +130,24 @@ impl Forward for ProfileChunkOutput { s: processing::forward::StoreHandle<'_>, ctx: processing::ForwardContext<'_>, ) -> Result<(), Rejected<()>> { - use crate::services::store::StoreProfileChunk; + use crate::services::store::{RawProfileContentType, StoreProfileChunk}; let Self(profile_chunks) = self; let retention_days = ctx.event_retention().standard; for item in profile_chunks.split(|pc| pc.profile_chunks) { + let (kafka_payload, raw_profile) = split_item_payload(&item); + s.send_to_store(item.map(|item, _| StoreProfileChunk { retention_days, - payload: item.payload(), + payload: kafka_payload, quantities: item.quantities(), + raw_profile_content_type: if raw_profile.is_some() { + Some(RawProfileContentType::Perfetto) + } else { + None + }, + raw_profile, })); } @@ -147,6 +155,32 @@ impl Forward for ProfileChunkOutput { } } +/// Splits a profile chunk item payload into its constituent parts. +/// +/// For compound items (those with a `meta_length` header), the payload is +/// `[expanded JSON][raw binary]`. Returns `(kafka_payload, raw_profile)`. +/// +/// For plain items, returns `(full_payload, None)`. +#[cfg(any(feature = "processing", test))] +fn split_item_payload(item: &Item) -> (bytes::Bytes, Option) { + let payload = item.payload(); + + let Some(meta_length) = item.meta_length() else { + return (payload, None); + }; + + let meta_length = meta_length as usize; + let Some((meta, body)) = payload.split_at_checked(meta_length) else { + return (payload, None); + }; + + if body.is_empty() { + return (payload.slice_ref(meta), None); + } + + (payload.slice_ref(meta), Some(payload.slice_ref(body))) +} + /// Serialized profile chunks extracted from an envelope. #[derive(Debug)] pub struct SerializedProfileChunks { @@ -184,3 +218,86 @@ impl Counted for SerializedProfileChunks { impl CountRateLimited for Managed { type Error = Error; } + +#[cfg(test)] +mod tests { + use similar_asserts::assert_eq; + + use crate::envelope::ContentType; + + use super::*; + + fn make_chunk_item(meta: &[u8]) -> Item { + let mut item = Item::new(ItemType::ProfileChunk); + item.set_payload(ContentType::Json, bytes::Bytes::copy_from_slice(meta)); + item + } + + 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); + + 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_split_plain_chunk() { + let item = make_chunk_item(b"{}"); + let (payload, raw) = split_item_payload(&item); + assert_eq!(payload.as_ref(), b"{}"); + assert!(raw.is_none()); + } + + #[test] + 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) = split_item_payload(&item); + assert_eq!(payload.as_ref(), meta.as_ref()); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + } + + #[test] + fn test_split_compound_empty_body() { + let meta = br#"{"content_type":"perfetto"}"#; + let item = make_compound_item(meta, b""); + + let (payload, raw) = split_item_payload(&item); + assert_eq!(payload.as_ref(), meta.as_ref()); + assert!(raw.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) = split_item_payload(&item); + assert_eq!(payload.as_ref(), body.as_ref()); + assert!(raw.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) = split_item_payload(&item); + assert_eq!(payload.as_ref(), b""); + assert_eq!(raw.as_deref(), Some(b"binary-data".as_ref())); + } +} diff --git a/relay-server/src/processing/profile_chunks/process.rs b/relay-server/src/processing/profile_chunks/process.rs index 293f77aa707..a99896b1074 100644 --- a/relay-server/src/processing/profile_chunks/process.rs +++ b/relay-server/src/processing/profile_chunks/process.rs @@ -1,10 +1,13 @@ +use std::net::IpAddr; + +use relay_dynamic_config::Feature; use relay_profiling::ProfileType; 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; @@ -22,6 +25,18 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte profile_chunks.retain( |pc| &mut pc.profile_chunks, |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())?; // Validate the item inferred profile type with the one from the payload, @@ -65,13 +80,284 @@ pub fn process(profile_chunks: &mut Managed, ctx: Conte } *item = { - let mut item = Item::new(ItemType::ProfileChunk); - item.set_platform(pc.platform().to_owned()); - item.set_payload(ContentType::Json, expanded); - item + let mut new_item = Item::new(ItemType::ProfileChunk); + new_item.set_platform(pc.platform().to_owned()); + new_item.set_payload(ContentType::Json, expanded); + new_item }; Ok(()) }, ); } + +/// 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()); + }; + + #[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()), + } + + if ctx.should_filter(Feature::ContinuousProfilingPerfetto) { + 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()); + } + + if item + .profile_type() + .is_some_and(|pt| pt != expanded.profile_type()) + { + return Err(relay_profiling::ProfileError::InvalidProfileType.into()); + } + + if item.profile_type().is_none() { + relay_statsd::metric!( + counter(RelayCounters::ProfileChunksWithoutPlatform) += 1, + sdk = sdk + ); + match expanded.profile_type() { + ProfileType::Ui => records.modify_by(DataCategory::ProfileChunkUi, 1), + ProfileType::Backend => records.modify_by(DataCategory::ProfileChunk, 1), + } + } + + 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 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(platform); + new_item.set_payload(ContentType::Json, compound.freeze()); + new_item.set_meta_length(expanded_payload.len() as u32); + new_item + }; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use similar_asserts::assert_eq; + + 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); + } +} diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index 4fba25dbdd3..2a7bda48a58 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -147,6 +147,14 @@ impl Counted for StoreSpanV2 { } } +/// Content type of a raw binary profile blob sent alongside the expanded JSON payload. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RawProfileContentType { + /// Perfetto binary trace format. + Perfetto, +} + /// Publishes a singular profile chunk to Kafka. #[derive(Debug)] pub struct StoreProfileChunk { @@ -158,6 +166,13 @@ pub struct StoreProfileChunk { /// /// Quantities are different for backend and ui profile chunks. pub quantities: Quantities, + /// 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`. + pub raw_profile_content_type: Option, } impl Counted for StoreProfileChunk { @@ -848,6 +863,8 @@ impl StoreService { scoping.project_id.to_string(), )]), payload: message.payload, + raw_profile: message.raw_profile, + raw_profile_content_type: message.raw_profile_content_type, }; self.produce(KafkaTopic::Profiles, KafkaMessage::ProfileChunk(message)) @@ -1688,6 +1705,10 @@ struct ProfileChunkKafkaMessage { #[serde(skip)] headers: BTreeMap, 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/tests/integration/test_profile_chunks_perfetto.py b/tests/integration/test_profile_chunks_perfetto.py new file mode 100644 index 00000000000..6bc25384e90 --- /dev/null +++ b/tests/integration/test_profile_chunks_perfetto.py @@ -0,0 +1,104 @@ +import json +from pathlib import Path + +from sentry_sdk.envelope import Envelope + +RELAY_ROOT = Path(__file__).parent.parent.parent + +TEST_CONFIG = { + "outcomes": { + "emit_outcomes": True, + }, +} + +PERFETTO_ENVELOPE_FIXTURE = ( + RELAY_ROOT + / "relay-profiling/tests/fixtures/android/perfetto/profile_chunk.envelope" +) + + +def test_perfetto_profile_chunk_end_to_end( + mini_sentry, + relay_with_processing, + outcomes_consumer, + profiles_consumer, +): + """ + Ingests a real Perfetto `profile_chunk` envelope end-to-end and verifies + that Relay decodes the binary Perfetto trace into a Sample v2 profile + that is forwarded to the profiles consumer. + + The fixture envelope was captured from the Android SDK and contains a + single `profile_chunk` item whose payload is `[JSON metadata][perfetto + binary]` concatenated, delimited by the `meta_length` item header. + """ + profiles_consumer = profiles_consumer() + outcomes_consumer = outcomes_consumer() + + project_id = 42 + project_config = mini_sentry.add_full_project_config(project_id)["config"] + project_config.setdefault("features", []).extend( + [ + "organizations:continuous-profiling", + "organizations:continuous-profiling-perfetto", + ] + ) + + upstream = relay_with_processing(TEST_CONFIG) + + with open(PERFETTO_ENVELOPE_FIXTURE, "rb") as f: + envelope = Envelope.deserialize_from(f) + + upstream.send_envelope(project_id, envelope) + + # Successful ingestion emits no outcomes from Relay (profile_duration is + # emitted later in Sentry itself). + outcomes_consumer.assert_empty() + + profile, headers = profiles_consumer.get_profile() + assert headers == [("project_id", b"42")] + + payload = json.loads(profile["payload"]) + + assert { + k: payload[k] for k in ("version", "platform", "chunk_id", "profiler_id") + } == { + "version": "2", + "platform": "android", + "chunk_id": "c3b09c0608844f558eaf6e65df6b9cdf", + "profiler_id": "814b081c638b4ad982ae351547bfe499", + } + assert payload["client_sdk"]["name"] == "sentry.java.android" + + profile_data = payload["profile"] + assert len(profile_data["samples"]) == 398 + assert len(profile_data["stacks"]) == 52 + assert len(profile_data["frames"]) == 358 + assert len(payload["debug_meta"]["images"]) == 17 + + samples = profile_data["samples"] + timestamps = [s["timestamp"] for s in samples] + assert timestamps == sorted(timestamps) + assert abs((timestamps[-1] - timestamps[0]) - 1.96) < 0.01 + + num_stacks = len(profile_data["stacks"]) + num_frames = len(profile_data["frames"]) + for sample in samples: + assert 0 <= sample["stack_id"] < num_stacks + assert isinstance(sample["thread_id"], str) + + for stack in profile_data["stacks"]: + for frame_id in stack: + assert 0 <= frame_id < num_frames + + frames = profile_data["frames"] + assert sum(1 for f in frames if f.get("function")) >= 350 + assert any(f.get("function", "").startswith("io.sentry.") for f in frames) + + sample_thread_ids = {s["thread_id"] for s in samples} + assert len(sample_thread_ids) == 6 + thread_metadata = profile_data["thread_metadata"] + assert any(meta.get("name") == "main" for meta in thread_metadata.values()) + for tid, meta in thread_metadata.items(): + assert isinstance(tid, str) + assert "name" in meta and isinstance(meta["name"], str)