import {lastOfArray} from 'sentry/utils/array/lastOfArray'; 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, 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; } }