releases.tsx 18 KB

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