index.tsx 9.2 KB

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