projectSessionsChartRequest.tsx 11 KB

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