123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669 |
- import {Fragment, useCallback} from 'react';
- import {type Theme, useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import color from 'color';
- import type {LineSeriesOption} from 'echarts';
- import type {TopLevelFormatterParams} from 'echarts/types/src/component/tooltip/TooltipModel';
- import moment from 'moment-timezone';
- import Feature from 'sentry/components/acl/feature';
- import {OnDemandMetricAlert} from 'sentry/components/alerts/onDemandMetricAlert';
- import {Button} from 'sentry/components/button';
- import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart';
- import {AreaChart} from 'sentry/components/charts/areaChart';
- import ChartZoom from 'sentry/components/charts/chartZoom';
- import MarkArea from 'sentry/components/charts/components/markArea';
- import MarkLine from 'sentry/components/charts/components/markLine';
- import {
- transformComparisonTimeseriesData,
- transformTimeseriesData,
- } from 'sentry/components/charts/eventsRequest';
- import LineSeries from 'sentry/components/charts/series/lineSeries';
- import {
- ChartControls,
- HeaderTitleLegend,
- InlineContainer,
- SectionHeading,
- SectionValue,
- } from 'sentry/components/charts/styles';
- import {isEmptySeries} from 'sentry/components/charts/utils';
- import CircleIndicator from 'sentry/components/circleIndicator';
- import {parseStatsPeriod} from 'sentry/components/organizations/pageFilters/parse';
- import Panel from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import Placeholder from 'sentry/components/placeholder';
- import {Tooltip} from 'sentry/components/tooltip';
- import {IconCheckmark, IconClock, IconFire, IconWarning} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import ConfigStore from 'sentry/stores/configStore';
- import {space} from 'sentry/styles/space';
- import type {DateString} from 'sentry/types/core';
- import type {Series} from 'sentry/types/echarts';
- import type {Organization} from 'sentry/types/organization';
- import type {Project} from 'sentry/types/project';
- import toArray from 'sentry/utils/array/toArray';
- import {DiscoverDatasets, SavedQueryDatasets} from 'sentry/utils/discover/types';
- import getDuration from 'sentry/utils/duration/getDuration';
- import getDynamicText from 'sentry/utils/getDynamicText';
- import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features';
- import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions';
- import {capitalize} from 'sentry/utils/string/capitalize';
- import normalizeUrl from 'sentry/utils/url/normalizeUrl';
- import {useLocation} from 'sentry/utils/useLocation';
- import {useNavigate} from 'sentry/utils/useNavigate';
- import useOrganization from 'sentry/utils/useOrganization';
- import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
- import {makeDefaultCta} from 'sentry/views/alerts/rules/metric/metricRulePresets';
- import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
- import {AlertRuleTriggerType, Dataset} from 'sentry/views/alerts/rules/metric/types';
- import {shouldUseErrorsDiscoverDataset} from 'sentry/views/alerts/rules/utils';
- import {getChangeStatus} from 'sentry/views/alerts/utils/getChangeStatus';
- import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
- import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
- import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
- import {useMetricEventStats} from 'sentry/views/issueDetails/metricIssues/useMetricEventStats';
- import {useMetricSessionStats} from 'sentry/views/issueDetails/metricIssues/useMetricSessionStats';
- import type {Anomaly, Incident} from '../../../types';
- import {
- alertDetailsLink,
- alertTooltipValueFormatter,
- isSessionAggregate,
- } from '../../../utils';
- import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
- import type {TimePeriodType} from './constants';
- import {
- getMetricAlertChartOption,
- transformSessionResponseToSeries,
- } from './metricChartOption';
- interface MetricChartProps {
- filter: string[] | null;
- interval: string;
- project: Project;
- query: string;
- rule: MetricRule;
- theme: Theme;
- timePeriod: TimePeriodType;
- anomalies?: Anomaly[];
- formattedAggregate?: string;
- incidents?: Incident[];
- isOnDemandAlert?: boolean;
- }
- function formatTooltipDate(date: moment.MomentInput, format: string): string {
- const {
- options: {timezone},
- } = ConfigStore.get('user');
- return moment.tz(date, timezone).format(format);
- }
- export function getRuleChangeSeries(
- rule: MetricRule,
- data: AreaChartSeries[],
- theme: Theme
- ): LineSeriesOption[] {
- const {dateModified} = rule;
- if (!data.length || !data[0]!.data.length || !dateModified) {
- return [];
- }
- const seriesData = data[0]!.data;
- const seriesStart = new Date(seriesData[0]!.name).getTime();
- const ruleChanged = new Date(dateModified).getTime();
- if (ruleChanged < seriesStart) {
- return [];
- }
- return [
- {
- type: 'line',
- markLine: MarkLine({
- silent: true,
- animation: false,
- lineStyle: {color: theme.gray200, type: 'solid', width: 1},
- data: [{xAxis: ruleChanged}],
- label: {
- show: false,
- },
- }),
- markArea: MarkArea({
- silent: true,
- itemStyle: {
- color: color(theme.gray100).alpha(0.42).rgb().string(),
- },
- data: [[{xAxis: seriesStart}, {xAxis: ruleChanged}]],
- }),
- data: [],
- },
- ];
- }
- export default function MetricChart({
- rule,
- project,
- timePeriod,
- query,
- anomalies,
- isOnDemandAlert,
- interval,
- filter,
- incidents,
- formattedAggregate,
- }: MetricChartProps) {
- const theme = useTheme();
- const location = useLocation();
- const navigate = useNavigate();
- const organization = useOrganization();
- const shouldUseSessionsStats = isCrashFreeAlert(rule.dataset);
- const handleZoom = useCallback(
- (start: DateString, end: DateString) => {
- navigate({
- pathname: location.pathname,
- query: {start, end},
- });
- },
- [location.pathname, navigate]
- );
- const renderEmpty = useCallback((placeholderText = '') => {
- return (
- <ChartPanel>
- <PanelBody withPadding>
- <TriggerChartPlaceholder>{placeholderText}</TriggerChartPlaceholder>
- </PanelBody>
- </ChartPanel>
- );
- }, []);
- const renderEmptyOnDemandAlert = useCallback(
- (org: Organization, timeseriesData: Series[] = [], loading?: boolean) => {
- if (
- loading ||
- !isOnDemandAlert ||
- !shouldShowOnDemandMetricAlertUI(org) ||
- !isEmptySeries(timeseriesData[0]!)
- ) {
- return null;
- }
- return (
- <OnDemandMetricAlert
- dismissable
- message={t(
- 'This alert lacks historical data due to filters for which we don’t routinely extract metrics.'
- )}
- />
- );
- },
- [isOnDemandAlert]
- );
- const renderChartActions = useCallback(
- (
- totalDuration: number,
- criticalDuration: number,
- warningDuration: number,
- waitingForDataDuration: number
- ) => {
- let dataset: DiscoverDatasets | undefined = undefined;
- if (shouldUseErrorsDiscoverDataset(query, rule.dataset, organization)) {
- dataset = DiscoverDatasets.ERRORS;
- }
- let openInDiscoverDataset: SavedQueryDatasets | undefined = undefined;
- if (hasDatasetSelector(organization)) {
- if (rule.dataset === Dataset.ERRORS) {
- openInDiscoverDataset = SavedQueryDatasets.ERRORS;
- } else if (
- rule.dataset === Dataset.TRANSACTIONS ||
- rule.dataset === Dataset.GENERIC_METRICS
- ) {
- openInDiscoverDataset = SavedQueryDatasets.TRANSACTIONS;
- }
- }
- const {buttonText, ...props} = makeDefaultCta({
- organization,
- projects: [project],
- rule,
- timePeriod,
- query,
- dataset,
- openInDiscoverDataset,
- });
- const resolvedPercent =
- (100 *
- Math.max(
- totalDuration - waitingForDataDuration - criticalDuration - warningDuration,
- 0
- )) /
- totalDuration;
- const criticalPercent = 100 * Math.min(criticalDuration / totalDuration, 1);
- const warningPercent = 100 * Math.min(warningDuration / totalDuration, 1);
- const waitingForDataPercent =
- 100 *
- Math.min(
- (waitingForDataDuration - criticalDuration - warningDuration) / totalDuration,
- 1
- );
- return (
- <StyledChartControls>
- <StyledInlineContainer>
- <Fragment>
- <SectionHeading>{t('Summary')}</SectionHeading>
- <StyledSectionValue>
- <ValueItem>
- <IconCheckmark color="successText" isCircled />
- {resolvedPercent ? resolvedPercent.toFixed(2) : 0}%
- </ValueItem>
- <ValueItem>
- <IconWarning color="warningText" />
- {warningPercent ? warningPercent.toFixed(2) : 0}%
- </ValueItem>
- <ValueItem>
- <IconFire color="errorText" />
- {criticalPercent ? criticalPercent.toFixed(2) : 0}%
- </ValueItem>
- {waitingForDataPercent > 0 && (
- <StyledTooltip
- underlineColor="gray200"
- showUnderline
- title={t(
- 'The time spent waiting for metrics matching the filters used.'
- )}
- >
- <ValueItem>
- <IconClock />
- {waitingForDataPercent.toFixed(2)}%
- </ValueItem>
- </StyledTooltip>
- )}
- </StyledSectionValue>
- </Fragment>
- </StyledInlineContainer>
- {!isSessionAggregate(rule.aggregate) &&
- (getAlertTypeFromAggregateDataset(rule) === 'eap_metrics' ? (
- <Feature features="visibility-explore-view">
- <Button size="sm" {...props}>
- {buttonText}
- </Button>
- </Feature>
- ) : (
- <Feature features="discover-basic">
- <Button size="sm" {...props}>
- {buttonText}
- </Button>
- </Feature>
- ))}
- </StyledChartControls>
- );
- },
- [rule, organization, project, timePeriod, query]
- );
- const renderChart = useCallback(
- (
- loading: boolean,
- timeseriesData?: Series[],
- minutesThresholdToDisplaySeconds?: number,
- comparisonTimeseriesData?: Series[]
- ) => {
- const {start, end} = timePeriod;
- if (loading || !timeseriesData) {
- return renderEmpty();
- }
- const handleIncidentClick = (incident: Incident) => {
- navigate(
- normalizeUrl({
- pathname: alertDetailsLink(organization, incident),
- query: {alert: incident.identifier},
- })
- );
- };
- const {
- criticalDuration,
- warningDuration,
- totalDuration,
- waitingForDataDuration,
- chartOption,
- } = getMetricAlertChartOption({
- timeseriesData,
- rule,
- seriesName: formattedAggregate,
- incidents,
- anomalies,
- showWaitingForData:
- shouldShowOnDemandMetricAlertUI(organization) && isOnDemandAlert,
- handleIncidentClick,
- });
- const comparisonSeriesName = capitalize(
- COMPARISON_DELTA_OPTIONS.find(({value}) => value === rule.comparisonDelta)
- ?.label || ''
- );
- const additionalSeries: LineSeriesOption[] = [
- ...(comparisonTimeseriesData || []).map(({data: _data, ...otherSeriesProps}) =>
- LineSeries({
- name: comparisonSeriesName,
- data: _data.map(({name, value}) => [name, value]),
- lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
- itemStyle: {color: theme.gray200},
- animation: false,
- animationThreshold: 1,
- animationDuration: 0,
- ...otherSeriesProps,
- })
- ),
- ...getRuleChangeSeries(rule, timeseriesData, theme),
- ];
- const queryFilter =
- filter?.join(' ') + t(' over ') + getDuration(rule.timeWindow * 60);
- return (
- <ChartPanel>
- <StyledPanelBody withPadding>
- <ChartHeader>
- <HeaderTitleLegend>
- {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
- </HeaderTitleLegend>
- </ChartHeader>
- <ChartFilters>
- <StyledCircleIndicator size={8} />
- <Filters>{formattedAggregate ?? rule.aggregate}</Filters>
- <Tooltip
- title={queryFilter}
- isHoverable
- skipWrapper
- overlayStyle={{
- maxWidth: '90vw',
- lineBreak: 'anywhere',
- textAlign: 'left',
- }}
- showOnlyOnOverflow
- >
- <QueryFilters>{queryFilter}</QueryFilters>
- </Tooltip>
- </ChartFilters>
- {getDynamicText({
- value: (
- <ChartZoom
- start={start}
- end={end}
- onZoom={zoomArgs => handleZoom(zoomArgs.start, zoomArgs.end)}
- >
- {zoomRenderProps => (
- <AreaChart
- {...zoomRenderProps}
- {...chartOption}
- showTimeInTooltip
- minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
- additionalSeries={additionalSeries}
- tooltip={getMetricChartTooltipFormatter({
- formattedAggregate,
- rule,
- interval,
- comparisonSeriesName,
- theme,
- })}
- />
- )}
- </ChartZoom>
- ),
- fixed: <Placeholder height="200px" testId="skeleton-ui" />,
- })}
- </StyledPanelBody>
- {renderChartActions(
- totalDuration,
- criticalDuration,
- warningDuration,
- waitingForDataDuration
- )}
- </ChartPanel>
- );
- },
- [
- anomalies,
- filter,
- formattedAggregate,
- handleZoom,
- incidents,
- interval,
- isOnDemandAlert,
- navigate,
- organization,
- renderChartActions,
- renderEmpty,
- rule,
- theme,
- timePeriod,
- ]
- );
- const {data: eventStats, isLoading: isLoadingEventStats} = useMetricEventStats(
- {
- project,
- rule,
- timePeriod,
- referrer: 'api.alerts.alert-rule-chart',
- },
- {enabled: !shouldUseSessionsStats}
- );
- const {data: sessionStats, isLoading: isLoadingSessionStats} = useMetricSessionStats(
- {
- project,
- rule,
- timePeriod,
- },
- {
- enabled: shouldUseSessionsStats,
- }
- );
- const isLoading = isLoadingEventStats || isLoadingSessionStats;
- const timeSeriesData = shouldUseSessionsStats
- ? transformSessionResponseToSeries(sessionStats ?? null, rule)
- : transformTimeseriesData(eventStats?.data ?? [], eventStats?.meta, rule.aggregate);
- const minutesThresholdToDisplaySeconds = shouldUseSessionsStats
- ? MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
- : undefined;
- const comparisonTimeseriesData = rule.comparisonDelta
- ? transformComparisonTimeseriesData(eventStats?.data ?? [])
- : [];
- return (
- <Fragment>
- {shouldUseSessionsStats
- ? null
- : renderEmptyOnDemandAlert(organization, timeSeriesData, isLoading)}
- {renderChart(
- isLoading,
- timeSeriesData,
- minutesThresholdToDisplaySeconds,
- comparisonTimeseriesData
- )}
- </Fragment>
- );
- }
- export function getMetricChartTooltipFormatter({
- interval,
- rule,
- theme,
- comparisonSeriesName,
- formattedAggregate,
- }: {
- interval: string;
- rule: MetricRule;
- theme: Theme;
- comparisonSeriesName?: string;
- formattedAggregate?: string;
- }): AreaChartProps['tooltip'] {
- const {dateModified, timeWindow} = rule;
- function formatter(seriesParams: TopLevelFormatterParams) {
- // seriesParams can be object instead of array
- const pointSeries = toArray(seriesParams);
- // @ts-expect-error TS(2339): Property 'marker' does not exist on type 'Callback... Remove this comment to see the full error message
- const {marker, data: pointData} = pointSeries[0];
- const seriesName = formattedAggregate ?? pointSeries[0]?.seriesName ?? '';
- const [pointX, pointY] = pointData as [number, number];
- const pointYFormatted = alertTooltipValueFormatter(
- pointY,
- seriesName,
- rule.aggregate
- );
- const isModified = dateModified && pointX <= new Date(dateModified).getTime();
- const startTime = formatTooltipDate(moment(pointX), 'MMM D LT');
- const {period, periodLength} = parseStatsPeriod(interval) ?? {
- periodLength: 'm',
- period: `${timeWindow}`,
- };
- const endTime = formatTooltipDate(
- moment(pointX).add(parseInt(period!, 10), periodLength),
- 'MMM D LT'
- );
- const comparisonSeries =
- pointSeries.length > 1
- ? pointSeries.find(({seriesName: _sn}) => _sn === comparisonSeriesName)
- : undefined;
- // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- const comparisonPointY = comparisonSeries?.data[1] as number | undefined;
- const comparisonPointYFormatted =
- comparisonPointY !== undefined
- ? alertTooltipValueFormatter(comparisonPointY, seriesName, rule.aggregate)
- : undefined;
- const changePercentage =
- comparisonPointY === undefined
- ? NaN
- : ((pointY - comparisonPointY) * 100) / comparisonPointY;
- const changeStatus = getChangeStatus(
- changePercentage,
- rule.thresholdType,
- rule.triggers
- );
- const changeStatusColor =
- changeStatus === AlertRuleTriggerType.CRITICAL
- ? theme.red300
- : changeStatus === AlertRuleTriggerType.WARNING
- ? theme.yellow300
- : theme.green300;
- return [
- `<div class="tooltip-series">`,
- isModified &&
- `<div><span class="tooltip-label"><strong>${t(
- 'Alert Rule Modified'
- )}</strong></span></div>`,
- `<div><span class="tooltip-label">${marker} <strong>${seriesName}</strong></span>${pointYFormatted}</div>`,
- comparisonSeries &&
- `<div><span class="tooltip-label">${comparisonSeries.marker} <strong>${comparisonSeriesName}</strong></span>${comparisonPointYFormatted}</div>`,
- `</div>`,
- `<div class="tooltip-footer">`,
- `<span>${startTime} — ${endTime}</span>`,
- comparisonPointY !== undefined &&
- Math.abs(changePercentage) !== Infinity &&
- !isNaN(changePercentage) &&
- `<span style="color:${changeStatusColor};margin-left:10px;">${
- Math.sign(changePercentage) === 1 ? '+' : '-'
- }${Math.abs(changePercentage).toFixed(2)}%</span>`,
- `</div>`,
- '<div class="tooltip-arrow"></div>',
- ]
- .filter(e => e)
- .join('');
- }
- return {formatter};
- }
- const ChartPanel = styled(Panel)`
- margin-top: ${space(2)};
- `;
- const ChartHeader = styled('div')`
- margin-bottom: ${space(3)};
- `;
- const StyledChartControls = styled(ChartControls)`
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- `;
- const StyledInlineContainer = styled(InlineContainer)`
- grid-auto-flow: column;
- grid-column-gap: ${space(1)};
- `;
- const StyledCircleIndicator = styled(CircleIndicator)`
- background: ${p => p.theme.formText};
- height: ${space(1)};
- margin-right: ${space(0.5)};
- `;
- const ChartFilters = styled('div')`
- font-size: ${p => p.theme.fontSizeSmall};
- font-family: ${p => p.theme.text.family};
- color: ${p => p.theme.textColor};
- display: inline-grid;
- grid-template-columns: max-content max-content auto;
- align-items: center;
- `;
- const Filters = styled('span')`
- margin-right: ${space(1)};
- `;
- const QueryFilters = styled('span')`
- min-width: 0px;
- ${p => p.theme.overflowEllipsis}
- `;
- const StyledSectionValue = styled(SectionValue)`
- display: grid;
- grid-template-columns: repeat(4, auto);
- gap: ${space(1.5)};
- margin: 0 0 0 ${space(1.5)};
- `;
- const ValueItem = styled('div')`
- display: grid;
- grid-template-columns: repeat(2, auto);
- gap: ${space(0.5)};
- align-items: center;
- font-variant-numeric: tabular-nums;
- text-underline-offset: ${space(4)};
- `;
- /* Override padding to make chart appear centered */
- const StyledPanelBody = styled(PanelBody)`
- padding-right: 6px;
- `;
- const TriggerChartPlaceholder = styled(Placeholder)`
- height: 200px;
- text-align: center;
- padding: ${space(3)};
- `;
- const StyledTooltip = styled(Tooltip)`
- text-underline-offset: ${space(0.5)} !important;
- `;
|