releasesRequest.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import {Component} from 'react';
  2. import {Location} from 'history';
  3. import isEqual from 'lodash/isEqual';
  4. import omit from 'lodash/omit';
  5. import pick from 'lodash/pick';
  6. import moment from 'moment';
  7. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  8. import {Client} from 'sentry/api';
  9. import {
  10. DateTimeObject,
  11. getDiffInMinutes,
  12. ONE_WEEK,
  13. TWENTY_FOUR_HOURS,
  14. TWO_WEEKS,
  15. } from 'sentry/components/charts/utils';
  16. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  17. import {URL_PARAM} from 'sentry/constants/pageFilters';
  18. import {t} from 'sentry/locale';
  19. import {
  20. HealthStatsPeriodOption,
  21. Organization,
  22. PageFilters,
  23. SessionApiResponse,
  24. SessionFieldWithOperation,
  25. } from 'sentry/types';
  26. import {defined, percent} from 'sentry/utils';
  27. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  28. import withApi from 'sentry/utils/withApi';
  29. import {getCrashFreePercent} from '../utils';
  30. import {ReleasesDisplayOption} from './releasesDisplayOptions';
  31. function omitIgnoredProps(props: Props) {
  32. return omit(props, [
  33. 'api',
  34. 'organization',
  35. 'children',
  36. 'selection.datetime.utc',
  37. 'location',
  38. ]);
  39. }
  40. function getInterval(datetimeObj: DateTimeObject) {
  41. const diffInMinutes = getDiffInMinutes(datetimeObj);
  42. if (diffInMinutes >= TWO_WEEKS) {
  43. return '1d';
  44. }
  45. if (diffInMinutes >= ONE_WEEK) {
  46. return '6h';
  47. }
  48. if (diffInMinutes > TWENTY_FOUR_HOURS) {
  49. return '4h';
  50. }
  51. // TODO(sessions): sub-hour session resolution is still not possible
  52. return '1h';
  53. }
  54. export function reduceTimeSeriesGroups(
  55. acc: number[],
  56. group: SessionApiResponse['groups'][number],
  57. field: 'count_unique(user)' | 'sum(session)'
  58. ) {
  59. group.series[field]?.forEach(
  60. (value, index) => (acc[index] = (acc[index] ?? 0) + value)
  61. );
  62. return acc;
  63. }
  64. export function sessionDisplayToField(display: ReleasesDisplayOption) {
  65. switch (display) {
  66. case ReleasesDisplayOption.USERS:
  67. return SessionFieldWithOperation.USERS;
  68. case ReleasesDisplayOption.SESSIONS:
  69. default:
  70. return SessionFieldWithOperation.SESSIONS;
  71. }
  72. }
  73. export type ReleasesRequestRenderProps = {
  74. errored: boolean;
  75. getHealthData: ReturnType<ReleasesRequest['getHealthData']>;
  76. isHealthLoading: boolean;
  77. };
  78. type Props = {
  79. api: Client;
  80. children: (renderProps: ReleasesRequestRenderProps) => React.ReactNode;
  81. display: ReleasesDisplayOption[];
  82. location: Location;
  83. organization: Organization;
  84. releases: string[];
  85. selection: PageFilters;
  86. defaultStatsPeriod?: string;
  87. disable?: boolean;
  88. healthStatsPeriod?: HealthStatsPeriodOption;
  89. releasesReloading?: boolean;
  90. };
  91. type State = {
  92. errored: boolean;
  93. loading: boolean;
  94. statusCountByProjectInPeriod: SessionApiResponse | null;
  95. statusCountByReleaseInPeriod: SessionApiResponse | null;
  96. totalCountByProjectIn24h: SessionApiResponse | null;
  97. totalCountByProjectInPeriod: SessionApiResponse | null;
  98. totalCountByReleaseIn24h: SessionApiResponse | null;
  99. totalCountByReleaseInPeriod: SessionApiResponse | null;
  100. };
  101. class ReleasesRequest extends Component<Props, State> {
  102. state: State = {
  103. loading: false,
  104. errored: false,
  105. statusCountByReleaseInPeriod: null,
  106. totalCountByReleaseIn24h: null,
  107. totalCountByProjectIn24h: null,
  108. statusCountByProjectInPeriod: null,
  109. totalCountByReleaseInPeriod: null,
  110. totalCountByProjectInPeriod: null,
  111. };
  112. componentDidMount() {
  113. this.fetchData();
  114. }
  115. componentDidUpdate(prevProps: Props) {
  116. if (this.props.releasesReloading) {
  117. return;
  118. }
  119. if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
  120. return;
  121. }
  122. this.fetchData();
  123. }
  124. get path() {
  125. const {organization} = this.props;
  126. return `/organizations/${organization.slug}/sessions/`;
  127. }
  128. get baseQueryParams() {
  129. const {location, selection, defaultStatsPeriod, releases} = this.props;
  130. return {
  131. query: new MutableSearch(
  132. releases.reduce((acc, release, index, allReleases) => {
  133. acc.push(`release:"${release}"`);
  134. if (index < allReleases.length - 1) {
  135. acc.push('OR');
  136. }
  137. return acc;
  138. }, [] as string[])
  139. ).formatString(),
  140. interval: getInterval(selection.datetime),
  141. ...normalizeDateTimeParams(pick(location.query, Object.values(URL_PARAM)), {
  142. defaultStatsPeriod,
  143. }),
  144. };
  145. }
  146. fetchData = async () => {
  147. const {api, healthStatsPeriod, disable} = this.props;
  148. if (disable) {
  149. return;
  150. }
  151. api.clear();
  152. this.setState({
  153. loading: true,
  154. errored: false,
  155. statusCountByReleaseInPeriod: null,
  156. totalCountByReleaseIn24h: null,
  157. totalCountByProjectIn24h: null,
  158. });
  159. const promises = [
  160. this.fetchStatusCountByReleaseInPeriod(),
  161. this.fetchTotalCountByReleaseIn24h(),
  162. this.fetchTotalCountByProjectIn24h(),
  163. ];
  164. if (healthStatsPeriod === HealthStatsPeriodOption.AUTO) {
  165. promises.push(this.fetchStatusCountByProjectInPeriod());
  166. promises.push(this.fetchTotalCountByReleaseInPeriod());
  167. promises.push(this.fetchTotalCountByProjectInPeriod());
  168. }
  169. try {
  170. const [
  171. statusCountByReleaseInPeriod,
  172. totalCountByReleaseIn24h,
  173. totalCountByProjectIn24h,
  174. statusCountByProjectInPeriod,
  175. totalCountByReleaseInPeriod,
  176. totalCountByProjectInPeriod,
  177. ] = await Promise.all(promises);
  178. this.setState({
  179. loading: false,
  180. statusCountByReleaseInPeriod,
  181. totalCountByReleaseIn24h,
  182. totalCountByProjectIn24h,
  183. statusCountByProjectInPeriod,
  184. totalCountByReleaseInPeriod,
  185. totalCountByProjectInPeriod,
  186. });
  187. } catch (error) {
  188. addErrorMessage(error.responseJSON?.detail ?? t('Error loading health data'));
  189. this.setState({
  190. loading: false,
  191. errored: true,
  192. });
  193. }
  194. };
  195. /**
  196. * Used to calculate crash free rate, count histogram (This Release series), and crash count
  197. */
  198. async fetchStatusCountByReleaseInPeriod() {
  199. const {api, display} = this.props;
  200. const response: SessionApiResponse = await api.requestPromise(this.path, {
  201. query: {
  202. ...this.baseQueryParams,
  203. field: [
  204. ...new Set([...display.map(d => sessionDisplayToField(d)), 'sum(session)']),
  205. ], // this request needs to be fired for sessions in both display options (because of crash count), removing potential sum(session) duplicated with Set
  206. groupBy: ['project', 'release', 'session.status'],
  207. },
  208. });
  209. return response;
  210. }
  211. /**
  212. * Used to calculate count histogram (Total Project series)
  213. */
  214. async fetchStatusCountByProjectInPeriod() {
  215. const {api, display} = this.props;
  216. const response: SessionApiResponse = await api.requestPromise(this.path, {
  217. query: {
  218. ...this.baseQueryParams,
  219. query: undefined,
  220. field: [
  221. ...new Set([...display.map(d => sessionDisplayToField(d)), 'sum(session)']),
  222. ],
  223. groupBy: ['project', 'session.status'],
  224. },
  225. });
  226. return response;
  227. }
  228. /**
  229. * Used to calculate adoption, and count histogram (This Release series)
  230. */
  231. async fetchTotalCountByReleaseIn24h() {
  232. const {api, display} = this.props;
  233. const response: SessionApiResponse = await api.requestPromise(this.path, {
  234. query: {
  235. ...this.baseQueryParams,
  236. field: display.map(d => sessionDisplayToField(d)),
  237. groupBy: ['project', 'release'],
  238. interval: '1h',
  239. statsPeriod: '24h',
  240. },
  241. });
  242. return response;
  243. }
  244. async fetchTotalCountByReleaseInPeriod() {
  245. const {api, display} = this.props;
  246. const response: SessionApiResponse = await api.requestPromise(this.path, {
  247. query: {
  248. ...this.baseQueryParams,
  249. field: display.map(d => sessionDisplayToField(d)),
  250. groupBy: ['project', 'release'],
  251. },
  252. });
  253. return response;
  254. }
  255. /**
  256. * Used to calculate adoption, and count histogram (Total Project series)
  257. */
  258. async fetchTotalCountByProjectIn24h() {
  259. const {api, display} = this.props;
  260. const response: SessionApiResponse = await api.requestPromise(this.path, {
  261. query: {
  262. ...this.baseQueryParams,
  263. query: undefined,
  264. field: display.map(d => sessionDisplayToField(d)),
  265. groupBy: ['project'],
  266. interval: '1h',
  267. statsPeriod: '24h',
  268. },
  269. });
  270. return response;
  271. }
  272. async fetchTotalCountByProjectInPeriod() {
  273. const {api, display} = this.props;
  274. const response: SessionApiResponse = await api.requestPromise(this.path, {
  275. query: {
  276. ...this.baseQueryParams,
  277. query: undefined,
  278. field: display.map(d => sessionDisplayToField(d)),
  279. groupBy: ['project'],
  280. },
  281. });
  282. return response;
  283. }
  284. getHealthData = () => {
  285. // TODO(sessions): investigate if this needs to be optimized to lower O(n) complexity
  286. return {
  287. getCrashCount: this.getCrashCount,
  288. getCrashFreeRate: this.getCrashFreeRate,
  289. get24hCountByRelease: this.get24hCountByRelease,
  290. get24hCountByProject: this.get24hCountByProject,
  291. getTimeSeries: this.getTimeSeries,
  292. getAdoption: this.getAdoption,
  293. };
  294. };
  295. getCrashCount = (version: string, project: number, display: ReleasesDisplayOption) => {
  296. const {statusCountByReleaseInPeriod} = this.state;
  297. const field = sessionDisplayToField(display);
  298. return statusCountByReleaseInPeriod?.groups.find(
  299. ({by}) =>
  300. by.release === version &&
  301. by.project === project &&
  302. by['session.status'] === 'crashed'
  303. )?.totals[field];
  304. };
  305. getCrashFreeRate = (
  306. version: string,
  307. project: number,
  308. display: ReleasesDisplayOption
  309. ) => {
  310. const {statusCountByReleaseInPeriod} = this.state;
  311. const field = sessionDisplayToField(display);
  312. const totalCount = statusCountByReleaseInPeriod?.groups
  313. .filter(({by}) => by.release === version && by.project === project)
  314. ?.reduce((acc, group) => acc + group.totals[field], 0);
  315. const crashedCount = this.getCrashCount(version, project, display);
  316. return !defined(totalCount) || totalCount === 0
  317. ? null
  318. : getCrashFreePercent(100 - percent(crashedCount ?? 0, totalCount ?? 0));
  319. };
  320. get24hCountByRelease = (
  321. version: string,
  322. project: number,
  323. display: ReleasesDisplayOption
  324. ) => {
  325. const {totalCountByReleaseIn24h} = this.state;
  326. const field = sessionDisplayToField(display);
  327. return totalCountByReleaseIn24h?.groups
  328. .filter(({by}) => by.release === version && by.project === project)
  329. ?.reduce((acc, group) => acc + group.totals[field], 0);
  330. };
  331. getPeriodCountByRelease = (
  332. version: string,
  333. project: number,
  334. display: ReleasesDisplayOption
  335. ) => {
  336. const {totalCountByReleaseInPeriod} = this.state;
  337. const field = sessionDisplayToField(display);
  338. return totalCountByReleaseInPeriod?.groups
  339. .filter(({by}) => by.release === version && by.project === project)
  340. ?.reduce((acc, group) => acc + group.totals[field], 0);
  341. };
  342. get24hCountByProject = (project: number, display: ReleasesDisplayOption) => {
  343. const {totalCountByProjectIn24h} = this.state;
  344. const field = sessionDisplayToField(display);
  345. return totalCountByProjectIn24h?.groups
  346. .filter(({by}) => by.project === project)
  347. ?.reduce((acc, group) => acc + group.totals[field], 0);
  348. };
  349. getPeriodCountByProject = (project: number, display: ReleasesDisplayOption) => {
  350. const {totalCountByProjectInPeriod} = this.state;
  351. const field = sessionDisplayToField(display);
  352. return totalCountByProjectInPeriod?.groups
  353. .filter(({by}) => by.project === project)
  354. ?.reduce((acc, group) => acc + group.totals[field], 0);
  355. };
  356. getTimeSeries = (version: string, project: number, display: ReleasesDisplayOption) => {
  357. const {healthStatsPeriod} = this.props;
  358. if (healthStatsPeriod === HealthStatsPeriodOption.AUTO) {
  359. return this.getPeriodTimeSeries(version, project, display);
  360. }
  361. return this.get24hTimeSeries(version, project, display);
  362. };
  363. get24hTimeSeries = (
  364. version: string,
  365. project: number,
  366. display: ReleasesDisplayOption
  367. ) => {
  368. const {totalCountByReleaseIn24h, totalCountByProjectIn24h} = this.state;
  369. const field = sessionDisplayToField(display);
  370. const intervals = totalCountByProjectIn24h?.intervals ?? [];
  371. const projectData = totalCountByProjectIn24h?.groups.find(
  372. ({by}) => by.project === project
  373. )?.series[field];
  374. const releaseData = totalCountByReleaseIn24h?.groups.find(
  375. ({by}) => by.project === project && by.release === version
  376. )?.series[field];
  377. return [
  378. {
  379. seriesName: t('This Release'),
  380. data: intervals?.map((interval, index) => ({
  381. name: moment(interval).valueOf(),
  382. value: releaseData?.[index] ?? 0,
  383. })),
  384. },
  385. {
  386. seriesName: t('Total Project'),
  387. data: intervals?.map((interval, index) => ({
  388. name: moment(interval).valueOf(),
  389. value: projectData?.[index] ?? 0,
  390. })),
  391. z: 0,
  392. },
  393. ];
  394. };
  395. getPeriodTimeSeries = (
  396. version: string,
  397. project: number,
  398. display: ReleasesDisplayOption
  399. ) => {
  400. const {statusCountByReleaseInPeriod, statusCountByProjectInPeriod} = this.state;
  401. const field = sessionDisplayToField(display);
  402. const intervals = statusCountByProjectInPeriod?.intervals ?? [];
  403. const projectData = statusCountByProjectInPeriod?.groups
  404. .filter(({by}) => by.project === project)
  405. ?.reduce((acc, group) => reduceTimeSeriesGroups(acc, group, field), [] as number[]);
  406. const releaseData = statusCountByReleaseInPeriod?.groups
  407. .filter(({by}) => by.project === project && by.release === version)
  408. ?.reduce((acc, group) => reduceTimeSeriesGroups(acc, group, field), [] as number[]);
  409. return [
  410. {
  411. seriesName: t('This Release'),
  412. data: intervals?.map((interval, index) => ({
  413. name: moment(interval).valueOf(),
  414. value: releaseData?.[index] ?? 0,
  415. })),
  416. },
  417. {
  418. seriesName: t('Total Project'),
  419. data: intervals?.map((interval, index) => ({
  420. name: moment(interval).valueOf(),
  421. value: projectData?.[index] ?? 0,
  422. })),
  423. z: 0,
  424. },
  425. ];
  426. };
  427. getAdoption = (version: string, project: number, display: ReleasesDisplayOption) => {
  428. const {healthStatsPeriod} = this.props;
  429. const countByRelease = (
  430. healthStatsPeriod === HealthStatsPeriodOption.AUTO
  431. ? this.getPeriodCountByRelease
  432. : this.get24hCountByRelease
  433. )(version, project, display);
  434. const countByProject = (
  435. healthStatsPeriod === HealthStatsPeriodOption.AUTO
  436. ? this.getPeriodCountByProject
  437. : this.get24hCountByProject
  438. )(project, display);
  439. return defined(countByRelease) && defined(countByProject)
  440. ? percent(countByRelease, countByProject)
  441. : undefined;
  442. };
  443. render() {
  444. const {loading, errored} = this.state;
  445. const {children} = this.props;
  446. return children({
  447. isHealthLoading: loading,
  448. errored,
  449. getHealthData: this.getHealthData(),
  450. });
  451. }
  452. }
  453. export default withApi(ReleasesRequest);