Repository: getsentry/sentry
Pull request: ref(event-details): Move all issue event details rendering logic from EventEntries to GroupEventDetails
Ref https://github.com/getsentry/sentry/issues/44053

- Moves conditional render logic from `EventEntries` to event section components like `EventContext`, `EventDevice` etc. This is to enable them to be imported and used in both EventEntries and GroupEventDetails without duplicating that logic.
- Add `GroupEventDetailsContent` which does what `EventEntries` did before, but in a way that is much more customizable. This removes the need for hacks like `injectResourcesEntry`

Future work:
- Add a similar component for shared issues and remove the `isShare` guards from `EventEntries`

Problem statement:
Break up the `EventEntries` component
The `EventEntries` component is used in issue details, discover, performance, shared issues. Now that issues are becoming more differentiated from the event details, it no longer makes sense to use it for all these disparate use cases.

This work will enable the issues UI to be more flexible and diverge from the default event UI without adding many extra props and conditionals that will make the code less maintainable.

Hints from issue discussion:

Commit message: ref(event-details): Move all issue event details rendering logic to groupEventDetails

Patch to review:
diff --git a/static/app/components/events/contexts/index.tsx b/static/app/components/events/contexts/index.tsx
index 133ced181c3c83..be6b001cf93722 100644
--- a/static/app/components/events/contexts/index.tsx
+++ b/static/app/components/events/contexts/index.tsx
@@ -15,7 +15,7 @@ type Props = {
 export function EventContexts({event, group}: Props) {
   const {user, contexts} = event;
 
-  const {feedback, ...otherContexts} = contexts;
+  const {feedback, ...otherContexts} = contexts ?? {};
 
   const usingOtel = useCallback(
     () => otherContexts.otel !== undefined,
diff --git a/static/app/components/events/device.tsx b/static/app/components/events/device.tsx
index 6591197fae88a8..f91201413edf19 100644
--- a/static/app/components/events/device.tsx
+++ b/static/app/components/events/device.tsx
@@ -1,6 +1,7 @@
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import {t} from 'sentry/locale';
 import {Event} from 'sentry/types/event';
+import {objectIsEmpty} from 'sentry/utils';
 
 import KeyValueList from './interfaces/keyValueList';
 
@@ -17,6 +18,10 @@ export function EventDevice({event}: Props) {
     isContextData: true,
   }));
 
+  if (objectIsEmpty(event.device)) {
+    return null;
+  }
+
   return (
     <EventDataSection type="device" title={t('Device')}>
       <KeyValueList
diff --git a/static/app/components/events/eventAttachments.tsx b/static/app/components/events/eventAttachments.tsx
index 61867977fd39fa..93f4f5f74e6680 100644
--- a/static/app/components/events/eventAttachments.tsx
+++ b/static/app/components/events/eventAttachments.tsx
@@ -91,7 +91,7 @@ const InlineEventAttachment = ({
   );
 };
 
-export const EventAttachments = ({event, projectSlug}: EventAttachmentsProps) => {
+const EventAttachmentsContent = ({event, projectSlug}: EventAttachmentsProps) => {
   const organization = useOrganization();
   const {
     data: attachments = [],
@@ -207,6 +207,16 @@ export const EventAttachments = ({event, projectSlug}: EventAttachmentsProps) =>
   );
 };
 
+export const EventAttachments = (props: EventAttachmentsProps) => {
+  const organization = useOrganization();
+
+  if (!organization.features.includes('event-attachments')) {
+    return null;
+  }
+
+  return <EventAttachmentsContent {...props} />;
+};
+
 const StyledPanelTable = styled(PanelTable)`
   grid-template-columns: 1fr auto auto;
 `;
diff --git a/static/app/components/events/eventEntries.spec.tsx b/static/app/components/events/eventEntries.spec.tsx
deleted file mode 100644
index e51b72df351b2c..00000000000000
--- a/static/app/components/events/eventEntries.spec.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import {initializeData} from 'sentry-test/performance/initializePerformanceData';
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import {EventEntries} from 'sentry/components/events/eventEntries';
-import {Group, IssueCategory} from 'sentry/types';
-import {EntryType} from 'sentry/types/event';
-
-const {organization, project, router} = initializeData();
-
-describe('EventEntries', function () {
-  const event = TestStubs.Event();
-
-  beforeEach(() => {
-    MockApiClient.addMockResponse({
-      url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/grouping-info/`,
-      body: {},
-    });
-    MockApiClient.addMockResponse({
-      method: 'GET',
-      url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/committers/`,
-      body: {
-        committers: [],
-      },
-    });
-  });
-
-  afterEach(() => {
-    MockApiClient.clearMockResponses();
-  });
-
-  describe('Rendering', function () {
-    it('renders the Resources section for Performance Issues', function () {
-      const group: Group = TestStubs.Group({issueCategory: IssueCategory.PERFORMANCE});
-
-      const newEvent = {
-        ...event,
-        entries: [{type: EntryType.SPANS, data: []}],
-      };
-
-      render(
-        <EventEntries
-          organization={organization}
-          event={newEvent}
-          project={project}
-          location={router.location}
-          group={group}
-        />,
-        {organization}
-      );
-
-      const resourcesHeadingText = screen.getByRole('heading', {
-        name: /resources and whatever/i,
-      });
-
-      expect(resourcesHeadingText).toBeInTheDocument();
-    });
-
-    it('injects the resources section in the correct spot', function () {
-      const group: Group = TestStubs.Group({issueCategory: IssueCategory.PERFORMANCE});
-      group.issueCategory = IssueCategory.PERFORMANCE;
-      const sampleBreadcrumb = {
-        type: 'default',
-        timestamp: '2022-09-19T19:29:32.261000Z',
-        level: 'info',
-        message: 'span.css-1hs7lfd.e1b8u3ky1 > svg',
-        category: 'ui.click',
-        data: null,
-        event_id: null,
-      };
-
-      const newEvent = {
-        ...event,
-        title: 'test',
-        perfProblem: {parentSpanIds: ['a'], causeSpanIds: ['a'], offenderSpanIds: ['a']},
-        entries: [
-          {type: EntryType.SPANS, data: [{span_id: 'a'}]},
-          {type: EntryType.BREADCRUMBS, data: {values: [sampleBreadcrumb]}},
-          {type: EntryType.REQUEST, data: {}},
-        ],
-      };
-
-      render(
-        <EventEntries
-          organization={organization}
-          event={newEvent}
-          project={project}
-          location={router.location}
-          group={group}
-        />
-      );
-
-      const spanEvidenceHeading = screen.getByRole('heading', {
-        name: /span evidence/i,
-      });
-      const breadcrumbsHeading = screen.getByRole('heading', {
-        name: /breadcrumbs/i,
-      });
-      const resourcesHeadingText = screen.getByRole('heading', {
-        name: /resources and whatever/i,
-      });
-
-      expect(spanEvidenceHeading).toBeInTheDocument();
-      expect(breadcrumbsHeading).toBeInTheDocument();
-      expect(resourcesHeadingText).toBeInTheDocument();
-
-      expect(
-        screen.getByRole('heading', {
-          name: /span evidence/i,
-        })
-      ).toBeInTheDocument();
-
-      expect(
-        screen.getByRole('heading', {
-          name: /breadcrumbs/i,
-        })
-      ).toBeInTheDocument();
-
-      expect(
-        screen.getByRole('heading', {
-          name: /resources and whatever/i,
-        })
-      ).toBeInTheDocument();
-    });
-  });
-});
diff --git a/static/app/components/events/eventEntries.tsx b/static/app/components/events/eventEntries.tsx
index 03166c62c10d9a..196e3617d7c6e0 100644
--- a/static/app/components/events/eventEntries.tsx
+++ b/static/app/components/events/eventEntries.tsx
@@ -3,15 +3,12 @@ import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import {CommitRow} from 'sentry/components/commitRow';
-import ErrorBoundary from 'sentry/components/errorBoundary';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {
   Entry,
-  EntryType,
   Event,
   Group,
-  IssueCategory,
   Organization,
   Project,
   SharedViewOrganization,
@@ -66,9 +63,6 @@ const EventEntries = ({
   const projectSlug = project.slug;
   const orgFeatures = organization?.features ?? [];
 
-  const hasEventAttachmentsFeature = orgFeatures.includes('event-attachments');
-  const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
-
   if (!event) {
     return (
       <LatestEventNotAvailable>
@@ -106,7 +100,6 @@ const EventEntries = ({
           projectSlug={projectSlug}
           location={location}
           isShare={isShare}
-          hasContext={hasContext}
         />
       )}
       <EventEvidence event={event} group={group} />
@@ -118,23 +111,13 @@ const EventEntries = ({
         isShare={isShare}
       />
       {hasContext && <EventContexts group={group} event={event} />}
-      {event && !objectIsEmpty(event.context) && <EventExtraData event={event} />}
-      {event && !objectIsEmpty(event.packages) && <EventPackageData event={event} />}
-      {event && !objectIsEmpty(event.device) && <EventDevice event={event} />}
-      {!isShare &&
-        organization.features?.includes('mobile-view-hierarchies') &&
-        hasEventAttachmentsFeature && (
-          <EventViewHierarchy event={event} project={project} />
-        )}
-      {!isShare && hasEventAttachmentsFeature && (
-        <EventAttachments event={event} projectSlug={projectSlug} />
-      )}
-      {event.sdk && !objectIsEmpty(event.sdk) && (
-        <EventSdk sdk={event.sdk} meta={event._meta?.sdk} />
-      )}
-      {!isShare && event?.sdkUpdates && event.sdkUpdates.length > 0 && (
-        <EventSdkUpdates event={{sdkUpdates: event.sdkUpdates, ...event}} />
-      )}
+      <EventExtraData event={event} />
+      <EventPackageData event={event} />
+      <EventDevice event={event} />
+      {!isShare && <EventViewHierarchy event={event} project={project} />}
+      {!isShare && <EventAttachments event={event} projectSlug={projectSlug} />}
+      <EventSdk sdk={event.sdk} meta={event._meta?.sdk} />
+      {!isShare && <EventSdkUpdates event={event} />}
       {!isShare && event.groupID && (
         <EventGroupingInfo
           projectSlug={projectSlug}
@@ -144,58 +127,13 @@ const EventEntries = ({
           }
         />
       )}
-      {!isShare && !hasReplay && hasEventAttachmentsFeature && (
-        <EventRRWebIntegration
-          event={event}
-          orgId={orgSlug}
-          projectSlug={projectSlug}
-          renderer={children => (
-            <StyledReplayEventDataSection type="context-replay" title={t('Replay')}>
-              {children}
-            </StyledReplayEventDataSection>
-          )}
-        />
+      {!isShare && (
+        <EventRRWebIntegration event={event} orgId={orgSlug} projectSlug={projectSlug} />
       )}
     </div>
   );
 };
 
-function injectResourcesEntry(definedEvent: Event) {
-  const entries = definedEvent.entries;
-  let adjustedEntries: Entry[] = [];
-
-  // This check is to ensure we are not injecting multiple Resources entries
-  const resourcesIndex = entries.findIndex(entry => entry.type === EntryType.RESOURCES);
-  if (resourcesIndex === -1) {
-    const spansIndex = entries.findIndex(entry => entry.type === EntryType.SPANS);
-    const breadcrumbsIndex = entries.findIndex(
-      entry => entry.type === EntryType.BREADCRUMBS
-    );
-
-    // We want the Resources section to appear after Breadcrumbs.
-    // If Breadcrumbs are included on this event, we will inject this entry right after it.
-    // Otherwise, we inject it after the Spans entry.
-    const resourcesEntry: Entry = {type: EntryType.RESOURCES, data: null};
-    if (breadcrumbsIndex > -1) {
-      adjustedEntries = [
-        ...entries.slice(0, breadcrumbsIndex + 1),
-        resourcesEntry,
-        ...entries.slice(breadcrumbsIndex + 1, entries.length),
-      ];
-    } else if (spansIndex > -1) {
-      adjustedEntries = [
-        ...entries.slice(0, spansIndex + 1),
-        resourcesEntry,
-        ...entries.slice(spansIndex + 1, entries.length),
-      ];
-    }
-  }
-
-  if (adjustedEntries.length > 0) {
-    definedEvent.entries = adjustedEntries;
-  }
-}
-
 function Entries({
   definedEvent,
   projectSlug,
@@ -211,30 +149,18 @@ function Entries({
     return null;
   }
 
-  if (group?.issueCategory === IssueCategory.PERFORMANCE) {
-    injectResourcesEntry(definedEvent);
-  }
-
   return (
     <Fragment>
       {(definedEvent.entries as Array<Entry>).map((entry, entryIdx) => (
-        <ErrorBoundary
-          key={`entry-${entryIdx}`}
-          customComponent={
-            <EventDataSection type={entry.type} title={entry.type}>
-              <p>{t('There was an error rendering this data.')}</p>
-            </EventDataSection>
-          }
-        >
-          <EventEntry
-            projectSlug={projectSlug}
-            group={group}
-            organization={organization}
-            event={definedEvent}
-            entry={entry}
-            isShare={isShare}
-          />
-        </ErrorBoundary>
+        <EventEntry
+          key={entryIdx}
+          projectSlug={projectSlug}
+          group={group}
+          organization={organization}
+          event={definedEvent}
+          entry={entry}
+          isShare={isShare}
+        />
       ))}
     </Fragment>
   );
@@ -256,9 +182,4 @@ const BorderlessEventEntries = styled(EventEntries)`
   }
 `;
 
-const StyledReplayEventDataSection = styled(EventDataSection)`
-  overflow: hidden;
-  margin-bottom: ${space(3)};
-`;
-
 export {EventEntries, BorderlessEventEntries};
diff --git a/static/app/components/events/eventEntry.tsx b/static/app/components/events/eventEntry.tsx
index a2d4d781b8c8b6..0cea38f182f4fe 100644
--- a/static/app/components/events/eventEntry.tsx
+++ b/static/app/components/events/eventEntry.tsx
@@ -1,3 +1,6 @@
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import {t} from 'sentry/locale';
 import {
   Group,
   IssueCategory,
@@ -14,9 +17,7 @@ import {Exception} from './interfaces/exception';
 import {ExceptionV2} from './interfaces/exceptionV2';
 import {Generic} from './interfaces/generic';
 import {Message} from './interfaces/message';
-import {Resources} from './interfaces/performance/resources';
 import {SpanEvidenceSection} from './interfaces/performance/spanEvidence';
-import {getResourceDescription, getResourceLinks} from './interfaces/performance/utils';
 import {Request} from './interfaces/request';
 import {Spans} from './interfaces/spans';
 import {StackTrace} from './interfaces/stackTrace';
@@ -34,7 +35,7 @@ type Props = {
   isShare?: boolean;
 };
 
-export function EventEntry({
+function EventEntryContent({
   entry,
   projectSlug,
   event,
@@ -174,17 +175,6 @@ export function EventEntry({
         />
       );
 
-    case EntryType.RESOURCES:
-      if (!group || !group.issueType) {
-        return null;
-      }
-      return (
-        <Resources
-          description={getResourceDescription(group.issueType)}
-          links={getResourceLinks(group.issueType, event.platform)}
-        />
-      );
-
     // this should not happen
     default:
       if (window.console) {
@@ -194,3 +184,17 @@ export function EventEntry({
       return null;
   }
 }
+
+export function EventEntry(props: Props) {
+  return (
+    <ErrorBoundary
+      customComponent={
+        <EventDataSection type={props.entry.type} title={props.entry.type}>
+          <p>{t('There was an error rendering this data.')}</p>
+        </EventDataSection>
+      }
+    >
+      <EventEntryContent {...props} />
+    </ErrorBoundary>
+  );
+}
diff --git a/static/app/components/events/eventExtraData/index.tsx b/static/app/components/events/eventExtraData/index.tsx
index d70b18df8dcb1a..c81fb9d94d85a2 100644
--- a/static/app/components/events/eventExtraData/index.tsx
+++ b/static/app/components/events/eventExtraData/index.tsx
@@ -6,7 +6,7 @@ import ContextBlock from 'sentry/components/events/contexts/contextBlock';
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import {t} from 'sentry/locale';
 import {Event} from 'sentry/types/event';
-import {defined} from 'sentry/utils';
+import {defined, objectIsEmpty} from 'sentry/utils';
 
 import {getKnownData} from '../contexts/utils';
 
@@ -20,6 +20,11 @@ type Props = {
 export const EventExtraData = memo(
   ({event}: Props) => {
     const [raw, setRaw] = useState(false);
+
+    if (objectIsEmpty(event.context)) {
+      return null;
+    }
+
     return (
       <EventDataSection
         type="extra"
diff --git a/static/app/components/events/eventSdk.tsx b/static/app/components/events/eventSdk.tsx
index 349186d08ce321..a786d1e74b7702 100644
--- a/static/app/components/events/eventSdk.tsx
+++ b/static/app/components/events/eventSdk.tsx
@@ -1,16 +1,21 @@
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import {t} from 'sentry/locale';
 import {Event} from 'sentry/types/event';
+import {objectIsEmpty} from 'sentry/utils';
 
 import KeyValueList from './interfaces/keyValueList';
 import {AnnotatedText} from './meta/annotatedText';
 
 type Props = {
-  sdk: NonNullable<Event['sdk']>;
   meta?: Record<any, any>;
+  sdk?: Event['sdk'];
 };
 
 export function EventSdk({sdk, meta}: Props) {
+  if (!sdk || objectIsEmpty(sdk)) {
+    return null;
+  }
+
   return (
     <EventDataSection type="sdk" title={t('SDK')}>
       <KeyValueList
diff --git a/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx b/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
index d34580733078fc..6dc45166c1f1fc 100644
--- a/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
+++ b/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
@@ -185,7 +185,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
         />,
         {organization}
       );
@@ -235,7 +234,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
           isShare
         />,
         {organization}
@@ -257,7 +255,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
           isShare
         />
       );
@@ -285,11 +282,10 @@ describe('EventTagsAndScreenshot', function () {
         <Fragment>
           <GlobalModal />
           <EventTagsAndScreenshot
-            event={event}
+            event={TestStubs.Event({user: {}, contexts: {}})}
             organization={organization}
             projectSlug={project.slug}
             location={router.location}
-            hasContext={false}
           />
         </Fragment>,
         {organization}
@@ -347,7 +343,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
         />,
         {organization}
       );
@@ -407,7 +402,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
         />,
         {organization}
       );
@@ -442,7 +436,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext
         />,
         {organization}
       );
@@ -476,7 +469,6 @@ describe('EventTagsAndScreenshot', function () {
           organization={organization}
           projectSlug={project.slug}
           location={router.location}
-          hasContext={false}
         />,
         {organization}
       );
diff --git a/static/app/components/events/eventTagsAndScreenshot/index.tsx b/static/app/components/events/eventTagsAndScreenshot/index.tsx
index 44507ae98a45ee..158ef40ddd2a4b 100644
--- a/static/app/components/events/eventTagsAndScreenshot/index.tsx
+++ b/static/app/components/events/eventTagsAndScreenshot/index.tsx
@@ -34,7 +34,6 @@ type Props = Omit<
   'projectSlug' | 'hasEventContext'
 > & {
   projectSlug: string;
-  hasContext?: boolean;
   isShare?: boolean;
 };
 
@@ -44,9 +43,9 @@ export function EventTagsAndScreenshot({
   event,
   organization,
   isShare = false,
-  hasContext = false,
 }: Props) {
   const {tags = []} = event;
+  const hasContext = !objectIsEmpty(event.user ?? {}) || !objectIsEmpty(event.contexts);
   const {data: attachments} = useFetchEventAttachments(
     {
       orgSlug: organization.slug,
diff --git a/static/app/components/events/eventViewHierarchy.spec.tsx b/static/app/components/events/eventViewHierarchy.spec.tsx
index 41d8a5abb1d531..03bce0b2ffd7ef 100644
--- a/static/app/components/events/eventViewHierarchy.spec.tsx
+++ b/static/app/components/events/eventViewHierarchy.spec.tsx
@@ -42,7 +42,9 @@ const MOCK_DATA = JSON.stringify({
   ],
 });
 
-const organization = TestStubs.Organization({features: ['event-attachments']});
+const organization = TestStubs.Organization({
+  features: ['event-attachments', 'mobile-view-hierarchies'],
+});
 const event = TestStubs.Event();
 
 describe('Event View Hierarchy', function () {
diff --git a/static/app/components/events/eventViewHierarchy.tsx b/static/app/components/events/eventViewHierarchy.tsx
index 939a147b99d255..74307c91e5da28 100644
--- a/static/app/components/events/eventViewHierarchy.tsx
+++ b/static/app/components/events/eventViewHierarchy.tsx
@@ -20,7 +20,7 @@ type Props = {
   project: Project;
 };
 
-function EventViewHierarchy({project, event}: Props) {
+function EventViewHierarchyContent({event, project}: Props) {
   const organization = useOrganization();
 
   const {data: attachments} = useFetchEventAttachments(
@@ -86,4 +86,17 @@ function EventViewHierarchy({project, event}: Props) {
   );
 }
 
+function EventViewHierarchy(props: Props) {
+  const organization = useOrganization();
+
+  if (
+    !organization.features?.includes('mobile-view-hierarchies') ||
+    !organization.features.includes('event-attachments')
+  ) {
+    return null;
+  }
+
+  return <EventViewHierarchyContent {...props} />;
+}
+
 export {EventViewHierarchy};
diff --git a/static/app/components/events/interfaces/performance/resources.tsx b/static/app/components/events/interfaces/performance/resources.tsx
index e04d98f4acd0d7..a137b554ff38bd 100644
--- a/static/app/components/events/interfaces/performance/resources.tsx
+++ b/static/app/components/events/interfaces/performance/resources.tsx
@@ -18,6 +18,10 @@ type Props = {
 
 // This section provides users with resources on how to resolve an issue
 export function Resources(props: Props) {
+  if (!props.description) {
+    return null;
+  }
+
   return (
     <EventDataSection type="resources-and-whatever" title={t('Resources and Whatever')}>
       {props.description}
diff --git a/static/app/components/events/packageData.tsx b/static/app/components/events/packageData.tsx
index 8515877bc46971..735a03af2ee9be 100644
--- a/static/app/components/events/packageData.tsx
+++ b/static/app/components/events/packageData.tsx
@@ -4,6 +4,7 @@ import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
 import {t} from 'sentry/locale';
 import {Event} from 'sentry/types/event';
+import {objectIsEmpty} from 'sentry/utils';
 
 type Props = {
   event: Event;
@@ -33,6 +34,10 @@ export function EventPackageData({event}: Props) {
       title = t('Packages');
   }
 
+  if (objectIsEmpty(event.packages)) {
+    return null;
+  }
+
   return (
     <EventDataSection type="packages" title={title}>
       <ClippedBox>
diff --git a/static/app/components/events/rrwebIntegration.tsx b/static/app/components/events/rrwebIntegration.tsx
index dde4b786acdef8..83bbdf63f1b721 100644
--- a/static/app/components/events/rrwebIntegration.tsx
+++ b/static/app/components/events/rrwebIntegration.tsx
@@ -1,20 +1,25 @@
+import styled from '@emotion/styled';
+
 import AsyncComponent from 'sentry/components/asyncComponent';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import LazyLoad from 'sentry/components/lazyLoad';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
 import {IssueAttachment, Organization, Project} from 'sentry/types';
 import {Event} from 'sentry/types/event';
+import useOrganization from 'sentry/utils/useOrganization';
 
 type Props = {
   event: Event;
   orgId: Organization['id'];
   projectSlug: Project['slug'];
-  renderer?: Function;
 } & AsyncComponent['props'];
 
 type State = {
   attachmentList: Array<IssueAttachment> | null;
 } & AsyncComponent['state'];
 
-export class EventRRWebIntegration extends AsyncComponent<Props, State> {
+class EventRRWebIntegrationContent extends AsyncComponent<Props, State> {
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
     const {orgId, projectSlug, event} = this.props;
     return [
@@ -44,7 +49,6 @@ export class EventRRWebIntegration extends AsyncComponent<Props, State> {
 
   renderBody() {
     const {attachmentList} = this.state;
-    const renderer = this.props.renderer || (children => children);
 
     if (!attachmentList?.length) {
       return null;
@@ -56,11 +60,32 @@ export class EventRRWebIntegration extends AsyncComponent<Props, State> {
       return `/api/0/projects/${orgId}/${projectSlug}/events/${event.id}/attachments/${attachment.id}/?download`;
     }
 
-    return renderer(
-      <LazyLoad
-        component={() => import('./rrwebReplayer')}
-        urls={attachmentList.map(createAttachmentUrl)}
-      />
+    return (
+      <StyledReplayEventDataSection type="context-replay" title={t('Replay')}>
+        <LazyLoad
+          component={() => import('./rrwebReplayer')}
+          urls={attachmentList.map(createAttachmentUrl)}
+        />
+      </StyledReplayEventDataSection>
     );
   }
 }
+
+export const EventRRWebIntegration = (props: Props) => {
+  const organization = useOrganization();
+  const hasReplay = Boolean(
+    props.event?.tags?.find(({key}) => key === 'replayId')?.value
+  );
+  const hasEventAttachmentsFeature = organization.features.includes('event-attachments');
+
+  if (hasReplay || !hasEventAttachmentsFeature) {
+    return null;
+  }
+
+  return <EventRRWebIntegrationContent {...props} />;
+};
+
+const StyledReplayEventDataSection = styled(EventDataSection)`
+  overflow: hidden;
+  margin-bottom: ${space(3)};
+`;
diff --git a/static/app/components/events/sdkUpdates/index.tsx b/static/app/components/events/sdkUpdates/index.tsx
index 8be17b4598738a..939ffff6b3dffb 100644
--- a/static/app/components/events/sdkUpdates/index.tsx
+++ b/static/app/components/events/sdkUpdates/index.tsx
@@ -5,39 +5,41 @@ import {Event} from 'sentry/types/event';
 import getSdkUpdateSuggestion from 'sentry/utils/getSdkUpdateSuggestion';
 
 type Props = {
-  event: Omit<Event, 'sdkUpdates'> & {
-    sdkUpdates: NonNullable<Event['sdkUpdates']>;
-  };
+  event: Event;
 };
 
 export function EventSdkUpdates({event}: Props) {
-  const {sdkUpdates} = event;
+  const eventDataSectionContent =
+    event.sdkUpdates
+      ?.map((sdkUpdate, index) => {
+        const suggestion = getSdkUpdateSuggestion({
+          suggestion: sdkUpdate,
+          sdk: event.sdk,
+        });
 
-  const eventDataSectinContent = sdkUpdates
-    .map((sdkUpdate, index) => {
-      const suggestion = getSdkUpdateSuggestion({suggestion: sdkUpdate, sdk: event.sdk});
+        if (!suggestion) {
+          return null;
+        }
 
-      if (!suggestion) {
-        return null;
-      }
+        return (
+          <Alert key={index} type="info" showIcon>
+            {tct('We recommend you [suggestion] ', {suggestion})}
+            {sdkUpdate.type === 'updateSdk' &&
+              t(
+                '(All sentry packages should be updated and their versions should match)'
+              )}
+          </Alert>
+        );
+      })
+      .filter(alert => !!alert) ?? [];
 
-      return (
-        <Alert key={index} type="info" showIcon>
-          {tct('We recommend you [suggestion] ', {suggestion})}
-          {sdkUpdate.type === 'updateSdk' &&
-            t('(All sentry packages should be updated and their versions should match)')}
-        </Alert>
-      );
-    })
-    .filter(alert => !!alert);
-
-  if (!eventDataSectinContent.length) {
+  if (!eventDataSectionContent.length) {
     return null;
   }
 
   return (
     <EventDataSection title={null} type="sdk-updates">
-      {eventDataSectinContent}
+      {eventDataSectionContent}
     </EventDataSection>
   );
 }
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
index cb65a4913ee896..1f02d010b0974f 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
@@ -4,7 +4,7 @@ import {Location} from 'history';
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
 
-import {Event, Group} from 'sentry/types';
+import {EntryType, Event, Group, IssueCategory, IssueType} from 'sentry/types';
 import {Organization} from 'sentry/types/organization';
 import {Project} from 'sentry/types/project';
 import GroupEventDetails, {
@@ -285,6 +285,52 @@ describe('groupEventDetails', () => {
       await screen.findByText(/events for this issue could not be found/)
     ).toBeInTheDocument();
   });
+
+  it('renders the Span Evidence and Resources section for Performance Issues', async function () {
+    const props = makeDefaultMockData();
+    const group: Group = TestStubs.Group({
+      issueCategory: IssueCategory.PERFORMANCE,
+      issueType: IssueType.PERFORMANCE_N_PLUS_ONE_DB_QUERIES,
+    });
+    const transaction = TestStubs.Event({
+      entries: [{type: EntryType.SPANS, data: []}],
+    });
+
+    mockGroupApis(
+      props.organization,
+      props.project,
+      props.group,
+      TestStubs.Event({
+        size: 1,
+        dateCreated: '2019-03-20T00:00:00.000Z',
+        errors: [],
+        entries: [],
+        tags: [{key: 'environment', value: 'dev'}],
+        previousEventID: 'prev-event-id',
+        nextEventID: 'next-event-id',
+      })
+    );
+
+    const routerContext = TestStubs.routerContext();
+    await act(async () => {
+      render(<TestComponent group={group} event={transaction} />, {
+        organization: props.organization,
+        context: routerContext,
+      });
+      await tick();
+    });
+
+    expect(
+      screen.getByRole('heading', {
+        name: /span evidence/i,
+      })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('heading', {
+        name: /resources/i,
+      })
+    ).toBeInTheDocument();
+  });
 });
 
 describe('EventCause', () => {
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx
index 17f1490faed448..3486968b5c15d2 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx
@@ -7,7 +7,6 @@ import isEqual from 'lodash/isEqual';
 import {fetchSentryAppComponents} from 'sentry/actionCreators/sentryAppComponents';
 import {Client} from 'sentry/api';
 import GroupEventDetailsLoadingError from 'sentry/components/errors/groupEventDetailsLoadingError';
-import {EventEntries} from 'sentry/components/events/eventEntries';
 import {withMeta} from 'sentry/components/events/meta/metaProxy';
 import GroupSidebar from 'sentry/components/group/sidebar';
 import * as Layout from 'sentry/components/layouts/thirds';
@@ -30,6 +29,7 @@ import fetchSentryAppInstallations from 'sentry/utils/fetchSentryAppInstallation
 import {QuickTraceContext} from 'sentry/utils/performance/quickTrace/quickTraceContext';
 import QuickTraceQuery from 'sentry/utils/performance/quickTrace/quickTraceQuery';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import GroupEventDetailsContent from 'sentry/views/issueDetails/groupEventDetails/groupEventDetailsContent';
 
 import GroupEventToolbar from '../eventToolbar';
 import ReprocessingProgress from '../reprocessingProgress';
@@ -140,16 +140,7 @@ class GroupEventDetails extends Component<GroupEventDetailsProps, State> {
   };
 
   renderContent(eventWithMeta?: Event) {
-    const {
-      group,
-      project,
-      organization,
-      environments,
-      location,
-      loadingEvent,
-      onRetry,
-      eventError,
-    } = this.props;
+    const {group, project, environments, loadingEvent, onRetry, eventError} = this.props;
 
     if (loadingEvent) {
       return <LoadingIndicator />;
@@ -162,13 +153,7 @@ class GroupEventDetails extends Component<GroupEventDetailsProps, State> {
     }
 
     return (
-      <EventEntries
-        group={group}
-        event={eventWithMeta}
-        organization={organization}
-        project={project}
-        location={location}
-      />
+      <GroupEventDetailsContent group={group} event={eventWithMeta} project={project} />
     );
   }
 
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
new file mode 100644
index 00000000000000..ea41d375b72f2d
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
@@ -0,0 +1,172 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {CommitRow} from 'sentry/components/commitRow';
+import {EventContexts} from 'sentry/components/events/contexts';
+import {EventDevice} from 'sentry/components/events/device';
+import {EventAttachments} from 'sentry/components/events/eventAttachments';
+import {EventCause} from 'sentry/components/events/eventCause';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import {EventEntry} from 'sentry/components/events/eventEntry';
+import {EventErrors} from 'sentry/components/events/eventErrors';
+import {EventEvidence} from 'sentry/components/events/eventEvidence';
+import {EventExtraData} from 'sentry/components/events/eventExtraData';
+import {EventSdk} from 'sentry/components/events/eventSdk';
+import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
+import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
+import {EventGroupingInfo} from 'sentry/components/events/groupingInfo';
+import {Resources} from 'sentry/components/events/interfaces/performance/resources';
+import {SpanEvidenceSection} from 'sentry/components/events/interfaces/performance/spanEvidence';
+import {
+  getResourceDescription,
+  getResourceLinks,
+} from 'sentry/components/events/interfaces/performance/utils';
+import {EventPackageData} from 'sentry/components/events/packageData';
+import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration';
+import {EventSdkUpdates} from 'sentry/components/events/sdkUpdates';
+import {EventUserFeedback} from 'sentry/components/events/userFeedback';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Event, Group, IssueCategory, Project} from 'sentry/types';
+import {EntryType, EventTransaction} from 'sentry/types/event';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type GroupEventDetailsContentProps = {
+  group: Group;
+  project: Project;
+  event?: Event;
+};
+
+type GroupEventEntryProps = {
+  entryType: EntryType;
+  event: Event;
+  group: Group;
+  project: Project;
+};
+
+const GroupEventEntry = ({event, entryType, group, project}: GroupEventEntryProps) => {
+  const organization = useOrganization();
+  const entries = event.entries.filter(entry => entry.type === entryType);
+
+  if (!entries.length) {
+    return null;
+  }
+
+  return (
+    <Fragment>
+      {entries.map((entry, index) => (
+        <EventEntry
+          key={index}
+          projectSlug={project.slug}
+          isShare={false}
+          group={group}
+          {...{organization, event, entry}}
+        />
+      ))}
+    </Fragment>
+  );
+};
+
+const GroupEventDetailsContent = ({
+  group,
+  event,
+  project,
+}: GroupEventDetailsContentProps) => {
+  const organization = useOrganization();
+  const location = useLocation();
+  const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
+
+  if (!event) {
+    return (
+      <NotFoundMessage>
+        <h3>{t('Latest event not available')}</h3>
+      </NotFoundMessage>
+    );
+  }
+
+  const eventEntryProps = {group, event, project};
+
+  return (
+    <Fragment>
+      <EventErrors event={event} project={project} isShare={false} />
+      <EventCause
+        project={project}
+        eventId={event.id}
+        group={group}
+        commitRow={CommitRow}
+      />
+      {event.userReport && (
+        <EventDataSection title="User Feedback" type="user-feedback">
+          <EventUserFeedback
+            report={event.userReport}
+            orgId={organization.slug}
+            issueId={group.id}
+          />
+        </EventDataSection>
+      )}
+      <EventTagsAndScreenshot
+        event={event}
+        organization={organization}
+        projectSlug={project.slug}
+        location={location}
+        isShare={false}
+      />
+      <EventEvidence event={event} group={group} />
+      <GroupEventEntry entryType={EntryType.MESSAGE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXCEPTION} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.STACKTRACE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.THREADS} {...eventEntryProps} />
+      {group.issueCategory === IssueCategory.PERFORMANCE && (
+        <SpanEvidenceSection
+          event={event as EventTransaction}
+          organization={organization}
+        />
+      )}
+      <GroupEventEntry entryType={EntryType.HPKP} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.CSP} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXPECTCT} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXPECTSTAPLE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.TEMPLATE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.BREADCRUMBS} {...eventEntryProps} />
+      <Resources
+        description={getResourceDescription(group.issueType)}
+        links={getResourceLinks(group.issueType, event.platform)}
+      />
+      <GroupEventEntry entryType={EntryType.REQUEST} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.DEBUGMETA} {...eventEntryProps} />
+      <EventContexts group={group} event={event} />
+      <EventExtraData event={event} />
+      <EventPackageData event={event} />
+      <EventDevice event={event} />
+      <EventViewHierarchy event={event} project={project} />
+      <EventAttachments event={event} projectSlug={project.slug} />
+      <EventSdk sdk={event.sdk} meta={event._meta?.sdk} />
+      <EventSdkUpdates event={event} />
+      {event.groupID && (
+        <EventGroupingInfo
+          projectSlug={project.slug}
+          event={event}
+          showGroupingConfig={
+            organization.features.includes('set-grouping-config') &&
+            'groupingConfig' in event
+          }
+        />
+      )}
+
+      {!hasReplay && (
+        <EventRRWebIntegration
+          event={event}
+          orgId={organization.slug}
+          projectSlug={project.slug}
+        />
+      )}
+    </Fragment>
+  );
+};
+
+const NotFoundMessage = styled('div')`
+  padding: ${space(2)} ${space(4)};
+`;
+
+export default GroupEventDetailsContent;


Code review comments:
diff_hunk: @@ -0,0 +1,172 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {CommitRow} from 'sentry/components/commitRow';
+import {EventContexts} from 'sentry/components/events/contexts';
+import {EventDevice} from 'sentry/components/events/device';
+import {EventAttachments} from 'sentry/components/events/eventAttachments';
+import {EventCause} from 'sentry/components/events/eventCause';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import {EventEntry} from 'sentry/components/events/eventEntry';
+import {EventErrors} from 'sentry/components/events/eventErrors';
+import {EventEvidence} from 'sentry/components/events/eventEvidence';
+import {EventExtraData} from 'sentry/components/events/eventExtraData';
+import {EventSdk} from 'sentry/components/events/eventSdk';
+import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
+import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
+import {EventGroupingInfo} from 'sentry/components/events/groupingInfo';
+import {Resources} from 'sentry/components/events/interfaces/performance/resources';
+import {SpanEvidenceSection} from 'sentry/components/events/interfaces/performance/spanEvidence';
+import {
+  getResourceDescription,
+  getResourceLinks,
+} from 'sentry/components/events/interfaces/performance/utils';
+import {EventPackageData} from 'sentry/components/events/packageData';
+import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration';
+import {EventSdkUpdates} from 'sentry/components/events/sdkUpdates';
+import {EventUserFeedback} from 'sentry/components/events/userFeedback';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Event, Group, IssueCategory, Project} from 'sentry/types';
+import {EntryType, EventTransaction} from 'sentry/types/event';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type GroupEventDetailsContentProps = {
+  group: Group;
+  project: Project;
+  event?: Event;
+};
+
+type GroupEventEntryProps = {
+  entryType: EntryType;
+  event: Event;
+  group: Group;
+  project: Project;
+};
+
+const GroupEventEntry = ({event, entryType, group, project}: GroupEventEntryProps) => {
+  const organization = useOrganization();
+  const entries = event.entries.filter(entry => entry.type === entryType);
+
+  if (!entries.length) {
+    return null;
+  }
+
+  return (
+    <Fragment>
+      {entries.map((entry, index) => (
+        <EventEntry
+          key={index}
+          projectSlug={project.slug}
+          isShare={false}
+          group={group}
+          {...{organization, event, entry}}
+        />
+      ))}
+    </Fragment>
+  );
+};
+
+const GroupEventDetailsContent = ({
+  group,
+  event,
+  project,
+}: GroupEventDetailsContentProps) => {
+  const organization = useOrganization();
+  const location = useLocation();
+  const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
+
+  if (!event) {
+    return (
+      <NotFoundMessage>
+        <h3>{t('Latest event not available')}</h3>
+      </NotFoundMessage>
+    );
+  }
+
+  const eventEntryProps = {group, event, project};
+
+  return (
+    <Fragment>
+      <EventErrors event={event} project={project} isShare={false} />
+      <EventCause
+        project={project}
+        eventId={event.id}
+        group={group}
+        commitRow={CommitRow}
+      />
+      {event.userReport && (
+        <EventDataSection title="User Feedback" type="user-feedback">
+          <EventUserFeedback
+            report={event.userReport}
+            orgId={organization.slug}
+            issueId={group.id}
+          />
+        </EventDataSection>
+      )}
+      <EventTagsAndScreenshot
+        event={event}
+        organization={organization}
+        projectSlug={project.slug}
+        location={location}
+        isShare={false}
+      />
+      <EventEvidence event={event} group={group} />
+      <GroupEventEntry entryType={EntryType.MESSAGE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXCEPTION} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.STACKTRACE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.THREADS} {...eventEntryProps} />
+      {group.issueCategory === IssueCategory.PERFORMANCE && (
+        <SpanEvidenceSection
+          event={event as EventTransaction}
+          organization={organization}
+        />
+      )}
+      <GroupEventEntry entryType={EntryType.HPKP} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.CSP} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXPECTCT} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.EXPECTSTAPLE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.TEMPLATE} {...eventEntryProps} />
+      <GroupEventEntry entryType={EntryType.BREADCRUMBS} {...eventEntryProps} />
+      <Resources
+        description={getResourceDescription(group.issueType)}
+        links={getResourceLinks(group.issueType, event.platform)}
+      />
path: static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
text: @user1:
This doesn't _quite_ do the same thing as `injectResourcesEntry`. The "Resources" section is supposed to go under the breadcrumbs only if they are included. If not, it's supposed to go under the "Span Evidence" section. Why it was defined that way I have no idea, but FYI that's the logic! Maybe worth asking Dora about this

@author:
Hmm I think this still does that, no? It's a bit confusing, but the entry types `HPKP, CSP, EXPECTCT, EXPECTSTAPLE, TEMPLATE` are never present in performance events. So it will always render below `BREADCRUMBS` when included, but otherwise it will be directly below the span evidence.

@user1:
🤔 that makes sense, then! cc @user2