genericWidgetQueries.tsx 14 KB

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