projectSessionsChartRequest.tsx 11 KB

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