metricChart.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. import {PureComponent} from 'react';
  2. import {browserHistory, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import color from 'color';
  5. import type {LineSeriesOption} from 'echarts';
  6. import capitalize from 'lodash/capitalize';
  7. import moment from 'moment';
  8. import momentTimezone from 'moment-timezone';
  9. import {Client} from 'sentry/api';
  10. import Feature from 'sentry/components/acl/feature';
  11. import {Button} from 'sentry/components/button';
  12. import {AreaChart, AreaChartSeries} from 'sentry/components/charts/areaChart';
  13. import ChartZoom from 'sentry/components/charts/chartZoom';
  14. import MarkArea from 'sentry/components/charts/components/markArea';
  15. import MarkLine from 'sentry/components/charts/components/markLine';
  16. import EventsRequest from 'sentry/components/charts/eventsRequest';
  17. import LineSeries from 'sentry/components/charts/series/lineSeries';
  18. import SessionsRequest from 'sentry/components/charts/sessionsRequest';
  19. import {
  20. ChartControls,
  21. HeaderTitleLegend,
  22. InlineContainer,
  23. SectionHeading,
  24. SectionValue,
  25. } from 'sentry/components/charts/styles';
  26. import CircleIndicator from 'sentry/components/circleIndicator';
  27. import {
  28. parseStatsPeriod,
  29. StatsPeriodType,
  30. } from 'sentry/components/organizations/pageFilters/parse';
  31. import {Panel, PanelBody} from 'sentry/components/panels';
  32. import Placeholder from 'sentry/components/placeholder';
  33. import Truncate from 'sentry/components/truncate';
  34. import {IconCheckmark, IconFire, IconWarning} from 'sentry/icons';
  35. import {t} from 'sentry/locale';
  36. import ConfigStore from 'sentry/stores/configStore';
  37. import {space} from 'sentry/styles/space';
  38. import {DateString, Organization, Project} from 'sentry/types';
  39. import {ReactEchartsRef, Series} from 'sentry/types/echarts';
  40. import {getUtcDateString} from 'sentry/utils/dates';
  41. import {getDuration} from 'sentry/utils/formatters';
  42. import getDynamicText from 'sentry/utils/getDynamicText';
  43. import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions';
  44. import theme from 'sentry/utils/theme';
  45. import toArray from 'sentry/utils/toArray';
  46. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  47. // eslint-disable-next-line no-restricted-imports
  48. import withSentryRouter from 'sentry/utils/withSentryRouter';
  49. import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
  50. import {makeDefaultCta} from 'sentry/views/alerts/rules/metric/metricRulePresets';
  51. import {
  52. AlertRuleTriggerType,
  53. MetricRule,
  54. TimePeriod,
  55. } from 'sentry/views/alerts/rules/metric/types';
  56. import {getChangeStatus} from 'sentry/views/alerts/utils/getChangeStatus';
  57. import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
  58. import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
  59. import {Incident} from '../../../types';
  60. import {
  61. alertDetailsLink,
  62. alertTooltipValueFormatter,
  63. isSessionAggregate,
  64. SESSION_AGGREGATE_TO_FIELD,
  65. } from '../../../utils';
  66. import {getMetricDatasetQueryExtras} from '../utils/getMetricDatasetQueryExtras';
  67. import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
  68. import {TimePeriodType} from './constants';
  69. import {
  70. getMetricAlertChartOption,
  71. transformSessionResponseToSeries,
  72. } from './metricChartOption';
  73. type Props = WithRouterProps & {
  74. api: Client;
  75. filter: string[] | null;
  76. interval: string;
  77. organization: Organization;
  78. project: Project;
  79. query: string;
  80. rule: MetricRule;
  81. timePeriod: TimePeriodType;
  82. incidents?: Incident[];
  83. selectedIncident?: Incident | null;
  84. };
  85. type State = {
  86. height: number;
  87. width: number;
  88. };
  89. function formatTooltipDate(date: moment.MomentInput, format: string): string {
  90. const {
  91. options: {timezone},
  92. } = ConfigStore.get('user');
  93. return momentTimezone.tz(date, timezone).format(format);
  94. }
  95. function getRuleChangeSeries(
  96. rule: MetricRule,
  97. data: AreaChartSeries[]
  98. ): LineSeriesOption[] {
  99. const {dateModified} = rule;
  100. if (!data.length || !data[0].data.length || !dateModified) {
  101. return [];
  102. }
  103. const seriesData = data[0].data;
  104. const seriesStart = new Date(seriesData[0].name).getTime();
  105. const ruleChanged = new Date(dateModified).getTime();
  106. if (ruleChanged < seriesStart) {
  107. return [];
  108. }
  109. return [
  110. {
  111. type: 'line',
  112. markLine: MarkLine({
  113. silent: true,
  114. animation: false,
  115. lineStyle: {color: theme.gray200, type: 'solid', width: 1},
  116. data: [{xAxis: ruleChanged}],
  117. label: {
  118. show: false,
  119. },
  120. }),
  121. markArea: MarkArea({
  122. silent: true,
  123. itemStyle: {
  124. color: color(theme.gray100).alpha(0.42).rgb().string(),
  125. },
  126. data: [[{xAxis: seriesStart}, {xAxis: ruleChanged}]],
  127. }),
  128. data: [],
  129. },
  130. ];
  131. }
  132. class MetricChart extends PureComponent<Props, State> {
  133. state = {
  134. width: -1,
  135. height: -1,
  136. };
  137. ref: null | ReactEchartsRef = null;
  138. /**
  139. * Syncs component state with the chart's width/heights
  140. */
  141. updateDimensions = () => {
  142. const chartRef = this.ref?.getEchartsInstance?.();
  143. if (!chartRef) {
  144. return;
  145. }
  146. const width = chartRef.getWidth();
  147. const height = chartRef.getHeight();
  148. if (width !== this.state.width || height !== this.state.height) {
  149. this.setState({
  150. width,
  151. height,
  152. });
  153. }
  154. };
  155. handleRef = (ref: ReactEchartsRef): void => {
  156. if (ref && !this.ref) {
  157. this.ref = ref;
  158. this.updateDimensions();
  159. }
  160. if (!ref) {
  161. this.ref = null;
  162. }
  163. };
  164. handleZoom = (start: DateString, end: DateString) => {
  165. const {location} = this.props;
  166. browserHistory.push({
  167. pathname: location.pathname,
  168. query: {
  169. start,
  170. end,
  171. },
  172. });
  173. };
  174. renderChartActions(
  175. totalDuration: number,
  176. criticalDuration: number,
  177. warningDuration: number
  178. ) {
  179. const {rule, organization, project, timePeriod, query} = this.props;
  180. const {buttonText, ...props} = makeDefaultCta({
  181. orgSlug: organization.slug,
  182. projects: [project],
  183. rule,
  184. timePeriod,
  185. query,
  186. });
  187. const resolvedPercent =
  188. (100 * Math.max(totalDuration - criticalDuration - warningDuration, 0)) /
  189. totalDuration;
  190. const criticalPercent = 100 * Math.min(criticalDuration / totalDuration, 1);
  191. const warningPercent = 100 * Math.min(warningDuration / totalDuration, 1);
  192. return (
  193. <StyledChartControls>
  194. <StyledInlineContainer>
  195. <SectionHeading>{t('Summary')}</SectionHeading>
  196. <StyledSectionValue>
  197. <ValueItem>
  198. <IconCheckmark color="successText" isCircled />
  199. {resolvedPercent ? resolvedPercent.toFixed(2) : 0}%
  200. </ValueItem>
  201. <ValueItem>
  202. <IconWarning color="warningText" />
  203. {warningPercent ? warningPercent.toFixed(2) : 0}%
  204. </ValueItem>
  205. <ValueItem>
  206. <IconFire color="errorText" />
  207. {criticalPercent ? criticalPercent.toFixed(2) : 0}%
  208. </ValueItem>
  209. </StyledSectionValue>
  210. </StyledInlineContainer>
  211. {!isSessionAggregate(rule.aggregate) && (
  212. <Feature features={['discover-basic']}>
  213. <Button size="sm" {...props}>
  214. {buttonText}
  215. </Button>
  216. </Feature>
  217. )}
  218. </StyledChartControls>
  219. );
  220. }
  221. renderChart(
  222. loading: boolean,
  223. timeseriesData?: Series[],
  224. minutesThresholdToDisplaySeconds?: number,
  225. comparisonTimeseriesData?: Series[]
  226. ) {
  227. const {
  228. router,
  229. selectedIncident,
  230. interval,
  231. filter,
  232. incidents,
  233. rule,
  234. organization,
  235. timePeriod: {start, end},
  236. } = this.props;
  237. const {width} = this.state;
  238. const {dateModified, timeWindow} = rule;
  239. if (loading || !timeseriesData) {
  240. return this.renderEmpty();
  241. }
  242. const handleIncidentClick = (incident: Incident) => {
  243. router.push(
  244. normalizeUrl({
  245. pathname: alertDetailsLink(organization, incident),
  246. query: {alert: incident.identifier},
  247. })
  248. );
  249. };
  250. const {criticalDuration, warningDuration, totalDuration, chartOption} =
  251. getMetricAlertChartOption({
  252. timeseriesData,
  253. rule,
  254. incidents,
  255. selectedIncident,
  256. handleIncidentClick,
  257. });
  258. const comparisonSeriesName = capitalize(
  259. COMPARISON_DELTA_OPTIONS.find(({value}) => value === rule.comparisonDelta)?.label ||
  260. ''
  261. );
  262. const additionalSeries: LineSeriesOption[] = [
  263. ...(comparisonTimeseriesData || []).map(({data: _data, ...otherSeriesProps}) =>
  264. LineSeries({
  265. name: comparisonSeriesName,
  266. data: _data.map(({name, value}) => [name, value]),
  267. lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
  268. itemStyle: {color: theme.gray200},
  269. animation: false,
  270. animationThreshold: 1,
  271. animationDuration: 0,
  272. ...otherSeriesProps,
  273. })
  274. ),
  275. ...getRuleChangeSeries(rule, timeseriesData),
  276. ];
  277. const queryFilter =
  278. filter?.join(' ') + t(' over ') + getDuration(rule.timeWindow * 60);
  279. const percentOfWidth =
  280. width >= 1151
  281. ? 15
  282. : width < 1151 && width >= 700
  283. ? 14
  284. : width < 700 && width >= 515
  285. ? 13
  286. : width < 515 && width >= 300
  287. ? 12
  288. : 8;
  289. const truncateWidth = (percentOfWidth / 100) * width;
  290. return (
  291. <ChartPanel>
  292. <StyledPanelBody withPadding>
  293. <ChartHeader>
  294. <HeaderTitleLegend>
  295. {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
  296. </HeaderTitleLegend>
  297. </ChartHeader>
  298. <ChartFilters>
  299. <StyledCircleIndicator size={8} />
  300. <Filters>{rule.aggregate}</Filters>
  301. <Truncate value={queryFilter ?? ''} maxLength={truncateWidth} />
  302. </ChartFilters>
  303. {getDynamicText({
  304. value: (
  305. <ChartZoom
  306. router={router}
  307. start={start}
  308. end={end}
  309. onZoom={zoomArgs => this.handleZoom(zoomArgs.start, zoomArgs.end)}
  310. onFinished={() => {
  311. // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
  312. // any graphics related to the triggers (e.g. the threshold areas + boundaries)
  313. this.updateDimensions();
  314. }}
  315. >
  316. {zoomRenderProps => (
  317. <AreaChart
  318. {...zoomRenderProps}
  319. {...chartOption}
  320. showTimeInTooltip
  321. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  322. forwardedRef={this.handleRef}
  323. additionalSeries={additionalSeries}
  324. tooltip={{
  325. formatter: seriesParams => {
  326. // seriesParams can be object instead of array
  327. const pointSeries = toArray(seriesParams);
  328. const {marker, data: pointData, seriesName} = pointSeries[0];
  329. const [pointX, pointY] = pointData as [number, number];
  330. const pointYFormatted = alertTooltipValueFormatter(
  331. pointY,
  332. seriesName ?? '',
  333. rule.aggregate
  334. );
  335. const isModified =
  336. dateModified && pointX <= new Date(dateModified).getTime();
  337. const startTime = formatTooltipDate(moment(pointX), 'MMM D LT');
  338. const {period, periodLength} = parseStatsPeriod(interval) ?? {
  339. periodLength: 'm',
  340. period: `${timeWindow}`,
  341. };
  342. const endTime = formatTooltipDate(
  343. moment(pointX).add(
  344. parseInt(period, 10),
  345. periodLength as StatsPeriodType
  346. ),
  347. 'MMM D LT'
  348. );
  349. const comparisonSeries =
  350. pointSeries.length > 1
  351. ? pointSeries.find(
  352. ({seriesName: _sn}) => _sn === comparisonSeriesName
  353. )
  354. : undefined;
  355. const comparisonPointY = comparisonSeries?.data[1] as
  356. | number
  357. | undefined;
  358. const comparisonPointYFormatted =
  359. comparisonPointY !== undefined
  360. ? alertTooltipValueFormatter(
  361. comparisonPointY,
  362. seriesName ?? '',
  363. rule.aggregate
  364. )
  365. : undefined;
  366. const changePercentage =
  367. comparisonPointY === undefined
  368. ? NaN
  369. : ((pointY - comparisonPointY) * 100) / comparisonPointY;
  370. const changeStatus = getChangeStatus(
  371. changePercentage,
  372. rule.thresholdType,
  373. rule.triggers
  374. );
  375. const changeStatusColor =
  376. changeStatus === AlertRuleTriggerType.CRITICAL
  377. ? theme.red300
  378. : changeStatus === AlertRuleTriggerType.WARNING
  379. ? theme.yellow300
  380. : theme.green300;
  381. return [
  382. `<div class="tooltip-series">`,
  383. isModified &&
  384. `<div><span class="tooltip-label"><strong>${t(
  385. 'Alert Rule Modified'
  386. )}</strong></span></div>`,
  387. `<div><span class="tooltip-label">${marker} <strong>${seriesName}</strong></span>${pointYFormatted}</div>`,
  388. comparisonSeries &&
  389. `<div><span class="tooltip-label">${comparisonSeries.marker} <strong>${comparisonSeriesName}</strong></span>${comparisonPointYFormatted}</div>`,
  390. `</div>`,
  391. `<div class="tooltip-footer">`,
  392. `<span>${startTime} &mdash; ${endTime}</span>`,
  393. comparisonPointY !== undefined &&
  394. Math.abs(changePercentage) !== Infinity &&
  395. !isNaN(changePercentage) &&
  396. `<span style="color:${changeStatusColor};margin-left:10px;">${
  397. Math.sign(changePercentage) === 1 ? '+' : '-'
  398. }${Math.abs(changePercentage).toFixed(2)}%</span>`,
  399. `</div>`,
  400. '<div class="tooltip-arrow"></div>',
  401. ]
  402. .filter(e => e)
  403. .join('');
  404. },
  405. }}
  406. />
  407. )}
  408. </ChartZoom>
  409. ),
  410. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  411. })}
  412. </StyledPanelBody>
  413. {this.renderChartActions(totalDuration, criticalDuration, warningDuration)}
  414. </ChartPanel>
  415. );
  416. }
  417. renderEmpty() {
  418. return (
  419. <ChartPanel>
  420. <PanelBody withPadding>
  421. <Placeholder height="200px" />
  422. </PanelBody>
  423. </ChartPanel>
  424. );
  425. }
  426. render() {
  427. const {api, rule, organization, timePeriod, project, interval, query, location} =
  428. this.props;
  429. const {aggregate, timeWindow, environment, dataset} = rule;
  430. // Fix for 7 days * 1m interval being over the max number of results from events api
  431. // 10k events is the current max
  432. if (
  433. timePeriod.usingPeriod &&
  434. timePeriod.period === TimePeriod.SEVEN_DAYS &&
  435. interval === '1m'
  436. ) {
  437. timePeriod.start = getUtcDateString(
  438. // -5 minutes provides a small cushion for rounding up minutes. This might be able to be smaller
  439. moment(moment.utc(timePeriod.end).subtract(10000 - 5, 'minutes'))
  440. );
  441. }
  442. // If the chart duration isn't as long as the rollup duration the events-stats
  443. // endpoint will return an invalid timeseriesData dataset
  444. const viableStartDate = getUtcDateString(
  445. moment.min(
  446. moment.utc(timePeriod.start),
  447. moment.utc(timePeriod.end).subtract(timeWindow, 'minutes')
  448. )
  449. );
  450. const viableEndDate = getUtcDateString(
  451. moment.utc(timePeriod.end).add(timeWindow, 'minutes')
  452. );
  453. const queryExtras = getMetricDatasetQueryExtras({
  454. organization,
  455. location,
  456. dataset,
  457. newAlertOrQuery: false,
  458. });
  459. return isCrashFreeAlert(dataset) ? (
  460. <SessionsRequest
  461. api={api}
  462. organization={organization}
  463. project={project.id ? [Number(project.id)] : []}
  464. environment={environment ? [environment] : undefined}
  465. start={viableStartDate}
  466. end={viableEndDate}
  467. query={query}
  468. interval={interval}
  469. field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
  470. groupBy={['session.status']}
  471. >
  472. {({loading, response}) =>
  473. this.renderChart(
  474. loading,
  475. transformSessionResponseToSeries(response, rule),
  476. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
  477. )
  478. }
  479. </SessionsRequest>
  480. ) : (
  481. <EventsRequest
  482. api={api}
  483. organization={organization}
  484. query={query}
  485. environment={environment ? [environment] : undefined}
  486. project={project.id ? [Number(project.id)] : []}
  487. interval={interval}
  488. comparisonDelta={rule.comparisonDelta ? rule.comparisonDelta * 60 : undefined}
  489. start={viableStartDate}
  490. end={viableEndDate}
  491. yAxis={aggregate}
  492. includePrevious={false}
  493. currentSeriesNames={[aggregate]}
  494. partial={false}
  495. queryExtras={queryExtras}
  496. referrer="api.alerts.alert-rule-chart"
  497. >
  498. {({loading, timeseriesData, comparisonTimeseriesData}) =>
  499. this.renderChart(loading, timeseriesData, undefined, comparisonTimeseriesData)
  500. }
  501. </EventsRequest>
  502. );
  503. }
  504. }
  505. export default withSentryRouter(MetricChart);
  506. const ChartPanel = styled(Panel)`
  507. margin-top: ${space(2)};
  508. `;
  509. const ChartHeader = styled('div')`
  510. margin-bottom: ${space(3)};
  511. `;
  512. const StyledChartControls = styled(ChartControls)`
  513. display: flex;
  514. justify-content: space-between;
  515. flex-wrap: wrap;
  516. `;
  517. const StyledInlineContainer = styled(InlineContainer)`
  518. grid-auto-flow: column;
  519. grid-column-gap: ${space(1)};
  520. `;
  521. const StyledCircleIndicator = styled(CircleIndicator)`
  522. background: ${p => p.theme.formText};
  523. height: ${space(1)};
  524. margin-right: ${space(0.5)};
  525. `;
  526. const ChartFilters = styled('div')`
  527. font-size: ${p => p.theme.fontSizeSmall};
  528. font-family: ${p => p.theme.text.family};
  529. color: ${p => p.theme.textColor};
  530. display: inline-grid;
  531. grid-template-columns: repeat(3, max-content);
  532. align-items: center;
  533. `;
  534. const Filters = styled('span')`
  535. margin-right: ${space(1)};
  536. `;
  537. const StyledSectionValue = styled(SectionValue)`
  538. display: grid;
  539. grid-template-columns: repeat(3, auto);
  540. gap: ${space(1.5)};
  541. margin: 0 0 0 ${space(1.5)};
  542. `;
  543. const ValueItem = styled('div')`
  544. display: grid;
  545. grid-template-columns: repeat(2, auto);
  546. gap: ${space(0.5)};
  547. align-items: center;
  548. font-variant-numeric: tabular-nums;
  549. `;
  550. /* Override padding to make chart appear centered */
  551. const StyledPanelBody = styled(PanelBody)`
  552. padding-right: 6px;
  553. `;