diff --git a/static/app/types/profiling.d.ts b/static/app/types/profiling.d.ts index b678d7545d9868..3851dffa587c37 100644 --- a/static/app/types/profiling.d.ts +++ b/static/app/types/profiling.d.ts @@ -141,6 +141,12 @@ declare namespace Profiling { profile: ContinuousProfile; } + interface SentryAndroidContinuousProfileChunk extends Omit { + profiles: ReadonlyArray>; + androidClock: string; + measurements?: ContinuousMeasurements; + } + //////////////// interface RawProfileBase { endValue: number; diff --git a/static/app/utils/profiling/guards/profile.tsx b/static/app/utils/profiling/guards/profile.tsx index 15a430ef3bba44..e4a89f0b70ef51 100644 --- a/static/app/utils/profiling/guards/profile.tsx +++ b/static/app/utils/profiling/guards/profile.tsx @@ -45,8 +45,17 @@ export function isSentryContinuousProfile( export function isSentryContinuousProfileChunk( profile: any -): profile is Profiling.SentryContinousProfileChunk { - return 'chunk_id' in profile; +): profile is + | Profiling.SentryContinousProfileChunk + | Profiling.SentryAndroidContinuousProfileChunk { + // Temporary fix to check for profiler_id in the chunk + return 'chunk_id' in profile || 'profiler_id' in profile; +} + +export function isSentryAndroidContinuousProfileChunk( + profile: any +): profile is Profiling.SentryAndroidContinuousProfileChunk { + return 'androidClock' in profile; } export function isContinuousProfileReference( diff --git a/static/app/utils/profiling/profile/continuousProfile.spec.tsx b/static/app/utils/profiling/profile/continuousProfile.spec.tsx index b26a7b16d9e9a1..25419498a55648 100644 --- a/static/app/utils/profiling/profile/continuousProfile.spec.tsx +++ b/static/app/utils/profiling/profile/continuousProfile.spec.tsx @@ -1,73 +1,164 @@ import {ContinuousProfile} from 'sentry/utils/profiling/profile/continuousProfile'; +import { + eventedProfileToSampledProfile, + importAndroidContinuousProfileChunk, +} from 'sentry/utils/profiling/profile/importProfile'; import {createContinuousProfileFrameIndex} from 'sentry/utils/profiling/profile/utils'; -import {makeSentryContinuousProfile, makeTestingBoilerplate} from './testUtils'; +import { + makeSentryAndroidContinuousProfileChunk, + makeSentryContinuousProfile, + makeTestingBoilerplate, +} from './testUtils'; describe('ContinuousProfile', () => { - it('imports the base properties', () => { - const trace = makeSentryContinuousProfile({ - profile: { - samples: [ - {timestamp: Date.now() / 1e3, stack_id: 0, thread_id: '0'}, - // 10ms later - {timestamp: Date.now() / 1e3 + 0.01, stack_id: 1, thread_id: '0'}, - ], - frames: [ - {function: 'foo', in_app: true}, - {function: 'bar', in_app: true}, - ], - stacks: [ - [0, 1], - [0, 1], - ], - }, + describe('sampled profile', () => { + it('imports the base properties', () => { + const trace = makeSentryContinuousProfile({ + profile: { + samples: [ + {timestamp: Date.now() / 1e3, stack_id: 0, thread_id: '0'}, + // 10ms later + {timestamp: Date.now() / 1e3 + 0.01, stack_id: 1, thread_id: '0'}, + ], + frames: [ + {function: 'foo', in_app: true}, + {function: 'bar', in_app: true}, + ], + stacks: [ + [0, 1], + [0, 1], + ], + }, + }); + + const profile = ContinuousProfile.FromProfile( + trace.profile, + createContinuousProfileFrameIndex(trace.profile.frames, 'node'), + {minTimestamp: 0, type: 'flamechart'} + ); + + expect(Math.round(profile.duration)).toBe(10); + expect(profile.startedAt).toBe(1508208080 * 1e3); + expect(profile.endedAt).toBe(1508208080.01 * 1e3); }); - const profile = ContinuousProfile.FromProfile( - trace.profile, - createContinuousProfileFrameIndex(trace.profile.frames, 'node'), - {minTimestamp: 0, type: 'flamechart'} - ); + it('rebuilds the stack', () => { + const trace = makeSentryContinuousProfile({ + profile: { + samples: [ + {timestamp: Date.now() / 1e3, stack_id: 0, thread_id: '0'}, + // 10ms later + {timestamp: Date.now() / 1e3 + 0.01, stack_id: 1, thread_id: '0'}, + ], + frames: [ + {function: 'foo', in_app: true, lineno: 0}, + {function: 'bar', in_app: true, lineno: 1}, + ], + stacks: [ + [0, 1], + [0, 1], + ], + }, + }); + + const {open, close, timings} = makeTestingBoilerplate(); + + const profile = ContinuousProfile.FromProfile( + trace.profile, + createContinuousProfileFrameIndex(trace.profile.frames, 'node'), + {minTimestamp: 0, type: 'flamechart'} + ); + + profile.forEach(open, close); - expect(Math.round(profile.duration)).toBe(10); - expect(profile.startedAt).toBe(1508208080 * 1e3); - expect(profile.endedAt).toBe(1508208080.01 * 1e3); + expect(timings).toEqual([ + ['bar', 'open'], + ['foo', 'open'], + ['foo', 'close'], + ['bar', 'close'], + ]); + }); }); - it('rebuilds the stack', () => { - const trace = makeSentryContinuousProfile({ - profile: { - samples: [ - {timestamp: Date.now() / 1e3, stack_id: 0, thread_id: '0'}, - // 10ms later - {timestamp: Date.now() / 1e3 + 0.01, stack_id: 1, thread_id: '0'}, - ], - frames: [ - {function: 'foo', in_app: true, lineno: 0}, - {function: 'bar', in_app: true, lineno: 1}, + describe('android continuous profile chunk', () => { + it('imports the base properties', () => { + const trace = makeSentryAndroidContinuousProfileChunk({ + metadata: { + timestamp: '2021-01-01T00:00:00.000Z', + }, + shared: { + frames: [ + {name: 'foo', line: 0}, + {name: 'bar', line: 1}, + ], + }, + profiles: [ + { + endValue: 100, + events: [ + {type: 'O', frame: 0, at: 0}, + {type: 'O', frame: 1, at: 1}, + {type: 'C', frame: 1, at: 2}, + {type: 'C', frame: 0, at: 3}, + ], + name: 'main', + startValue: 0, + threadID: 1, + type: 'evented', + unit: 'nanoseconds', + }, ], - stacks: [ - [0, 1], - [0, 1], - ], - }, - }); + }); - const {open, close, timings} = makeTestingBoilerplate(); + const profile = importAndroidContinuousProfileChunk(trace, '123', { + span: undefined, + type: 'flamechart', + frameFilter: undefined, + profileIds: undefined, + }); - const profile = ContinuousProfile.FromProfile( - trace.profile, - createContinuousProfileFrameIndex(trace.profile.frames, 'node'), - {minTimestamp: 0, type: 'flamechart'} - ); + expect(profile.profiles[0]!.duration).toBe(0); + expect(profile.profiles[0]!.startedAt).toBe(0); + expect(profile.profiles[0]!.endedAt).toBe(0); + }); - profile.forEach(open, close); + it('assigns stacks', () => { + const trace = makeSentryAndroidContinuousProfileChunk({ + metadata: { + timestamp: '2021-01-01T00:00:00.000Z', + }, + shared: { + frames: [ + {name: 'foo', line: 0}, + {name: 'bar', line: 1}, + ], + }, + profiles: [ + { + endValue: 100, + events: [ + {type: 'O', frame: 0, at: 0}, + {type: 'O', frame: 1, at: 1}, + {type: 'C', frame: 1, at: 2}, + {type: 'C', frame: 0, at: 3}, + ], + name: 'main', + startValue: 0, + threadID: 1, + type: 'evented', + unit: 'nanoseconds', + }, + ], + }); - expect(timings).toEqual([ - ['bar', 'open'], - ['foo', 'open'], - ['foo', 'close'], - ['bar', 'close'], - ]); + const profile = eventedProfileToSampledProfile(0, trace.profiles); + expect(profile.stacks).toEqual([[0], [1, 0], [0], []]); + expect(profile.samples).toHaveLength(4); + expect(profile.samples[0]!.stack_id).toBe(0); + expect(profile.samples[1]!.stack_id).toBe(1); + // We do not deduplicate stacks, so the third sample has a different stack_id + expect(profile.samples[2]!.stack_id).toBe(2); + }); }); }); diff --git a/static/app/utils/profiling/profile/importProfile.tsx b/static/app/utils/profiling/profile/importProfile.tsx index 7982568678cc96..05490480a6e019 100644 --- a/static/app/utils/profiling/profile/importProfile.tsx +++ b/static/app/utils/profiling/profile/importProfile.tsx @@ -10,6 +10,7 @@ import { isJSProfile, isSampledProfile, isSchema, + isSentryAndroidContinuousProfileChunk, isSentryContinuousProfile, isSentryContinuousProfileChunk, isSentrySampledProfile, @@ -22,6 +23,7 @@ import type {Profile} from './profile'; import {SampledProfile} from './sampledProfile'; import {SentrySampledProfile} from './sentrySampledProfile'; import { + createAndroidContinuousProfileFrameIndex, createContinuousProfileFrameIndex, createFrameIndex, createSentrySampleProfileFrameIndex, @@ -67,6 +69,15 @@ export function importProfile( try { if (isSentryContinuousProfileChunk(input)) { scope.setTag('profile.type', 'sentry-continuous'); + if (isSentryAndroidContinuousProfileChunk(input)) { + return importAndroidContinuousProfileChunk(input, traceID, { + span, + type, + frameFilter, + activeThreadId, + continuous: true, + }); + } return importSentryContinuousProfileChunk(input, traceID, { span, type, @@ -241,6 +252,195 @@ export function importSchema( }; } +export function eventedProfileToSampledProfile( + profileTimestamp: number, + input: ReadonlyArray> +): Pick< + Readonly['profile'], + 'samples' | 'stacks' | 'thread_metadata' +> { + const stacks: Profiling.SentrySampledProfile['profile']['stacks'] = []; + const samples: Profiling.SentrySampledProfileChunkSample[] = []; + const thread_metadata: Profiling.SentrySampledProfile['profile']['thread_metadata'] = + {}; + + for (const profile of input) { + let stackId = 0; + const stack: number[] = []; + + thread_metadata[profile.threadID] = { + name: profile.name, + }; + + stack.push(profile.events[0]!.frame); + samples.push({ + stack_id: stackId, + thread_id: String(profile.threadID), + timestamp: profileTimestamp + profile.events[0]!.at * 1e-9, + }); + stacks[stackId] = stack.slice().reverse(); + stackId++; + + for (let i = 1; i < profile.events.length; i++) { + const current = profile.events[i]!; + const previous = profile.events[i - 1] ?? current; + + if (current.type === 'O') { + stack.push(current.frame); + } else if (current.type === 'C') { + const poppedFrame = stack.pop(); + + if (poppedFrame === undefined) { + throw new Error('Stack underflow'); + } + if (poppedFrame !== current.frame) { + throw new Error('Stack mismatch'); + } + } else { + throw new TypeError('Unknown event type, expected O or C, got ' + current.type); + } + + if (current.at !== previous.at) { + samples.push({ + stack_id: stackId, + thread_id: String(profile.threadID), + timestamp: profileTimestamp + current.at * 1e-9, + }); + + stacks[stackId] = stack.slice().reverse(); + stackId++; + } + } + + if (stack.length > 0) { + samples.push({ + stack_id: stackId, + thread_id: String(profile.threadID), + timestamp: + profileTimestamp + profile.events[profile.events.length - 1]!.at * 1e-9, + }); + stacks[stackId] = stack.slice().reverse(); + stackId++; + } + } + + return { + samples, + stacks, + thread_metadata, + }; +} + +export function importAndroidContinuousProfileChunk( + input: Profiling.SentryAndroidContinuousProfileChunk, + traceID: string, + options: ImportOptions +): ProfileGroup { + const frameIndex = createAndroidContinuousProfileFrameIndex( + input.shared.frames, + input.metadata.platform + ); + + const frames: Profiling.SentrySampledProfileFrame[] = []; + for (const frame of input.shared.frames) { + frames.push({ + in_app: frame.is_application ?? false, + filename: frame.file, + abs_path: frame.path, + module: frame.module, + package: frame.package, + column: frame.columnNumber ?? frame?.col ?? frame?.column, + symbol: frame.symbol, + lineno: frame.lineNumber, + colno: frame.columnNumber, + function: frame.name, + }); + } + + const samplesByThread: Record< + string, + Profiling.SentryContinousProfileChunk['profile']['samples'] + > = {}; + + if (!input.metadata.timestamp) { + throw new TypeError( + 'No timestamp found in metadata, typestamp is required to render continuous profiles' + ); + } + const profileTimestampInSeconds = new Date(input.metadata.timestamp).getTime() * 1e-3; + + const convertedProfile = eventedProfileToSampledProfile( + profileTimestampInSeconds, + input.profiles + ); + + const minTimestamp = minTimestampInChunk( + {...convertedProfile, frames}, + input.measurements ?? {} + ); + + for (const sample of convertedProfile.samples) { + if (!samplesByThread[sample.thread_id]) { + samplesByThread[sample.thread_id] = []; + } + samplesByThread[sample.thread_id]!.push(sample); + } + + const profiles: ContinuousProfile[] = []; + let activeProfileIndex = input.activeProfileIndex ?? 0; + + if (options.activeThreadId === undefined) { + options.activeThreadId = String(input.profiles[activeProfileIndex]?.threadID); + } + + for (const key in samplesByThread) { + samplesByThread[key]!.sort((a, b) => a.timestamp - b.timestamp); + + const profile: Profiling.ContinuousProfile = { + ...convertedProfile, + frames, + samples: samplesByThread[key]!, + }; + + if (options.activeThreadId && key === options.activeThreadId) { + activeProfileIndex = profiles.length; + } + + profiles.push( + wrapWithSpan( + options.span, + () => + ContinuousProfile.FromProfile(profile, frameIndex, { + minTimestamp, + type: options.type, + frameFilter: options.frameFilter, + }), + { + op: 'profile.import', + description: 'continuous', + } + ) + ); + } + + return { + traceID, + name: '', + type: 'continuous', + transactionID: null, + activeProfileIndex, + profiles, + measurements: measurementsFromContinuousMeasurements( + input.measurements ?? {}, + minTimestamp + ), + metadata: { + platform: input.metadata.platform, + projectID: input.metadata.projectID, + }, + }; +} + export function importSentryContinuousProfileChunk( input: Readonly, traceID: string, @@ -265,14 +465,11 @@ export function importSentryContinuousProfileChunk( samplesByThread[sample.thread_id]!.push(sample); } - for (const key in samplesByThread) { - samplesByThread[key]!.sort((a, b) => a.timestamp - b.timestamp); - } - const profiles: ContinuousProfile[] = []; let activeProfileIndex = 0; for (const key in samplesByThread) { + samplesByThread[key]!.sort((a, b) => a.timestamp - b.timestamp); const profile: Profiling.ContinuousProfile = { ...input, ...input.profile, diff --git a/static/app/utils/profiling/profile/testUtils.tsx b/static/app/utils/profiling/profile/testUtils.tsx index 3a952bd2472fe5..cf4d74fc2f83d8 100644 --- a/static/app/utils/profiling/profile/testUtils.tsx +++ b/static/app/utils/profiling/profile/testUtils.tsx @@ -70,6 +70,23 @@ export function makeSentryContinuousProfile( ) as Profiling.SentryContinousProfileChunk; } +export function makeSentryAndroidContinuousProfileChunk( + profile?: DeepPartial +): Profiling.SentryAndroidContinuousProfileChunk { + return merge( + { + transactionName: 'foo', + metrics: [], + profiles: [], + metadata: {}, + profileID: '1', + projectID: 1, + shared: {}, + }, + profile + ) as Profiling.SentryAndroidContinuousProfileChunk; +} + export const makeSentrySampledProfile = ( profile?: DeepPartial ) => { diff --git a/static/app/utils/profiling/profile/utils.tsx b/static/app/utils/profiling/profile/utils.tsx index ea126d01c6422d..48832223c81d34 100644 --- a/static/app/utils/profiling/profile/utils.tsx +++ b/static/app/utils/profiling/profile/utils.tsx @@ -54,6 +54,51 @@ export function createContinuousProfileFrameIndex( return index; } +export function createAndroidContinuousProfileFrameIndex( + frames: Profiling.SentryAndroidContinuousProfileChunk['shared']['frames'], + platform: 'mobile' | 'node' | 'javascript' | string +): FrameIndex { + const index: FrameIndex = {}; + const insertionCache: Record = {}; + let idx = -1; + + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]!; + const frameKey = `${frame.file ?? ''}:${frame.name ?? 'unknown'}:${String( + frame.lineNumber + )}:${frame.instructionAddr ?? ''}`; + + const existing = insertionCache[frameKey]; + if (existing) { + index[++idx] = existing; + continue; + } + + const f = new Frame( + { + key: i, + is_application: frame.is_application, + file: frame.file, + path: frame.path, + module: frame.module, + package: frame.package, + name: frame.name ?? 'unknown', + line: frame.lineNumber, + column: frame.colno ?? frame?.col ?? frame?.column, + instructionAddr: frame.instructionAddr, + symbol: frame.symbol, + symbolAddr: frame.symbolAddr, + symbolicatorStatus: frame.symbolicatorStatus, + }, + platform + ); + index[++idx] = f; + insertionCache[frameKey] = f; + } + + return index; +} + export function createSentrySampleProfileFrameIndex( frames: Profiling.SentrySampledProfile['profile']['frames'], platform: 'mobile' | 'node' | 'javascript' | string diff --git a/static/app/views/profiling/profilesProvider.tsx b/static/app/views/profiling/profilesProvider.tsx index 7621032e42ab65..5e40370e2a5bc7 100644 --- a/static/app/views/profiling/profilesProvider.tsx +++ b/static/app/views/profiling/profilesProvider.tsx @@ -42,7 +42,12 @@ function fetchContinuousProfileFlamegraph( }, includeAllArgs: true, }) - .then(([data]) => data.chunk); + .then(([data]) => { + // Temporary fix to ensure the profiler_id is set for continuous profiles + const profile = data.chunk; + profile.profiler_id = query.profiler_id; + return profile; + }); } type ProfileProviderValue = RequestState;