releaseWidgetQueries.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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 pick from 'lodash/pick';
  6. import trimStart from 'lodash/trimStart';
  7. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  8. import type {Client} from 'sentry/api';
  9. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  10. import {t} from 'sentry/locale';
  11. import type {PageFilters} from 'sentry/types/core';
  12. import type {Series} from 'sentry/types/echarts';
  13. import type {MetricsApiResponse} from 'sentry/types/metrics';
  14. import type {Organization, SessionApiResponse} from 'sentry/types/organization';
  15. import type {Release} from 'sentry/types/release';
  16. import {defined} from 'sentry/utils';
  17. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  18. import {stripDerivedMetricsPrefix} from 'sentry/utils/discover/fields';
  19. import {TOP_N} from 'sentry/utils/discover/types';
  20. import {dashboardFiltersToString} from 'sentry/views/dashboards/utils';
  21. import {ReleasesConfig} from '../datasetConfig/releases';
  22. import type {DashboardFilters, Widget, WidgetQuery} from '../types';
  23. import {DEFAULT_TABLE_LIMIT, DisplayType} from '../types';
  24. import {
  25. DERIVED_STATUS_METRICS_PATTERN,
  26. DerivedStatusFields,
  27. DISABLED_SORT,
  28. METRICS_EXPRESSION_TO_FIELD,
  29. } from '../widgetBuilder/releaseWidget/fields';
  30. import type {
  31. GenericWidgetQueriesChildrenProps,
  32. GenericWidgetQueriesProps,
  33. } from './genericWidgetQueries';
  34. import GenericWidgetQueries from './genericWidgetQueries';
  35. type Props = {
  36. api: Client;
  37. children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
  38. organization: Organization;
  39. selection: PageFilters;
  40. widget: Widget;
  41. cursor?: string;
  42. dashboardFilters?: DashboardFilters;
  43. limit?: number;
  44. onDataFetched?: (results: {
  45. tableResults?: TableDataWithTitle[];
  46. timeseriesResults?: Series[];
  47. }) => void;
  48. };
  49. type State = {
  50. loading: boolean;
  51. errorMessage?: string;
  52. releases?: Release[];
  53. };
  54. export function derivedMetricsToField(field: string): string {
  55. return METRICS_EXPRESSION_TO_FIELD[field] ?? field;
  56. }
  57. function getReleasesQuery(releases: Release[]): {
  58. releaseQueryString: string;
  59. releasesUsed: string[];
  60. } {
  61. let releaseCondition = '';
  62. const releasesArray: string[] = [];
  63. releaseCondition += 'release:[' + releases[0]!.version;
  64. releasesArray.push(releases[0]!.version);
  65. for (let i = 1; i < releases.length; i++) {
  66. releaseCondition += ',' + releases[i]!.version;
  67. releasesArray.push(releases[i]!.version);
  68. }
  69. releaseCondition += ']';
  70. if (releases.length < 10) {
  71. return {releaseQueryString: releaseCondition, releasesUsed: releasesArray};
  72. }
  73. if (releases.length > 10 && releaseCondition.length > 1500) {
  74. return getReleasesQuery(releases.slice(0, -10));
  75. }
  76. return {releaseQueryString: releaseCondition, releasesUsed: releasesArray};
  77. }
  78. /**
  79. * Given a list of requested fields, this function returns
  80. * 'aggregates' which is a list of aggregate functions that
  81. * can be passed to either Metrics or Sessions endpoints,
  82. * 'derivedStatusFields' which need to be requested from the
  83. * Metrics endpoint and 'injectFields' which are fields not
  84. * requested but required to calculate the value of a derived
  85. * status field so will need to be stripped away in post processing.
  86. */
  87. export function resolveDerivedStatusFields(
  88. fields: string[],
  89. orderby: string,
  90. useSessionAPI: boolean
  91. ): {
  92. aggregates: string[];
  93. derivedStatusFields: string[];
  94. injectedFields: string[];
  95. } {
  96. const aggregates = fields.map(stripDerivedMetricsPrefix);
  97. const derivedStatusFields = aggregates.filter(agg =>
  98. Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields)
  99. );
  100. const injectedFields: string[] = [];
  101. const rawOrderby = trimStart(orderby, '-');
  102. const unsupportedOrderby =
  103. DISABLED_SORT.includes(rawOrderby) || useSessionAPI || rawOrderby === 'release';
  104. if (rawOrderby && !unsupportedOrderby && !fields.includes(rawOrderby)) {
  105. if (!injectedFields.includes(rawOrderby)) {
  106. injectedFields.push(rawOrderby);
  107. }
  108. }
  109. if (!useSessionAPI) {
  110. return {aggregates, derivedStatusFields, injectedFields};
  111. }
  112. derivedStatusFields.forEach(field => {
  113. const result = field.match(DERIVED_STATUS_METRICS_PATTERN);
  114. if (result) {
  115. if (result[2] === 'user' && !aggregates.includes('count_unique(user)')) {
  116. injectedFields.push('count_unique(user)');
  117. aggregates.push('count_unique(user)');
  118. }
  119. if (result[2] === 'session' && !aggregates.includes('sum(session)')) {
  120. injectedFields.push('sum(session)');
  121. aggregates.push('sum(session)');
  122. }
  123. }
  124. });
  125. return {aggregates, derivedStatusFields, injectedFields};
  126. }
  127. export function requiresCustomReleaseSorting(query: WidgetQuery): boolean {
  128. const useMetricsAPI = !query.columns.includes('session.status');
  129. const rawOrderby = trimStart(query.orderby, '-');
  130. return useMetricsAPI && rawOrderby === 'release';
  131. }
  132. class ReleaseWidgetQueries extends Component<Props, State> {
  133. state: State = {
  134. loading: false,
  135. errorMessage: undefined,
  136. releases: undefined,
  137. };
  138. componentDidMount() {
  139. this._isMounted = true;
  140. if (requiresCustomReleaseSorting(this.props.widget.queries[0]!)) {
  141. this.fetchReleases();
  142. return;
  143. }
  144. }
  145. componentDidUpdate(prevProps: Readonly<Props>): void {
  146. if (
  147. !requiresCustomReleaseSorting(prevProps.widget.queries[0]!) &&
  148. requiresCustomReleaseSorting(this.props.widget.queries[0]!) &&
  149. !this.state.loading &&
  150. !defined(this.state.releases)
  151. ) {
  152. this.fetchReleases();
  153. return;
  154. }
  155. }
  156. componentWillUnmount() {
  157. this._isMounted = false;
  158. }
  159. config = ReleasesConfig;
  160. private _isMounted: boolean = false;
  161. fetchReleases = async () => {
  162. this.setState({loading: true, errorMessage: undefined});
  163. const {selection, api, organization, dashboardFilters} = this.props;
  164. const {environments, projects} = selection;
  165. try {
  166. const releases = await api.requestPromise(
  167. `/organizations/${organization.slug}/releases/`,
  168. {
  169. method: 'GET',
  170. data: {
  171. sort: 'date',
  172. project: projects,
  173. per_page: 50,
  174. environment: environments,
  175. // Propagate release filters
  176. query: dashboardFilters
  177. ? dashboardFiltersToString(pick(dashboardFilters, 'release'))
  178. : undefined,
  179. },
  180. }
  181. );
  182. if (!this._isMounted) {
  183. return;
  184. }
  185. this.setState({releases, loading: false});
  186. } catch (error) {
  187. if (!this._isMounted) {
  188. return;
  189. }
  190. const message = error.responseJSON
  191. ? error.responseJSON.error
  192. : t('Error sorting by releases');
  193. this.setState({errorMessage: message, loading: false});
  194. addErrorMessage(message);
  195. }
  196. };
  197. get limit() {
  198. const {limit} = this.props;
  199. switch (this.props.widget.displayType) {
  200. case DisplayType.TOP_N:
  201. return TOP_N;
  202. case DisplayType.TABLE:
  203. return limit ?? DEFAULT_TABLE_LIMIT;
  204. case DisplayType.BIG_NUMBER:
  205. return 1;
  206. default:
  207. return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
  208. }
  209. }
  210. customDidUpdateComparator = (
  211. prevProps: GenericWidgetQueriesProps<
  212. SessionApiResponse | MetricsApiResponse,
  213. SessionApiResponse | MetricsApiResponse
  214. >,
  215. nextProps: GenericWidgetQueriesProps<
  216. SessionApiResponse | MetricsApiResponse,
  217. SessionApiResponse | MetricsApiResponse
  218. >
  219. ) => {
  220. const {loading, limit, widget, cursor, organization, selection, dashboardFilters} =
  221. nextProps;
  222. const ignoredWidgetProps = [
  223. 'queries',
  224. 'title',
  225. 'id',
  226. 'layout',
  227. 'tempId',
  228. 'widgetType',
  229. ];
  230. const ignoredQueryProps = ['name', 'fields', 'aggregates', 'columns'];
  231. return (
  232. limit !== prevProps.limit ||
  233. organization.slug !== prevProps.organization.slug ||
  234. !isEqual(dashboardFilters, prevProps.dashboardFilters) ||
  235. !isSelectionEqual(selection, prevProps.selection) ||
  236. // If the widget changed (ignore unimportant fields, + queries as they are handled lower)
  237. !isEqual(
  238. omit(widget, ignoredWidgetProps),
  239. omit(prevProps.widget, ignoredWidgetProps)
  240. ) ||
  241. // If the queries changed (ignore unimportant name, + fields as they are handled lower)
  242. !isEqual(
  243. widget.queries.map(q => omit(q, ignoredQueryProps)),
  244. prevProps.widget.queries.map(q => omit(q, ignoredQueryProps))
  245. ) ||
  246. // If the fields changed (ignore falsy/empty fields -> they can happen after clicking on Add Series)
  247. !isEqual(
  248. widget.queries.flatMap(q => q.fields?.filter(field => !!field)),
  249. prevProps.widget.queries.flatMap(q => q.fields?.filter(field => !!field))
  250. ) ||
  251. !isEqual(
  252. widget.queries.flatMap(q => q.aggregates.filter(aggregate => !!aggregate)),
  253. prevProps.widget.queries.flatMap(q =>
  254. q.aggregates.filter(aggregate => !!aggregate)
  255. )
  256. ) ||
  257. !isEqual(
  258. widget.queries.flatMap(q => q.columns.filter(column => !!column)),
  259. prevProps.widget.queries.flatMap(q => q.columns.filter(column => !!column))
  260. ) ||
  261. loading !== prevProps.loading ||
  262. cursor !== prevProps.cursor
  263. );
  264. };
  265. transformWidget = (initialWidget: Widget): Widget => {
  266. const {releases} = this.state;
  267. const widget = cloneDeep(initialWidget);
  268. const isCustomReleaseSorting = requiresCustomReleaseSorting(widget.queries[0]!);
  269. const isDescending = widget.queries[0]!.orderby.startsWith('-');
  270. const useSessionAPI = widget.queries[0]!.columns.includes('session.status');
  271. let releaseCondition = '';
  272. const releasesArray: string[] = [];
  273. if (isCustomReleaseSorting) {
  274. if (releases && releases.length === 1) {
  275. releaseCondition += `release:${releases[0]!.version}`;
  276. releasesArray.push(releases[0]!.version);
  277. }
  278. if (releases && releases.length > 1) {
  279. const {releaseQueryString, releasesUsed} = getReleasesQuery(releases);
  280. releaseCondition += releaseQueryString;
  281. releasesArray.push(...releasesUsed);
  282. if (!isDescending) {
  283. releasesArray.reverse();
  284. }
  285. }
  286. }
  287. if (!useSessionAPI) {
  288. widget.queries.forEach(query => {
  289. query.conditions =
  290. query.conditions + (releaseCondition === '' ? '' : ` ${releaseCondition}`);
  291. });
  292. }
  293. return widget;
  294. };
  295. afterFetchData = (data: SessionApiResponse | MetricsApiResponse) => {
  296. const {widget} = this.props;
  297. const {releases} = this.state;
  298. const isDescending = widget.queries[0]!.orderby.startsWith('-');
  299. const releasesArray: string[] = [];
  300. if (requiresCustomReleaseSorting(widget.queries[0]!)) {
  301. if (releases && releases.length === 1) {
  302. releasesArray.push(releases[0]!.version);
  303. }
  304. if (releases && releases.length > 1) {
  305. const {releasesUsed} = getReleasesQuery(releases);
  306. releasesArray.push(...releasesUsed);
  307. if (!isDescending) {
  308. releasesArray.reverse();
  309. }
  310. }
  311. }
  312. if (releasesArray.length) {
  313. data.groups.sort(function (group1, group2) {
  314. const release1 = group1.by.release;
  315. const release2 = group2.by.release;
  316. // @ts-expect-error TS(2345): Argument of type 'string | number | undefined' is ... Remove this comment to see the full error message
  317. return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
  318. });
  319. data.groups = data.groups.slice(0, this.limit);
  320. }
  321. };
  322. render() {
  323. const {
  324. api,
  325. children,
  326. organization,
  327. selection,
  328. widget,
  329. cursor,
  330. dashboardFilters,
  331. onDataFetched,
  332. } = this.props;
  333. const config = ReleasesConfig;
  334. return (
  335. <GenericWidgetQueries<
  336. SessionApiResponse | MetricsApiResponse,
  337. SessionApiResponse | MetricsApiResponse
  338. >
  339. config={config}
  340. api={api}
  341. organization={organization}
  342. selection={selection}
  343. widget={this.transformWidget(widget)}
  344. dashboardFilters={dashboardFilters}
  345. cursor={cursor}
  346. limit={this.limit}
  347. onDataFetched={onDataFetched}
  348. loading={
  349. requiresCustomReleaseSorting(widget.queries[0]!)
  350. ? !this.state.releases
  351. : undefined
  352. }
  353. customDidUpdateComparator={this.customDidUpdateComparator}
  354. afterFetchTableData={this.afterFetchData}
  355. afterFetchSeriesData={this.afterFetchData}
  356. >
  357. {({errorMessage, ...rest}) =>
  358. children({
  359. errorMessage: this.state.errorMessage ?? errorMessage,
  360. ...rest,
  361. })
  362. }
  363. </GenericWidgetQueries>
  364. );
  365. }
  366. }
  367. export default ReleaseWidgetQueries;