Просмотр исходного кода

feat(perf): Add hybrid widget tag for time-series widgets (#32160)

* feat(perf): Add hybrid widget tag for time-series widgets

This PR adds a 'tag' showing the dataset state (exact ui still needs design) if a key is sent back for that series in 'event-stats' meta. Currently set up for any of the time-series widgets. We can expand working widgets in the future.
Kev 3 лет назад
Родитель
Сommit
c7979ce333

+ 43 - 16
static/app/components/charts/eventsRequest.tsx

@@ -47,9 +47,15 @@ type LoadingStatus = {
   errorMessage?: string;
 };
 
+// Can hold additional data from the root an events stat object (eg. start, end, order, isMetricsData).
+interface AdditionalSeriesInfo {
+  isMetricsData?: boolean;
+}
+
 export type RenderProps = LoadingStatus &
   TimeSeriesData & {
     results?: Series[]; // Chart with multiple series.
+    seriesAdditionalInfo?: Record<string, AdditionalSeriesInfo>;
   };
 
 type DefaultProps = {
@@ -429,7 +435,7 @@ class EventsRequest extends React.PureComponent<EventsRequestProps, EventsReques
   }
 
   processData(response: EventsStats, seriesIndex: number = 0, seriesName?: string) {
-    const {data, totals} = response;
+    const {data, isMetricsData, totals} = response;
     const {
       includeTransformedData,
       includeTimeAggregation,
@@ -479,6 +485,7 @@ class EventsRequest extends React.PureComponent<EventsRequestProps, EventsReques
       allData: data,
       originalData: current,
       totals,
+      isMetricsData,
       originalPreviousData: previous,
       previousData,
       timeAggregatedData,
@@ -503,23 +510,35 @@ class EventsRequest extends React.PureComponent<EventsRequestProps, EventsReques
       // As the server will have replied with a map like:
       // {[titleString: string]: EventsStats}
       let timeframe: {end: number; start: number} | undefined = undefined;
+      const seriesAdditionalInfo: Record<string, AdditionalSeriesInfo> = {};
       const sortedTimeseriesData = Object.keys(timeseriesData)
-        .map((seriesName: string, index: number): [number, Series, Series | null] => {
-          const seriesData: EventsStats = timeseriesData[seriesName];
-          const processedData = this.processData(
-            seriesData,
-            index,
-            stripEquationPrefix(seriesName)
-          );
-          if (!timeframe) {
-            timeframe = processedData.timeframe;
+        .map(
+          (
+            seriesName: string,
+            index: number
+          ): [number, Series, Series | null, AdditionalSeriesInfo] => {
+            const seriesData: EventsStats = timeseriesData[seriesName];
+            const processedData = this.processData(
+              seriesData,
+              index,
+              stripEquationPrefix(seriesName)
+            );
+            if (!timeframe) {
+              timeframe = processedData.timeframe;
+            }
+            if (processedData.isMetricsData) {
+              seriesAdditionalInfo[seriesName] = {
+                isMetricsData: processedData.isMetricsData,
+              };
+            }
+            return [
+              seriesData.order || 0,
+              processedData.data[0],
+              processedData.previousData,
+              {isMetricsData: processedData.isMetricsData},
+            ];
           }
-          return [
-            seriesData.order || 0,
-            processedData.data[0],
-            processedData.previousData,
-          ];
-        })
+        )
         .sort((a, b) => a[0] - b[0]);
       const results: Series[] = sortedTimeseriesData.map(item => {
         return item[1];
@@ -540,6 +559,7 @@ class EventsRequest extends React.PureComponent<EventsRequestProps, EventsReques
         results,
         timeframe,
         previousTimeseriesData,
+        seriesAdditionalInfo,
         // sometimes we want to reference props that were given to EventsRequest
         ...props,
       });
@@ -555,13 +575,20 @@ class EventsRequest extends React.PureComponent<EventsRequestProps, EventsReques
         previousData: previousTimeseriesData,
         timeAggregatedData,
         timeframe,
+        isMetricsData,
       } = this.processData(timeseriesData);
 
+      const seriesAdditionalInfo = {
+        [this.props.currentSeriesNames?.[0] ?? 'current']: {isMetricsData},
+      };
+
       return children({
         loading,
         reloading,
         errored,
         errorMessage,
+        // meta data,
+        seriesAdditionalInfo,
         // timeseries data
         timeseriesData: transformedTimeseriesData,
         comparisonTimeseriesData: transformedComparisonTimeseriesData,

+ 1 - 0
static/app/types/organization.tsx

@@ -192,6 +192,7 @@ export type EventsGeoData = {count: number; 'geo.country_code': string}[];
 export type EventsStats = {
   data: EventsStatsData;
   end?: number;
+  isMetricsData?: boolean;
   order?: number;
   start?: number;
   totals?: {count: number};

+ 54 - 0
static/app/utils/performance/contexts/metricsEnhancedPerformanceContext.tsx

@@ -0,0 +1,54 @@
+import {ReactNode, useState} from 'react';
+
+import Tag from 'sentry/components/tag';
+import {t} from 'sentry/locale';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useMEPSettingContext} from './metricsEnhancedSetting';
+import {createDefinedContext} from './utils';
+
+interface MetricsEnhancedPerformanceDataContext {
+  setIsMetricsData: (value?: boolean) => void;
+  isMetricsData?: boolean;
+}
+
+const [_MEPDataProvider, _useMEPDataContext] =
+  createDefinedContext<MetricsEnhancedPerformanceDataContext>({
+    name: 'MetricsEnhancedPerformanceDataContext',
+  });
+
+export const MEPDataProvider = ({children}: {children: ReactNode}) => {
+  const [isMetricsData, setIsMetricsData] = useState<boolean | undefined>(undefined); // Uses undefined to cover 'not initialized'
+  return (
+    <_MEPDataProvider value={{isMetricsData, setIsMetricsData}}>
+      {children}
+    </_MEPDataProvider>
+  );
+};
+
+export const useMEPDataContext = _useMEPDataContext;
+
+export const MEPTag = () => {
+  const {isMetricsData} = useMEPDataContext();
+  const {isMEPEnabled} = useMEPSettingContext();
+  const organization = useOrganization();
+
+  if (!organization.features.includes('performance-use-metrics')) {
+    // Separate if for easier flag deletion
+    return null;
+  }
+
+  if (isMetricsData || !isMEPEnabled) {
+    return null;
+  }
+  return (
+    <Tag
+      tooltipText={t(
+        'These search conditions are only applicable to sampled transaction data. To edit sampling rates, go to Filters & Sampling in settings.'
+      )}
+      data-test-id="has-metrics-data-tag"
+    >
+      {'Sampled'}
+    </Tag>
+  );
+};

+ 42 - 0
static/app/utils/performance/contexts/metricsEnhancedSetting.tsx

@@ -0,0 +1,42 @@
+import {ReactNode, useState} from 'react';
+
+import localStorage from 'sentry/utils/localStorage';
+
+import {createDefinedContext} from './utils';
+
+const storageKey = 'performance.metrics-enhanced-setting';
+
+interface MetricsEnhancedSettingContext {
+  isMEPEnabled: boolean;
+  setMEPEnabled: (value: boolean) => void;
+}
+
+const [_MEPSettingProvider, _useMEPSettingContext] =
+  createDefinedContext<MetricsEnhancedSettingContext>({
+    name: 'MetricsEnhancedSettingContext',
+  });
+
+export const MEPSettingProvider = ({
+  children,
+  _isMEPEnabled,
+}: {
+  children: ReactNode;
+  _isMEPEnabled?: boolean;
+}) => {
+  const isControlledMEPEnabled = typeof _isMEPEnabled === 'boolean';
+  const [isMEPEnabled, _setMEPEnabled] = useState<boolean>(
+    isControlledMEPEnabled ? _isMEPEnabled : localStorage.getItem(storageKey) !== 'false'
+  );
+
+  function setMEPEnabled(value: boolean) {
+    _setMEPEnabled(value);
+    localStorage.setItem(storageKey, value ? 'true' : 'false');
+  }
+  return (
+    <_MEPSettingProvider value={{isMEPEnabled, setMEPEnabled}}>
+      {children}
+    </_MEPSettingProvider>
+  );
+};
+
+export const useMEPSettingContext = _useMEPSettingContext;

+ 2 - 1
static/app/views/performance/index.tsx

@@ -3,6 +3,7 @@ import Alert from 'sentry/components/alert';
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
 import {Organization} from 'sentry/types';
+import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
 import withOrganization from 'sentry/utils/withOrganization';
 
 type Props = {
@@ -26,7 +27,7 @@ function PerformanceContainer({organization, children}: Props) {
       organization={organization}
       renderDisabled={renderNoAccess}
     >
-      {children}
+      <MEPSettingProvider>{children}</MEPSettingProvider>
     </Feature>
   );
 }

+ 33 - 0
static/app/views/performance/landing/index.tsx

@@ -3,6 +3,8 @@ import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 
+import {openModal} from 'sentry/actionCreators/modal';
+import Feature from 'sentry/components/acl/feature';
 import Button from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import SearchBar from 'sentry/components/events/searchBar';
@@ -12,6 +14,7 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import PageHeading from 'sentry/components/pageHeading';
 import * as TeamKeyTransactionManager from 'sentry/components/performance/teamKeyTransactionsManager';
 import {MAX_QUERY_LENGTH} from 'sentry/constants';
+import {IconSettings} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
 import space from 'sentry/styles/space';
@@ -19,6 +22,7 @@ import {Organization, PageFilters, Project} from 'sentry/types';
 import EventView from 'sentry/utils/discover/eventView';
 import {generateAggregateFields} from 'sentry/utils/discover/fields';
 import {GenericQueryBatcher} from 'sentry/utils/performance/contexts/genericQueryBatcher';
+import {useMEPSettingContext} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
 import {
   PageErrorAlert,
   PageErrorProvider,
@@ -33,6 +37,7 @@ import {BackendView} from './views/backendView';
 import {FrontendOtherView} from './views/frontendOtherView';
 import {FrontendPageloadView} from './views/frontendPageloadView';
 import {MobileView} from './views/mobileView';
+import SamplingModal, {modalCss} from './samplingModal';
 import {
   getDefaultDisplayForPlatform,
   getLandingDisplayFromParam,
@@ -104,6 +109,26 @@ export function PerformanceLanding(props: Props) {
 
   const ViewComponent = fieldToViewMap[landingDisplay.field];
 
+  const {isMEPEnabled, setMEPEnabled} = useMEPSettingContext();
+
+  const fnOpenModal = () => {
+    openModal(
+      modalProps => (
+        <SamplingModal
+          {...modalProps}
+          organization={organization}
+          eventView={eventView}
+          projects={projects}
+          isMEPEnabled={isMEPEnabled}
+          onApply={value => {
+            setMEPEnabled(value);
+          }}
+        />
+      ),
+      {modalCss, backdrop: 'static'}
+    );
+  };
+
   return (
     <StyledPageContent data-test-id="performance-landing-v3">
       <PageErrorProvider>
@@ -121,6 +146,14 @@ export function PerformanceLanding(props: Props) {
                 >
                   {t('View Trends')}
                 </Button>
+                <Feature features={['organizations:performance-use-metrics']}>
+                  <Button
+                    onClick={() => fnOpenModal()}
+                    icon={<IconSettings />}
+                    aria-label={t('Settings')}
+                    data-test-id="open-meps-settings"
+                  />
+                </Feature>
               </ButtonBar>
             )}
           </Layout.HeaderActions>

+ 97 - 0
static/app/views/performance/landing/samplingModal.tsx

@@ -0,0 +1,97 @@
+import {Fragment, ReactNode, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import RadioGroup from 'sentry/components/forms/controls/radioGroup';
+import Link from 'sentry/components/links/link';
+import {t, tct} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Organization, Project} from 'sentry/types';
+import EventView from 'sentry/utils/discover/eventView';
+
+type Props = {
+  eventView: EventView;
+  isMEPEnabled: boolean;
+  onApply: (isMEPEnabled: boolean) => void;
+  organization: Organization;
+  projects: Project[];
+} & ModalRenderProps;
+
+const SamplingModal = (props: Props) => {
+  const {Header, Body, Footer, organization, eventView, isMEPEnabled, projects} = props;
+
+  const project = projects.find(p => `${eventView.project[0]}` === p.id);
+
+  const choices: [string, ReactNode][] = [
+    ['true', t('Automatically switch to sampled data when required')],
+    ['false', t('Always show sampled data')],
+  ];
+
+  const [choice, setChoice] = useState(choices[isMEPEnabled ? 0 : 1][0]);
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h4>{t('Sampling Settings')}</h4>
+      </Header>
+      <Body>
+        <Instruction>
+          {tct(
+            "The visualizations shown are based on your data without any filters or sampling. This does not contribute to your quota usage but transaction details are limited. If you'd like to improve accuracy, we recommend adding more transactions to your quota. or modifying your data set through [projectSettings: Filters & Sampling in settings].",
+            {
+              projectSettings: (
+                <Link
+                  to={`/settings/${organization.slug}/projects/${project?.slug}/filters-and-sampling/`}
+                />
+              ),
+            }
+          )}
+        </Instruction>
+        <Instruction>
+          <RadioGroup
+            style={{flex: 1}}
+            choices={choices}
+            value={choice}
+            label=""
+            onChange={(id: string) => setChoice(id)}
+          />
+        </Instruction>
+      </Body>
+      <Footer>
+        <ButtonBar gap={1}>
+          <Button priority="default" onClick={() => {}} data-test-id="reset-all">
+            {t('Read the docs')}
+          </Button>
+          <Button
+            aria-label={t('Apply')}
+            priority="primary"
+            onClick={event => {
+              event.preventDefault();
+              props.closeModal();
+              // Use onApply since modal might be outside of the provider due to portal/wormholing.
+              props.onApply(choice === 'true');
+            }}
+            data-test-id="apply-threshold"
+          >
+            {t('Apply')}
+          </Button>
+        </ButtonBar>
+      </Footer>
+    </Fragment>
+  );
+};
+
+const Instruction = styled('div')`
+  margin-bottom: ${space(4)};
+`;
+
+export default SamplingModal;
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 650px;
+  margin: 70px auto;
+`;

+ 13 - 10
static/app/views/performance/landing/widgets/components/performanceWidget.tsx

@@ -8,6 +8,7 @@ import {IconWarning} from 'sentry/icons/iconWarning';
 import space from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import {MEPDataProvider} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceContext';
 import useApi from 'sentry/utils/useApi';
 import getPerformanceWidgetContainer from 'sentry/views/performance/landing/widgets/components/performanceWidgetContainer';
 
@@ -65,16 +66,18 @@ export function GenericPerformanceWidget<T extends WidgetDataConstraint>(
 
   return (
     <Fragment>
-      <QueryHandler
-        eventView={props.eventView}
-        widgetData={widgetData}
-        setWidgetDataForKey={setWidgetDataForKey}
-        removeWidgetDataForKey={removeWidgetDataForKey}
-        queryProps={props}
-        queries={queries}
-        api={api}
-      />
-      <_DataDisplay<T> {...props} {...widgetProps} totalHeight={totalHeight} />
+      <MEPDataProvider>
+        <QueryHandler
+          eventView={props.eventView}
+          widgetData={widgetData}
+          setWidgetDataForKey={setWidgetDataForKey}
+          removeWidgetDataForKey={removeWidgetDataForKey}
+          queryProps={props}
+          queries={queries}
+          api={api}
+        />
+        <_DataDisplay<T> {...props} {...widgetProps} totalHeight={totalHeight} />
+      </MEPDataProvider>
     </Fragment>
   );
 }

+ 6 - 0
static/app/views/performance/landing/widgets/components/queryHandler.tsx

@@ -1,6 +1,7 @@
 import {Fragment, useEffect} from 'react';
 
 import {getUtcToLocalDateObject} from 'sentry/utils/dates';
+import {useMEPDataContext} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceContext';
 
 import {QueryDefinitionWithKey, QueryHandlerProps, WidgetDataConstraint} from '../types';
 import {PerformanceWidgetSetting} from '../widgetDefinitions';
@@ -88,10 +89,15 @@ function QueryResultSaver<T extends WidgetDataConstraint>(
     results: any;
   } & QueryHandlerProps<T>
 ) {
+  const mepContext = useMEPDataContext();
   const {results, query} = props;
+
   const transformed = query.transform(props.queryProps, results, props.query);
 
   useEffect(() => {
+    const isMetricsData =
+      results?.seriesAdditionalInfo?.[props.queryProps.fields[0]]?.isMetricsData;
+    mepContext.setIsMetricsData(isMetricsData);
     props.setWidgetDataForKey(query.queryKey, transformed);
   }, [transformed?.hasData, transformed?.isLoading, transformed?.isErrored]);
   return <Fragment />;

+ 2 - 0
static/app/views/performance/landing/widgets/components/widgetHeader.tsx

@@ -4,6 +4,7 @@ import {HeaderTitleLegend} from 'sentry/components/charts/styles';
 import QuestionTooltip from 'sentry/components/questionTooltip';
 import TextOverflow from 'sentry/components/textOverflow';
 import space from 'sentry/styles/space';
+import {MEPTag} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceContext';
 
 import {
   GenericPerformanceWidgetProps,
@@ -20,6 +21,7 @@ export function WidgetHeader<T extends WidgetDataConstraint>(
       <TitleContainer>
         <StyledHeaderTitleLegend data-test-id="performance-widget-title">
           <TextOverflow>{title}</TextOverflow>
+          <MEPTag />
           <QuestionTooltip position="top" size="sm" title={titleTooltip} />
         </StyledHeaderTitleLegend>
         {Subtitle ? <Subtitle {...props} /> : null}

Некоторые файлы не были показаны из-за большого количества измененных файлов