releaseWidgetQueries.tsx 11 KB

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