Files
2024-02-15 17:01:50 +00:00

299 lines
8.9 KiB
TypeScript

import moment from 'moment';
import {defined, lastOfArray} from 'sentry/utils';
import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
import type {Frame} from './../frame';
import {Profile} from './profile';
import type {createSentrySampleProfileFrameIndex} from './utils';
type WeightedSample = Profiling.SentrySampledProfile['profile']['samples'][0] & {
weight: number;
};
function sortSentrySampledProfileSamples(
samples: Readonly<WeightedSample[]>,
stacks: Profiling.SentrySampledProfile['profile']['stacks'],
frames: Profiling.SentrySampledProfile['profile']['frames'],
frameFilter?: (i: number) => boolean
) {
const frameIds = [...Array(frames.length).keys()].sort((a, b) => {
const frameA = frames[a];
const frameB = frames[b];
if (defined(frameA.function) && defined(frameB.function)) {
// sort alphabetically first
const ret = frameA.function.localeCompare(frameB.function);
if (ret !== 0) {
return ret;
}
// break ties using the line number
if (defined(frameA.lineno) && defined(frameB.lineno)) {
return frameA.lineno - frameB.lineno;
}
if (defined(frameA.lineno)) {
return -1;
}
if (defined(frameB.lineno)) {
return 1;
}
} else if (defined(frameA.function)) {
// if only frameA is defined, it goes first
return -1;
} else if (defined(frameB.function)) {
// if only frameB is defined, it goes first
return 1;
}
// if neither functions are defined, they're treated as equal
return 0;
});
const framesMapping = frameIds.reduce((acc, frameId, idx) => {
acc[frameId] = idx;
return acc;
}, {});
return [...samples].sort((a, b) => {
// same stack id, these are the same
if (a.stack_id === b.stack_id) {
return 0;
}
const stackA = frameFilter
? stacks[a.stack_id].filter(frameFilter)
: stacks[a.stack_id];
const stackB = frameFilter
? stacks[b.stack_id].filter(frameFilter)
: stacks[b.stack_id];
const minDepth = Math.min(stackA.length, stackB.length);
for (let i = 0; i < minDepth; i++) {
// we iterate from the end of each stack because that's where the main function is
const frameIdA = stackA[stackA.length - i - 1];
const frameIdB = stackB[stackB.length - i - 1];
// same frame id, so check the next frame in the stack
if (frameIdA === frameIdB) {
continue;
}
const frameIdxA = framesMapping[frameIdA];
const frameIdxB = framesMapping[frameIdB];
// same frame idx, so check the next frame in the stack
if (frameIdxA === frameIdxB) {
continue;
}
return frameIdxA - frameIdxB;
}
// if all frames up to the depth of the shorter stack matches,
// then the deeper stack goes first
return stackB.length - stackA.length;
});
}
export class SentrySampledProfile extends Profile {
static FromProfile(
sampledProfile: Profiling.SentrySampledProfile,
frameIndex: ReturnType<typeof createSentrySampleProfileFrameIndex>,
options: {
type: 'flamechart' | 'flamegraph';
frameFilter?: (frame: Frame) => boolean;
}
): Profile {
const weightedSamples: WeightedSample[] = sampledProfile.profile.samples.map(
(sample, i) => {
// falling back to the current sample timestamp has the effect
// of giving the last sample a weight of 0
const nextSample = sampledProfile.profile.samples[i + 1] ?? sample;
return {
...sample,
weight: nextSample.elapsed_since_start_ns - sample.elapsed_since_start_ns,
};
}
);
function resolveFrame(index): Frame {
const resolvedFrame = frameIndex[index];
if (!resolvedFrame) {
throw new Error(`Could not resolve frame ${index} in frame index`);
}
return resolvedFrame;
}
const {frames, stacks} = sampledProfile.profile;
const samples =
options.type === 'flamegraph'
? sortSentrySampledProfileSamples(
weightedSamples,
stacks,
frames,
options.frameFilter ? i => options.frameFilter!(resolveFrame(i)) : undefined
)
: weightedSamples;
const startedAt = samples[0].elapsed_since_start_ns;
const endedAt = samples[samples.length - 1].elapsed_since_start_ns;
if (Number.isNaN(startedAt) || Number.isNaN(endedAt)) {
throw TypeError('startedAt or endedAt is NaN');
}
const {threadId, threadName} = getThreadData(sampledProfile);
const profile = new SentrySampledProfile({
// .unix() only has second resolution
timestamp: moment(sampledProfile.timestamp).valueOf() / 1000,
duration: endedAt - startedAt,
startedAt,
endedAt,
unit: 'nanoseconds',
name: threadName,
threadId,
type: options.type,
});
for (let i = 0; i < samples.length; i++) {
const sample = samples[i];
let stack = stacks[sample.stack_id].map(resolveFrame);
if (options.frameFilter) {
stack = stack.filter(frame => options.frameFilter!(frame));
}
profile.appendSampleWithWeight(stack, sample.weight);
}
return profile.build();
}
appendSampleWithWeight(stack: Frame[], weight: number): void {
// Keep track of discarded samples and ones that may have negative weights
this.trackSampleStats(weight);
// Ignore samples with 0 weight
if (weight === 0) {
return;
}
let node = this.callTree;
const framesInStack: CallTreeNode[] = [];
// frames are ordered outermost -> innermost so we have to iterate backward
for (let i = stack.length - 1; i >= 0; i--) {
const frame = stack[i];
const last = lastOfArray(node.children);
// Find common frame between two stacks
if (last && !last.isLocked() && last.frame === frame) {
node = last;
} else {
const parent = node;
node = new CallTreeNode(frame, node);
parent.children.push(node);
}
node.totalWeight += weight;
// TODO: This is On^2, because we iterate over all frames in the stack to check if our
// frame is a recursive frame. We could do this in O(1) by keeping a map of frames in the stack
// We check the stack in a top-down order to find the first recursive frame.
let start = framesInStack.length - 1;
while (start >= 0) {
if (framesInStack[start].frame === node.frame) {
// The recursion edge is bidirectional
framesInStack[start].recursive = node;
node.recursive = framesInStack[start];
break;
}
start--;
}
framesInStack.push(node);
}
node.selfWeight += weight;
this.minFrameDuration = Math.min(weight, this.minFrameDuration);
// Lock the stack node, so we make sure we dont mutate it in the future.
// The samples should be ordered by timestamp when processed so we should never
// iterate over them again in the future.
for (const child of node.children) {
child.lock();
}
node.frame.selfWeight += weight;
for (const stackNode of framesInStack) {
stackNode.frame.totalWeight += weight;
stackNode.count++;
}
// If node is the same as the previous sample, add the weight to the previous sample
if (node === lastOfArray(this.samples)) {
this.weights[this.weights.length - 1] += weight;
} else {
this.samples.push(node);
this.weights.push(weight);
}
}
build(): Profile {
this.duration = Math.max(
this.duration,
this.weights.reduce((a, b) => a + b, 0)
);
// We had no frames with duration > 0, so set min duration to timeline duration
// which effectively disables any zooming on the flamegraphs
if (
this.minFrameDuration === Number.POSITIVE_INFINITY ||
this.minFrameDuration === 0
) {
this.minFrameDuration = this.duration;
}
return this;
}
}
const COCOA_MAIN_THREAD = 'com.apple.main-thread';
function getThreadData(profile: Profiling.SentrySampledProfile): {
threadId: number;
threadName: string;
} {
const {samples, queue_metadata = {}, thread_metadata = {}} = profile.profile;
const sample = samples[0];
const threadId = parseInt(sample.thread_id, 10);
const threadName = thread_metadata?.[threadId]?.name;
if (threadName) {
return {threadId, threadName};
}
// cocoa has a queue address that we fall back to to try to get a thread name
// is this the only platform string to check for?
if (profile.platform === 'cocoa') {
// only the active thread should get the main thread name
if (threadId === profile.transaction.active_thread_id) {
return {threadId, threadName: COCOA_MAIN_THREAD};
}
const queueName =
sample.queue_address && queue_metadata?.[sample.queue_address]?.label;
// if a queue has the main thread name, we discard it
if (queueName && queueName !== COCOA_MAIN_THREAD) {
return {threadId, threadName: queueName};
}
}
return {threadId, threadName: ''};
}