Skip to content

Commit 21978e3

Browse files
feat(flags): add flag series to event graph (#78147)
adds release-like lines for feature flag changes on top of the event bar chart. there is a legend to toggle the feature flag lines on and off. new legend to match the figma: <img width="377" alt="SCR-20241004-kija" src="https://github.com/user-attachments/assets/a0c1f0d5-8751-4b41-9e34-d8bcd2c7add8"> the video below shows the graph with mock data with the 3 types of possible flag actions - created, updated, deleted: https://github.com/user-attachments/assets/62aeb0f5-9908-411c-ac99-e8841f7ec1c3 With real data returned from the endpoint: <img width="368" alt="SCR-20241004-jepb" src="https://github.com/user-attachments/assets/d6b1c422-b78b-468f-961c-6b693336a388"> https://github.com/user-attachments/assets/b77b33e2-2842-4a58-8c0a-bf25d2d93be7 closes #77812
1 parent 20d7d33 commit 21978e3

File tree

6 files changed

+182
-7
lines changed

6 files changed

+182
-7
lines changed

static/app/components/charts/baseChart.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,11 @@ const getTooltipStyles = (p: {theme: Theme}) => css`
704704
justify-content: space-between;
705705
align-items: baseline;
706706
}
707+
.tooltip-code-no-margin {
708+
padding-left: 0;
709+
margin-left: 0;
710+
color: ${p.theme.subText};
711+
}
707712
.tooltip-footer {
708713
border-top: solid 1px ${p.theme.innerBorder};
709714
text-align: center;

static/app/components/charts/releaseSeries.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,6 @@ class ReleaseSeries extends Component<ReleaseSeriesProps, State> {
292292
'<div class="tooltip-footer">',
293293
time,
294294
'</div>',
295-
'</div>',
296295
'<div class="tooltip-arrow"></div>',
297296
].join('');
298297
},

static/app/components/charts/series/barSeries.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import 'echarts/lib/chart/bar';
22

3-
import type {BarSeriesOption} from 'echarts';
3+
import type {BarSeriesOption, LineSeriesOption} from 'echarts';
44

5-
function barSeries(props: BarSeriesOption): BarSeriesOption {
5+
/**
6+
* The return type can be BarSeriesOption or LineSeriesOption so that we can add
7+
* custom lines on top of the event bar chart in `eventGraph.tsx`.
8+
*/
9+
function barSeries(props: BarSeriesOption): BarSeriesOption | LineSeriesOption {
610
return {
711
...props,
8-
type: 'bar',
12+
type: props.type ?? 'bar',
913
};
1014
}
1115

static/app/types/echarts.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import type {
77
import type ReactEchartsCore from 'echarts-for-react/lib/core';
88

99
export type SeriesDataUnit = {
10+
// number because we sometimes use timestamps
1011
name: string | number;
1112
value: number;
1213
itemStyle?: {
1314
color?: string;
1415
};
15-
// number because we sometimes use timestamps
1616
onClick?: (series: Series, instance: ECharts) => void;
1717
};
1818

static/app/views/issueDetails/streamline/eventGraph.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {useMemo, useState} from 'react';
2+
import {useTheme} from '@emotion/react';
23
import styled from '@emotion/styled';
34

45
import {LinkButton} from 'sentry/components/button';
56
import {BarChart, type BarChartSeries} from 'sentry/components/charts/barChart';
7+
import Legend from 'sentry/components/charts/components/legend';
68
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
79
import {Tooltip} from 'sentry/components/tooltip';
810
import {IconTelescope} from 'sentry/icons';
@@ -13,9 +15,9 @@ import type {Group} from 'sentry/types/group';
1315
import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
1416
import {SavedQueryDatasets} from 'sentry/utils/discover/types';
1517
import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
16-
import theme from 'sentry/utils/theme';
1718
import useOrganization from 'sentry/utils/useOrganization';
1819
import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
20+
import useFlagSeries from 'sentry/views/issueDetails/streamline/flagSeries';
1921
import {useIssueDetailsEventView} from 'sentry/views/issueDetails/streamline/useIssueDetailsDiscoverQuery';
2022

2123
export const enum EventGraphSeries {
@@ -48,10 +50,12 @@ function createSeriesAndCount(stats: EventsStats) {
4850
}
4951

5052
export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
53+
const theme = useTheme();
5154
const organization = useOrganization();
5255
const [visibleSeries, setVisibleSeries] = useState<EventGraphSeries>(
5356
EventGraphSeries.EVENT
5457
);
58+
5559
const [isGraphHovered, setIsGraphHovered] = useState(false);
5660
const eventStats = groupStats['count()'];
5761
const {series: eventSeries, count: eventCount} = useMemo(
@@ -71,6 +75,18 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
7175
hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
7276
);
7377

78+
const [legendSelected, setLegendSelected] = useState({
79+
['Feature Flags']: true,
80+
});
81+
82+
const flagSeries = useFlagSeries({
83+
query: {
84+
start: eventView.start,
85+
end: eventView.end,
86+
statsPeriod: eventView.statsPeriod,
87+
},
88+
});
89+
7490
const series: BarChartSeries[] = [];
7591

7692
if (eventStats && visibleSeries === EventGraphSeries.USER) {
@@ -97,6 +113,33 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
97113
data: eventSeries,
98114
});
99115
}
116+
if (flagSeries.markLine) {
117+
series.push(flagSeries as BarChartSeries);
118+
}
119+
120+
const legend = Legend({
121+
theme: theme,
122+
icon: 'path://M 10 10 H 500 V 9000 H 10 L 10 10',
123+
orient: 'horizontal',
124+
align: 'left',
125+
show: true,
126+
right: 35,
127+
top: 5,
128+
data: ['Feature Flags'],
129+
selected: legendSelected,
130+
});
131+
132+
const onLegendSelectChanged = useMemo(
133+
() =>
134+
({name, selected: record}) => {
135+
const newValue = record[name];
136+
setLegendSelected(prevState => ({
137+
...prevState,
138+
[name]: newValue,
139+
}));
140+
},
141+
[]
142+
);
100143

101144
return (
102145
<GraphWrapper>
@@ -134,10 +177,12 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
134177
<BarChart
135178
height={100}
136179
series={series}
180+
legend={legend}
181+
onLegendSelectChanged={onLegendSelectChanged}
137182
isGroupedByDate
138183
showTimeInTooltip
139184
grid={{
140-
top: 8,
185+
top: 28, // leave room for legend
141186
left: 8,
142187
right: 8,
143188
bottom: 0,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {useTheme} from '@emotion/react';
2+
3+
import MarkLine from 'sentry/components/charts/components/markLine';
4+
import {t} from 'sentry/locale';
5+
import type {Organization} from 'sentry/types/organization';
6+
import {getFormattedDate} from 'sentry/utils/dates';
7+
import {useApiQuery} from 'sentry/utils/queryClient';
8+
import useOrganization from 'sentry/utils/useOrganization';
9+
10+
type RawFlag = {
11+
action: string;
12+
created_at: string;
13+
created_by: string;
14+
created_by_type: string;
15+
flag: string;
16+
id: number;
17+
tags: Record<string, any>;
18+
};
19+
20+
export type RawFlagData = {data: RawFlag[]};
21+
22+
type FlagSeriesDatapoint = {
23+
// flag action
24+
label: {formatter: () => string};
25+
// flag name
26+
name: string;
27+
// unix timestamp
28+
xAxis: number;
29+
};
30+
31+
function useOrganizationFlagLog({
32+
organization,
33+
query,
34+
}: {
35+
organization: Organization;
36+
query: Record<string, any>;
37+
}) {
38+
const {data, isError, isPending} = useApiQuery<RawFlagData>(
39+
[`/organizations/${organization.slug}/flags/logs/`, {query}],
40+
{
41+
staleTime: 0,
42+
enabled: organization.features?.includes('feature-flag-ui'),
43+
}
44+
);
45+
return {data, isError, isPending};
46+
}
47+
48+
function hydrateFlagData({
49+
rawFlagData,
50+
}: {
51+
rawFlagData: RawFlagData;
52+
}): FlagSeriesDatapoint[] {
53+
// transform raw flag data into series data
54+
// each data point needs to be type FlagSeriesDatapoint
55+
const flagData = rawFlagData.data.map(f => {
56+
return {
57+
xAxis: Date.parse(f.created_at),
58+
label: {formatter: () => f.action},
59+
name: `${f.flag}`,
60+
};
61+
});
62+
return flagData;
63+
}
64+
65+
export default function useFlagSeries({query = {}}: {query?: Record<string, any>}) {
66+
const theme = useTheme();
67+
const organization = useOrganization();
68+
const {
69+
data: rawFlagData,
70+
isError,
71+
isPending,
72+
} = useOrganizationFlagLog({organization, query});
73+
74+
if (!rawFlagData || isError || isPending) {
75+
return {
76+
seriesName: t('Feature Flags'),
77+
markLine: {},
78+
data: [],
79+
};
80+
}
81+
82+
const hydratedFlagData: FlagSeriesDatapoint[] = hydrateFlagData({rawFlagData});
83+
84+
// create a markline series using hydrated flag data
85+
const markLine = MarkLine({
86+
animation: false,
87+
lineStyle: {
88+
color: theme.purple300,
89+
opacity: 0.3,
90+
type: 'solid',
91+
},
92+
label: {
93+
show: false,
94+
},
95+
data: hydratedFlagData,
96+
tooltip: {
97+
trigger: 'item',
98+
formatter: ({data}: any) => {
99+
const time = getFormattedDate(data.xAxis, 'MMM D, YYYY LT z');
100+
return [
101+
'<div class="tooltip-series">',
102+
`<div><span class="tooltip-label"><strong>${t(
103+
'Feature Flag'
104+
)}</strong></span></div>`,
105+
`<div><code class="tooltip-code-no-margin">${data.name}</code>${data.label.formatter()}</div>`,
106+
'</div>',
107+
'<div class="tooltip-footer">',
108+
time,
109+
'</div>',
110+
'<div class="tooltip-arrow"></div>',
111+
].join('');
112+
},
113+
},
114+
});
115+
116+
return {
117+
seriesName: t('Feature Flags'),
118+
data: [],
119+
markLine,
120+
type: 'line', // use this type so the bar chart doesn't shrink/grow
121+
};
122+
}

0 commit comments

Comments
 (0)