releaseWidgetQueries.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import {Component} from 'react';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import isEqual from 'lodash/isEqual';
  4. import omit from 'lodash/omit';
  5. import trimStart from 'lodash/trimStart';
  6. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  7. import {doMetricsRequest} from 'sentry/actionCreators/metrics';
  8. import {doSessionsRequest} from 'sentry/actionCreators/sessions';
  9. import {Client, ResponseMeta} from 'sentry/api';
  10. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  11. import {t} from 'sentry/locale';
  12. import {
  13. MetricsApiResponse,
  14. OrganizationSummary,
  15. PageFilters,
  16. Release,
  17. SessionApiResponse,
  18. } from 'sentry/types';
  19. import {Series} from 'sentry/types/echarts';
  20. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  21. import {stripDerivedMetricsPrefix} from 'sentry/utils/discover/fields';
  22. import {TOP_N} from 'sentry/utils/discover/types';
  23. import {DEFAULT_TABLE_LIMIT, DisplayType, Widget} from '../types';
  24. import {getWidgetInterval} from '../utils';
  25. import {
  26. DERIVED_STATUS_METRICS_PATTERN,
  27. DerivedStatusFields,
  28. DISABLED_SORT,
  29. FIELD_TO_METRICS_EXPRESSION,
  30. METRICS_EXPRESSION_TO_FIELD,
  31. } from '../widgetBuilder/releaseWidget/fields';
  32. import {transformSessionsResponseToSeries} from './transformSessionsResponseToSeries';
  33. import {transformSessionsResponseToTable} from './transformSessionsResponseToTable';
  34. type Props = {
  35. api: Client;
  36. children: (
  37. props: Pick<
  38. State,
  39. 'loading' | 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'pageLinks'
  40. >
  41. ) => React.ReactNode;
  42. organization: OrganizationSummary;
  43. selection: PageFilters;
  44. widget: Widget;
  45. cursor?: string;
  46. includeAllArgs?: boolean;
  47. limit?: number;
  48. onDataFetched?: (results: {
  49. tableResults?: TableDataWithTitle[];
  50. timeseriesResults?: Series[];
  51. }) => void;
  52. };
  53. type State = {
  54. loading: boolean;
  55. errorMessage?: string;
  56. pageLinks?: string;
  57. queryFetchID?: symbol;
  58. rawResults?: SessionApiResponse[] | MetricsApiResponse[];
  59. releases?: Release[];
  60. tableResults?: TableDataWithTitle[];
  61. timeseriesResults?: Series[];
  62. };
  63. function fieldsToDerivedMetrics(field: string): string {
  64. return FIELD_TO_METRICS_EXPRESSION[field] ?? field;
  65. }
  66. export function derivedMetricsToField(field: string): string {
  67. return METRICS_EXPRESSION_TO_FIELD[field] ?? field;
  68. }
  69. /**
  70. * Given a list of requested fields, this function returns
  71. * 'aggregates' which is a list of aggregate functions that
  72. * can be passed to either Metrics or Sessions endpoints,
  73. * 'derivedStatusFields' which need to be requested from the
  74. * Metrics endpoint and 'injectFields' which are fields not
  75. * requested but required to calculate the value of a derived
  76. * status field so will need to be stripped away in post processing.
  77. */
  78. function resolveDerivedStatusFields(
  79. fields: string[],
  80. useSessionAPI: boolean
  81. ): {
  82. aggregates: string[];
  83. derivedStatusFields: string[];
  84. injectedFields: string[];
  85. } {
  86. const aggregates = fields.map(stripDerivedMetricsPrefix);
  87. const derivedStatusFields = aggregates.filter(agg =>
  88. Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields)
  89. );
  90. const injectedFields: string[] = [];
  91. if (!!!useSessionAPI) {
  92. return {aggregates, derivedStatusFields, injectedFields};
  93. }
  94. derivedStatusFields.forEach(field => {
  95. const result = field.match(DERIVED_STATUS_METRICS_PATTERN);
  96. if (result) {
  97. if (result[2] === 'user' && !!!aggregates.includes('count_unique(user)')) {
  98. injectedFields.push('count_unique(user)');
  99. aggregates.push('count_unique(user)');
  100. }
  101. if (result[2] === 'session' && !!!aggregates.includes('sum(session)')) {
  102. injectedFields.push('sum(session)');
  103. aggregates.push('sum(session)');
  104. }
  105. }
  106. });
  107. return {aggregates, derivedStatusFields, injectedFields};
  108. }
  109. class ReleaseWidgetQueries extends Component<Props, State> {
  110. state: State = {
  111. loading: true,
  112. queryFetchID: undefined,
  113. errorMessage: undefined,
  114. timeseriesResults: undefined,
  115. rawResults: undefined,
  116. tableResults: undefined,
  117. releases: undefined,
  118. };
  119. componentDidMount() {
  120. this._isMounted = true;
  121. if (this.requiresCustomReleaseSorting()) {
  122. this.fetchReleasesAndData();
  123. return;
  124. }
  125. this.fetchData();
  126. }
  127. componentDidUpdate(prevProps: Props) {
  128. const {loading, rawResults} = this.state;
  129. const {selection, widget, organization, limit, cursor} = this.props;
  130. const ignroredWidgetProps = [
  131. 'queries',
  132. 'title',
  133. 'id',
  134. 'layout',
  135. 'tempId',
  136. 'widgetType',
  137. ];
  138. const ignoredQueryProps = ['name', 'fields', 'aggregates', 'columns'];
  139. const widgetQueryNames = widget.queries.map(q => q.name);
  140. const prevWidgetQueryNames = prevProps.widget.queries.map(q => q.name);
  141. if (
  142. this.requiresCustomReleaseSorting() &&
  143. (!isEqual(
  144. widget.queries.map(q => q.orderby),
  145. prevProps.widget.queries.map(q => q.orderby)
  146. ) ||
  147. !isSelectionEqual(selection, prevProps.selection) ||
  148. !isEqual(organization, prevProps.organization))
  149. ) {
  150. this.fetchReleasesAndData();
  151. return;
  152. }
  153. if (
  154. limit !== prevProps.limit ||
  155. organization.slug !== prevProps.organization.slug ||
  156. !isSelectionEqual(selection, prevProps.selection) ||
  157. // If the widget changed (ignore unimportant fields, + queries as they are handled lower)
  158. !isEqual(
  159. omit(widget, ignroredWidgetProps),
  160. omit(prevProps.widget, ignroredWidgetProps)
  161. ) ||
  162. // If the queries changed (ignore unimportant name, + fields as they are handled lower)
  163. !isEqual(
  164. widget.queries.map(q => omit(q, ignoredQueryProps)),
  165. prevProps.widget.queries.map(q => omit(q, ignoredQueryProps))
  166. ) ||
  167. // If the fields changed (ignore falsy/empty fields -> they can happen after clicking on Add Overlay)
  168. !isEqual(
  169. widget.queries.flatMap(q => q.fields?.filter(field => !!field)),
  170. prevProps.widget.queries.flatMap(q => q.fields?.filter(field => !!field))
  171. ) ||
  172. !isEqual(
  173. widget.queries.flatMap(q => q.aggregates.filter(aggregate => !!aggregate)),
  174. prevProps.widget.queries.flatMap(q =>
  175. q.aggregates.filter(aggregate => !!aggregate)
  176. )
  177. ) ||
  178. !isEqual(
  179. widget.queries.flatMap(q => q.columns.filter(column => !!column)),
  180. prevProps.widget.queries.flatMap(q => q.columns.filter(column => !!column))
  181. ) ||
  182. cursor !== prevProps.cursor
  183. ) {
  184. this.fetchData();
  185. return;
  186. }
  187. // If the query names have changed, then update timeseries labels
  188. const useSessionAPI = widget.queries[0].columns.includes('session.status');
  189. if (
  190. !loading &&
  191. !isEqual(widgetQueryNames, prevWidgetQueryNames) &&
  192. rawResults?.length === widget.queries.length
  193. ) {
  194. const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields(
  195. widget.queries[0].aggregates,
  196. useSessionAPI
  197. );
  198. // eslint-disable-next-line react/no-did-update-set-state
  199. this.setState(prevState => {
  200. return {
  201. ...prevState,
  202. timeseriesResults: prevState.rawResults?.flatMap((rawResult, index) =>
  203. transformSessionsResponseToSeries(
  204. rawResult,
  205. derivedStatusFields,
  206. injectedFields,
  207. widget.queries[index].name
  208. )
  209. ),
  210. };
  211. });
  212. }
  213. }
  214. componentWillUnmount() {
  215. this._isMounted = false;
  216. }
  217. private _isMounted: boolean = false;
  218. get limit() {
  219. const {limit} = this.props;
  220. switch (this.props.widget.displayType) {
  221. case DisplayType.TOP_N:
  222. return TOP_N;
  223. case DisplayType.TABLE:
  224. return limit ?? DEFAULT_TABLE_LIMIT;
  225. case DisplayType.BIG_NUMBER:
  226. return 1;
  227. default:
  228. return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
  229. }
  230. }
  231. requiresCustomReleaseSorting() {
  232. const {widget} = this.props;
  233. const useMetricsAPI = !!!widget.queries[0].columns.includes('session.status');
  234. const rawOrderby = trimStart(widget.queries[0].orderby, '-');
  235. return useMetricsAPI && rawOrderby === 'release';
  236. }
  237. async fetchReleasesAndData() {
  238. const {selection, api, organization} = this.props;
  239. const {environments, projects} = selection;
  240. try {
  241. const releases = await api.requestPromise(
  242. `/organizations/${organization.slug}/releases/`,
  243. {
  244. method: 'GET',
  245. data: {
  246. sort: 'date',
  247. project: projects,
  248. per_page: 50,
  249. environments,
  250. },
  251. }
  252. );
  253. if (!this._isMounted) {
  254. return;
  255. }
  256. this.setState({releases});
  257. } catch (error) {
  258. addErrorMessage(
  259. error.responseJSON ? error.responseJSON.error : t('Error sorting by releases')
  260. );
  261. }
  262. this.fetchData();
  263. }
  264. fetchData() {
  265. const {selection, api, organization, widget, includeAllArgs, cursor, onDataFetched} =
  266. this.props;
  267. if (widget.displayType === DisplayType.WORLD_MAP) {
  268. this.setState({errorMessage: t('World Map is not supported by metrics.')});
  269. return;
  270. }
  271. const queryFetchID = Symbol('queryFetchID');
  272. this.setState({
  273. loading: true,
  274. errorMessage: undefined,
  275. timeseriesResults: [],
  276. rawResults: [],
  277. tableResults: [],
  278. queryFetchID,
  279. });
  280. const {environments, projects, datetime} = selection;
  281. const {start, end, period} = datetime;
  282. const promises: Promise<
  283. MetricsApiResponse | [MetricsApiResponse, string, ResponseMeta] | SessionApiResponse
  284. >[] = [];
  285. // Only time we need to use sessions API is when session.status is requested
  286. // as a group by.
  287. const useSessionAPI = widget.queries[0].columns.includes('session.status');
  288. const isDescending = widget.queries[0].orderby.startsWith('-');
  289. const rawOrderby = trimStart(widget.queries[0].orderby, '-');
  290. const unsupportedOrderby =
  291. DISABLED_SORT.includes(rawOrderby) || useSessionAPI || rawOrderby === 'release';
  292. // Temporary solution to support sorting on releases when querying the
  293. // Metrics API:
  294. //
  295. // We first request the top 50 recent releases from postgres. Note that the
  296. // release request is based on the project and environment selected in the
  297. // page filters.
  298. //
  299. // We then construct a massive OR condition and append it to any specified
  300. // filter condition. We also maintain an ordered array of release versions
  301. // to order the results returned from the metrics endpoint.
  302. //
  303. // Also note that we request a limit of 100 on the metrics endpoint, this
  304. // is because in a query, the limit should be applied after the results are
  305. // sorted based on the release version. The larger number of rows we
  306. // request, the more accurate our results are going to be.
  307. //
  308. // After the results are sorted, we truncate the data to the requested
  309. // limit. This will result in a few edge cases:
  310. //
  311. // 1. low to high sort may not show releases at the beginning of the
  312. // selected period if there are more than 50 releases in the selected
  313. // period.
  314. //
  315. // 2. if a recent release is not returned due to the 100 row limit
  316. // imposed on the metrics query the user won't see it on the
  317. // table/chart/
  318. //
  319. const isCustomReleaseSorting = this.requiresCustomReleaseSorting();
  320. const {releases} = this.state;
  321. const interval = getWidgetInterval(
  322. widget,
  323. {start, end, period},
  324. // requesting low fidelity for release sort because metrics api can't return 100 rows of high fidelity series data
  325. isCustomReleaseSorting ? 'low' : undefined
  326. );
  327. let releaseCondition = '';
  328. const releasesArray: string[] = [];
  329. if (isCustomReleaseSorting) {
  330. if (releases && releases.length === 1) {
  331. releaseCondition += `release:${releases[0].version}`;
  332. releasesArray.push(releases[0].version);
  333. }
  334. if (releases && releases.length > 1) {
  335. releaseCondition += 'release:[' + releases[0].version;
  336. releasesArray.push(releases[0].version);
  337. for (let i = 1; i < releases.length; i++) {
  338. releaseCondition += ',' + releases[i].version;
  339. releasesArray.push(releases[i].version);
  340. }
  341. releaseCondition += ']';
  342. if (!!!isDescending) {
  343. releasesArray.reverse();
  344. }
  345. }
  346. }
  347. const {aggregates, derivedStatusFields, injectedFields} = resolveDerivedStatusFields(
  348. widget.queries[0].aggregates,
  349. useSessionAPI
  350. );
  351. const columns = widget.queries[0].columns;
  352. const includeSeries = widget.displayType !== DisplayType.TABLE ? 1 : 0;
  353. const includeTotals =
  354. widget.displayType === DisplayType.TABLE ||
  355. widget.displayType === DisplayType.BIG_NUMBER ||
  356. columns.length > 0
  357. ? 1
  358. : 0;
  359. widget.queries.forEach(query => {
  360. let requestData;
  361. let requester;
  362. if (useSessionAPI) {
  363. const sessionAggregates = aggregates.filter(
  364. agg =>
  365. !!!Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields)
  366. );
  367. requestData = {
  368. field: sessionAggregates,
  369. orgSlug: organization.slug,
  370. end,
  371. environment: environments,
  372. groupBy: columns,
  373. limit: undefined,
  374. orderBy: '', // Orderby not supported with session.status
  375. interval,
  376. project: projects,
  377. query: query.conditions,
  378. start,
  379. statsPeriod: period,
  380. includeAllArgs,
  381. cursor,
  382. };
  383. requester = doSessionsRequest;
  384. } else {
  385. requestData = {
  386. field: aggregates.map(fieldsToDerivedMetrics),
  387. orgSlug: organization.slug,
  388. end,
  389. environment: environments,
  390. groupBy: columns.map(fieldsToDerivedMetrics),
  391. limit: columns.length === 0 ? 1 : isCustomReleaseSorting ? 100 : this.limit,
  392. orderBy: unsupportedOrderby
  393. ? ''
  394. : isDescending
  395. ? `-${fieldsToDerivedMetrics(rawOrderby)}`
  396. : fieldsToDerivedMetrics(rawOrderby),
  397. interval,
  398. project: projects,
  399. query:
  400. query.conditions + (releaseCondition === '' ? '' : ` ${releaseCondition}`),
  401. start,
  402. statsPeriod: period,
  403. includeAllArgs,
  404. cursor,
  405. includeSeries,
  406. includeTotals,
  407. };
  408. requester = doMetricsRequest;
  409. if (
  410. rawOrderby &&
  411. !!!unsupportedOrderby &&
  412. !!!aggregates.includes(rawOrderby) &&
  413. !!!columns.includes(rawOrderby)
  414. ) {
  415. requestData.field = [...requestData.field, fieldsToDerivedMetrics(rawOrderby)];
  416. if (!!!injectedFields.includes(rawOrderby)) {
  417. injectedFields.push(rawOrderby);
  418. }
  419. }
  420. }
  421. promises.push(requester(api, requestData));
  422. });
  423. let completed = 0;
  424. promises.forEach(async (promise, requestIndex) => {
  425. try {
  426. const res = await promise;
  427. let data: SessionApiResponse | MetricsApiResponse;
  428. let response: ResponseMeta;
  429. if (Array.isArray(res)) {
  430. data = res[0];
  431. response = res[2];
  432. } else {
  433. data = res;
  434. }
  435. if (!this._isMounted) {
  436. return;
  437. }
  438. this.setState(prevState => {
  439. if (prevState.queryFetchID !== queryFetchID) {
  440. // invariant: a different request was initiated after this request
  441. return prevState;
  442. }
  443. if (releasesArray.length) {
  444. data.groups.sort(function (group1, group2) {
  445. const release1 = group1.by.release;
  446. const release2 = group2.by.release;
  447. return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
  448. });
  449. data.groups = data.groups.slice(0, this.limit);
  450. }
  451. // Transform to fit the table format
  452. let tableResults: TableDataWithTitle[] | undefined;
  453. if (includeTotals) {
  454. const tableData = transformSessionsResponseToTable(
  455. data,
  456. derivedStatusFields,
  457. injectedFields
  458. ) as TableDataWithTitle; // Cast so we can add the title.
  459. tableData.title = widget.queries[requestIndex]?.name ?? '';
  460. tableResults = [...(prevState.tableResults ?? []), tableData];
  461. } else {
  462. tableResults = undefined;
  463. }
  464. // Transform to fit the chart format
  465. const timeseriesResults = [...(prevState.timeseriesResults ?? [])];
  466. if (includeSeries) {
  467. const transformedResult = transformSessionsResponseToSeries(
  468. data,
  469. derivedStatusFields,
  470. injectedFields,
  471. widget.queries[requestIndex].name
  472. );
  473. // When charting timeseriesData on echarts, color association to a timeseries result
  474. // is order sensitive, ie series at index i on the timeseries array will use color at
  475. // index i on the color array. This means that on multi series results, we need to make
  476. // sure that the order of series in our results do not change between fetches to avoid
  477. // coloring inconsistencies between renders.
  478. transformedResult.forEach((result, resultIndex) => {
  479. timeseriesResults[requestIndex * transformedResult.length + resultIndex] =
  480. result;
  481. });
  482. }
  483. onDataFetched?.({timeseriesResults, tableResults});
  484. if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
  485. return {
  486. ...prevState,
  487. errorMessage: undefined,
  488. tableResults,
  489. pageLinks: response?.getResponseHeader('link') ?? undefined,
  490. };
  491. }
  492. const rawResultsClone = cloneDeep(prevState.rawResults ?? []);
  493. rawResultsClone[requestIndex] = data;
  494. return {
  495. ...prevState,
  496. errorMessage: undefined,
  497. timeseriesResults,
  498. rawResults: rawResultsClone,
  499. pageLinks: response?.getResponseHeader('link') ?? undefined,
  500. };
  501. });
  502. } catch (err) {
  503. const errorMessage = err?.responseJSON?.detail || t('An unknown error occurred.');
  504. if (!this._isMounted) {
  505. return;
  506. }
  507. this.setState({errorMessage});
  508. } finally {
  509. completed++;
  510. if (!this._isMounted) {
  511. return;
  512. }
  513. this.setState(prevState => {
  514. if (prevState.queryFetchID !== queryFetchID) {
  515. // invariant: a different request was initiated after this request
  516. return prevState;
  517. }
  518. return {
  519. ...prevState,
  520. loading: completed === promises.length ? false : true,
  521. };
  522. });
  523. }
  524. });
  525. }
  526. render() {
  527. const {children} = this.props;
  528. const {loading, timeseriesResults, tableResults, errorMessage, pageLinks} =
  529. this.state;
  530. return children({
  531. loading,
  532. timeseriesResults,
  533. tableResults,
  534. errorMessage,
  535. pageLinks,
  536. });
  537. }
  538. }
  539. export default ReleaseWidgetQueries;