Browse Source

feat(stat-detectors): Add minimaps for event comparison (#55517)

Add a section below the regression chart that lets users compare events
before/after the breakpoint.

The approach taken here is to use the breakpoint to query from the
`requestStart` to the `breakpoint`, then make a discover request to get
up to 5 transactions that have similar durations to the baseline
duration. Currently I went with 70% -> 130% to ensure there are usually
samples, we can tweak this later.
Nar Saynorath 1 year ago
parent
commit
740deb7240

+ 5 - 16
static/app/components/events/eventStatisticalDetector/breakpointChart.tsx

@@ -24,24 +24,13 @@ function EventBreakpointChart({event}: EventBreakpointChartProps) {
   const organization = useOrganization();
   const location = useLocation();
 
+  const {transaction, requestStart, requestEnd} = event?.occurrence?.evidenceData ?? {};
+
   const eventView = EventView.fromLocation(location);
-  eventView.query = `event.type:transaction transaction:"${event?.occurrence?.evidenceData?.transaction}"`;
+  eventView.query = `event.type:transaction transaction:"${transaction}"`;
   eventView.fields = [{field: 'transaction'}, {field: 'project'}];
-
-  // Set the start and end time to 7 days before and after the breakpoint
-  // TODO: This should be removed when the endpoint begins returning the start and end
-  // explicitly
-  if (event?.occurrence) {
-    eventView.statsPeriod = undefined;
-    const detectionTime = new Date(event?.occurrence?.evidenceData?.breakpoint * 1000);
-    const start = new Date(detectionTime).setDate(detectionTime.getDate() - 7);
-    const end = new Date(detectionTime).setDate(detectionTime.getDate() + 7);
-
-    eventView.start = new Date(start).toISOString();
-    eventView.end = new Date(Math.min(end, Date.now())).toISOString();
-  } else {
-    eventView.statsPeriod = '14d';
-  }
+  eventView.start = new Date(requestStart * 1000).toISOString();
+  eventView.end = new Date(requestEnd * 1000).toISOString();
 
   // The evidence data keys are returned to us in camelCase, but we need to
   // convert them to snake_case to match the NormalizedTrendsTransaction type

+ 226 - 0
static/app/components/events/eventStatisticalDetector/eventComparison/eventDisplay.tsx

@@ -0,0 +1,226 @@
+import {useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {CompactSelect} from 'sentry/components/compactSelect';
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import {MINIMAP_HEIGHT} from 'sentry/components/events/interfaces/spans/constants';
+import {ActualMinimap} from 'sentry/components/events/interfaces/spans/header';
+import WaterfallModel from 'sentry/components/events/interfaces/spans/waterfallModel';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import TextOverflow from 'sentry/components/textOverflow';
+import {IconEllipsis, IconJson, IconLink} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {EventTransaction, Project} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {getShortEventId} from 'sentry/utils/events';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+// A hook for getting "sample events" for a transaction
+// In its current state it will just fetch at most 5 events that match the
+// transaction name within a range of the duration baseline provided
+function useFetchSampleEvents({
+  start,
+  end,
+  transaction,
+  durationBaseline,
+  projectId,
+}: {
+  durationBaseline: number;
+  end: number;
+  projectId: number;
+  start: number;
+  transaction: string;
+}) {
+  const location = useLocation();
+  const organization = useOrganization();
+  const eventView = new EventView({
+    dataset: DiscoverDatasets.DISCOVER,
+    start: new Date(start * 1000).toISOString(),
+    end: new Date(end * 1000).toISOString(),
+    fields: [{field: 'id'}],
+    query: `event.type:transaction transaction:"${transaction}" transaction.duration:>=${
+      durationBaseline * 0.7
+    }ms transaction.duration:<=${durationBaseline * 1.3}ms`,
+
+    createdBy: undefined,
+    display: undefined,
+    id: undefined,
+    environment: [],
+    name: undefined,
+    project: [projectId],
+    sorts: [],
+    statsPeriod: undefined,
+    team: [],
+    topEvents: undefined,
+  });
+
+  return useDiscoverQuery({
+    eventView,
+    location,
+    orgSlug: organization.slug,
+    limit: 5,
+  });
+}
+
+type EventDisplayProps = {
+  durationBaseline: number;
+  end: number;
+  eventSelectLabel: string;
+  project: Project;
+  start: number;
+  transaction: string;
+};
+
+function EventDisplay({
+  eventSelectLabel,
+  project,
+  start,
+  end,
+  transaction,
+  durationBaseline,
+}: EventDisplayProps) {
+  const organization = useOrganization();
+  const [selectedEventId, setSelectedEventId] = useState<string>('');
+
+  const {data, isLoading, isError} = useFetchSampleEvents({
+    start,
+    end,
+    transaction,
+    durationBaseline,
+    projectId: parseInt(project.id, 10),
+  });
+
+  const eventIds = data?.data.map(({id}) => id);
+
+  const {data: eventData, isFetching} = useApiQuery<EventTransaction>(
+    [`/organizations/${organization.slug}/events/${project.slug}:${selectedEventId}/`],
+    {staleTime: Infinity, retry: false, enabled: !!selectedEventId && !!project.slug}
+  );
+
+  useEffect(() => {
+    if (defined(eventIds) && eventIds.length > 0 && !selectedEventId) {
+      setSelectedEventId(eventIds[0]);
+    }
+  }, [eventIds, selectedEventId]);
+
+  if (isError) {
+    return null;
+  }
+
+  if (isLoading || isFetching) {
+    return <LoadingIndicator />;
+  }
+
+  if (!defined(eventData) || !defined(eventIds)) {
+    return (
+      <EmptyStateWrapper>
+        <EmptyStateWarning withIcon>
+          <div>{t('Unable to find a sample event')}</div>
+        </EmptyStateWarning>
+      </EmptyStateWrapper>
+    );
+  }
+
+  const waterfallModel = new WaterfallModel(eventData);
+
+  return (
+    <div>
+      <StyledEventSelectorControlBar>
+        <CompactSelect
+          size="sm"
+          disabled={false}
+          options={eventIds.map(id => ({value: id, label: id}))}
+          value={selectedEventId}
+          onChange={({value}) => setSelectedEventId(value)}
+          triggerLabel={
+            <ButtonLabelWrapper>
+              <TextOverflow>
+                {eventSelectLabel}: {getShortEventId(selectedEventId)}
+              </TextOverflow>
+            </ButtonLabelWrapper>
+          }
+        />
+        <Button aria-label="icon" icon={<IconEllipsis />} size="sm" />
+        <Button aria-label="icon" icon={<IconJson />} size="sm" />
+        <Button aria-label="icon" icon={<IconLink />} size="sm" />
+      </StyledEventSelectorControlBar>
+      <ComparisonContentWrapper>
+        <MinimapContainer>
+          <MinimapPositioningContainer>
+            <ActualMinimap
+              spans={waterfallModel.getWaterfall({
+                viewStart: 0,
+                viewEnd: 1,
+              })}
+              generateBounds={waterfallModel.generateBounds({
+                viewStart: 0,
+                viewEnd: 1,
+              })}
+              dividerPosition={0}
+              rootSpan={waterfallModel.rootSpan.span}
+            />
+          </MinimapPositioningContainer>
+        </MinimapContainer>
+
+        <FakeSpanOpBreakdown>span op breakdown</FakeSpanOpBreakdown>
+      </ComparisonContentWrapper>
+    </div>
+  );
+}
+
+export {EventDisplay};
+
+const ButtonLabelWrapper = styled('span')`
+  width: 100%;
+  text-align: left;
+  align-items: center;
+  display: inline-grid;
+  grid-template-columns: 1fr auto;
+`;
+
+const StyledEventSelectorControlBar = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+`;
+
+const MinimapPositioningContainer = styled('div')`
+  position: absolute;
+  top: 0;
+  width: 100%;
+`;
+
+const MinimapContainer = styled('div')`
+  height: ${MINIMAP_HEIGHT}px;
+  max-height: ${MINIMAP_HEIGHT}px;
+  position: relative;
+`;
+
+const FakeSpanOpBreakdown = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 120px;
+  background: antiquewhite;
+`;
+
+const ComparisonContentWrapper = styled('div')`
+  border: ${({theme}) => `1px solid ${theme.border}`};
+  border-radius: ${({theme}) => theme.borderRadius};
+  overflow: hidden;
+`;
+
+const EmptyStateWrapper = styled('div')`
+  border: ${({theme}) => `1px solid ${theme.border}`};
+  border-radius: ${({theme}) => theme.borderRadius};
+  display: flex;
+  justify-content: center;
+  align-items: center;
+`;

+ 61 - 0
static/app/components/events/eventStatisticalDetector/eventComparison/index.tsx

@@ -0,0 +1,61 @@
+import styled from '@emotion/styled';
+
+import {EventDisplay} from 'sentry/components/events/eventStatisticalDetector/eventComparison/eventDisplay';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Event, Project} from 'sentry/types';
+
+import {DataSection} from '../../styles';
+
+const COMPARISON_DESCRIPTION = t(
+  'To better understand what happened before and after this regression, compare a baseline event with a regressed event. Look for any significant shape changes, operation percentage changes, and tag differences.'
+);
+
+type EventComparisonProps = {
+  event: Event;
+  project: Project;
+};
+
+function EventComparison({event, project}: EventComparisonProps) {
+  const {
+    aggregateRange1,
+    aggregateRange2,
+    requestStart,
+    requestEnd,
+    breakpoint,
+    transaction,
+  } = event?.occurrence?.evidenceData ?? {};
+
+  return (
+    <DataSection>
+      <strong>{t('Compare Events:')}</strong>
+      <p>{COMPARISON_DESCRIPTION}</p>
+      <StyledGrid>
+        <EventDisplay
+          eventSelectLabel={t('Baseline Event ID')}
+          project={project}
+          start={requestStart}
+          end={breakpoint}
+          transaction={transaction}
+          durationBaseline={aggregateRange1}
+        />
+        <EventDisplay
+          eventSelectLabel={t('Regressed Event ID')}
+          project={project}
+          start={breakpoint}
+          end={requestEnd}
+          transaction={transaction}
+          durationBaseline={aggregateRange2}
+        />
+      </StyledGrid>
+    </DataSection>
+  );
+}
+
+export default EventComparison;
+
+const StyledGrid = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: ${space(2)};
+`;

+ 1 - 0
static/app/components/events/interfaces/spans/header.tsx

@@ -976,3 +976,4 @@ const RightSidePane = styled('div')`
 `;
 
 export default TraceViewHeader;
+export {ActualMinimap};

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

@@ -15,6 +15,7 @@ import {EventExtraData} from 'sentry/components/events/eventExtraData';
 import EventReplay from 'sentry/components/events/eventReplay';
 import {EventSdk} from 'sentry/components/events/eventSdk';
 import EventBreakpointChart from 'sentry/components/events/eventStatisticalDetector/breakpointChart';
+import EventComparison from 'sentry/components/events/eventStatisticalDetector/eventComparison';
 import RegressionMessage from 'sentry/components/events/eventStatisticalDetector/regressionMessage';
 import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
 import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
@@ -96,6 +97,7 @@ function GroupEventDetailsContent({
         <Fragment>
           <RegressionMessage event={event} />
           <EventBreakpointChart event={event} />
+          <EventComparison event={event} project={project} />
         </Fragment>
       </Feature>
     );