Browse Source

ref(replay): Create a Replay class for data marshaling (#34215)

Create a Replay class to marshal the different replay data sources.

This class will allow the existing useReplayEvent react hook to offload the domain-specific filtering and type management that it's doing, and instead that react hook can focus on data loading and react-state.

One domain specific task that's being newly handled: consistent end_timestamp. The Replay class has all the context to figure out the end_timestamp for a replay based on the last rrweb, span, or breadcrumb that has been saved.

There is no need for any of the values inside Replay to be inside react state anymore because they are all derived from state. They are however memoized because it's a) easy b) gives us flexibility to get data from anywhere without worrying about perf. If the upstream useState data is changed we will get a new class instance with an empty cache, so memoized values should be cleared.

Related to #34172
Ryan Albrecht 2 years ago
parent
commit
fb498804f9

+ 23 - 0
static/app/utils/replays/mergeBreadcrumbEntries.tsx

@@ -0,0 +1,23 @@
+import {Entry, EntryType, Event} from 'sentry/types/event';
+
+/**
+ * Merge all breadcrumbs from each Event in the `events` array
+ */
+export default function mergeBreadcrumbEntries(events: Event[]): Entry {
+  const allValues = events.flatMap(event =>
+    event.entries.flatMap((entry: Entry) =>
+      entry.type === EntryType.BREADCRUMBS ? entry.data.values : []
+    )
+  );
+
+  const stringified = allValues.map(value => JSON.stringify(value));
+  const deduped = Array.from(new Set(stringified));
+  const values = deduped.map(value => JSON.parse(value));
+
+  return {
+    type: EntryType.BREADCRUMBS,
+    data: {
+      values,
+    },
+  };
+}

+ 15 - 0
static/app/utils/replays/mergeSpanEntries.tsx

@@ -0,0 +1,15 @@
+import type {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
+import {Entry, EntryType, Event} from 'sentry/types/event';
+
+/**
+ * Merge all spans from each Event in the `events` array
+ */
+export default function mergeSpanEntries(events: Event[]): Entry {
+  const spans = events.flatMap(event =>
+    event.entries.flatMap((entry: Entry) =>
+      entry.type === EntryType.SPANS ? (entry.data as RawSpanType[]) : []
+    )
+  );
+
+  return {type: EntryType.SPANS, data: spans};
+}

+ 87 - 0
static/app/utils/replays/replayReader.tsx

@@ -0,0 +1,87 @@
+import memoize from 'lodash/memoize';
+import type {eventWithTime} from 'rrweb/typings/types';
+
+import type {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
+import type {RawCrumb} from 'sentry/types/breadcrumbs';
+import type {Event, EventTransaction} from 'sentry/types/event';
+import {EntryType} from 'sentry/types/event';
+import mergeBreadcrumbEntries from 'sentry/utils/replays/mergeBreadcrumbEntries';
+import mergeSpanEntries from 'sentry/utils/replays/mergeSpanEntries';
+
+function last<T>(arr: T[]): T {
+  return arr[arr.length - 1];
+}
+
+export default class ReplayReader {
+  static factory(
+    event: EventTransaction | undefined,
+    rrwebEvents: eventWithTime[] | undefined,
+    replayEvents: Event[] | undefined
+  ) {
+    if (!event || !rrwebEvents || !replayEvents) {
+      return null;
+    }
+    return new ReplayReader(event, rrwebEvents, replayEvents);
+  }
+
+  private constructor(
+    /**
+     * The root Replay event, created at the start of the browser session.
+     */
+    private _event: EventTransaction,
+
+    /**
+     * The captured data from rrweb.
+     * Saved as N attachments that belong to the root Replay event.
+     */
+    private _rrwebEvents: eventWithTime[],
+
+    /**
+     * Regular Sentry SDK events that occurred during the rrweb session.
+     */
+    private _replayEvents: Event[]
+  ) {}
+
+  getEvent = memoize(() => {
+    const breadcrumbs = this.getEntryType(EntryType.BREADCRUMBS);
+    const spans = this.getEntryType(EntryType.SPANS);
+
+    const lastRRweb = last(this._rrwebEvents);
+    const lastBreadcrumb = last(breadcrumbs?.data.values as RawCrumb[]);
+    const lastSpan = last(spans?.data as RawSpanType[]);
+
+    // 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 occuring over time.
+    // So we need to figure out the real end time (in seconds).
+    const endTimestamp =
+      Math.max(
+        lastRRweb.timestamp,
+        +new Date(lastBreadcrumb.timestamp || 0),
+        lastSpan.timestamp * 1000
+      ) / 1000;
+
+    return {
+      ...this._event,
+      entries: [breadcrumbs, spans],
+      endTimestamp,
+    } as EventTransaction;
+  });
+
+  getRRWebEvents() {
+    return this._rrwebEvents;
+  }
+
+  getEntryType = memoize((type: EntryType) => {
+    switch (type) {
+      case EntryType.BREADCRUMBS:
+        return mergeBreadcrumbEntries(this._replayEvents);
+      case EntryType.SPANS:
+        return mergeSpanEntries(this._replayEvents);
+      default:
+        throw new Error(
+          `ReplayReader is unable to prepare EntryType ${type}. Type not supported.`
+        );
+    }
+  });
+}

+ 1 - 1
static/app/views/replays/detail/detailLayout.tsx

@@ -18,9 +18,9 @@ import getUrlPathname from 'sentry/utils/getUrlPathname';
 
 
 type Props = {
 type Props = {
   children: React.ReactNode;
   children: React.ReactNode;
-  event: Event | undefined;
   orgId: string;
   orgId: string;
   crumbs?: RawCrumb[];
   crumbs?: RawCrumb[];
+  event?: Event;
 };
 };
 
 
 function DetailLayout({children, event, orgId, crumbs}: Props) {
 function DetailLayout({children, event, orgId, crumbs}: Props) {

+ 11 - 8
static/app/views/replays/details.tsx

@@ -39,7 +39,7 @@ function ReplayDetails() {
     fetchError,
     fetchError,
     fetching,
     fetching,
     onRetry,
     onRetry,
-    rrwebEvents,
+    replay,
   } = useReplayEvent({
   } = useReplayEvent({
     eventSlug,
     eventSlug,
     location,
     location,
@@ -50,25 +50,25 @@ function ReplayDetails() {
 
 
   if (fetching) {
   if (fetching) {
     return (
     return (
-      <DetailLayout event={event} orgId={orgId}>
+      <DetailLayout orgId={orgId}>
         <LoadingIndicator />
         <LoadingIndicator />
       </DetailLayout>
       </DetailLayout>
     );
     );
   }
   }
-  if (!event) {
+  if (!replay) {
     // TODO(replay): Give the user more details when errors happen
     // TODO(replay): Give the user more details when errors happen
     console.log({fetching, fetchError}); // eslint-disable-line no-console
     console.log({fetching, fetchError}); // eslint-disable-line no-console
     return (
     return (
-      <DetailLayout event={event} orgId={orgId}>
+      <DetailLayout orgId={orgId}>
         <PageContent>
         <PageContent>
           <NotFound />
           <NotFound />
         </PageContent>
         </PageContent>
       </DetailLayout>
       </DetailLayout>
     );
     );
   }
   }
-  if (!rrwebEvents || rrwebEvents.length < 2) {
+  if (replay.getRRWebEvents().length < 2) {
     return (
     return (
-      <DetailLayout event={event} orgId={orgId}>
+      <DetailLayout event={replay.getEvent()} orgId={orgId}>
         <DetailedError
         <DetailedError
           onRetry={onRetry}
           onRetry={onRetry}
           hideSupportLinks
           hideSupportLinks
@@ -89,7 +89,10 @@ function ReplayDetails() {
   }
   }
 
 
   return (
   return (
-    <ReplayContextProvider events={rrwebEvents} initialTimeOffset={initialTimeOffset}>
+    <ReplayContextProvider
+      events={replay.getRRWebEvents()}
+      initialTimeOffset={initialTimeOffset}
+    >
       <DetailLayout
       <DetailLayout
         event={event}
         event={event}
         orgId={orgId}
         orgId={orgId}
@@ -117,7 +120,7 @@ function ReplayDetails() {
               <BreadcrumbTimeline crumbs={breadcrumbEntry?.data.values || []} />
               <BreadcrumbTimeline crumbs={breadcrumbEntry?.data.values || []} />
             </Panel>
             </Panel>
             <FocusArea
             <FocusArea
-              event={event}
+              event={replay.getEvent()}
               eventWithSpans={mergedReplayEvent}
               eventWithSpans={mergedReplayEvent}
               memorySpans={memorySpans}
               memorySpans={memorySpans}
             />
             />

+ 6 - 3
static/app/views/replays/utils/useReplayEvent.tsx

@@ -4,9 +4,10 @@ import type {eventWithTime} from 'rrweb/typings/types';
 
 
 import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
 import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
 import {IssueAttachment} from 'sentry/types';
 import {IssueAttachment} from 'sentry/types';
-import {Entry, Event} from 'sentry/types/event';
+import {Entry, Event, EventTransaction} from 'sentry/types/event';
 import EventView from 'sentry/utils/discover/eventView';
 import EventView from 'sentry/utils/discover/eventView';
 import {generateEventSlug} from 'sentry/utils/discover/urls';
 import {generateEventSlug} from 'sentry/utils/discover/urls';
+import ReplayReader from 'sentry/utils/replays/replayReader';
 import RequestError from 'sentry/utils/requestError/requestError';
 import RequestError from 'sentry/utils/requestError/requestError';
 import useApi from 'sentry/utils/useApi';
 import useApi from 'sentry/utils/useApi';
 
 
@@ -24,7 +25,7 @@ type State = {
   /**
   /**
    * The root replay event
    * The root replay event
    */
    */
-  event: undefined | Event;
+  event: undefined | EventTransaction;
 
 
   /**
   /**
    * If any request returned an error then nothing is being returned
    * If any request returned an error then nothing is being returned
@@ -71,6 +72,7 @@ type Options = {
 
 
 interface Result extends State {
 interface Result extends State {
   onRetry: () => void;
   onRetry: () => void;
+  replay: ReplayReader | null;
 }
 }
 
 
 const IS_RRWEB_ATTACHMENT_FILENAME = /rrweb-[0-9]{13}.json/;
 const IS_RRWEB_ATTACHMENT_FILENAME = /rrweb-[0-9]{13}.json/;
@@ -100,7 +102,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
   function fetchEvent() {
   function fetchEvent() {
     return api.requestPromise(
     return api.requestPromise(
       `/organizations/${orgId}/events/${eventSlug}/`
       `/organizations/${orgId}/events/${eventSlug}/`
-    ) as Promise<Event>;
+    ) as Promise<EventTransaction>;
   }
   }
 
 
   async function fetchRRWebEvents() {
   async function fetchRRWebEvents() {
@@ -215,6 +217,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
     fetchError: state.fetchError,
     fetchError: state.fetchError,
     fetching: state.fetching,
     fetching: state.fetching,
     onRetry,
     onRetry,
+    replay: ReplayReader.factory(state.event, state.rrwebEvents, state.replayEvents),
 
 
     breadcrumbEntry: state.breadcrumbEntry,
     breadcrumbEntry: state.breadcrumbEntry,
     event: state.event,
     event: state.event,