spansList.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import {useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Location} from 'history';
  4. import moment from 'moment';
  5. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  6. import Link from 'sentry/components/links/link';
  7. import LoadingIndicator from 'sentry/components/loadingIndicator';
  8. import {IconWarning} from 'sentry/icons';
  9. import {t, tct} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {Organization} from 'sentry/types';
  12. import {trackAnalytics} from 'sentry/utils/analytics';
  13. import {parsePeriodToHours} from 'sentry/utils/dates';
  14. import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
  15. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  16. import SuspectSpansQuery from 'sentry/utils/performance/suspectSpans/suspectSpansQuery';
  17. import {SuspectSpan, SuspectSpans} from 'sentry/utils/performance/suspectSpans/types';
  18. import theme from 'sentry/utils/theme';
  19. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  20. import useProjects from 'sentry/utils/useProjects';
  21. import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
  22. import {
  23. SpanSortOption,
  24. SpanSortOthers,
  25. SpanSortPercentiles,
  26. } from 'sentry/views/performance/transactionSummary/transactionSpans/types';
  27. import {
  28. getSuspectSpanSortFromLocation,
  29. SPAN_SORT_TO_FIELDS,
  30. } from 'sentry/views/performance/transactionSummary/transactionSpans/utils';
  31. import {
  32. getQueryParams,
  33. relativeChange,
  34. } from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
  35. import {
  36. NormalizedTrendsTransaction,
  37. TrendChangeType,
  38. TrendView,
  39. } from 'sentry/views/performance/trends/types';
  40. import {getTrendProjectId} from 'sentry/views/performance/trends/utils';
  41. type SpansListProps = {
  42. breakpoint: number;
  43. location: Location;
  44. organization: Organization;
  45. transaction: NormalizedTrendsTransaction;
  46. trendChangeType: TrendChangeType;
  47. trendView: TrendView;
  48. };
  49. type AveragedSuspectSpan = SuspectSpan & {
  50. avgSumExclusiveTime: number;
  51. };
  52. export type ChangedSuspectSpan = AveragedSuspectSpan & {
  53. avgTimeDifference: number;
  54. changeType: string;
  55. percentChange: number;
  56. };
  57. type NumberedSpansListProps = {
  58. isError: boolean;
  59. isLoading: boolean;
  60. limit: number;
  61. location: Location;
  62. organization: Organization;
  63. transactionName: string;
  64. projectID?: string;
  65. spans?: ChangedSuspectSpan[];
  66. };
  67. export const SpanChangeType = {
  68. added: t('Added'),
  69. removed: t('Removed'),
  70. regressed: t('Regressed'),
  71. improved: t('Improved'),
  72. };
  73. export function SpansList(props: SpansListProps) {
  74. const {trendView, location, organization, breakpoint, transaction, trendChangeType} =
  75. props;
  76. const hours = trendView.statsPeriod ? parsePeriodToHours(trendView.statsPeriod) : 0;
  77. const startTime = useMemo(
  78. () =>
  79. trendView.start ? trendView.start : moment().subtract(hours, 'h').toISOString(),
  80. [hours, trendView.start]
  81. );
  82. const breakpointTime = breakpoint ? new Date(breakpoint * 1000).toISOString() : '';
  83. const endTime = useMemo(
  84. () => (trendView.end ? trendView.end : moment().toISOString()),
  85. [trendView.end]
  86. );
  87. const {projects} = useProjects();
  88. const projectID = getTrendProjectId(transaction, projects);
  89. const beforeLocation = updateLocation(
  90. location,
  91. startTime,
  92. breakpointTime,
  93. transaction,
  94. projectID
  95. );
  96. const beforeSort = getSuspectSpanSortFromLocation(beforeLocation, 'spanSort');
  97. const beforeEventView = updateEventView(
  98. trendView,
  99. startTime,
  100. breakpointTime,
  101. transaction,
  102. beforeSort,
  103. projectID
  104. );
  105. const beforeFields = SPAN_SORT_TO_FIELDS[beforeSort.field];
  106. beforeEventView.fields = beforeFields ? beforeFields.map(field => ({field})) : [];
  107. const afterLocation = updateLocation(
  108. location,
  109. startTime,
  110. breakpointTime,
  111. transaction,
  112. projectID
  113. );
  114. const afterSort = getSuspectSpanSortFromLocation(afterLocation, 'spanSort');
  115. const afterEventView = updateEventView(
  116. trendView,
  117. breakpointTime,
  118. endTime,
  119. transaction,
  120. afterSort,
  121. projectID
  122. );
  123. const afterFields = SPAN_SORT_TO_FIELDS[afterSort.field];
  124. afterEventView.fields = afterFields ? afterFields.map(field => ({field})) : [];
  125. const {
  126. data: totalTransactionsBefore,
  127. isLoading: transactionsLoadingBefore,
  128. isError: transactionsErrorBefore,
  129. } = useDiscoverQuery(
  130. getQueryParams(
  131. startTime,
  132. breakpointTime,
  133. ['count'],
  134. 'transaction',
  135. DiscoverDatasets.METRICS,
  136. organization,
  137. trendView,
  138. transaction.transaction,
  139. location
  140. )
  141. );
  142. const transactionCountBefore = totalTransactionsBefore?.data
  143. ? (totalTransactionsBefore?.data[0]['count()'] as number)
  144. : 0;
  145. const {
  146. data: totalTransactionsAfter,
  147. isLoading: transactionsLoadingAfter,
  148. isError: transactionsErrorAfter,
  149. } = useDiscoverQuery(
  150. getQueryParams(
  151. breakpointTime,
  152. endTime,
  153. ['count'],
  154. 'transaction',
  155. DiscoverDatasets.METRICS,
  156. organization,
  157. trendView,
  158. transaction.transaction,
  159. location
  160. )
  161. );
  162. const transactionCountAfter = totalTransactionsAfter?.data
  163. ? (totalTransactionsAfter?.data[0]['count()'] as number)
  164. : 0;
  165. return (
  166. <SuspectSpansQuery
  167. location={beforeLocation}
  168. orgSlug={organization.slug}
  169. eventView={beforeEventView}
  170. limit={50}
  171. perSuspect={0}
  172. >
  173. {({
  174. suspectSpans: suspectSpansBefore,
  175. isLoading: spansLoadingBefore,
  176. error: spansErrorBefore,
  177. }) => {
  178. const hasSpansErrorBefore = spansErrorBefore !== null;
  179. return (
  180. <SuspectSpansQuery
  181. location={afterLocation}
  182. orgSlug={organization.slug}
  183. eventView={afterEventView}
  184. limit={50}
  185. perSuspect={0}
  186. >
  187. {({
  188. suspectSpans: suspectSpansAfter,
  189. isLoading: spansLoadingAfter,
  190. error: spansErrorAfter,
  191. }) => {
  192. const hasSpansErrorAfter = spansErrorAfter !== null;
  193. // need these averaged fields because comparing total self times may be inaccurate depending on
  194. // where the breakpoint is
  195. const spansAveragedAfter = addAvgSumExclusiveTime(
  196. suspectSpansAfter,
  197. transactionCountAfter
  198. );
  199. const spansAveragedBefore = addAvgSumExclusiveTime(
  200. suspectSpansBefore,
  201. transactionCountBefore
  202. );
  203. const addedSpans = addSpanChangeFields(
  204. findSpansNotIn(spansAveragedAfter, spansAveragedBefore),
  205. true
  206. );
  207. const removedSpans = addSpanChangeFields(
  208. findSpansNotIn(spansAveragedBefore, spansAveragedAfter),
  209. false
  210. );
  211. const remainingSpansBefore = findSpansIn(
  212. spansAveragedBefore,
  213. spansAveragedAfter
  214. );
  215. const remainingSpansAfter = findSpansIn(
  216. spansAveragedAfter,
  217. spansAveragedBefore
  218. );
  219. const remainingSpansWithChange = addPercentChangeInSpans(
  220. remainingSpansBefore,
  221. remainingSpansAfter
  222. );
  223. const allSpansUpdated = remainingSpansWithChange
  224. ?.concat(addedSpans ? addedSpans : [])
  225. .concat(removedSpans ? removedSpans : []);
  226. // sorts all spans in descending order of avgTimeDifference (change in avg total self time)
  227. const spanList = allSpansUpdated?.sort(
  228. (a, b) => b.avgTimeDifference - a.avgTimeDifference
  229. );
  230. // reverse the span list when trendChangeType is improvement so most negative (improved) change is first
  231. return (
  232. <div style={{marginTop: space(4)}}>
  233. <h6>{t('Relevant Suspect Spans')}</h6>
  234. <NumberedSpansList
  235. spans={
  236. trendChangeType === TrendChangeType.REGRESSION
  237. ? spanList
  238. : spanList?.reverse()
  239. }
  240. projectID={projectID}
  241. location={location}
  242. organization={organization}
  243. transactionName={transaction.transaction}
  244. limit={4}
  245. isLoading={
  246. transactionsLoadingBefore ||
  247. transactionsLoadingAfter ||
  248. spansLoadingBefore ||
  249. spansLoadingAfter
  250. }
  251. isError={
  252. transactionsErrorBefore ||
  253. transactionsErrorAfter ||
  254. hasSpansErrorBefore ||
  255. hasSpansErrorAfter
  256. }
  257. />
  258. </div>
  259. );
  260. }}
  261. </SuspectSpansQuery>
  262. );
  263. }}
  264. </SuspectSpansQuery>
  265. );
  266. }
  267. function updateLocation(
  268. location: Location,
  269. start: string,
  270. end: string,
  271. transaction: NormalizedTrendsTransaction,
  272. projectID?: string
  273. ) {
  274. return {
  275. ...location,
  276. start,
  277. end,
  278. statsPeriod: undefined,
  279. sort: SpanSortOthers.SUM_EXCLUSIVE_TIME,
  280. project: projectID,
  281. query: {
  282. query: 'transaction:' + transaction.transaction,
  283. statsPeriod: undefined,
  284. start,
  285. end,
  286. project: projectID,
  287. },
  288. };
  289. }
  290. function updateEventView(
  291. trendView: TrendView,
  292. start: string,
  293. end: string,
  294. transaction: NormalizedTrendsTransaction,
  295. sort: SpanSortOption,
  296. projectID?: string
  297. ) {
  298. const newEventView = trendView.clone();
  299. newEventView.start = start;
  300. newEventView.end = end;
  301. newEventView.statsPeriod = undefined;
  302. newEventView.query = `event.type:transaction transaction:${transaction.transaction}`;
  303. newEventView.project = projectID ? [parseInt(projectID, 10)] : [];
  304. newEventView.additionalConditions = new MutableSearch('');
  305. return newEventView
  306. .withColumns(
  307. [...Object.values(SpanSortOthers), ...Object.values(SpanSortPercentiles)].map(
  308. field => ({kind: 'field', field})
  309. )
  310. )
  311. .withSorts([{kind: 'desc', field: sort.field}]);
  312. }
  313. function findSpansNotIn(
  314. initialSpans: AveragedSuspectSpan[] | undefined,
  315. comparingSpans: AveragedSuspectSpan[] | undefined
  316. ) {
  317. return initialSpans?.filter(initialValue => {
  318. const spanInComparingSet = comparingSpans?.find(
  319. comparingValue =>
  320. comparingValue.op === initialValue.op &&
  321. comparingValue.group === initialValue.group
  322. );
  323. return spanInComparingSet === undefined;
  324. });
  325. }
  326. function findSpansIn(
  327. initialSpans: AveragedSuspectSpan[] | undefined,
  328. comparingSpans: AveragedSuspectSpan[] | undefined
  329. ) {
  330. return initialSpans?.filter(initialValue => {
  331. const spanInComparingSet = comparingSpans?.find(
  332. comparingValue =>
  333. comparingValue.op === initialValue.op &&
  334. comparingValue.group === initialValue.group
  335. );
  336. return spanInComparingSet !== undefined;
  337. });
  338. }
  339. /**
  340. *
  341. * adds an average of the sumExclusive time so it is more comparable when the breakpoint
  342. * is not close to the middle of the timeseries
  343. */
  344. function addAvgSumExclusiveTime(
  345. suspectSpans: SuspectSpans | null,
  346. transactionCount: number
  347. ) {
  348. return suspectSpans?.map(span => {
  349. return {
  350. ...span,
  351. avgSumExclusiveTime: span.sumExclusiveTime
  352. ? span.sumExclusiveTime / transactionCount
  353. : 0,
  354. };
  355. });
  356. }
  357. function addPercentChangeInSpans(
  358. before: AveragedSuspectSpan[] | undefined,
  359. after: AveragedSuspectSpan[] | undefined
  360. ) {
  361. return after?.map(spanAfter => {
  362. const spanBefore = before?.find(
  363. beforeValue =>
  364. spanAfter.op === beforeValue.op && spanAfter.group === beforeValue.group
  365. );
  366. const percentageChange =
  367. relativeChange(
  368. spanBefore?.avgSumExclusiveTime || 0,
  369. spanAfter.avgSumExclusiveTime
  370. ) * 100;
  371. return {
  372. ...spanAfter,
  373. percentChange: percentageChange,
  374. avgTimeDifference:
  375. spanAfter.avgSumExclusiveTime - (spanBefore?.avgSumExclusiveTime || 0),
  376. changeType:
  377. percentageChange < 0 ? SpanChangeType.improved : SpanChangeType.regressed,
  378. };
  379. });
  380. }
  381. function addSpanChangeFields(
  382. spans: AveragedSuspectSpan[] | undefined,
  383. added: boolean
  384. ): ChangedSuspectSpan[] | undefined {
  385. // percent change is hardcoded to pass the 1% change threshold,
  386. // avoid infinite values and reflect correct change type
  387. return spans?.map(span => {
  388. if (added) {
  389. return {
  390. ...span,
  391. percentChange: 100,
  392. avgTimeDifference: span.avgSumExclusiveTime,
  393. changeType: SpanChangeType.added,
  394. };
  395. }
  396. return {
  397. ...span,
  398. percentChange: -100,
  399. avgTimeDifference: 0 - span.avgSumExclusiveTime,
  400. changeType: SpanChangeType.removed,
  401. };
  402. });
  403. }
  404. export function TimeDifference({difference}: {difference: number}) {
  405. const positive = difference >= 0;
  406. const roundedDifference = difference.toPrecision(3);
  407. return (
  408. <p
  409. style={{
  410. alignSelf: 'end',
  411. color: positive ? theme.red300 : theme.green300,
  412. marginLeft: space(2),
  413. }}
  414. data-test-id="list-delta"
  415. >
  416. {positive ? `+${roundedDifference} ms` : `${roundedDifference} ms`}
  417. </p>
  418. );
  419. }
  420. export function NumberedSpansList(props: NumberedSpansListProps) {
  421. const {
  422. spans,
  423. projectID,
  424. location,
  425. transactionName,
  426. organization,
  427. limit,
  428. isLoading,
  429. isError,
  430. } = props;
  431. if (isLoading) {
  432. return <LoadingIndicator />;
  433. }
  434. if (isError) {
  435. return (
  436. <ErrorWrapper>
  437. <IconWarning data-test-id="error-indicator-spans" color="gray200" size="xxl" />
  438. <p>{t('There was an issue finding suspect spans for this transaction')}</p>
  439. </ErrorWrapper>
  440. );
  441. }
  442. if (spans?.length === 0 || !spans) {
  443. return (
  444. <EmptyStateWarning>
  445. <p data-test-id="spans-no-results">{t('No results found for suspect spans')}</p>
  446. </EmptyStateWarning>
  447. );
  448. }
  449. // percent change of a span must be more than 1%
  450. const formattedSpans = spans
  451. ?.filter(span => (spans.length > 10 ? Math.abs(span.percentChange) >= 1 : true))
  452. .slice(0, limit)
  453. .map((span, index) => {
  454. const spanDetailsPage = spanDetailsRouteWithQuery({
  455. orgSlug: organization.slug,
  456. transaction: transactionName,
  457. query: location.query,
  458. spanSlug: {op: span.op, group: span.group},
  459. projectID,
  460. });
  461. const handleClickAnalytics = () => {
  462. trackAnalytics(
  463. 'performance_views.performance_change_explorer.span_link_clicked',
  464. {
  465. organization,
  466. transaction: transactionName,
  467. op: span.op,
  468. group: span.group,
  469. }
  470. );
  471. };
  472. return (
  473. <li key={`list-item-${index}`}>
  474. <ListItemWrapper data-test-id="list-item">
  475. <p style={{marginLeft: space(2)}}>
  476. {tct('[changeType] suspect span', {changeType: span.changeType})}
  477. </p>
  478. <ListLink to={spanDetailsPage} onClick={handleClickAnalytics}>
  479. {span.description ? `${span.op} - ${span.description}` : span.op}
  480. </ListLink>
  481. <TimeDifference difference={span.avgTimeDifference} />
  482. </ListItemWrapper>
  483. </li>
  484. );
  485. });
  486. if (formattedSpans?.length === 0) {
  487. return (
  488. <EmptyStateWarning>
  489. <p data-test-id="spans-no-changes">{t('No sizable changes in suspect spans')}</p>
  490. </EmptyStateWarning>
  491. );
  492. }
  493. return <ol>{formattedSpans}</ol>;
  494. }
  495. export const ListLink = styled(Link)`
  496. margin-left: ${space(1)};
  497. ${p => p.theme.overflowEllipsis}
  498. `;
  499. export const ListItemWrapper = styled('div')`
  500. display: flex;
  501. white-space: nowrap;
  502. `;
  503. export const ErrorWrapper = styled('div')`
  504. display: flex;
  505. margin-top: ${space(4)};
  506. flex-direction: column;
  507. align-items: center;
  508. gap: ${space(3)};
  509. `;