mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
250 lines
7.3 KiB
TypeScript
250 lines
7.3 KiB
TypeScript
import {lastOfArray} from 'sentry/utils';
|
|
import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
|
|
import type {Frame} from 'sentry/utils/profiling/frame';
|
|
import {formatTo} from 'sentry/utils/profiling/units/units';
|
|
|
|
import {Profile} from './profile';
|
|
import type {createFrameIndex} from './utils';
|
|
|
|
export class EventedProfile extends Profile {
|
|
calltree: CallTreeNode[] = [this.callTree];
|
|
stack: Frame[] = [];
|
|
|
|
lastValue = 0;
|
|
samplingIntervalApproximation = 0;
|
|
|
|
static FromProfile(
|
|
eventedProfile: Profiling.EventedProfile,
|
|
frameIndex: ReturnType<typeof createFrameIndex>,
|
|
options: {
|
|
type: 'flamechart' | 'flamegraph';
|
|
frameFilter?: (frame: Frame) => boolean;
|
|
}
|
|
): EventedProfile {
|
|
const profile = new EventedProfile({
|
|
duration: eventedProfile.endValue - eventedProfile.startValue,
|
|
startedAt: eventedProfile.startValue,
|
|
endedAt: eventedProfile.endValue,
|
|
name: eventedProfile.name,
|
|
unit: eventedProfile.unit,
|
|
threadId: eventedProfile.threadID,
|
|
type: options.type,
|
|
});
|
|
|
|
// If frames are offset, we need to set lastValue to profile start, so that delta between
|
|
// samples is correctly offset by the start value.
|
|
profile.lastValue = Math.max(0, eventedProfile.startValue);
|
|
profile.samplingIntervalApproximation = formatTo(
|
|
10,
|
|
'milliseconds',
|
|
eventedProfile.unit
|
|
);
|
|
|
|
for (const event of eventedProfile.events) {
|
|
const frame = frameIndex[event.frame];
|
|
|
|
if (!frame) {
|
|
throw new Error(`Cannot retrieve event: ${event.frame} from frame index`);
|
|
}
|
|
|
|
if (options.frameFilter && !options.frameFilter(frame)) {
|
|
continue;
|
|
}
|
|
|
|
switch (event.type) {
|
|
// Open a new frame
|
|
case 'O': {
|
|
profile.enterFrame(frame, event.at);
|
|
break;
|
|
}
|
|
// Close a frame
|
|
case 'C': {
|
|
profile.leaveFrame(frame, event.at);
|
|
break;
|
|
}
|
|
default: {
|
|
throw new TypeError(`Unknown event type ${event.type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const built = profile.build();
|
|
|
|
// The way the samples are constructed assumes that the trees are always appended to the
|
|
// calltree. This is not the case for flamegraphs where nodes are mutated in place.
|
|
// Because that assumption is invalidated with flamegraphs, we need to filter
|
|
// out duplicate samples and their weights.
|
|
if (options.type === 'flamegraph') {
|
|
const visited = new Set();
|
|
const samples: CallTreeNode[] = [];
|
|
const weights: number[] = [];
|
|
|
|
for (let i = 0; i < built.samples.length; i++) {
|
|
const sample = built.samples[i];
|
|
|
|
if (visited.has(sample)) {
|
|
continue;
|
|
}
|
|
|
|
visited.add(sample);
|
|
|
|
samples.push(sample);
|
|
weights.push(sample.totalWeight);
|
|
}
|
|
|
|
built.samples = samples;
|
|
built.weights = weights;
|
|
}
|
|
|
|
return built;
|
|
}
|
|
|
|
addWeightToFrames(weight: number): void {
|
|
const weightDelta = weight - this.lastValue;
|
|
|
|
for (const frame of this.stack) {
|
|
frame.totalWeight += weightDelta;
|
|
}
|
|
|
|
const top = lastOfArray(this.stack);
|
|
if (top) {
|
|
top.selfWeight += weight;
|
|
}
|
|
}
|
|
|
|
addWeightsToNodes(value: number) {
|
|
const delta = value - this.lastValue;
|
|
|
|
for (const node of this.calltree) {
|
|
node.totalWeight += delta;
|
|
}
|
|
const stackTop = lastOfArray(this.calltree);
|
|
|
|
if (stackTop) {
|
|
stackTop.selfWeight += delta;
|
|
}
|
|
}
|
|
|
|
enterFrame(frame: Frame, at: number): void {
|
|
this.addWeightToFrames(at);
|
|
this.addWeightsToNodes(at);
|
|
|
|
const lastTop = lastOfArray(this.calltree);
|
|
|
|
if (lastTop) {
|
|
const sampleDelta = at - this.lastValue;
|
|
|
|
if (sampleDelta < 0) {
|
|
throw new Error(
|
|
'Sample delta cannot be negative, samples may be corrupt or out of order'
|
|
);
|
|
}
|
|
|
|
// If the sample timestamp is not the same as the same as of previous frame,
|
|
// we can deduce that this is a new sample and need to push it on the stack
|
|
if (sampleDelta > 0) {
|
|
this.samples.push(lastTop);
|
|
this.weights.push(sampleDelta);
|
|
}
|
|
|
|
// If we are in flamegraph mode, we will look for any children of the current stack top
|
|
// that may contain the frame we are entering. If we find one, we will use that as the
|
|
// new stack top (this essentially makes it a graph). This does not apply flamecharts,
|
|
// where chronological order matters, in that case we can only look at the last child of the
|
|
// current stack top and use that as the new stack top if the frames match, else we create a new child
|
|
let node: CallTreeNode;
|
|
|
|
if (this.type === 'flamegraph') {
|
|
const last = lastTop.children.find(c => c.frame === frame);
|
|
if (last) {
|
|
node = last;
|
|
} else {
|
|
node = new CallTreeNode(frame, lastTop);
|
|
lastTop.children.push(node);
|
|
}
|
|
} else {
|
|
const last = lastOfArray(lastTop.children);
|
|
if (last && !last.isLocked() && last.frame === frame) {
|
|
node = last;
|
|
} else {
|
|
node = new CallTreeNode(frame, lastTop);
|
|
lastTop.children.push(node);
|
|
}
|
|
}
|
|
|
|
// 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 with their respective indexes
|
|
// We check the stack in a top-down order to find the first recursive frame.
|
|
let start = this.calltree.length - 1;
|
|
while (start >= 0) {
|
|
if (this.calltree[start].frame === node.frame) {
|
|
// The recursion edge is bidirectional
|
|
this.calltree[start].recursive = node;
|
|
node.recursive = this.calltree[start];
|
|
break;
|
|
}
|
|
start--;
|
|
}
|
|
|
|
this.calltree.push(node);
|
|
}
|
|
|
|
this.stack.push(frame);
|
|
this.lastValue = at;
|
|
}
|
|
|
|
leaveFrame(_event: Frame, at: number): void {
|
|
this.addWeightToFrames(at);
|
|
this.addWeightsToNodes(at);
|
|
this.trackSampleStats(at);
|
|
|
|
const leavingStackTop = this.calltree.pop();
|
|
|
|
if (leavingStackTop === undefined) {
|
|
throw new Error('Unbalanced stack');
|
|
}
|
|
|
|
// 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.
|
|
leavingStackTop.lock();
|
|
const sampleDelta = at - this.lastValue;
|
|
|
|
leavingStackTop.count += Math.ceil(
|
|
leavingStackTop.totalWeight / this.samplingIntervalApproximation
|
|
);
|
|
|
|
if (sampleDelta > 0) {
|
|
this.samples.push(leavingStackTop);
|
|
this.weights.push(sampleDelta);
|
|
// Keep track of the minFrameDuration
|
|
this.minFrameDuration = Math.min(sampleDelta, this.minFrameDuration);
|
|
}
|
|
|
|
this.stack.pop();
|
|
this.lastValue = at;
|
|
}
|
|
|
|
build(): EventedProfile {
|
|
if (this.calltree.length > 1) {
|
|
throw new Error('Unbalanced append order stack');
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|