eventGraph.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  125. const userCount = uniqueUsersCount?.data[0]?.['count_unique(user)'] ?? 0;
  126. const {series: eventSeries, count: eventCount} = useMemo(() => {
  127. if (!groupStats['count()']) {
  128. return {series: [], count: 0};
  129. }
  130. return createSeriesAndCount(groupStats['count()']);
  131. }, [groupStats]);
  132. // Ensure the dropdown can access the new filtered event count
  133. useEffect(() => {
  134. dispatch({type: 'UPDATE_EVENT_COUNT', count: eventCount});
  135. }, [eventCount, dispatch]);
  136. const {series: unfilteredEventSeries} = useMemo(() => {
  137. if (!unfilteredGroupStats?.['count()']) {
  138. return {series: []};
  139. }
  140. return createSeriesAndCount(unfilteredGroupStats['count()']);
  141. }, [unfilteredGroupStats]);
  142. const {series: unfilteredUserSeries} = useMemo(() => {
  143. if (!unfilteredGroupStats?.['count_unique(user)']) {
  144. return {series: []};
  145. }
  146. return createSeriesAndCount(unfilteredGroupStats['count_unique(user)']);
  147. }, [unfilteredGroupStats]);
  148. const userSeries = useMemo(() => {
  149. if (!groupStats['count_unique(user)']) {
  150. return [];
  151. }
  152. return createSeriesAndCount(groupStats['count_unique(user)']).series;
  153. }, [groupStats]);
  154. const chartZoomProps = useChartZoom({
  155. saveOnZoom: true,
  156. });
  157. const currentEventSeries = useCurrentEventMarklineSeries({
  158. event,
  159. group,
  160. });
  161. const releaseSeries = useReleaseMarkLineSeries({group});
  162. const flagSeries = useFlagSeries({
  163. query: {
  164. start: eventView.start,
  165. end: eventView.end,
  166. statsPeriod: eventView.statsPeriod,
  167. },
  168. event,
  169. });
  170. const series = useMemo((): BarChartSeries[] => {
  171. const seriesData: BarChartSeries[] = [];
  172. const translucentGray300 = Color(theme.gray300).alpha(0.5).string();
  173. if (visibleSeries === EventGraphSeries.USER) {
  174. if (isUnfilteredStatsEnabled) {
  175. seriesData.push({
  176. seriesName: t('Total users'),
  177. itemStyle: {
  178. borderRadius: [2, 2, 0, 0],
  179. borderColor: theme.translucentGray200,
  180. color: translucentGray300,
  181. },
  182. barGap: '-100%', // Makes bars overlap completely
  183. data: unfilteredUserSeries,
  184. animation: false,
  185. });
  186. }
  187. seriesData.push({
  188. seriesName: isUnfilteredStatsEnabled ? t('Matching users') : t('Users'),
  189. itemStyle: {
  190. borderRadius: [2, 2, 0, 0],
  191. borderColor: theme.translucentGray200,
  192. color: theme.purple200,
  193. },
  194. data: userSeries,
  195. animation: false,
  196. });
  197. }
  198. if (visibleSeries === EventGraphSeries.EVENT) {
  199. if (isUnfilteredStatsEnabled) {
  200. seriesData.push({
  201. seriesName: t('Total events'),
  202. itemStyle: {
  203. borderRadius: [2, 2, 0, 0],
  204. borderColor: theme.translucentGray200,
  205. color: translucentGray300,
  206. },
  207. barGap: '-100%', // Makes bars overlap completely
  208. data: unfilteredEventSeries,
  209. animation: false,
  210. });
  211. }
  212. seriesData.push({
  213. seriesName: isUnfilteredStatsEnabled ? t('Matching events') : t('Events'),
  214. itemStyle: {
  215. borderRadius: [2, 2, 0, 0],
  216. borderColor: theme.translucentGray200,
  217. color: isUnfilteredStatsEnabled ? theme.purple200 : translucentGray300,
  218. },
  219. data: eventSeries,
  220. animation: false,
  221. });
  222. }
  223. if (currentEventSeries.markLine) {
  224. seriesData.push(currentEventSeries as BarChartSeries);
  225. }
  226. if (releaseSeries.markLine) {
  227. seriesData.push(releaseSeries as BarChartSeries);
  228. }
  229. if (flagSeries.markLine && flagSeries.type === 'line') {
  230. seriesData.push(flagSeries as BarChartSeries);
  231. }
  232. return seriesData;
  233. }, [
  234. visibleSeries,
  235. userSeries,
  236. eventSeries,
  237. currentEventSeries,
  238. releaseSeries,
  239. flagSeries,
  240. theme,
  241. isUnfilteredStatsEnabled,
  242. unfilteredEventSeries,
  243. unfilteredUserSeries,
  244. ]);
  245. const bucketSize = eventSeries ? getBucketSize(series) : undefined;
  246. const [legendSelected, setLegendSelected] = useLocalStorageState(
  247. 'issue-details-graph-legend',
  248. {
  249. ['Feature Flags']: true,
  250. ['Releases']: false,
  251. }
  252. );
  253. const legend = Legend({
  254. theme,
  255. orient: 'horizontal',
  256. align: 'left',
  257. show: true,
  258. top: 4,
  259. right: 8,
  260. data: flagSeries.type === 'line' ? ['Feature Flags', 'Releases'] : ['Releases'],
  261. selected: legendSelected,
  262. zlevel: 10,
  263. inactiveColor: theme.gray200,
  264. });
  265. const onLegendSelectChanged = useMemo(
  266. () =>
  267. ({name, selected: record}: any) => {
  268. const newValue = record[name];
  269. setLegendSelected(prevState => ({
  270. ...prevState,
  271. [name]: newValue,
  272. }));
  273. },
  274. [setLegendSelected]
  275. );
  276. if (error) {
  277. return (
  278. <GraphAlert type="error" showIcon {...styleProps}>
  279. {tct('Graph Query Error: [message]', {message: error.message})}
  280. </GraphAlert>
  281. );
  282. }
  283. if (isLoadingStats || isPendingUniqueUsersCount) {
  284. return (
  285. <GraphWrapper {...styleProps}>
  286. <SummaryContainer>
  287. <GraphButton
  288. isActive={visibleSeries === EventGraphSeries.EVENT}
  289. disabled
  290. label={t('Events')}
  291. />
  292. <GraphButton
  293. isActive={visibleSeries === EventGraphSeries.USER}
  294. disabled
  295. label={t('Users')}
  296. />
  297. </SummaryContainer>
  298. <LoadingChartContainer>
  299. <Placeholder height="96px" testId="event-graph-loading" />
  300. </LoadingChartContainer>
  301. </GraphWrapper>
  302. );
  303. }
  304. return (
  305. <GraphWrapper {...styleProps}>
  306. <SummaryContainer>
  307. <GraphButton
  308. onClick={() =>
  309. visibleSeries === EventGraphSeries.USER &&
  310. setVisibleSeries(EventGraphSeries.EVENT)
  311. }
  312. isActive={visibleSeries === EventGraphSeries.EVENT}
  313. disabled={visibleSeries === EventGraphSeries.EVENT}
  314. label={tn('Event', 'Events', eventCount)}
  315. count={String(eventCount)}
  316. />
  317. <GraphButton
  318. onClick={() =>
  319. visibleSeries === EventGraphSeries.EVENT &&
  320. setVisibleSeries(EventGraphSeries.USER)
  321. }
  322. isActive={visibleSeries === EventGraphSeries.USER}
  323. disabled={visibleSeries === EventGraphSeries.USER}
  324. label={tn('User', 'Users', userCount)}
  325. count={String(userCount)}
  326. />
  327. </SummaryContainer>
  328. <ChartContainer role="figure">
  329. <BarChart
  330. height={100}
  331. series={series}
  332. legend={legend}
  333. onLegendSelectChanged={onLegendSelectChanged}
  334. showTimeInTooltip
  335. grid={{
  336. left: 8,
  337. right: 8,
  338. top: 20,
  339. bottom: 0,
  340. }}
  341. tooltip={{
  342. formatAxisLabel: (
  343. value,
  344. isTimestamp,
  345. utc,
  346. showTimeInTooltip,
  347. addSecondsToTimeFormat,
  348. _bucketSize,
  349. _seriesParamsOrParam
  350. ) =>
  351. String(
  352. defaultFormatAxisLabel(
  353. value,
  354. isTimestamp,
  355. utc,
  356. showTimeInTooltip,
  357. addSecondsToTimeFormat,
  358. bucketSize
  359. )
  360. ),
  361. }}
  362. yAxis={{
  363. splitNumber: 2,
  364. minInterval: 1,
  365. axisLabel: {
  366. formatter: (value: number) => {
  367. return formatAbbreviatedNumber(value);
  368. },
  369. },
  370. }}
  371. {...chartZoomProps}
  372. />
  373. </ChartContainer>
  374. </GraphWrapper>
  375. );
  376. }
  377. function GraphButton({
  378. isActive,
  379. label,
  380. count,
  381. ...props
  382. }: {isActive: boolean; label: string; count?: string} & Partial<ButtonProps>) {
  383. return (
  384. <Callout
  385. isActive={isActive}
  386. aria-label={`${t('Toggle graph series')} - ${label}`}
  387. {...props}
  388. >
  389. <InteractionStateLayer hidden={isActive} />
  390. <Flex column>
  391. <Label isActive={isActive}>{label}</Label>
  392. <Count isActive={isActive}>{count ? formatAbbreviatedNumber(count) : '-'}</Count>
  393. </Flex>
  394. </Callout>
  395. );
  396. }
  397. const GraphWrapper = styled('div')`
  398. display: grid;
  399. grid-template-columns: auto 1fr;
  400. `;
  401. const SummaryContainer = styled('div')`
  402. display: flex;
  403. gap: ${space(0.5)};
  404. flex-direction: column;
  405. margin: ${space(1)} ${space(1)} ${space(1)} 0;
  406. border-radius: ${p => p.theme.borderRadiusLeft};
  407. `;
  408. const Callout = styled(Button)<{isActive: boolean}>`
  409. cursor: ${p => (p.isActive ? 'initial' : 'pointer')};
  410. border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  411. background: ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  412. padding: ${space(0.5)} ${space(2)};
  413. box-shadow: none;
  414. height: unset;
  415. overflow: hidden;
  416. &:disabled {
  417. opacity: 1;
  418. }
  419. &:hover {
  420. border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
  421. }
  422. `;
  423. const Label = styled('div')<{isActive: boolean}>`
  424. line-height: 1;
  425. font-size: ${p => p.theme.fontSizeSmall};
  426. color: ${p => (p.isActive ? p.theme.purple400 : p.theme.subText)};
  427. `;
  428. const Count = styled('div')<{isActive: boolean}>`
  429. line-height: 1;
  430. margin-top: ${space(0.5)};
  431. font-size: 20px;
  432. font-weight: ${p => p.theme.fontWeightNormal};
  433. color: ${p => (p.isActive ? p.theme.purple400 : p.theme.textColor)};
  434. `;
  435. const ChartContainer = styled('div')`
  436. position: relative;
  437. padding: ${space(0.75)} ${space(1)} ${space(0.75)} 0;
  438. `;
  439. const LoadingChartContainer = styled('div')`
  440. position: relative;
  441. padding: ${space(1)} ${space(1)};
  442. `;
  443. const GraphAlert = styled(Alert)`
  444. padding-left: 24px;
  445. margin: 0 0 0 -24px;
  446. border: 0;
  447. border-radius: 0;
  448. `;