genericWidgetQueries.tsx 13 KB

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