genericDiscoverQuery.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import {Component, useContext} from 'react';
  2. import {useQuery} from '@tanstack/react-query';
  3. import type {Location} from 'history';
  4. import type {EventQuery} from 'sentry/actionCreators/events';
  5. import type {ResponseMeta} from 'sentry/api';
  6. import {Client} from 'sentry/api';
  7. import {t} from 'sentry/locale';
  8. import type {ImmutableEventView, LocationQuery} from 'sentry/utils/discover/eventView';
  9. import type EventView from 'sentry/utils/discover/eventView';
  10. import {isAPIPayloadSimilar} from 'sentry/utils/discover/eventView';
  11. import type {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';
  12. import {PerformanceEventViewContext} from 'sentry/utils/performance/contexts/performanceEventViewContext';
  13. import useApi from '../useApi';
  14. import useOrganization from '../useOrganization';
  15. export interface DiscoverQueryExtras {
  16. useOnDemandMetrics?: boolean;
  17. }
  18. interface _DiscoverQueryExtras {
  19. queryExtras?: DiscoverQueryExtras;
  20. }
  21. export class QueryError extends Error {
  22. message: string;
  23. private originalError: any; // For debugging in case parseError picks a value that doesn't make sense.
  24. constructor(errorMessage: string, originalError?: any) {
  25. super(errorMessage);
  26. this.message = errorMessage;
  27. this.originalError = originalError;
  28. }
  29. getOriginalError() {
  30. return this.originalError;
  31. }
  32. }
  33. export type GenericChildrenProps<T> = {
  34. /**
  35. * Error, if not null.
  36. */
  37. error: null | QueryError;
  38. /**
  39. * Loading state of this query.
  40. */
  41. isLoading: boolean;
  42. /**
  43. * Pagelinks, if applicable. Can be provided to the Pagination component.
  44. */
  45. pageLinks: null | string;
  46. /**
  47. * Data / result.
  48. */
  49. tableData: T | null;
  50. };
  51. type OptionalContextProps = {
  52. eventView?: EventView | ImmutableEventView;
  53. orgSlug?: string;
  54. };
  55. type BaseDiscoverQueryProps = {
  56. /**
  57. * Used as the default source for cursor values.
  58. */
  59. location: Location;
  60. /**
  61. * Explicit cursor value if you aren't using `location.query.cursor` because there are
  62. * multiple paginated results on the page.
  63. */
  64. cursor?: string;
  65. /**
  66. * Appends a raw string to query to be able to sidestep the tokenizer.
  67. * @deprecated
  68. */
  69. forceAppendRawQueryString?: string;
  70. /**
  71. * Record limit to get.
  72. */
  73. limit?: number;
  74. /**
  75. * Include this whenever pagination won't be used. Limit can still be used when this is
  76. * passed, but cursor will be ignored.
  77. */
  78. noPagination?: boolean;
  79. options?: Omit<Parameters<typeof useQuery>[2], 'initialData'>;
  80. /**
  81. * A container for query batching data and functions.
  82. */
  83. queryBatching?: QueryBatching;
  84. /**
  85. * Extra query parameters to be added.
  86. */
  87. queryExtras?: Record<string, string | string[] | undefined>;
  88. /**
  89. * Sets referrer parameter in the API Payload. Set of allowed referrers are defined
  90. * on the OrganizationDiscoverEndpoint view.
  91. */
  92. referrer?: string;
  93. /**
  94. * A callback to set an error so that the error can be rendered in parent components
  95. */
  96. setError?: (errObject: QueryError | undefined) => void;
  97. /**
  98. * A flag to skip aborting the request when api.clear() is called, which happens
  99. * frequently on component unmounts.
  100. */
  101. skipAbort?: boolean;
  102. };
  103. export type DiscoverQueryPropsWithContext = BaseDiscoverQueryProps & OptionalContextProps;
  104. export type DiscoverQueryProps = BaseDiscoverQueryProps & {
  105. eventView: EventView | ImmutableEventView;
  106. orgSlug: string;
  107. };
  108. type InnerRequestProps<P> = DiscoverQueryProps & P;
  109. type OuterRequestProps<P> = DiscoverQueryPropsWithContext & P;
  110. export type ReactProps<T> = {
  111. children?: (props: GenericChildrenProps<T>) => React.ReactNode;
  112. };
  113. type ComponentProps<T, P> = {
  114. /**
  115. * Route to the endpoint
  116. */
  117. route: string;
  118. /**
  119. * A hook to modify data into the correct output after data has been received
  120. */
  121. afterFetch?: (data: any, props?: Props<T, P>) => T;
  122. /**
  123. * A hook before fetch that can be used to do things like clearing the api
  124. */
  125. beforeFetch?: (api: Client) => void;
  126. /**
  127. * A hook for parent orchestrators to pass down data based on query results, unlike afterFetch it is not meant for specializations as it will not modify data.
  128. */
  129. didFetch?: (data: T) => void;
  130. /**
  131. * Allows components to modify the payload before it is set.
  132. */
  133. getRequestPayload?: (props: Props<T, P>) => any;
  134. options?: Omit<Parameters<typeof useQuery>[2], 'initialData'>;
  135. /**
  136. * An external hook to parse errors in case there are differences for a specific api.
  137. */
  138. parseError?: (error: any) => QueryError | null;
  139. /**
  140. * An external hook in addition to the event view check to check if data should be refetched
  141. */
  142. shouldRefetchData?: (prevProps: Props<T, P>, props: Props<T, P>) => boolean;
  143. };
  144. type Props<T, P> = InnerRequestProps<P> & ReactProps<T> & ComponentProps<T, P>;
  145. type OuterProps<T, P> = OuterRequestProps<P> & ReactProps<T> & ComponentProps<T, P>;
  146. type State<T> = {
  147. api: Client;
  148. tableFetchID: symbol | undefined;
  149. } & GenericChildrenProps<T>;
  150. /**
  151. * Generic component for discover queries
  152. */
  153. class _GenericDiscoverQuery<T, P> extends Component<Props<T, P>, State<T>> {
  154. state: State<T> = {
  155. isLoading: true,
  156. tableFetchID: undefined,
  157. error: null,
  158. tableData: null,
  159. pageLinks: null,
  160. api: new Client(),
  161. };
  162. componentDidMount() {
  163. this.fetchData();
  164. }
  165. componentDidUpdate(prevProps: Props<T, P>) {
  166. // Reload data if the payload changes
  167. const refetchCondition = this._shouldRefetchData(prevProps);
  168. // or if we've moved from an invalid view state to a valid one,
  169. const eventViewValidation =
  170. prevProps.eventView.isValid() === false && this.props.eventView.isValid();
  171. const shouldRefetchExternal = this.props.shouldRefetchData
  172. ? this.props.shouldRefetchData(prevProps, this.props)
  173. : false;
  174. if (refetchCondition || eventViewValidation || shouldRefetchExternal) {
  175. this.fetchData();
  176. }
  177. }
  178. _shouldRefetchData = (prevProps: Props<T, P>): boolean => {
  179. const thisAPIPayload = getPayload(this.props);
  180. const otherAPIPayload = getPayload(prevProps);
  181. return (
  182. !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload) ||
  183. prevProps.limit !== this.props.limit ||
  184. prevProps.route !== this.props.route ||
  185. prevProps.cursor !== this.props.cursor
  186. );
  187. };
  188. /**
  189. * The error type isn't consistent across APIs. We see detail as just string some times, other times as an object.
  190. */
  191. _parseError = (error: any): QueryError | null => {
  192. if (this.props.parseError) {
  193. return this.props.parseError(error);
  194. }
  195. return parseError(error);
  196. };
  197. fetchData = async () => {
  198. const {
  199. queryBatching,
  200. beforeFetch,
  201. afterFetch,
  202. didFetch,
  203. eventView,
  204. orgSlug,
  205. route,
  206. setError,
  207. } = this.props;
  208. const {api} = this.state;
  209. if (!eventView.isValid()) {
  210. return;
  211. }
  212. const url = `/organizations/${orgSlug}/${route}/`;
  213. const tableFetchID = Symbol(`tableFetchID`);
  214. const apiPayload: Partial<EventQuery & LocationQuery> = getPayload(this.props);
  215. this.setState({isLoading: true, tableFetchID});
  216. setError?.(undefined);
  217. beforeFetch?.(api);
  218. // clear any inflight requests since they are now stale
  219. api.clear();
  220. try {
  221. const [data, , resp] = await doDiscoverQuery<T>(api, url, apiPayload, {
  222. queryBatching,
  223. });
  224. if (this.state.tableFetchID !== tableFetchID) {
  225. // invariant: a different request was initiated after this request
  226. return;
  227. }
  228. const tableData = afterFetch ? afterFetch(data, this.props) : data;
  229. didFetch?.(tableData);
  230. this.setState(prevState => ({
  231. isLoading: false,
  232. tableFetchID: undefined,
  233. error: null,
  234. pageLinks: resp?.getResponseHeader('Link') ?? prevState.pageLinks,
  235. tableData,
  236. }));
  237. } catch (err) {
  238. const error = this._parseError(err);
  239. this.setState({
  240. isLoading: false,
  241. tableFetchID: undefined,
  242. error,
  243. tableData: null,
  244. });
  245. if (setError) {
  246. setError(error ?? undefined);
  247. }
  248. }
  249. };
  250. render() {
  251. const {isLoading, error, tableData, pageLinks} = this.state;
  252. const childrenProps: GenericChildrenProps<T> = {
  253. isLoading,
  254. error,
  255. tableData,
  256. pageLinks,
  257. };
  258. const children: ReactProps<T>['children'] = this.props.children; // Explicitly setting type due to issues with generics and React's children
  259. return children?.(childrenProps);
  260. }
  261. }
  262. // Shim to allow us to use generic discover query or any specialization with or without passing org slug or eventview, which are now contexts.
  263. // This will help keep tests working and we can remove extra uses of context-provided props and update tests as we go.
  264. export function GenericDiscoverQuery<T, P>(props: OuterProps<T, P>) {
  265. const organizationSlug = useOrganization({allowNull: true})?.slug;
  266. const performanceEventView = useContext(PerformanceEventViewContext)?.eventView;
  267. const orgSlug = props.orgSlug ?? organizationSlug;
  268. const eventView = props.eventView ?? performanceEventView;
  269. if (orgSlug === undefined || eventView === undefined) {
  270. throw new Error('GenericDiscoverQuery requires both an orgSlug and eventView');
  271. }
  272. const _props: Props<T, P> = {
  273. ...props,
  274. orgSlug,
  275. eventView,
  276. };
  277. return <_GenericDiscoverQuery<T, P> {..._props} />;
  278. }
  279. export type DiscoverQueryRequestParams = Partial<
  280. EventQuery & LocationQuery & _DiscoverQueryExtras
  281. >;
  282. type RetryOptions = {
  283. statusCodes: number[];
  284. tries: number;
  285. baseTimeout?: number;
  286. timeoutMultiplier?: number;
  287. };
  288. const BASE_TIMEOUT = 200;
  289. const TIMEOUT_MULTIPLIER = 2;
  290. const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
  291. export async function doDiscoverQuery<T>(
  292. api: Client,
  293. url: string,
  294. params: DiscoverQueryRequestParams,
  295. options: {
  296. queryBatching?: QueryBatching;
  297. retry?: RetryOptions;
  298. skipAbort?: boolean;
  299. } = {}
  300. ): Promise<[T, string | undefined, ResponseMeta<T> | undefined]> {
  301. const {queryBatching, retry, skipAbort} = options;
  302. if (queryBatching?.batchRequest) {
  303. return queryBatching.batchRequest(api, url, {
  304. query: params,
  305. includeAllArgs: true,
  306. });
  307. }
  308. const baseTimeout = retry?.baseTimeout ?? BASE_TIMEOUT;
  309. const timeoutMultiplier = retry?.timeoutMultiplier ?? TIMEOUT_MULTIPLIER;
  310. const statusCodes = retry?.statusCodes ?? [];
  311. const maxTries = retry?.tries ?? 1;
  312. let tries = 0;
  313. let timeout = 0;
  314. let error;
  315. while (tries < maxTries && (!error || statusCodes.includes(error.status))) {
  316. if (timeout > 0) {
  317. await wait(timeout);
  318. }
  319. try {
  320. tries++;
  321. return await api.requestPromise(url, {
  322. method: 'GET',
  323. includeAllArgs: true,
  324. query: {
  325. // marking params as any so as to not cause typescript errors
  326. ...(params as any),
  327. },
  328. skipAbort,
  329. });
  330. } catch (err) {
  331. error = err;
  332. timeout = baseTimeout * timeoutMultiplier ** (tries - 1);
  333. }
  334. }
  335. throw error;
  336. }
  337. function getPayload<T, P>(props: Props<T, P>) {
  338. const {
  339. cursor,
  340. limit,
  341. noPagination,
  342. referrer,
  343. getRequestPayload,
  344. eventView,
  345. location,
  346. forceAppendRawQueryString,
  347. } = props;
  348. const payload = getRequestPayload
  349. ? getRequestPayload(props)
  350. : eventView.getEventsAPIPayload(location, forceAppendRawQueryString);
  351. if (cursor !== undefined) {
  352. payload.cursor = cursor;
  353. }
  354. if (limit) {
  355. payload.per_page = limit;
  356. }
  357. if (noPagination) {
  358. payload.noPagination = noPagination;
  359. }
  360. if (referrer) {
  361. payload.referrer = referrer;
  362. }
  363. Object.assign(payload, props.queryExtras ?? {});
  364. return payload;
  365. }
  366. export function useGenericDiscoverQuery<T, P>(props: Props<T, P>) {
  367. const api = useApi();
  368. const {orgSlug, route, options} = props;
  369. const url = `/organizations/${orgSlug}/${route}/`;
  370. const apiPayload = getPayload<T, P>(props);
  371. const res = useQuery<[T, string | undefined, ResponseMeta<T> | undefined], QueryError>(
  372. [route, apiPayload],
  373. ({signal: _signal}) =>
  374. doDiscoverQuery<T>(api, url, apiPayload, {
  375. queryBatching: props.queryBatching,
  376. skipAbort: props.skipAbort,
  377. }),
  378. options
  379. );
  380. return {
  381. ...res,
  382. data: res.data?.[0] ?? undefined,
  383. error: parseError(res.error),
  384. statusCode: res.data?.[1] ?? undefined,
  385. response: res.data?.[2] ?? undefined,
  386. };
  387. }
  388. const parseError = (error: any): QueryError | null => {
  389. if (!error) {
  390. return null;
  391. }
  392. const detail = error.responseJSON?.detail;
  393. if (typeof detail === 'string') {
  394. return new QueryError(detail, error);
  395. }
  396. const message = detail?.message;
  397. if (typeof message === 'string') {
  398. return new QueryError(message, error);
  399. }
  400. return new QueryError(t('An unknown error occurred.'), error);
  401. };
  402. export default GenericDiscoverQuery;