Browse Source

feat(perf): Add notices for folks whose projects have old SDKs and no data (#56954)

We don't have any SDK restrictions, but we _do_ have SDK guidelines. We
will show a simple text notice to anyone who is not receiving span
metrics _and_ has an old SDK, to encourage them to upgrade. Not a
foolproof approach, but the list of customers who are affected should be
small!

## Solution

Here's how it works:

- the `NoDataDueToOldSDKMessage` component
- takes the current selected projects from the URL, and checks if there
is _any_ span metrics data for it, by fetching a `count()` metric
- checks whether the current selected projects have any SDK updates
available
- checks the available SDK updates against the hard-coded list of SDK
required versions
    - lists any projects that are outdated if there's no recent data

This shows up in a little banner or notice on the Performance page, in
the "Time Spent in Database" widget, if it's showing no data. Also shows
up at the top of the Performance Queries landing page.
George Gritsouk 1 year ago
parent
commit
653ee48579

+ 20 - 0
fixtures/js-stubs/projectSdkUpdates.tsx

@@ -0,0 +1,20 @@
+import type {ProjectSdkUpdates as TProjectSdkUpdates} from 'sentry/types';
+
+export function ProjectSdkUpdates(
+  overrides?: Partial<TProjectSdkUpdates>
+): TProjectSdkUpdates {
+  return {
+    projectId: '1',
+    sdkName: 'sentry.javascript',
+    sdkVersion: '7.50.0.',
+    suggestions: [
+      {
+        enables: [],
+        newSdkVersion: '7.63.0',
+        sdkName: 'sentry.javascript',
+        type: 'updateSdk',
+      },
+    ],
+    ...overrides,
+  };
+}

+ 22 - 0
static/app/utils/useOrganizationSDKUpdates.tsx

@@ -0,0 +1,22 @@
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {ProjectSdkUpdates} from '../types/project';
+
+interface Options {
+  enabled?: boolean;
+  projectId?: string[];
+}
+
+export function useOrganizationSDKUpdates({projectId, enabled}: Options): {
+  isError: boolean;
+  isFetching: boolean;
+  data?: ProjectSdkUpdates[];
+} {
+  const organization = useOrganization();
+
+  return useApiQuery<ProjectSdkUpdates[]>(
+    [`/organizations/${organization.slug}/sdk-updates/`, {query: {project: projectId}}],
+    {staleTime: 5000, enabled}
+  );
+}

+ 8 - 0
static/app/views/performance/database/databaseLandingPage.tsx

@@ -1,5 +1,6 @@
 import styled from '@emotion/styled';
 
+import Alert from 'sentry/components/alert';
 import Breadcrumbs from 'sentry/components/breadcrumbs';
 import DatePageFilter from 'sentry/components/datePageFilter';
 import FeatureBadge from 'sentry/components/featureBadge';
@@ -11,6 +12,7 @@ import {space} from 'sentry/styles/space';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
+import {NoDataDueToOldSDKMessage} from 'sentry/views/performance/database/noDataDueToOldSDKMessage';
 import {RELEASE_LEVEL} from 'sentry/views/performance/database/settings';
 import {ModuleName, SpanMetricsField} from 'sentry/views/starfish/types';
 import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
@@ -54,6 +56,8 @@ function DatabaseLandingPage() {
 
       <Layout.Body>
         <Layout.Main fullWidth>
+          <NoDataDueToOldSDKMessage Wrapper={AlertBanner} />
+
           <PaddedContainer>
             <PageFilterBar condensed>
               <ProjectPageFilter />
@@ -86,6 +90,10 @@ const PaddedContainer = styled('div')`
   margin-bottom: ${space(2)};
 `;
 
+function AlertBanner(props) {
+  return <Alert {...props} type="info" showIcon />;
+}
+
 const FilterOptionsContainer = styled('div')`
   display: grid;
   grid-template-columns: repeat(3, 1fr);

+ 61 - 0
static/app/views/performance/database/noDataDueToOldSDKMessage.spec.tsx

@@ -0,0 +1,61 @@
+import {PageFilters} from 'sentry-fixture/pageFilters';
+import {Project} from 'sentry-fixture/project';
+import {ProjectSdkUpdates} from 'sentry-fixture/projectSdkUpdates';
+
+import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import importedUsePageFilters from 'sentry/utils/usePageFilters';
+
+jest.mock('sentry/utils/usePageFilters');
+
+const usePageFilters = jest.mocked(importedUsePageFilters);
+
+import {NoDataDueToOldSDKMessage} from 'sentry/views/performance/database/noDataDueToOldSDKMessage';
+
+describe('NoDataDueToOldSDKMessage', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
+    usePageFilters.mockClear();
+    ProjectsStore.loadInitialData([Project({name: 'Awesome API', slug: 'awesome-api'})]);
+  });
+
+  it('shows a list of outdated SDKs if there is no data available and SDKs are outdated', async function () {
+    usePageFilters.mockImplementation(() => ({
+      selection: PageFilters({projects: [2]}),
+      isReady: true,
+      shouldPersist: true,
+      pinnedFilters: new Set(),
+      desyncedFilters: new Set(),
+    }));
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/sdk-updates/',
+      body: [ProjectSdkUpdates({projectId: '2'})],
+    });
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events/',
+      body: {
+        data: {count: 1},
+      },
+    });
+
+    render(<NoDataDueToOldSDKMessage />);
+
+    await waitFor(() => {
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher('You may be missing data due to outdated SDKs.')
+        )
+      ).toBeInTheDocument();
+    });
+
+    expect(screen.getAllByRole('link')[1]).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/projects/awesome-api/'
+    );
+  });
+});

+ 84 - 0
static/app/views/performance/database/noDataDueToOldSDKMessage.tsx

@@ -0,0 +1,84 @@
+import {Fragment} from 'react';
+
+import ExternalLink from 'sentry/components/links/externalLink';
+import {tct} from 'sentry/locale';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {useIneligibleProjects} from 'sentry/views/performance/database/useIneligibleProjects';
+import {useHasAnySpanMetrics} from 'sentry/views/starfish/queries/useHasAnySpanMetrics';
+
+interface Props {
+  Wrapper?: React.ComponentType;
+}
+
+function DivWrapper(props) {
+  return <div {...props} />;
+}
+
+export function NoDataDueToOldSDKMessage({Wrapper = DivWrapper}: Props) {
+  const {selection, isReady: pageFilterIsReady} = usePageFilters();
+
+  const options = {
+    projectId: pageFilterIsReady
+      ? selection.projects.map(projectId => projectId.toString())
+      : undefined,
+    enabled: pageFilterIsReady,
+  };
+
+  const {hasMetrics} = useHasAnySpanMetrics(options);
+  const {ineligibleProjects} = useIneligibleProjects(options);
+
+  const organization = useOrganization();
+
+  const hasMoreIneligibleProjectsThanVisible =
+    ineligibleProjects.length > MAX_LISTED_PROJECTS;
+
+  if (hasMetrics) {
+    return null;
+  }
+
+  if (ineligibleProjects.length < 1) {
+    return null;
+  }
+
+  const listedProjects = ineligibleProjects.slice(0, MAX_LISTED_PROJECTS + 1);
+
+  return (
+    <Wrapper>
+      {tct(
+        "You may be missing data due to outdated SDKs. Please refer to Sentry's [documentation:documentation] for more information. Projects with outdated SDKs: [projectList]",
+        {
+          documentation: (
+            <ExternalLink href="https://docs.sentry.io/product/performance/database/" />
+          ),
+          projectList: (
+            <Fragment>
+              {listedProjects.map((project, projectIndex) => {
+                return (
+                  <span key={project.id}>
+                    <a
+                      href={normalizeUrl(
+                        `/organizations/${organization.slug}/projects/${project.slug}/`
+                      )}
+                    >
+                      {project.name}
+                    </a>
+                    {projectIndex < listedProjects.length - 1 && ', '}
+                  </span>
+                );
+              })}
+            </Fragment>
+          ),
+        }
+      )}
+      {hasMoreIneligibleProjectsThanVisible
+        ? tct(' and [count] more', {
+            count: ineligibleProjects.length - MAX_LISTED_PROJECTS,
+          })
+        : ''}
+    </Wrapper>
+  );
+}
+
+const MAX_LISTED_PROJECTS = 3;

+ 11 - 0
static/app/views/performance/database/settings.ts

@@ -1,3 +1,14 @@
 import {BadgeType} from 'sentry/components/featureBadge';
 
 export const RELEASE_LEVEL: BadgeType = 'alpha';
+
+export const MIN_SDK_VERSION_BY_PLATFORM: {[platform: string]: string} = {
+  'sentry.python': '1.29.2',
+  'sentry.javascript': '7.63.0',
+  'sentry.laravel': '3.8.0',
+  'sentry.cocoa': '8.11.0',
+  'sentry.java': '6.29.0',
+  'sentry.ruby': '5.11.0',
+  'sentry.dotnet': '3.39.0',
+  'sentry.symfony': '4.11.0',
+};

+ 56 - 0
static/app/views/performance/database/useIneligibleProjects.tsx

@@ -0,0 +1,56 @@
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {useOrganizationSDKUpdates} from 'sentry/utils/useOrganizationSDKUpdates';
+import {semverCompare} from 'sentry/utils/versions';
+import {MIN_SDK_VERSION_BY_PLATFORM} from 'sentry/views/performance/database/settings';
+
+interface Options {
+  enabled?: boolean;
+  projectId?: string[];
+}
+
+/**
+ * Returns a list of projects that are not eligible for span metrics
+ * due to SDK requirements.
+ *
+ * @param options Additional options
+ * @param options.projectId List of project IDs to check against. If omitted, checks all organization projects
+ * @returns List of projects
+ */
+export function useIneligibleProjects(options?: Options) {
+  const response = useOrganizationSDKUpdates(options ?? {});
+  const {data: availableUpdates} = response;
+
+  const ineligibleProjects = (availableUpdates ?? [])
+    .filter(update => {
+      const platform = removeFlavorFromSDKName(update.sdkName);
+      const minimumRequiredVersion = MIN_SDK_VERSION_BY_PLATFORM[platform];
+
+      if (!minimumRequiredVersion) {
+        // If a minimum version is not specified, assume that the platform
+        // doesn't have any support at all
+        return true;
+      }
+
+      return semverCompare(update.sdkVersion, minimumRequiredVersion) === -1;
+    })
+    .map(update => update.projectId)
+    .map(projectId => {
+      return ProjectsStore.getById(projectId);
+    })
+    .filter((item): item is NonNullable<typeof item> => Boolean(item));
+
+  return {
+    ...response,
+    ineligibleProjects,
+  };
+}
+
+/**
+ * Strips the SDK flavour from its name
+ *
+ * @param sdkName Name of the SDK, like `"sentry.javascript.react"
+ * @returns Platform name like `"sentry.javascript"`
+ */
+function removeFlavorFromSDKName(sdkName: string): string {
+  return sdkName.split('.').slice(0, 2).join('.');
+}

+ 14 - 0
static/app/views/performance/landing/widgets/components/selectableList.tsx

@@ -1,3 +1,4 @@
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
@@ -12,6 +13,7 @@ import {space} from 'sentry/styles/space';
 import {getConfigureIntegrationsDocsLink} from 'sentry/utils/docs';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjects from 'sentry/utils/useProjects';
+import {NoDataDueToOldSDKMessage} from 'sentry/views/performance/database/noDataDueToOldSDKMessage';
 import {getIsMultiProject} from 'sentry/views/performance/utils';
 
 type Props = {
@@ -89,6 +91,18 @@ export function WidgetEmptyStateWarning() {
   );
 }
 
+export function TimeSpentInDatabaseWidgetEmptyStateWarning() {
+  return (
+    <StyledEmptyStateWarning>
+      <PrimaryMessage>{t('No results found')}</PrimaryMessage>
+      <SecondaryMessage>
+        {t('Spans may not be listed due to the filters above.')}{' '}
+        <NoDataDueToOldSDKMessage Wrapper={Fragment} />
+      </SecondaryMessage>
+    </StyledEmptyStateWarning>
+  );
+}
+
 export function WidgetAddInstrumentationWarning({type}: {type: 'db' | 'http'}) {
   const pageFilters = usePageFilters();
   const fullProjects = useProjects();

+ 16 - 9
static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx

@@ -42,6 +42,7 @@ import SelectableList, {
   ListClose,
   RightAlignedCell,
   Subtitle,
+  TimeSpentInDatabaseWidgetEmptyStateWarning,
   WidgetAddInstrumentationWarning,
   WidgetEmptyStateWarning,
 } from '../components/selectableList';
@@ -88,15 +89,21 @@ export function LineChartListWidget(props: PerformanceWidgetProps) {
   const canHaveIntegrationEmptyState = integrationEmptyStateWidgets.includes(
     props.chartSetting
   );
-  const emptyComponent = canHaveIntegrationEmptyState
-    ? () => (
-        <WidgetAddInstrumentationWarning
-          type={
-            props.chartSetting === PerformanceWidgetSetting.SLOW_DB_OPS ? 'db' : 'http'
-          }
-        />
-      )
-    : WidgetEmptyStateWarning;
+
+  let emptyComponent;
+  if (props.chartSetting === PerformanceWidgetSetting.MOST_TIME_SPENT_DB_QUERIES) {
+    emptyComponent = TimeSpentInDatabaseWidgetEmptyStateWarning;
+  } else {
+    emptyComponent = canHaveIntegrationEmptyState
+      ? () => (
+          <WidgetAddInstrumentationWarning
+            type={
+              props.chartSetting === PerformanceWidgetSetting.SLOW_DB_OPS ? 'db' : 'http'
+            }
+          />
+        )
+      : WidgetEmptyStateWarning;
+  }
 
   const field = props.fields[0];
 

+ 35 - 0
static/app/views/starfish/queries/useHasAnySpanMetrics.tsx

@@ -0,0 +1,35 @@
+import EventView from 'sentry/utils/discover/eventView';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {useSpansQuery} from 'sentry/views/starfish/utils/useSpansQuery';
+
+interface Options {
+  enabled?: boolean;
+  projectId?: string[];
+}
+
+export const useHasAnySpanMetrics = ({projectId, enabled}: Options) => {
+  const eventView = EventView.fromSavedQuery({
+    name: 'Has Any Span Metrics',
+    query: '',
+    fields: ['count()'],
+    projects: projectId && projectId.map(id => parseInt(id, 10)),
+    dataset: DiscoverDatasets.SPANS_METRICS,
+    version: 2,
+  });
+
+  eventView.statsPeriod = SAMPLE_STATS_PERIOD;
+
+  const result = useSpansQuery({
+    eventView,
+    initialData: true,
+    enabled,
+    referrer: 'span-metrics',
+  });
+
+  return {
+    ...result,
+    hasMetrics: result?.data?.[0]?.count > 0,
+  };
+};
+
+const SAMPLE_STATS_PERIOD = '10d'; // The time period in which to check for any presence of span metrics