genericWidgetQueries.tsx 13 KB

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