import {Fragment, PureComponent} from 'react'; import {browserHistory, WithRouterProps} from 'react-router'; import styled from '@emotion/styled'; import color from 'color'; import type {LineSeriesOption} from 'echarts'; import capitalize from 'lodash/capitalize'; import moment from 'moment'; import momentTimezone from 'moment-timezone'; import {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 {AreaChart, AreaChartSeries} 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 { parseStatsPeriod, StatsPeriodType, } 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 Truncate from 'sentry/components/truncate'; 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 {DateString, Organization, Project} from 'sentry/types'; import {ReactEchartsRef, Series} from 'sentry/types/echarts'; import {getUtcDateString} from 'sentry/utils/dates'; import {getDuration} from 'sentry/utils/formatters'; import getDynamicText from 'sentry/utils/getDynamicText'; import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features'; import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions'; import theme from 'sentry/utils/theme'; import toArray from 'sentry/utils/toArray'; 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 { AlertRuleTriggerType, MetricRule, 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 {Incident} from '../../../types'; import { alertDetailsLink, alertTooltipValueFormatter, isSessionAggregate, SESSION_AGGREGATE_TO_FIELD, } from '../../../utils'; import {getMetricDatasetQueryExtras} from '../utils/getMetricDatasetQueryExtras'; import {isCrashFreeAlert} from '../utils/isCrashFreeAlert'; import {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 = { height: number; width: number; }; 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: [], }, ]; } class MetricChart extends PureComponent { state = { width: -1, height: -1, }; ref: null | ReactEchartsRef = null; /** * Syncs component state with the chart's width/heights */ updateDimensions = () => { const chartRef = this.ref?.getEchartsInstance?.(); if (!chartRef) { return; } const width = chartRef.getWidth(); const height = chartRef.getHeight(); if (width !== this.state.width || height !== this.state.height) { this.setState({ width, height, }); } }; handleRef = (ref: ReactEchartsRef): void => { if (ref && !this.ref) { this.ref = ref; this.updateDimensions(); } if (!ref) { this.ref = 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; const {buttonText, ...props} = makeDefaultCta({ orgSlug: organization.slug, projects: [project], rule, timePeriod, query, }); 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 {width} = this.state; 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); const percentOfWidth = width >= 1151 ? 15 : width < 1151 && width >= 700 ? 14 : width < 700 && width >= 515 ? 13 : width < 515 && width >= 300 ? 12 : 8; const truncateWidth = (percentOfWidth / 100) * width; return ( {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]} {rule.aggregate} {getDynamicText({ value: ( this.handleZoom(zoomArgs.start, zoomArgs.end)} onFinished={() => { // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of // any graphics related to the triggers (e.g. the threshold areas + boundaries) this.updateDimensions(); }} > {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 = getMetricDatasetQueryExtras({ organization, location, dataset, newAlertOrQuery: false, }); 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: repeat(3, max-content); align-items: center; `; const Filters = styled('span')` margin-right: ${space(1)}; `; 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; `;