metricChart.tsx 19 KB

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