eventGraph.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import {useMemo, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {LinkButton} from 'sentry/components/button';
  5. import {BarChart, type BarChartSeries} from 'sentry/components/charts/barChart';
  6. import Legend from 'sentry/components/charts/components/legend';
  7. import {useChartZoom} from 'sentry/components/charts/useChartZoom';
  8. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  9. import {IconTelescope} from 'sentry/icons';
  10. import {t, tn} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {SeriesDataUnit} from 'sentry/types/echarts';
  13. import type {Event} from 'sentry/types/event';
  14. import type {Group} from 'sentry/types/group';
  15. import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
  16. import {SavedQueryDatasets} from 'sentry/utils/discover/types';
  17. import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
  18. import useOrganization from 'sentry/utils/useOrganization';
  19. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  20. import useFlagSeries from 'sentry/views/issueDetails/streamline/flagSeries';
  21. import {useIssueDetailsEventView} from 'sentry/views/issueDetails/streamline/useIssueDetailsDiscoverQuery';
  22. export const enum EventGraphSeries {
  23. EVENT = 'event',
  24. USER = 'user',
  25. }
  26. interface EventGraphProps {
  27. event: Event;
  28. group: Group;
  29. groupStats: MultiSeriesEventsStats;
  30. searchQuery: string;
  31. }
  32. function createSeriesAndCount(stats: EventsStats) {
  33. return stats?.data?.reduce(
  34. (result, [timestamp, countData]) => {
  35. const count = countData?.[0]?.count ?? 0;
  36. return {
  37. series: [
  38. ...result.series,
  39. {
  40. name: timestamp * 1000, // ms -> s
  41. value: count,
  42. },
  43. ],
  44. count: result.count + count,
  45. };
  46. },
  47. {series: [] as SeriesDataUnit[], count: 0}
  48. );
  49. }
  50. export function EventGraph({group, groupStats, searchQuery, event}: EventGraphProps) {
  51. const theme = useTheme();
  52. const organization = useOrganization();
  53. const [visibleSeries, setVisibleSeries] = useState<EventGraphSeries>(
  54. EventGraphSeries.EVENT
  55. );
  56. const [isGraphHovered, setIsGraphHovered] = useState(false);
  57. const eventStats = groupStats['count()'];
  58. const {series: eventSeries, count: eventCount} = useMemo(
  59. () => createSeriesAndCount(eventStats),
  60. [eventStats]
  61. );
  62. const userStats = groupStats['count_unique(user)'];
  63. const {series: userSeries, count: userCount} = useMemo(
  64. () => createSeriesAndCount(userStats),
  65. [userStats]
  66. );
  67. const eventView = useIssueDetailsEventView({group, queryProps: {query: searchQuery}});
  68. const discoverUrl = eventView.getResultsViewUrlTarget(
  69. organization.slug,
  70. false,
  71. hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
  72. );
  73. const chartZoomProps = useChartZoom({
  74. saveOnZoom: true,
  75. });
  76. const flagSeries = useFlagSeries({
  77. query: {
  78. start: eventView.start,
  79. end: eventView.end,
  80. statsPeriod: eventView.statsPeriod,
  81. },
  82. event,
  83. });
  84. const series = useMemo((): BarChartSeries[] => {
  85. const seriesData: BarChartSeries[] = [];
  86. if (eventStats && visibleSeries === EventGraphSeries.USER) {
  87. seriesData.push({
  88. seriesName: t('Users'),
  89. itemStyle: {
  90. borderRadius: [2, 2, 0, 0],
  91. borderColor: theme.translucentGray200,
  92. color: theme.purple200,
  93. },
  94. stack: 'stats',
  95. data: userSeries,
  96. });
  97. }
  98. if (eventStats && visibleSeries === EventGraphSeries.EVENT) {
  99. seriesData.push({
  100. seriesName: t('Events'),
  101. itemStyle: {
  102. borderRadius: [2, 2, 0, 0],
  103. borderColor: theme.translucentGray200,
  104. color: theme.gray200,
  105. },
  106. stack: 'stats',
  107. data: eventSeries,
  108. });
  109. }
  110. if (flagSeries.markLine) {
  111. seriesData.push(flagSeries as BarChartSeries);
  112. }
  113. return seriesData;
  114. }, [eventStats, visibleSeries, userSeries, eventSeries, flagSeries, theme]);
  115. const [legendSelected, setLegendSelected] = useState({
  116. ['Feature Flags']: true,
  117. });
  118. const legend = Legend({
  119. theme: theme,
  120. icon: 'path://M 10 10 H 500 V 9000 H 10 L 10 10',
  121. orient: 'horizontal',
  122. align: 'left',
  123. show: true,
  124. right: 35,
  125. top: 5,
  126. data: ['Feature Flags'],
  127. selected: legendSelected,
  128. });
  129. const onLegendSelectChanged = useMemo(
  130. () =>
  131. ({name, selected: record}) => {
  132. const newValue = record[name];
  133. setLegendSelected(prevState => ({
  134. ...prevState,
  135. [name]: newValue,
  136. }));
  137. },
  138. []
  139. );
  140. return (
  141. <GraphWrapper>
  142. <SummaryContainer>
  143. <Callout
  144. onClick={() =>
  145. visibleSeries === EventGraphSeries.USER &&
  146. setVisibleSeries(EventGraphSeries.EVENT)
  147. }
  148. isActive={visibleSeries === EventGraphSeries.EVENT}
  149. disabled={visibleSeries === EventGraphSeries.EVENT}
  150. >
  151. <InteractionStateLayer hidden={visibleSeries === EventGraphSeries.EVENT} />
  152. <Label>{tn('Event', 'Events', eventCount)}</Label>
  153. <Count>{formatAbbreviatedNumber(eventCount)}</Count>
  154. </Callout>
  155. <Callout
  156. onClick={() =>
  157. visibleSeries === EventGraphSeries.EVENT &&
  158. setVisibleSeries(EventGraphSeries.USER)
  159. }
  160. isActive={visibleSeries === EventGraphSeries.USER}
  161. disabled={visibleSeries === EventGraphSeries.USER}
  162. >
  163. <InteractionStateLayer hidden={visibleSeries === EventGraphSeries.USER} />
  164. <Label>{tn('User', 'Users', userCount)}</Label>
  165. <Count>{formatAbbreviatedNumber(userCount)}</Count>
  166. </Callout>
  167. </SummaryContainer>
  168. <ChartContainer
  169. role="figure"
  170. onMouseEnter={() => setIsGraphHovered(true)}
  171. onMouseLeave={() => setIsGraphHovered(false)}
  172. >
  173. <BarChart
  174. height={100}
  175. series={series}
  176. legend={legend}
  177. onLegendSelectChanged={onLegendSelectChanged}
  178. showTimeInTooltip
  179. grid={{
  180. top: 28, // leave room for legend
  181. left: 8,
  182. right: 8,
  183. bottom: 0,
  184. }}
  185. yAxis={{
  186. splitNumber: 2,
  187. axisLabel: {
  188. formatter: (value: number) => {
  189. return formatAbbreviatedNumber(value);
  190. },
  191. },
  192. }}
  193. {...chartZoomProps}
  194. />
  195. {discoverUrl && isGraphHovered && (
  196. <OpenInDiscoverButton>
  197. <LinkButton
  198. size="xs"
  199. icon={<IconTelescope />}
  200. to={discoverUrl}
  201. aria-label={t('Open in Discover')}
  202. title={t('Open in Discover')}
  203. />
  204. </OpenInDiscoverButton>
  205. )}
  206. </ChartContainer>
  207. </GraphWrapper>
  208. );
  209. }
  210. const GraphWrapper = styled('div')`
  211. display: grid;
  212. grid-template-columns: auto 1fr;
  213. `;
  214. const SummaryContainer = styled('div')`
  215. display: flex;
  216. flex-direction: column;
  217. margin-right: space(1);
  218. border-radius: ${p => p.theme.borderRadiusLeft};
  219. `;
  220. const Callout = styled('button')<{isActive: boolean}>`
  221. flex: 1;
  222. cursor: ${p => (p.isActive ? 'initial' : 'pointer')};
  223. outline: 0;
  224. position: relative;
  225. border: 1px solid ${p => p.theme.translucentInnerBorder};
  226. background: ${p => (p.isActive ? p.theme.background : p.theme.backgroundSecondary)};
  227. text-align: left;
  228. padding: ${space(1)} ${space(2)};
  229. &:first-child {
  230. border-radius: ${p => p.theme.borderRadius} 0 ${p => p.theme.borderRadius} 0;
  231. border-width: ${p => (p.isActive ? '0' : '0 1px 1px 0')};
  232. }
  233. &:last-child {
  234. border-radius: 0 ${p => p.theme.borderRadius} 0 ${p => p.theme.borderRadius};
  235. border-width: ${p => (p.isActive ? '0' : '1px 1px 0 0')};
  236. }
  237. `;
  238. const Label = styled('div')`
  239. font-size: ${p => p.theme.fontSizeSmall};
  240. color: ${p => p.theme.subText};
  241. font-weight: ${p => p.theme.fontWeightBold};
  242. line-height: 1;
  243. `;
  244. const Count = styled('div')`
  245. font-size: ${p => p.theme.headerFontSize};
  246. margin-top: ${space(0.5)};
  247. line-height: 1;
  248. `;
  249. const ChartContainer = styled('div')`
  250. padding: ${space(0.75)} ${space(1)} ${space(0.75)} 0;
  251. position: relative;
  252. `;
  253. const OpenInDiscoverButton = styled('div')`
  254. position: absolute;
  255. top: ${space(1)};
  256. right: ${space(1)};
  257. `;