import {Component} from 'react'; import type {Theme} from '@emotion/react'; import {withTheme} from '@emotion/react'; import type {LineSeriesOption} from 'echarts'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {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 type {PageFilters} from 'sentry/types/core'; import type {Series} from 'sentry/types/echarts'; import type {Organization, SessionApiResponse} from 'sentry/types/organization'; import {SessionFieldWithOperation, SessionStatus} from 'sentry/types/organization'; import {getPeriod} from 'sentry/utils/duration/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 // ANR is handled by the ProjectSessionsAnrRequest component | 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 crash free sessions and users, we need to make a separate request to get the total count in period if (this.isCrashFreeRate) { requests.push( api.requestPromise(this.path, { query: { ...queryParams, field: displayMode === DisplayModes.STABILITY_USERS ? SessionFieldWithOperation.USERS : SessionFieldWithOperation.SESSIONS, groupBy: undefined, ...(shouldFetchWithPrevious ? {statsPeriod: datetime.period} : {}), }, }) ); } const [response, totalCountResponse]: SessionApiResponse[] = await Promise.all(requests); const filteredResponse = filterSessionsInTimeWindow( response, queryParams.start, queryParams.end ); const {timeseriesData, previousTimeseriesData, totalCount} = displayMode === DisplayModes.SESSIONS ? this.transformSessionCountData(filteredResponse) : this.transformData(filteredResponse, totalCountResponse, { fetchedWithPrevious: shouldFetchWithPrevious, }); if (this.unmounting) { return; } this.setState({ reloading: false, timeseriesData, previousTimeseriesData, totalSessions: totalCount, }); onTotalValuesChange(totalCount); } 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; switch (displayMode) { case DisplayModes.STABILITY_USERS: return SessionFieldWithOperation.CRASH_FREE_RATE_USERS; case DisplayModes.STABILITY: return SessionFieldWithOperation.CRASH_FREE_RATE_SESSIONS; default: return SessionFieldWithOperation.SESSIONS; } } get isCrashFreeRate() { return [DisplayModes.STABILITY, DisplayModes.STABILITY_USERS].includes( this.props.displayMode ); } queryParams({shouldFetchWithPrevious = false}): Record { const {selection, query, organization} = this.props; const {datetime, projects, environments: environment} = selection; const baseParams = { field: this.field, groupBy: this.isCrashFreeRate ? undefined : '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, totalCountResponse: 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); // 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 crashedSessionsPercent = responseData.groups[0]?.series[field].slice( fetchedWithPrevious ? dataMiddleIndex : 0 )[i] * 100; return { name: interval, value: getCrashFreePercent(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 crashedSessionsPercent = responseData.groups[0]?.series[field].slice(0, dataMiddleIndex)[i] * 100; return { name: responseData.intervals[i + dataMiddleIndex], value: getCrashFreePercent(crashedSessionsPercent), }; }), } as Series) // TODO(project-detail): Change SeriesDataUnit value to support null : null; const totalCount = totalCountResponse?.groups[0].totals[ this.props.displayMode === DisplayModes.STABILITY_USERS ? SessionFieldWithOperation.USERS : SessionFieldWithOperation.SESSIONS ]; return { timeseriesData, totalCount, 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, totalCount: 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);