releases.tsx 16 KB

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