Browse Source

feat(related_issues): Show trace-related issues in the Issue Details page (#71049)

**NOTE**: This UI change will have a follow-up PR creating a new
polished component.

If the trace for an issue has two different issues involved, show links to
the related issues and do not show the trace timeline.

To be clear, this only impacts the case when we have two issues regardless
of the number of events.

You can load [this
event](https://dev.getsentry.net:7999/issues/4208426984/events/abef1576630343a496798c9250fe69db/?project=1)
locally with `yarn dev-ui` if you want to compare it with [the
production
event](https://sentry.sentry.io/issues/4208426984/events/abef1576630343a496798c9250fe69db/).

The following screenshot shows the before and after.

Before: Timeline showing two events for two different issues (not clear):
<img width="519" alt="image"
src="https://github.com/getsentry/sentry/assets/44410/0a054fd9-cd2f-4b79-94c7-f9142dfde92c">

After: We replaced the timeline with info about the issues involved in
the trace:
<img width="414" alt="image"
src="https://github.com/getsentry/sentry/assets/44410/d09580c8-65cd-49b6-954e-a18d0b0be0e0">

<hr />
This also works for other types of issues besides errors:
<img width="415" alt="image"
src="https://github.com/getsentry/sentry/assets/44410/d8cfd2a9-56e3-4d2a-ae79-40c8f521fc60">
Armen Zambrano G 9 months ago
parent
commit
2359b4aed2

+ 75 - 0
static/app/views/issueDetails/traceTimeline/traceTimeline.spec.tsx

@@ -152,4 +152,79 @@ describe('TraceTimeline', () => {
     render(<TraceTimeline event={event} />, {organization});
     expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
   });
+
+  it('skips the timeline and shows related issues (2 issues)', async () => {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: issuePlatformBody,
+      match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
+    });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: discoverBody,
+      match: [MockApiClient.matchQuery({dataset: 'discover'})],
+    });
+    // I believe the call to projects is to determine what projects a user belongs to
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/projects/`,
+      body: [],
+    });
+
+    render(<TraceTimeline event={event} />, {
+      organization: OrganizationFixture({
+        features: ['related-issues-issue-details-page'],
+      }),
+    });
+
+    // Instead of a timeline, we should see related issues
+    expect(await screen.findByText('Slow DB Query')).toBeInTheDocument();
+    expect(
+      await screen.findByText('AttributeError: Something Failed')
+    ).toBeInTheDocument();
+    expect(screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
+    expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
+      trace_timeline_status: 'empty',
+    });
+    let element = await screen.getByTestId('this-event-1');
+    expect(element).toHaveTextContent('This event');
+    element = await screen.getByTestId('this-event-abc');
+    expect(element).toHaveTextContent('');
+  });
+
+  it('skips the timeline and shows NO related issues (only 1 issue)', async () => {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: emptyBody,
+      match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
+    });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      // Only 1 issue
+      body: discoverBody,
+      match: [MockApiClient.matchQuery({dataset: 'discover'})],
+    });
+    // I believe the call to projects is to determine what projects a user belongs to
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/projects/`,
+      body: [],
+    });
+
+    render(<TraceTimeline event={event} />, {
+      organization: OrganizationFixture({
+        features: ['related-issues-issue-details-page'],
+      }),
+    });
+
+    // We do not display any related issues because we only have 1 issue
+    expect(await screen.queryByText('Slow DB Query')).not.toBeInTheDocument();
+    expect(
+      await screen.queryByText('AttributeError: Something Failed')
+    ).not.toBeInTheDocument();
+
+    // We do not display the timeline because we only have 1 event
+    expect(await screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
+    expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
+      trace_timeline_status: 'empty',
+    });
+  });
 });

+ 66 - 21
static/app/views/issueDetails/traceTimeline/traceTimeline.tsx

@@ -1,6 +1,7 @@
 import {useRef} from 'react';
 import styled from '@emotion/styled';
 
+import Feature from 'sentry/components/acl/feature';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import QuestionTooltip from 'sentry/components/questionTooltip';
 import {t} from 'sentry/locale';
@@ -8,24 +9,38 @@ import {space} from 'sentry/styles/space';
 import type {Event} from 'sentry/types/event';
 import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
 import {useDimensions} from 'sentry/utils/useDimensions';
+import useOrganization from 'sentry/utils/useOrganization';
 
 import {TraceTimelineEvents} from './traceTimelineEvents';
-import {useTraceTimelineEvents} from './useTraceTimelineEvents';
+import {EventItem} from './traceTimelineTooltip';
+import {type TimelineEvent, useTraceTimelineEvents} from './useTraceTimelineEvents';
+
+const ISSUES_TO_SKIP_TIMELINE = 2;
 
 interface TraceTimelineProps {
   event: Event;
 }
 
 export function TraceTimeline({event}: TraceTimelineProps) {
+  const organization = useOrganization();
   const timelineRef = useRef<HTMLDivElement>(null);
   const {width} = useDimensions({elementRef: timelineRef});
   const {isError, isLoading, traceEvents} = useTraceTimelineEvents({event});
 
   const hasTraceId = !!event.contexts?.trace?.trace_id;
 
-  let timelineStatus: string | undefined;
+  let timelineStatus: string | undefined = 'empty';
+  let timelineSkipped = false;
+  let issuesCount = 0;
   if (hasTraceId && !isLoading) {
-    timelineStatus = traceEvents.length > 1 ? 'shown' : 'empty';
+    if (!organization.features.includes('related-issues-issue-details-page')) {
+      timelineStatus = traceEvents.length > 1 ? 'shown' : 'empty';
+    } else {
+      issuesCount = getIssuesCountFromEvents(traceEvents);
+      // When we have more than 2 issues regardless of the number of events we skip the timeline
+      timelineSkipped = issuesCount === ISSUES_TO_SKIP_TIMELINE;
+      timelineStatus = timelineSkipped ? 'empty' : 'shown';
+    }
   } else if (!hasTraceId) {
     timelineStatus = 'no_trace_id';
   }
@@ -49,28 +64,58 @@ export function TraceTimeline({event}: TraceTimelineProps) {
 
   return (
     <ErrorBoundary mini>
-      <TimelineWrapper>
-        <div ref={timelineRef}>
-          <TimelineEventsContainer>
-            <TimelineOutline />
-            {/* Sets a min width of 200 for testing */}
-            <TraceTimelineEvents event={event} width={Math.max(width, 200)} />
-          </TimelineEventsContainer>
-        </div>
-        <QuestionTooltipWrapper>
-          <QuestionTooltip
-            size="sm"
-            title={t(
-              'This is a trace timeline showing all related events happening upstream and downstream of this event'
-            )}
-            position="bottom"
-          />
-        </QuestionTooltipWrapper>
-      </TimelineWrapper>
+      {timelineStatus === 'shown' ? (
+        <TimelineWrapper>
+          <div ref={timelineRef}>
+            <TimelineEventsContainer>
+              <TimelineOutline />
+              {/* Sets a min width of 200 for testing */}
+              <TraceTimelineEvents event={event} width={Math.max(width, 200)} />
+            </TimelineEventsContainer>
+          </div>
+          <QuestionTooltipWrapper>
+            <QuestionTooltip
+              size="sm"
+              title={t(
+                'This is a trace timeline showing all related events happening upstream and downstream of this event'
+              )}
+              position="bottom"
+            />
+          </QuestionTooltipWrapper>
+        </TimelineWrapper>
+      ) : (
+        <Feature features="related-issues-issue-details-page">
+          {timelineSkipped && (
+            // XXX: Temporary. This will need to be replaced with a styled component
+            <div style={{width: '400px'}}>
+              {traceEvents.map((traceEvent, index) => (
+                <div key={index} style={{display: 'flex', alignItems: 'center'}}>
+                  <div
+                    style={{whiteSpace: 'nowrap', minWidth: '75px'}}
+                    data-test-id={`this-event-${traceEvent.id}`}
+                  >
+                    {event.id === traceEvent.id && <span>This event</span>}
+                  </div>
+                  <EventItem key={index} timelineEvent={traceEvent} />
+                </div>
+              ))}
+            </div>
+          )}
+        </Feature>
+      )}
     </ErrorBoundary>
   );
 }
 
+function getIssuesCountFromEvents(events: TimelineEvent[]): number {
+  const distinctIssues = events.filter(
+    (event, index, self) =>
+      event['issue.id'] !== undefined &&
+      self.findIndex(e => e['issue.id'] === event['issue.id']) === index
+  );
+  return distinctIssues.length;
+}
+
 const TimelineWrapper = styled('div')`
   display: grid;
   grid-template-columns: 1fr auto;

+ 2 - 0
static/app/views/issueDetails/traceTimeline/traceTimelineTooltip.tsx

@@ -181,3 +181,5 @@ const TraceItem = styled('div')`
   border-radius: ${p => p.theme.borderRadius};
   border-top: 1px solid ${p => p.theme.innerBorder};
 `;
+
+export {EventItem};