releaseWidgetQueries.tsx 12 KB

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