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

367 lines
11 KiB
TypeScript

import {Fragment, useMemo, useState} from 'react';
import styled from '@emotion/styled';
import {Button} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import {SectionHeading} from 'sentry/components/charts/styles';
import {StackTraceContent} from 'sentry/components/events/interfaces/crashContent/stackTrace';
import {Tooltip} from 'sentry/components/tooltip';
import {IconChevron, IconProfiling} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {EventTransaction, Frame, PlatformKey} from 'sentry/types';
import {EntryType} from 'sentry/types/event';
import {StackView} from 'sentry/types/stacktrace';
import {defined} from 'sentry/utils';
import {formatPercentage} from 'sentry/utils/formatters';
import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
import {Frame as ProfilingFrame} from 'sentry/utils/profiling/frame';
import type {Profile} from 'sentry/utils/profiling/profile/profile';
import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
import {formatTo} from 'sentry/utils/profiling/units/units';
import useOrganization from 'sentry/utils/useOrganization';
import useProjects from 'sentry/utils/useProjects';
import {useProfileGroup} from 'sentry/views/profiling/profileGroupProvider';
import type {SpanType} from './types';
const MAX_STACK_DEPTH = 8;
const MAX_TOP_NODES = 5;
const MIN_TOP_NODES = 3;
const TOP_NODE_MIN_COUNT = 3;
interface SpanProfileDetailsProps {
event: Readonly<EventTransaction>;
span: Readonly<SpanType>;
onNoProfileFound?: () => void;
}
export function SpanProfileDetails({
event,
span,
onNoProfileFound,
}: SpanProfileDetailsProps) {
const organization = useOrganization();
const {projects} = useProjects();
const project = projects.find(p => p.id === event.projectID);
const profileGroup = useProfileGroup();
const processedEvent = useMemo(() => {
const entries: EventTransaction['entries'] = [...(event.entries || [])];
if (profileGroup.images) {
entries.push({
data: {images: profileGroup.images},
type: EntryType.DEBUGMETA,
});
}
return {...event, entries};
}, [event, profileGroup]);
// TODO: Pick another thread if it's more relevant.
const threadId = useMemo(
() => profileGroup.profiles[profileGroup.activeProfileIndex]?.threadId,
[profileGroup]
);
const profile = useMemo(() => {
if (!defined(threadId)) {
return null;
}
return profileGroup.profiles.find(p => p.threadId === threadId) ?? null;
}, [profileGroup.profiles, threadId]);
const nodes: CallTreeNode[] = useMemo(() => {
if (profile === null) {
return [];
}
// The most recent profile formats should contain a timestamp indicating
// the beginning of the profile. This timestamp can be after the start
// timestamp on the transaction, so we need to account for the gap and
// make sure the relative start timestamps we compute for the span is
// relative to the start of the profile.
//
// If the profile does not contain a timestamp, we fall back to using the
// start timestamp on the transaction. This won't be as accurate but it's
// the next best thing.
const startTimestamp = profile.timestamp ?? event.startTimestamp;
const relativeStartTimestamp = formatTo(
span.start_timestamp - startTimestamp,
'second',
profile.unit
);
const relativeStopTimestamp = formatTo(
span.timestamp - startTimestamp,
'second',
profile.unit
);
return getTopNodes(profile, relativeStartTimestamp, relativeStopTimestamp).filter(
hasApplicationFrame
);
}, [profile, span, event]);
const [index, setIndex] = useState(0);
const totalWeight = useMemo(
() => nodes.reduce((count, node) => count + node.count, 0),
[nodes]
);
const maxNodes = useMemo(() => {
// find the number of nodes with the minimum number of samples
let hasMinCount = 0;
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].count >= TOP_NODE_MIN_COUNT) {
hasMinCount += 1;
} else {
break;
}
}
hasMinCount = Math.max(MIN_TOP_NODES, hasMinCount);
return Math.min(nodes.length, MAX_TOP_NODES, hasMinCount);
}, [nodes]);
const {frames, hasPrevious, hasNext} = useMemo(() => {
if (index >= maxNodes) {
return {frames: [], hasPrevious: false, hasNext: false};
}
return {
frames: extractFrames(nodes[index], event.platform || 'other'),
hasPrevious: index > 0,
hasNext: index + 1 < maxNodes,
};
}, [index, maxNodes, event, nodes]);
const spanTarget =
project &&
profileGroup &&
profileGroup.metadata.profileID &&
profile &&
generateProfileFlamechartRouteWithQuery({
orgSlug: organization.slug,
projectSlug: project.slug,
profileId: profileGroup.metadata.profileID,
query: {
tid: String(profile.threadId),
spanId: span.span_id,
},
});
if (!defined(profile) || !defined(spanTarget)) {
return null;
}
if (!frames.length) {
if (onNoProfileFound) {
onNoProfileFound();
}
return null;
}
return (
<Fragment>
<SpanDetails>
<SpanDetailsItem grow>
<SectionHeading>{t('Most Frequent Stacks in this Span')}</SectionHeading>
</SpanDetailsItem>
<SpanDetailsItem>
<SectionSubtext>
<Tooltip title={t('%s out of %s samples', nodes[index].count, totalWeight)}>
{tct('Showing stacks [index] of [total] ([percentage])', {
index: index + 1,
total: maxNodes,
percentage: formatPercentage(nodes[index].count / totalWeight),
})}
</Tooltip>
</SectionSubtext>
</SpanDetailsItem>
<SpanDetailsItem>
<ButtonBar merged>
<Button
icon={<IconChevron direction="left" />}
aria-label={t('Previous')}
size="xs"
disabled={!hasPrevious}
onClick={() => {
setIndex(prevIndex => prevIndex - 1);
}}
/>
<Button
icon={<IconChevron direction="right" />}
aria-label={t('Next')}
size="xs"
disabled={!hasNext}
onClick={() => {
setIndex(prevIndex => prevIndex + 1);
}}
/>
</ButtonBar>
</SpanDetailsItem>
<SpanDetailsItem>
<Button icon={<IconProfiling />} to={spanTarget} size="xs">
{t('View Profile')}
</Button>
</SpanDetailsItem>
</SpanDetails>
<StackTraceContent
event={processedEvent}
hasHierarchicalGrouping={false}
newestFirst
platform={event.platform || 'other'}
stacktrace={{
framesOmitted: null,
hasSystemFrames: false,
registers: null,
frames,
}}
stackView={StackView.APP}
inlined
maxDepth={MAX_STACK_DEPTH}
/>
</Fragment>
);
}
function getTopNodes(profile: Profile, startTimestamp, stopTimestamp): CallTreeNode[] {
let duration = profile.startedAt;
const callTree: CallTreeNode = new CallTreeNode(ProfilingFrame.Root, null);
for (let i = 0; i < profile.samples.length; i++) {
const sample = profile.samples[i];
// TODO: should this take self times into consideration?
const inRange = startTimestamp <= duration && duration < stopTimestamp;
duration += profile.weights[i];
if (sample.isRoot || !inRange) {
continue;
}
const stack: CallTreeNode[] = [sample];
let node: CallTreeNode | null = sample;
while (node && !node.isRoot) {
stack.push(node);
node = node.parent;
}
let tree = callTree;
// make sure to iterate the stack backwards here, the 0th index is the
// inner most frame, and the last index is the outer most frame
for (let j = stack.length - 1; j >= 0; j--) {
node = stack[j]!;
const frame = node.frame;
// find a child in the current tree with the same frame,
// merge the current node into it if it exists
let last = tree.children.find(n => n.frame === frame);
if (!defined(last)) {
last = new CallTreeNode(frame, tree);
tree.children.push(last);
}
// make sure to increment the count/weight so it can be sorted later
last.count += node.count;
last.selfWeight += node.selfWeight;
tree = last;
}
}
const nodes: CallTreeNode[] = [];
const trees = [callTree];
while (trees.length) {
const tree = trees.pop()!;
// walk to the leaf nodes, these correspond with the inner most frame
// on a stack
if (tree.children.length === 0) {
nodes.push(tree);
continue;
}
trees.push(...tree.children);
}
return nodes.sort(sortByCount);
}
// TODO: does this work for android? The counts on the evented format may not be what we expect
function sortByCount(a: CallTreeNode, b: CallTreeNode) {
if (a.count === b.count) {
return b.selfWeight - a.selfWeight;
}
return b.count - a.count;
}
function hasApplicationFrame(node: CallTreeNode | null) {
while (node && !node.isRoot) {
if (node.frame.is_application) {
return true;
}
node = node.parent;
}
return false;
}
function extractFrames(node: CallTreeNode | null, platform: PlatformKey): Frame[] {
const frames: Frame[] = [];
while (node && !node.isRoot) {
const frame = {
absPath: node.frame.path ?? null,
colNo: node.frame.column ?? null,
context: [],
filename: node.frame.file ?? null,
function: node.frame.name ?? null,
inApp: node.frame.is_application,
instructionAddr: node.frame.instructionAddr ?? null,
lineNo: node.frame.line ?? null,
module: node.frame.module ?? null,
package: node.frame.package ?? null,
platform,
rawFunction: null,
symbol: node.frame.symbol ?? null,
symbolAddr: node.frame.symbolAddr ?? null,
symbolicatorStatus: node.frame.symbolicatorStatus,
trust: null,
vars: null,
};
frames.push(frame);
node = node.parent;
}
// Profile stacks start from the inner most frame, while error stacks
// start from the outer most frame. Reverse the order here to match
// the convention on errors.
return frames.reverse();
}
const SpanDetails = styled('div')`
padding: ${space(2)};
display: flex;
align-items: center;
gap: ${space(1)};
`;
const SpanDetailsItem = styled('span')<{grow?: boolean}>`
${p => (p.grow ? 'flex: 1 2 auto' : 'flex: 0 1 auto')}
`;
const SectionSubtext = styled('span')`
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSizeMedium};
`;