genericWidgetQueries.tsx 14 KB

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