Browse Source

feat(replays): Update Replay pages to read from the new api (#37849)

Convert all the places were we read Replay's to use the new backend api's.

There are a bunch of files, but really only 4 spots to consider:
1. The replay-details page is mostly handled by changes to `useReplayData` & simplification inside `ReplayReader`
2. There are 3 list pages:
  1. `static/app/views/replays/replays.tsx` and `replaysTable.tsx` -> The main list page
  2. `groupReplays.tsx`
  3. `performance/transactionSummary/*`

In the case of the list pages:
- The duplicated table headers is moved into `replaysTable.tsx` 
- Expected data types are improved: `ReplayListLocationQuery` to represent list query params & `ReplayListRecord` to represent the ajax response that will be passed into the `replaysTable.tsx`

There is also the shared utility `mapResponseToReplayRecord` which hydrates date fields.
Other things were tidied up inside `replayDataUtils.tsx`, the biggest win will be when `replayTimestamps` gets deleted. Also I'm expected `getPageLinks()` to be deleted soonish.


Fixes [#36585](https://github.com/getsentry/sentry/issues/36585)
Fixes [#36586](https://github.com/getsentry/sentry/issues/36586)
Fixes [#36518](https://github.com/getsentry/sentry/issues/36518)
Ryan Albrecht 2 years ago
parent
commit
c07a2d907d

+ 11 - 12
static/app/components/replays/replayHighlight.tsx

@@ -2,29 +2,28 @@ import React from 'react';
 
 import ScoreBar from 'sentry/components/scoreBar';
 import CHART_PALETTE from 'sentry/constants/chartPalette';
-import {ReplayDurationAndErrors} from 'sentry/views/replays/types';
+import type {ReplayListRecord} from 'sentry/views/replays/types';
 
 interface Props {
-  data: ReplayDurationAndErrors | undefined;
+  replay: undefined | Pick<ReplayListRecord, 'countErrors' | 'duration' | 'urls'>;
 }
 
-function replayHighlight({data}: Props) {
+const palette = new Array(10).fill([CHART_PALETTE[0][0]]);
+
+function ReplayHighlight({replay}: Props) {
   let score = 1;
 
-  if (data) {
-    // Mocked data 👇 - this will change with the new backend
-    const pagesVisited = 1;
-    const {count_if_event_type_equals_error: errors, 'equation[0]': durationInSeconds} =
-      data;
+  if (replay) {
+    const {countErrors, duration, urls} = replay;
+    const pagesVisited = urls.length;
 
-    const pagesVisitedOverTime = pagesVisited / (durationInSeconds || 1);
+    const pagesVisitedOverTime = pagesVisited / (duration || 1);
 
-    score = (errors * 25 + pagesVisited * 5 + pagesVisitedOverTime) / 10;
+    score = (countErrors * 25 + pagesVisited * 5 + pagesVisitedOverTime) / 10;
     score = Math.floor(Math.min(10, Math.max(1, score)));
   }
 
-  const palette = new Array(10).fill([CHART_PALETTE[0][0]]);
   return <ScoreBar size={20} score={score} palette={palette} radius={0} />;
 }
 
-export default replayHighlight;
+export default ReplayHighlight;

+ 90 - 65
static/app/utils/replays/hooks/useReplayData.tsx

@@ -2,10 +2,10 @@ import {useCallback, useEffect, useMemo, useState} from 'react';
 import * as Sentry from '@sentry/react';
 import {inflate} from 'pako';
 
-import {IssueAttachment} from 'sentry/types';
-import {EventTransaction} from 'sentry/types/event';
+import type {ResponseMeta} from 'sentry/api';
 import flattenListOfObjects from 'sentry/utils/replays/flattenListOfObjects';
 import useReplayErrors from 'sentry/utils/replays/hooks/useReplayErrors';
+import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
 import ReplayReader from 'sentry/utils/replays/replayReader';
 import RequestError from 'sentry/utils/requestError/requestError';
 import useApi from 'sentry/utils/useApi';
@@ -13,6 +13,8 @@ import type {
   RecordingEvent,
   ReplayCrumb,
   ReplayError,
+  ReplayRecord,
+  ReplaySegment,
   ReplaySpan,
 } from 'sentry/views/replays/types';
 
@@ -24,11 +26,6 @@ type State = {
    */
   errors: undefined | ReplayError[];
 
-  /**
-   * The root replay event
-   */
-  event: undefined | EventTransaction;
-
   /**
    * If any request returned an error then nothing is being returned
    */
@@ -45,6 +42,11 @@ type State = {
    */
   isErrorsFetching: boolean;
 
+  /**
+   * The root replay event
+   */
+  replayRecord: undefined | ReplayRecord;
+
   /**
    * The flattened list of rrweb events. These are stored as multiple attachments on the root replay object: the `event` prop.
    */
@@ -57,10 +59,10 @@ type Options = {
   /**
    * The organization slug
    */
-  orgSlug: string;
 
+  orgSlug: string;
   /**
-   * The projectSlug and eventId concatenated together
+   * The projectSlug and replayId concatenated together
    */
   replaySlug: string;
 };
@@ -78,11 +80,6 @@ interface Result extends Pick<State, 'fetchError' | 'fetching'> {
   replay: ReplayReader | null;
 }
 
-const IS_RRWEB_ATTACHMENT_FILENAME = /rrweb-[0-9]{13}.json/;
-
-function isRRWebEventAttachment(attachment: IssueAttachment) {
-  return IS_RRWEB_ATTACHMENT_FILENAME.test(attachment.name);
-}
 export function mapRRWebAttachments(unsortedReplayAttachments): ReplayAttachment {
   const replayAttachments: ReplayAttachment = {
     breadcrumbs: [],
@@ -104,16 +101,40 @@ export function mapRRWebAttachments(unsortedReplayAttachments): ReplayAttachment
 }
 
 const INITIAL_STATE: State = Object.freeze({
+  breadcrumbs: undefined,
   errors: undefined,
-  event: undefined,
   fetchError: undefined,
   fetching: true,
   isErrorsFetching: true,
+  replayRecord: undefined,
   rrwebEvents: undefined,
   spans: undefined,
-  breadcrumbs: undefined,
 });
 
+async function decompressSegmentData(
+  data: any,
+  _textStatus: string | undefined,
+  resp: ResponseMeta | undefined
+) {
+  // for non-compressed events, parse and return
+  try {
+    return mapRRWebAttachments(JSON.parse(data));
+  } catch (error) {
+    // swallow exception.. if we can't parse it, it's going to be compressed
+  }
+
+  // for non-compressed events, parse and return
+  try {
+    // for compressed events, inflate the blob and map the events
+    const responseBlob = await resp?.rawResponse.blob();
+    const responseArray = (await responseBlob?.arrayBuffer()) as Uint8Array;
+    const parsedPayload = JSON.parse(inflate(responseArray, {to: 'string'}));
+    return mapRRWebAttachments(parsedPayload);
+  } catch (error) {
+    return {};
+  }
+}
+
 /**
  * A react hook to load core replay data over the network.
  *
@@ -132,58 +153,50 @@ const INITIAL_STATE: State = Object.freeze({
  * @param {orgSlug, replaySlug} Where to find the root replay event
  * @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
  */
-function useReplayData({orgSlug, replaySlug}: Options): Result {
-  const [projectId, eventId] = replaySlug.split(':');
+function useReplayData({replaySlug, orgSlug}: Options): Result {
+  const [projectSlug, replayId] = replaySlug.split(':');
 
   const api = useApi();
   const [state, setState] = useState<State>(INITIAL_STATE);
 
-  const fetchEvent = useCallback(() => {
-    return api.requestPromise(
-      `/organizations/${orgSlug}/events/${replaySlug}/`
-    ) as Promise<EventTransaction>;
-  }, [api, orgSlug, replaySlug]);
-
-  const fetchRRWebEvents = useCallback(async () => {
-    const attachmentIds = (await api.requestPromise(
-      `/projects/${orgSlug}/${projectId}/events/${eventId}/attachments/`
-    )) as IssueAttachment[];
-    const rrwebAttachmentIds = attachmentIds.filter(isRRWebEventAttachment);
-    const attachments = await Promise.all(
-      rrwebAttachmentIds.map(async attachment => {
-        const response = await api.requestPromise(
-          `/api/0/projects/${orgSlug}/${projectId}/events/${eventId}/attachments/${attachment.id}/?download`,
-          {
-            includeAllArgs: true,
-          }
-        );
-
-        // for non-compressed events, parse and return
-        try {
-          return mapRRWebAttachments(JSON.parse(response[0]));
-        } catch (error) {
-          // swallow exception.. if we can't parse it, it's going to be compressed
-        }
-
-        // for non-compressed events, parse and return
-        try {
-          // for compressed events, inflate the blob and map the events
-          const responseBlob = await response[2]?.rawResponse.blob();
-          const responseArray = (await responseBlob?.arrayBuffer()) as Uint8Array;
-          const parsedPayload = JSON.parse(inflate(responseArray, {to: 'string'}));
-          return mapRRWebAttachments(parsedPayload);
-        } catch (error) {
-          return {};
-        }
-      })
+  // Fetch every field of the replay. We're overfetching, not every field is needed
+  const fetchReplay = useCallback(async () => {
+    const response = await api.requestPromise(
+      `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/`
     );
+    return response.data;
+  }, [api, orgSlug, projectSlug, replayId]);
 
-    // ReplayAttachment[] => ReplayAttachment (merge each key of ReplayAttachment)
-    return flattenListOfObjects(attachments);
-  }, [api, eventId, orgSlug, projectId]);
+  const fetchSegmentList = useCallback(async () => {
+    const response = await api.requestPromise(
+      `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/`
+    );
+    return response.data as ReplaySegment[];
+  }, [api, orgSlug, projectSlug, replayId]);
+
+  const fetchRRWebEvents = useCallback(
+    async (segmentIds: number[]) => {
+      const attachments = await Promise.all(
+        segmentIds.map(async segmentId => {
+          const response = await api.requestPromise(
+            `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/${segmentId}/?download`,
+            {
+              includeAllArgs: true,
+            }
+          );
+
+          return decompressSegmentData(...response);
+        })
+      );
+
+      // ReplayAttachment[] => ReplayAttachment (merge each key of ReplayAttachment)
+      return flattenListOfObjects(attachments);
+    },
+    [api, replayId, orgSlug, projectSlug]
+  );
 
   const {isLoading: isErrorsFetching, data: errors} = useReplayErrors({
-    replayId: eventId,
+    replayId,
   });
 
   useEffect(() => {
@@ -201,11 +214,17 @@ function useReplayData({orgSlug, replaySlug}: Options): Result {
     setState(INITIAL_STATE);
 
     try {
-      const [event, attachments] = await Promise.all([fetchEvent(), fetchRRWebEvents()]);
+      const [record, segments] = await Promise.all([fetchReplay(), fetchSegmentList()]);
+
+      // TODO(replays): Something like `range(record.countSegments)` could work
+      // once we make sure that segments have sequential id's and are not dropped.
+      const segmentIds = segments.map(segment => segment.segmentId);
+
+      const attachments = await fetchRRWebEvents(segmentIds);
 
       setState(prev => ({
         ...prev,
-        event,
+        replayRecord: mapResponseToReplayRecord(record),
         fetchError: undefined,
         fetching: prev.isErrorsFetching || false,
         rrwebEvents: attachments.recording,
@@ -220,7 +239,7 @@ function useReplayData({orgSlug, replaySlug}: Options): Result {
         fetching: false,
       });
     }
-  }, [fetchEvent, fetchRRWebEvents]);
+  }, [fetchReplay, fetchSegmentList, fetchRRWebEvents]);
 
   useEffect(() => {
     loadEvents();
@@ -228,13 +247,19 @@ function useReplayData({orgSlug, replaySlug}: Options): Result {
 
   const replay = useMemo(() => {
     return ReplayReader.factory({
-      event: state.event,
+      replayRecord: state.replayRecord,
       errors: state.errors,
       rrwebEvents: state.rrwebEvents,
       breadcrumbs: state.breadcrumbs,
       spans: state.spans,
     });
-  }, [state.event, state.rrwebEvents, state.breadcrumbs, state.spans, state.errors]);
+  }, [
+    state.replayRecord,
+    state.rrwebEvents,
+    state.breadcrumbs,
+    state.spans,
+    state.errors,
+  ]);
 
   return {
     fetchError: state.fetchError,

+ 172 - 0
static/app/utils/replays/hooks/useReplayList.tsx

@@ -0,0 +1,172 @@
+import {useCallback, useEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
+import type {Location} from 'history';
+import {stringify} from 'query-string';
+
+import type {Organization} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {getLocalToSystem, getUserTimezone, getUtcDateString} from 'sentry/utils/dates';
+import EventView from 'sentry/utils/discover/eventView';
+import {decodeInteger} from 'sentry/utils/queryString';
+import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
+import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
+
+const DEFAULT_LIMIT = 50;
+export const DEFAULT_SORT = '-startedAt';
+
+export const REPLAY_LIST_FIELDS = [
+  'countErrors',
+  'duration',
+  'finishedAt',
+  'id',
+  'projectId',
+  'startedAt',
+  'urls',
+  'user',
+];
+
+type Options = {
+  eventView: EventView;
+  organization: Organization;
+  defaultLimit?: number;
+};
+
+type State = {
+  fetchError: undefined | RequestError;
+  isFetching: boolean;
+  pageLinks: null | string;
+  replays: undefined | ReplayListRecord[];
+};
+
+type Result = State;
+
+function useReplayList({
+  eventView,
+  organization,
+  defaultLimit = DEFAULT_LIMIT,
+}: Options): Result {
+  const api = useApi();
+  const location = useLocation<ReplayListLocationQuery>();
+
+  const [data, setData] = useState<State>({
+    fetchError: undefined,
+    isFetching: true,
+    pageLinks: null,
+    replays: [],
+  });
+
+  const init = useCallback(async () => {
+    try {
+      setData(prev => ({
+        ...prev,
+        isFetching: true,
+      }));
+
+      const queryLimit = decodeInteger(location.query.limit, defaultLimit);
+      const queryOffset = decodeInteger(location.query.offset, 0);
+
+      const path = `/organizations/${organization.slug}/replays/`;
+      const query = eventView.generateQueryStringObject();
+
+      // TODO(replays): Need to add one as a sentinel value to detect if there
+      // are more pages. Shouldn't need this when response has pageLinks header.
+      query.limit = String(queryLimit + 1);
+      query.offset = String(queryOffset);
+      const response = await api.requestPromise(path, {
+        query: eventView.getEventsAPIPayload(location),
+      });
+
+      // TODO(replays): Remove the `slice()` call once `pageLinks` is in response headers.
+      const records = response.data.slice(0, queryLimit);
+
+      // TODO(replays): Response should include pageLinks header instead of this.
+      const pageLinks = getPageLinks({
+        defaultLimit,
+        query: eventView.generateQueryStringObject(),
+        path,
+        records: response.data,
+      });
+
+      setData({
+        fetchError: undefined,
+        isFetching: false,
+        pageLinks,
+        replays: records.map(mapResponseToReplayRecord),
+      });
+    } catch (error) {
+      Sentry.captureException(error);
+      setData({
+        fetchError: error,
+        isFetching: false,
+        pageLinks: null,
+        replays: [],
+      });
+    }
+  }, [api, organization, defaultLimit, location, eventView]);
+
+  useEffect(() => {
+    init();
+  }, [init]);
+
+  return data;
+}
+
+// We should be getting pageLinks as response headers, not constructing them.
+function getPageLinks({
+  defaultLimit,
+  path,
+  query,
+  records,
+}: {
+  defaultLimit: number;
+  path: string;
+  query: Location<ReplayListLocationQuery>['query'];
+  records: unknown[];
+}) {
+  // Remove extra fields that EventView uses internally
+  Object.keys(query).forEach(key => {
+    if (!defined(query[key]) || query[key] === '') {
+      delete query[key];
+    }
+  });
+
+  // Add & subtract one because we added one above as a sentinel to tell if there is a next page
+  const queryLimit = decodeInteger(query.limit, defaultLimit);
+  const queryOffset = decodeInteger(query.offset, 0);
+
+  const prevOffset = queryOffset - queryLimit;
+  const nextOffset = queryOffset + queryLimit;
+
+  const hasPrev = prevOffset >= 0;
+  const hasNext = records.length === queryLimit;
+
+  const utc =
+    query.utc === 'true'
+      ? true
+      : query.utc === 'false'
+      ? false
+      : getUserTimezone() === 'UTC';
+
+  const qs = stringify({
+    ...query,
+    limit: String(queryLimit),
+    offset: String(queryOffset),
+    start: getUtcDateString(utc ? query.start : getLocalToSystem(query.start)),
+    end: getUtcDateString(utc ? query.end : getLocalToSystem(query.end)),
+  });
+  const url = `${path}?${qs}`;
+
+  return [
+    hasPrev
+      ? `<${url}>; rel="previous"; cursor="${prevOffset}"`
+      : `<${url}>; rel="previous"; results="false"`,
+    hasNext
+      ? `<${url}>; rel="next"; cursor="${nextOffset}"`
+      : `<${url}>; rel="next"; results="false" `,
+  ].join(',');
+}
+
+export default useReplayList;

+ 27 - 17
static/app/utils/replays/replayDataUtils.tsx

@@ -9,48 +9,59 @@ import type {
   RawCrumb,
 } from 'sentry/types/breadcrumbs';
 import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
-import {Event} from 'sentry/types/event';
 import type {
   RecordingEvent,
   ReplayCrumb,
   ReplayError,
+  ReplayRecord,
   ReplaySpan,
 } from 'sentry/views/replays/types';
 
+export function mapResponseToReplayRecord(apiResponse: any): ReplayRecord {
+  return {
+    ...apiResponse,
+    ...(apiResponse.startedAt ? {startedAt: new Date(apiResponse.startedAt)} : {}),
+    ...(apiResponse.finishedAt ? {finishedAt: new Date(apiResponse.finishedAt)} : {}),
+    user: {
+      email: apiResponse.user.email || '',
+      id: apiResponse.user.id || '',
+      ip_address: apiResponse.user.ip_address || '',
+      name: apiResponse.user.name || '',
+      username: '',
+    },
+  };
+}
+
 export function rrwebEventListFactory(
-  startTimestampMs: number,
-  endTimestampMs: number,
+  replayRecord: ReplayRecord,
   rrwebEvents: RecordingEvent[]
 ) {
   const events = ([] as RecordingEvent[]).concat(rrwebEvents).concat({
     type: 5, // EventType.Custom,
-    timestamp: endTimestampMs,
+    timestamp: replayRecord.finishedAt.getTime(),
     data: {
       tag: 'replay-end',
     },
   });
+
   events.sort((a, b) => a.timestamp - b.timestamp);
 
-  const firstRRWebEvent = first(events);
-  if (firstRRWebEvent) {
-    firstRRWebEvent.timestamp = startTimestampMs;
-  }
+  const firstRRWebEvent = first(events) as RecordingEvent;
+  firstRRWebEvent.timestamp = replayRecord.startedAt.getTime();
 
   return events;
 }
 
 export function breadcrumbFactory(
-  startTimestamp: number,
-  rootEvent: Event,
+  replayRecord: ReplayRecord,
   errors: ReplayError[],
   rawCrumbs: ReplayCrumb[],
   spans: ReplaySpan[]
 ): Crumb[] {
-  const {tags} = rootEvent;
-  const initialUrl = tags.find(tag => tag.key === 'url')?.value;
+  const initialUrl = replayRecord.tags.url;
   const initBreadcrumb = {
     type: BreadcrumbType.INIT,
-    timestamp: new Date(startTimestamp).toISOString(),
+    timestamp: replayRecord.startedAt.toISOString(),
     level: BreadcrumbLevelType.INFO,
     message: initialUrl,
     data: {
@@ -134,11 +145,10 @@ export function spansFactory(spans: ReplaySpan[]) {
 }
 
 /**
- * The original `this._event.startTimestamp` and `this._event.endTimestamp`
- * are the same. It's because the root replay event is re-purposing the
- * `transaction` type, but it is not a real span occurring over time.
- * So we need to figure out the real start and end timestamps based on when
+ * We need to figure out the real start and end timestamps based on when
  * first and last bits of data were collected. In milliseconds.
+ *
+ * @deprecated Once the backend returns the corrected timestamps, this is not needed.
  */
 export function replayTimestamps(
   rrwebEvents: RecordingEvent[],

+ 20 - 86
static/app/utils/replays/replayReader.tsx

@@ -1,6 +1,4 @@
-import type {BreadcrumbTypeNavigation, Crumb} from 'sentry/types/breadcrumbs';
-import {BreadcrumbType} from 'sentry/types/breadcrumbs';
-import type {Event, EventTransaction} from 'sentry/types/event';
+import type {Crumb} from 'sentry/types/breadcrumbs';
 import {
   breadcrumbFactory,
   replayTimestamps,
@@ -23,7 +21,7 @@ interface ReplayReaderParams {
   /**
    * The root Replay event, created at the start of the browser session.
    */
-  event: Event | undefined;
+  replayRecord: ReplayRecord | undefined;
 
   /**
    * The captured data from rrweb.
@@ -39,109 +37,45 @@ type RequiredNotNull<T> = {
 };
 
 export default class ReplayReader {
-  static factory({breadcrumbs, event, errors, rrwebEvents, spans}: ReplayReaderParams) {
-    if (!breadcrumbs || !event || !rrwebEvents || !spans || !errors) {
+  static factory({
+    breadcrumbs,
+    replayRecord,
+    errors,
+    rrwebEvents,
+    spans,
+  }: ReplayReaderParams) {
+    if (!breadcrumbs || !replayRecord || !rrwebEvents || !spans || !errors) {
       return null;
     }
 
-    return new ReplayReader({breadcrumbs, event, errors, rrwebEvents, spans});
+    return new ReplayReader({breadcrumbs, replayRecord, errors, rrwebEvents, spans});
   }
 
   private constructor({
     breadcrumbs,
-    event,
+    replayRecord,
     errors,
     rrwebEvents,
     spans,
   }: RequiredNotNull<ReplayReaderParams>) {
+    // TODO(replays): We should get correct timestamps from the backend instead
+    // of having to fix them up here.
     const {startTimestampMs, endTimestampMs} = replayTimestamps(
       rrwebEvents,
       breadcrumbs,
       spans
     );
+    replayRecord.startedAt = new Date(startTimestampMs);
+    replayRecord.finishedAt = new Date(endTimestampMs);
 
     this.spans = spansFactory(spans);
-    this.breadcrumbs = breadcrumbFactory(
-      startTimestampMs,
-      event,
-      errors,
-      breadcrumbs,
-      this.spans
-    );
+    this.breadcrumbs = breadcrumbFactory(replayRecord, errors, breadcrumbs, this.spans);
 
-    this.rrwebEvents = rrwebEventListFactory(
-      startTimestampMs,
-      endTimestampMs,
-      rrwebEvents
-    );
+    this.rrwebEvents = rrwebEventListFactory(replayRecord, rrwebEvents);
 
-    this.event = {
-      ...event,
-      startTimestamp: startTimestampMs / 1000,
-      endTimestamp: endTimestampMs / 1000,
-    } as EventTransaction;
-
-    const urls = (
-      this.getRawCrumbs().filter(
-        crumb => crumb.category === BreadcrumbType.NAVIGATION
-      ) as BreadcrumbTypeNavigation[]
-    )
-      .map(crumb => crumb.data?.to)
-      .filter(Boolean) as string[];
-
-    this.replayRecord = {
-      browser: {
-        name: null,
-        version: null,
-      },
-      countErrors: this.getRawCrumbs().filter(
-        crumb => crumb.category === BreadcrumbType.ERROR
-      ).length,
-      countSegments: 0,
-      countUrls: urls.length,
-      dist: this.event.dist,
-      device: {
-        brand: null,
-        family: null,
-        model: null,
-        name: null,
-      },
-      duration: endTimestampMs - startTimestampMs,
-      environment: null,
-      errorIds: [],
-      finishedAt: new Date(endTimestampMs), // TODO(replay): Convert from string to Date when reading API
-      id: this.event.id,
-      longestTransaction: 0,
-      os: {
-        name: null,
-        version: null,
-      },
-      platform: this.event.platform,
-      projectId: this.event.projectID,
-      release: null, // event.release is not a string, expected to be `version@1.4`
-      sdk: {
-        name: this.event.sdk?.name,
-        version: this.event.sdk?.version,
-      },
-      startedAt: new Date(startTimestampMs), // TODO(replay): Convert from string to Date when reading API
-      tags: this.event.tags.reduce((tags, {key, value}) => {
-        tags[key] = value;
-        return tags;
-      }, {} as ReplayRecord['tags']),
-      title: this.event.title,
-      traceIds: [],
-      urls,
-      user: {
-        email: this.event.user?.email,
-        id: this.event.user?.id,
-        ip_address: this.event.user?.ip_address,
-        name: this.event.user?.name,
-      },
-      userAgent: '',
-    } as ReplayRecord;
+    this.replayRecord = replayRecord;
   }
 
-  private event: EventTransaction;
   private replayRecord: ReplayRecord;
   private rrwebEvents: RecordingEvent[];
   private breadcrumbs: Crumb[];
@@ -151,7 +85,7 @@ export default class ReplayReader {
    * @returns Duration of Replay (milliseonds)
    */
   getDurationMs = () => {
-    return this.replayRecord.duration;
+    return this.replayRecord.duration * 1000;
   };
 
   getReplay = () => {

+ 42 - 121
static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx

@@ -1,151 +1,72 @@
-import {Fragment} from 'react';
+import {useMemo} from 'react';
 import styled from '@emotion/styled';
 
-import Link from 'sentry/components/links/link';
 import Pagination from 'sentry/components/pagination';
-import {PanelTable} from 'sentry/components/panels';
-import {IconArrow} from 'sentry/icons';
-import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
-import {Group, NewQuery} from 'sentry/types';
-import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
+import type {Group} from 'sentry/types';
 import EventView from 'sentry/utils/discover/eventView';
-import {getQueryParamAsString} from 'sentry/utils/replays/getQueryParamAsString';
+import {decodeScalar} from 'sentry/utils/queryString';
+import useReplayList, {
+  DEFAULT_SORT,
+  REPLAY_LIST_FIELDS,
+} from 'sentry/utils/replays/hooks/useReplayList';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import {useParams} from 'sentry/utils/useParams';
 import ReplayTable from 'sentry/views/replays/replayTable';
-import {ReplayDiscoveryListItem} from 'sentry/views/replays/types';
-
-const DEFAULT_DISCOVER_LIMIT = 50;
+import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
 type Props = {
   group: Group;
 };
 
 const GroupReplays = ({group}: Props) => {
-  const location = useLocation();
+  const location = useLocation<ReplayListLocationQuery>();
   const organization = useOrganization();
   const params = useParams();
   const {project} = group;
 
-  const getEventView = () => {
-    const {groupId} = params;
-    const eventQueryParams: NewQuery = {
-      id: '',
-      name: '',
-      version: 2,
-      fields: [
-        'replayId',
-        'eventID',
-        'project',
-        'timestamp',
-        'url',
-        'user.display',
-        'user.email',
-        'user.id',
-        'user.ip_address',
-        'user.name',
-        'user.username',
-      ],
-      projects: [+project.id],
-      orderby: getQueryParamAsString(query.sort) || '-timestamp',
-      query: `issue.id:${groupId} has:replayId`,
-    };
-
-    return EventView.fromNewQueryWithLocation(eventQueryParams, location);
-  };
-
-  const {query} = location;
-  const {cursor: _cursor, page: _page, ...currentQuery} = query;
-
-  const sort: {
-    field: string;
-  } = {
-    field: getQueryParamAsString(query.sort) || '-timestamp',
-  };
-
-  const arrowDirection = sort.field.startsWith('-') ? 'down' : 'up';
-  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
+  const eventView = useMemo(() => {
+    const query = decodeScalar(location.query.query, '');
+    const conditions = new MutableSearch(query);
+    conditions.addFilterValues('issue.id', params.groupId);
+
+    return EventView.fromNewQueryWithLocation(
+      {
+        id: '',
+        name: '',
+        version: 2,
+        fields: REPLAY_LIST_FIELDS,
+        projects: [Number(project.id)],
+        query: conditions.formatString(),
+        orderby: decodeScalar(location.query.sort, DEFAULT_SORT),
+      },
+      location
+    );
+  }, [location, project.id, params.groupId]);
+
+  const {replays, pageLinks, isFetching} = useReplayList({
+    organization,
+    eventView,
+  });
 
   return (
-    <Fragment>
-      <StyledPageContent>
-        <DiscoverQuery
-          eventView={getEventView()}
-          location={location}
-          orgSlug={organization.slug}
-          limit={DEFAULT_DISCOVER_LIMIT}
-        >
-          {data => {
-            return (
-              <Fragment>
-                <StyledPanelTable
-                  isLoading={data.isLoading}
-                  isEmpty={data.tableData?.data.length === 0}
-                  headers={[
-                    t('Session'),
-                    <SortLink
-                      key="timestamp"
-                      role="columnheader"
-                      aria-sort={
-                        !sort.field.endsWith('timestamp')
-                          ? 'none'
-                          : sort.field === '-timestamp'
-                          ? 'descending'
-                          : 'ascending'
-                      }
-                      to={{
-                        pathname: location.pathname,
-                        query: {
-                          ...currentQuery,
-                          sort: sort.field === '-timestamp' ? 'timestamp' : '-timestamp',
-                        },
-                      }}
-                    >
-                      {t('Timestamp')} {sort.field.endsWith('timestamp') && sortArrow}
-                    </SortLink>,
-                    t('Duration'),
-                    t('Errors'),
-                    t('Interest'),
-                  ]}
-                >
-                  {data.tableData ? (
-                    <ReplayTable
-                      idKey="replayId"
-                      replayList={data.tableData.data as ReplayDiscoveryListItem[]}
-                    />
-                  ) : null}
-                </StyledPanelTable>
-                <Pagination pageLinks={data.pageLinks} />
-              </Fragment>
-            );
-          }}
-        </DiscoverQuery>
-      </StyledPageContent>
-    </Fragment>
+    <StyledPageContent>
+      <ReplayTable
+        isFetching={isFetching}
+        replays={replays}
+        showProjectColumn={false}
+        sort={eventView.sorts[0]}
+      />
+      <Pagination pageLinks={pageLinks} />
+    </StyledPageContent>
   );
 };
 
-const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content;
-`;
-
 const StyledPageContent = styled(PageContent)`
   box-shadow: 0px 0px 1px ${p => p.theme.gray200};
   background-color: ${p => p.theme.background};
 `;
 
-const SortLink = styled(Link)`
-  color: inherit;
-
-  :hover {
-    color: inherit;
-  }
-
-  svg {
-    vertical-align: top;
-  }
-`;
-
 export default GroupReplays;

+ 21 - 79
static/app/views/performance/transactionSummary/transactionReplays/content.tsx

@@ -7,48 +7,34 @@ import DatePageFilter from 'sentry/components/datePageFilter';
 import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
 import SearchBar from 'sentry/components/events/searchBar';
 import * as Layout from 'sentry/components/layouts/thirds';
-import Link from 'sentry/components/links/link';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import Pagination from 'sentry/components/pagination';
-import {PanelTable} from 'sentry/components/panels';
-import {IconArrow} from 'sentry/icons';
-import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
+import type {Organization} from 'sentry/types';
 import {defined} from 'sentry/utils';
-import {TableData} from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
-import {decodeScalar} from 'sentry/utils/queryString';
-import {getQueryParamAsString} from 'sentry/utils/replays/getQueryParamAsString';
 import ReplayTable from 'sentry/views/replays/replayTable';
-import {ReplayDiscoveryListItem} from 'sentry/views/replays/types';
-
-import {SetStateAction} from '../types';
+import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
 
 type Props = {
   eventView: EventView;
-  location: Location;
+  isFetching: boolean;
+  location: Location<ReplayListLocationQuery>;
   organization: Organization;
   pageLinks: string | null;
-  setError: SetStateAction<string | undefined>;
-  tableData: TableData;
-  transactionName: string;
+  replays: ReplayListRecord[];
 };
 
-function ReplaysContent(props: Props) {
-  const {tableData, pageLinks, location, organization, eventView} = props;
-
-  const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
-  const query = decodeScalar(location.query.query, '');
-
-  const sort: {
-    field: string;
-  } = {
-    field: getQueryParamAsString(location.query.sort) || '-timestamp',
-  };
-  const arrowDirection = sort.field.startsWith('-') ? 'down' : 'up';
-  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
+function ReplaysContent({
+  eventView,
+  isFetching,
+  location,
+  organization,
+  pageLinks,
+  replays,
+}: Props) {
+  const query = location.query;
 
   function handleChange(key: string) {
     return function (value: string | undefined) {
@@ -81,66 +67,22 @@ function ReplaysContent(props: Props) {
         <SearchBar
           organization={organization}
           projectIds={eventView.project}
-          query={query}
+          query={query.query}
           fields={eventView.fields}
           onSearch={handleChange('query')}
         />
       </FilterActions>
-      <StyledPanelTable
-        isEmpty={tableData.data.length === 0}
-        headers={[
-          t('Session'),
-          <SortLink
-            key="timestamp"
-            role="columnheader"
-            aria-sort={
-              !sort.field.endsWith('timestamp')
-                ? 'none'
-                : sort.field === '-timestamp'
-                ? 'descending'
-                : 'ascending'
-            }
-            to={{
-              pathname: location.pathname,
-              query: {
-                ...currentQuery,
-                sort: sort.field === '-timestamp' ? 'timestamp' : '-timestamp',
-              },
-            }}
-          >
-            {t('Timestamp')} {sort.field.endsWith('timestamp') && sortArrow}
-          </SortLink>,
-          t('Duration'),
-          t('Errors'),
-          t('Interest'),
-        ]}
-      >
-        <ReplayTable
-          idKey="replayId"
-          replayList={tableData.data as ReplayDiscoveryListItem[]}
-        />
-      </StyledPanelTable>
+      <ReplayTable
+        isFetching={isFetching}
+        replays={replays}
+        showProjectColumn={false}
+        sort={eventView.sorts[0]}
+      />
       <Pagination pageLinks={pageLinks} />
     </Layout.Main>
   );
 }
 
-const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content;
-`;
-
-const SortLink = styled(Link)`
-  color: inherit;
-
-  :hover {
-    color: inherit;
-  }
-
-  svg {
-    vertical-align: top;
-  }
-`;
-
 const FilterActions = styled('div')`
   display: grid;
   gap: ${space(2)};

+ 44 - 56
static/app/views/performance/transactionSummary/transactionReplays/index.tsx

@@ -1,3 +1,4 @@
+import {Fragment, useEffect} from 'react';
 import {Location} from 'history';
 
 import Feature from 'sentry/components/acl/feature';
@@ -6,14 +7,18 @@ import * as Layout from 'sentry/components/layouts/thirds';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
-import {Organization, Project} from 'sentry/types';
-import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
+import type {Organization, Project} from 'sentry/types';
 import EventView from 'sentry/utils/discover/eventView';
 import {isAggregateField} from 'sentry/utils/discover/fields';
 import {decodeScalar} from 'sentry/utils/queryString';
+import useReplayList, {
+  DEFAULT_SORT,
+  REPLAY_LIST_FIELDS,
+} from 'sentry/utils/replays/hooks/useReplayList';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import withOrganization from 'sentry/utils/withOrganization';
 import withProjects from 'sentry/utils/withProjects';
+import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
 import PageLayout, {ChildProps} from '../pageLayout';
 import Tab from '../tabs';
@@ -21,7 +26,7 @@ import Tab from '../tabs';
 import ReplaysContent from './content';
 
 type Props = {
-  location: Location;
+  location: Location<ReplayListLocationQuery>;
   organization: Organization;
   projects: Project[];
 };
@@ -56,40 +61,39 @@ function TransactionReplays(props: Props) {
   );
 }
 
-function ReplaysContentWrapper(props: ChildProps) {
-  const {location, organization, eventView, transactionName, setError} = props;
+function ReplaysContentWrapper({
+  eventView,
+  location,
+  organization,
+  setError,
+}: ChildProps) {
+  const {replays, pageLinks, isFetching, fetchError} = useReplayList({
+    organization,
+    eventView,
+  });
 
-  return (
-    <DiscoverQuery
+  useEffect(() => {
+    setError(fetchError?.message);
+  }, [setError, fetchError]);
+
+  if (isFetching) {
+    return (
+      <Layout.Main fullWidth>
+        <LoadingIndicator />
+      </Layout.Main>
+    );
+  }
+  return replays ? (
+    <ReplaysContent
       eventView={eventView}
-      orgSlug={organization.slug}
+      isFetching={isFetching}
       location={location}
-      setError={error => setError(error?.message)}
-      referrer="api.performance.transaction-summary"
-      cursor="0:0:0"
-      useEvents
-    >
-      {({isLoading, tableData, pageLinks}) => {
-        if (isLoading) {
-          return (
-            <Layout.Main fullWidth>
-              <LoadingIndicator />
-            </Layout.Main>
-          );
-        }
-        return tableData ? (
-          <ReplaysContent
-            eventView={eventView}
-            location={location}
-            organization={organization}
-            setError={setError}
-            transactionName={transactionName}
-            tableData={tableData}
-            pageLinks={pageLinks}
-          />
-        ) : null;
-      }}
-    </DiscoverQuery>
+      organization={organization}
+      pageLinks={pageLinks}
+      replays={replays}
+    />
+  ) : (
+    <Fragment>{null}</Fragment>
   );
 }
 
@@ -110,11 +114,10 @@ function generateEventView({
 }: {
   location: Location;
   transactionName: string;
-}): EventView {
+}) {
   const query = decodeScalar(location.query.query, '');
   const conditions = new MutableSearch(query);
 
-  conditions.setFilterValues('event.type', ['transaction']);
   conditions.setFilterValues('transaction', [transactionName]);
 
   Object.keys(conditions.filters).forEach(field => {
@@ -123,30 +126,15 @@ function generateEventView({
     }
   });
 
-  // Default fields for relative span view
-  const fields = [
-    'replayId',
-    'eventID',
-    'project',
-    'timestamp',
-    'url',
-    'user.display',
-    'user.email',
-    'user.id',
-    'user.ip_address',
-    'user.name',
-    'user.username',
-  ];
-
   return EventView.fromNewQueryWithLocation(
     {
-      id: undefined,
-      version: 2,
+      id: '',
       name: transactionName,
-      fields,
-      query: `${conditions.formatString()} has:replayId`,
+      version: 2,
+      fields: REPLAY_LIST_FIELDS,
       projects: [],
-      orderby: decodeScalar(location.query.sort, '-timestamp'),
+      query: conditions.formatString(),
+      orderby: decodeScalar(location.query.sort, DEFAULT_SORT),
     },
     location
   );

+ 153 - 118
static/app/views/replays/replayTable.tsx

@@ -1,148 +1,183 @@
-import React, {Fragment, useMemo} from 'react';
+import {Fragment} from 'react';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import Duration from 'sentry/components/duration';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import UserBadge from 'sentry/components/idBadge/userBadge';
 import Link from 'sentry/components/links/link';
-import Placeholder from 'sentry/components/placeholder';
+import {PanelTable} from 'sentry/components/panels';
 import ReplayHighlight from 'sentry/components/replays/replayHighlight';
 import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
 import TimeSince from 'sentry/components/timeSince';
-import {IconCalendar} from 'sentry/icons';
+import {IconArrow, IconCalendar} from 'sentry/icons';
+import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {generateEventSlug} from 'sentry/utils/discover/urls';
-import useDiscoverQuery from 'sentry/utils/replays/hooks/useDiscoveryQuery';
-import theme from 'sentry/utils/theme';
+import type {Organization} from 'sentry/types';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {useLocation} from 'sentry/utils/useLocation';
 import useMedia from 'sentry/utils/useMedia';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
-import {
-  ReplayDiscoveryListItem,
-  ReplayDurationAndErrors,
-} from 'sentry/views/replays/types';
+import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
 
 type Props = {
-  idKey: string;
-  replayList: ReplayDiscoveryListItem[];
-  showProjectColumn?: boolean;
+  isFetching: boolean;
+  replays: undefined | ReplayListRecord[];
+  showProjectColumn: boolean;
+  sort: Sort;
 };
 
-function ReplayTable({replayList, idKey, showProjectColumn}: Props) {
+type RowProps = {
+  minWidthIsSmall: boolean;
+  organization: Organization;
+  replay: ReplayListRecord;
+  showProjectColumn: boolean;
+};
+
+function ReplayTable({isFetching, replays, showProjectColumn, sort}: Props) {
+  const location = useLocation<ReplayListLocationQuery>();
   const organization = useOrganization();
-  const {projects} = useProjects();
-  const isScreenLarge = useMedia(`(min-width: ${theme.breakpoints.small})`);
-
-  const query = replayList.map(item => `replayId:${item[idKey]}`).join(' OR ');
-
-  const discoverQuery = useMemo(
-    () => ({
-      fields: [
-        'replayId',
-        'max(timestamp)',
-        'min(timestamp)',
-        'equation|max(timestamp)-min(timestamp)',
-        'count_if(event.type,equals,error)',
-      ],
-      orderby: '-min_timestamp',
-      query: `(title:"sentry-replay-event-*" OR event.type:error) AND (${query})`,
-    }),
-    [query]
-  );
+  const theme = useTheme();
+  const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
 
-  const {data} = useDiscoverQuery<ReplayDurationAndErrors>({discoverQuery});
+  const {pathname} = location;
 
-  const dataEntries = data
-    ? Object.fromEntries(data.map(item => [item.replayId, item]))
-    : {};
+  const arrowDirection = sort.kind === 'asc' ? 'up' : 'down';
+  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
 
   return (
-    <Fragment>
-      {replayList?.map(replay => (
-        <Fragment key={replay.id}>
-          <UserBadge
-            avatarSize={32}
-            displayName={
-              <Link
-                to={`/organizations/${organization.slug}/replays/${generateEventSlug({
-                  project: replay.project,
-                  id: replay[idKey],
-                })}/`}
-              >
-                {replay['user.display']}
-              </Link>
-            }
-            user={{
-              username: replay['user.username'] ?? '',
-              id: replay['user.id'] ?? '',
-              ip_address: replay['user.ip_address'] ?? '',
-              name: replay['user.name'] ?? '',
-              email: replay['user.email'] ?? '',
-            }}
-            // this is the subheading for the avatar, so displayEmail in this case is a misnomer
-            displayEmail={<StringWalker urls={[]} />}
-          />
-          {isScreenLarge && showProjectColumn && (
-            <Item>
-              <ProjectBadge
-                project={
-                  projects.find(p => p.slug === replay.project) || {
-                    slug: replay.project,
-                  }
-                }
-                avatarSize={16}
-              />
-            </Item>
-          )}
-          <Item>
-            <TimeSinceWrapper>
-              {isScreenLarge && <StyledIconCalendarWrapper color="gray500" size="sm" />}
-              <TimeSince date={replay.timestamp} />
-            </TimeSinceWrapper>
-          </Item>
-          {data ? (
-            <React.Fragment>
-              <Item>
-                <Duration
-                  seconds={
-                    Math.floor(
-                      dataEntries[replay[idKey]]
-                        ? dataEntries[replay[idKey]]['equation[0]']
-                        : 0
-                    ) || 1
-                  }
-                  exact
-                  abbreviation
-                />
-              </Item>
-              <Item>
-                {dataEntries[replay[idKey]]
-                  ? dataEntries[replay[idKey]]?.count_if_event_type_equals_error
-                  : 0}
-              </Item>
-              <Item>
-                <ReplayHighlight data={dataEntries[replay[idKey]]} />
-              </Item>
-            </React.Fragment>
-          ) : (
-            <React.Fragment>
-              <Item>
-                <Placeholder height="24px" />
-              </Item>
-              <Item>
-                <Placeholder height="24px" />
-              </Item>
-              <Item>
-                <Placeholder height="24px" />
-              </Item>
-            </React.Fragment>
-          )}
-        </Fragment>
+    <StyledPanelTable
+      isLoading={isFetching}
+      isEmpty={replays?.length === 0}
+      headers={[
+        t('Session'),
+        showProjectColumn && minWidthIsSmall ? t('Project') : null,
+        <SortLink
+          key="startedAt"
+          role="columnheader"
+          aria-sort={
+            sort.field === 'startedAt'
+              ? sort.kind === 'asc'
+                ? 'ascending'
+                : 'descending'
+              : 'none'
+          }
+          to={{
+            pathname,
+            query: {
+              ...location.query,
+              sort: sort.kind === 'desc' ? 'startedAt' : '-startedAt',
+            },
+          }}
+        >
+          {t('Start Time')} {sort.field.endsWith('startedAt') && sortArrow}
+        </SortLink>,
+        <SortLink
+          key="duration"
+          role="columnheader"
+          aria-sort={
+            sort.field.endsWith('duration')
+              ? sort.kind === 'asc'
+                ? 'ascending'
+                : 'descending'
+              : 'none'
+          }
+          to={{
+            pathname,
+            query: {
+              ...location.query,
+              sort: sort.kind === 'desc' ? 'duration' : '-duration',
+            },
+          }}
+        >
+          {t('Duration')} {sort.field === 'duration' && sortArrow}
+        </SortLink>,
+        t('Errors'),
+        t('Interest'),
+      ]}
+    >
+      {replays?.map(replay => (
+        <ReplayTableRow
+          key={replay.id}
+          replay={replay}
+          organization={organization}
+          showProjectColumn={showProjectColumn}
+          minWidthIsSmall={minWidthIsSmall}
+        />
       ))}
+    </StyledPanelTable>
+  );
+}
+
+function ReplayTableRow({
+  minWidthIsSmall,
+  organization,
+  replay,
+  showProjectColumn,
+}: RowProps) {
+  const {projects} = useProjects();
+  const project = projects.find(p => p.id === replay.projectId);
+  return (
+    <Fragment>
+      <UserBadge
+        avatarSize={32}
+        displayName={
+          <Link
+            to={`/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/`}
+          >
+            {replay.user.username ||
+              replay.user.name ||
+              replay.user.email ||
+              replay.user.ip_address ||
+              replay.user.id ||
+              ''}
+          </Link>
+        }
+        user={replay.user}
+        // this is the subheading for the avatar, so displayEmail in this case is a misnomer
+        displayEmail={<StringWalker urls={replay.urls} />}
+      />
+      {showProjectColumn && minWidthIsSmall && (
+        <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
+      )}
+      <Item>
+        <TimeSinceWrapper>
+          {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
+          <TimeSince date={replay.startedAt} />
+        </TimeSinceWrapper>
+      </Item>
+      <Item>
+        <Duration seconds={Math.floor(replay.duration)} exact abbreviation />
+      </Item>
+      <Item>{replay.countErrors || 0}</Item>
+      <Item>
+        <ReplayHighlight replay={replay} />
+      </Item>
     </Fragment>
   );
 }
 
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content max-content;
+
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content;
+  }
+`;
+
+const SortLink = styled(Link)`
+  color: inherit;
+
+  :hover {
+    color: inherit;
+  }
+
+  svg {
+    vertical-align: top;
+  }
+`;
+
 const Item = styled('div')`
   display: flex;
   align-items: center;

+ 59 - 157
static/app/views/replays/replays.tsx

@@ -1,99 +1,53 @@
-import {Fragment, useEffect, useState} from 'react';
-import {browserHistory} from 'react-router';
+import {Fragment, useMemo} from 'react';
+import {browserHistory, RouteComponentProps} from 'react-router';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
-import Link from 'sentry/components/links/link';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
 import PageHeading from 'sentry/components/pageHeading';
 import Pagination from 'sentry/components/pagination';
-import {PanelTable} from 'sentry/components/panels';
 import ReplaysFeatureBadge from 'sentry/components/replays/replaysFeatureBadge';
-import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {PageContent, PageHeader} from 'sentry/styles/organization';
 import space from 'sentry/styles/space';
-import {NewQuery} from 'sentry/types';
-import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
-import {getQueryParamAsString} from 'sentry/utils/replays/getQueryParamAsString';
-import theme from 'sentry/utils/theme';
-import {useLocation} from 'sentry/utils/useLocation';
+import {decodeScalar} from 'sentry/utils/queryString';
+import useReplayList, {
+  DEFAULT_SORT,
+  REPLAY_LIST_FIELDS,
+} from 'sentry/utils/replays/hooks/useReplayList';
 import useMedia from 'sentry/utils/useMedia';
 import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
 import ReplaysFilters from 'sentry/views/replays/filters';
 import ReplayTable from 'sentry/views/replays/replayTable';
-import {ReplayDiscoveryListItem} from 'sentry/views/replays/types';
+import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
-const columns = [t('Session'), t('Project')];
+type Props = RouteComponentProps<{orgId: string}, {}, any, ReplayListLocationQuery>;
 
-function Replays() {
-  const location = useLocation();
+function Replays({location}: Props) {
   const organization = useOrganization();
-  const {selection} = usePageFilters();
-  const isScreenLarge = useMedia(`(min-width: ${theme.breakpoints.small})`);
-
-  const [searchQuery, setSearchQuery] = useState<string>(
-    getQueryParamAsString(location.query.query)
-  );
-
-  useEffect(() => {
-    setSearchQuery(getQueryParamAsString(location.query.query));
-  }, [location.query.query]);
-
-  const getEventView = () => {
-    const {query} = location;
-    const eventQueryParams: NewQuery = {
-      id: '',
-      name: '',
-      version: 2,
-      fields: [
-        // 'id' is always returned, don't need to list it here.
-        'eventID',
-        'project',
-        'timestamp',
-        'url',
-        'user.display',
-        'user.email',
-        'user.id',
-        'user.ip_address',
-        'user.name',
-        'user.username',
-      ],
-      orderby: getQueryParamAsString(query.sort) || '-timestamp',
-      environment: selection.environments,
-      projects: selection.projects,
-      query: `title:sentry-replay ${searchQuery}`,
-    };
-
-    if (selection.datetime.period) {
-      eventQueryParams.range = selection.datetime.period;
-    }
-    return EventView.fromNewQueryWithLocation(eventQueryParams, location);
-  };
-
-  const handleSearchQuery = (query: string) => {
-    browserHistory.push({
-      pathname: location.pathname,
-      query: {
-        ...location.query,
-        cursor: undefined,
-        query: String(query).trim() || undefined,
+  const theme = useTheme();
+  const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
+
+  const eventView = useMemo(() => {
+    return EventView.fromNewQueryWithLocation(
+      {
+        id: '',
+        name: '',
+        version: 2,
+        fields: REPLAY_LIST_FIELDS,
+        projects: [],
+        orderby: decodeScalar(location.query.sort, DEFAULT_SORT),
       },
-    });
-  };
-
-  const {query} = location;
-  const {cursor: _cursor, page: _page, ...currentQuery} = query;
-
-  const sort: {
-    field: string;
-  } = {
-    field: getQueryParamAsString(query.sort) || '-timestamp',
-  };
+      location
+    );
+  }, [location]);
 
-  const arrowDirection = sort.field.startsWith('-') ? 'down' : 'up';
-  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
+  const {pathname, query} = location;
+  const {replays, pageLinks, isFetching} = useReplayList({
+    organization,
+    eventView,
+  });
 
   return (
     <Fragment>
@@ -106,67 +60,35 @@ function Replays() {
       </StyledPageHeader>
       <PageFiltersContainer>
         <StyledPageContent>
-          <DiscoverQuery
-            eventView={getEventView()}
-            location={location}
-            orgSlug={organization.slug}
-            limit={15}
-          >
-            {data => {
-              return (
-                <Fragment>
-                  <ReplaysFilters
-                    query={searchQuery}
-                    organization={organization}
-                    handleSearchQuery={handleSearchQuery}
-                  />
-                  <StyledPanelTable
-                    isLoading={data.isLoading}
-                    isEmpty={data.tableData?.data.length === 0}
-                    headers={[
-                      ...(!isScreenLarge
-                        ? columns.filter(col => col === t('Session'))
-                        : columns),
-                      <SortLink
-                        key="timestamp"
-                        role="columnheader"
-                        aria-sort={
-                          !sort.field.endsWith('timestamp')
-                            ? 'none'
-                            : sort.field === '-timestamp'
-                            ? 'descending'
-                            : 'ascending'
-                        }
-                        to={{
-                          pathname: location.pathname,
-                          query: {
-                            ...currentQuery,
-                            // sort by timestamp should start by ascending on first click
-                            sort:
-                              sort.field === '-timestamp' ? 'timestamp' : '-timestamp',
-                          },
-                        }}
-                      >
-                        {t('Timestamp')} {sort.field.endsWith('timestamp') && sortArrow}
-                      </SortLink>,
-                      t('Duration'),
-                      t('Errors'),
-                      t('Interest'),
-                    ]}
-                  >
-                    {data.tableData ? (
-                      <ReplayTable
-                        idKey="id"
-                        showProjectColumn
-                        replayList={data.tableData.data as ReplayDiscoveryListItem[]}
-                      />
-                    ) : null}
-                  </StyledPanelTable>
-                  <Pagination pageLinks={data.pageLinks} />
-                </Fragment>
-              );
+          <ReplaysFilters
+            query={query.query || ''}
+            organization={organization}
+            handleSearchQuery={searchQuery => {
+              browserHistory.push({
+                pathname,
+                query: {
+                  ...query,
+                  cursor: undefined,
+                  query: searchQuery.trim(),
+                },
+              });
             }}
-          </DiscoverQuery>
+          />
+          <ReplayTable
+            isFetching={isFetching}
+            replays={replays}
+            showProjectColumn={minWidthIsSmall}
+            sort={eventView.sorts[0]}
+          />
+          <Pagination
+            pageLinks={pageLinks}
+            onCursor={(offset, path, searchQuery) => {
+              browserHistory.push({
+                pathname: path,
+                query: {...searchQuery, offset},
+              });
+            }}
+          />
         </StyledPageContent>
       </PageFiltersContainer>
     </Fragment>
@@ -184,14 +106,6 @@ const StyledPageContent = styled(PageContent)`
   background-color: ${p => p.theme.background};
 `;
 
-const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content max-content;
-
-  @media (max-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: minmax(0, 1fr) max-content max-content max-content max-content;
-  }
-`;
-
 const HeaderTitle = styled(PageHeading)`
   display: flex;
   align-items: center;
@@ -199,16 +113,4 @@ const HeaderTitle = styled(PageHeading)`
   flex: 1;
 `;
 
-const SortLink = styled(Link)`
-  color: inherit;
-
-  :hover {
-    color: inherit;
-  }
-
-  svg {
-    vertical-align: top;
-  }
-`;
-
 export default Replays;

Some files were not shown because too many files changed in this diff