index.tsx 9.9 KB

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