import {Component} from 'react'; import type {Location} from 'history'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import moment from 'moment'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {Client} from 'sentry/api'; import type {DateTimeObject} from 'sentry/components/charts/utils'; import { getDiffInMinutes, ONE_WEEK, TWENTY_FOUR_HOURS, TWO_WEEKS, } from 'sentry/components/charts/utils'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import {URL_PARAM} from 'sentry/constants/pageFilters'; import {t} from 'sentry/locale'; import type {Organization, PageFilters, SessionApiResponse} from 'sentry/types'; import {HealthStatsPeriodOption, SessionFieldWithOperation} from 'sentry/types'; import {defined, percent} from 'sentry/utils'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import withApi from 'sentry/utils/withApi'; import {getCrashFreePercent} from '../utils'; import {ReleasesDisplayOption} from './releasesDisplayOptions'; function omitIgnoredProps(props: Props) { return omit(props, [ 'api', 'organization', 'children', 'selection.datetime.utc', 'location', ]); } function getInterval(datetimeObj: DateTimeObject) { const diffInMinutes = getDiffInMinutes(datetimeObj); if (diffInMinutes >= TWO_WEEKS) { return '1d'; } if (diffInMinutes >= ONE_WEEK) { return '6h'; } if (diffInMinutes > TWENTY_FOUR_HOURS) { return '4h'; } // TODO(sessions): sub-hour session resolution is still not possible return '1h'; } export function reduceTimeSeriesGroups( acc: number[], group: SessionApiResponse['groups'][number], field: 'count_unique(user)' | 'sum(session)' ) { group.series[field]?.forEach( (value, index) => (acc[index] = (acc[index] ?? 0) + value) ); return acc; } export function sessionDisplayToField(display: ReleasesDisplayOption) { switch (display) { case ReleasesDisplayOption.USERS: return SessionFieldWithOperation.USERS; case ReleasesDisplayOption.SESSIONS: default: return SessionFieldWithOperation.SESSIONS; } } export type ReleasesRequestRenderProps = { errored: boolean; getHealthData: ReturnType; isHealthLoading: boolean; }; type Props = { api: Client; children: (renderProps: ReleasesRequestRenderProps) => React.ReactNode; display: ReleasesDisplayOption[]; location: Location; organization: Organization; releases: string[]; selection: PageFilters; defaultStatsPeriod?: string; disable?: boolean; healthStatsPeriod?: HealthStatsPeriodOption; releasesReloading?: boolean; }; type State = { errored: boolean; loading: boolean; statusCountByProjectInPeriod: SessionApiResponse | null; statusCountByReleaseInPeriod: SessionApiResponse | null; totalCountByProjectIn24h: SessionApiResponse | null; totalCountByProjectInPeriod: SessionApiResponse | null; totalCountByReleaseIn24h: SessionApiResponse | null; totalCountByReleaseInPeriod: SessionApiResponse | null; }; class ReleasesRequest extends Component { state: State = { loading: false, errored: false, statusCountByReleaseInPeriod: null, totalCountByReleaseIn24h: null, totalCountByProjectIn24h: null, statusCountByProjectInPeriod: null, totalCountByReleaseInPeriod: null, totalCountByProjectInPeriod: null, }; componentDidMount() { this.fetchData(); } componentDidUpdate(prevProps: Props) { if (this.props.releasesReloading) { return; } if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) { return; } this.fetchData(); } get path() { const {organization} = this.props; return `/organizations/${organization.slug}/sessions/`; } get baseQueryParams() { const {location, selection, defaultStatsPeriod, releases} = this.props; return { query: new MutableSearch( releases.reduce((acc, release, index, allReleases) => { acc.push(`release:"${release}"`); if (index < allReleases.length - 1) { acc.push('OR'); } return acc; }, []) ).formatString(), interval: getInterval(selection.datetime), ...normalizeDateTimeParams(pick(location.query, Object.values(URL_PARAM)), { defaultStatsPeriod, }), }; } fetchData = async () => { const {api, healthStatsPeriod, disable} = this.props; if (disable) { return; } api.clear(); this.setState({ loading: true, errored: false, statusCountByReleaseInPeriod: null, totalCountByReleaseIn24h: null, totalCountByProjectIn24h: null, }); const promises = [ this.fetchStatusCountByReleaseInPeriod(), this.fetchTotalCountByReleaseIn24h(), this.fetchTotalCountByProjectIn24h(), ]; if (healthStatsPeriod === HealthStatsPeriodOption.AUTO) { promises.push(this.fetchStatusCountByProjectInPeriod()); promises.push(this.fetchTotalCountByReleaseInPeriod()); promises.push(this.fetchTotalCountByProjectInPeriod()); } try { const [ statusCountByReleaseInPeriod, totalCountByReleaseIn24h, totalCountByProjectIn24h, statusCountByProjectInPeriod, totalCountByReleaseInPeriod, totalCountByProjectInPeriod, ] = await Promise.all(promises); this.setState({ loading: false, statusCountByReleaseInPeriod, totalCountByReleaseIn24h, totalCountByProjectIn24h, statusCountByProjectInPeriod, totalCountByReleaseInPeriod, totalCountByProjectInPeriod, }); } catch (error) { addErrorMessage(error.responseJSON?.detail ?? t('Error loading health data')); this.setState({ loading: false, errored: true, }); } }; /** * Used to calculate crash free rate, count histogram (This Release series), and crash count */ async fetchStatusCountByReleaseInPeriod() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, field: [ ...new Set([...display.map(d => sessionDisplayToField(d)), 'sum(session)']), ], // this request needs to be fired for sessions in both display options (because of crash count), removing potential sum(session) duplicated with Set groupBy: ['project', 'release', 'session.status'], }, }); return response; } /** * Used to calculate count histogram (Total Project series) */ async fetchStatusCountByProjectInPeriod() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, query: undefined, field: [ ...new Set([...display.map(d => sessionDisplayToField(d)), 'sum(session)']), ], groupBy: ['project', 'session.status'], }, }); return response; } /** * Used to calculate adoption, and count histogram (This Release series) */ async fetchTotalCountByReleaseIn24h() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, field: display.map(d => sessionDisplayToField(d)), groupBy: ['project', 'release'], interval: '1h', statsPeriod: '24h', }, }); return response; } async fetchTotalCountByReleaseInPeriod() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, field: display.map(d => sessionDisplayToField(d)), groupBy: ['project', 'release'], }, }); return response; } /** * Used to calculate adoption, and count histogram (Total Project series) */ async fetchTotalCountByProjectIn24h() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, query: undefined, field: display.map(d => sessionDisplayToField(d)), groupBy: ['project'], interval: '1h', statsPeriod: '24h', }, }); return response; } async fetchTotalCountByProjectInPeriod() { const {api, display} = this.props; const response: SessionApiResponse = await api.requestPromise(this.path, { query: { ...this.baseQueryParams, query: undefined, field: display.map(d => sessionDisplayToField(d)), groupBy: ['project'], }, }); return response; } getHealthData = () => { // TODO(sessions): investigate if this needs to be optimized to lower O(n) complexity return { getCrashCount: this.getCrashCount, getCrashFreeRate: this.getCrashFreeRate, get24hCountByRelease: this.get24hCountByRelease, get24hCountByProject: this.get24hCountByProject, getTimeSeries: this.getTimeSeries, getAdoption: this.getAdoption, }; }; getCrashCount = (version: string, project: number, display: ReleasesDisplayOption) => { const {statusCountByReleaseInPeriod} = this.state; const field = sessionDisplayToField(display); return statusCountByReleaseInPeriod?.groups.find( ({by}) => by.release === version && by.project === project && by['session.status'] === 'crashed' )?.totals[field]; }; getCrashFreeRate = ( version: string, project: number, display: ReleasesDisplayOption ) => { const {statusCountByReleaseInPeriod} = this.state; const field = sessionDisplayToField(display); const totalCount = statusCountByReleaseInPeriod?.groups .filter(({by}) => by.release === version && by.project === project) ?.reduce((acc, group) => acc + group.totals[field], 0); const crashedCount = this.getCrashCount(version, project, display); return !defined(totalCount) || totalCount === 0 ? null : getCrashFreePercent(100 - percent(crashedCount ?? 0, totalCount ?? 0)); }; get24hCountByRelease = ( version: string, project: number, display: ReleasesDisplayOption ) => { const {totalCountByReleaseIn24h} = this.state; const field = sessionDisplayToField(display); return totalCountByReleaseIn24h?.groups .filter(({by}) => by.release === version && by.project === project) ?.reduce((acc, group) => acc + group.totals[field], 0); }; getPeriodCountByRelease = ( version: string, project: number, display: ReleasesDisplayOption ) => { const {totalCountByReleaseInPeriod} = this.state; const field = sessionDisplayToField(display); return totalCountByReleaseInPeriod?.groups .filter(({by}) => by.release === version && by.project === project) ?.reduce((acc, group) => acc + group.totals[field], 0); }; get24hCountByProject = (project: number, display: ReleasesDisplayOption) => { const {totalCountByProjectIn24h} = this.state; const field = sessionDisplayToField(display); return totalCountByProjectIn24h?.groups .filter(({by}) => by.project === project) ?.reduce((acc, group) => acc + group.totals[field], 0); }; getPeriodCountByProject = (project: number, display: ReleasesDisplayOption) => { const {totalCountByProjectInPeriod} = this.state; const field = sessionDisplayToField(display); return totalCountByProjectInPeriod?.groups .filter(({by}) => by.project === project) ?.reduce((acc, group) => acc + group.totals[field], 0); }; getTimeSeries = (version: string, project: number, display: ReleasesDisplayOption) => { const {healthStatsPeriod} = this.props; if (healthStatsPeriod === HealthStatsPeriodOption.AUTO) { return this.getPeriodTimeSeries(version, project, display); } return this.get24hTimeSeries(version, project, display); }; get24hTimeSeries = ( version: string, project: number, display: ReleasesDisplayOption ) => { const {totalCountByReleaseIn24h, totalCountByProjectIn24h} = this.state; const field = sessionDisplayToField(display); const intervals = totalCountByProjectIn24h?.intervals ?? []; const projectData = totalCountByProjectIn24h?.groups.find( ({by}) => by.project === project )?.series[field]; const releaseData = totalCountByReleaseIn24h?.groups.find( ({by}) => by.project === project && by.release === version )?.series[field]; return [ { seriesName: t('This Release'), data: intervals?.map((interval, index) => ({ name: moment(interval).valueOf(), value: releaseData?.[index] ?? 0, })), }, { seriesName: t('Total Project'), data: intervals?.map((interval, index) => ({ name: moment(interval).valueOf(), value: projectData?.[index] ?? 0, })), z: 0, }, ]; }; getPeriodTimeSeries = ( version: string, project: number, display: ReleasesDisplayOption ) => { const {statusCountByReleaseInPeriod, statusCountByProjectInPeriod} = this.state; const field = sessionDisplayToField(display); const intervals = statusCountByProjectInPeriod?.intervals ?? []; const projectData = statusCountByProjectInPeriod?.groups .filter(({by}) => by.project === project) ?.reduce((acc, group) => reduceTimeSeriesGroups(acc, group, field), [] as number[]); const releaseData = statusCountByReleaseInPeriod?.groups .filter(({by}) => by.project === project && by.release === version) ?.reduce((acc, group) => reduceTimeSeriesGroups(acc, group, field), [] as number[]); return [ { seriesName: t('This Release'), data: intervals?.map((interval, index) => ({ name: moment(interval).valueOf(), value: releaseData?.[index] ?? 0, })), }, { seriesName: t('Total Project'), data: intervals?.map((interval, index) => ({ name: moment(interval).valueOf(), value: projectData?.[index] ?? 0, })), z: 0, }, ]; }; getAdoption = (version: string, project: number, display: ReleasesDisplayOption) => { const {healthStatsPeriod} = this.props; const countByRelease = ( healthStatsPeriod === HealthStatsPeriodOption.AUTO ? this.getPeriodCountByRelease : this.get24hCountByRelease )(version, project, display); const countByProject = ( healthStatsPeriod === HealthStatsPeriodOption.AUTO ? this.getPeriodCountByProject : this.get24hCountByProject )(project, display); return defined(countByRelease) && defined(countByProject) ? percent(countByRelease, countByProject) : undefined; }; render() { const {loading, errored} = this.state; const {children} = this.props; return children({ isHealthLoading: loading, errored, getHealthData: this.getHealthData(), }); } } export default withApi(ReleasesRequest);