Browse Source

feat(replays): Add `useReplayErrors` to fetch errors for a replay (#35099)

Add a custom hook to fetch errors in a replay.
Billy Vong 2 years ago
parent
commit
a5325768ff

+ 109 - 0
static/app/utils/replays/hooks/useDiscoveryQuery.tsx

@@ -0,0 +1,109 @@
+import {useEffect, useState} from 'react';
+import {Location} from 'history';
+
+import {NewQuery} from 'sentry/types';
+import EventView from 'sentry/utils/discover/eventView';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import {ReplayError} from 'sentry/views/replays/types';
+
+type OptionalProperties = 'projects' | 'environment' | 'id' | 'name' | 'version';
+interface Params {
+  /**
+   * The Discover query to perform. This is a function because we will require the consumer of the hook to memoize this function.
+   */
+  discoverQuery: Omit<NewQuery, OptionalProperties> &
+    Partial<Pick<NewQuery, OptionalProperties>>;
+
+  endpoint?: string;
+
+  /**
+   * Should we ignore the current URL parameter `cursor`?
+   *
+   * This is useful when we are making nested discover queries and the child queries have their own cursor (or do not need it at all).
+   */
+  ignoreCursor?: boolean;
+}
+
+interface State {
+  data: ReplayError[] | undefined;
+  error: Error | undefined;
+  isLoading: boolean;
+  pageLinks: string | undefined;
+}
+
+const INITIAL_STATE: State = {
+  isLoading: true,
+  error: undefined,
+  data: undefined,
+  pageLinks: undefined,
+} as const;
+
+const FAKE_LOCATION = {
+  query: {},
+} as Location;
+
+/**
+ * Simple custom hook to perform a Discover query.
+ *
+ * Note this does *not* handle URL parameters like the render component `<DiscoverQuery>`.
+ * It will need to be handled in a parent.
+ */
+export default function useDiscoverQuery({
+  endpoint,
+  discoverQuery,
+  ignoreCursor,
+}: Params) {
+  const [state, setState] = useState<State>(INITIAL_STATE);
+  const api = useApi();
+  const organization = useOrganization();
+
+  useEffect(() => {
+    async function runQuery() {
+      const url = endpoint || `/organizations/${organization.slug}/eventsv2/`;
+      const eventView = EventView.fromNewQueryWithLocation(
+        {
+          environment: [],
+          projects: [],
+          id: '',
+          name: '',
+          version: 2,
+          ...discoverQuery,
+        },
+        FAKE_LOCATION
+      );
+      const query = eventView.getEventsAPIPayload(FAKE_LOCATION);
+
+      setState(prevState => ({...prevState, isLoading: true, error: undefined}));
+      api.clear();
+
+      try {
+        const [data, , resp] = await api.requestPromise(url, {
+          includeAllArgs: true,
+          query,
+        });
+        setState(prevState => ({
+          ...prevState,
+          isLoading: false,
+          error: undefined,
+          pageLinks: resp?.getResponseHeader('Link') ?? prevState.pageLinks,
+          data: data.data,
+        }));
+      } catch (error) {
+        setState(prevState => ({
+          ...prevState,
+          isLoading: false,
+          error,
+          data: undefined,
+        }));
+      }
+    }
+
+    runQuery();
+
+    // location is ignored in deps array, see getEventView comments
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [endpoint, discoverQuery, organization.slug, ignoreCursor]);
+
+  return state;
+}

+ 41 - 5
static/app/utils/replays/hooks/useReplayData.tsx

@@ -7,13 +7,25 @@ import {EventTransaction} from 'sentry/types/event';
 import ReplayReader from 'sentry/utils/replays/replayReader';
 import RequestError from 'sentry/utils/requestError/requestError';
 import useApi from 'sentry/utils/useApi';
-import type {RecordingEvent, ReplayCrumb, ReplaySpan} from 'sentry/views/replays/types';
+import type {
+  RecordingEvent,
+  ReplayCrumb,
+  ReplayError,
+  ReplaySpan,
+} from 'sentry/views/replays/types';
 
 import flattenListOfObjects from '../flattenListOfObjects';
 
+import useReplayErrors from './useReplayErrors';
+
 type State = {
   breadcrumbs: undefined | ReplayCrumb[];
 
+  /**
+   * List of errors that occurred during replay
+   */
+  errors: undefined | ReplayError[];
+
   /**
    * The root replay event
    */
@@ -30,6 +42,11 @@ type State = {
    */
   fetching: boolean;
 
+  /**
+   * Are errors currently being fetched
+   */
+  isErrorsFetching: boolean;
+
   /**
    * The flattened list of rrweb events. These are stored as multiple attachments on the root replay object: the `event` prop.
    */
@@ -89,9 +106,11 @@ export function mapRRWebAttachments(unsortedReplayAttachments): ReplayAttachment
 }
 
 const INITIAL_STATE: State = Object.freeze({
+  errors: undefined,
   event: undefined,
   fetchError: undefined,
   fetching: true,
+  isErrorsFetching: true,
   rrwebEvents: undefined,
   spans: undefined,
   breadcrumbs: undefined,
@@ -166,20 +185,36 @@ function useReplayData({eventSlug, orgId}: Options): Result {
     return flattenListOfObjects(attachments);
   }, [api, eventId, orgId, projectId]);
 
+  const {isLoading: isErrorsFetching, data: errors} = useReplayErrors({
+    replayId: eventId,
+  });
+
+  useEffect(() => {
+    if (!isErrorsFetching) {
+      setState(prevState => ({
+        ...prevState,
+        fetching: prevState.fetching || isErrorsFetching,
+        isErrorsFetching,
+        errors,
+      }));
+    }
+  }, [isErrorsFetching, errors]);
+
   const loadEvents = useCallback(async () => {
     setState(INITIAL_STATE);
 
     try {
       const [event, attachments] = await Promise.all([fetchEvent(), fetchRRWebEvents()]);
 
-      setState({
+      setState(prev => ({
+        ...prev,
         event,
         fetchError: undefined,
-        fetching: false,
+        fetching: prev.isErrorsFetching || false,
         rrwebEvents: attachments.recording,
         spans: attachments.replaySpans,
         breadcrumbs: attachments.breadcrumbs,
-      });
+      }));
     } catch (error) {
       Sentry.captureException(error);
       setState({
@@ -197,11 +232,12 @@ function useReplayData({eventSlug, orgId}: Options): Result {
   const replay = useMemo(() => {
     return ReplayReader.factory({
       event: state.event,
+      errors: state.errors,
       rrwebEvents: state.rrwebEvents,
       breadcrumbs: state.breadcrumbs,
       spans: state.spans,
     });
-  }, [state.event, state.rrwebEvents, state.breadcrumbs, state.spans]);
+  }, [state.event, state.rrwebEvents, state.breadcrumbs, state.spans, state.errors]);
 
   return {
     fetchError: state.fetchError,

+ 28 - 0
static/app/utils/replays/hooks/useReplayErrors.tsx

@@ -0,0 +1,28 @@
+import {useMemo} from 'react';
+
+import useDiscoverQuery from './useDiscoveryQuery';
+
+interface Params {
+  replayId: string;
+  ignoreCursor?: boolean;
+}
+
+/**
+ * Fetches a list of errors that occurred in a replay
+ */
+export default function useReplayErrors({replayId, ...props}: Params) {
+  const discoverQuery = useMemo(
+    () => ({
+      query: `replayId:${replayId} AND event.type:error`,
+      fields: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+
+      // environment and project shouldn't matter because having a replayId
+      // assumes we have already filtered down to proper env/project
+      environment: [],
+      projects: [],
+    }),
+    [replayId]
+  );
+
+  return useDiscoverQuery({discoverQuery, ...props});
+}

+ 20 - 10
static/app/utils/replays/replayDataUtils.tsx

@@ -1,14 +1,16 @@
 import first from 'lodash/first';
 
-import {
-  getVirtualCrumb,
-  transformCrumbs,
-} from 'sentry/components/events/interfaces/breadcrumbs/utils';
+import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
 import {t} from 'sentry/locale';
 import type {BreadcrumbTypeDefault, Crumb, RawCrumb} from 'sentry/types/breadcrumbs';
 import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
 import {Event} from 'sentry/types/event';
-import type {RecordingEvent, ReplayCrumb, ReplaySpan} from 'sentry/views/replays/types';
+import type {
+  RecordingEvent,
+  ReplayCrumb,
+  ReplayError,
+  ReplaySpan,
+} from 'sentry/views/replays/types';
 
 export function rrwebEventListFactory(
   startTimestampMS: number,
@@ -49,10 +51,11 @@ export function rrwebEventListFactory(
 
 export function breadcrumbFactory(
   startTimestamp: number,
-  events: Event[],
+  rootEvent: Event,
+  errors: ReplayError[],
   rawCrumbs: ReplayCrumb[]
 ): Crumb[] {
-  const {tags} = events[0];
+  const {tags} = rootEvent;
   const initBreadcrumb = {
     type: BreadcrumbType.INIT,
     timestamp: new Date(startTimestamp).toISOString(),
@@ -64,9 +67,16 @@ export function breadcrumbFactory(
     },
   } as BreadcrumbTypeDefault;
 
-  const errorCrumbs = events
-    .map(getVirtualCrumb)
-    .filter((crumb: RawCrumb | undefined): crumb is RawCrumb => crumb !== undefined);
+  const errorCrumbs: RawCrumb[] = errors.map(error => ({
+    type: BreadcrumbType.ERROR,
+    level: BreadcrumbLevelType.ERROR,
+    category: 'exception',
+    data: {
+      type: error['error.type'],
+      value: error['error.value'],
+    },
+    timestamp: error.timestamp,
+  }));
 
   const result = transformCrumbs([
     initBreadcrumb,

+ 7 - 4
static/app/utils/replays/replayReader.tsx

@@ -10,11 +10,13 @@ import type {
   MemorySpanType,
   RecordingEvent,
   ReplayCrumb,
+  ReplayError,
   ReplaySpan,
 } from 'sentry/views/replays/types';
 
 interface ReplayReaderParams {
   breadcrumbs: ReplayCrumb[] | undefined;
+  errors: ReplayError[] | undefined;
 
   /**
    * The root Replay event, created at the start of the browser session.
@@ -35,17 +37,18 @@ type RequiredNotNull<T> = {
 };
 
 export default class ReplayReader {
-  static factory({breadcrumbs, event, rrwebEvents, spans}: ReplayReaderParams) {
-    if (!breadcrumbs || !event || !rrwebEvents || !spans) {
+  static factory({breadcrumbs, event, errors, rrwebEvents, spans}: ReplayReaderParams) {
+    if (!breadcrumbs || !event || !rrwebEvents || !spans || !errors) {
       return null;
     }
 
-    return new ReplayReader({breadcrumbs, event, rrwebEvents, spans});
+    return new ReplayReader({breadcrumbs, event, errors, rrwebEvents, spans});
   }
 
   private constructor({
     breadcrumbs,
     event,
+    errors,
     rrwebEvents,
     spans,
   }: RequiredNotNull<ReplayReaderParams>) {
@@ -56,7 +59,7 @@ export default class ReplayReader {
     );
 
     this.spans = spansFactory(spans);
-    this.breadcrumbs = breadcrumbFactory(startTimestampMS, [event], breadcrumbs);
+    this.breadcrumbs = breadcrumbFactory(startTimestampMS, event, errors, breadcrumbs);
 
     this.rrwebEvents = rrwebEventListFactory(
       startTimestampMS,

+ 12 - 0
static/app/views/replays/types.tsx

@@ -62,3 +62,15 @@ export type ReplayCrumb = RawCrumb & {
    */
   timestamp: number;
 };
+
+/**
+ * This is a result of a custom discover query
+ */
+export interface ReplayError {
+  ['error.type']: string;
+  ['error.value']: string;
+  id: string;
+  ['issue.id']: number;
+  ['project.name']: string;
+  timestamp: string;
+}

+ 186 - 0
tests/js/spec/utils/replays/hooks/useDiscoverQuery.spec.tsx

@@ -0,0 +1,186 @@
+import {useMemo} from 'react';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useDiscoverQuery from 'sentry/utils/replays/hooks/useDiscoveryQuery';
+import {RouteContext} from 'sentry/views/routeContext';
+
+function MockComponent({replayId, ...props}) {
+  const discoverQuery = useMemo(
+    () => ({
+      query: `replayId:${replayId} AND event.type:error`,
+      fields: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+
+      // environment and project shouldn't matter because having a replayId
+      // assumes we have already filtered down to proper env/project
+      environment: [],
+      projects: [],
+    }),
+    [replayId]
+  );
+  const {data, isLoading, error} = useDiscoverQuery({
+    discoverQuery,
+    ...props,
+  });
+
+  if (isLoading) {
+    return <div>Loading</div>;
+  }
+  if (error) {
+    return <div>Error</div>;
+  }
+
+  return <div>{data}</div>;
+}
+
+function renderMock({props = {}, location = {}} = {}) {
+  const {routerContext, router, organization} = initializeOrg();
+
+  return render(
+    <RouteContext.Provider
+      value={{
+        location: {...routerContext.context.location, ...location},
+        router,
+        params: {},
+        routes: [],
+      }}
+    >
+      <MockComponent replayId="1" {...props} />
+    </RouteContext.Provider>,
+    {context: routerContext, organization}
+  );
+}
+const API_URL = `/organizations/org-slug/eventsv2/`;
+
+describe('useDiscoverQuery', function () {
+  let mockRequest: ReturnType<typeof MockApiClient.addMockResponse>;
+
+  beforeAll(function () {
+    mockRequest = MockApiClient.addMockResponse({
+      url: API_URL,
+      statusCode: 200,
+      body: {data: 'success'},
+    });
+  });
+
+  beforeEach(function () {
+    mockRequest.mockReset();
+  });
+
+  it('makes a request', async function () {
+    renderMock();
+
+    await screen.findByText('success');
+
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+    expect(mockRequest).toHaveBeenCalledWith(
+      API_URL,
+      expect.objectContaining({
+        query: {
+          environment: [],
+          field: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+          per_page: 50,
+          project: [],
+          query: 'replayId:1 AND event.type:error',
+          statsPeriod: '14d', // TODO: This will need to be changed
+        },
+      })
+    );
+  });
+
+  it('makes a single request on multiple renders if replayId does not change', async function () {
+    const {routerContext, router, organization} = initializeOrg();
+
+    const location = {...routerContext.context.location};
+
+    const {rerender} = render(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>,
+      {context: routerContext, organization}
+    );
+
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>
+    );
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+  });
+
+  it('makes a new request when replayId changes', async function () {
+    const {routerContext, router, organization} = initializeOrg();
+
+    const location = {...routerContext.context.location};
+
+    const {rerender} = render(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>,
+      {context: routerContext, organization}
+    );
+
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="2" />
+      </RouteContext.Provider>
+    );
+    await screen.findByText('success');
+
+    expect(mockRequest).toHaveBeenCalledTimes(2);
+    expect(mockRequest).toHaveBeenLastCalledWith(
+      API_URL,
+      expect.objectContaining({
+        query: {
+          environment: [],
+          field: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+          per_page: 50,
+          project: [],
+          query: 'replayId:2 AND event.type:error',
+          statsPeriod: '14d',
+        },
+      })
+    );
+  });
+
+  /**
+   * Note we do not want this hook to use URL params because it is a discover
+   * query depending on a parent query that requires the URL params. e.g. cursor is for the pagination of a parent query, but child cursor will needs its own
+   */
+  it('does not use current location params in request', async function () {
+    renderMock({
+      props: {
+        ignoreCursor: true,
+      },
+      location: {
+        query: {
+          cursor: 'foo',
+        },
+      },
+    });
+
+    await screen.findByText('success');
+
+    expect(mockRequest).toHaveBeenLastCalledWith(
+      API_URL,
+      expect.objectContaining({
+        query: {
+          environment: [],
+          field: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+          per_page: 50,
+          project: [],
+          query: 'replayId:1 AND event.type:error',
+          statsPeriod: '14d',
+        },
+      })
+    );
+  });
+});

+ 98 - 0
tests/js/spec/utils/replays/hooks/useReplayErrors.spec.tsx

@@ -0,0 +1,98 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useReplayErrors from 'sentry/utils/replays/hooks/useReplayErrors';
+import {RouteContext} from 'sentry/views/routeContext';
+
+function MockComponent(props) {
+  const {data, isLoading, error} = useReplayErrors(props);
+
+  if (isLoading) {
+    return <div>Loading</div>;
+  }
+  if (error) {
+    return <div>Error</div>;
+  }
+
+  return <div>{data}</div>;
+}
+
+const API_URL = `/organizations/org-slug/eventsv2/`;
+
+describe('useReplayErrors', function () {
+  let mockRequest: ReturnType<typeof MockApiClient.addMockResponse>;
+
+  beforeAll(function () {
+    mockRequest = MockApiClient.addMockResponse({
+      url: API_URL,
+      statusCode: 200,
+      body: {data: 'success'},
+    });
+  });
+
+  beforeEach(function () {
+    mockRequest.mockReset();
+  });
+
+  it('makes a single request on multiple renders if replayId does not change', async function () {
+    const {routerContext, router, organization} = initializeOrg();
+
+    const location = {...routerContext.context.location};
+
+    const {rerender} = render(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>,
+      {context: routerContext, organization}
+    );
+
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>
+    );
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+  });
+
+  it('makes a new request when replayId changes', async function () {
+    const {routerContext, router, organization} = initializeOrg();
+
+    const location = {...routerContext.context.location};
+
+    const {rerender} = render(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="1" />
+      </RouteContext.Provider>,
+      {context: routerContext, organization}
+    );
+
+    await screen.findByText('success');
+    expect(mockRequest).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <RouteContext.Provider value={{location, router, params: {}, routes: []}}>
+        <MockComponent replayId="2" />
+      </RouteContext.Provider>
+    );
+    await screen.findByText('success');
+
+    expect(mockRequest).toHaveBeenCalledTimes(2);
+    expect(mockRequest).toHaveBeenLastCalledWith(
+      API_URL,
+      expect.objectContaining({
+        query: {
+          environment: [],
+          field: ['event.id', 'error.value', 'timestamp', 'error.type', 'issue.id'],
+          per_page: 50,
+          project: [],
+          query: 'replayId:2 AND event.type:error',
+          statsPeriod: '14d',
+        },
+      })
+    );
+  });
+});