Browse Source

Threshold status releases index (#61057)

Adds a thresholds health column to relevant releases that have
thresholds set.

Filters thresholds by environment if listed

highlights threshold health in `Success`/`Active`/`Error`

<img width="420" alt="Screenshot 2023-12-04 at 4 26 55 PM"
src="https://github.com/getsentry/sentry/assets/6186377/42a838ad-f73f-4f1a-9f7d-73732652d20b">
Nathan Hsieh 1 year ago
parent
commit
5a1cdbd690

+ 63 - 3
static/app/views/releases/list/index.tsx

@@ -5,6 +5,7 @@ import styled from '@emotion/styled';
 import pick from 'lodash/pick';
 
 import {fetchTagValues} from 'sentry/actionCreators/tags';
+import {Client} from 'sentry/api';
 import {Alert} from 'sentry/components/alert';
 import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import EmptyMessage from 'sentry/components/emptyMessage';
@@ -42,6 +43,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
 import {SEMVER_TAGS} from 'sentry/utils/discover/fields';
 import Projects from 'sentry/utils/projects';
 import routeTitleGen from 'sentry/utils/routeTitle';
+import withApi from 'sentry/utils/withApi';
 import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 import withProjects from 'sentry/utils/withProjects';
@@ -51,6 +53,8 @@ import Header from '../components/header';
 import ReleaseFeedbackBanner from '../components/releaseFeedbackBanner';
 import ReleaseArchivedNotice from '../detail/overview/releaseArchivedNotice';
 import {isMobileRelease} from '../utils';
+import {fetchThresholdStatuses} from '../utils/fetchThresholdStatus';
+import {ThresholdStatus, ThresholdStatusesQuery} from '../utils/types';
 
 import ReleaseCard from './releaseCard';
 import ReleasesAdoptionChart from './releasesAdoptionChart';
@@ -65,6 +69,7 @@ type RouteParams = {
 };
 
 type Props = RouteComponentProps<RouteParams, {}> & {
+  api: Client;
   organization: Organization;
   projects: Project[];
   selection: PageFilters;
@@ -72,6 +77,7 @@ type Props = RouteComponentProps<RouteParams, {}> & {
 
 type State = {
   releases: Release[];
+  thresholdStatuses?: {[key: string]: ThresholdStatus[]};
 } & DeprecatedAsyncView['state'];
 
 class ReleasesList extends DeprecatedAsyncView<Props, State> {
@@ -129,9 +135,48 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
        * uses shouldReload=true and there is no reloading happening.
        */
       forceCheck();
+      if (this.hasV2ReleaseUIEnabled) {
+        // Refetch new threshold statuses if  new releases are fetched
+        this.fetchThresholdStatuses();
+      }
     }
   }
 
+  fetchThresholdStatuses() {
+    const {selection, organization, api} = this.props;
+    const {releases} = this.state;
+
+    // Grab earliest release and latest release - then fetch all statuses within
+    let start = releases[0].dateCreated;
+    let end = releases[0].dateCreated;
+    const releaseVersions: string[] = [];
+    releases.forEach(release => {
+      if (release.dateCreated < start) {
+        start = release.dateCreated;
+      }
+      if (release.dateCreated > end) {
+        end = release.dateCreated;
+      }
+      releaseVersions.push(release.version);
+    });
+
+    const query: ThresholdStatusesQuery = {
+      start,
+      end,
+      release: releaseVersions,
+    };
+    if (selection.projects.length) {
+      query.project = this.getSelectedProjectSlugs();
+    }
+    if (selection.environments.length) {
+      query.environment = selection.environments;
+    }
+
+    fetchThresholdStatuses(organization, api, query).then(thresholdStatuses => {
+      this.setState({thresholdStatuses});
+    });
+  }
+
   getQuery() {
     const {query} = this.props.location.query;
 
@@ -185,6 +230,18 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
     return projects?.find(p => p.id === `${selectedProjectId}`);
   }
 
+  getSelectedProjectSlugs(): string[] {
+    const {selection, projects} = this.props;
+    const projIdSet = new Set(selection.projects);
+
+    return projects.reduce((result: string[], proj) => {
+      if (projIdSet.has(Number(proj.id))) {
+        result.push(proj.slug);
+      }
+      return result;
+    }, []);
+  }
+
   get projectHasSessions() {
     return this.getSelectedProject()?.hasSessions ?? null;
   }
@@ -437,7 +494,7 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
     showReleaseAdoptionStages: boolean
   ) {
     const {location, selection, organization, router} = this.props;
-    const {releases, reloading, releasesPageLinks} = this.state;
+    const {releases, reloading, releasesPageLinks, thresholdStatuses} = this.state;
 
     const selectedProject = this.getSelectedProject();
     const hasReleasesSetup = selectedProject?.features.includes('releases');
@@ -468,6 +525,8 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
           const singleProjectSelected =
             selection.projects?.length === 1 &&
             selection.projects[0] !== ALL_ACCESS_PROJECTS;
+
+          // TODO: project specific chart should live on the project details page.
           const isMobileProject =
             selectedProject?.platform && isMobileRelease(selectedProject.platform);
 
@@ -485,7 +544,7 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
 
               {releases.map((release, index) => (
                 <ReleaseCard
-                  key={`${release.version}-${release.projects[0].slug}`}
+                  key={`${release.projects[0].slug}-${release.version}`}
                   activeDisplay={activeDisplay}
                   release={release}
                   organization={organization}
@@ -496,6 +555,7 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
                   isTopRelease={index === 0}
                   getHealthData={getHealthData}
                   showReleaseAdoptionStages={showReleaseAdoptionStages}
+                  thresholdStatuses={thresholdStatuses || {}}
                 />
               ))}
               <Pagination pageLinks={releasesPageLinks} />
@@ -652,4 +712,4 @@ const StyledSmartSearchBar = styled(SmartSearchBar)`
   }
 `;
 
-export default withProjects(withOrganization(withPageFilters(ReleasesList)));
+export default withApi(withProjects(withOrganization(withPageFilters(ReleasesList))));

+ 73 - 38
static/app/views/releases/list/releaseCard/index.tsx

@@ -18,6 +18,7 @@ import {t, tct, tn} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Organization, PageFilters, Release} from 'sentry/types';
 
+import {ThresholdStatus} from '../../utils/types';
 import {ReleasesDisplayOption} from '../releasesDisplayOptions';
 import {ReleasesRequestRenderProps} from '../releasesRequest';
 
@@ -54,9 +55,9 @@ type Props = {
   selection: PageFilters;
   showHealthPlaceholders: boolean;
   showReleaseAdoptionStages: boolean;
+  thresholdStatuses: {[key: string]: ThresholdStatus[]};
 };
 
-// TODO: change to functional component
 function ReleaseCard({
   release,
   organization,
@@ -68,25 +69,41 @@ function ReleaseCard({
   isTopRelease,
   getHealthData,
   showReleaseAdoptionStages,
+  thresholdStatuses,
 }: Props) {
-  const {version, commitCount, lastDeploy, dateCreated, versionInfo} = release;
-
-  // TODO: fetch release threshold status
-  // Query for every release card??
-  // Query a full batch and match via card?
+  const {
+    version,
+    commitCount,
+    lastDeploy,
+    dateCreated,
+    versionInfo,
+    adoptionStages,
+    projects,
+  } = release;
 
   const [projectsToShow, projectsToHide] = useMemo(() => {
     // sort health rows inside release card alphabetically by project name,
     // show only the ones that are selected in global header
     return partition(
-      release.projects.sort((a, b) => a.slug.localeCompare(b.slug)),
+      projects.sort((a, b) => a.slug.localeCompare(b.slug)),
       p =>
         // do not filter for My Projects & All Projects
         selection.projects.length > 0 && !selection.projects.includes(-1)
           ? selection.projects.includes(p.id)
           : true
     );
-  }, [release, selection.projects]);
+  }, [projects, selection.projects]);
+
+  const hasThresholds = useMemo(() => {
+    const project_slugs = projects.map(proj => proj.slug);
+    let has = false;
+    project_slugs.forEach(slug => {
+      if (`${slug}-${version}` in thresholdStatuses) {
+        has = thresholdStatuses[`${slug}-${version}`].length > 0;
+      }
+    });
+    return has;
+  }, [thresholdStatuses, version, projects]);
 
   const getHiddenProjectsTooltip = () => {
     const limitedProjects = projectsToHide.map(p => p.slug).slice(0, 5);
@@ -102,6 +119,7 @@ function ReleaseCard({
   return (
     <StyledPanel reloading={reloading ? 1 : 0} data-test-id="release-panel">
       <ReleaseInfo>
+        {/* Header/info is the table sidecard */}
         <ReleaseInfoHeader>
           <GlobalSelectionLink
             to={{
@@ -136,8 +154,12 @@ function ReleaseCard({
       </ReleaseInfo>
 
       <ReleaseProjects>
+        {/* projects is the table */}
         <ReleaseProjectsHeader lightText>
-          <ReleaseProjectsLayout showReleaseAdoptionStages={showReleaseAdoptionStages}>
+          <ReleaseProjectsLayout
+            showReleaseAdoptionStages={showReleaseAdoptionStages}
+            hasThresholds={hasThresholds}
+          >
             <ReleaseProjectColumn>{t('Project Name')}</ReleaseProjectColumn>
             {showReleaseAdoptionStages && (
               <AdoptionStageColumn>{t('Adoption Stage')}</AdoptionStageColumn>
@@ -147,8 +169,9 @@ function ReleaseCard({
               <ReleaseCardStatsPeriod location={location} />
             </AdoptionColumn>
             <CrashFreeRateColumn>{t('Crash Free Rate')}</CrashFreeRateColumn>
-            <CrashesColumn>{t('Crashes')}</CrashesColumn>
+            <DisplaySmallCol>{t('Crashes')}</DisplaySmallCol>
             <NewIssuesColumn>{t('New Issues')}</NewIssuesColumn>
+            {hasThresholds && <DisplaySmallCol>{t('Thresholds')}</DisplaySmallCol>}
           </ReleaseProjectsLayout>
         </ReleaseProjectsHeader>
 
@@ -169,22 +192,28 @@ function ReleaseCard({
               </CollapseButtonWrapper>
             )}
           >
-            {projectsToShow.map((project, index) => (
-              <ReleaseCardProjectRow
-                key={`${release.version}-${project.slug}-row`}
-                index={index}
-                organization={organization}
-                project={project}
-                location={location}
-                getHealthData={getHealthData}
-                releaseVersion={release.version}
-                activeDisplay={activeDisplay}
-                showPlaceholders={showHealthPlaceholders}
-                showReleaseAdoptionStages={showReleaseAdoptionStages}
-                isTopRelease={isTopRelease}
-                adoptionStages={release.adoptionStages}
-              />
-            ))}
+            {projectsToShow.map((project, index) => {
+              const key = `${project.slug}-${version}`;
+              return (
+                <ReleaseCardProjectRow
+                  key={`${key}-row`}
+                  activeDisplay={activeDisplay}
+                  adoptionStages={adoptionStages}
+                  getHealthData={getHealthData}
+                  hasThresholds={hasThresholds}
+                  index={index}
+                  isTopRelease={isTopRelease}
+                  location={location}
+                  organization={organization}
+                  project={project}
+                  releaseVersion={version}
+                  lastDeploy={lastDeploy}
+                  showPlaceholders={showHealthPlaceholders}
+                  showReleaseAdoptionStages={showReleaseAdoptionStages}
+                  thresholdStatuses={hasThresholds ? thresholdStatuses[`${key}`] : []}
+                />
+              );
+            })}
           </Collapsible>
         </ProjectRows>
 
@@ -305,7 +334,10 @@ const CollapseButtonWrapper = styled('div')`
   height: 41px;
 `;
 
-export const ReleaseProjectsLayout = styled('div')<{showReleaseAdoptionStages?: boolean}>`
+export const ReleaseProjectsLayout = styled('div')<{
+  hasThresholds?: boolean;
+  showReleaseAdoptionStages?: boolean;
+}>`
   display: grid;
   grid-template-columns: 1fr 1.4fr 0.6fr 0.7fr;
 
@@ -314,22 +346,25 @@ export const ReleaseProjectsLayout = styled('div')<{showReleaseAdoptionStages?:
   width: 100%;
 
   @media (min-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr 0.5fr;
+    ${p => {
+      const thresholdSize = p.hasThresholds ? '0.5fr' : '';
+      return `grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr ${thresholdSize} 0.5fr`;
+    }}
   }
 
   @media (min-width: ${p => p.theme.breakpoints.medium}) {
-    grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr 0.5fr;
+    ${p => {
+      const thresholdSize = p.hasThresholds ? '0.5fr' : '';
+      return `grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr ${thresholdSize} 0.5fr`;
+    }}
   }
 
   @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
-    ${p =>
-      p.showReleaseAdoptionStages
-        ? `
-      grid-template-columns: 1fr 0.7fr 1fr 1fr 0.7fr 0.7fr 0.5fr;
-    `
-        : `
-      grid-template-columns: 1fr 1fr 1fr 0.7fr 0.7fr 0.5fr;
-    `}
+    ${p => {
+      const adoptionStagesSize = p.showReleaseAdoptionStages ? '0.7fr' : '';
+      const thresholdSize = p.hasThresholds ? '0.7fr' : '';
+      return `grid-template-columns: 1fr ${adoptionStagesSize} 1fr 1fr 0.7fr 0.7fr ${thresholdSize} 0.5fr`;
+    }}
   }
 `;
 
@@ -385,7 +420,7 @@ export const CrashFreeRateColumn = styled(ReleaseProjectColumn)`
   }
 `;
 
-export const CrashesColumn = styled(ReleaseProjectColumn)`
+export const DisplaySmallCol = styled(ReleaseProjectColumn)`
   display: none;
   font-variant-numeric: tabular-nums;
 

+ 86 - 11
static/app/views/releases/list/releaseCard/releaseCardProjectRow.tsx

@@ -1,3 +1,4 @@
+import {useMemo} from 'react';
 import LazyLoad from 'react-lazyload';
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
@@ -19,7 +20,7 @@ import {Tooltip} from 'sentry/components/tooltip';
 import {IconCheckmark, IconFire, IconWarning} from 'sentry/icons';
 import {t, tn} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {Organization, Release, ReleaseProject} from 'sentry/types';
+import {Deploy, Organization, Release, ReleaseProject} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import type {IconSize} from 'sentry/utils/theme';
 
@@ -30,14 +31,15 @@ import {
   getReleaseUnhandledIssuesUrl,
   isMobileRelease,
 } from '../../utils';
+import {ThresholdStatus} from '../../utils/types';
 import {ReleasesDisplayOption} from '../releasesDisplayOptions';
 import {ReleasesRequestRenderProps} from '../releasesRequest';
 
 import {
   AdoptionColumn,
   AdoptionStageColumn,
-  CrashesColumn,
   CrashFreeRateColumn,
+  DisplaySmallCol,
   NewIssuesColumn,
   ReleaseProjectColumn,
   ReleaseProjectsLayout,
@@ -61,6 +63,7 @@ function getCrashFreeIcon(crashFreePercent: number, iconSize: IconSize = 'sm') {
 type Props = {
   activeDisplay: ReleasesDisplayOption;
   getHealthData: ReleasesRequestRenderProps['getHealthData'];
+  hasThresholds: boolean;
   index: number;
   isTopRelease: boolean;
   location: Location;
@@ -69,21 +72,26 @@ type Props = {
   releaseVersion: string;
   showPlaceholders: boolean;
   showReleaseAdoptionStages: boolean;
+  thresholdStatuses: ThresholdStatus[];
   adoptionStages?: Release['adoptionStages'];
+  lastDeploy?: Deploy | undefined;
 };
 
 function ReleaseCardProjectRow({
+  activeDisplay,
+  adoptionStages,
+  getHealthData,
+  hasThresholds,
   index,
-  project,
-  organization,
+  isTopRelease,
+  lastDeploy,
   location,
-  getHealthData,
+  organization,
+  project,
   releaseVersion,
-  activeDisplay,
   showPlaceholders,
   showReleaseAdoptionStages,
-  isTopRelease,
-  adoptionStages,
+  thresholdStatuses,
 }: Props) {
   const theme = useTheme();
   const {id, newGroups} = project;
@@ -93,6 +101,23 @@ function ReleaseCardProjectRow({
     id,
     ReleasesDisplayOption.SESSIONS
   );
+
+  const thresholds = useMemo(() => {
+    return (
+      thresholdStatuses?.filter(status => {
+        return status.environment?.name === lastDeploy?.environment;
+      }) || []
+    );
+  }, [thresholdStatuses, lastDeploy]);
+
+  const healthyThresholds = thresholds.filter(status => {
+    return status.is_healthy;
+  });
+
+  const pendingThresholds = thresholds.filter(status => {
+    return new Date(status.end || '') > new Date();
+  });
+
   const crashFreeRate = getHealthData.getCrashFreeRate(releaseVersion, id, activeDisplay);
   const get24hCountByProject = getHealthData.get24hCountByProject(id, activeDisplay);
   const timeSeries = getHealthData.getTimeSeries(releaseVersion, id, activeDisplay);
@@ -110,7 +135,10 @@ function ReleaseCardProjectRow({
 
   return (
     <ProjectRow data-test-id="release-card-project-row">
-      <ReleaseProjectsLayout showReleaseAdoptionStages={showReleaseAdoptionStages}>
+      <ReleaseProjectsLayout
+        showReleaseAdoptionStages={showReleaseAdoptionStages}
+        hasThresholds={hasThresholds}
+      >
         <ReleaseProjectColumn>
           <ProjectBadge project={project} avatarSize={16} />
         </ReleaseProjectColumn>
@@ -178,7 +206,7 @@ function ReleaseCardProjectRow({
           )}
         </CrashFreeRateColumn>
 
-        <CrashesColumn>
+        <DisplaySmallCol>
           {showPlaceholders ? (
             <StyledPlaceholder width="30px" />
           ) : defined(crashCount) ? (
@@ -196,7 +224,7 @@ function ReleaseCardProjectRow({
           ) : (
             <NotAvailable />
           )}
-        </CrashesColumn>
+        </DisplaySmallCol>
 
         <NewIssuesColumn>
           <Tooltip title={t('Open in Issues')}>
@@ -208,6 +236,38 @@ function ReleaseCardProjectRow({
           </Tooltip>
         </NewIssuesColumn>
 
+        {hasThresholds && (
+          <DisplaySmallCol>
+            {/* TODO: link to release details page */}
+            {thresholds && thresholds.length > 0 && (
+              <Tooltip
+                title={
+                  <div>
+                    <div>
+                      {`${healthyThresholds.length} / ${thresholds.length} ` +
+                        t('thresholds succeeded')}
+                    </div>
+                    {pendingThresholds.length > 0 && (
+                      <div>
+                        {`${pendingThresholds.length} / ${thresholds.length} ` +
+                          t('still pending')}
+                      </div>
+                    )}
+                    {t('Open in Release Details')}
+                  </div>
+                }
+              >
+                <ThresholdHealth
+                  allHealthy={healthyThresholds.length === thresholds.length}
+                  allThresholdsFinished={pendingThresholds.length === 0}
+                >
+                  {healthyThresholds.length} / {thresholds && thresholds.length}
+                </ThresholdHealth>
+              </Tooltip>
+            )}
+          </DisplaySmallCol>
+        )}
+
         <ViewColumn>
           <GuideAnchor disabled={!isTopRelease || index !== 0} target="view_release">
             <Button
@@ -272,3 +332,18 @@ const ViewColumn = styled('div')`
   line-height: 20px;
   text-align: right;
 `;
+
+const ThresholdHealth = styled('div')<{
+  allHealthy?: boolean;
+  allThresholdsFinished?: boolean;
+}>`
+  color: ${p => {
+    if (!p.allHealthy) {
+      return p.theme.errorText;
+    }
+    if (p.allThresholdsFinished) {
+      return p.theme.successText;
+    }
+    return p.theme.activeText;
+  }};
+`;

+ 18 - 0
static/app/views/releases/utils/fetchThresholdStatus.tsx

@@ -0,0 +1,18 @@
+import {Client} from 'sentry/api';
+import {Organization} from 'sentry/types';
+
+import {ThresholdStatus, ThresholdStatusesQuery} from './types';
+
+export function fetchThresholdStatuses(
+  organization: Organization,
+  api: Client,
+  query: ThresholdStatusesQuery
+): Promise<{[key: string]: ThresholdStatus[]}> {
+  return api.requestPromise(
+    `/organizations/${organization.slug}/release-threshold-statuses/`,
+    {
+      method: 'GET',
+      query,
+    }
+  );
+}

+ 14 - 2
static/app/views/releases/utils/types.tsx

@@ -3,10 +3,20 @@ import moment from 'moment';
 import {Environment, Project} from 'sentry/types';
 
 export type ThresholdQuery = {
-  environment?: string[] | undefined;
-  project?: number[] | undefined;
+  environment?: string[] | undefined; // list of environment names
+  project?: number[] | undefined; // list of project ids
 };
 
+export type ThresholdStatusesQuery = Omit<ThresholdQuery, 'project'> & {
+  end: string;
+  release: string[]; // list of release versions
+  start: string;
+  project?: string[]; // list of project slugs
+};
+
+export type ThresholdStatus = Threshold & {
+  is_healthy: boolean;
+};
 export type Threshold = {
   environment: Environment;
   id: string;
@@ -16,6 +26,8 @@ export type Threshold = {
   value: number;
   window_in_seconds: number;
   date_added?: string;
+  end?: string;
+  start?: string;
 };
 
 export type EditingThreshold = Omit<Threshold, 'environment' | 'window_in_seconds'> & {