eventGraph.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. import {type CSSProperties, useEffect, useMemo, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import Color from 'color';
  5. import Alert from 'sentry/components/alert';
  6. import {Button, type ButtonProps} from 'sentry/components/button';
  7. import {BarChart, type BarChartSeries} from 'sentry/components/charts/barChart';
  8. import Legend from 'sentry/components/charts/components/legend';
  9. import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
  10. import {useChartZoom} from 'sentry/components/charts/useChartZoom';
  11. import {Flex} from 'sentry/components/container/flex';
  12. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  13. import Placeholder from 'sentry/components/placeholder';
  14. import {t, tct, tn} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {SeriesDataUnit} from 'sentry/types/echarts';
  17. import type {Event} from 'sentry/types/event';
  18. import type {Group} from 'sentry/types/group';
  19. import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
  20. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  21. import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
  22. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  23. import {useApiQuery} from 'sentry/utils/queryClient';
  24. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {getBucketSize} from 'sentry/views/dashboards/widgetCard/utils';
  28. import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context';
  29. import {useCurrentEventMarklineSeries} from 'sentry/views/issueDetails/streamline/hooks/useEventMarkLineSeries';
  30. import useFlagSeries from 'sentry/views/issueDetails/streamline/hooks/useFlagSeries';
  31. import {
  32. useIssueDetailsDiscoverQuery,
  33. useIssueDetailsEventView,
  34. } from 'sentry/views/issueDetails/streamline/hooks/useIssueDetailsDiscoverQuery';
  35. import {useReleaseMarkLineSeries} from 'sentry/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries';
  36. const enum EventGraphSeries {
  37. EVENT = 'event',
  38. USER = 'user',
  39. }
  40. interface EventGraphProps {
  41. event: Event | undefined;
  42. group: Group;
  43. className?: string;
  44. style?: CSSProperties;
  45. }
  46. function createSeriesAndCount(stats: EventsStats) {
  47. return stats?.data?.reduce(
  48. (result, [timestamp, countData]) => {
  49. const count = countData?.[0]?.count ?? 0;
  50. return {
  51. series: [
  52. ...result.series,
  53. {
  54. name: timestamp * 1000, // ms -> s
  55. value: count,
  56. },
  57. ],
  58. count: result.count + count,
  59. };
  60. },
  61. {series: [] as SeriesDataUnit[], count: 0}
  62. );
  63. }
  64. export function EventGraph({group, event, ...styleProps}: EventGraphProps) {
  65. const theme = useTheme();
  66. const organization = useOrganization();
  67. const location = useLocation();
  68. const [visibleSeries, setVisibleSeries] = useState<EventGraphSeries>(
  69. EventGraphSeries.EVENT
  70. );
  71. const eventView = useIssueDetailsEventView({group});
  72. const config = getConfigForIssueType(group, group.project);
  73. const {dispatch} = useIssueDetails();
  74. const {
  75. data: groupStats = {},
  76. isPending: isLoadingStats,
  77. error,
  78. } = useIssueDetailsDiscoverQuery<MultiSeriesEventsStats>({
  79. params: {
  80. route: 'events-stats',
  81. eventView,
  82. referrer: 'issue_details.streamline_graph',
  83. },
  84. });
  85. const noQueryEventView = eventView.clone();
  86. noQueryEventView.query = `issue:${group.shortId}`;
  87. noQueryEventView.environment = [];
  88. const isUnfilteredStatsEnabled =
  89. eventView.query !== noQueryEventView.query || eventView.environment.length > 0;
  90. const {data: unfilteredGroupStats} =
  91. useIssueDetailsDiscoverQuery<MultiSeriesEventsStats>({
  92. options: {
  93. enabled: isUnfilteredStatsEnabled,
  94. },
  95. params: {
  96. route: 'events-stats',
  97. eventView: noQueryEventView,
  98. referrer: 'issue_details.streamline_graph',
  99. },
  100. });
  101. const {data: uniqueUsersCount, isPending: isPendingUniqueUsersCount} = useApiQuery<{
  102. data: Array<{count_unique: number}>;
  103. }>(
  104. [
  105. `/organizations/${organization.slug}/events/`,
  106. {
  107. query: {
  108. ...eventView.getEventsAPIPayload(location),
  109. dataset: config.usesIssuePlatform
  110. ? DiscoverDatasets.ISSUE_PLATFORM
  111. : DiscoverDatasets.ERRORS,
  112. field: 'count_unique(user)',
  113. per_page: 50,
  114. project: group.project.id,
  115. query: eventView.query,
  116. referrer: 'issue_details.streamline_graph',
  117. },
  118. },
  119. ],
  120. {
  121. staleTime: 60_000,
  122. }
  123. );
  124. const userCount = uniqueUsersCount?.data[0]?.['count_unique(user)'] ?? 0;
  125. const {series: eventSeries, count: eventCount} = useMemo(() => {
  126. if (!groupStats['count()']) {
  127. return {series: [], count: 0};
  128. }
  129. return createSeriesAndCount(groupStats['count()']);
  130. }, [groupStats]);
  131. // Ensure the dropdown can access the new filtered event count
  132. useEffect(() => {
  133. dispatch({type: 'UPDATE_EVENT_COUNT', count: eventCount});
  134. }, [eventCount, dispatch]);
  135. const {series: unfilteredEventSeries} = useMemo(() => {
  136. if (!unfilteredGroupStats?.['count()']) {
  137. return {series: []};
  138. }
  139. return createSeriesAndCount(unfilteredGroupStats['count()']);
  140. }, [unfilteredGroupStats]);
  141. const {series: unfilteredUserSeries} = useMemo(() => {
  142. if (!unfilteredGroupStats?.['count_unique(user)']) {
  143. return {series: []};
  144. }
  145. return createSeriesAndCount(unfilteredGroupStats['count_unique(user)']);
  146. }, [unfilteredGroupStats]);
  147. const userSeries = useMemo(() => {
  148. if (!groupStats['count_unique(user)']) {
  149. return [];
  150. }
  151. return createSeriesAndCount(groupStats['count_unique(user)']).series;
  152. }, [groupStats]);
  153. const chartZoomProps = useChartZoom({
  154. saveOnZoom: true,
  155. });
  156. const currentEventSeries = useCurrentEventMarklineSeries({
  157. event,
  158. group,
  159. });
  160. const releaseSeries = useReleaseMarkLineSeries({group});
  161. const flagSeries = useFlagSeries({
  162. query: {
  163. start: eventView.start,
  164. end: eventView.end,
  165. statsPeriod: eventView.statsPeriod,
  166. },
  167. event,
  168. });
  169. const series = useMemo((): BarChartSeries[] => {
  170. const seriesData: BarChartSeries[] = [];
  171. const translucentGray300 = Color(theme.gray300).alpha(0.3).string();
  172. if (visibleSeries === EventGraphSeries.USER) {
  173. if (isUnfilteredStatsEnabled) {
  174. seriesData.push({
  175. seriesName: t('Total users'),
  176. itemStyle: {
  177. borderRadius: [2, 2, 0, 0],
  178. borderColor: theme.translucentGray200,
  179. color: translucentGray300,
  180. },
  181. barGap: '-100%', // Makes bars overlap completely
  182. data: unfilteredUserSeries,
  183. animation: false,
  184. });
  185. }
  186. seriesData.push({
  187. seriesName: isUnfilteredStatsEnabled ? t('Matching users') : t('Users'),
  188. itemStyle: {
  189. borderRadius: [2, 2, 0, 0],
  190. borderColor: theme.translucentGray200,
  191. color: theme.purple200,
  192. },
  193. data: userSeries,
  194. animation: false,
  195. });
  196. }
  197. if (visibleSeries === EventGraphSeries.EVENT) {
  198. if (isUnfilteredStatsEnabled) {
  199. seriesData.push({
  200. seriesName: t('Total events'),
  201. itemStyle: {
  202. borderRadius: [2, 2, 0, 0],
  203. borderColor: theme.translucentGray200,
  204. color: translucentGray300,
  205. },
  206. barGap: '-100%', // Makes bars overlap completely
  207. data: unfilteredEventSeries,
  208. animation: false,
  209. });
  210. }
  211. seriesData.push({
  212. seriesName: isUnfilteredStatsEnabled ? t('Matching events') : t('Events'),
  213. itemStyle: {
  214. borderRadius: [2, 2, 0, 0],
  215. borderColor: theme.translucentGray200,
  216. color: isUnfilteredStatsEnabled ? theme.purple200 : translucentGray300,
  217. },
  218. data: eventSeries,
  219. animation: false,
  220. });
  221. }
  222. if (currentEventSeries.markLine) {
  223. seriesData.push(currentEventSeries as BarChartSeries);
  224. }
  225. if (releaseSeries.markLine) {
  226. seriesData.push(releaseSeries as BarChartSeries);
  227. }
  228. if (flagSeries.markLine && flagSeries.type === 'line') {
  229. seriesData.push(flagSeries as BarChartSeries);
  230. }
  231. return seriesData;
  232. }, [
  233. visibleSeries,
  234. userSeries,
  235. eventSeries,
  236. currentEventSeries,
  237. releaseSeries,
  238. flagSeries,
  239. theme,
  240. isUnfilteredStatsEnabled,
  241. unfilteredEventSeries,
  242. unfilteredUserSeries,
  243. ]);
  244. const bucketSize = eventSeries ? getBucketSize(series) : undefined;
  245. const [legendSelected, setLegendSelected] = useLocalStorageState(
  246. 'issue-details-graph-legend',
  247. {
  248. ['Feature Flags']: true,
  249. ['Releases']: false,
  250. }
  251. );
  252. const legend = Legend({
  253. theme,
  254. orient: 'horizontal',
  255. align: 'left',
  256. show: true,
  257. top: 4,
  258. right: 8,
  259. data: flagSeries.type === 'line' ? ['Feature Flags', 'Releases'] : ['Releases'],
  260. selected: legendSelected,
  261. zlevel: 10,
  262. inactiveColor: theme.gray200,
  263. });
  264. const onLegendSelectChanged = useMemo(
  265. () =>
  266. ({name, selected: record}) => {
  267. const newValue = record[name];
  268. setLegendSelected(prevState => ({
  269. ...prevState,
  270. [name]: newValue,
  271. }));
  272. },
  273. [setLegendSelected]
  274. );
  275. if (error) {
  276. return (
  277. <GraphAlert type="error" showIcon {...styleProps}>
  278. {tct('Graph Query Error: [message]', {message: error.message})}
  279. </GraphAlert>
  280. );
  281. }
  282. if (isLoadingStats || isPendingUniqueUsersCount) {
  283. return (
  284. <GraphWrapper {...styleProps}>
  285. <SummaryContainer>
  286. <GraphButton
  287. isActive={visibleSeries === EventGraphSeries.EVENT}
  288. disabled
  289. label={t('Events')}
  290. />
  291. <GraphButton
  292. isActive={visibleSeries === EventGraphSeries.USER}
  293. disabled
  294. label={t('Users')}
  295. />
  296. </SummaryContainer>
  297. <LoadingChartContainer>
  298. <Placeholder height="96px" testId="event-graph-loading" />
  299. </LoadingChartContainer>
  300. </GraphWrapper>
  301. );
  302. }
  303. return (
  304. <GraphWrapper {...styleProps}>
  305. <SummaryContainer>
  306. <GraphButton
  307. onClick={() =>
  308. visibleSeries === EventGraphSeries.USER &&
  309. setVisibleSeries(EventGraphSeries.EVENT)
  310. }
  311. isActive={visibleSeries === EventGraphSeries.EVENT}
  312. disabled={visibleSeries === EventGraphSeries.EVENT}
  313. label={tn('Event', 'Events', eventCount)}
  314. count={String(eventCount)}
  315. />
  316. <GraphButton
  317. onClick={() =>
  318. visibleSeries === EventGraphSeries.EVENT &&
  319. setVisibleSeries(EventGraphSeries.USER)
  320. }
  321. isActive={visibleSeries === EventGraphSeries.USER}
  322. disabled={visibleSeries === EventGraphSeries.USER}
  323. label={tn('User', 'Users', userCount)}
  324. count={String(userCount)}
  325. />
  326. </SummaryContainer>
  327. <ChartContainer role="figure">
  328. <BarChart
  329. height={100}
  330. series={series}
  331. legend={legend}
  332. onLegendSelectChanged={onLegendSelectChanged}
  333. showTimeInTooltip
  334. grid={{
  335. left: 8,
  336. right: 8,
  337. top: 20,
  338. bottom: 0,
  339. }}
  340. tooltip={{
  341. formatAxisLabel: (
  342. value,
  343. isTimestamp,
  344. utc,
  345. showTimeInTooltip,
  346. addSecondsToTimeFormat,
  347. _bucketSize,
  348. _seriesParamsOrParam
  349. ) =>
  350. String(
  351. defaultFormatAxisLabel(
  352. value,
  353. isTimestamp,
  354. utc,
  355. showTimeInTooltip,
  356. addSecondsToTimeFormat,
  357. bucketSize
  358. )
  359. ),
  360. }}
  361. yAxis={{
  362. splitNumber: 2,
  363. minInterval: 1,
  364. axisLabel: {
  365. formatter: (value: number) => {
  366. return formatAbbreviatedNumber(value);
  367. },
  368. },
  369. }}
  370. {...chartZoomProps}
  371. />
  372. </ChartContainer>
  373. </GraphWrapper>
  374. );
  375. }
  376. function GraphButton({
  377. isActive,
  378. label,
  379. count,
  380. ...props
  381. }: {isActive: boolean; label: string; count?: string} & Partial<ButtonProps>) {
  382. return (
  383. <Callout
  384. isActive={isActive}
  385. aria-label={`${t('Toggle graph series')} - ${label}`}
  386. {...props}
  387. >
  388. <InteractionStateLayer hidden={isActive} />
  389. <Flex column>
  390. <Label isActive={isActive}>{label}</Label>
  391. <Count isActive={isActive}>{count ? formatAbbreviatedNumber(count) : '-'}</Count>
  392. </Flex>
  393. </Callout>
  394. );
  395. }
  396. const GraphWrapper = styled('div')`
  397. display: grid;
  398. grid-template-columns: auto 1fr;
  399. `;
  400. const SummaryContainer = styled('div')`
  401. display: flex;
  402. gap: ${space(0.5)};
  403. flex-direction: column;
  404. margin: ${space(1)} ${space(1)} ${space(1)} 0;
  405. border-radius: ${p => p.theme.borderRadiusLeft};
  406. `;
  407. const Callout = styled(Button)<{isActive: boolean}>`
  408. cursor: ${p => (p.isActive ? 'initial' : 'pointer')};
  409. border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  410. background: ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  411. padding: ${space(0.5)} ${space(2)};
  412. box-shadow: none;
  413. height: unset;
  414. overflow: hidden;
  415. &:disabled {
  416. opacity: 1;
  417. }
  418. &:hover {
  419. border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  420. }
  421. `;
  422. const Label = styled('div')<{isActive: boolean}>`
  423. line-height: 1;
  424. font-size: ${p => p.theme.fontSizeSmall};
  425. color: ${p => (p.isActive ? p.theme.purple400 : p.theme.subText)};
  426. `;
  427. const Count = styled('div')<{isActive: boolean}>`
  428. line-height: 1;
  429. margin-top: ${space(0.5)};
  430. font-size: 20px;
  431. font-weight: ${p => p.theme.fontWeightNormal};
  432. color: ${p => (p.isActive ? p.theme.purple400 : p.theme.textColor)};
  433. `;
  434. const ChartContainer = styled('div')`
  435. position: relative;
  436. padding: ${space(0.75)} ${space(1)} ${space(0.75)} 0;
  437. `;
  438. const LoadingChartContainer = styled('div')`
  439. position: relative;
  440. padding: ${space(1)} ${space(1)};
  441. `;
  442. const GraphAlert = styled(Alert)`
  443. padding-left: 24px;
  444. margin: 0 0 0 -24px;
  445. border: 0;
  446. border-radius: 0;
  447. `;