projectSessionsChartRequest.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import {Component} from 'react';
  2. import {Theme, withTheme} from '@emotion/react';
  3. import {LineSeriesOption} from 'echarts';
  4. import isEqual from 'lodash/isEqual';
  5. import omit from 'lodash/omit';
  6. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  7. import {Client} from 'sentry/api';
  8. import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
  9. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  10. import {t} from 'sentry/locale';
  11. import {
  12. Organization,
  13. PageFilters,
  14. SessionApiResponse,
  15. SessionFieldWithOperation,
  16. SessionStatus,
  17. } from 'sentry/types';
  18. import {Series} from 'sentry/types/echarts';
  19. import {percent} from 'sentry/utils';
  20. import {getPeriod} from 'sentry/utils/getPeriod';
  21. import {
  22. filterSessionsInTimeWindow,
  23. getCount,
  24. getCountSeries,
  25. getSessionsInterval,
  26. initSessionsChart,
  27. } from 'sentry/utils/sessions';
  28. import {getCrashFreePercent} from 'sentry/views/releases/utils';
  29. import {DisplayModes} from '../projectCharts';
  30. const omitIgnoredProps = (props: ProjectSessionsChartRequestProps) =>
  31. omit(props, ['api', 'organization', 'children', 'selection.datetime.utc']);
  32. type ProjectSessionsChartRequestRenderProps = {
  33. errored: boolean;
  34. loading: boolean;
  35. previousTimeseriesData: Series | null;
  36. reloading: boolean;
  37. timeseriesData: Series[];
  38. totalSessions: number | null;
  39. additionalSeries?: LineSeriesOption[];
  40. };
  41. export type ProjectSessionsChartRequestProps = {
  42. api: Client;
  43. children: (renderProps: ProjectSessionsChartRequestRenderProps) => React.ReactNode;
  44. displayMode:
  45. | DisplayModes.SESSIONS
  46. | DisplayModes.STABILITY
  47. | DisplayModes.STABILITY_USERS
  48. | DisplayModes.ANR_RATE
  49. | DisplayModes.FOREGROUND_ANR_RATE;
  50. onTotalValuesChange: (value: number | null) => void;
  51. organization: Organization;
  52. selection: PageFilters;
  53. theme: Theme;
  54. disablePrevious?: boolean;
  55. query?: string;
  56. };
  57. type State = {
  58. errored: boolean;
  59. previousTimeseriesData: Series | null;
  60. reloading: boolean;
  61. timeseriesData: Series[] | null;
  62. totalSessions: number | null;
  63. };
  64. class ProjectSessionsChartRequest extends Component<
  65. ProjectSessionsChartRequestProps,
  66. State
  67. > {
  68. state: State = {
  69. reloading: false,
  70. errored: false,
  71. timeseriesData: null,
  72. previousTimeseriesData: null,
  73. totalSessions: null,
  74. };
  75. componentDidMount() {
  76. this.fetchData();
  77. }
  78. componentDidUpdate(prevProps: ProjectSessionsChartRequestProps) {
  79. if (!isEqual(omitIgnoredProps(this.props), omitIgnoredProps(prevProps))) {
  80. this.fetchData();
  81. }
  82. }
  83. componentWillUnmount() {
  84. this.unmounting = true;
  85. }
  86. private unmounting: boolean = false;
  87. fetchData = async () => {
  88. const {
  89. api,
  90. selection: {datetime},
  91. onTotalValuesChange,
  92. displayMode,
  93. disablePrevious,
  94. } = this.props;
  95. const shouldFetchWithPrevious =
  96. !disablePrevious &&
  97. shouldFetchPreviousPeriod({
  98. start: datetime.start,
  99. end: datetime.end,
  100. period: datetime.period,
  101. });
  102. this.setState(state => ({
  103. reloading: state.timeseriesData !== null,
  104. errored: false,
  105. }));
  106. try {
  107. const queryParams = this.queryParams({shouldFetchWithPrevious});
  108. const requests = [
  109. api.requestPromise(this.path, {
  110. query: queryParams,
  111. }),
  112. ];
  113. // for users, we need to make a separate request to get the total count in period
  114. if (displayMode === DisplayModes.STABILITY_USERS) {
  115. requests.push(
  116. api.requestPromise(this.path, {
  117. query: {
  118. ...queryParams,
  119. groupBy: undefined,
  120. ...(shouldFetchWithPrevious ? {statsPeriod: datetime.period} : {}),
  121. },
  122. })
  123. );
  124. }
  125. const [response, responseUsersTotal]: SessionApiResponse[] = await Promise.all(
  126. requests
  127. );
  128. const filteredResponse = filterSessionsInTimeWindow(
  129. response,
  130. queryParams.start,
  131. queryParams.end
  132. );
  133. // totalSessions can't be used when we're talking about users
  134. // users are a set and counting together buckets or statuses is not correct
  135. // because one user can be present in multiple buckets/statuses
  136. const {timeseriesData, previousTimeseriesData, totalSessions} =
  137. displayMode === DisplayModes.SESSIONS
  138. ? this.transformSessionCountData(filteredResponse)
  139. : this.transformData(filteredResponse, {
  140. fetchedWithPrevious: shouldFetchWithPrevious,
  141. });
  142. const totalUsers = responseUsersTotal?.groups[0].totals[this.field];
  143. const totalNumber =
  144. displayMode === DisplayModes.STABILITY_USERS ? totalUsers : totalSessions;
  145. if (this.unmounting) {
  146. return;
  147. }
  148. this.setState({
  149. reloading: false,
  150. timeseriesData,
  151. previousTimeseriesData,
  152. totalSessions: totalNumber,
  153. });
  154. onTotalValuesChange(totalNumber);
  155. } catch {
  156. addErrorMessage(t('Error loading chart data'));
  157. this.setState({
  158. errored: true,
  159. reloading: false,
  160. timeseriesData: null,
  161. previousTimeseriesData: null,
  162. totalSessions: null,
  163. });
  164. }
  165. };
  166. get path() {
  167. const {organization} = this.props;
  168. return `/organizations/${organization.slug}/sessions/`;
  169. }
  170. get field() {
  171. const {displayMode} = this.props;
  172. return displayMode === DisplayModes.STABILITY_USERS
  173. ? SessionFieldWithOperation.USERS
  174. : SessionFieldWithOperation.SESSIONS;
  175. }
  176. queryParams({shouldFetchWithPrevious = false}): Record<string, any> {
  177. const {selection, query, organization} = this.props;
  178. const {datetime, projects, environments: environment} = selection;
  179. const baseParams = {
  180. field: this.field,
  181. groupBy: 'session.status',
  182. interval: getSessionsInterval(datetime, {
  183. highFidelity: organization.features.includes('minute-resolution-sessions'),
  184. }),
  185. project: projects[0],
  186. environment,
  187. query,
  188. };
  189. if (!shouldFetchWithPrevious) {
  190. return {
  191. ...baseParams,
  192. ...normalizeDateTimeParams(datetime),
  193. };
  194. }
  195. const {period} = selection.datetime;
  196. const doubledPeriod = getPeriod(
  197. {period, start: undefined, end: undefined},
  198. {shouldDoublePeriod: true}
  199. ).statsPeriod;
  200. return {
  201. ...baseParams,
  202. statsPeriod: doubledPeriod,
  203. };
  204. }
  205. transformData(responseData: SessionApiResponse, {fetchedWithPrevious = false}) {
  206. const {theme} = this.props;
  207. const {field} = this;
  208. // Take the floor just in case, but data should always be divisible by 2
  209. const dataMiddleIndex = Math.floor(responseData.intervals.length / 2);
  210. // calculate the total number of sessions for this period (exclude previous if there)
  211. const totalSessions = responseData.groups.reduce(
  212. (acc, group) =>
  213. acc +
  214. group.series[field]
  215. .slice(fetchedWithPrevious ? dataMiddleIndex : 0)
  216. .reduce((value, groupAcc) => groupAcc + value, 0),
  217. 0
  218. );
  219. const previousPeriodTotalSessions = fetchedWithPrevious
  220. ? responseData.groups.reduce(
  221. (acc, group) =>
  222. acc +
  223. group.series[field]
  224. .slice(0, dataMiddleIndex)
  225. .reduce((value, groupAcc) => groupAcc + value, 0),
  226. 0
  227. )
  228. : 0;
  229. // TODO(project-details): refactor this to avoid duplication as we add more session charts
  230. const timeseriesData = [
  231. {
  232. seriesName: t('This Period'),
  233. color: theme.green300,
  234. data: responseData.intervals
  235. .slice(fetchedWithPrevious ? dataMiddleIndex : 0)
  236. .map((interval, i) => {
  237. const totalIntervalSessions = responseData.groups.reduce(
  238. (acc, group) =>
  239. acc +
  240. group.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i],
  241. 0
  242. );
  243. const intervalCrashedSessions =
  244. responseData.groups
  245. .find(group => group.by['session.status'] === 'crashed')
  246. ?.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i] ?? 0;
  247. const crashedSessionsPercent = percent(
  248. intervalCrashedSessions,
  249. totalIntervalSessions
  250. );
  251. return {
  252. name: interval,
  253. value:
  254. totalSessions === 0 && previousPeriodTotalSessions === 0
  255. ? 0
  256. : totalIntervalSessions === 0
  257. ? null
  258. : getCrashFreePercent(100 - crashedSessionsPercent),
  259. };
  260. }),
  261. },
  262. ] as Series[]; // TODO(project-detail): Change SeriesDataUnit value to support null
  263. const previousTimeseriesData = fetchedWithPrevious
  264. ? ({
  265. seriesName: t('Previous Period'),
  266. data: responseData.intervals.slice(0, dataMiddleIndex).map((_interval, i) => {
  267. const totalIntervalSessions = responseData.groups.reduce(
  268. (acc, group) => acc + group.series[field].slice(0, dataMiddleIndex)[i],
  269. 0
  270. );
  271. const intervalCrashedSessions =
  272. responseData.groups
  273. .find(group => group.by['session.status'] === 'crashed')
  274. ?.series[field].slice(0, dataMiddleIndex)[i] ?? 0;
  275. const crashedSessionsPercent = percent(
  276. intervalCrashedSessions,
  277. totalIntervalSessions
  278. );
  279. return {
  280. name: responseData.intervals[i + dataMiddleIndex],
  281. value:
  282. totalSessions === 0 && previousPeriodTotalSessions === 0
  283. ? 0
  284. : totalIntervalSessions === 0
  285. ? null
  286. : getCrashFreePercent(100 - crashedSessionsPercent),
  287. };
  288. }),
  289. } as Series) // TODO(project-detail): Change SeriesDataUnit value to support null
  290. : null;
  291. return {
  292. totalSessions,
  293. timeseriesData,
  294. previousTimeseriesData,
  295. };
  296. }
  297. transformSessionCountData(responseData: SessionApiResponse) {
  298. const {theme} = this.props;
  299. const sessionsChart = initSessionsChart(theme);
  300. const {intervals, groups} = responseData;
  301. const totalSessions = getCount(
  302. responseData.groups,
  303. SessionFieldWithOperation.SESSIONS
  304. );
  305. const chartData = [
  306. {
  307. ...sessionsChart[SessionStatus.HEALTHY],
  308. data: getCountSeries(
  309. SessionFieldWithOperation.SESSIONS,
  310. groups.find(g => g.by['session.status'] === SessionStatus.HEALTHY),
  311. intervals
  312. ),
  313. },
  314. {
  315. ...sessionsChart[SessionStatus.ERRORED],
  316. data: getCountSeries(
  317. SessionFieldWithOperation.SESSIONS,
  318. groups.find(g => g.by['session.status'] === SessionStatus.ERRORED),
  319. intervals
  320. ),
  321. },
  322. {
  323. ...sessionsChart[SessionStatus.ABNORMAL],
  324. data: getCountSeries(
  325. SessionFieldWithOperation.SESSIONS,
  326. groups.find(g => g.by['session.status'] === SessionStatus.ABNORMAL),
  327. intervals
  328. ),
  329. },
  330. {
  331. ...sessionsChart[SessionStatus.CRASHED],
  332. data: getCountSeries(
  333. SessionFieldWithOperation.SESSIONS,
  334. groups.find(g => g.by['session.status'] === SessionStatus.CRASHED),
  335. intervals
  336. ),
  337. },
  338. ];
  339. return {
  340. timeseriesData: chartData,
  341. previousTimeseriesData: null,
  342. totalSessions,
  343. };
  344. }
  345. render() {
  346. const {children} = this.props;
  347. const {timeseriesData, reloading, errored, totalSessions, previousTimeseriesData} =
  348. this.state;
  349. const loading = timeseriesData === null;
  350. return children({
  351. loading,
  352. reloading,
  353. errored,
  354. totalSessions,
  355. previousTimeseriesData,
  356. timeseriesData: timeseriesData ?? [],
  357. });
  358. }
  359. }
  360. export default withTheme(ProjectSessionsChartRequest);