releases.tsx 15 KB

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