Repository: getsentry/sentry
Pull request: feat(replay): Load all events with a given replayId on the details page
Fetch all events associated with the given root replay, and then render N `<Spans/>` and `<Breadcrumbs/>`, one for each related `sentry-replay-event`.

I put one instance of the `<TagsTable/>` because the tags for every set of `sentry-replay` events should be the same... as the data model evolves that will be a guarantee.

<img width="1698" alt="Screen Shot 2022-04-04 at 12 52 56 PM" src="https://user-images.githubusercontent.com/187460/161633299-29486acc-87ec-4233-8f42-ebe8a38fc599.png">
<img width="1443" alt="Screen Shot 2022-04-04 at 2 14 07 PM" src="https://user-images.githubusercontent.com/187460/161633308-d95d392c-39dd-4771-8c53-c38e32a7a623.png">



Fixes #33060 

Problem statement:
Fetch and render all replay events' spans and breadcrumbs
Currently we will have a transaction with name `sentry-replay` that represents the root event that will contain a collection of replays. This will be tagged with a `replayId` that spans other events as well.

This will be temporary, but when we load the details for a replay, let's fetch all transactions that have the same `replayId` tag as the root transaction. We will then aggregate all spans from these child transactions and render them. Treat breadcrumbs in a similar manner.

Hints from issue discussion:
[Failed](https://github.com/getsentry/sentry/actions/runs/2065832394) to route to `Team: Replays`.  😕

Defaulting to @getsentry/open-source for [triage](https://develop.sentry.dev/processing-tickets/#3-triage). ⏲️

Commit message: feat(replay): Load all events with a given replayId on the details page

Patch to review:
diff --git a/static/app/routes.tsx b/static/app/routes.tsx
index ca14021e7fcda4..df3bff3af19d28 100644
--- a/static/app/routes.tsx
+++ b/static/app/routes.tsx
@@ -1146,7 +1146,7 @@ function buildRoutes() {
         component={SafeLazyLoad}
       />
       <Route
-        path=":eventSlug/"
+        path=":replayId/"
         componentPromise={() => import('sentry/views/replays/details')}
         component={SafeLazyLoad}
       />
diff --git a/static/app/views/replays/detail/replayEvents.tsx b/static/app/views/replays/detail/replayEvents.tsx
new file mode 100644
index 00000000000000..9ec06bfb989030
--- /dev/null
+++ b/static/app/views/replays/detail/replayEvents.tsx
@@ -0,0 +1,122 @@
+import {RouteComponentProps, withRouter} from 'react-router';
+import styled from '@emotion/styled';
+import first from 'lodash/first';
+
+import AsyncComponent from 'sentry/components/asyncComponent';
+import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
+import EventMessage from 'sentry/components/events/eventMessage';
+import RRWebIntegration from 'sentry/components/events/rrwebIntegration';
+import * as Layout from 'sentry/components/layouts/thirds';
+import TagsTable from 'sentry/components/tagsTable';
+import space from 'sentry/styles/space';
+import {Event} from 'sentry/types/event';
+import EventView from 'sentry/utils/discover/eventView';
+import {getMessage} from 'sentry/utils/events';
+
+import {Replay} from '../types';
+
+type Props = AsyncComponent['props'] &
+  RouteComponentProps<{orgId: string; replayId: string}, {}> & {
+    eventSlugs: string[];
+  };
+
+type State = AsyncComponent['state'] & {
+  replayList: Replay[] | null;
+};
+
+const EventHeader = ({event}: {event: Event}) => {
+  const message = getMessage(event);
+  return (
+    <EventHeaderContainer data-test-id="event-header">
+      <TitleWrapper>
+        <EventOrGroupTitle data={event} /> {event.id}
+      </TitleWrapper>
+      {message && (
+        <MessageWrapper>
+          <EventMessage message={message} />
+        </MessageWrapper>
+      )}
+    </EventHeaderContainer>
+  );
+};
+
+class ReplayEvents extends AsyncComponent<Props, State> {
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {eventSlugs, params, location} = this.props;
+
+    return eventSlugs.map(eventSlug => {
+      const eventView = EventView.fromLocation(location);
+      const query = eventView.getEventsAPIPayload(location);
+
+      return [eventSlug, `/organizations/${params.orgId}/events/${eventSlug}/`, {query}];
+    });
+  }
+
+  renderLoading() {
+    return super.renderLoading(); // todo
+  }
+
+  generateTagUrl = () => {
+    return ''; // todo
+  };
+
+  getEventView = (): EventView => {
+    const {location} = this.props;
+
+    return EventView.fromLocation(location);
+  };
+
+  getProjectSlug(event: Event) {
+    return event.projectSlug || event['project.name']; // seems janky
+  }
+
+  renderBody() {
+    const {params, eventSlugs} = this.props;
+    const orgSlug = params.orgId;
+
+    const events = eventSlugs.map(eventSlug => this.state[eventSlug]);
+    const eventView = this.getEventView();
+
+    const firstEvent = first(events);
+
+    return (
+      <Layout.Body>
+        <Layout.Main>
+          {events.map(event => (
+            <EventHeader key={event.id} event={event} />
+          ))}
+          {events.map(event => (
+            <RRWebIntegration
+              key={event.id}
+              event={event}
+              orgId={orgSlug}
+              projectId={this.getProjectSlug(event)}
+            />
+          ))}
+        </Layout.Main>
+        <Layout.Side>
+          <TagsTable
+            generateUrl={this.generateTagUrl}
+            event={firstEvent}
+            query={eventView.query}
+          />
+        </Layout.Side>
+      </Layout.Body>
+    );
+  }
+}
+
+const EventHeaderContainer = styled('div')`
+  max-width: ${p => p.theme.breakpoints[0]};
+`;
+
+const TitleWrapper = styled('div')`
+  font-size: ${p => p.theme.headerFontSize};
+  margin-top: 20px;
+`;
+
+const MessageWrapper = styled('div')`
+  margin-top: ${space(1)};
+`;
+
+export default withRouter(ReplayEvents);
diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx
index cefce32aa94e8a..101b3c6523de7e 100644
--- a/static/app/views/replays/details.tsx
+++ b/static/app/views/replays/details.tsx
@@ -3,43 +3,24 @@ import styled from '@emotion/styled';
 
 import Breadcrumbs from 'sentry/components/breadcrumbs';
 import NotFound from 'sentry/components/errors/notFound';
-import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
-import EventMessage from 'sentry/components/events/eventMessage';
-import RRWebIntegration from 'sentry/components/events/rrwebIntegration';
 import * as Layout from 'sentry/components/layouts/thirds';
-import TagsTable from 'sentry/components/tagsTable';
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
-import space from 'sentry/styles/space';
 import {Event} from 'sentry/types/event';
 import EventView from 'sentry/utils/discover/eventView';
-import {getMessage} from 'sentry/utils/events';
+import {generateEventSlug} from 'sentry/utils/discover/urls';
 import AsyncView from 'sentry/views/asyncView';
 
+import ReplayEvents from './detail/replayEvents';
+
 type Props = AsyncView['props'] &
-  RouteComponentProps<{eventSlug: string; orgId: string}, {}>;
+  RouteComponentProps<{orgId: string; replayId: string}, {}>;
 
 type State = AsyncView['state'] & {
   event: Event | undefined;
   eventView: EventView;
 };
 
-const EventHeader = ({event}: {event: Event}) => {
-  const message = getMessage(event);
-  return (
-    <EventHeaderContainer data-test-id="event-header">
-      <TitleWrapper>
-        <EventOrGroupTitle data={event} />
-      </TitleWrapper>
-      {message && (
-        <MessageWrapper>
-          <EventMessage message={message} />
-        </MessageWrapper>
-      )}
-    </EventHeaderContainer>
-  );
-};
-
 class ReplayDetails extends AsyncView<Props, State> {
   state: State = {
     eventView: EventView.fromLocation(this.props.location),
@@ -53,11 +34,21 @@ class ReplayDetails extends AsyncView<Props, State> {
   getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
     const {params, location} = this.props;
 
-    const eventView = EventView.fromLocation(location);
-    const query = eventView.getEventsAPIPayload(location);
+    const eventList = EventView.fromSavedQuery({
+      id: '',
+      name: '',
+      version: 2,
+      fields: ['timestamp', 'replayId'],
+      orderby: 'timestamp',
+      projects: [],
+      range: '14d',
+      query: `transaction:sentry-replay`,
+    });
+    eventList.additionalConditions.addFilterValues('replayId', [params.replayId]);
+    const apiPayload = eventList.getEventsAPIPayload(location);
 
     return [
-      ['event', `/organizations/${params.orgId}/events/${params.eventSlug}/`, {query}],
+      ['eventList', `/organizations/${params.orgId}/eventsv2/`, {query: apiPayload}],
     ];
   }
 
@@ -72,25 +63,19 @@ class ReplayDetails extends AsyncView<Props, State> {
     return <PageContent>{super.renderLoading()}</PageContent>;
   }
 
-  generateTagUrl = () => {
-    return ''; // todo
-  };
-
-  getEventView = (): EventView => {
-    const {location} = this.props;
-
-    return EventView.fromLocation(location);
-  };
-
   renderBody() {
-    const {event} = this.state;
+    const {eventList} = this.state;
     const orgSlug = this.props.params.orgId;
-    if (!event) {
+    if (!eventList) {
       return <NotFound />;
     }
 
-    const eventView = this.getEventView();
-    const projectSlug = event.projectSlug || event['project.name']; // seems janky
+    const eventSlugs = eventList.data.map(event =>
+      generateEventSlug({
+        project: event['project.name'],
+        id: event.id,
+      })
+    );
 
     return (
       <NoPaddingContent>
@@ -105,40 +90,21 @@ class ReplayDetails extends AsyncView<Props, State> {
                 {label: t('Replay Details')}, // TODO: put replay ID or something here
               ]}
             />
-            <EventHeader event={event} />
+            <TitleWrapper>Replay: {this.props.params.replayId}</TitleWrapper>
           </Layout.HeaderContent>
         </Layout.Header>
 
-        <Layout.Body>
-          <Layout.Main>
-            <RRWebIntegration event={event} orgId={orgSlug} projectId={projectSlug} />
-          </Layout.Main>
-          <Layout.Side>
-            <TagsTable
-              generateUrl={this.generateTagUrl}
-              event={event}
-              query={eventView.query}
-            />
-          </Layout.Side>
-        </Layout.Body>
+        <ReplayEvents {...this.props} eventSlugs={eventSlugs} />
       </NoPaddingContent>
     );
   }
 }
 
-const EventHeaderContainer = styled('div')`
-  max-width: ${p => p.theme.breakpoints[0]};
-`;
-
 const TitleWrapper = styled('div')`
   font-size: ${p => p.theme.headerFontSize};
   margin-top: 20px;
 `;
 
-const MessageWrapper = styled('div')`
-  margin-top: ${space(1)};
-`;
-
 const NoPaddingContent = styled(PageContent)`
   padding: 0;
 `;
diff --git a/static/app/views/replays/replays.tsx b/static/app/views/replays/replays.tsx
index cc3551f34b4c78..263da7533f5a9e 100644
--- a/static/app/views/replays/replays.tsx
+++ b/static/app/views/replays/replays.tsx
@@ -79,10 +79,7 @@ class Replays extends AsyncView<Props, State> {
               {replayList?.map(replay => (
                 <PanelItemCentered key={replay.id}>
                   <Link
-                    to={`/organizations/${organization.slug}/replays/${generateEventSlug({
-                      project: replay['project.name'],
-                      id: replay.id,
-                    })}/`}
+                    to={`/organizations/${organization.slug}/replays/${replay.replayId}/`}
                   >
                     {replay.timestamp}
                   </Link>


Code review comments:
diff_hunk: @@ -0,0 +1,122 @@
+import {RouteComponentProps, withRouter} from 'react-router';
+import styled from '@emotion/styled';
+import first from 'lodash/first';
+
+import AsyncComponent from 'sentry/components/asyncComponent';
+import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
+import EventMessage from 'sentry/components/events/eventMessage';
+import RRWebIntegration from 'sentry/components/events/rrwebIntegration';
+import * as Layout from 'sentry/components/layouts/thirds';
+import TagsTable from 'sentry/components/tagsTable';
+import space from 'sentry/styles/space';
+import {Event} from 'sentry/types/event';
+import EventView from 'sentry/utils/discover/eventView';
+import {getMessage} from 'sentry/utils/events';
+
+import {Replay} from '../types';
+
+type Props = AsyncComponent['props'] &
+  RouteComponentProps<{orgId: string; replayId: string}, {}> & {
+    eventSlugs: string[];
+  };
+
+type State = AsyncComponent['state'] & {
+  replayList: Replay[] | null;
+};
+
+const EventHeader = ({event}: {event: Event}) => {
+  const message = getMessage(event);
+  return (
+    <EventHeaderContainer data-test-id="event-header">
+      <TitleWrapper>
+        <EventOrGroupTitle data={event} /> {event.id}
+      </TitleWrapper>
+      {message && (
+        <MessageWrapper>
+          <EventMessage message={message} />
+        </MessageWrapper>
+      )}
+    </EventHeaderContainer>
+  );
+};
+
+class ReplayEvents extends AsyncComponent<Props, State> {
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {eventSlugs, params, location} = this.props;
+
+    return eventSlugs.map(eventSlug => {
+      const eventView = EventView.fromLocation(location);
+      const query = eventView.getEventsAPIPayload(location);
+
+      return [eventSlug, `/organizations/${params.orgId}/events/${eventSlug}/`, {query}];
path: static/app/views/replays/detail/replayEvents.tsx
text: @author:
Here's where we are doing N fetches, one per each event that is related to the `replayId`