spanProfileDetails.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import {useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button, LinkButton} from 'sentry/components/button';
  4. import ButtonBar from 'sentry/components/buttonBar';
  5. import {SectionHeading} from 'sentry/components/charts/styles';
  6. import {StackTraceContent} from 'sentry/components/events/interfaces/crashContent/stackTrace';
  7. import {StackTraceContentPanel} from 'sentry/components/events/interfaces/crashContent/stackTrace/content';
  8. import QuestionTooltip from 'sentry/components/questionTooltip';
  9. import {IconChevron, IconProfiling} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import {EntryType, type EventTransaction, type Frame} from 'sentry/types/event';
  13. import type {Organization} from 'sentry/types/organization';
  14. import type {PlatformKey, Project} from 'sentry/types/project';
  15. import {StackView} from 'sentry/types/stacktrace';
  16. import {defined} from 'sentry/utils';
  17. import {formatPercentage} from 'sentry/utils/number/formatPercentage';
  18. import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
  19. import {Frame as ProfilingFrame} from 'sentry/utils/profiling/frame';
  20. import type {Profile} from 'sentry/utils/profiling/profile/profile';
  21. import {
  22. generateContinuousProfileFlamechartRouteWithQuery,
  23. generateProfileFlamechartRouteWithQuery,
  24. } from 'sentry/utils/profiling/routes';
  25. import {formatTo} from 'sentry/utils/profiling/units/units';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import useProjects from 'sentry/utils/useProjects';
  28. import {useProfileGroup} from 'sentry/views/profiling/profileGroupProvider';
  29. import type {SpanType} from './types';
  30. const MAX_STACK_DEPTH = 8;
  31. const MAX_TOP_NODES = 5;
  32. const MIN_TOP_NODES = 3;
  33. const TOP_NODE_MIN_COUNT = 3;
  34. interface SpanProfileDetailsProps {
  35. event: Readonly<EventTransaction>;
  36. span: Readonly<SpanType>;
  37. onNoProfileFound?: () => void;
  38. }
  39. export function useSpanProfileDetails(
  40. organization: Organization,
  41. project: Project | undefined,
  42. event: Readonly<EventTransaction>,
  43. span: Readonly<SpanType>
  44. ) {
  45. const profileGroup = useProfileGroup();
  46. const processedEvent = useMemo(() => {
  47. const entries: EventTransaction['entries'] = [...(event.entries || [])];
  48. if (profileGroup.images) {
  49. entries.push({
  50. data: {images: profileGroup.images},
  51. type: EntryType.DEBUGMETA,
  52. });
  53. }
  54. return {...event, entries};
  55. }, [event, profileGroup]);
  56. // TODO: Pick another thread if it's more relevant.
  57. const threadId = useMemo(
  58. () => profileGroup.profiles[profileGroup.activeProfileIndex]?.threadId,
  59. [profileGroup]
  60. );
  61. const profile = useMemo(() => {
  62. if (!defined(threadId)) {
  63. return null;
  64. }
  65. return profileGroup.profiles.find(p => p.threadId === threadId) ?? null;
  66. }, [profileGroup.profiles, threadId]);
  67. const nodes: CallTreeNode[] = useMemo(() => {
  68. if (profile === null) {
  69. return [];
  70. }
  71. // The most recent profile formats should contain a timestamp indicating
  72. // the beginning of the profile. This timestamp can be after the start
  73. // timestamp on the transaction, so we need to account for the gap and
  74. // make sure the relative start timestamps we compute for the span is
  75. // relative to the start of the profile.
  76. //
  77. // If the profile does not contain a timestamp, we fall back to using the
  78. // start timestamp on the transaction. This won't be as accurate but it's
  79. // the next best thing.
  80. const startTimestamp = profile.timestamp ?? event.startTimestamp;
  81. const relativeStartTimestamp = formatTo(
  82. span.start_timestamp - startTimestamp,
  83. 'second',
  84. profile.unit
  85. );
  86. const relativeStopTimestamp = formatTo(
  87. span.timestamp - startTimestamp,
  88. 'second',
  89. profile.unit
  90. );
  91. return getTopNodes(profile, relativeStartTimestamp, relativeStopTimestamp).filter(
  92. hasApplicationFrame
  93. );
  94. }, [profile, span, event]);
  95. const [index, setIndex] = useState(0);
  96. const totalWeight = useMemo(
  97. () => nodes.reduce((count, node) => count + node.count, 0),
  98. [nodes]
  99. );
  100. const maxNodes = useMemo(() => {
  101. // find the number of nodes with the minimum number of samples
  102. let hasMinCount = 0;
  103. for (const node of nodes) {
  104. if (node.count >= TOP_NODE_MIN_COUNT) {
  105. hasMinCount += 1;
  106. } else {
  107. break;
  108. }
  109. }
  110. hasMinCount = Math.max(MIN_TOP_NODES, hasMinCount);
  111. return Math.min(nodes.length, MAX_TOP_NODES, hasMinCount);
  112. }, [nodes]);
  113. const {frames, hasPrevious, hasNext} = useMemo(() => {
  114. if (index >= maxNodes) {
  115. return {frames: [], hasPrevious: false, hasNext: false};
  116. }
  117. return {
  118. frames: extractFrames(nodes[index]!, event.platform || 'other'),
  119. hasPrevious: index > 0,
  120. hasNext: index + 1 < maxNodes,
  121. };
  122. }, [index, maxNodes, event, nodes]);
  123. const profileTarget = useMemo(() => {
  124. if (defined(project)) {
  125. const profileContext = event.contexts.profile ?? {};
  126. if (defined(profileContext.profile_id)) {
  127. return generateProfileFlamechartRouteWithQuery({
  128. orgSlug: organization.slug,
  129. projectSlug: project.slug,
  130. profileId: profileContext.profile_id,
  131. query: {
  132. spanId: span.span_id,
  133. },
  134. });
  135. }
  136. if (defined(profileContext.profiler_id)) {
  137. return generateContinuousProfileFlamechartRouteWithQuery({
  138. orgSlug: organization.slug,
  139. projectSlug: project.slug,
  140. profilerId: profileContext.profiler_id,
  141. start: new Date(event.startTimestamp * 1000).toISOString(),
  142. end: new Date(event.endTimestamp * 1000).toISOString(),
  143. query: {
  144. eventId: event.id,
  145. spanId: span.span_id,
  146. },
  147. });
  148. }
  149. }
  150. return undefined;
  151. }, [organization, project, event, span]);
  152. return {
  153. processedEvent,
  154. profileGroup,
  155. profileTarget,
  156. profile,
  157. nodes,
  158. index,
  159. setIndex,
  160. totalWeight,
  161. maxNodes,
  162. frames,
  163. hasPrevious,
  164. hasNext,
  165. };
  166. }
  167. export function SpanProfileDetails({
  168. event,
  169. span,
  170. onNoProfileFound,
  171. }: SpanProfileDetailsProps) {
  172. const organization = useOrganization();
  173. const {projects} = useProjects();
  174. const project = projects.find(p => p.id === event.projectID);
  175. const {
  176. processedEvent,
  177. profileTarget,
  178. nodes,
  179. index,
  180. setIndex,
  181. maxNodes,
  182. hasNext,
  183. hasPrevious,
  184. totalWeight,
  185. frames,
  186. } = useSpanProfileDetails(organization, project, event, span);
  187. if (!defined(profileTarget)) {
  188. return null;
  189. }
  190. if (!frames.length) {
  191. if (onNoProfileFound) {
  192. onNoProfileFound();
  193. }
  194. return null;
  195. }
  196. const percentage = formatPercentage(nodes[index]!.count / totalWeight);
  197. return (
  198. <SpanContainer>
  199. <SpanDetails>
  200. <SpanDetailsItem grow>
  201. <SectionHeading>{t('Most Frequent Stacks in this Span')}</SectionHeading>
  202. </SpanDetailsItem>
  203. <SpanDetailsItem>
  204. <SectionSubtext>
  205. {tct('Showing stacks [index] of [total] ([percentage])', {
  206. index: index + 1,
  207. total: maxNodes,
  208. percentage,
  209. })}
  210. </SectionSubtext>
  211. </SpanDetailsItem>
  212. <QuestionTooltip
  213. position="top"
  214. size="xs"
  215. title={t(
  216. '%s out of %s (%s) of the call stacks collected during this span',
  217. nodes[index]!.count,
  218. totalWeight,
  219. percentage
  220. )}
  221. />
  222. <SpanDetailsItem>
  223. <ButtonBar merged>
  224. <Button
  225. icon={<IconChevron direction="left" />}
  226. aria-label={t('Previous')}
  227. size="xs"
  228. disabled={!hasPrevious}
  229. onClick={() => {
  230. setIndex(prevIndex => prevIndex - 1);
  231. }}
  232. />
  233. <Button
  234. icon={<IconChevron direction="right" />}
  235. aria-label={t('Next')}
  236. size="xs"
  237. disabled={!hasNext}
  238. onClick={() => {
  239. setIndex(prevIndex => prevIndex + 1);
  240. }}
  241. />
  242. </ButtonBar>
  243. </SpanDetailsItem>
  244. <SpanDetailsItem>
  245. <LinkButton icon={<IconProfiling />} to={profileTarget} size="xs">
  246. {t('Profile')}
  247. </LinkButton>
  248. </SpanDetailsItem>
  249. </SpanDetails>
  250. <StackTraceContent
  251. event={processedEvent}
  252. newestFirst
  253. platform={event.platform || 'other'}
  254. stacktrace={{
  255. framesOmitted: null,
  256. hasSystemFrames: false,
  257. registers: null,
  258. frames,
  259. }}
  260. stackView={StackView.APP}
  261. inlined
  262. maxDepth={MAX_STACK_DEPTH}
  263. />
  264. </SpanContainer>
  265. );
  266. }
  267. function getTopNodes(
  268. profile: Profile,
  269. startTimestamp: any,
  270. stopTimestamp: any
  271. ): CallTreeNode[] {
  272. let duration = profile.startedAt;
  273. const callTree: CallTreeNode = new CallTreeNode(ProfilingFrame.Root, null);
  274. for (let i = 0; i < profile.samples.length; i++) {
  275. const sample = profile.samples[i]!;
  276. // TODO: should this take self times into consideration?
  277. const inRange = startTimestamp <= duration && duration < stopTimestamp;
  278. duration += profile.weights[i]!;
  279. if (sample.isRoot || !inRange) {
  280. continue;
  281. }
  282. const stack: CallTreeNode[] = [sample];
  283. let node: CallTreeNode | null = sample;
  284. while (node && !node.isRoot) {
  285. stack.push(node);
  286. node = node.parent;
  287. }
  288. let tree = callTree;
  289. // make sure to iterate the stack backwards here, the 0th index is the
  290. // inner most frame, and the last index is the outer most frame
  291. for (let j = stack.length - 1; j >= 0; j--) {
  292. node = stack[j]!;
  293. const frame = node.frame;
  294. // find a child in the current tree with the same frame,
  295. // merge the current node into it if it exists
  296. let last = tree.children.find(n => n.frame === frame);
  297. if (!defined(last)) {
  298. last = new CallTreeNode(frame, tree);
  299. tree.children.push(last);
  300. }
  301. // make sure to increment the count/weight so it can be sorted later
  302. last.count += node.count;
  303. last.selfWeight += node.selfWeight;
  304. tree = last;
  305. }
  306. }
  307. const nodes: CallTreeNode[] = [];
  308. const trees = [callTree];
  309. while (trees.length) {
  310. const tree = trees.pop()!;
  311. // walk to the leaf nodes, these correspond with the inner most frame
  312. // on a stack
  313. if (tree.children.length === 0) {
  314. nodes.push(tree);
  315. continue;
  316. }
  317. trees.push(...tree.children);
  318. }
  319. return nodes.sort(sortByCount);
  320. }
  321. // TODO: does this work for android? The counts on the evented format may not be what we expect
  322. function sortByCount(a: CallTreeNode, b: CallTreeNode) {
  323. if (a.count === b.count) {
  324. return b.selfWeight - a.selfWeight;
  325. }
  326. return b.count - a.count;
  327. }
  328. function hasApplicationFrame(node: CallTreeNode | null) {
  329. while (node && !node.isRoot) {
  330. if (node.frame.is_application) {
  331. return true;
  332. }
  333. node = node.parent;
  334. }
  335. return false;
  336. }
  337. function extractFrames(node: CallTreeNode | null, platform: PlatformKey): Frame[] {
  338. const frames: Frame[] = [];
  339. while (node && !node.isRoot) {
  340. const frame = {
  341. absPath: node.frame.path ?? null,
  342. colNo: node.frame.column ?? null,
  343. context: [],
  344. filename: node.frame.file ?? null,
  345. function: node.frame.name ?? null,
  346. inApp: node.frame.is_application,
  347. instructionAddr: node.frame.instructionAddr ?? null,
  348. lineNo: node.frame.line ?? null,
  349. module: node.frame.module ?? null,
  350. package: node.frame.package ?? null,
  351. platform,
  352. rawFunction: null,
  353. symbol: node.frame.symbol ?? null,
  354. symbolAddr: node.frame.symbolAddr ?? null,
  355. symbolicatorStatus: node.frame.symbolicatorStatus,
  356. trust: null,
  357. vars: null,
  358. };
  359. frames.push(frame);
  360. node = node.parent;
  361. }
  362. // Profile stacks start from the inner most frame, while error stacks
  363. // start from the outer most frame. Reverse the order here to match
  364. // the convention on errors.
  365. return frames.reverse();
  366. }
  367. const SpanContainer = styled('div')`
  368. container: profiling-container / inline-size;
  369. border: 1px solid ${p => p.theme.innerBorder};
  370. border-radius: ${p => p.theme.borderRadius};
  371. overflow: hidden;
  372. ${StackTraceContentPanel} {
  373. margin-bottom: 0;
  374. box-shadow: none;
  375. }
  376. `;
  377. const SpanDetails = styled('div')`
  378. padding: ${space(0.5)} ${space(1)};
  379. display: flex;
  380. align-items: center;
  381. gap: ${space(1)};
  382. `;
  383. const SpanDetailsItem = styled('span')<{grow?: boolean}>`
  384. flex: ${p => (p.grow ? '1 2 auto' : 'flex: 0 1 auto')};
  385. &:nth-child(2) {
  386. @container profiling-container (width < 680px) {
  387. display: none;
  388. }
  389. }
  390. &:first-child {
  391. flex: 0 1 100%;
  392. min-width: 0;
  393. }
  394. h4 {
  395. display: block;
  396. white-space: nowrap;
  397. overflow: hidden;
  398. text-overflow: ellipsis;
  399. width: 100%;
  400. }
  401. `;
  402. const SectionSubtext = styled('span')`
  403. color: ${p => p.theme.subText};
  404. font-size: ${p => p.theme.fontSizeMedium};
  405. `;