eventsTable.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. import type React from 'react';
  2. import {Component, Fragment, type ReactNode} from 'react';
  3. import styled from '@emotion/styled';
  4. import type {Location, LocationDescriptor, LocationDescriptorObject} from 'history';
  5. import groupBy from 'lodash/groupBy';
  6. import {Client} from 'sentry/api';
  7. import {LinkButton} from 'sentry/components/button';
  8. import type {GridColumn} from 'sentry/components/gridEditable';
  9. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  10. import SortLink from 'sentry/components/gridEditable/sortLink';
  11. import Link from 'sentry/components/links/link';
  12. import Pagination from 'sentry/components/pagination';
  13. import QuestionTooltip from 'sentry/components/questionTooltip';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconProfiling} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import type {IssueAttachment} from 'sentry/types/group';
  18. import type {RouteContextInterface} from 'sentry/types/legacyReactRouter';
  19. import type {Organization} from 'sentry/types/organization';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import toArray from 'sentry/utils/array/toArray';
  22. import {browserHistory} from 'sentry/utils/browserHistory';
  23. import type {TableData, TableDataRow} from 'sentry/utils/discover/discoverQuery';
  24. import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
  25. import type EventView from 'sentry/utils/discover/eventView';
  26. import {isFieldSortable} from 'sentry/utils/discover/eventView';
  27. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  28. import {
  29. fieldAlignment,
  30. getAggregateAlias,
  31. isSpanOperationBreakdownField,
  32. SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
  33. } from 'sentry/utils/discover/fields';
  34. import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls';
  35. import ViewReplayLink from 'sentry/utils/discover/viewReplayLink';
  36. import {isEmptyObject} from 'sentry/utils/object/isEmptyObject';
  37. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  38. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  39. import CellAction, {Actions, updateQuery} from 'sentry/views/discover/table/cellAction';
  40. import type {TableColumn} from 'sentry/views/discover/table/types';
  41. import type {DomainViewFilters} from 'sentry/views/insights/pages/useFilters';
  42. import {COLUMN_TITLES} from '../../data';
  43. import {TraceViewSources} from '../../newTraceDetails/traceHeader/breadcrumbs';
  44. import Tab from '../tabs';
  45. import {
  46. generateProfileLink,
  47. generateReplayLink,
  48. generateTraceLink,
  49. normalizeSearchConditions,
  50. } from '../utils';
  51. import type {TitleProps} from './operationSort';
  52. import OperationSort from './operationSort';
  53. function shouldRenderColumn(containsSpanOpsBreakdown: boolean, col: string): boolean {
  54. if (containsSpanOpsBreakdown && isSpanOperationBreakdownField(col)) {
  55. return false;
  56. }
  57. if (
  58. col === 'profiler.id' ||
  59. col === 'thread.id' ||
  60. col === 'precise.start_ts' ||
  61. col === 'precise.finish_ts'
  62. ) {
  63. return false;
  64. }
  65. return true;
  66. }
  67. function OperationTitle({onClick}: TitleProps) {
  68. return (
  69. <div onClick={onClick}>
  70. <span>{t('operation duration')}</span>
  71. <StyledIconQuestion
  72. size="xs"
  73. position="top"
  74. title={t(
  75. `Span durations are summed over the course of an entire transaction. Any overlapping spans are only counted once.`
  76. )}
  77. />
  78. </div>
  79. );
  80. }
  81. type Props = {
  82. eventView: EventView;
  83. location: Location;
  84. organization: Organization;
  85. routes: RouteContextInterface['routes'];
  86. setError: (msg: string | undefined) => void;
  87. transactionName: string;
  88. applyEnvironmentFilter?: boolean;
  89. columnTitles?: string[];
  90. customColumns?: Array<'attachments' | 'minidump'>;
  91. domainViewFilters?: DomainViewFilters;
  92. excludedTags?: string[];
  93. hidePagination?: boolean;
  94. isEventLoading?: boolean;
  95. isRegressionIssue?: boolean;
  96. issueId?: string;
  97. projectSlug?: string;
  98. referrer?: string;
  99. renderTableHeader?: (props: {
  100. isPending: boolean;
  101. pageEventsCount: number;
  102. pageLinks: string | null;
  103. totalEventsCount: ReactNode;
  104. }) => ReactNode;
  105. };
  106. type State = {
  107. attachments: IssueAttachment[];
  108. hasMinidumps: boolean;
  109. lastFetchedCursor: string;
  110. widths: number[];
  111. };
  112. class EventsTable extends Component<Props, State> {
  113. state: State = {
  114. widths: [],
  115. lastFetchedCursor: '',
  116. attachments: [],
  117. hasMinidumps: false,
  118. };
  119. api = new Client();
  120. replayLinkGenerator = generateReplayLink(this.props.routes);
  121. handleCellAction = (column: TableColumn<keyof TableDataRow>) => {
  122. return (action: Actions, value: React.ReactText) => {
  123. const {eventView, location, organization, excludedTags, applyEnvironmentFilter} =
  124. this.props;
  125. trackAnalytics('performance_views.transactionEvents.cellaction', {
  126. organization,
  127. action,
  128. });
  129. const searchConditions = normalizeSearchConditions(eventView.query);
  130. if (excludedTags) {
  131. excludedTags.forEach(tag => {
  132. searchConditions.removeFilter(tag);
  133. });
  134. }
  135. updateQuery(searchConditions, action, column, value);
  136. if (applyEnvironmentFilter && column.key === 'environment') {
  137. let newEnvs = toArray(location.query.environment);
  138. if (action === Actions.ADD) {
  139. if (!newEnvs.includes(String(value))) {
  140. newEnvs.push(String(value));
  141. }
  142. } else {
  143. newEnvs = newEnvs.filter(env => env !== value);
  144. }
  145. // Updates the environment filter, instead of relying on the search query
  146. browserHistory.push({
  147. pathname: location.pathname,
  148. query: {
  149. ...location.query,
  150. cursor: undefined,
  151. environment: newEnvs,
  152. },
  153. });
  154. return;
  155. }
  156. browserHistory.push({
  157. pathname: location.pathname,
  158. query: {
  159. ...location.query,
  160. cursor: undefined,
  161. query: searchConditions.formatString(),
  162. },
  163. });
  164. };
  165. };
  166. renderBodyCell(
  167. tableData: TableData | null,
  168. column: TableColumn<keyof TableDataRow>,
  169. dataRow: TableDataRow
  170. ): React.ReactNode {
  171. const {eventView, organization, location, transactionName, projectSlug} = this.props;
  172. if (!tableData || !tableData.meta) {
  173. return dataRow[column.key];
  174. }
  175. const tableMeta = tableData.meta;
  176. const field = String(column.key);
  177. const fieldRenderer = getFieldRenderer(field, tableMeta);
  178. const rendered = fieldRenderer(dataRow, {
  179. organization,
  180. location,
  181. eventView,
  182. projectSlug,
  183. });
  184. const allowActions = [
  185. Actions.ADD,
  186. Actions.EXCLUDE,
  187. Actions.SHOW_GREATER_THAN,
  188. Actions.SHOW_LESS_THAN,
  189. ];
  190. if (['attachments', 'minidump'].includes(field)) {
  191. return rendered;
  192. }
  193. if (field === 'id' || field === 'trace') {
  194. const {issueId, isRegressionIssue} = this.props;
  195. const isIssue: boolean = !!issueId;
  196. let target: LocationDescriptor = {};
  197. const locationWithTab = {...location, query: {...location.query, tab: Tab.EVENTS}};
  198. // TODO: set referrer properly
  199. if (isIssue && !isRegressionIssue && field === 'id') {
  200. target.pathname = `/organizations/${organization.slug}/issues/${issueId}/events/${dataRow.id}/`;
  201. } else {
  202. if (field === 'id') {
  203. target = generateLinkToEventInTraceView({
  204. traceSlug: dataRow.trace?.toString()!,
  205. projectSlug: dataRow['project.name']?.toString()!,
  206. eventId: dataRow.id,
  207. timestamp: dataRow.timestamp!,
  208. location: locationWithTab,
  209. organization,
  210. transactionName,
  211. source: TraceViewSources.PERFORMANCE_TRANSACTION_SUMMARY,
  212. view: this.props.domainViewFilters?.view,
  213. });
  214. } else {
  215. target = generateTraceLink(transactionName, this.props.domainViewFilters?.view)(
  216. organization,
  217. dataRow,
  218. locationWithTab
  219. );
  220. }
  221. }
  222. return (
  223. <CellAction
  224. column={column}
  225. dataRow={dataRow}
  226. handleCellAction={this.handleCellAction(column)}
  227. allowActions={allowActions}
  228. >
  229. <Link to={target}>{rendered}</Link>
  230. </CellAction>
  231. );
  232. }
  233. if (field === 'replayId') {
  234. const target: LocationDescriptor | null = dataRow.replayId
  235. ? this.replayLinkGenerator(organization, dataRow, undefined)
  236. : null;
  237. return (
  238. <CellAction
  239. column={column}
  240. dataRow={dataRow}
  241. handleCellAction={this.handleCellAction(column)}
  242. allowActions={allowActions}
  243. >
  244. {target ? (
  245. <ViewReplayLink replayId={dataRow.replayId!} to={target}>
  246. {rendered}
  247. </ViewReplayLink>
  248. ) : (
  249. rendered
  250. )}
  251. </CellAction>
  252. );
  253. }
  254. if (field === 'profile.id') {
  255. const target = generateProfileLink()(organization, dataRow, undefined);
  256. const transactionMeetsProfilingRequirements =
  257. typeof dataRow['transaction.duration'] === 'number' &&
  258. dataRow['transaction.duration'] > 20;
  259. return (
  260. <Tooltip
  261. title={
  262. !transactionMeetsProfilingRequirements && !dataRow['profile.id']
  263. ? t('Profiles require a transaction duration of at least 20ms')
  264. : null
  265. }
  266. >
  267. <CellAction
  268. column={column}
  269. dataRow={dataRow}
  270. handleCellAction={this.handleCellAction(column)}
  271. allowActions={allowActions}
  272. >
  273. <div>
  274. <LinkButton
  275. disabled={!target || isEmptyObject(target)}
  276. to={target || {}}
  277. size="xs"
  278. >
  279. <IconProfiling size="xs" />
  280. </LinkButton>
  281. </div>
  282. </CellAction>
  283. </Tooltip>
  284. );
  285. }
  286. const fieldName = getAggregateAlias(field);
  287. const value = dataRow[fieldName];
  288. if (tableMeta[fieldName] === 'integer' && typeof value === 'number' && value > 999) {
  289. return (
  290. <Tooltip
  291. title={value.toLocaleString()}
  292. containerDisplayMode="block"
  293. position="right"
  294. >
  295. <CellAction
  296. column={column}
  297. dataRow={dataRow}
  298. handleCellAction={this.handleCellAction(column)}
  299. allowActions={allowActions}
  300. >
  301. {rendered}
  302. </CellAction>
  303. </Tooltip>
  304. );
  305. }
  306. return (
  307. <CellAction
  308. column={column}
  309. dataRow={dataRow}
  310. handleCellAction={this.handleCellAction(column)}
  311. allowActions={allowActions}
  312. >
  313. {rendered}
  314. </CellAction>
  315. );
  316. }
  317. renderBodyCellWithData = (tableData: TableData | null) => {
  318. return (
  319. column: TableColumn<keyof TableDataRow>,
  320. dataRow: TableDataRow
  321. ): React.ReactNode => this.renderBodyCell(tableData, column, dataRow);
  322. };
  323. onSortClick(currentSortKind?: string, currentSortField?: string) {
  324. const {organization} = this.props;
  325. trackAnalytics('performance_views.transactionEvents.sort', {
  326. organization,
  327. field: currentSortField,
  328. direction: currentSortKind,
  329. });
  330. }
  331. renderHeadCell(
  332. tableMeta: TableData['meta'],
  333. column: TableColumn<keyof TableDataRow>,
  334. title: React.ReactNode
  335. ): React.ReactNode {
  336. const {eventView, location} = this.props;
  337. const align = fieldAlignment(column.name, column.type, tableMeta);
  338. const field = {field: column.name, width: column.width};
  339. function generateSortLink(): LocationDescriptorObject | undefined {
  340. if (!tableMeta) {
  341. return undefined;
  342. }
  343. const nextEventView = eventView.sortOnField(field, tableMeta);
  344. const queryStringObject = nextEventView.generateQueryStringObject();
  345. return {
  346. ...location,
  347. query: {...location.query, sort: queryStringObject.sort},
  348. };
  349. }
  350. const currentSort = eventView.sortForField(field, tableMeta);
  351. // EventId, TraceId, and ReplayId are technically sortable but we don't want to sort them here since sorting by a uuid value doesn't make sense
  352. const canSort =
  353. field.field !== 'id' &&
  354. field.field !== 'trace' &&
  355. field.field !== 'replayId' &&
  356. field.field !== SPAN_OP_RELATIVE_BREAKDOWN_FIELD &&
  357. isFieldSortable(field, tableMeta);
  358. const currentSortKind = currentSort ? currentSort.kind : undefined;
  359. const currentSortField = currentSort ? currentSort.field : undefined;
  360. if (field.field === SPAN_OP_RELATIVE_BREAKDOWN_FIELD) {
  361. title = (
  362. <OperationSort
  363. title={OperationTitle}
  364. eventView={eventView}
  365. tableMeta={tableMeta}
  366. location={location}
  367. />
  368. );
  369. }
  370. const sortLink = (
  371. <SortLink
  372. align={align}
  373. title={title || field.field}
  374. direction={currentSortKind}
  375. canSort={canSort}
  376. generateSortLink={generateSortLink}
  377. onClick={() => this.onSortClick(currentSortKind, currentSortField)}
  378. />
  379. );
  380. return sortLink;
  381. }
  382. renderHeadCellWithMeta = (tableMeta: TableData['meta']) => {
  383. const columnTitles = this.props.columnTitles ?? COLUMN_TITLES;
  384. return (column: TableColumn<keyof TableDataRow>, index: number): React.ReactNode =>
  385. this.renderHeadCell(tableMeta, column, columnTitles[index]);
  386. };
  387. handleResizeColumn = (columnIndex: number, nextColumn: GridColumn) => {
  388. const widths: number[] = [...this.state.widths];
  389. widths[columnIndex] = nextColumn.width
  390. ? Number(nextColumn.width)
  391. : COL_WIDTH_UNDEFINED;
  392. this.setState({...this.state, widths});
  393. };
  394. render() {
  395. const {eventView, organization, location, setError, referrer, isEventLoading} =
  396. this.props;
  397. const totalEventsView = eventView.clone();
  398. totalEventsView.sorts = [];
  399. totalEventsView.fields = [{field: 'count()', width: -1}];
  400. const {widths} = this.state;
  401. const containsSpanOpsBreakdown = !!eventView
  402. .getColumns()
  403. .find(
  404. (col: TableColumn<React.ReactText>) =>
  405. col.name === SPAN_OP_RELATIVE_BREAKDOWN_FIELD
  406. );
  407. const columnOrder = eventView
  408. .getColumns()
  409. .filter((col: TableColumn<React.ReactText>) =>
  410. shouldRenderColumn(containsSpanOpsBreakdown, col.name)
  411. )
  412. .map((col: TableColumn<React.ReactText>, i: number) => {
  413. if (typeof widths[i] === 'number') {
  414. return {...col, width: widths[i]};
  415. }
  416. return col;
  417. });
  418. if (
  419. this.props.customColumns?.includes('attachments') &&
  420. this.state.attachments.length
  421. ) {
  422. columnOrder.push({
  423. isSortable: false,
  424. key: 'attachments',
  425. name: 'attachments',
  426. type: 'never',
  427. column: {field: 'attachments', kind: 'field', alias: undefined},
  428. });
  429. }
  430. if (this.props.customColumns?.includes('minidump') && this.state.hasMinidumps) {
  431. columnOrder.push({
  432. isSortable: false,
  433. key: 'minidump',
  434. name: 'minidump',
  435. type: 'never',
  436. column: {field: 'minidump', kind: 'field', alias: undefined},
  437. });
  438. }
  439. const joinCustomData = ({data}: TableData) => {
  440. const attachmentsByEvent = groupBy(this.state.attachments, 'event_id');
  441. data.forEach(event => {
  442. event.attachments = (attachmentsByEvent[event.id] || []) as any;
  443. });
  444. };
  445. const fetchAttachments = async ({data}: TableData, cursor: string) => {
  446. const eventIds = data.map(value => value.id);
  447. const fetchOnlyMinidumps = !this.props.customColumns?.includes('attachments');
  448. const queries: string = [
  449. 'per_page=50',
  450. ...(fetchOnlyMinidumps ? ['types=event.minidump'] : []),
  451. ...eventIds.map(eventId => `event_id=${eventId}`),
  452. ].join('&');
  453. const res: IssueAttachment[] = await this.api.requestPromise(
  454. `/api/0/issues/${this.props.issueId}/attachments/?${queries}`
  455. );
  456. let hasMinidumps = false;
  457. res.forEach(attachment => {
  458. if (attachment.type === 'event.minidump') {
  459. hasMinidumps = true;
  460. }
  461. });
  462. this.setState({
  463. ...this.state,
  464. lastFetchedCursor: cursor,
  465. attachments: res,
  466. hasMinidumps,
  467. });
  468. };
  469. return (
  470. <div data-test-id="events-table">
  471. <DiscoverQuery
  472. eventView={totalEventsView}
  473. orgSlug={organization.slug}
  474. location={location}
  475. setError={error => setError(error?.message)}
  476. referrer="api.performance.transaction-summary"
  477. cursor="0:0:0"
  478. >
  479. {({isLoading: isTotalEventsLoading, tableData: table}) => {
  480. const totalEventsCount = table?.data[0]?.['count()'] ?? 0;
  481. return (
  482. <DiscoverQuery
  483. eventView={eventView}
  484. orgSlug={organization.slug}
  485. location={location}
  486. setError={error => setError(error?.message)}
  487. referrer={referrer || 'api.performance.transaction-events'}
  488. >
  489. {({pageLinks, isLoading: isDiscoverQueryLoading, tableData}) => {
  490. tableData ??= {data: []};
  491. const pageEventsCount = tableData?.data?.length ?? 0;
  492. const parsedPageLinks = parseLinkHeader(pageLinks);
  493. const cursor = parsedPageLinks?.next?.cursor;
  494. const shouldFetchAttachments: boolean =
  495. organization.features.includes('event-attachments') &&
  496. !!this.props.issueId &&
  497. !!cursor &&
  498. this.state.lastFetchedCursor !== cursor; // Only fetch on issue details page
  499. const paginationCaption =
  500. totalEventsCount && pageEventsCount
  501. ? tct('Showing [pageEventsCount] of [totalEventsCount] events', {
  502. pageEventsCount: pageEventsCount.toLocaleString(),
  503. totalEventsCount: totalEventsCount.toLocaleString(),
  504. })
  505. : undefined;
  506. if (cursor && shouldFetchAttachments) {
  507. fetchAttachments(tableData, cursor);
  508. }
  509. joinCustomData(tableData);
  510. return (
  511. <Fragment>
  512. <VisuallyCompleteWithData
  513. id="TransactionEvents-EventsTable"
  514. hasData={!!tableData?.data?.length}
  515. >
  516. {this.props.renderTableHeader
  517. ? this.props.renderTableHeader({
  518. isPending: isDiscoverQueryLoading,
  519. pageLinks,
  520. pageEventsCount,
  521. totalEventsCount,
  522. })
  523. : null}
  524. <GridEditable
  525. isLoading={
  526. isTotalEventsLoading ||
  527. isDiscoverQueryLoading ||
  528. shouldFetchAttachments ||
  529. isEventLoading
  530. }
  531. data={tableData?.data ?? []}
  532. columnOrder={columnOrder}
  533. columnSortBy={eventView.getSorts()}
  534. grid={{
  535. onResizeColumn: this.handleResizeColumn,
  536. renderHeadCell: this.renderHeadCellWithMeta(
  537. tableData?.meta
  538. ) as any,
  539. renderBodyCell: this.renderBodyCellWithData(tableData) as any,
  540. }}
  541. />
  542. </VisuallyCompleteWithData>
  543. {this.props.hidePagination ? null : (
  544. <Pagination
  545. disabled={isDiscoverQueryLoading}
  546. caption={paginationCaption}
  547. pageLinks={pageLinks}
  548. />
  549. )}
  550. </Fragment>
  551. );
  552. }}
  553. </DiscoverQuery>
  554. );
  555. }}
  556. </DiscoverQuery>
  557. </div>
  558. );
  559. }
  560. }
  561. const StyledIconQuestion = styled(QuestionTooltip)`
  562. position: relative;
  563. top: 1px;
  564. left: 4px;
  565. `;
  566. export default EventsTable;