Files
2024-02-15 17:01:50 +00:00

634 lines
19 KiB
TypeScript

import {Fragment, PureComponent} from 'react';
import styled from '@emotion/styled';
import type {Location} from 'history';
import isEqual from 'lodash/isEqual';
import maxBy from 'lodash/maxBy';
import minBy from 'lodash/minBy';
import {fetchTotalCount} from 'sentry/actionCreators/events';
import type {Client} from 'sentry/api';
import ErrorPanel from 'sentry/components/charts/errorPanel';
import EventsRequest from 'sentry/components/charts/eventsRequest';
import type {LineChartSeries} from 'sentry/components/charts/lineChart';
import {OnDemandMetricRequest} from 'sentry/components/charts/onDemandMetricRequest';
import SessionsRequest from 'sentry/components/charts/sessionsRequest';
import {
ChartControls,
InlineContainer,
SectionHeading,
SectionValue,
} from 'sentry/components/charts/styles';
import {CompactSelect} from 'sentry/components/compactSelect';
import LoadingMask from 'sentry/components/loadingMask';
import PanelAlert from 'sentry/components/panels/panelAlert';
import Placeholder from 'sentry/components/placeholder';
import {IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {
EventsStats,
MultiSeriesEventsStats,
Organization,
Project,
} from 'sentry/types';
import type {Series} from 'sentry/types/echarts';
import {parsePeriodToHours} from 'sentry/utils/dates';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {getForceMetricsLayerQueryExtras} from 'sentry/utils/metrics/features';
import {formatMRIField} from 'sentry/utils/metrics/mri';
import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features';
import {
getCrashFreeRateSeries,
MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
} from 'sentry/utils/sessions';
import {capitalize} from 'sentry/utils/string/capitalize';
import withApi from 'sentry/utils/withApi';
import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
import {shouldUseErrorsDiscoverDataset} from 'sentry/views/alerts/rules/utils';
import {isSessionAggregate, SESSION_AGGREGATE_TO_FIELD} from 'sentry/views/alerts/utils';
import {getComparisonMarkLines} from 'sentry/views/alerts/utils/getComparisonMarkLines';
import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
import type {MetricRule, Trigger} from '../../types';
import {
AlertRuleComparisonType,
Dataset,
SessionsAggregate,
TimePeriod,
TimeWindow,
} from '../../types';
import {getMetricDatasetQueryExtras} from '../../utils/getMetricDatasetQueryExtras';
import ThresholdsChart from './thresholdsChart';
type Props = {
aggregate: MetricRule['aggregate'];
api: Client;
comparisonType: AlertRuleComparisonType;
dataset: MetricRule['dataset'];
environment: string | null;
isQueryValid: boolean;
location: Location;
newAlertOrQuery: boolean;
organization: Organization;
projects: Project[];
query: MetricRule['query'];
resolveThreshold: MetricRule['resolveThreshold'];
thresholdType: MetricRule['thresholdType'];
timeWindow: MetricRule['timeWindow'];
triggers: Trigger[];
comparisonDelta?: number;
header?: React.ReactNode;
isOnDemandMetricAlert?: boolean;
onDataLoaded?: (data: EventsStats | MultiSeriesEventsStats | null) => void;
showTotalCount?: boolean;
};
const TIME_PERIOD_MAP: Record<TimePeriod, string> = {
[TimePeriod.SIX_HOURS]: t('Last 6 hours'),
[TimePeriod.ONE_DAY]: t('Last 24 hours'),
[TimePeriod.THREE_DAYS]: t('Last 3 days'),
[TimePeriod.SEVEN_DAYS]: t('Last 7 days'),
[TimePeriod.FOURTEEN_DAYS]: t('Last 14 days'),
};
/**
* Just to avoid repeating it
*/
const MOST_TIME_PERIODS: readonly TimePeriod[] = [
TimePeriod.ONE_DAY,
TimePeriod.THREE_DAYS,
TimePeriod.SEVEN_DAYS,
TimePeriod.FOURTEEN_DAYS,
];
/**
* TimeWindow determines data available in TimePeriod
* If TimeWindow is small, lower TimePeriod to limit data points
*/
export const AVAILABLE_TIME_PERIODS: Record<TimeWindow, readonly TimePeriod[]> = {
[TimeWindow.ONE_MINUTE]: [
TimePeriod.SIX_HOURS,
TimePeriod.ONE_DAY,
TimePeriod.THREE_DAYS,
TimePeriod.SEVEN_DAYS,
],
[TimeWindow.FIVE_MINUTES]: MOST_TIME_PERIODS,
[TimeWindow.TEN_MINUTES]: MOST_TIME_PERIODS,
[TimeWindow.FIFTEEN_MINUTES]: MOST_TIME_PERIODS,
[TimeWindow.THIRTY_MINUTES]: MOST_TIME_PERIODS,
[TimeWindow.ONE_HOUR]: MOST_TIME_PERIODS,
[TimeWindow.TWO_HOURS]: MOST_TIME_PERIODS,
[TimeWindow.FOUR_HOURS]: [
TimePeriod.THREE_DAYS,
TimePeriod.SEVEN_DAYS,
TimePeriod.FOURTEEN_DAYS,
],
[TimeWindow.ONE_DAY]: [TimePeriod.FOURTEEN_DAYS],
};
const TIME_WINDOW_TO_SESSION_INTERVAL = {
[TimeWindow.THIRTY_MINUTES]: '30m',
[TimeWindow.ONE_HOUR]: '1h',
[TimeWindow.TWO_HOURS]: '2h',
[TimeWindow.FOUR_HOURS]: '4h',
[TimeWindow.ONE_DAY]: '1d',
};
const SESSION_AGGREGATE_TO_HEADING = {
[SessionsAggregate.CRASH_FREE_SESSIONS]: t('Total Sessions'),
[SessionsAggregate.CRASH_FREE_USERS]: t('Total Users'),
};
type State = {
sampleRate: number;
statsPeriod: TimePeriod;
totalCount: number | null;
};
const getStatsPeriodFromQuery = (
queryParam: string | string[] | null | undefined
): TimePeriod => {
if (typeof queryParam !== 'string') {
return TimePeriod.SEVEN_DAYS;
}
const inMinutes = parsePeriodToHours(queryParam || '') * 60;
switch (inMinutes) {
case 6 * 60:
return TimePeriod.SIX_HOURS;
case 24 * 60:
return TimePeriod.ONE_DAY;
case 3 * 24 * 60:
return TimePeriod.THREE_DAYS;
case 9999:
return TimePeriod.SEVEN_DAYS;
case 14 * 24 * 60:
return TimePeriod.FOURTEEN_DAYS;
default:
return TimePeriod.SEVEN_DAYS;
}
};
/**
* This is a chart to be used in Metric Alert rules that fetches events based on
* query, timewindow, and aggregations.
*/
class TriggersChart extends PureComponent<Props, State> {
state: State = {
statsPeriod: getStatsPeriodFromQuery(this.props.location.query.statsPeriod),
totalCount: null,
sampleRate: 1,
};
componentDidMount() {
const {aggregate, showTotalCount} = this.props;
if (showTotalCount && !isSessionAggregate(aggregate)) {
this.fetchTotalCount();
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
const {query, environment, timeWindow, aggregate, projects, showTotalCount} =
this.props;
const {statsPeriod} = this.state;
if (
showTotalCount &&
!isSessionAggregate(aggregate) &&
(!isEqual(prevProps.projects, projects) ||
prevProps.environment !== environment ||
prevProps.query !== query ||
!isEqual(prevProps.timeWindow, timeWindow) ||
!isEqual(prevState.statsPeriod, statsPeriod))
) {
this.fetchTotalCount();
}
}
get availableTimePeriods() {
// We need to special case sessions, because sub-hour windows are available
// only when time period is six hours or less (backend limitation)
if (isSessionAggregate(this.props.aggregate)) {
return {
...AVAILABLE_TIME_PERIODS,
[TimeWindow.THIRTY_MINUTES]: [TimePeriod.SIX_HOURS],
};
}
return AVAILABLE_TIME_PERIODS;
}
handleStatsPeriodChange = (timePeriod: TimePeriod) => {
this.setState({statsPeriod: timePeriod});
};
getStatsPeriod = () => {
const {statsPeriod} = this.state;
const {timeWindow} = this.props;
const statsPeriodOptions = this.availableTimePeriods[timeWindow];
const period = statsPeriodOptions.includes(statsPeriod)
? statsPeriod
: statsPeriodOptions[statsPeriodOptions.length - 1];
return period;
};
get comparisonSeriesName() {
return capitalize(
COMPARISON_DELTA_OPTIONS.find(({value}) => value === this.props.comparisonDelta)
?.label || ''
);
}
async fetchTotalCount() {
const {
api,
organization,
location,
newAlertOrQuery,
environment,
projects,
query,
dataset,
} = this.props;
const statsPeriod = this.getStatsPeriod();
const queryExtras = getMetricDatasetQueryExtras({
organization,
location,
dataset,
newAlertOrQuery,
});
let queryDataset = queryExtras.dataset as undefined | DiscoverDatasets;
if (shouldUseErrorsDiscoverDataset(query, dataset)) {
queryDataset = DiscoverDatasets.ERRORS;
}
try {
const totalCount = await fetchTotalCount(api, organization.slug, {
field: [],
project: projects.map(({id}) => id),
query,
statsPeriod,
environment: environment ? [environment] : [],
dataset: queryDataset,
...getForceMetricsLayerQueryExtras(organization, dataset),
});
this.setState({totalCount});
} catch (e) {
this.setState({totalCount: null});
}
}
renderChart({
isLoading,
isReloading,
timeseriesData = [],
comparisonData,
comparisonMarkLines,
errorMessage,
minutesThresholdToDisplaySeconds,
isQueryValid,
errored,
orgFeatures,
seriesAdditionalInfo,
}: {
isLoading: boolean;
isQueryValid: boolean;
isReloading: boolean;
orgFeatures: string[];
timeseriesData: Series[];
comparisonData?: Series[];
comparisonMarkLines?: LineChartSeries[];
errorMessage?: string;
errored?: boolean;
minutesThresholdToDisplaySeconds?: number;
seriesAdditionalInfo?: Record<string, any>;
}) {
const {
triggers,
resolveThreshold,
thresholdType,
header,
timeWindow,
aggregate,
comparisonType,
organization,
showTotalCount,
} = this.props;
const {statsPeriod, totalCount} = this.state;
const statsPeriodOptions = this.availableTimePeriods[timeWindow];
const period = this.getStatsPeriod();
const error = orgFeatures.includes('alert-allow-indexed')
? errored || errorMessage
: errored || errorMessage || !isQueryValid;
const showExtrapolatedChartData =
shouldShowOnDemandMetricAlertUI(organization) &&
seriesAdditionalInfo?.[timeseriesData[0]?.seriesName]?.isExtrapolatedData;
const totalCountLabel = isSessionAggregate(aggregate)
? SESSION_AGGREGATE_TO_HEADING[aggregate]
: showExtrapolatedChartData
? t('Estimated Transactions')
: t('Total');
return (
<Fragment>
{header}
<TransparentLoadingMask visible={isReloading} />
{isLoading && !error ? (
<ChartPlaceholder />
) : error ? (
<ErrorChart
isAllowIndexed={orgFeatures.includes('alert-allow-indexed')}
errorMessage={errorMessage}
isQueryValid={isQueryValid}
/>
) : (
<ThresholdsChart
period={statsPeriod}
minValue={minBy(timeseriesData[0]?.data, ({value}) => value)?.value}
maxValue={maxBy(timeseriesData[0]?.data, ({value}) => value)?.value}
data={timeseriesData}
comparisonData={comparisonData ?? []}
comparisonSeriesName={this.comparisonSeriesName}
comparisonMarkLines={comparisonMarkLines ?? []}
hideThresholdLines={comparisonType === AlertRuleComparisonType.CHANGE}
triggers={triggers}
resolveThreshold={resolveThreshold}
thresholdType={thresholdType}
aggregate={aggregate}
minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
isExtrapolatedData={showExtrapolatedChartData}
/>
)}
<ChartControls>
{showTotalCount ? (
<InlineContainer data-test-id="alert-total-events">
<SectionHeading>{totalCountLabel}</SectionHeading>
<SectionValue>
{totalCount !== null ? totalCount.toLocaleString() : '\u2014'}
</SectionValue>
</InlineContainer>
) : (
<InlineContainer />
)}
<InlineContainer>
<CompactSelect
size="sm"
options={statsPeriodOptions.map(timePeriod => ({
value: timePeriod,
label: TIME_PERIOD_MAP[timePeriod],
}))}
value={period}
onChange={opt => this.handleStatsPeriodChange(opt.value)}
position="bottom-end"
triggerProps={{
borderless: true,
prefix: t('Display'),
}}
disabled={isLoading || isReloading}
/>
</InlineContainer>
</ChartControls>
</Fragment>
);
}
render() {
const {
api,
organization,
projects,
timeWindow,
query,
location,
aggregate,
dataset,
newAlertOrQuery,
onDataLoaded,
environment,
comparisonDelta,
triggers,
thresholdType,
isQueryValid,
isOnDemandMetricAlert,
} = this.props;
const period = this.getStatsPeriod();
const renderComparisonStats = Boolean(
organization.features.includes('change-alerts') && comparisonDelta
);
const queryExtras = {
...getMetricDatasetQueryExtras({
organization,
location,
dataset,
newAlertOrQuery,
}),
...getForceMetricsLayerQueryExtras(organization, dataset),
...(shouldUseErrorsDiscoverDataset(query, dataset)
? {dataset: DiscoverDatasets.ERRORS}
: {}),
};
if (isOnDemandMetricAlert) {
return (
<OnDemandMetricRequest
api={api}
organization={organization}
query={query}
environment={environment ? [environment] : undefined}
project={projects.map(({id}) => Number(id))}
interval={`${timeWindow}m`}
comparisonDelta={comparisonDelta && comparisonDelta * 60}
period={period}
yAxis={aggregate}
includePrevious={false}
currentSeriesNames={[aggregate]}
partial={false}
queryExtras={queryExtras}
sampleRate={this.state.sampleRate}
dataLoadedCallback={onDataLoaded}
>
{({
loading,
errored,
errorMessage,
reloading,
timeseriesData,
comparisonTimeseriesData,
seriesAdditionalInfo,
}) => {
let comparisonMarkLines: LineChartSeries[] = [];
if (renderComparisonStats && comparisonTimeseriesData) {
comparisonMarkLines = getComparisonMarkLines(
timeseriesData,
comparisonTimeseriesData,
timeWindow,
triggers,
thresholdType
);
}
return this.renderChart({
timeseriesData: timeseriesData as Series[],
isLoading: loading,
isReloading: reloading,
comparisonData: comparisonTimeseriesData,
comparisonMarkLines,
errorMessage,
isQueryValid,
errored,
orgFeatures: organization.features,
seriesAdditionalInfo,
});
}}
</OnDemandMetricRequest>
);
}
return isSessionAggregate(aggregate) ? (
<SessionsRequest
api={api}
organization={organization}
project={projects.map(({id}) => Number(id))}
environment={environment ? [environment] : undefined}
statsPeriod={period}
query={query}
interval={TIME_WINDOW_TO_SESSION_INTERVAL[timeWindow]}
field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
groupBy={['session.status']}
>
{({loading, errored, reloading, response}) => {
const {groups, intervals} = response || {};
const sessionTimeSeries = [
{
seriesName:
AlertWizardAlertNames[
getAlertTypeFromAggregateDataset({aggregate, dataset: Dataset.SESSIONS})
],
data: getCrashFreeRateSeries(
groups,
intervals,
SESSION_AGGREGATE_TO_FIELD[aggregate]
),
},
];
return this.renderChart({
timeseriesData: sessionTimeSeries,
isLoading: loading,
isReloading: reloading,
comparisonData: undefined,
comparisonMarkLines: undefined,
minutesThresholdToDisplaySeconds: MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
isQueryValid,
errored,
orgFeatures: organization.features,
});
}}
</SessionsRequest>
) : (
<EventsRequest
api={api}
organization={organization}
query={query}
environment={environment ? [environment] : undefined}
project={projects.map(({id}) => Number(id))}
interval={`${timeWindow}m`}
comparisonDelta={comparisonDelta && comparisonDelta * 60}
period={period}
yAxis={aggregate}
includePrevious={false}
currentSeriesNames={[formatMRIField(aggregate)]}
partial={false}
queryExtras={queryExtras}
useOnDemandMetrics
dataLoadedCallback={onDataLoaded}
>
{({
loading,
errored,
errorMessage,
reloading,
timeseriesData,
comparisonTimeseriesData,
}) => {
let comparisonMarkLines: LineChartSeries[] = [];
if (renderComparisonStats && comparisonTimeseriesData) {
comparisonMarkLines = getComparisonMarkLines(
timeseriesData,
comparisonTimeseriesData,
timeWindow,
triggers,
thresholdType
);
}
return this.renderChart({
timeseriesData: timeseriesData as Series[],
isLoading: loading,
isReloading: reloading,
comparisonData: comparisonTimeseriesData,
comparisonMarkLines,
errorMessage,
isQueryValid,
errored,
orgFeatures: organization.features,
});
}}
</EventsRequest>
);
}
}
export default withApi(TriggersChart);
const TransparentLoadingMask = styled(LoadingMask)<{visible: boolean}>`
${p => !p.visible && 'display: none;'};
opacity: 0.4;
z-index: 1;
`;
const ChartPlaceholder = styled(Placeholder)`
/* Height and margin should add up to graph size (200px) */
margin: 0 0 ${space(2)};
height: 184px;
`;
const StyledErrorPanel = styled(ErrorPanel)`
/* Height and margin should with the alert should match up placeholder height of (184px) */
padding: ${space(2)};
height: 119px;
`;
const ChartErrorWrapper = styled('div')`
margin-top: ${space(2)};
`;
function ErrorChart({isAllowIndexed, isQueryValid, errorMessage}) {
return (
<ChartErrorWrapper>
<PanelAlert type="error">
{!isAllowIndexed && !isQueryValid
? t('Your filter conditions contain an unsupported field - please review.')
: typeof errorMessage === 'string'
? errorMessage
: t('An error occurred while fetching data')}
</PanelAlert>
<StyledErrorPanel>
<IconWarning color="gray500" size="lg" />
</StyledErrorPanel>
</ChartErrorWrapper>
);
}