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

feat(dashboards): Add metrics enhanced discover queries (#33822)

Matej Minar 2 лет назад
Родитель
Сommit
576e8d558f

+ 1 - 0
static/app/components/modals/addDashboardWidgetModal.tsx

@@ -746,6 +746,7 @@ class AddDashboardWidgetModal extends React.Component<Props, State> {
               isSorting={false}
               currentWidgetDragging={false}
               noLazyLoad
+              showStoredAlert
             />
           </React.Fragment>
         );

+ 1 - 0
static/app/components/modals/widgetBuilder/addToDashboardModal.tsx

@@ -170,6 +170,7 @@ function AddToDashboardModal({
           widgetLimitReached={false}
           selection={selection}
           widget={widget}
+          showStoredAlert
         />
       </Body>
 

+ 1 - 1
static/app/utils/discover/eventView.tsx

@@ -52,7 +52,7 @@ import {MutableSearch} from '../tokenizeSearch';
 import {getSortField} from './fieldRenderers';
 
 // Metadata mapping for discover results.
-export type MetaType = Record<string, ColumnType>;
+export type MetaType = Record<string, ColumnType> & {isMetricsData?: boolean};
 
 // Data in discover results.
 export type EventData = Record<string, any>;

+ 8 - 0
static/app/views/dashboardsV2/utils.tsx

@@ -305,3 +305,11 @@ export function flattenErrors(
   }
   return update;
 }
+
+export function getDashboardsMEPQueryParams(isMEPEnabled: boolean) {
+  return isMEPEnabled
+    ? {
+        metricsEnhanced: '1',
+      }
+    : {};
+}

+ 37 - 0
static/app/views/dashboardsV2/widgetCard/dashboardsMEPContext.tsx

@@ -0,0 +1,37 @@
+import {ReactNode, useState} from 'react';
+
+import {createDefinedContext} from 'sentry/utils/performance/contexts/utils';
+
+interface DashboardsMEPContextInterface {
+  setIsMetricsData: (value?: boolean) => void;
+  isMetricsData?: boolean;
+}
+
+const [_DashboardsMEPProvider, useDashboardsMEPContext, DashboardsMEPContext] =
+  createDefinedContext<DashboardsMEPContextInterface>({
+    name: 'DashboardsMEPContext',
+  });
+
+const DashboardsMEPConsumer = DashboardsMEPContext.Consumer;
+
+function DashboardsMEPProvider({children}: {children: ReactNode}) {
+  const [isMetricsData, setIsMetricsData] = useState<boolean | undefined>(undefined); // undefined means not initialized
+
+  return (
+    <_DashboardsMEPProvider
+      value={{
+        isMetricsData,
+        setIsMetricsData,
+      }}
+    >
+      {children}
+    </_DashboardsMEPProvider>
+  );
+}
+
+export {
+  DashboardsMEPContext,
+  DashboardsMEPProvider,
+  DashboardsMEPConsumer,
+  useDashboardsMEPContext,
+};

+ 62 - 27
static/app/views/dashboardsV2/widgetCard/index.tsx

@@ -6,14 +6,17 @@ import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import {Client} from 'sentry/api';
+import Feature from 'sentry/components/acl/feature';
+import Alert from 'sentry/components/alert';
 import Button from 'sentry/components/button';
 import {HeaderTitle} from 'sentry/components/charts/styles';
 import ErrorBoundary from 'sentry/components/errorBoundary';
+import ExternalLink from 'sentry/components/links/externalLink';
 import {Panel} from 'sentry/components/panels';
 import Placeholder from 'sentry/components/placeholder';
 import Tooltip from 'sentry/components/tooltip';
 import {IconCopy, IconDelete, IconEdit, IconGrabbable} from 'sentry/icons';
-import {t} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import overflowEllipsis from 'sentry/styles/overflowEllipsis';
 import space from 'sentry/styles/space';
 import {Organization, PageFilters} from 'sentry/types';
@@ -24,8 +27,9 @@ import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 
 import {DRAG_HANDLE_CLASS} from '../dashboard';
-import {Widget} from '../types';
+import {Widget, WidgetType} from '../types';
 
+import {DashboardsMEPConsumer, DashboardsMEPProvider} from './dashboardsMEPContext';
 import WidgetCardChartContainer from './widgetCardChartContainer';
 import WidgetCardContextMenu from './widgetCardContextMenu';
 
@@ -52,6 +56,7 @@ type Props = WithRouterProps & {
   onEdit?: () => void;
   renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
   showContextMenu?: boolean;
+  showStoredAlert?: boolean;
   showWidgetViewerButton?: boolean;
   tableItemLimit?: number;
   windowWidth?: number;
@@ -205,32 +210,25 @@ class WidgetCard extends React.Component<Props, State> {
       tableItemLimit,
       windowWidth,
       noLazyLoad,
+      showStoredAlert,
     } = this.props;
     return (
       <ErrorBoundary
         customComponent={<ErrorCard>{t('Error loading widget data')}</ErrorCard>}
       >
-        <StyledPanel isDragging={false}>
-          <WidgetHeader>
-            <Tooltip title={widget.title} containerDisplayMode="grid" showOnlyOnOverflow>
-              <WidgetTitle>{widget.title}</WidgetTitle>
-            </Tooltip>
-            {this.renderContextMenu()}
-          </WidgetHeader>
-          {noLazyLoad ? (
-            <WidgetCardChartContainer
-              api={api}
-              organization={organization}
-              selection={selection}
-              widget={widget}
-              isMobile={isMobile}
-              renderErrorMessage={renderErrorMessage}
-              tableItemLimit={tableItemLimit}
-              windowWidth={windowWidth}
-              onDataFetched={this.setData}
-            />
-          ) : (
-            <LazyLoad once resize height={200}>
+        <DashboardsMEPProvider>
+          <StyledPanel isDragging={false}>
+            <WidgetHeader>
+              <Tooltip
+                title={widget.title}
+                containerDisplayMode="grid"
+                showOnlyOnOverflow
+              >
+                <WidgetTitle>{widget.title}</WidgetTitle>
+              </Tooltip>
+              {this.renderContextMenu()}
+            </WidgetHeader>
+            {noLazyLoad ? (
               <WidgetCardChartContainer
                 api={api}
                 organization={organization}
@@ -242,10 +240,42 @@ class WidgetCard extends React.Component<Props, State> {
                 windowWidth={windowWidth}
                 onDataFetched={this.setData}
               />
-            </LazyLoad>
-          )}
-          {this.renderToolbar()}
-        </StyledPanel>
+            ) : (
+              <LazyLoad once resize height={200}>
+                <WidgetCardChartContainer
+                  api={api}
+                  organization={organization}
+                  selection={selection}
+                  widget={widget}
+                  isMobile={isMobile}
+                  renderErrorMessage={renderErrorMessage}
+                  tableItemLimit={tableItemLimit}
+                  windowWidth={windowWidth}
+                  onDataFetched={this.setData}
+                />
+              </LazyLoad>
+            )}
+            {this.renderToolbar()}
+          </StyledPanel>
+          <Feature organization={organization} features={['dashboards-mep']}>
+            <DashboardsMEPConsumer>
+              {({isMetricsData}) =>
+                showStoredAlert &&
+                widget.widgetType === WidgetType.DISCOVER &&
+                isMetricsData === false && (
+                  <StoredDataAlert showIcon>
+                    {tct(
+                      "Your selection is only applicable to [storedData: stored event data]. We've automatically adjusted your results.",
+                      {
+                        storedData: <ExternalLink href="https://docs.sentry.io/" />, // TODO(dashboards): Update the docs URL
+                      }
+                    )}
+                  </StoredDataAlert>
+                )
+              }
+            </DashboardsMEPConsumer>
+          </Feature>
+        </DashboardsMEPProvider>
       </ErrorBoundary>
     );
   }
@@ -318,3 +348,8 @@ const WidgetHeader = styled('div')`
   align-items: center;
   justify-content: space-between;
 `;
+
+const StoredDataAlert = styled(Alert)`
+  margin-top: ${space(1)};
+  margin-bottom: 0;
+`;

+ 18 - 0
static/app/views/dashboardsV2/widgetCard/widgetCardContextMenu.tsx

@@ -3,11 +3,13 @@ import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import {openDashboardWidgetQuerySelectorModal} from 'sentry/actionCreators/modal';
+import Feature from 'sentry/components/acl/feature';
 import Button from 'sentry/components/button';
 import {openConfirmModal} from 'sentry/components/confirm';
 import DropdownMenuControlV2 from 'sentry/components/dropdownMenuControlV2';
 import {MenuItemProps} from 'sentry/components/dropdownMenuItemV2';
 import {isWidgetViewerPath} from 'sentry/components/modals/widgetViewerModal/utils';
+import Tag from 'sentry/components/tag';
 import {IconEllipsis, IconExpand} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
@@ -20,6 +22,8 @@ import {getWidgetDiscoverUrl, getWidgetIssueUrl} from 'sentry/views/dashboardsV2
 import {Widget, WidgetType} from '../types';
 import {WidgetViewerContext} from '../widgetViewer/widgetViewerContext';
 
+import {useDashboardsMEPContext} from './dashboardsMEPContext';
+
 type Props = {
   location: Location;
   organization: Organization;
@@ -61,6 +65,7 @@ function WidgetCardContextMenu({
   pageLinks,
   totalIssuesCount,
 }: Props) {
+  const {isMetricsData} = useDashboardsMEPContext();
   if (!showContextMenu) {
     return null;
   }
@@ -204,6 +209,15 @@ function WidgetCardContextMenu({
     <WidgetViewerContext.Consumer>
       {({setData}) => (
         <ContextWrapper>
+          <Feature organization={organization} features={['dashboards-mep']}>
+            {isMetricsData === false && (
+              <StoredTag
+                tooltipText={t('This widget is only applicable to stored event data.')}
+              >
+                {t('Stored')}
+              </StoredTag>
+            )}
+          </Feature>
           <StyledDropdownMenuControlV2
             items={menuOptions}
             triggerProps={{
@@ -264,3 +278,7 @@ const OpenWidgetViewerButton = styled(Button)`
     border-color: transparent;
   }
 `;
+
+const StoredTag = styled(Tag)`
+  margin-right: ${space(0.5)};
+`;

+ 40 - 1
static/app/views/dashboardsV2/widgetCard/widgetQueries.tsx

@@ -23,7 +23,13 @@ import {
 import {TOP_N} from 'sentry/utils/discover/types';
 
 import {DEFAULT_TABLE_LIMIT, DisplayType, Widget, WidgetQuery} from '../types';
-import {eventViewFromWidget, getWidgetInterval} from '../utils';
+import {
+  eventViewFromWidget,
+  getDashboardsMEPQueryParams,
+  getWidgetInterval,
+} from '../utils';
+
+import {DashboardsMEPContext} from './dashboardsMEPContext';
 
 type RawResult = EventsStats | MultiSeriesEventsStats;
 
@@ -83,6 +89,15 @@ export function flattenMultiSeriesDataWithGrouping(
   return seriesWithOrdering;
 }
 
+function getIsMetricsDataFromSeriesResponse(result: RawResult): boolean | undefined {
+  const multiIsMetricsData = Object.values(result)
+    .map(({isMetricsData}) => isMetricsData)
+    // One non-metrics series will cause all of them to be marked as such
+    .reduce((acc, value) => (acc === false ? false : value), undefined);
+
+  return isMultiSeriesStats(result) ? multiIsMetricsData : result.isMetricsData;
+}
+
 function transformResult(
   query: WidgetQuery,
   result: RawResult,
@@ -254,8 +269,18 @@ class WidgetQueries extends React.Component<Props, State> {
     this._isMounted = false;
   }
 
+  static contextType = DashboardsMEPContext;
+  context: React.ContextType<typeof DashboardsMEPContext> | undefined;
+
   private _isMounted: boolean = false;
 
+  get isMEPEnabled() {
+    // Events endpoint can return either always transactions, metrics, or metrics with a fallback to transactions (basically auto).
+    // For now, we are always keeping it on "auto" (if you have feature flag enabled).
+    // There's a chance that in the future this might become an explicit selector in the product.
+    return this.props.organization.features.includes('dashboards-mep');
+  }
+
   fetchEventData(queryFetchID: symbol) {
     const {selection, api, organization, widget, limit, cursor, onDataFetched} =
       this.props;
@@ -271,6 +296,7 @@ class WidgetQueries extends React.Component<Props, State> {
       const params: DiscoverQueryRequestParams = {
         per_page: limit ?? DEFAULT_TABLE_LIMIT,
         cursor,
+        ...getDashboardsMEPQueryParams(this.isMEPEnabled),
       };
       if (widget.displayType === 'table') {
         url = `/organizations/${organization.slug}/eventsv2/`;
@@ -296,9 +322,12 @@ class WidgetQueries extends React.Component<Props, State> {
     });
 
     let completed = 0;
+    let isMetricsData: boolean | undefined;
     promises.forEach(async (promise, i) => {
       try {
         const [data, _textstatus, resp] = await promise;
+        // If one of the queries is sampled, then mark the whole thing as sampled
+        isMetricsData = isMetricsData === false ? false : data.meta?.isMetricsData;
 
         // Cast so we can add the title.
         const tableData = data as TableDataWithTitle;
@@ -334,6 +363,7 @@ class WidgetQueries extends React.Component<Props, State> {
         if (!this._isMounted) {
           return;
         }
+        this.context?.setIsMetricsData(isMetricsData);
         this.setState(prevState => {
           if (prevState.queryFetchID !== queryFetchID) {
             // invariant: a different request was initiated after this request
@@ -381,6 +411,7 @@ class WidgetQueries extends React.Component<Props, State> {
           partial: true,
           topEvents: TOP_N,
           field: [...query.columns, ...query.aggregates],
+          queryExtras: getDashboardsMEPQueryParams(this.isMEPEnabled),
         };
         if (query.orderby) {
           requestData.orderby = query.orderby;
@@ -400,6 +431,7 @@ class WidgetQueries extends React.Component<Props, State> {
           includePrevious: false,
           referrer: `api.dashboards.widget.${displayType}-chart`,
           partial: true,
+          queryExtras: getDashboardsMEPQueryParams(this.isMEPEnabled),
         };
 
         if (
@@ -421,12 +453,18 @@ class WidgetQueries extends React.Component<Props, State> {
     });
 
     let completed = 0;
+    let isMetricsData: boolean | undefined;
     promises.forEach(async (promise, requestIndex) => {
       try {
         const rawResults = await promise;
         if (!this._isMounted) {
           return;
         }
+        // If one of the queries is sampled, then mark the whole thing as sampled
+        isMetricsData =
+          isMetricsData === false
+            ? false
+            : getIsMetricsDataFromSeriesResponse(rawResults);
         this.setState(prevState => {
           if (prevState.queryFetchID !== queryFetchID) {
             // invariant: a different request was initiated after this request
@@ -469,6 +507,7 @@ class WidgetQueries extends React.Component<Props, State> {
         if (!this._isMounted) {
           return;
         }
+        this.context?.setIsMetricsData(isMetricsData);
         this.setState(prevState => {
           if (prevState.queryFetchID !== queryFetchID) {
             // invariant: a different request was initiated after this request

+ 11 - 0
tests/js/spec/views/dashboardsV2/utils.spec.tsx

@@ -3,6 +3,7 @@ import {
   constructWidgetFromQuery,
   eventViewFromWidget,
   flattenErrors,
+  getDashboardsMEPQueryParams,
   getFieldsFromEquations,
   getWidgetDiscoverUrl,
   getWidgetIssueUrl,
@@ -250,4 +251,14 @@ describe('Dashboards util', () => {
       });
     });
   });
+  describe('getDashboardsMEPQueryParams', function () {
+    it('returns correct params if enabled', function () {
+      expect(getDashboardsMEPQueryParams(true)).toEqual({
+        metricsEnhanced: '1',
+      });
+    });
+    it('returns empty object if disabled', function () {
+      expect(getDashboardsMEPQueryParams(false)).toEqual({});
+    });
+  });
 });

+ 50 - 1
tests/js/spec/views/dashboardsV2/widgetCard.spec.tsx

@@ -1,6 +1,6 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {mountGlobalModal} from 'sentry-test/modal';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
 import * as modal from 'sentry/actionCreators/modal';
 import {Client} from 'sentry/api';
@@ -560,4 +560,53 @@ describe('Dashboards > WidgetCard', function () {
       expect.objectContaining({pathname: '/mock-pathname/widget/10/'})
     );
   });
+
+  it('renders stored data disclaimer', async function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/eventsv2/',
+      body: {
+        meta: {title: 'string', isMetricsData: false},
+        data: [{title: 'title'}],
+      },
+    });
+
+    render(
+      <WidgetCard
+        api={api}
+        organization={{
+          ...organization,
+          features: [...organization.features, 'dashboards-mep'],
+        }}
+        widget={{
+          ...multipleQueryWidget,
+          displayType: DisplayType.TABLE,
+          queries: [{...multipleQueryWidget.queries[0]}],
+        }}
+        selection={selection}
+        isEditing={false}
+        onDelete={() => undefined}
+        onEdit={() => undefined}
+        onDuplicate={() => undefined}
+        renderErrorMessage={() => undefined}
+        isSorting={false}
+        currentWidgetDragging={false}
+        showContextMenu
+        widgetLimitReached={false}
+        showStoredAlert
+      />,
+      {context: routerContext}
+    );
+
+    await waitFor(() => {
+      // Badge in the widget header
+      expect(screen.getByText('Stored')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(
+        // Alert below the widget
+        screen.getByText(/we've automatically adjusted your results/i)
+      ).toBeInTheDocument();
+    });
+  });
 });

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