genericWidgetQueries.tsx 13 KB

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