genericDiscoverQuery.tsx 12 KB

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