Browse Source

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 https://github.com/getsentry/sentry/issues/77812
Michelle Zhang 5 months ago
parent
commit
21978e35e4

+ 5 - 0
static/app/components/charts/baseChart.tsx

@@ -704,6 +704,11 @@ const getTooltipStyles = (p: {theme: Theme}) => css`
     justify-content: space-between;
     align-items: baseline;
   }
+  .tooltip-code-no-margin {
+    padding-left: 0;
+    margin-left: 0;
+    color: ${p.theme.subText};
+  }
   .tooltip-footer {
     border-top: solid 1px ${p.theme.innerBorder};
     text-align: center;

+ 0 - 1
static/app/components/charts/releaseSeries.tsx

@@ -292,7 +292,6 @@ class ReleaseSeries extends Component<ReleaseSeriesProps, State> {
             '<div class="tooltip-footer">',
             time,
             '</div>',
-            '</div>',
             '<div class="tooltip-arrow"></div>',
           ].join('');
         },

+ 7 - 3
static/app/components/charts/series/barSeries.tsx

@@ -1,11 +1,15 @@
 import 'echarts/lib/chart/bar';
 
-import type {BarSeriesOption} from 'echarts';
+import type {BarSeriesOption, LineSeriesOption} from 'echarts';
 
-function barSeries(props: BarSeriesOption): BarSeriesOption {
+/**
+ * The return type can be BarSeriesOption or LineSeriesOption so that we can add
+ * custom lines on top of the event bar chart in `eventGraph.tsx`.
+ */
+function barSeries(props: BarSeriesOption): BarSeriesOption | LineSeriesOption {
   return {
     ...props,
-    type: 'bar',
+    type: props.type ?? 'bar',
   };
 }
 

+ 1 - 1
static/app/types/echarts.tsx

@@ -7,12 +7,12 @@ import type {
 import type ReactEchartsCore from 'echarts-for-react/lib/core';
 
 export type SeriesDataUnit = {
+  // number because we sometimes use timestamps
   name: string | number;
   value: number;
   itemStyle?: {
     color?: string;
   };
-  // number because we sometimes use timestamps
   onClick?: (series: Series, instance: ECharts) => void;
 };
 

+ 47 - 2
static/app/views/issueDetails/streamline/eventGraph.tsx

@@ -1,8 +1,10 @@
 import {useMemo, useState} from 'react';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {LinkButton} from 'sentry/components/button';
 import {BarChart, type BarChartSeries} from 'sentry/components/charts/barChart';
+import Legend from 'sentry/components/charts/components/legend';
 import InteractionStateLayer from 'sentry/components/interactionStateLayer';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconTelescope} from 'sentry/icons';
@@ -13,9 +15,9 @@ import type {Group} from 'sentry/types/group';
 import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
 import {SavedQueryDatasets} from 'sentry/utils/discover/types';
 import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
-import theme from 'sentry/utils/theme';
 import useOrganization from 'sentry/utils/useOrganization';
 import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
+import useFlagSeries from 'sentry/views/issueDetails/streamline/flagSeries';
 import {useIssueDetailsEventView} from 'sentry/views/issueDetails/streamline/useIssueDetailsDiscoverQuery';
 
 export const enum EventGraphSeries {
@@ -48,10 +50,12 @@ function createSeriesAndCount(stats: EventsStats) {
 }
 
 export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
+  const theme = useTheme();
   const organization = useOrganization();
   const [visibleSeries, setVisibleSeries] = useState<EventGraphSeries>(
     EventGraphSeries.EVENT
   );
+
   const [isGraphHovered, setIsGraphHovered] = useState(false);
   const eventStats = groupStats['count()'];
   const {series: eventSeries, count: eventCount} = useMemo(
@@ -71,6 +75,18 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
     hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
   );
 
+  const [legendSelected, setLegendSelected] = useState({
+    ['Feature Flags']: true,
+  });
+
+  const flagSeries = useFlagSeries({
+    query: {
+      start: eventView.start,
+      end: eventView.end,
+      statsPeriod: eventView.statsPeriod,
+    },
+  });
+
   const series: BarChartSeries[] = [];
 
   if (eventStats && visibleSeries === EventGraphSeries.USER) {
@@ -97,6 +113,33 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
       data: eventSeries,
     });
   }
+  if (flagSeries.markLine) {
+    series.push(flagSeries as BarChartSeries);
+  }
+
+  const legend = Legend({
+    theme: theme,
+    icon: 'path://M 10 10 H 500 V 9000 H 10 L 10 10',
+    orient: 'horizontal',
+    align: 'left',
+    show: true,
+    right: 35,
+    top: 5,
+    data: ['Feature Flags'],
+    selected: legendSelected,
+  });
+
+  const onLegendSelectChanged = useMemo(
+    () =>
+      ({name, selected: record}) => {
+        const newValue = record[name];
+        setLegendSelected(prevState => ({
+          ...prevState,
+          [name]: newValue,
+        }));
+      },
+    []
+  );
 
   return (
     <GraphWrapper>
@@ -134,10 +177,12 @@ export function EventGraph({group, groupStats, searchQuery}: EventGraphProps) {
         <BarChart
           height={100}
           series={series}
+          legend={legend}
+          onLegendSelectChanged={onLegendSelectChanged}
           isGroupedByDate
           showTimeInTooltip
           grid={{
-            top: 8,
+            top: 28, // leave room for legend
             left: 8,
             right: 8,
             bottom: 0,

+ 122 - 0
static/app/views/issueDetails/streamline/flagSeries.tsx

@@ -0,0 +1,122 @@
+import {useTheme} from '@emotion/react';
+
+import MarkLine from 'sentry/components/charts/components/markLine';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {getFormattedDate} from 'sentry/utils/dates';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type RawFlag = {
+  action: string;
+  created_at: string;
+  created_by: string;
+  created_by_type: string;
+  flag: string;
+  id: number;
+  tags: Record<string, any>;
+};
+
+export type RawFlagData = {data: RawFlag[]};
+
+type FlagSeriesDatapoint = {
+  // flag action
+  label: {formatter: () => string};
+  // flag name
+  name: string;
+  // unix timestamp
+  xAxis: number;
+};
+
+function useOrganizationFlagLog({
+  organization,
+  query,
+}: {
+  organization: Organization;
+  query: Record<string, any>;
+}) {
+  const {data, isError, isPending} = useApiQuery<RawFlagData>(
+    [`/organizations/${organization.slug}/flags/logs/`, {query}],
+    {
+      staleTime: 0,
+      enabled: organization.features?.includes('feature-flag-ui'),
+    }
+  );
+  return {data, isError, isPending};
+}
+
+function hydrateFlagData({
+  rawFlagData,
+}: {
+  rawFlagData: RawFlagData;
+}): FlagSeriesDatapoint[] {
+  // transform raw flag data into series data
+  // each data point needs to be type FlagSeriesDatapoint
+  const flagData = rawFlagData.data.map(f => {
+    return {
+      xAxis: Date.parse(f.created_at),
+      label: {formatter: () => f.action},
+      name: `${f.flag}`,
+    };
+  });
+  return flagData;
+}
+
+export default function useFlagSeries({query = {}}: {query?: Record<string, any>}) {
+  const theme = useTheme();
+  const organization = useOrganization();
+  const {
+    data: rawFlagData,
+    isError,
+    isPending,
+  } = useOrganizationFlagLog({organization, query});
+
+  if (!rawFlagData || isError || isPending) {
+    return {
+      seriesName: t('Feature Flags'),
+      markLine: {},
+      data: [],
+    };
+  }
+
+  const hydratedFlagData: FlagSeriesDatapoint[] = hydrateFlagData({rawFlagData});
+
+  // create a markline series using hydrated flag data
+  const markLine = MarkLine({
+    animation: false,
+    lineStyle: {
+      color: theme.purple300,
+      opacity: 0.3,
+      type: 'solid',
+    },
+    label: {
+      show: false,
+    },
+    data: hydratedFlagData,
+    tooltip: {
+      trigger: 'item',
+      formatter: ({data}: any) => {
+        const time = getFormattedDate(data.xAxis, 'MMM D, YYYY LT z');
+        return [
+          '<div class="tooltip-series">',
+          `<div><span class="tooltip-label"><strong>${t(
+            'Feature Flag'
+          )}</strong></span></div>`,
+          `<div><code class="tooltip-code-no-margin">${data.name}</code>${data.label.formatter()}</div>`,
+          '</div>',
+          '<div class="tooltip-footer">',
+          time,
+          '</div>',
+          '<div class="tooltip-arrow"></div>',
+        ].join('');
+      },
+    },
+  });
+
+  return {
+    seriesName: t('Feature Flags'),
+    data: [],
+    markLine,
+    type: 'line', // use this type so the bar chart doesn't shrink/grow
+  };
+}