releaseWidgetQueries.tsx 12 KB

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