releases.tsx 17 KB

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