import {Fragment, PureComponent} from 'react'; import type {WithRouterProps} from 'react-router'; import styled from '@emotion/styled'; import color from 'color'; import type {LineSeriesOption} from 'echarts'; import moment from 'moment'; import momentTimezone from 'moment-timezone'; import type {Client} from 'sentry/api'; import Feature from 'sentry/components/acl/feature'; import {OnDemandMetricAlert} from 'sentry/components/alerts/onDemandMetricAlert'; import {Button} from 'sentry/components/button'; import type {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 EventsRequest from 'sentry/components/charts/eventsRequest'; import LineSeries from 'sentry/components/charts/series/lineSeries'; import SessionsRequest from 'sentry/components/charts/sessionsRequest'; 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 type {StatsPeriodType} from 'sentry/components/organizations/pageFilters/parse'; 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, Organization, Project} from 'sentry/types'; import type {ReactEchartsRef, Series} from 'sentry/types/echarts'; import toArray from 'sentry/utils/array/toArray'; import {browserHistory} from 'sentry/utils/browserHistory'; import {getUtcDateString} from 'sentry/utils/dates'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import getDuration from 'sentry/utils/duration/getDuration'; import getDynamicText from 'sentry/utils/getDynamicText'; import {getForceMetricsLayerQueryExtras} from 'sentry/utils/metrics/features'; import {formatMRIField} from 'sentry/utils/metrics/mri'; 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 theme from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; // eslint-disable-next-line no-restricted-imports import withSentryRouter from 'sentry/utils/withSentryRouter'; 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, TimePeriod, } from 'sentry/views/alerts/rules/metric/types'; 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 type {Incident} from '../../../types'; import { alertDetailsLink, alertTooltipValueFormatter, isSessionAggregate, SESSION_AGGREGATE_TO_FIELD, } from '../../../utils'; import {getMetricDatasetQueryExtras} from '../utils/getMetricDatasetQueryExtras'; import {isCrashFreeAlert} from '../utils/isCrashFreeAlert'; import type {TimePeriodType} from './constants'; import { getMetricAlertChartOption, transformSessionResponseToSeries, } from './metricChartOption'; type Props = WithRouterProps & { api: Client; filter: string[] | null; interval: string; organization: Organization; project: Project; query: string; rule: MetricRule; timePeriod: TimePeriodType; incidents?: Incident[]; isOnDemandAlert?: boolean; selectedIncident?: Incident | null; }; type State = Record; function formatTooltipDate(date: moment.MomentInput, format: string): string { const { options: {timezone}, } = ConfigStore.get('user'); return momentTimezone.tz(date, timezone).format(format); } function getRuleChangeSeries( rule: MetricRule, data: AreaChartSeries[] ): 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: [], }, ]; } function shouldUseErrorsDataset( organization: Organization, dataset: Dataset, query: string ): boolean { return ( dataset === Dataset.ERRORS && /\bis:unresolved\b/.test(query) && organization.features.includes('metric-alert-ignore-archived') ); } class MetricChart extends PureComponent { ref: null | ReactEchartsRef = null; handleZoom = (start: DateString, end: DateString) => { const {location} = this.props; browserHistory.push({ pathname: location.pathname, query: { start, end, }, }); }; renderChartActions( totalDuration: number, criticalDuration: number, warningDuration: number, waitingForDataDuration: number ) { const {rule, organization, project, timePeriod, query} = this.props; let dataset: DiscoverDatasets | undefined = undefined; if (shouldUseErrorsDataset(organization, rule.dataset, query)) { dataset = DiscoverDatasets.ERRORS; } const {buttonText, ...props} = makeDefaultCta({ orgSlug: organization.slug, projects: [project], rule, timePeriod, query, dataset, }); 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 ( {t('Summary')} {resolvedPercent ? resolvedPercent.toFixed(2) : 0}% {warningPercent ? warningPercent.toFixed(2) : 0}% {criticalPercent ? criticalPercent.toFixed(2) : 0}% {waitingForDataPercent > 0 && ( {waitingForDataPercent.toFixed(2)}% )} {!isSessionAggregate(rule.aggregate) && ( )} ); } renderChart( loading: boolean, timeseriesData?: Series[], minutesThresholdToDisplaySeconds?: number, comparisonTimeseriesData?: Series[] ) { const { router, selectedIncident, interval, filter, incidents, rule, organization, timePeriod: {start, end}, } = this.props; const {dateModified, timeWindow} = rule; if (loading || !timeseriesData) { return this.renderEmpty(); } const handleIncidentClick = (incident: Incident) => { router.push( normalizeUrl({ pathname: alertDetailsLink(organization, incident), query: {alert: incident.identifier}, }) ); }; const { criticalDuration, warningDuration, totalDuration, waitingForDataDuration, chartOption, } = getMetricAlertChartOption({ timeseriesData, rule, incidents, selectedIncident, showWaitingForData: shouldShowOnDemandMetricAlertUI(organization) && this.props.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), ]; const queryFilter = filter?.join(' ') + t(' over ') + getDuration(rule.timeWindow * 60); return ( {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]} {formatMRIField(rule.aggregate)} {queryFilter} {getDynamicText({ value: ( this.handleZoom(zoomArgs.start, zoomArgs.end)} > {zoomRenderProps => ( { // seriesParams can be object instead of array const pointSeries = toArray(seriesParams); const {marker, data: pointData, seriesName} = pointSeries[0]; 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 as StatsPeriodType ), 'MMM D LT' ); const comparisonSeries = pointSeries.length > 1 ? pointSeries.find( ({seriesName: _sn}) => _sn === comparisonSeriesName ) : undefined; 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 [ `
`, isModified && `
${t( 'Alert Rule Modified' )}
`, `
${marker} ${seriesName}${pointYFormatted}
`, comparisonSeries && `
${comparisonSeries.marker} ${comparisonSeriesName}${comparisonPointYFormatted}
`, `
`, ``, '
', ] .filter(e => e) .join(''); }, }} /> )}
), fixed: , })}
{this.renderChartActions( totalDuration, criticalDuration, warningDuration, waitingForDataDuration )}
); } renderEmptyOnDemandAlert( organization: Organization, timeseriesData: Series[] = [], loading?: boolean ) { if ( loading || !this.props.isOnDemandAlert || !shouldShowOnDemandMetricAlertUI(organization) || !isEmptySeries(timeseriesData[0]) ) { return null; } return ( ); } renderEmpty(placeholderText = '') { return ( {placeholderText} ); } render() { const { api, rule, organization, timePeriod, project, interval, query, location, isOnDemandAlert, } = this.props; const {aggregate, timeWindow, environment, dataset} = rule; // Fix for 7 days * 1m interval being over the max number of results from events api // 10k events is the current max if ( timePeriod.usingPeriod && timePeriod.period === TimePeriod.SEVEN_DAYS && interval === '1m' ) { timePeriod.start = getUtcDateString( // -5 minutes provides a small cushion for rounding up minutes. This might be able to be smaller moment(moment.utc(timePeriod.end).subtract(10000 - 5, 'minutes')) ); } // If the chart duration isn't as long as the rollup duration the events-stats // endpoint will return an invalid timeseriesData dataset const viableStartDate = getUtcDateString( moment.min( moment.utc(timePeriod.start), moment.utc(timePeriod.end).subtract(timeWindow, 'minutes') ) ); const viableEndDate = getUtcDateString( moment.utc(timePeriod.end).add(timeWindow, 'minutes') ); const queryExtras: Record = { ...getMetricDatasetQueryExtras({ organization, location, dataset, newAlertOrQuery: false, useOnDemandMetrics: isOnDemandAlert, }), ...getForceMetricsLayerQueryExtras(organization, dataset), }; if (shouldUseErrorsDataset(organization, dataset, query)) { queryExtras.dataset = 'errors'; } return isCrashFreeAlert(dataset) ? ( {({loading, response}) => this.renderChart( loading, transformSessionResponseToSeries(response, rule), MINUTES_THRESHOLD_TO_DISPLAY_SECONDS ) } ) : ( {({loading, timeseriesData, comparisonTimeseriesData}) => ( {this.renderEmptyOnDemandAlert(organization, timeseriesData, loading)} {this.renderChart( loading, timeseriesData, undefined, comparisonTimeseriesData )} )} ); } } export default withSentryRouter(MetricChart); 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; `;