releaseWidgetQueries.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 type {Client} from 'sentry/api';
  8. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  9. import {t} from 'sentry/locale';
  10. import type {
  11. MetricsApiResponse,
  12. Organization,
  13. PageFilters,
  14. Release,
  15. SessionApiResponse,
  16. } from 'sentry/types';
  17. import type {Series} from 'sentry/types/echarts';
  18. import type {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 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: true,
  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. componentWillUnmount() {
  146. this._isMounted = false;
  147. }
  148. config = ReleasesConfig;
  149. private _isMounted: boolean = false;
  150. fetchReleases = async () => {
  151. this.setState({loading: true, errorMessage: undefined});
  152. const {selection, api, organization} = this.props;
  153. const {environments, projects} = selection;
  154. try {
  155. const releases = await api.requestPromise(
  156. `/organizations/${organization.slug}/releases/`,
  157. {
  158. method: 'GET',
  159. data: {
  160. sort: 'date',
  161. project: projects,
  162. per_page: 50,
  163. environment: environments,
  164. },
  165. }
  166. );
  167. if (!this._isMounted) {
  168. return;
  169. }
  170. this.setState({releases, loading: false});
  171. } catch (error) {
  172. if (!this._isMounted) {
  173. return;
  174. }
  175. const message = error.responseJSON
  176. ? error.responseJSON.error
  177. : t('Error sorting by releases');
  178. this.setState({errorMessage: message, loading: false});
  179. addErrorMessage(message);
  180. }
  181. };
  182. get limit() {
  183. const {limit} = this.props;
  184. switch (this.props.widget.displayType) {
  185. case DisplayType.TOP_N:
  186. return TOP_N;
  187. case DisplayType.TABLE:
  188. return limit ?? DEFAULT_TABLE_LIMIT;
  189. case DisplayType.BIG_NUMBER:
  190. return 1;
  191. default:
  192. return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
  193. }
  194. }
  195. customDidUpdateComparator = (
  196. prevProps: GenericWidgetQueriesProps<
  197. SessionApiResponse | MetricsApiResponse,
  198. SessionApiResponse | MetricsApiResponse
  199. >,
  200. nextProps: GenericWidgetQueriesProps<
  201. SessionApiResponse | MetricsApiResponse,
  202. SessionApiResponse | MetricsApiResponse
  203. >
  204. ) => {
  205. const {loading, limit, widget, cursor, organization, selection, dashboardFilters} =
  206. nextProps;
  207. const ignoredWidgetProps = [
  208. 'queries',
  209. 'title',
  210. 'id',
  211. 'layout',
  212. 'tempId',
  213. 'widgetType',
  214. ];
  215. const ignoredQueryProps = ['name', 'fields', 'aggregates', 'columns'];
  216. return (
  217. limit !== prevProps.limit ||
  218. organization.slug !== prevProps.organization.slug ||
  219. !isEqual(dashboardFilters, prevProps.dashboardFilters) ||
  220. !isSelectionEqual(selection, prevProps.selection) ||
  221. // If the widget changed (ignore unimportant fields, + queries as they are handled lower)
  222. !isEqual(
  223. omit(widget, ignoredWidgetProps),
  224. omit(prevProps.widget, ignoredWidgetProps)
  225. ) ||
  226. // If the queries changed (ignore unimportant name, + fields as they are handled lower)
  227. !isEqual(
  228. widget.queries.map(q => omit(q, ignoredQueryProps)),
  229. prevProps.widget.queries.map(q => omit(q, ignoredQueryProps))
  230. ) ||
  231. // If the fields changed (ignore falsy/empty fields -> they can happen after clicking on Add Overlay)
  232. !isEqual(
  233. widget.queries.flatMap(q => q.fields?.filter(field => !!field)),
  234. prevProps.widget.queries.flatMap(q => q.fields?.filter(field => !!field))
  235. ) ||
  236. !isEqual(
  237. widget.queries.flatMap(q => q.aggregates.filter(aggregate => !!aggregate)),
  238. prevProps.widget.queries.flatMap(q =>
  239. q.aggregates.filter(aggregate => !!aggregate)
  240. )
  241. ) ||
  242. !isEqual(
  243. widget.queries.flatMap(q => q.columns.filter(column => !!column)),
  244. prevProps.widget.queries.flatMap(q => q.columns.filter(column => !!column))
  245. ) ||
  246. loading !== prevProps.loading ||
  247. cursor !== prevProps.cursor
  248. );
  249. };
  250. transformWidget = (initialWidget: Widget): Widget => {
  251. const {releases} = this.state;
  252. const widget = cloneDeep(initialWidget);
  253. const isCustomReleaseSorting = requiresCustomReleaseSorting(widget.queries[0]);
  254. const isDescending = widget.queries[0].orderby.startsWith('-');
  255. const useSessionAPI = widget.queries[0].columns.includes('session.status');
  256. let releaseCondition = '';
  257. const releasesArray: string[] = [];
  258. if (isCustomReleaseSorting) {
  259. if (releases && releases.length === 1) {
  260. releaseCondition += `release:${releases[0].version}`;
  261. releasesArray.push(releases[0].version);
  262. }
  263. if (releases && releases.length > 1) {
  264. const {releaseQueryString, releasesUsed} = getReleasesQuery(releases);
  265. releaseCondition += releaseQueryString;
  266. releasesArray.push(...releasesUsed);
  267. if (!isDescending) {
  268. releasesArray.reverse();
  269. }
  270. }
  271. }
  272. if (!useSessionAPI) {
  273. widget.queries.forEach(query => {
  274. query.conditions =
  275. query.conditions + (releaseCondition === '' ? '' : ` ${releaseCondition}`);
  276. });
  277. }
  278. return widget;
  279. };
  280. afterFetchData = (data: SessionApiResponse | MetricsApiResponse) => {
  281. const {widget} = this.props;
  282. const {releases} = this.state;
  283. const isDescending = widget.queries[0].orderby.startsWith('-');
  284. const releasesArray: string[] = [];
  285. if (requiresCustomReleaseSorting(widget.queries[0])) {
  286. if (releases && releases.length === 1) {
  287. releasesArray.push(releases[0].version);
  288. }
  289. if (releases && releases.length > 1) {
  290. const {releasesUsed} = getReleasesQuery(releases);
  291. releasesArray.push(...releasesUsed);
  292. if (!isDescending) {
  293. releasesArray.reverse();
  294. }
  295. }
  296. }
  297. if (releasesArray.length) {
  298. data.groups.sort(function (group1, group2) {
  299. const release1 = group1.by.release;
  300. const release2 = group2.by.release;
  301. return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
  302. });
  303. data.groups = data.groups.slice(0, this.limit);
  304. }
  305. };
  306. render() {
  307. const {
  308. api,
  309. children,
  310. organization,
  311. selection,
  312. widget,
  313. cursor,
  314. dashboardFilters,
  315. onDataFetched,
  316. } = this.props;
  317. const config = ReleasesConfig;
  318. return (
  319. <GenericWidgetQueries<
  320. SessionApiResponse | MetricsApiResponse,
  321. SessionApiResponse | MetricsApiResponse
  322. >
  323. config={config}
  324. api={api}
  325. organization={organization}
  326. selection={selection}
  327. widget={this.transformWidget(widget)}
  328. dashboardFilters={dashboardFilters}
  329. cursor={cursor}
  330. limit={this.limit}
  331. onDataFetched={onDataFetched}
  332. loading={
  333. requiresCustomReleaseSorting(widget.queries[0])
  334. ? !this.state.releases
  335. : undefined
  336. }
  337. customDidUpdateComparator={this.customDidUpdateComparator}
  338. afterFetchTableData={this.afterFetchData}
  339. afterFetchSeriesData={this.afterFetchData}
  340. >
  341. {({errorMessage, ...rest}) =>
  342. children({
  343. errorMessage: this.state.errorMessage ?? errorMessage,
  344. ...rest,
  345. })
  346. }
  347. </GenericWidgetQueries>
  348. );
  349. }
  350. }
  351. export default ReleaseWidgetQueries;