Browse Source

feat(statistical-detectors): List example profiles in function regres… (#57529)

…sion issue

This renders a list of example profiles from the before and after period
defined by the breakpoint to allow users to navigate to example profiles
that contain the regressed function.

# Screenshot


![image](https://github.com/getsentry/sentry/assets/10239353/a39798f0-d0be-4352-b228-74e2786bc339)
Tony Xiao 1 year ago
parent
commit
1feb61bba0

+ 299 - 0
static/app/components/events/eventStatisticalDetector/eventFunctionComparisonList.tsx

@@ -0,0 +1,299 @@
+import {Fragment, useEffect, useMemo} from 'react';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+
+import DateTime from 'sentry/components/dateTime';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import Link from 'sentry/components/links/link';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Event, Group, Organization, Project} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {getShortEventId} from 'sentry/utils/events';
+import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents';
+import {useProfileFunctions} from 'sentry/utils/profiling/hooks/useProfileFunctions';
+import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface EventFunctionComparisonListProps {
+  event: Event;
+  group: Group;
+  project: Project;
+}
+
+export function EventFunctionComparisonList({
+  event,
+  project,
+}: EventFunctionComparisonListProps) {
+  const evidenceData = event.occurrence?.evidenceData;
+  const fingerprint = evidenceData?.fingerprint;
+  const breakpoint = evidenceData?.breakpoint;
+  const frameName = evidenceData?.function;
+  const framePackage = evidenceData?.package || evidenceData?.module;
+
+  const isValid =
+    defined(fingerprint) &&
+    defined(breakpoint) &&
+    defined(frameName) &&
+    defined(framePackage);
+
+  useEffect(() => {
+    if (isValid) {
+      return;
+    }
+
+    Sentry.withScope(scope => {
+      scope.setContext('evidence data fields', {
+        fingerprint,
+        breakpoint,
+        frameName,
+        framePackage,
+      });
+
+      Sentry.captureException(
+        new Error('Missing required evidence data on function regression issue.')
+      );
+    });
+  }, [isValid, fingerprint, breakpoint, frameName, framePackage]);
+
+  if (!isValid) {
+    return null;
+  }
+
+  return (
+    <EventComparisonListInner
+      breakpoint={breakpoint}
+      fingerprint={fingerprint}
+      frameName={frameName}
+      framePackage={framePackage}
+      project={project}
+    />
+  );
+}
+
+interface EventComparisonListInnerProps {
+  breakpoint: number;
+  fingerprint: number;
+  frameName: string;
+  framePackage: string;
+  project: Project;
+}
+
+const DAY = 24 * 60 * 60 * 1000;
+
+function EventComparisonListInner({
+  breakpoint,
+  fingerprint,
+  frameName,
+  framePackage,
+  project,
+}: EventComparisonListInnerProps) {
+  const organization = useOrganization();
+  const maxDateTime = Date.now();
+  const minDateTime = maxDateTime - 90 * DAY;
+
+  const breakpointTime = breakpoint * 1000;
+  const breakpointDateTime = new Date(breakpointTime);
+
+  const beforeTime = breakpointTime - DAY;
+  const beforeDateTime =
+    beforeTime >= minDateTime ? new Date(beforeTime) : new Date(minDateTime);
+
+  const afterTime = breakpointTime + DAY;
+  const afterDateTime =
+    afterTime <= maxDateTime ? new Date(afterTime) : new Date(maxDateTime);
+
+  const beforeProfilesQuery = useProfileFunctions({
+    datetime: {
+      start: beforeDateTime,
+      end: breakpointDateTime,
+      utc: true,
+      period: null,
+    },
+    fields: ['examples()'],
+    sort: {
+      key: 'examples()',
+      order: 'asc',
+    },
+    query: `fingerprint:${fingerprint}`,
+    projects: [project.id],
+    limit: 1,
+    referrer: 'api.profiling.functions.regression.list',
+  });
+
+  const afterProfilesQuery = useProfileFunctions({
+    datetime: {
+      start: breakpointDateTime,
+      end: afterDateTime,
+      utc: true,
+      period: null,
+    },
+    fields: ['examples()'],
+    sort: {
+      key: 'examples()',
+      order: 'asc',
+    },
+    query: `fingerprint:${fingerprint}`,
+    projects: [project.id],
+    limit: 1,
+    referrer: 'api.profiling.functions.regression.list',
+  });
+
+  const beforeProfileIds =
+    (beforeProfilesQuery.data?.data?.[0]?.['examples()'] as string[]) ?? [];
+  const afterProfileIds =
+    (afterProfilesQuery.data?.data?.[0]?.['examples()'] as string[]) ?? [];
+
+  const profilesQuery = useProfileEvents({
+    fields: ['id', 'transaction', 'timestamp', 'profile.duration'],
+    query: `id:[${[...beforeProfileIds, ...afterProfileIds].join(', ')}]`,
+    sort: {
+      key: 'profile.duration',
+      order: 'desc',
+    },
+    projects: [project.id],
+    limit: beforeProfileIds.length + afterProfileIds.length,
+    enabled: beforeProfileIds.length > 0 && afterProfileIds.length > 0,
+    referrer: 'api.profiling.functions.regression.examples',
+  });
+
+  const beforeProfiles = useMemo(() => {
+    const profileIds = new Set(
+      (beforeProfilesQuery.data?.data?.[0]?.['examples()'] as string[]) ?? []
+    );
+
+    return (
+      (profilesQuery.data?.data?.filter(row =>
+        profileIds.has(row.id as string)
+      ) as ProfileItem[]) ?? []
+    );
+  }, [beforeProfilesQuery, profilesQuery]);
+
+  const afterProfiles = useMemo(() => {
+    const profileIds = new Set(
+      (afterProfilesQuery.data?.data?.[0]?.['examples()'] as string[]) ?? []
+    );
+
+    return (
+      (profilesQuery.data?.data?.filter(row =>
+        profileIds.has(row.id as string)
+      ) as ProfileItem[]) ?? []
+    );
+  }, [afterProfilesQuery, profilesQuery]);
+
+  return (
+    <Wrapper>
+      <EventDataSection type="profiles-before" title={t('Example Profiles Before')}>
+        <EventList
+          frameName={frameName}
+          framePackage={framePackage}
+          organization={organization}
+          profiles={beforeProfiles}
+          project={project}
+        />
+      </EventDataSection>
+      <EventDataSection type="profiles-after" title={t('Example Profiles After')}>
+        <EventList
+          frameName={frameName}
+          framePackage={framePackage}
+          organization={organization}
+          profiles={afterProfiles}
+          project={project}
+        />
+      </EventDataSection>
+    </Wrapper>
+  );
+}
+
+interface ProfileItem {
+  id: string;
+  'profile.duration': number;
+  timestamp: string;
+  transaction: string;
+}
+
+interface EventListProps {
+  frameName: string;
+  framePackage: string;
+  organization: Organization;
+  profiles: ProfileItem[];
+  project: Project;
+}
+
+function EventList({
+  frameName,
+  framePackage,
+  organization,
+  profiles,
+  project,
+}: EventListProps) {
+  return (
+    <ListContainer>
+      <Container>
+        <strong>{t('Profile ID')}</strong>
+      </Container>
+      <Container>
+        <strong>{t('Timestamp')}</strong>
+      </Container>
+      <NumberContainer>
+        <strong>{t('Duration')}</strong>
+      </NumberContainer>
+      {profiles.map(item => {
+        const target = generateProfileFlamechartRouteWithQuery({
+          orgSlug: organization.slug,
+          projectSlug: project.slug,
+          profileId: item.id,
+          query: {
+            frameName,
+            framePackage,
+          },
+        });
+
+        return (
+          <Fragment key={item.id}>
+            <Container>
+              <Tooltip title={item.transaction} position="top">
+                <Link
+                  to={target}
+                  onClick={() => {
+                    trackAnalytics('profiling_views.go_to_flamegraph', {
+                      organization,
+                      source: 'profiling.issue.function_regression',
+                    });
+                  }}
+                >
+                  {getShortEventId(item.id)}
+                </Link>
+              </Tooltip>
+            </Container>
+            <Container>
+              <DateTime date={item.timestamp} year seconds timeZone />
+            </Container>
+            <NumberContainer>
+              <PerformanceDuration nanoseconds={item['profile.duration']} abbreviation />
+            </NumberContainer>
+          </Fragment>
+        );
+      })}
+    </ListContainer>
+  );
+}
+
+const Wrapper = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr;
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    grid-template-columns: 1fr 1fr;
+  }
+`;
+
+const ListContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 2fr 1fr;
+  gap: ${space(1)};
+`;

+ 1 - 0
static/app/utils/analytics/profilingAnalyticsEvents.tsx

@@ -11,6 +11,7 @@ type ProfilingEventSource =
   | 'profiling.function_trends.improvement'
   | 'profiling.function_trends.regression'
   | 'profiling.global_suspect_functions'
+  | 'profiling.issue.function_regression'
   | 'profiling_transaction.suspect_functions_table'
   | 'slowest_transaction_panel'
   | 'transaction_details'

+ 5 - 0
static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

@@ -19,6 +19,7 @@ import AggregateSpanDiff from 'sentry/components/events/eventStatisticalDetector
 import EventSpanOpBreakdown from 'sentry/components/events/eventStatisticalDetector/aggregateSpanOps/spanOpBreakdown';
 import EventBreakpointChart from 'sentry/components/events/eventStatisticalDetector/breakpointChart';
 import EventComparison from 'sentry/components/events/eventStatisticalDetector/eventComparison';
+import {EventFunctionComparisonList} from 'sentry/components/events/eventStatisticalDetector/eventFunctionComparisonList';
 import RegressionMessage from 'sentry/components/events/eventStatisticalDetector/regressionMessage';
 import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
 import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
@@ -217,6 +218,7 @@ function PerformanceDurationRegressionIssueDetailsContent({
 function ProfilingDurationRegressionIssueDetailsContent({
   group,
   event,
+  project,
 }: Required<GroupEventDetailsContentProps>) {
   const organization = useOrganization();
 
@@ -228,6 +230,9 @@ function ProfilingDurationRegressionIssueDetailsContent({
     >
       <Fragment>
         <RegressionMessage event={event} group={group} />
+        <ErrorBoundary mini>
+          <EventFunctionComparisonList event={event} group={group} project={project} />
+        </ErrorBoundary>
       </Fragment>
     </Feature>
   );