index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import {PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import type {EventQuery} from 'sentry/actionCreators/events';
  5. import type {Client} from 'sentry/api';
  6. import ErrorBoundary from 'sentry/components/errorBoundary';
  7. import type {CursorHandler} from 'sentry/components/pagination';
  8. import Pagination from 'sentry/components/pagination';
  9. import {t} from 'sentry/locale';
  10. import type {Organization} from 'sentry/types/organization';
  11. import {metric, trackAnalytics} from 'sentry/utils/analytics';
  12. import {CustomMeasurementsContext} from 'sentry/utils/customMeasurements/customMeasurementsContext';
  13. import type {TableData} from 'sentry/utils/discover/discoverQuery';
  14. import type {LocationQuery} from 'sentry/utils/discover/eventView';
  15. import type EventView from 'sentry/utils/discover/eventView';
  16. import {isAPIPayloadSimilar, isFieldsSimilar} from 'sentry/utils/discover/eventView';
  17. import {SPAN_OP_BREAKDOWN_FIELDS} from 'sentry/utils/discover/fields';
  18. import type {DiscoverDatasets, SavedQueryDatasets} from 'sentry/utils/discover/types';
  19. import Measurements from 'sentry/utils/measurements/measurements';
  20. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  21. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  22. import withApi from 'sentry/utils/withApi';
  23. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  24. import TableView from './tableView';
  25. type TableProps = {
  26. api: Client;
  27. confirmedQuery: boolean;
  28. eventView: EventView;
  29. location: Location;
  30. onChangeShowTags: () => void;
  31. onCursor: CursorHandler;
  32. organization: Organization;
  33. setError: (msg: string, code: number) => void;
  34. showTags: boolean;
  35. title: string;
  36. dataset?: DiscoverDatasets;
  37. isHomepage?: boolean;
  38. queryDataset?: SavedQueryDatasets;
  39. setSplitDecision?: (value: SavedQueryDatasets) => void;
  40. setTips?: (tips: string[]) => void;
  41. };
  42. type TableState = {
  43. error: null | string;
  44. isLoading: boolean;
  45. pageLinks: null | string;
  46. prevView: null | EventView;
  47. tableData: TableData | null | undefined;
  48. tableFetchID: symbol | undefined;
  49. };
  50. /**
  51. * `Table` is a container element that handles 2 things
  52. * 1. Fetch data from source
  53. * 2. Handle pagination of data
  54. *
  55. * It will pass the data it fetched to `TableView`, where the state of the
  56. * Table is maintained and controlled
  57. */
  58. class Table extends PureComponent<TableProps, TableState> {
  59. static getDerivedStateFromProps(
  60. nextProps: Readonly<TableProps>,
  61. prevState: TableState
  62. ): TableState {
  63. // Force loading state to be true if certain eventView props change.
  64. // This is because this (and the TableView) component rerenders before
  65. // loading state is set. In some cases, eventView changes such that
  66. // the custom field renderer for event id expects trace id (and throws
  67. // an error if trace id doesn't exist) but the table data isn't refetched
  68. // and loading state isn't set yet. This results in the componen crashing.
  69. const nextEventView = nextProps.eventView;
  70. const prevEventView = prevState.prevView;
  71. if (
  72. prevEventView &&
  73. (!isFieldsSimilar(
  74. nextEventView.fields.map(f => f.field),
  75. prevEventView.fields.map(f => f.field)
  76. ) ||
  77. nextEventView.dataset !== prevEventView.dataset)
  78. ) {
  79. return {...prevState, isLoading: true};
  80. }
  81. return prevState;
  82. }
  83. state: TableState = {
  84. isLoading: true,
  85. tableFetchID: undefined,
  86. error: null,
  87. pageLinks: null,
  88. tableData: null,
  89. prevView: null,
  90. };
  91. componentDidMount() {
  92. this.fetchData();
  93. }
  94. componentDidUpdate(prevProps: TableProps) {
  95. // Reload data if we aren't already loading, or if we've moved
  96. // from an invalid view state to a valid one.
  97. if (
  98. (!this.state.isLoading && this.shouldRefetchData(prevProps)) ||
  99. (prevProps.eventView.isValid() === false && this.props.eventView.isValid()) ||
  100. (prevProps.confirmedQuery !== this.props.confirmedQuery && this.didViewChange())
  101. ) {
  102. this.fetchData();
  103. }
  104. }
  105. didViewChange = (): boolean => {
  106. const {prevView} = this.state;
  107. const thisAPIPayload = this.props.eventView.getEventsAPIPayload(this.props.location);
  108. if (prevView === null) {
  109. return true;
  110. }
  111. const otherAPIPayload = prevView.getEventsAPIPayload(this.props.location);
  112. return !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload);
  113. };
  114. shouldRefetchData = (prevProps: TableProps): boolean => {
  115. const thisAPIPayload = this.props.eventView.getEventsAPIPayload(this.props.location);
  116. const otherAPIPayload = prevProps.eventView.getEventsAPIPayload(prevProps.location);
  117. return !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload);
  118. };
  119. fetchData = () => {
  120. const {
  121. eventView,
  122. organization,
  123. location,
  124. setError,
  125. confirmedQuery,
  126. setTips,
  127. setSplitDecision,
  128. } = this.props;
  129. if (!eventView.isValid() || !confirmedQuery) {
  130. return;
  131. }
  132. this.setState({prevView: eventView, isLoading: true});
  133. // note: If the eventView has no aggregates, the endpoint will automatically add the event id in
  134. // the API payload response
  135. const url = `/organizations/${organization.slug}/events/`;
  136. const tableFetchID = Symbol('tableFetchID');
  137. const apiPayload = eventView.getEventsAPIPayload(location) as LocationQuery &
  138. EventQuery;
  139. // We are now routing to the trace view on clicking event ids. Therefore, we need the trace slug associated to the event id.
  140. // Note: Event ID or 'id' is added to the fields in the API payload response by default for all non-aggregate queries.
  141. if (!eventView.hasAggregateField() || apiPayload.field.includes('id')) {
  142. apiPayload.field.push('trace');
  143. // We need to include the event.type field because we want to
  144. // route to issue details for error and default event types.
  145. if (!hasDatasetSelector(organization)) {
  146. apiPayload.field.push('event.type');
  147. }
  148. }
  149. // To generate the target url for TRACE ID and EVENT ID links we always include a timestamp,
  150. // to speed up the trace endpoint. Adding timestamp for the non-aggregate case and
  151. // max(timestamp) for the aggregate case as fields, to accomodate this.
  152. if (
  153. eventView.hasAggregateField() &&
  154. apiPayload.field.includes('trace') &&
  155. !apiPayload.field.includes('max(timestamp)') &&
  156. !apiPayload.field.includes('timestamp')
  157. ) {
  158. apiPayload.field.push('max(timestamp)');
  159. } else if (
  160. apiPayload.field.includes('trace') &&
  161. !apiPayload.field.includes('timestamp')
  162. ) {
  163. apiPayload.field.push('timestamp');
  164. }
  165. if (hasDatasetSelector(organization) && eventView.id) {
  166. apiPayload.discoverSavedQueryId = eventView.id;
  167. }
  168. apiPayload.referrer = 'api.discover.query-table';
  169. setError('', 200);
  170. this.setState({isLoading: true, tableFetchID});
  171. metric.mark({name: `discover-events-start-${apiPayload.query}`});
  172. this.props.api.clear();
  173. this.props.api
  174. .requestPromise(url, {
  175. method: 'GET',
  176. includeAllArgs: true,
  177. query: apiPayload,
  178. })
  179. .then(([data, _, resp]) => {
  180. // We want to measure this metric regardless of whether we use the result
  181. metric.measure({
  182. name: 'app.api.discover-query',
  183. start: `discover-events-start-${apiPayload.query}`,
  184. data: {
  185. status: resp?.status,
  186. },
  187. });
  188. if (this.state.tableFetchID !== tableFetchID) {
  189. // invariant: a different request was initiated after this request
  190. return;
  191. }
  192. const {fields, ...nonFieldsMeta} = data.meta ?? {};
  193. // events api uses a different response format so we need to construct tableData differently
  194. const tableData = {
  195. ...data,
  196. meta: {...fields, ...nonFieldsMeta, fields},
  197. };
  198. trackAnalytics('discover_search.success', {
  199. has_results: tableData.data.length > 0,
  200. organization: this.props.organization,
  201. search_type: 'events',
  202. search_source: 'discover_search',
  203. });
  204. this.setState(prevState => ({
  205. isLoading: false,
  206. tableFetchID: undefined,
  207. error: null,
  208. pageLinks: resp ? resp.getResponseHeader('Link') : prevState.pageLinks,
  209. tableData,
  210. }));
  211. const tips: string[] = [];
  212. const {query, columns} = tableData?.meta?.tips ?? {};
  213. if (query) {
  214. tips.push(query);
  215. }
  216. if (columns) {
  217. tips.push(columns);
  218. }
  219. setTips?.(tips);
  220. const splitDecision = tableData?.meta?.discoverSplitDecision;
  221. if (splitDecision) {
  222. setSplitDecision?.(splitDecision);
  223. }
  224. })
  225. .catch(err => {
  226. metric.measure({
  227. name: 'app.api.discover-query',
  228. start: `discover-events-start-${apiPayload.query}`,
  229. data: {
  230. status: err.status,
  231. },
  232. });
  233. const message = err?.responseJSON?.detail || t('An unknown error occurred.');
  234. this.setState({
  235. isLoading: false,
  236. tableFetchID: undefined,
  237. error: message,
  238. pageLinks: null,
  239. tableData: null,
  240. });
  241. trackAnalytics('discover_search.failed', {
  242. organization: this.props.organization,
  243. search_type: 'events',
  244. search_source: 'discover_search',
  245. error: message,
  246. });
  247. setError(message, err.status);
  248. });
  249. };
  250. render() {
  251. const {eventView, onCursor, dataset, queryDataset} = this.props;
  252. const {pageLinks, tableData, isLoading, error} = this.state;
  253. const isFirstPage = pageLinks
  254. ? parseLinkHeader(pageLinks).previous.results === false
  255. : false;
  256. return (
  257. <Container>
  258. <Measurements>
  259. {({measurements}) => {
  260. const measurementKeys = Object.values(measurements).map(({key}) => key);
  261. return (
  262. <CustomMeasurementsContext.Consumer>
  263. {contextValue => (
  264. <VisuallyCompleteWithData
  265. id="Discover-Table"
  266. hasData={(tableData?.data?.length ?? 0) > 0}
  267. isLoading={isLoading}
  268. >
  269. <ErrorBoundary>
  270. <TableView
  271. {...this.props}
  272. isLoading={isLoading}
  273. isFirstPage={isFirstPage}
  274. error={error}
  275. eventView={eventView}
  276. tableData={tableData}
  277. measurementKeys={measurementKeys}
  278. spanOperationBreakdownKeys={SPAN_OP_BREAKDOWN_FIELDS}
  279. customMeasurements={contextValue?.customMeasurements ?? undefined}
  280. dataset={dataset}
  281. queryDataset={queryDataset}
  282. />
  283. </ErrorBoundary>
  284. </VisuallyCompleteWithData>
  285. )}
  286. </CustomMeasurementsContext.Consumer>
  287. );
  288. }}
  289. </Measurements>
  290. <Pagination pageLinks={pageLinks} onCursor={onCursor} />
  291. </Container>
  292. );
  293. }
  294. }
  295. export default withApi(Table);
  296. const Container = styled('div')`
  297. min-width: 0;
  298. `;