123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- import {type CSSProperties, useMemo, useState} from 'react';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import Color from 'color';
- import Alert from 'sentry/components/alert';
- import {Button, type ButtonProps} from 'sentry/components/button';
- import {BarChart, type BarChartSeries} from 'sentry/components/charts/barChart';
- import Legend from 'sentry/components/charts/components/legend';
- import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
- import {useChartZoom} from 'sentry/components/charts/useChartZoom';
- import {Flex} from 'sentry/components/container/flex';
- import InteractionStateLayer from 'sentry/components/interactionStateLayer';
- import Placeholder from 'sentry/components/placeholder';
- import {t, tct, tn} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {SeriesDataUnit} from 'sentry/types/echarts';
- import type {Event} from 'sentry/types/event';
- import type {Group} from 'sentry/types/group';
- import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
- import {DiscoverDatasets} from 'sentry/utils/discover/types';
- import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
- import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
- import {useApiQuery} from 'sentry/utils/queryClient';
- import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
- import {useLocation} from 'sentry/utils/useLocation';
- import useOrganization from 'sentry/utils/useOrganization';
- import {getBucketSize} from 'sentry/views/dashboards/widgetCard/utils';
- import useFlagSeries from 'sentry/views/issueDetails/streamline/useFlagSeries';
- import {
- useIssueDetailsDiscoverQuery,
- useIssueDetailsEventView,
- } from 'sentry/views/issueDetails/streamline/useIssueDetailsDiscoverQuery';
- import {useReleaseMarkLineSeries} from 'sentry/views/issueDetails/streamline/useReleaseMarkLineSeries';
- export const enum EventGraphSeries {
- EVENT = 'event',
- USER = 'user',
- }
- interface EventGraphProps {
- event: Event | undefined;
- group: Group;
- className?: string;
- style?: CSSProperties;
- }
- function createSeriesAndCount(stats: EventsStats) {
- return stats?.data?.reduce(
- (result, [timestamp, countData]) => {
- const count = countData?.[0]?.count ?? 0;
- return {
- series: [
- ...result.series,
- {
- name: timestamp * 1000, // ms -> s
- value: count,
- },
- ],
- count: result.count + count,
- };
- },
- {series: [] as SeriesDataUnit[], count: 0}
- );
- }
- export function EventGraph({group, event, ...styleProps}: EventGraphProps) {
- const theme = useTheme();
- const organization = useOrganization();
- const location = useLocation();
- const [visibleSeries, setVisibleSeries] = useState<EventGraphSeries>(
- EventGraphSeries.EVENT
- );
- const eventView = useIssueDetailsEventView({group});
- const hasFeatureFlagFeature = organization.features.includes('feature-flag-ui');
- const config = getConfigForIssueType(group, group.project);
- const {
- data: groupStats = {},
- isPending: isLoadingStats,
- error,
- } = useIssueDetailsDiscoverQuery<MultiSeriesEventsStats>({
- params: {
- route: 'events-stats',
- eventView,
- referrer: 'issue_details.streamline_graph',
- },
- });
- const noQueryEventView = eventView.clone();
- noQueryEventView.query = `issue:${group.shortId}`;
- noQueryEventView.environment = [];
- const isUnfilteredStatsEnabled =
- eventView.query !== noQueryEventView.query || eventView.environment.length > 0;
- const {data: unfilteredGroupStats} =
- useIssueDetailsDiscoverQuery<MultiSeriesEventsStats>({
- options: {
- enabled: isUnfilteredStatsEnabled,
- },
- params: {
- route: 'events-stats',
- eventView: noQueryEventView,
- referrer: 'issue_details.streamline_graph',
- },
- });
- const {data: uniqueUsersCount, isPending: isPendingUniqueUsersCount} = useApiQuery<{
- data: Array<{count_unique: number}>;
- }>(
- [
- `/organizations/${organization.slug}/events/`,
- {
- query: {
- ...eventView.getEventsAPIPayload(location),
- dataset: config.usesIssuePlatform
- ? DiscoverDatasets.ISSUE_PLATFORM
- : DiscoverDatasets.ERRORS,
- field: 'count_unique(user)',
- per_page: 50,
- project: group.project.id,
- query: eventView.query,
- referrer: 'issue_details.streamline_graph',
- },
- },
- ],
- {
- staleTime: 60_000,
- }
- );
- const userCount = uniqueUsersCount?.data[0]?.['count_unique(user)'] ?? 0;
- const {series: eventSeries, count: eventCount} = useMemo(() => {
- if (!groupStats['count()']) {
- return {series: [], count: 0};
- }
- return createSeriesAndCount(groupStats['count()']);
- }, [groupStats]);
- const {series: unfilteredEventSeries} = useMemo(() => {
- if (!unfilteredGroupStats?.['count()']) {
- return {series: []};
- }
- return createSeriesAndCount(unfilteredGroupStats['count()']);
- }, [unfilteredGroupStats]);
- const {series: unfilteredUserSeries} = useMemo(() => {
- if (!unfilteredGroupStats?.['count_unique(user)']) {
- return {series: []};
- }
- return createSeriesAndCount(unfilteredGroupStats['count_unique(user)']);
- }, [unfilteredGroupStats]);
- const userSeries = useMemo(() => {
- if (!groupStats['count_unique(user)']) {
- return [];
- }
- return createSeriesAndCount(groupStats['count_unique(user)']).series;
- }, [groupStats]);
- const chartZoomProps = useChartZoom({
- saveOnZoom: true,
- });
- const releaseSeries = useReleaseMarkLineSeries({group});
- const flagSeries = useFlagSeries({
- query: {
- start: eventView.start,
- end: eventView.end,
- statsPeriod: eventView.statsPeriod,
- },
- event,
- });
- const series = useMemo((): BarChartSeries[] => {
- const seriesData: BarChartSeries[] = [];
- const translucentGray300 = Color(theme.gray300).alpha(0.3).string();
- if (visibleSeries === EventGraphSeries.USER) {
- if (isUnfilteredStatsEnabled) {
- seriesData.push({
- seriesName: t('Total users'),
- itemStyle: {
- borderRadius: [2, 2, 0, 0],
- borderColor: theme.translucentGray200,
- color: translucentGray300,
- },
- barGap: '-100%', // Makes bars overlap completely
- data: unfilteredUserSeries,
- animation: false,
- });
- }
- seriesData.push({
- seriesName: isUnfilteredStatsEnabled ? t('Matching users') : t('Users'),
- itemStyle: {
- borderRadius: [2, 2, 0, 0],
- borderColor: theme.translucentGray200,
- color: theme.purple200,
- },
- data: userSeries,
- animation: false,
- });
- }
- if (visibleSeries === EventGraphSeries.EVENT) {
- if (isUnfilteredStatsEnabled) {
- seriesData.push({
- seriesName: t('Total events'),
- itemStyle: {
- borderRadius: [2, 2, 0, 0],
- borderColor: theme.translucentGray200,
- color: translucentGray300,
- },
- barGap: '-100%', // Makes bars overlap completely
- data: unfilteredEventSeries,
- animation: false,
- });
- }
- seriesData.push({
- seriesName: isUnfilteredStatsEnabled ? t('Matching events') : t('Events'),
- itemStyle: {
- borderRadius: [2, 2, 0, 0],
- borderColor: theme.translucentGray200,
- color: isUnfilteredStatsEnabled ? theme.purple200 : translucentGray300,
- },
- data: eventSeries,
- animation: false,
- });
- }
- if (releaseSeries.markLine) {
- seriesData.push(releaseSeries as BarChartSeries);
- }
- if (flagSeries.markLine && hasFeatureFlagFeature) {
- seriesData.push(flagSeries as BarChartSeries);
- }
- return seriesData;
- }, [
- visibleSeries,
- userSeries,
- eventSeries,
- releaseSeries,
- flagSeries,
- theme,
- hasFeatureFlagFeature,
- isUnfilteredStatsEnabled,
- unfilteredEventSeries,
- unfilteredUserSeries,
- ]);
- const bucketSize = eventSeries ? getBucketSize(series) : undefined;
- const [legendSelected, setLegendSelected] = useLocalStorageState(
- 'issue-details-graph-legend',
- {
- ['Feature Flags']: true,
- ['Releases']: false,
- }
- );
- const legend = Legend({
- theme: theme,
- orient: 'horizontal',
- align: 'left',
- show: true,
- top: 4,
- right: 8,
- data: hasFeatureFlagFeature ? ['Feature Flags', 'Releases'] : ['Releases'],
- selected: legendSelected,
- zlevel: 10,
- inactiveColor: theme.gray200,
- });
- const onLegendSelectChanged = useMemo(
- () =>
- ({name, selected: record}) => {
- const newValue = record[name];
- setLegendSelected(prevState => ({
- ...prevState,
- [name]: newValue,
- }));
- },
- [setLegendSelected]
- );
- if (error) {
- return (
- <GraphAlert type="error" showIcon {...styleProps}>
- {tct('Graph Query Error: [message]', {message: error.message})}
- </GraphAlert>
- );
- }
- if (isLoadingStats || isPendingUniqueUsersCount) {
- return (
- <GraphWrapper {...styleProps}>
- <SummaryContainer>
- <GraphButton
- isActive={visibleSeries === EventGraphSeries.EVENT}
- disabled
- label={t('Events')}
- />
- <GraphButton
- isActive={visibleSeries === EventGraphSeries.USER}
- disabled
- label={t('Users')}
- />
- </SummaryContainer>
- <LoadingChartContainer>
- <Placeholder height="96px" testId="event-graph-loading" />
- </LoadingChartContainer>
- </GraphWrapper>
- );
- }
- return (
- <GraphWrapper {...styleProps}>
- <SummaryContainer>
- <GraphButton
- onClick={() =>
- visibleSeries === EventGraphSeries.USER &&
- setVisibleSeries(EventGraphSeries.EVENT)
- }
- isActive={visibleSeries === EventGraphSeries.EVENT}
- disabled={visibleSeries === EventGraphSeries.EVENT}
- label={tn('Event', 'Events', eventCount)}
- count={String(eventCount)}
- />
- <GraphButton
- onClick={() =>
- visibleSeries === EventGraphSeries.EVENT &&
- setVisibleSeries(EventGraphSeries.USER)
- }
- isActive={visibleSeries === EventGraphSeries.USER}
- disabled={visibleSeries === EventGraphSeries.USER}
- label={tn('User', 'Users', userCount)}
- count={String(userCount)}
- />
- </SummaryContainer>
- <ChartContainer role="figure">
- <BarChart
- height={100}
- series={series}
- legend={legend}
- onLegendSelectChanged={onLegendSelectChanged}
- showTimeInTooltip
- grid={{
- left: 8,
- right: 8,
- top: 20,
- bottom: 0,
- }}
- tooltip={{
- formatAxisLabel: (
- value,
- isTimestamp,
- utc,
- showTimeInTooltip,
- addSecondsToTimeFormat,
- _bucketSize,
- _seriesParamsOrParam
- ) =>
- String(
- defaultFormatAxisLabel(
- value,
- isTimestamp,
- utc,
- showTimeInTooltip,
- addSecondsToTimeFormat,
- bucketSize
- )
- ),
- }}
- yAxis={{
- splitNumber: 2,
- minInterval: 1,
- axisLabel: {
- formatter: (value: number) => {
- return formatAbbreviatedNumber(value);
- },
- },
- }}
- {...chartZoomProps}
- />
- </ChartContainer>
- </GraphWrapper>
- );
- }
- function GraphButton({
- isActive,
- label,
- count,
- ...props
- }: {isActive: boolean; label: string; count?: string} & Partial<ButtonProps>) {
- return (
- <Callout
- isActive={isActive}
- aria-label={`${t('Toggle graph series')} - ${label}`}
- {...props}
- >
- <InteractionStateLayer hidden={isActive} />
- <Flex column>
- <Label isActive={isActive}>{label}</Label>
- <Count isActive={isActive}>{count ? formatAbbreviatedNumber(count) : '-'}</Count>
- </Flex>
- </Callout>
- );
- }
- const GraphWrapper = styled('div')`
- display: grid;
- grid-template-columns: auto 1fr;
- `;
- const SummaryContainer = styled('div')`
- display: flex;
- gap: ${space(0.5)};
- flex-direction: column;
- margin: ${space(1)} ${space(1)} ${space(1)} 0;
- border-radius: ${p => p.theme.borderRadiusLeft};
- `;
- const Callout = styled(Button)<{isActive: boolean}>`
- cursor: ${p => (p.isActive ? 'initial' : 'pointer')};
- border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
- background: ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
- padding: ${space(0.5)} ${space(2)};
- box-shadow: none;
- height: unset;
- overflow: hidden;
- &:disabled {
- opacity: 1;
- }
- &:hover {
- border: 1px solid ${p => (p.isActive ? p.theme.purple100 : 'transparent')};
- }
- `;
- const Label = styled('div')<{isActive: boolean}>`
- line-height: 1;
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => (p.isActive ? p.theme.purple400 : p.theme.subText)};
- `;
- const Count = styled('div')<{isActive: boolean}>`
- line-height: 1;
- margin-top: ${space(0.5)};
- font-size: 20px;
- font-weight: ${p => p.theme.fontWeightNormal};
- color: ${p => (p.isActive ? p.theme.purple400 : p.theme.textColor)};
- `;
- const ChartContainer = styled('div')`
- position: relative;
- padding: ${space(0.75)} ${space(1)} ${space(0.75)} 0;
- `;
- const LoadingChartContainer = styled('div')`
- position: relative;
- padding: ${space(1)} ${space(1)};
- `;
- const GraphAlert = styled(Alert)`
- padding-left: 24px;
- margin: 0 0 0 -24px;
- border: 0;
- border-radius: 0;
- `;
|