releases.tsx 17 KB

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