releases.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import omit from 'lodash/omit';
  2. import trimStart from 'lodash/trimStart';
  3. import {doMetricsRequest} from 'sentry/actionCreators/metrics';
  4. import {doSessionsRequest} from 'sentry/actionCreators/sessions';
  5. import {Client} from 'sentry/api';
  6. import {t} from 'sentry/locale';
  7. import {
  8. MetricsApiResponse,
  9. Organization,
  10. PageFilters,
  11. SelectValue,
  12. SessionApiResponse,
  13. SessionField,
  14. SessionsMeta,
  15. } from 'sentry/types';
  16. import {Series} from 'sentry/types/echarts';
  17. import {defined} from 'sentry/utils';
  18. import {statsPeriodToDays} from 'sentry/utils/dates';
  19. import {TableData} from 'sentry/utils/discover/discoverQuery';
  20. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  21. import {QueryFieldValue} from 'sentry/utils/discover/fields';
  22. import {FieldValueOption} from 'sentry/views/discover/table/queryField';
  23. import {FieldValue, FieldValueKind} from 'sentry/views/discover/table/types';
  24. import {DisplayType, Widget, WidgetQuery} from '../types';
  25. import {getWidgetInterval} from '../utils';
  26. import {ReleaseSearchBar} from '../widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar';
  27. import {
  28. DERIVED_STATUS_METRICS_PATTERN,
  29. DerivedStatusFields,
  30. DISABLED_SORT,
  31. FIELD_TO_METRICS_EXPRESSION,
  32. generateReleaseWidgetFieldOptions,
  33. SESSIONS_FIELDS,
  34. SESSIONS_TAGS,
  35. TAG_SORT_DENY_LIST,
  36. } from '../widgetBuilder/releaseWidget/fields';
  37. import {
  38. derivedMetricsToField,
  39. requiresCustomReleaseSorting,
  40. resolveDerivedStatusFields,
  41. } from '../widgetCard/releaseWidgetQueries';
  42. import {getSeriesName} from '../widgetCard/transformSessionsResponseToSeries';
  43. import {
  44. changeObjectValuesToTypes,
  45. getDerivedMetrics,
  46. mapDerivedMetricsToFields,
  47. } from '../widgetCard/transformSessionsResponseToTable';
  48. import {DatasetConfig, handleOrderByReset} from './base';
  49. const DEFAULT_WIDGET_QUERY: WidgetQuery = {
  50. name: '',
  51. fields: [`crash_free_rate(${SessionField.SESSION})`],
  52. columns: [],
  53. fieldAliases: [],
  54. aggregates: [`crash_free_rate(${SessionField.SESSION})`],
  55. conditions: '',
  56. orderby: `-crash_free_rate(${SessionField.SESSION})`,
  57. };
  58. const METRICS_BACKED_SESSIONS_START_DATE = new Date('2022-07-12');
  59. export const ReleasesConfig: DatasetConfig<
  60. SessionApiResponse | MetricsApiResponse,
  61. SessionApiResponse | MetricsApiResponse
  62. > = {
  63. defaultWidgetQuery: DEFAULT_WIDGET_QUERY,
  64. enableEquations: false,
  65. disableSortOptions,
  66. getTableRequest: (
  67. api: Client,
  68. query: WidgetQuery,
  69. organization: Organization,
  70. pageFilters: PageFilters,
  71. limit?: number,
  72. cursor?: string
  73. ) =>
  74. getReleasesRequest(
  75. 0,
  76. 1,
  77. api,
  78. query,
  79. organization,
  80. pageFilters,
  81. undefined,
  82. limit,
  83. cursor
  84. ),
  85. getSeriesRequest: getReleasesSeriesRequest,
  86. getTableSortOptions,
  87. getTimeseriesSortOptions,
  88. filterTableOptions: filterPrimaryReleaseTableOptions,
  89. filterAggregateParams,
  90. filterYAxisAggregateParams: (_fieldValue: QueryFieldValue, _displayType: DisplayType) =>
  91. filterAggregateParams,
  92. filterYAxisOptions,
  93. getCustomFieldRenderer: (field, meta) => getFieldRenderer(field, meta, false),
  94. SearchBar: ReleaseSearchBar,
  95. getTableFieldOptions: getReleasesTableFieldOptions,
  96. getGroupByFieldOptions: (_organization: Organization) =>
  97. generateReleaseWidgetFieldOptions([] as SessionsMeta[], SESSIONS_TAGS),
  98. handleColumnFieldChangeOverride,
  99. handleOrderByReset: handleReleasesTableOrderByReset,
  100. filterSeriesSortOptions,
  101. supportedDisplayTypes: [
  102. DisplayType.AREA,
  103. DisplayType.BAR,
  104. DisplayType.BIG_NUMBER,
  105. DisplayType.LINE,
  106. DisplayType.TABLE,
  107. DisplayType.TOP_N,
  108. ],
  109. transformSeries: transformSessionsResponseToSeries,
  110. transformTable: transformSessionsResponseToTable,
  111. };
  112. function disableSortOptions(widgetQuery: WidgetQuery) {
  113. const {columns} = widgetQuery;
  114. if (columns.includes('session.status')) {
  115. return {
  116. disableSort: true,
  117. disableSortDirection: true,
  118. disableSortReason: t('Sorting currently not supported with session.status'),
  119. };
  120. }
  121. return {
  122. disableSort: false,
  123. disableSortDirection: false,
  124. };
  125. }
  126. function getTableSortOptions(_organization: Organization, widgetQuery: WidgetQuery) {
  127. const {columns, aggregates} = widgetQuery;
  128. const options: SelectValue<string>[] = [];
  129. [...aggregates, ...columns]
  130. .filter(field => !!field)
  131. .filter(field => !DISABLED_SORT.includes(field))
  132. .filter(field => !TAG_SORT_DENY_LIST.includes(field))
  133. .forEach(field => {
  134. options.push({label: field, value: field});
  135. });
  136. return options;
  137. }
  138. function getTimeseriesSortOptions(_organization: Organization, widgetQuery: WidgetQuery) {
  139. const columnSet = new Set(widgetQuery.columns);
  140. const releaseFieldOptions = generateReleaseWidgetFieldOptions(
  141. Object.values(SESSIONS_FIELDS),
  142. SESSIONS_TAGS
  143. );
  144. const options: Record<string, SelectValue<FieldValue>> = {};
  145. Object.entries(releaseFieldOptions).forEach(([key, option]) => {
  146. if (['count_healthy', 'count_errored'].includes(option.value.meta.name)) {
  147. return;
  148. }
  149. if (option.value.kind === FieldValueKind.FIELD) {
  150. // Only allow sorting by release tag
  151. if (option.value.meta.name === 'release' && columnSet.has(option.value.meta.name)) {
  152. options[key] = option;
  153. }
  154. return;
  155. }
  156. options[key] = option;
  157. });
  158. return options;
  159. }
  160. function filterSeriesSortOptions(columns: Set<string>) {
  161. return (option: FieldValueOption) => {
  162. if (['count_healthy', 'count_errored'].includes(option.value.meta.name)) {
  163. return false;
  164. }
  165. if (option.value.kind === FieldValueKind.FIELD) {
  166. // Only allow sorting by release tag
  167. return columns.has(option.value.meta.name) && option.value.meta.name === 'release';
  168. }
  169. return filterPrimaryReleaseTableOptions(option);
  170. };
  171. }
  172. function getReleasesSeriesRequest(
  173. api: Client,
  174. widget: Widget,
  175. queryIndex: number,
  176. organization: Organization,
  177. pageFilters: PageFilters
  178. ) {
  179. const query = widget.queries[queryIndex];
  180. const {displayType, limit} = widget;
  181. const {datetime} = pageFilters;
  182. const {start, end, period} = datetime;
  183. const isCustomReleaseSorting = requiresCustomReleaseSorting(query);
  184. const includeTotals = query.columns.length > 0 ? 1 : 0;
  185. const interval = getWidgetInterval(
  186. displayType,
  187. {start, end, period},
  188. '5m',
  189. // requesting low fidelity for release sort because metrics api can't return 100 rows of high fidelity series data
  190. isCustomReleaseSorting ? 'low' : undefined
  191. );
  192. return getReleasesRequest(
  193. 1,
  194. includeTotals,
  195. api,
  196. query,
  197. organization,
  198. pageFilters,
  199. interval,
  200. limit
  201. );
  202. }
  203. function filterPrimaryReleaseTableOptions(option: FieldValueOption) {
  204. return [
  205. FieldValueKind.FUNCTION,
  206. FieldValueKind.FIELD,
  207. FieldValueKind.NUMERIC_METRICS,
  208. ].includes(option.value.kind);
  209. }
  210. function filterAggregateParams(option: FieldValueOption) {
  211. return option.value.kind === FieldValueKind.METRICS;
  212. }
  213. function filterYAxisOptions(_displayType: DisplayType) {
  214. return (option: FieldValueOption) => {
  215. return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
  216. option.value.kind
  217. );
  218. };
  219. }
  220. function handleReleasesTableOrderByReset(widgetQuery: WidgetQuery, newFields: string[]) {
  221. const disableSortBy = widgetQuery.columns.includes('session.status');
  222. if (disableSortBy) {
  223. widgetQuery.orderby = '';
  224. }
  225. return handleOrderByReset(widgetQuery, newFields);
  226. }
  227. function handleColumnFieldChangeOverride(widgetQuery: WidgetQuery): WidgetQuery {
  228. if (widgetQuery.aggregates.length === 0) {
  229. // Release Health widgets require an aggregate in tables
  230. const defaultReleaseHealthAggregate = `crash_free_rate(${SessionField.SESSION})`;
  231. widgetQuery.aggregates = [defaultReleaseHealthAggregate];
  232. widgetQuery.fields = widgetQuery.fields
  233. ? [...widgetQuery.fields, defaultReleaseHealthAggregate]
  234. : [defaultReleaseHealthAggregate];
  235. }
  236. return widgetQuery;
  237. }
  238. function getReleasesTableFieldOptions(_organization: Organization) {
  239. return generateReleaseWidgetFieldOptions(Object.values(SESSIONS_FIELDS), SESSIONS_TAGS);
  240. }
  241. export function transformSessionsResponseToTable(
  242. data: SessionApiResponse | MetricsApiResponse,
  243. widgetQuery: WidgetQuery
  244. ): TableData {
  245. const useSessionAPI = widgetQuery.columns.includes('session.status');
  246. const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields(
  247. widgetQuery.aggregates,
  248. widgetQuery.orderby,
  249. useSessionAPI
  250. );
  251. const rows = data.groups.map((group, index) => ({
  252. id: String(index),
  253. ...mapDerivedMetricsToFields(group.by),
  254. // if `sum(session)` or `count_unique(user)` are not
  255. // requested as a part of the payload for
  256. // derived status metrics through the Sessions API,
  257. // they are injected into the payload and need to be
  258. // stripped.
  259. ...omit(mapDerivedMetricsToFields(group.totals), injectedFields),
  260. // if session.status is a groupby, some post processing
  261. // is needed to calculate the status derived metrics
  262. // from grouped results of `sum(session)` or `count_unique(user)`
  263. ...getDerivedMetrics(group.by, group.totals, derivedStatusFields),
  264. }));
  265. const singleRow = rows[0];
  266. const meta = {
  267. ...changeObjectValuesToTypes(omit(singleRow, 'id')),
  268. };
  269. return {meta, data: rows};
  270. }
  271. export function transformSessionsResponseToSeries(
  272. data: SessionApiResponse | MetricsApiResponse,
  273. widgetQuery: WidgetQuery
  274. ) {
  275. if (data === null) {
  276. return [];
  277. }
  278. const queryAlias = widgetQuery.name;
  279. const useSessionAPI = widgetQuery.columns.includes('session.status');
  280. const {derivedStatusFields: requestedStatusMetrics, injectedFields} =
  281. resolveDerivedStatusFields(
  282. widgetQuery.aggregates,
  283. widgetQuery.orderby,
  284. useSessionAPI
  285. );
  286. const results: Series[] = [];
  287. if (!data.groups.length) {
  288. return [
  289. {
  290. seriesName: `(${t('no results')})`,
  291. data: data.intervals.map(interval => ({
  292. name: interval,
  293. value: 0,
  294. })),
  295. },
  296. ];
  297. }
  298. data.groups.forEach(group => {
  299. Object.keys(group.series).forEach(field => {
  300. // if `sum(session)` or `count_unique(user)` are not
  301. // requested as a part of the payload for
  302. // derived status metrics through the Sessions API,
  303. // they are injected into the payload and need to be
  304. // stripped.
  305. if (!injectedFields.includes(derivedMetricsToField(field))) {
  306. results.push({
  307. seriesName: getSeriesName(field, group, queryAlias),
  308. data: data.intervals.map((interval, index) => ({
  309. name: interval,
  310. value: group.series[field][index] ?? 0,
  311. })),
  312. });
  313. }
  314. });
  315. // if session.status is a groupby, some post processing
  316. // is needed to calculate the status derived metrics
  317. // from grouped results of `sum(session)` or `count_unique(user)`
  318. if (requestedStatusMetrics.length && defined(group.by['session.status'])) {
  319. requestedStatusMetrics.forEach(status => {
  320. const result = status.match(DERIVED_STATUS_METRICS_PATTERN);
  321. if (result) {
  322. let metricField: string | undefined = undefined;
  323. if (group.by['session.status'] === result[1]) {
  324. if (result[2] === 'session') {
  325. metricField = 'sum(session)';
  326. } else if (result[2] === 'user') {
  327. metricField = 'count_unique(user)';
  328. }
  329. }
  330. results.push({
  331. seriesName: getSeriesName(status, group, queryAlias),
  332. data: data.intervals.map((interval, index) => ({
  333. name: interval,
  334. value: metricField ? group.series[metricField][index] ?? 0 : 0,
  335. })),
  336. });
  337. }
  338. });
  339. }
  340. });
  341. return results;
  342. }
  343. function fieldsToDerivedMetrics(field: string): string {
  344. return FIELD_TO_METRICS_EXPRESSION[field] ?? field;
  345. }
  346. function getReleasesRequest(
  347. includeSeries: number,
  348. includeTotals: number,
  349. api: Client,
  350. query: WidgetQuery,
  351. organization: Organization,
  352. pageFilters: PageFilters,
  353. interval?: string,
  354. limit?: number,
  355. cursor?: string
  356. ) {
  357. const {environments, projects, datetime} = pageFilters;
  358. const {start, end, period} = datetime;
  359. let showIncompleteDataAlert: boolean = false;
  360. if (start) {
  361. let startDate: Date | undefined = undefined;
  362. if (typeof start === 'string') {
  363. startDate = new Date(start);
  364. } else {
  365. startDate = start;
  366. }
  367. showIncompleteDataAlert = startDate < METRICS_BACKED_SESSIONS_START_DATE;
  368. } else if (period) {
  369. const periodInDays = statsPeriodToDays(period);
  370. const current = new Date();
  371. const prior = new Date(new Date().setDate(current.getDate() - periodInDays));
  372. showIncompleteDataAlert = prior < METRICS_BACKED_SESSIONS_START_DATE;
  373. }
  374. if (showIncompleteDataAlert) {
  375. return Promise.reject(
  376. new Error(
  377. t(
  378. 'Releases data is only available from Jul 12. Please retry your query with a more recent date range.'
  379. )
  380. )
  381. );
  382. }
  383. // Only time we need to use sessions API is when session.status is requested
  384. // as a group by.
  385. const useSessionAPI = query.columns.includes('session.status');
  386. const isCustomReleaseSorting = requiresCustomReleaseSorting(query);
  387. const isDescending = query.orderby.startsWith('-');
  388. const rawOrderby = trimStart(query.orderby, '-');
  389. const unsupportedOrderby =
  390. DISABLED_SORT.includes(rawOrderby) || useSessionAPI || rawOrderby === 'release';
  391. const columns = query.columns;
  392. // Temporary solution to support sorting on releases when querying the
  393. // Metrics API:
  394. //
  395. // We first request the top 50 recent releases from postgres. Note that the
  396. // release request is based on the project and environment selected in the
  397. // page filters.
  398. //
  399. // We then construct a massive OR condition and append it to any specified
  400. // filter condition. We also maintain an ordered array of release versions
  401. // to order the results returned from the metrics endpoint.
  402. //
  403. // Also note that we request a limit of 100 on the metrics endpoint, this
  404. // is because in a query, the limit should be applied after the results are
  405. // sorted based on the release version. The larger number of rows we
  406. // request, the more accurate our results are going to be.
  407. //
  408. // After the results are sorted, we truncate the data to the requested
  409. // limit. This will result in a few edge cases:
  410. //
  411. // 1. low to high sort may not show releases at the beginning of the
  412. // selected period if there are more than 50 releases in the selected
  413. // period.
  414. //
  415. // 2. if a recent release is not returned due to the 100 row limit
  416. // imposed on the metrics query the user won't see it on the
  417. // table/chart/
  418. //
  419. const {aggregates, injectedFields} = resolveDerivedStatusFields(
  420. query.aggregates,
  421. query.orderby,
  422. useSessionAPI
  423. );
  424. let requestData;
  425. let requester;
  426. if (useSessionAPI) {
  427. const sessionAggregates = aggregates.filter(
  428. agg => !Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields)
  429. );
  430. requestData = {
  431. field: sessionAggregates,
  432. orgSlug: organization.slug,
  433. end,
  434. environment: environments,
  435. groupBy: columns,
  436. limit: undefined,
  437. orderBy: '', // Orderby not supported with session.status
  438. interval,
  439. project: projects,
  440. query: query.conditions,
  441. start,
  442. statsPeriod: period,
  443. includeAllArgs: true,
  444. cursor,
  445. };
  446. requester = doSessionsRequest;
  447. } else {
  448. requestData = {
  449. field: aggregates.map(fieldsToDerivedMetrics),
  450. orgSlug: organization.slug,
  451. end,
  452. environment: environments,
  453. groupBy: columns.map(fieldsToDerivedMetrics),
  454. limit: columns.length === 0 ? 1 : isCustomReleaseSorting ? 100 : limit,
  455. orderBy: unsupportedOrderby
  456. ? ''
  457. : isDescending
  458. ? `-${fieldsToDerivedMetrics(rawOrderby)}`
  459. : fieldsToDerivedMetrics(rawOrderby),
  460. interval,
  461. project: projects,
  462. query: query.conditions,
  463. start,
  464. statsPeriod: period,
  465. includeAllArgs: true,
  466. cursor,
  467. includeSeries,
  468. includeTotals,
  469. };
  470. requester = doMetricsRequest;
  471. if (
  472. rawOrderby &&
  473. !unsupportedOrderby &&
  474. !aggregates.includes(rawOrderby) &&
  475. !columns.includes(rawOrderby)
  476. ) {
  477. requestData.field = [...requestData.field, fieldsToDerivedMetrics(rawOrderby)];
  478. if (!injectedFields.includes(rawOrderby)) {
  479. injectedFields.push(rawOrderby);
  480. }
  481. }
  482. }
  483. return requester(api, requestData);
  484. }