genericWidgetQueries.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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 {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
  14. import {dashboardFiltersToString} from 'sentry/views/dashboards/utils';
  15. import {DatasetConfig} from '../datasetConfig/base';
  16. import {
  17. DashboardFilters,
  18. DEFAULT_TABLE_LIMIT,
  19. DisplayType,
  20. Widget,
  21. WidgetQuery,
  22. } from '../types';
  23. function getReferrer(displayType: DisplayType) {
  24. let referrer: string = '';
  25. if (displayType === DisplayType.TABLE) {
  26. referrer = 'api.dashboards.tablewidget';
  27. } else if (displayType === DisplayType.BIG_NUMBER) {
  28. referrer = 'api.dashboards.bignumberwidget';
  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. forceOnDemand?: boolean;
  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. onDemandControlContext?: OnDemandControlContext;
  80. };
  81. type State<SeriesResponse> = {
  82. loading: boolean;
  83. errorMessage?: GenericWidgetQueriesChildrenProps['errorMessage'];
  84. pageLinks?: GenericWidgetQueriesChildrenProps['pageLinks'];
  85. queryFetchID?: symbol;
  86. rawResults?: SeriesResponse[];
  87. tableResults?: GenericWidgetQueriesChildrenProps['tableResults'];
  88. timeseriesResults?: GenericWidgetQueriesChildrenProps['timeseriesResults'];
  89. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  90. };
  91. class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
  92. GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  93. State<SeriesResponse>
  94. > {
  95. state: State<SeriesResponse> = {
  96. loading: true,
  97. queryFetchID: undefined,
  98. errorMessage: undefined,
  99. timeseriesResults: undefined,
  100. rawResults: undefined,
  101. tableResults: undefined,
  102. pageLinks: undefined,
  103. timeseriesResultsTypes: undefined,
  104. };
  105. componentDidMount() {
  106. this._isMounted = true;
  107. if (!this.props.loading) {
  108. this.fetchData();
  109. }
  110. }
  111. componentDidUpdate(
  112. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  113. ) {
  114. const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
  115. this.props;
  116. // We do not fetch data whenever the query name changes.
  117. // Also don't count empty fields when checking for field changes
  118. const [prevWidgetQueryNames, prevWidgetQueries] = prevProps.widget.queries
  119. .map((query: WidgetQuery) => {
  120. query.aggregates = query.aggregates.filter(field => !!field);
  121. query.columns = query.columns.filter(field => !!field);
  122. return query;
  123. })
  124. .reduce(
  125. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  126. names.push(name);
  127. rest.fields = rest.fields?.filter(field => !!field) ?? [];
  128. // Ignore aliases because changing alias does not need a query
  129. rest = omit(rest, 'fieldAliases');
  130. queries.push(rest);
  131. return [names, queries];
  132. },
  133. [[], []]
  134. );
  135. const [widgetQueryNames, widgetQueries] = widget.queries
  136. .map((query: WidgetQuery) => {
  137. query.aggregates = query.aggregates.filter(
  138. field => !!field && field !== 'equation|'
  139. );
  140. query.columns = query.columns.filter(field => !!field && field !== 'equation|');
  141. return query;
  142. })
  143. .reduce(
  144. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  145. names.push(name);
  146. rest.fields = rest.fields?.filter(field => !!field) ?? [];
  147. // Ignore aliases because changing alias does not need a query
  148. rest = omit(rest, 'fieldAliases');
  149. queries.push(rest);
  150. return [names, queries];
  151. },
  152. [[], []]
  153. );
  154. if (
  155. customDidUpdateComparator
  156. ? customDidUpdateComparator(prevProps, this.props)
  157. : widget.limit !== prevProps.widget.limit ||
  158. !isEqual(widget.displayType, prevProps.widget.displayType) ||
  159. !isEqual(widget.interval, prevProps.widget.interval) ||
  160. !isEqual(new Set(widgetQueries), new Set(prevWidgetQueries)) ||
  161. !isEqual(this.props.dashboardFilters, prevProps.dashboardFilters) ||
  162. !isEqual(this.props.forceOnDemand, prevProps.forceOnDemand) ||
  163. !isSelectionEqual(selection, prevProps.selection) ||
  164. cursor !== prevProps.cursor
  165. ) {
  166. this.fetchData();
  167. return;
  168. }
  169. if (
  170. !this.state.loading &&
  171. !isEqual(prevWidgetQueryNames, widgetQueryNames) &&
  172. this.state.rawResults?.length === widget.queries.length
  173. ) {
  174. // If the query names has changed, then update timeseries labels
  175. // eslint-disable-next-line react/no-did-update-set-state
  176. this.setState(prevState => {
  177. const timeseriesResults = widget.queries.reduce((acc: Series[], query, index) => {
  178. return acc.concat(
  179. config.transformSeries!(prevState.rawResults![index], query, organization)
  180. );
  181. }, []);
  182. return {...prevState, timeseriesResults};
  183. });
  184. }
  185. }
  186. componentWillUnmount() {
  187. this._isMounted = false;
  188. }
  189. private _isMounted: boolean = false;
  190. applyDashboardFilters(widget: Widget): Widget {
  191. const {dashboardFilters} = this.props;
  192. const dashboardFilterConditions = dashboardFiltersToString(dashboardFilters);
  193. widget.queries.forEach(query => {
  194. query.conditions =
  195. query.conditions +
  196. (dashboardFilterConditions === '' ? '' : ` ${dashboardFilterConditions}`);
  197. });
  198. return widget;
  199. }
  200. async fetchTableData(queryFetchID: symbol) {
  201. const {
  202. widget: originalWidget,
  203. limit,
  204. config,
  205. api,
  206. organization,
  207. selection,
  208. cursor,
  209. afterFetchTableData,
  210. onDataFetched,
  211. onDemandControlContext,
  212. mepSetting,
  213. } = this.props;
  214. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  215. const responses = await Promise.all(
  216. widget.queries.map(query => {
  217. const requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
  218. const requestCreator = config.getTableRequest;
  219. if (!requestCreator) {
  220. throw new Error(
  221. t('This display type is not supported by the selected dataset.')
  222. );
  223. }
  224. return requestCreator(
  225. api,
  226. widget,
  227. query,
  228. organization,
  229. selection,
  230. onDemandControlContext,
  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. onDemandControlContext,
  280. } = this.props;
  281. const widget = this.applyDashboardFilters(cloneDeep(originalWidget));
  282. const responses = await Promise.all(
  283. widget.queries.map((_query, index) => {
  284. return config.getSeriesRequest!(
  285. api,
  286. widget,
  287. index,
  288. organization,
  289. selection,
  290. onDemandControlContext,
  291. getReferrer(widget.displayType),
  292. mepSetting
  293. );
  294. })
  295. );
  296. const rawResultsClone = cloneDeep(this.state.rawResults) ?? [];
  297. const transformedTimeseriesResults: Series[] = [];
  298. responses.forEach(([data], requestIndex) => {
  299. afterFetchSeriesData?.(data);
  300. rawResultsClone[requestIndex] = data;
  301. const transformedResult = config.transformSeries!(
  302. data,
  303. widget.queries[requestIndex],
  304. organization
  305. );
  306. // When charting timeseriesData on echarts, color association to a timeseries result
  307. // is order sensitive, ie series at index i on the timeseries array will use color at
  308. // index i on the color array. This means that on multi series results, we need to make
  309. // sure that the order of series in our results do not change between fetches to avoid
  310. // coloring inconsistencies between renders.
  311. transformedResult.forEach((result, resultIndex) => {
  312. transformedTimeseriesResults[
  313. requestIndex * transformedResult.length + resultIndex
  314. ] = result;
  315. });
  316. });
  317. // Get series result type
  318. // Only used by custom measurements in errorsAndTransactions at the moment
  319. const timeseriesResultsTypes = config.getSeriesResultType?.(
  320. responses[0][0],
  321. widget.queries[0]
  322. );
  323. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  324. onDataFetched?.({
  325. timeseriesResults: transformedTimeseriesResults,
  326. timeseriesResultsTypes,
  327. });
  328. this.setState({
  329. timeseriesResults: transformedTimeseriesResults,
  330. rawResults: rawResultsClone,
  331. timeseriesResultsTypes,
  332. });
  333. }
  334. }
  335. async fetchData() {
  336. const {widget} = this.props;
  337. const queryFetchID = Symbol('queryFetchID');
  338. this.setState({
  339. loading: true,
  340. tableResults: undefined,
  341. timeseriesResults: undefined,
  342. errorMessage: undefined,
  343. queryFetchID,
  344. });
  345. try {
  346. if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
  347. await this.fetchTableData(queryFetchID);
  348. } else {
  349. await this.fetchSeriesData(queryFetchID);
  350. }
  351. } catch (err) {
  352. if (this._isMounted) {
  353. this.setState({
  354. errorMessage:
  355. err?.responseJSON?.detail || err?.message || t('An unknown error occurred.'),
  356. });
  357. }
  358. } finally {
  359. if (this._isMounted) {
  360. this.setState({loading: false});
  361. }
  362. }
  363. }
  364. render() {
  365. const {children} = this.props;
  366. const {
  367. loading,
  368. tableResults,
  369. timeseriesResults,
  370. errorMessage,
  371. pageLinks,
  372. timeseriesResultsTypes,
  373. } = this.state;
  374. return children({
  375. loading,
  376. tableResults,
  377. timeseriesResults,
  378. errorMessage,
  379. pageLinks,
  380. timeseriesResultsTypes,
  381. });
  382. }
  383. }
  384. export default GenericWidgetQueries;