projectSessionsChartRequest.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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[] =
  126. await Promise.all(requests);
  127. const filteredResponse = filterSessionsInTimeWindow(
  128. response,
  129. queryParams.start,
  130. queryParams.end
  131. );
  132. // totalSessions can't be used when we're talking about users
  133. // users are a set and counting together buckets or statuses is not correct
  134. // because one user can be present in multiple buckets/statuses
  135. const {timeseriesData, previousTimeseriesData, totalSessions} =
  136. displayMode === DisplayModes.SESSIONS
  137. ? this.transformSessionCountData(filteredResponse)
  138. : this.transformData(filteredResponse, {
  139. fetchedWithPrevious: shouldFetchWithPrevious,
  140. });
  141. const totalUsers = responseUsersTotal?.groups[0].totals[this.field];
  142. const totalNumber =
  143. displayMode === DisplayModes.STABILITY_USERS ? totalUsers : totalSessions;
  144. if (this.unmounting) {
  145. return;
  146. }
  147. this.setState({
  148. reloading: false,
  149. timeseriesData,
  150. previousTimeseriesData,
  151. totalSessions: totalNumber,
  152. });
  153. onTotalValuesChange(totalNumber);
  154. } catch {
  155. addErrorMessage(t('Error loading chart data'));
  156. this.setState({
  157. errored: true,
  158. reloading: false,
  159. timeseriesData: null,
  160. previousTimeseriesData: null,
  161. totalSessions: null,
  162. });
  163. }
  164. };
  165. get path() {
  166. const {organization} = this.props;
  167. return `/organizations/${organization.slug}/sessions/`;
  168. }
  169. get field() {
  170. const {displayMode} = this.props;
  171. return displayMode === DisplayModes.STABILITY_USERS
  172. ? SessionFieldWithOperation.USERS
  173. : SessionFieldWithOperation.SESSIONS;
  174. }
  175. queryParams({shouldFetchWithPrevious = false}): Record<string, any> {
  176. const {selection, query, organization} = this.props;
  177. const {datetime, projects, environments: environment} = selection;
  178. const baseParams = {
  179. field: this.field,
  180. groupBy: 'session.status',
  181. interval: getSessionsInterval(datetime, {
  182. highFidelity: organization.features.includes('minute-resolution-sessions'),
  183. }),
  184. project: projects[0],
  185. environment,
  186. query,
  187. };
  188. if (!shouldFetchWithPrevious) {
  189. return {
  190. ...baseParams,
  191. ...normalizeDateTimeParams(datetime),
  192. };
  193. }
  194. const {period} = selection.datetime;
  195. const doubledPeriod = getPeriod(
  196. {period, start: undefined, end: undefined},
  197. {shouldDoublePeriod: true}
  198. ).statsPeriod;
  199. return {
  200. ...baseParams,
  201. statsPeriod: doubledPeriod,
  202. };
  203. }
  204. transformData(responseData: SessionApiResponse, {fetchedWithPrevious = false}) {
  205. const {theme} = this.props;
  206. const {field} = this;
  207. // Take the floor just in case, but data should always be divisible by 2
  208. const dataMiddleIndex = Math.floor(responseData.intervals.length / 2);
  209. // calculate the total number of sessions for this period (exclude previous if there)
  210. const totalSessions = responseData.groups.reduce(
  211. (acc, group) =>
  212. acc +
  213. group.series[field]
  214. .slice(fetchedWithPrevious ? dataMiddleIndex : 0)
  215. .reduce((value, groupAcc) => groupAcc + value, 0),
  216. 0
  217. );
  218. const previousPeriodTotalSessions = fetchedWithPrevious
  219. ? responseData.groups.reduce(
  220. (acc, group) =>
  221. acc +
  222. group.series[field]
  223. .slice(0, dataMiddleIndex)
  224. .reduce((value, groupAcc) => groupAcc + value, 0),
  225. 0
  226. )
  227. : 0;
  228. // TODO(project-details): refactor this to avoid duplication as we add more session charts
  229. const timeseriesData = [
  230. {
  231. seriesName: t('This Period'),
  232. color: theme.green300,
  233. data: responseData.intervals
  234. .slice(fetchedWithPrevious ? dataMiddleIndex : 0)
  235. .map((interval, i) => {
  236. const totalIntervalSessions = responseData.groups.reduce(
  237. (acc, group) =>
  238. acc +
  239. group.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i],
  240. 0
  241. );
  242. const intervalCrashedSessions =
  243. responseData.groups
  244. .find(group => group.by['session.status'] === 'crashed')
  245. ?.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i] ?? 0;
  246. const crashedSessionsPercent = percent(
  247. intervalCrashedSessions,
  248. totalIntervalSessions
  249. );
  250. return {
  251. name: interval,
  252. value:
  253. totalSessions === 0 && previousPeriodTotalSessions === 0
  254. ? 0
  255. : totalIntervalSessions === 0
  256. ? null
  257. : getCrashFreePercent(100 - crashedSessionsPercent),
  258. };
  259. }),
  260. },
  261. ] as Series[]; // TODO(project-detail): Change SeriesDataUnit value to support null
  262. const previousTimeseriesData = fetchedWithPrevious
  263. ? ({
  264. seriesName: t('Previous Period'),
  265. data: responseData.intervals.slice(0, dataMiddleIndex).map((_interval, i) => {
  266. const totalIntervalSessions = responseData.groups.reduce(
  267. (acc, group) => acc + group.series[field].slice(0, dataMiddleIndex)[i],
  268. 0
  269. );
  270. const intervalCrashedSessions =
  271. responseData.groups
  272. .find(group => group.by['session.status'] === 'crashed')
  273. ?.series[field].slice(0, dataMiddleIndex)[i] ?? 0;
  274. const crashedSessionsPercent = percent(
  275. intervalCrashedSessions,
  276. totalIntervalSessions
  277. );
  278. return {
  279. name: responseData.intervals[i + dataMiddleIndex],
  280. value:
  281. totalSessions === 0 && previousPeriodTotalSessions === 0
  282. ? 0
  283. : totalIntervalSessions === 0
  284. ? null
  285. : getCrashFreePercent(100 - crashedSessionsPercent),
  286. };
  287. }),
  288. } as Series) // TODO(project-detail): Change SeriesDataUnit value to support null
  289. : null;
  290. return {
  291. totalSessions,
  292. timeseriesData,
  293. previousTimeseriesData,
  294. };
  295. }
  296. transformSessionCountData(responseData: SessionApiResponse) {
  297. const {theme} = this.props;
  298. const sessionsChart = initSessionsChart(theme);
  299. const {intervals, groups} = responseData;
  300. const totalSessions = getCount(
  301. responseData.groups,
  302. SessionFieldWithOperation.SESSIONS
  303. );
  304. const chartData = [
  305. {
  306. ...sessionsChart[SessionStatus.HEALTHY],
  307. data: getCountSeries(
  308. SessionFieldWithOperation.SESSIONS,
  309. groups.find(g => g.by['session.status'] === SessionStatus.HEALTHY),
  310. intervals
  311. ),
  312. },
  313. {
  314. ...sessionsChart[SessionStatus.ERRORED],
  315. data: getCountSeries(
  316. SessionFieldWithOperation.SESSIONS,
  317. groups.find(g => g.by['session.status'] === SessionStatus.ERRORED),
  318. intervals
  319. ),
  320. },
  321. {
  322. ...sessionsChart[SessionStatus.ABNORMAL],
  323. data: getCountSeries(
  324. SessionFieldWithOperation.SESSIONS,
  325. groups.find(g => g.by['session.status'] === SessionStatus.ABNORMAL),
  326. intervals
  327. ),
  328. },
  329. {
  330. ...sessionsChart[SessionStatus.CRASHED],
  331. data: getCountSeries(
  332. SessionFieldWithOperation.SESSIONS,
  333. groups.find(g => g.by['session.status'] === SessionStatus.CRASHED),
  334. intervals
  335. ),
  336. },
  337. ];
  338. return {
  339. timeseriesData: chartData,
  340. previousTimeseriesData: null,
  341. totalSessions,
  342. };
  343. }
  344. render() {
  345. const {children} = this.props;
  346. const {timeseriesData, reloading, errored, totalSessions, previousTimeseriesData} =
  347. this.state;
  348. const loading = timeseriesData === null;
  349. return children({
  350. loading,
  351. reloading,
  352. errored,
  353. totalSessions,
  354. previousTimeseriesData,
  355. timeseriesData: timeseriesData ?? [],
  356. });
  357. }
  358. }
  359. export default withTheme(ProjectSessionsChartRequest);