index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import capitalize from 'lodash/capitalize';
  4. import maxBy from 'lodash/maxBy';
  5. import minBy from 'lodash/minBy';
  6. import {fetchTotalCount} from 'sentry/actionCreators/events';
  7. import {Client} from 'sentry/api';
  8. import EventsRequest from 'sentry/components/charts/eventsRequest';
  9. import {LineChartSeries} from 'sentry/components/charts/lineChart';
  10. import OptionSelector from 'sentry/components/charts/optionSelector';
  11. import SessionsRequest from 'sentry/components/charts/sessionsRequest';
  12. import {
  13. ChartControls,
  14. InlineContainer,
  15. SectionHeading,
  16. SectionValue,
  17. } from 'sentry/components/charts/styles';
  18. import LoadingMask from 'sentry/components/loadingMask';
  19. import Placeholder from 'sentry/components/placeholder';
  20. import {t} from 'sentry/locale';
  21. import space from 'sentry/styles/space';
  22. import {Organization, Project} from 'sentry/types';
  23. import type {Series} from 'sentry/types/echarts';
  24. import {
  25. getCrashFreeRateSeries,
  26. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
  27. } from 'sentry/utils/sessions';
  28. import withApi from 'sentry/utils/withApi';
  29. import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
  30. import {isSessionAggregate, SESSION_AGGREGATE_TO_FIELD} from 'sentry/views/alerts/utils';
  31. import {getComparisonMarkLines} from 'sentry/views/alerts/utils/getComparisonMarkLines';
  32. import {
  33. AlertWizardAlertNames,
  34. getMEPAlertsDataset,
  35. } from 'sentry/views/alerts/wizard/options';
  36. import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
  37. import {
  38. AlertRuleComparisonType,
  39. Dataset,
  40. MetricRule,
  41. SessionsAggregate,
  42. TimePeriod,
  43. TimeWindow,
  44. Trigger,
  45. } from '../../types';
  46. import ThresholdsChart from './thresholdsChart';
  47. type Props = {
  48. aggregate: MetricRule['aggregate'];
  49. api: Client;
  50. comparisonType: AlertRuleComparisonType;
  51. dataset: MetricRule['dataset'];
  52. environment: string | null;
  53. handleMEPAlertDataset: (isMetricsData?: boolean) => void;
  54. newAlertOrQuery: boolean;
  55. organization: Organization;
  56. projects: Project[];
  57. query: MetricRule['query'];
  58. resolveThreshold: MetricRule['resolveThreshold'];
  59. thresholdType: MetricRule['thresholdType'];
  60. timeWindow: MetricRule['timeWindow'];
  61. triggers: Trigger[];
  62. comparisonDelta?: number;
  63. header?: React.ReactNode;
  64. };
  65. const TIME_PERIOD_MAP: Record<TimePeriod, string> = {
  66. [TimePeriod.SIX_HOURS]: t('Last 6 hours'),
  67. [TimePeriod.ONE_DAY]: t('Last 24 hours'),
  68. [TimePeriod.THREE_DAYS]: t('Last 3 days'),
  69. [TimePeriod.SEVEN_DAYS]: t('Last 7 days'),
  70. [TimePeriod.FOURTEEN_DAYS]: t('Last 14 days'),
  71. [TimePeriod.THIRTY_DAYS]: t('Last 30 days'),
  72. };
  73. /**
  74. * Just to avoid repeating it
  75. */
  76. const MOST_TIME_PERIODS: readonly TimePeriod[] = [
  77. TimePeriod.ONE_DAY,
  78. TimePeriod.THREE_DAYS,
  79. TimePeriod.SEVEN_DAYS,
  80. TimePeriod.FOURTEEN_DAYS,
  81. TimePeriod.THIRTY_DAYS,
  82. ];
  83. /**
  84. * TimeWindow determines data available in TimePeriod
  85. * If TimeWindow is small, lower TimePeriod to limit data points
  86. */
  87. const AVAILABLE_TIME_PERIODS: Record<TimeWindow, readonly TimePeriod[]> = {
  88. [TimeWindow.ONE_MINUTE]: [
  89. TimePeriod.SIX_HOURS,
  90. TimePeriod.ONE_DAY,
  91. TimePeriod.THREE_DAYS,
  92. TimePeriod.SEVEN_DAYS,
  93. ],
  94. [TimeWindow.FIVE_MINUTES]: MOST_TIME_PERIODS,
  95. [TimeWindow.TEN_MINUTES]: MOST_TIME_PERIODS,
  96. [TimeWindow.FIFTEEN_MINUTES]: MOST_TIME_PERIODS,
  97. [TimeWindow.THIRTY_MINUTES]: MOST_TIME_PERIODS,
  98. [TimeWindow.ONE_HOUR]: MOST_TIME_PERIODS,
  99. [TimeWindow.TWO_HOURS]: MOST_TIME_PERIODS,
  100. [TimeWindow.FOUR_HOURS]: [
  101. TimePeriod.THREE_DAYS,
  102. TimePeriod.SEVEN_DAYS,
  103. TimePeriod.FOURTEEN_DAYS,
  104. TimePeriod.THIRTY_DAYS,
  105. ],
  106. [TimeWindow.ONE_DAY]: [TimePeriod.THIRTY_DAYS],
  107. };
  108. const TIME_WINDOW_TO_SESSION_INTERVAL = {
  109. [TimeWindow.THIRTY_MINUTES]: '30m',
  110. [TimeWindow.ONE_HOUR]: '1h',
  111. [TimeWindow.TWO_HOURS]: '2h',
  112. [TimeWindow.FOUR_HOURS]: '4h',
  113. [TimeWindow.ONE_DAY]: '1d',
  114. };
  115. const SESSION_AGGREGATE_TO_HEADING = {
  116. [SessionsAggregate.CRASH_FREE_SESSIONS]: t('Total Sessions'),
  117. [SessionsAggregate.CRASH_FREE_USERS]: t('Total Users'),
  118. };
  119. type State = {
  120. statsPeriod: TimePeriod;
  121. totalCount: number | null;
  122. };
  123. /**
  124. * This is a chart to be used in Metric Alert rules that fetches events based on
  125. * query, timewindow, and aggregations.
  126. */
  127. class TriggersChart extends PureComponent<Props, State> {
  128. state: State = {
  129. statsPeriod: TimePeriod.SEVEN_DAYS,
  130. totalCount: null,
  131. };
  132. componentDidMount() {
  133. if (!isSessionAggregate(this.props.aggregate)) {
  134. this.fetchTotalCount();
  135. }
  136. }
  137. componentDidUpdate(prevProps: Props, prevState: State) {
  138. const {query, environment, timeWindow, aggregate, projects} = this.props;
  139. const {statsPeriod} = this.state;
  140. if (
  141. !isSessionAggregate(aggregate) &&
  142. (prevProps.projects !== projects ||
  143. prevProps.environment !== environment ||
  144. prevProps.query !== query ||
  145. prevProps.timeWindow !== timeWindow ||
  146. prevState.statsPeriod !== statsPeriod)
  147. ) {
  148. this.fetchTotalCount();
  149. }
  150. }
  151. get availableTimePeriods() {
  152. // We need to special case sessions, because sub-hour windows are available
  153. // only when time period is six hours or less (backend limitation)
  154. if (isSessionAggregate(this.props.aggregate)) {
  155. return {
  156. ...AVAILABLE_TIME_PERIODS,
  157. [TimeWindow.THIRTY_MINUTES]: [TimePeriod.SIX_HOURS],
  158. };
  159. }
  160. return AVAILABLE_TIME_PERIODS;
  161. }
  162. handleStatsPeriodChange = (timePeriod: string) => {
  163. this.setState({statsPeriod: timePeriod as TimePeriod});
  164. };
  165. getStatsPeriod = () => {
  166. const {statsPeriod} = this.state;
  167. const {timeWindow} = this.props;
  168. const statsPeriodOptions = this.availableTimePeriods[timeWindow];
  169. const period = statsPeriodOptions.includes(statsPeriod)
  170. ? statsPeriod
  171. : statsPeriodOptions[statsPeriodOptions.length - 1];
  172. return period;
  173. };
  174. get comparisonSeriesName() {
  175. return capitalize(
  176. COMPARISON_DELTA_OPTIONS.find(({value}) => value === this.props.comparisonDelta)
  177. ?.label || ''
  178. );
  179. }
  180. async fetchTotalCount() {
  181. const {api, organization, environment, projects, query} = this.props;
  182. const statsPeriod = this.getStatsPeriod();
  183. try {
  184. const totalCount = await fetchTotalCount(api, organization.slug, {
  185. field: [],
  186. project: projects.map(({id}) => id),
  187. query,
  188. statsPeriod,
  189. environment: environment ? [environment] : [],
  190. });
  191. this.setState({totalCount});
  192. } catch (e) {
  193. this.setState({totalCount: null});
  194. }
  195. }
  196. renderChart(
  197. timeseriesData: Series[] = [],
  198. isLoading: boolean,
  199. isReloading: boolean,
  200. comparisonData?: Series[],
  201. comparisonMarkLines?: LineChartSeries[],
  202. minutesThresholdToDisplaySeconds?: number
  203. ) {
  204. const {
  205. triggers,
  206. resolveThreshold,
  207. thresholdType,
  208. header,
  209. timeWindow,
  210. aggregate,
  211. comparisonType,
  212. } = this.props;
  213. const {statsPeriod, totalCount} = this.state;
  214. const statsPeriodOptions = this.availableTimePeriods[timeWindow];
  215. const period = this.getStatsPeriod();
  216. return (
  217. <Fragment>
  218. {header}
  219. <TransparentLoadingMask visible={isReloading} />
  220. {isLoading ? (
  221. <ChartPlaceholder />
  222. ) : (
  223. <ThresholdsChart
  224. period={statsPeriod}
  225. minValue={minBy(timeseriesData[0]?.data, ({value}) => value)?.value}
  226. maxValue={maxBy(timeseriesData[0]?.data, ({value}) => value)?.value}
  227. data={timeseriesData}
  228. comparisonData={comparisonData ?? []}
  229. comparisonSeriesName={this.comparisonSeriesName}
  230. comparisonMarkLines={comparisonMarkLines ?? []}
  231. hideThresholdLines={comparisonType === AlertRuleComparisonType.CHANGE}
  232. triggers={triggers}
  233. resolveThreshold={resolveThreshold}
  234. thresholdType={thresholdType}
  235. aggregate={aggregate}
  236. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  237. />
  238. )}
  239. <ChartControls>
  240. <InlineContainer>
  241. <SectionHeading>
  242. {isSessionAggregate(aggregate)
  243. ? SESSION_AGGREGATE_TO_HEADING[aggregate]
  244. : t('Total Events')}
  245. </SectionHeading>
  246. <SectionValue>
  247. {totalCount !== null ? totalCount.toLocaleString() : '\u2014'}
  248. </SectionValue>
  249. </InlineContainer>
  250. <InlineContainer>
  251. <OptionSelector
  252. options={statsPeriodOptions.map(timePeriod => ({
  253. label: TIME_PERIOD_MAP[timePeriod],
  254. value: timePeriod,
  255. disabled: isLoading || isReloading,
  256. }))}
  257. selected={period}
  258. onChange={this.handleStatsPeriodChange}
  259. title={t('Display')}
  260. />
  261. </InlineContainer>
  262. </ChartControls>
  263. </Fragment>
  264. );
  265. }
  266. render() {
  267. const {
  268. api,
  269. organization,
  270. projects,
  271. timeWindow,
  272. query,
  273. aggregate,
  274. dataset,
  275. newAlertOrQuery,
  276. handleMEPAlertDataset,
  277. environment,
  278. comparisonDelta,
  279. triggers,
  280. thresholdType,
  281. } = this.props;
  282. const period = this.getStatsPeriod();
  283. const renderComparisonStats = Boolean(
  284. organization.features.includes('change-alerts') && comparisonDelta
  285. );
  286. const queryExtras = {
  287. ...(organization.features.includes('metrics-performance-alerts')
  288. ? {dataset: getMEPAlertsDataset(dataset, newAlertOrQuery)}
  289. : {}),
  290. };
  291. return isSessionAggregate(aggregate) ? (
  292. <SessionsRequest
  293. api={api}
  294. organization={organization}
  295. project={projects.map(({id}) => Number(id))}
  296. environment={environment ? [environment] : undefined}
  297. statsPeriod={period}
  298. query={query}
  299. interval={TIME_WINDOW_TO_SESSION_INTERVAL[timeWindow]}
  300. field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
  301. groupBy={['session.status']}
  302. >
  303. {({loading, reloading, response}) => {
  304. const {groups, intervals} = response || {};
  305. const sessionTimeSeries = [
  306. {
  307. seriesName:
  308. AlertWizardAlertNames[
  309. getAlertTypeFromAggregateDataset({aggregate, dataset: Dataset.SESSIONS})
  310. ],
  311. data: getCrashFreeRateSeries(
  312. groups,
  313. intervals,
  314. SESSION_AGGREGATE_TO_FIELD[aggregate]
  315. ),
  316. },
  317. ];
  318. return this.renderChart(
  319. sessionTimeSeries,
  320. loading,
  321. reloading,
  322. undefined,
  323. undefined,
  324. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
  325. );
  326. }}
  327. </SessionsRequest>
  328. ) : (
  329. <EventsRequest
  330. api={api}
  331. organization={organization}
  332. query={query}
  333. environment={environment ? [environment] : undefined}
  334. project={projects.map(({id}) => Number(id))}
  335. interval={`${timeWindow}m`}
  336. comparisonDelta={comparisonDelta && comparisonDelta * 60}
  337. period={period}
  338. yAxis={aggregate}
  339. includePrevious={false}
  340. currentSeriesNames={[aggregate]}
  341. partial={false}
  342. queryExtras={queryExtras}
  343. processDataCallback={handleMEPAlertDataset}
  344. >
  345. {({loading, reloading, timeseriesData, comparisonTimeseriesData}) => {
  346. let comparisonMarkLines: LineChartSeries[] = [];
  347. if (renderComparisonStats && comparisonTimeseriesData) {
  348. comparisonMarkLines = getComparisonMarkLines(
  349. timeseriesData,
  350. comparisonTimeseriesData,
  351. timeWindow,
  352. triggers,
  353. thresholdType
  354. );
  355. }
  356. return this.renderChart(
  357. timeseriesData,
  358. loading,
  359. reloading,
  360. comparisonTimeseriesData,
  361. comparisonMarkLines
  362. );
  363. }}
  364. </EventsRequest>
  365. );
  366. }
  367. }
  368. export default withApi(TriggersChart);
  369. const TransparentLoadingMask = styled(LoadingMask)<{visible: boolean}>`
  370. ${p => !p.visible && 'display: none;'};
  371. opacity: 0.4;
  372. z-index: 1;
  373. `;
  374. const ChartPlaceholder = styled(Placeholder)`
  375. /* Height and margin should add up to graph size (200px) */
  376. margin: 0 0 ${space(2)};
  377. height: 184px;
  378. `;