genericWidgetQueries.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import {Component} from 'react';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import isEqual from 'lodash/isEqual';
  4. import {Client, ResponseMeta} from 'sentry/api';
  5. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  6. import {t} from 'sentry/locale';
  7. import {Organization, PageFilters} from 'sentry/types';
  8. import {Series} from 'sentry/types/echarts';
  9. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  10. import {AggregationOutputType} from 'sentry/utils/discover/fields';
  11. import {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  12. import {DatasetConfig} from '../datasetConfig/base';
  13. import {
  14. DashboardFilters,
  15. DEFAULT_TABLE_LIMIT,
  16. DisplayType,
  17. Widget,
  18. WidgetQuery,
  19. } from '../types';
  20. function getReferrer(displayType: DisplayType) {
  21. let referrer: string = '';
  22. if (displayType === DisplayType.TABLE) {
  23. referrer = 'api.dashboards.tablewidget';
  24. } else if (displayType === DisplayType.BIG_NUMBER) {
  25. referrer = 'api.dashboards.bignumberwidget';
  26. } else if (displayType === DisplayType.WORLD_MAP) {
  27. referrer = 'api.dashboards.worldmapwidget';
  28. } else {
  29. referrer = `api.dashboards.widget.${displayType}-chart`;
  30. }
  31. return referrer;
  32. }
  33. export type OnDataFetchedProps = {
  34. pageLinks?: string;
  35. tableResults?: TableDataWithTitle[];
  36. timeseriesResults?: Series[];
  37. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  38. totalIssuesCount?: string;
  39. };
  40. export type GenericWidgetQueriesChildrenProps = {
  41. loading: boolean;
  42. errorMessage?: string;
  43. pageLinks?: string;
  44. tableResults?: TableDataWithTitle[];
  45. timeseriesResults?: Series[];
  46. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  47. totalCount?: string;
  48. };
  49. export type GenericWidgetQueriesProps<SeriesResponse, TableResponse> = {
  50. api: Client;
  51. children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
  52. config: DatasetConfig<SeriesResponse, TableResponse>;
  53. organization: Organization;
  54. selection: PageFilters;
  55. widget: Widget;
  56. afterFetchSeriesData?: (result: SeriesResponse) => void;
  57. afterFetchTableData?: (
  58. result: TableResponse,
  59. response?: ResponseMeta
  60. ) => void | {totalIssuesCount?: string};
  61. cursor?: string;
  62. customDidUpdateComparator?: (
  63. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  64. nextProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  65. ) => boolean;
  66. dashboardFilters?: DashboardFilters;
  67. limit?: number;
  68. loading?: boolean;
  69. mepSetting?: MEPState | null;
  70. onDataFetched?: ({
  71. tableResults,
  72. timeseriesResults,
  73. totalIssuesCount,
  74. pageLinks,
  75. timeseriesResultsTypes,
  76. }: OnDataFetchedProps) => void;
  77. };
  78. type State<SeriesResponse> = {
  79. loading: boolean;
  80. errorMessage?: GenericWidgetQueriesChildrenProps['errorMessage'];
  81. pageLinks?: GenericWidgetQueriesChildrenProps['pageLinks'];
  82. queryFetchID?: symbol;
  83. rawResults?: SeriesResponse[];
  84. tableResults?: GenericWidgetQueriesChildrenProps['tableResults'];
  85. timeseriesResults?: GenericWidgetQueriesChildrenProps['timeseriesResults'];
  86. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  87. };
  88. class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
  89. GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  90. State<SeriesResponse>
  91. > {
  92. state: State<SeriesResponse> = {
  93. loading: true,
  94. queryFetchID: undefined,
  95. errorMessage: undefined,
  96. timeseriesResults: undefined,
  97. rawResults: undefined,
  98. tableResults: undefined,
  99. pageLinks: undefined,
  100. timeseriesResultsTypes: undefined,
  101. };
  102. componentDidMount() {
  103. this._isMounted = true;
  104. if (!this.props.loading) {
  105. this.fetchData();
  106. }
  107. }
  108. componentDidUpdate(
  109. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  110. ) {
  111. const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
  112. this.props;
  113. // We do not fetch data whenever the query name changes.
  114. // Also don't count empty fields when checking for field changes
  115. const [prevWidgetQueryNames, prevWidgetQueries] = prevProps.widget.queries
  116. .map((query: WidgetQuery) => {
  117. query.aggregates = query.aggregates.filter(field => !!field);
  118. query.columns = query.columns.filter(field => !!field);
  119. return query;
  120. })
  121. .reduce(
  122. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  123. names.push(name);
  124. queries.push(rest);
  125. return [names, queries];
  126. },
  127. [[], []]
  128. );
  129. const [widgetQueryNames, widgetQueries] = widget.queries
  130. .map((query: WidgetQuery) => {
  131. query.aggregates = query.aggregates.filter(
  132. field => !!field && field !== 'equation|'
  133. );
  134. query.columns = query.columns.filter(field => !!field && field !== 'equation|');
  135. return query;
  136. })
  137. .reduce(
  138. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  139. names.push(name);
  140. queries.push(rest);
  141. return [names, queries];
  142. },
  143. [[], []]
  144. );
  145. if (
  146. customDidUpdateComparator
  147. ? customDidUpdateComparator(prevProps, this.props)
  148. : widget.limit !== prevProps.widget.limit ||
  149. !isEqual(widget.displayType, prevProps.widget.displayType) ||
  150. !isEqual(widget.interval, prevProps.widget.interval) ||
  151. !isEqual(widgetQueries, prevWidgetQueries) ||
  152. !isEqual(this.props.dashboardFilters, prevProps.dashboardFilters) ||
  153. !isSelectionEqual(selection, prevProps.selection) ||
  154. cursor !== prevProps.cursor
  155. ) {
  156. this.fetchData();
  157. return;
  158. }
  159. if (
  160. !this.state.loading &&
  161. !isEqual(prevWidgetQueryNames, widgetQueryNames) &&
  162. this.state.rawResults?.length === widget.queries.length
  163. ) {
  164. // If the query names has changed, then update timeseries labels
  165. // eslint-disable-next-line react/no-did-update-set-state
  166. this.setState(prevState => {
  167. const timeseriesResults = widget.queries.reduce((acc: Series[], query, index) => {
  168. return acc.concat(
  169. config.transformSeries!(prevState.rawResults![index], query, organization)
  170. );
  171. }, []);
  172. return {...prevState, timeseriesResults};
  173. });
  174. }
  175. }
  176. componentWillUnmount() {
  177. this._isMounted = false;
  178. }
  179. private _isMounted: boolean = false;
  180. applyDashboardFilters(widget: Widget): Widget {
  181. const {dashboardFilters} = this.props;
  182. let dashboardFilterConditions = '';
  183. if (dashboardFilters) {
  184. for (const [key, activeFilters] of Object.entries(dashboardFilters)) {
  185. if (activeFilters.length === 1) {
  186. dashboardFilterConditions += `${key}:${activeFilters[0]} `;
  187. } else if (activeFilters.length > 1) {
  188. dashboardFilterConditions += `${key}:[${activeFilters.join(',')}] `;
  189. }
  190. }
  191. }
  192. widget.queries.forEach(query => {
  193. query.conditions =
  194. query.conditions +
  195. (dashboardFilterConditions === '' ? '' : ` ${dashboardFilterConditions}`);
  196. });
  197. return widget;
  198. }
  199. async fetchTableData(queryFetchID: symbol) {
  200. const {
  201. widget: originalWidget,
  202. limit,
  203. config,
  204. api,
  205. organization,
  206. selection,
  207. cursor,
  208. afterFetchTableData,
  209. onDataFetched,
  210. mepSetting,
  211. } = this.props;
  212. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  213. const responses = await Promise.all(
  214. widget.queries.map(query => {
  215. let requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
  216. let requestCreator = config.getTableRequest;
  217. if (widget.displayType === DisplayType.WORLD_MAP) {
  218. requestLimit = undefined;
  219. requestCreator = config.getWorldMapRequest;
  220. }
  221. if (!requestCreator) {
  222. throw new Error(
  223. t('This display type is not supported by the selected dataset.')
  224. );
  225. }
  226. return requestCreator(
  227. api,
  228. query,
  229. organization,
  230. selection,
  231. requestLimit,
  232. cursor,
  233. getReferrer(widget.displayType),
  234. mepSetting
  235. );
  236. })
  237. );
  238. let transformedTableResults: TableDataWithTitle[] = [];
  239. let responsePageLinks: string | undefined;
  240. let afterTableFetchData: OnDataFetchedProps | undefined;
  241. responses.forEach(([data, _textstatus, resp], i) => {
  242. afterTableFetchData = afterFetchTableData?.(data, resp) ?? {};
  243. // Cast so we can add the title.
  244. const transformedData = config.transformTable(
  245. data,
  246. widget.queries[0],
  247. organization,
  248. selection
  249. ) as TableDataWithTitle;
  250. transformedData.title = widget.queries[i]?.name ?? '';
  251. // Overwrite the local var to work around state being stale in tests.
  252. transformedTableResults = [...transformedTableResults, transformedData];
  253. // There is some inconsistency with the capitalization of "link" in response headers
  254. responsePageLinks =
  255. (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
  256. });
  257. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  258. onDataFetched?.({
  259. tableResults: transformedTableResults,
  260. pageLinks: responsePageLinks,
  261. ...afterTableFetchData,
  262. });
  263. this.setState({
  264. tableResults: transformedTableResults,
  265. pageLinks: responsePageLinks,
  266. });
  267. }
  268. }
  269. async fetchSeriesData(queryFetchID: symbol) {
  270. const {
  271. widget: originalWidget,
  272. config,
  273. api,
  274. organization,
  275. selection,
  276. afterFetchSeriesData,
  277. onDataFetched,
  278. mepSetting,
  279. } = this.props;
  280. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  281. const responses = await Promise.all(
  282. widget.queries.map((_query, index) => {
  283. return config.getSeriesRequest!(
  284. api,
  285. widget,
  286. index,
  287. organization,
  288. selection,
  289. getReferrer(widget.displayType),
  290. mepSetting
  291. );
  292. })
  293. );
  294. const rawResultsClone = cloneDeep(this.state.rawResults) ?? [];
  295. const transformedTimeseriesResults: Series[] = [];
  296. responses.forEach(([data], requestIndex) => {
  297. afterFetchSeriesData?.(data);
  298. rawResultsClone[requestIndex] = data;
  299. const transformedResult = config.transformSeries!(
  300. data,
  301. widget.queries[requestIndex],
  302. organization
  303. );
  304. // When charting timeseriesData on echarts, color association to a timeseries result
  305. // is order sensitive, ie series at index i on the timeseries array will use color at
  306. // index i on the color array. This means that on multi series results, we need to make
  307. // sure that the order of series in our results do not change between fetches to avoid
  308. // coloring inconsistencies between renders.
  309. transformedResult.forEach((result, resultIndex) => {
  310. transformedTimeseriesResults[
  311. requestIndex * transformedResult.length + resultIndex
  312. ] = result;
  313. });
  314. });
  315. // Get series result type
  316. // Only used by custom measurements in errorsAndTransactions at the moment
  317. const timeseriesResultsTypes = config.getSeriesResultType?.(
  318. responses[0][0],
  319. widget.queries[0]
  320. );
  321. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  322. onDataFetched?.({
  323. timeseriesResults: transformedTimeseriesResults,
  324. timeseriesResultsTypes,
  325. });
  326. this.setState({
  327. timeseriesResults: transformedTimeseriesResults,
  328. rawResults: rawResultsClone,
  329. timeseriesResultsTypes,
  330. });
  331. }
  332. }
  333. async fetchData() {
  334. const {widget} = this.props;
  335. const queryFetchID = Symbol('queryFetchID');
  336. this.setState({
  337. loading: true,
  338. tableResults: undefined,
  339. timeseriesResults: undefined,
  340. errorMessage: undefined,
  341. queryFetchID,
  342. });
  343. try {
  344. if (
  345. [DisplayType.TABLE, DisplayType.BIG_NUMBER, DisplayType.WORLD_MAP].includes(
  346. widget.displayType
  347. )
  348. ) {
  349. await this.fetchTableData(queryFetchID);
  350. } else {
  351. await this.fetchSeriesData(queryFetchID);
  352. }
  353. } catch (err) {
  354. if (this._isMounted) {
  355. this.setState({
  356. errorMessage:
  357. err?.responseJSON?.detail || err?.message || t('An unknown error occurred.'),
  358. });
  359. }
  360. } finally {
  361. if (this._isMounted) {
  362. this.setState({loading: false});
  363. }
  364. }
  365. }
  366. render() {
  367. const {children} = this.props;
  368. const {
  369. loading,
  370. tableResults,
  371. timeseriesResults,
  372. errorMessage,
  373. pageLinks,
  374. timeseriesResultsTypes,
  375. } = this.state;
  376. return children({
  377. loading,
  378. tableResults,
  379. timeseriesResults,
  380. errorMessage,
  381. pageLinks,
  382. timeseriesResultsTypes,
  383. });
  384. }
  385. }
  386. export default GenericWidgetQueries;