import {Component} from 'react'; import {Theme, withTheme} from '@emotion/react'; import {LineSeriesOption} from 'echarts'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import {t} from 'sentry/locale'; import { Organization, PageFilters, SessionApiResponse, SessionFieldWithOperation, SessionStatus, } from 'sentry/types'; import {Series} from 'sentry/types/echarts'; import {percent} from 'sentry/utils'; import {getPeriod} from 'sentry/utils/getPeriod'; import { filterSessionsInTimeWindow, getCount, getCountSeries, getSessionsInterval, initSessionsChart, } from 'sentry/utils/sessions'; import {getCrashFreePercent} from 'sentry/views/releases/utils'; import {DisplayModes} from '../projectCharts'; const omitIgnoredProps = (props: ProjectSessionsChartRequestProps) => omit(props, ['api', 'organization', 'children', 'selection.datetime.utc']); type ProjectSessionsChartRequestRenderProps = { errored: boolean; loading: boolean; previousTimeseriesData: Series | null; reloading: boolean; timeseriesData: Series[]; totalSessions: number | null; additionalSeries?: LineSeriesOption[]; }; export type ProjectSessionsChartRequestProps = { api: Client; children: (renderProps: ProjectSessionsChartRequestRenderProps) => React.ReactNode; displayMode: | DisplayModes.SESSIONS | DisplayModes.STABILITY | DisplayModes.STABILITY_USERS | DisplayModes.ANR_RATE | DisplayModes.FOREGROUND_ANR_RATE; onTotalValuesChange: (value: number | null) => void; organization: Organization; selection: PageFilters; theme: Theme; disablePrevious?: boolean; query?: string; }; type State = { errored: boolean; previousTimeseriesData: Series | null; reloading: boolean; timeseriesData: Series[] | null; totalSessions: number | null; }; class ProjectSessionsChartRequest extends Component< ProjectSessionsChartRequestProps, State > { state: State = { reloading: false, errored: false, timeseriesData: null, previousTimeseriesData: null, totalSessions: null, }; componentDidMount() { this.fetchData(); } componentDidUpdate(prevProps: ProjectSessionsChartRequestProps) { if (!isEqual(omitIgnoredProps(this.props), omitIgnoredProps(prevProps))) { this.fetchData(); } } componentWillUnmount() { this.unmounting = true; } private unmounting: boolean = false; fetchData = async () => { const { api, selection: {datetime}, onTotalValuesChange, displayMode, disablePrevious, } = this.props; const shouldFetchWithPrevious = !disablePrevious && shouldFetchPreviousPeriod({ start: datetime.start, end: datetime.end, period: datetime.period, }); this.setState(state => ({ reloading: state.timeseriesData !== null, errored: false, })); try { const queryParams = this.queryParams({shouldFetchWithPrevious}); const requests = [ api.requestPromise(this.path, { query: queryParams, }), ]; // for users, we need to make a separate request to get the total count in period if (displayMode === DisplayModes.STABILITY_USERS) { requests.push( api.requestPromise(this.path, { query: { ...queryParams, groupBy: undefined, ...(shouldFetchWithPrevious ? {statsPeriod: datetime.period} : {}), }, }) ); } const [response, responseUsersTotal]: SessionApiResponse[] = await Promise.all(requests); const filteredResponse = filterSessionsInTimeWindow( response, queryParams.start, queryParams.end ); // totalSessions can't be used when we're talking about users // users are a set and counting together buckets or statuses is not correct // because one user can be present in multiple buckets/statuses const {timeseriesData, previousTimeseriesData, totalSessions} = displayMode === DisplayModes.SESSIONS ? this.transformSessionCountData(filteredResponse) : this.transformData(filteredResponse, { fetchedWithPrevious: shouldFetchWithPrevious, }); const totalUsers = responseUsersTotal?.groups[0].totals[this.field]; const totalNumber = displayMode === DisplayModes.STABILITY_USERS ? totalUsers : totalSessions; if (this.unmounting) { return; } this.setState({ reloading: false, timeseriesData, previousTimeseriesData, totalSessions: totalNumber, }); onTotalValuesChange(totalNumber); } catch { addErrorMessage(t('Error loading chart data')); this.setState({ errored: true, reloading: false, timeseriesData: null, previousTimeseriesData: null, totalSessions: null, }); } }; get path() { const {organization} = this.props; return `/organizations/${organization.slug}/sessions/`; } get field() { const {displayMode} = this.props; return displayMode === DisplayModes.STABILITY_USERS ? SessionFieldWithOperation.USERS : SessionFieldWithOperation.SESSIONS; } queryParams({shouldFetchWithPrevious = false}): Record { const {selection, query, organization} = this.props; const {datetime, projects, environments: environment} = selection; const baseParams = { field: this.field, groupBy: 'session.status', interval: getSessionsInterval(datetime, { highFidelity: organization.features.includes('minute-resolution-sessions'), }), project: projects[0], environment, query, }; if (!shouldFetchWithPrevious) { return { ...baseParams, ...normalizeDateTimeParams(datetime), }; } const {period} = selection.datetime; const doubledPeriod = getPeriod( {period, start: undefined, end: undefined}, {shouldDoublePeriod: true} ).statsPeriod; return { ...baseParams, statsPeriod: doubledPeriod, }; } transformData(responseData: SessionApiResponse, {fetchedWithPrevious = false}) { const {theme} = this.props; const {field} = this; // Take the floor just in case, but data should always be divisible by 2 const dataMiddleIndex = Math.floor(responseData.intervals.length / 2); // calculate the total number of sessions for this period (exclude previous if there) const totalSessions = responseData.groups.reduce( (acc, group) => acc + group.series[field] .slice(fetchedWithPrevious ? dataMiddleIndex : 0) .reduce((value, groupAcc) => groupAcc + value, 0), 0 ); const previousPeriodTotalSessions = fetchedWithPrevious ? responseData.groups.reduce( (acc, group) => acc + group.series[field] .slice(0, dataMiddleIndex) .reduce((value, groupAcc) => groupAcc + value, 0), 0 ) : 0; // TODO(project-details): refactor this to avoid duplication as we add more session charts const timeseriesData = [ { seriesName: t('This Period'), color: theme.green300, data: responseData.intervals .slice(fetchedWithPrevious ? dataMiddleIndex : 0) .map((interval, i) => { const totalIntervalSessions = responseData.groups.reduce( (acc, group) => acc + group.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i], 0 ); const intervalCrashedSessions = responseData.groups .find(group => group.by['session.status'] === 'crashed') ?.series[field].slice(fetchedWithPrevious ? dataMiddleIndex : 0)[i] ?? 0; const crashedSessionsPercent = percent( intervalCrashedSessions, totalIntervalSessions ); return { name: interval, value: totalSessions === 0 && previousPeriodTotalSessions === 0 ? 0 : totalIntervalSessions === 0 ? null : getCrashFreePercent(100 - crashedSessionsPercent), }; }), }, ] as Series[]; // TODO(project-detail): Change SeriesDataUnit value to support null const previousTimeseriesData = fetchedWithPrevious ? ({ seriesName: t('Previous Period'), data: responseData.intervals.slice(0, dataMiddleIndex).map((_interval, i) => { const totalIntervalSessions = responseData.groups.reduce( (acc, group) => acc + group.series[field].slice(0, dataMiddleIndex)[i], 0 ); const intervalCrashedSessions = responseData.groups .find(group => group.by['session.status'] === 'crashed') ?.series[field].slice(0, dataMiddleIndex)[i] ?? 0; const crashedSessionsPercent = percent( intervalCrashedSessions, totalIntervalSessions ); return { name: responseData.intervals[i + dataMiddleIndex], value: totalSessions === 0 && previousPeriodTotalSessions === 0 ? 0 : totalIntervalSessions === 0 ? null : getCrashFreePercent(100 - crashedSessionsPercent), }; }), } as Series) // TODO(project-detail): Change SeriesDataUnit value to support null : null; return { totalSessions, timeseriesData, previousTimeseriesData, }; } transformSessionCountData(responseData: SessionApiResponse) { const {theme} = this.props; const sessionsChart = initSessionsChart(theme); const {intervals, groups} = responseData; const totalSessions = getCount( responseData.groups, SessionFieldWithOperation.SESSIONS ); const chartData = [ { ...sessionsChart[SessionStatus.HEALTHY], data: getCountSeries( SessionFieldWithOperation.SESSIONS, groups.find(g => g.by['session.status'] === SessionStatus.HEALTHY), intervals ), }, { ...sessionsChart[SessionStatus.ERRORED], data: getCountSeries( SessionFieldWithOperation.SESSIONS, groups.find(g => g.by['session.status'] === SessionStatus.ERRORED), intervals ), }, { ...sessionsChart[SessionStatus.ABNORMAL], data: getCountSeries( SessionFieldWithOperation.SESSIONS, groups.find(g => g.by['session.status'] === SessionStatus.ABNORMAL), intervals ), }, { ...sessionsChart[SessionStatus.CRASHED], data: getCountSeries( SessionFieldWithOperation.SESSIONS, groups.find(g => g.by['session.status'] === SessionStatus.CRASHED), intervals ), }, ]; return { timeseriesData: chartData, previousTimeseriesData: null, totalSessions, }; } render() { const {children} = this.props; const {timeseriesData, reloading, errored, totalSessions, previousTimeseriesData} = this.state; const loading = timeseriesData === null; return children({ loading, reloading, errored, totalSessions, previousTimeseriesData, timeseriesData: timeseriesData ?? [], }); } } export default withTheme(ProjectSessionsChartRequest);