projectSessionsChartRequest.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  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 {getPeriod} from 'sentry/utils/getPeriod';
  20. import {
  21. filterSessionsInTimeWindow,
  22. getCount,
  23. getCountSeries,
  24. getSessionsInterval,
  25. initSessionsChart,
  26. } from 'sentry/utils/sessions';
  27. import {getCrashFreePercent} from 'sentry/views/releases/utils';
  28. import {DisplayModes} from '../projectCharts';
  29. const omitIgnoredProps = (props: ProjectSessionsChartRequestProps) =>
  30. omit(props, ['api', 'organization', 'children', 'selection.datetime.utc']);
  31. type ProjectSessionsChartRequestRenderProps = {
  32. errored: boolean;
  33. loading: boolean;
  34. previousTimeseriesData: Series | null;
  35. reloading: boolean;
  36. timeseriesData: Series[];
  37. totalSessions: number | null;
  38. additionalSeries?: LineSeriesOption[];
  39. };
  40. export type ProjectSessionsChartRequestProps = {
  41. api: Client;
  42. children: (renderProps: ProjectSessionsChartRequestRenderProps) => React.ReactNode;
  43. displayMode:
  44. | DisplayModes.SESSIONS
  45. | DisplayModes.STABILITY
  46. | DisplayModes.STABILITY_USERS
  47. // ANR is handled by the ProjectSessionsAnrRequest component
  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 crash free sessions and users, we need to make a separate request to get the total count in period
  114. if (this.isCrashFreeRate) {
  115. requests.push(
  116. api.requestPromise(this.path, {
  117. query: {
  118. ...queryParams,
  119. field:
  120. displayMode === DisplayModes.STABILITY_USERS
  121. ? SessionFieldWithOperation.USERS
  122. : SessionFieldWithOperation.SESSIONS,
  123. groupBy: undefined,
  124. ...(shouldFetchWithPrevious ? {statsPeriod: datetime.period} : {}),
  125. },
  126. })
  127. );
  128. }
  129. const [response, totalCountResponse]: SessionApiResponse[] =
  130. await Promise.all(requests);
  131. const filteredResponse = filterSessionsInTimeWindow(
  132. response,
  133. queryParams.start,
  134. queryParams.end
  135. );
  136. const {timeseriesData, previousTimeseriesData, totalCount} =
  137. displayMode === DisplayModes.SESSIONS
  138. ? this.transformSessionCountData(filteredResponse)
  139. : this.transformData(filteredResponse, totalCountResponse, {
  140. fetchedWithPrevious: shouldFetchWithPrevious,
  141. });
  142. if (this.unmounting) {
  143. return;
  144. }
  145. this.setState({
  146. reloading: false,
  147. timeseriesData,
  148. previousTimeseriesData,
  149. totalSessions: totalCount,
  150. });
  151. onTotalValuesChange(totalCount);
  152. } catch {
  153. addErrorMessage(t('Error loading chart data'));
  154. this.setState({
  155. errored: true,
  156. reloading: false,
  157. timeseriesData: null,
  158. previousTimeseriesData: null,
  159. totalSessions: null,
  160. });
  161. }
  162. };
  163. get path() {
  164. const {organization} = this.props;
  165. return `/organizations/${organization.slug}/sessions/`;
  166. }
  167. get field() {
  168. const {displayMode} = this.props;
  169. switch (displayMode) {
  170. case DisplayModes.STABILITY_USERS:
  171. return SessionFieldWithOperation.CRASH_FREE_RATE_USERS;
  172. case DisplayModes.STABILITY:
  173. return SessionFieldWithOperation.CRASH_FREE_RATE_SESSIONS;
  174. default:
  175. return SessionFieldWithOperation.SESSIONS;
  176. }
  177. }
  178. get isCrashFreeRate() {
  179. return [DisplayModes.STABILITY, DisplayModes.STABILITY_USERS].includes(
  180. this.props.displayMode
  181. );
  182. }
  183. queryParams({shouldFetchWithPrevious = false}): Record<string, any> {
  184. const {selection, query, organization} = this.props;
  185. const {datetime, projects, environments: environment} = selection;
  186. const baseParams = {
  187. field: this.field,
  188. groupBy: this.isCrashFreeRate ? undefined : 'session.status',
  189. interval: getSessionsInterval(datetime, {
  190. highFidelity: organization.features.includes('minute-resolution-sessions'),
  191. }),
  192. project: projects[0],
  193. environment,
  194. query,
  195. };
  196. if (!shouldFetchWithPrevious) {
  197. return {
  198. ...baseParams,
  199. ...normalizeDateTimeParams(datetime),
  200. };
  201. }
  202. const {period} = selection.datetime;
  203. const doubledPeriod = getPeriod(
  204. {period, start: undefined, end: undefined},
  205. {shouldDoublePeriod: true}
  206. ).statsPeriod;
  207. return {
  208. ...baseParams,
  209. statsPeriod: doubledPeriod,
  210. };
  211. }
  212. transformData(
  213. responseData: SessionApiResponse,
  214. totalCountResponse: SessionApiResponse,
  215. {fetchedWithPrevious = false}
  216. ) {
  217. const {theme} = this.props;
  218. const {field} = this;
  219. // Take the floor just in case, but data should always be divisible by 2
  220. const dataMiddleIndex = Math.floor(responseData.intervals.length / 2);
  221. // TODO(project-details): refactor this to avoid duplication as we add more session charts
  222. const timeseriesData = [
  223. {
  224. seriesName: t('This Period'),
  225. color: theme.green300,
  226. data: responseData.intervals
  227. .slice(fetchedWithPrevious ? dataMiddleIndex : 0)
  228. .map((interval, i) => {
  229. const crashedSessionsPercent =
  230. responseData.groups[0]?.series[field].slice(
  231. fetchedWithPrevious ? dataMiddleIndex : 0
  232. )[i] * 100 ?? 0;
  233. return {
  234. name: interval,
  235. value: getCrashFreePercent(crashedSessionsPercent),
  236. };
  237. }),
  238. },
  239. ] as Series[]; // TODO(project-detail): Change SeriesDataUnit value to support null
  240. const previousTimeseriesData = fetchedWithPrevious
  241. ? ({
  242. seriesName: t('Previous Period'),
  243. data: responseData.intervals.slice(0, dataMiddleIndex).map((_interval, i) => {
  244. const crashedSessionsPercent =
  245. responseData.groups[0]?.series[field].slice(0, dataMiddleIndex)[i] * 100 ??
  246. 0;
  247. return {
  248. name: responseData.intervals[i + dataMiddleIndex],
  249. value: getCrashFreePercent(crashedSessionsPercent),
  250. };
  251. }),
  252. } as Series) // TODO(project-detail): Change SeriesDataUnit value to support null
  253. : null;
  254. const totalCount =
  255. totalCountResponse?.groups[0].totals[
  256. this.props.displayMode === DisplayModes.STABILITY_USERS
  257. ? SessionFieldWithOperation.USERS
  258. : SessionFieldWithOperation.SESSIONS
  259. ];
  260. return {
  261. timeseriesData,
  262. totalCount,
  263. previousTimeseriesData,
  264. };
  265. }
  266. transformSessionCountData(responseData: SessionApiResponse) {
  267. const {theme} = this.props;
  268. const sessionsChart = initSessionsChart(theme);
  269. const {intervals, groups} = responseData;
  270. const totalSessions = getCount(
  271. responseData.groups,
  272. SessionFieldWithOperation.SESSIONS
  273. );
  274. const chartData = [
  275. {
  276. ...sessionsChart[SessionStatus.HEALTHY],
  277. data: getCountSeries(
  278. SessionFieldWithOperation.SESSIONS,
  279. groups.find(g => g.by['session.status'] === SessionStatus.HEALTHY),
  280. intervals
  281. ),
  282. },
  283. {
  284. ...sessionsChart[SessionStatus.ERRORED],
  285. data: getCountSeries(
  286. SessionFieldWithOperation.SESSIONS,
  287. groups.find(g => g.by['session.status'] === SessionStatus.ERRORED),
  288. intervals
  289. ),
  290. },
  291. {
  292. ...sessionsChart[SessionStatus.ABNORMAL],
  293. data: getCountSeries(
  294. SessionFieldWithOperation.SESSIONS,
  295. groups.find(g => g.by['session.status'] === SessionStatus.ABNORMAL),
  296. intervals
  297. ),
  298. },
  299. {
  300. ...sessionsChart[SessionStatus.CRASHED],
  301. data: getCountSeries(
  302. SessionFieldWithOperation.SESSIONS,
  303. groups.find(g => g.by['session.status'] === SessionStatus.CRASHED),
  304. intervals
  305. ),
  306. },
  307. ];
  308. return {
  309. timeseriesData: chartData,
  310. previousTimeseriesData: null,
  311. totalCount: totalSessions,
  312. };
  313. }
  314. render() {
  315. const {children} = this.props;
  316. const {timeseriesData, reloading, errored, totalSessions, previousTimeseriesData} =
  317. this.state;
  318. const loading = timeseriesData === null;
  319. return children({
  320. loading,
  321. reloading,
  322. errored,
  323. totalSessions,
  324. previousTimeseriesData,
  325. timeseriesData: timeseriesData ?? [],
  326. });
  327. }
  328. }
  329. export default withTheme(ProjectSessionsChartRequest);