Browse Source

feat(replays): Replay Details header link to view errors in Console tab (#39038)

Updated the header to have a link that will open the Console tab, and
auto-select the 'error' filter:

![Screen Shot 2022-09-21 at 9 33 07
AM](https://user-images.githubusercontent.com/187460/191561421-652ef647-ec0f-41c1-89f3-79771c08f96f.png)

If there are no errors in the replay, then clicking the link will still
open the Console tab, and set the filter to be `level=error`. Normally
you wouldn't be able to set `level=error` yourself when there are no
errors because the dropdown is populated only with values that exist in
the data.

Depends on #39030
Fixes #38861
Ryan Albrecht 2 years ago
parent
commit
ea47737d93

+ 4 - 0
static/app/types/breadcrumbs.tsx

@@ -112,6 +112,10 @@ interface DefaultCrumb extends BaseCrumb, BreadcrumbTypeDefault {}
 
 export type Crumb = NavigationCrumb | HTTPCrumb | DefaultCrumb;
 
+export function isBreadcrumbLogLevel(logLevel: string): logLevel is BreadcrumbLevelType {
+  return Object.values(BreadcrumbLevelType).includes(logLevel as any);
+}
+
 export function isBreadcrumbTypeDefault(
   breadcrumb: Crumb
 ): breadcrumb is Extract<Crumb, BreadcrumbTypeDefault> {

+ 4 - 1
static/app/views/replays/detail/console/index.tsx

@@ -73,7 +73,10 @@ function Console({breadcrumbs, startTimestampMs = 0}: Props) {
           triggerProps={{prefix: t('Log Level')}}
           triggerLabel={logLevel.length === 0 ? t('Any') : null}
           multiple
-          options={getLogLevels(breadcrumbs).map(value => ({value, label: value}))}
+          options={getLogLevels(breadcrumbs, logLevel).map(value => ({
+            value,
+            label: value,
+          }))}
           onChange={selected => setLogLevel(selected.map(_ => _.value))}
           size="sm"
           value={logLevel}

+ 8 - 4
static/app/views/replays/detail/console/useConsoleFilters.tsx

@@ -1,7 +1,11 @@
 import {useCallback, useMemo} from 'react';
 
-import type {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
-import {isBreadcrumbTypeDefault} from 'sentry/types/breadcrumbs';
+import type {
+  BreadcrumbLevelType,
+  BreadcrumbTypeDefault,
+  Crumb,
+} from 'sentry/types/breadcrumbs';
+import {isBreadcrumbLogLevel, isBreadcrumbTypeDefault} from 'sentry/types/breadcrumbs';
 import {decodeList, decodeScalar} from 'sentry/utils/queryString';
 import useFiltersInLocationQuery from 'sentry/utils/replays/hooks/useFiltersInLocationQuery';
 import {filterItems} from 'sentry/views/replays/detail/utils';
@@ -19,7 +23,7 @@ type Options = {
 
 type Return = {
   items: Item[];
-  logLevel: string[];
+  logLevel: BreadcrumbLevelType[];
   searchTerm: string;
   setLogLevel: (logLevel: string[]) => void;
   setSearchTerm: (searchTerm: string) => void;
@@ -41,7 +45,7 @@ function useConsoleFilters({breadcrumbs}: Options): Return {
     [breadcrumbs]
   );
 
-  const logLevel = decodeList(query.f_c_logLevel);
+  const logLevel = decodeList(query.f_c_logLevel).filter(isBreadcrumbLogLevel);
   const searchTerm = decodeScalar(query.f_c_search, '').toLowerCase();
 
   const items = useMemo(

+ 36 - 0
static/app/views/replays/detail/console/utils.spec.tsx

@@ -0,0 +1,36 @@
+import {BreadcrumbLevelType, Crumb} from 'sentry/types/breadcrumbs';
+
+import {getLogLevels} from './utils';
+
+describe('getLogLevels', () => {
+  const CRUMB_LOG_1 = {level: BreadcrumbLevelType.LOG} as Crumb;
+  const CRUMB_LOG_2 = {level: BreadcrumbLevelType.LOG} as Crumb;
+  const CRUMB_WARN = {level: BreadcrumbLevelType.WARNING} as Crumb;
+  const CRUMB_ERROR = {level: BreadcrumbLevelType.ERROR} as Crumb;
+
+  it('should return a sorted list of BreadcrumbLevelType', () => {
+    const crumbs = [CRUMB_LOG_1, CRUMB_WARN, CRUMB_ERROR];
+    const extra = [];
+    expect(getLogLevels(crumbs, extra)).toStrictEqual([
+      BreadcrumbLevelType.ERROR,
+      BreadcrumbLevelType.LOG,
+      BreadcrumbLevelType.WARNING,
+    ]);
+  });
+
+  it('should deduplicate BreadcrumbLevelType', () => {
+    const crumbs = [CRUMB_LOG_1, CRUMB_LOG_2];
+    const extra = [];
+    expect(getLogLevels(crumbs, extra)).toStrictEqual([BreadcrumbLevelType.LOG]);
+  });
+
+  it('should inject extra BreadcrumbLevelType values', () => {
+    const crumbs = [CRUMB_WARN, CRUMB_ERROR];
+    const extra = [BreadcrumbLevelType.LOG];
+    expect(getLogLevels(crumbs, extra)).toStrictEqual([
+      BreadcrumbLevelType.ERROR,
+      BreadcrumbLevelType.LOG,
+      BreadcrumbLevelType.WARNING,
+    ]);
+  });
+});

+ 3 - 3
static/app/views/replays/detail/console/utils.tsx

@@ -1,6 +1,6 @@
 import type {BreadcrumbLevelType, Crumb} from 'sentry/types/breadcrumbs';
 
-export const getLogLevels = (breadcrumbs: Crumb[]) =>
+export const getLogLevels = (breadcrumbs: Crumb[], selected: BreadcrumbLevelType[]) =>
   Array.from(
-    new Set<BreadcrumbLevelType>(breadcrumbs.map(breadcrumb => breadcrumb.level))
-  );
+    new Set(breadcrumbs.map(breadcrumb => breadcrumb.level).concat(selected))
+  ).sort();

+ 22 - 0
static/app/views/replays/detail/domMutations/utils.spec.tsx

@@ -0,0 +1,22 @@
+import {BreadcrumbType} from 'sentry/types/breadcrumbs';
+import type {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+
+import {getDomMutationsTypes} from './utils';
+
+describe('getDomMutationsTypes', () => {
+  const MUTATION_DEBUG = {crumb: {type: BreadcrumbType.DEBUG}} as Extraction;
+  const MUTATION_UI = {crumb: {type: BreadcrumbType.UI}} as Extraction;
+
+  it('should return a sorted list of BreadcrumbType', () => {
+    const mutations = [MUTATION_DEBUG, MUTATION_UI];
+    expect(getDomMutationsTypes(mutations)).toStrictEqual([
+      BreadcrumbType.DEBUG,
+      BreadcrumbType.UI,
+    ]);
+  });
+
+  it('should deduplicate BreadcrumbType', () => {
+    const mutations = [MUTATION_DEBUG, MUTATION_DEBUG];
+    expect(getDomMutationsTypes(mutations)).toStrictEqual([BreadcrumbType.DEBUG]);
+  });
+});

+ 4 - 4
static/app/views/replays/detail/domMutations/utils.tsx

@@ -1,7 +1,7 @@
-import {BreadcrumbType} from 'sentry/types/breadcrumbs';
-import {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import type {BreadcrumbType} from 'sentry/types/breadcrumbs';
+import type {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
 
 export const getDomMutationsTypes = (actions: Extraction[]) =>
   Array.from(
-    new Set<BreadcrumbType>(actions.map(mutation => mutation.crumb.type).sort())
-  );
+    new Set<BreadcrumbType>(actions.map(mutation => mutation.crumb.type))
+  ).sort();

+ 37 - 0
static/app/views/replays/detail/network/utils.spec.tsx

@@ -0,0 +1,37 @@
+import {getResourceTypes, getStatusTypes, NetworkSpan} from './utils';
+
+describe('getResourceTypes', () => {
+  const SPAN_NAVIGATE = {op: 'navigation.navigate'} as NetworkSpan;
+  const SPAN_FETCH = {op: 'resource.fetch'} as NetworkSpan;
+  const SPAN_PUSH = {op: 'navigation.push'} as NetworkSpan;
+
+  it('should return a sorted list of BreadcrumbType', () => {
+    const spans = [SPAN_NAVIGATE, SPAN_FETCH, SPAN_PUSH];
+    expect(getResourceTypes(spans)).toStrictEqual([
+      'fetch',
+      'navigation.navigate',
+      'navigation.push',
+    ]);
+  });
+
+  it('should deduplicate BreadcrumbType', () => {
+    const spans = [SPAN_FETCH, SPAN_FETCH];
+    expect(getResourceTypes(spans)).toStrictEqual(['fetch']);
+  });
+});
+
+describe('getStatusTypes', () => {
+  const SPAN_200 = {data: {statusCode: 200}} as unknown as NetworkSpan;
+  const SPAN_404 = {data: {statusCode: 404}} as unknown as NetworkSpan;
+  const SPAN_UNKNOWN = {data: {statusCode: undefined}} as unknown as NetworkSpan;
+
+  it('should return a sorted list of BreadcrumbType', () => {
+    const spans = [SPAN_200, SPAN_404, SPAN_UNKNOWN];
+    expect(getStatusTypes(spans)).toStrictEqual(['200', '404', 'unknown']);
+  });
+
+  it('should deduplicate BreadcrumbType', () => {
+    const spans = [SPAN_200, SPAN_200];
+    expect(getStatusTypes(spans)).toStrictEqual(['200']);
+  });
+});

+ 5 - 8
static/app/views/replays/detail/network/utils.tsx

@@ -50,17 +50,14 @@ export function sortNetwork(
 
 export const getResourceTypes = (networkSpans: NetworkSpan[]) =>
   Array.from(
-    new Set<string>(
-      networkSpans.map(networkSpan => networkSpan.op.replace('resource.', '')).sort()
-    )
-  );
+    new Set(networkSpans.map(networkSpan => networkSpan.op.replace('resource.', '')))
+  ).sort();
 
 export const getStatusTypes = (networkSpans: NetworkSpan[]) =>
   Array.from(
-    new Set<string>(
+    new Set(
       networkSpans
-        .map(networkSpan => networkSpan.data?.statusCode ?? UNKNOWN_STATUS)
-        .sort()
+        .map(networkSpan => networkSpan.data.statusCode ?? UNKNOWN_STATUS)
         .map(String)
     )
-  );
+  ).sort();

+ 41 - 20
static/app/views/replays/detail/replayMetaData.tsx

@@ -1,11 +1,14 @@
-import React from 'react';
+import {ComponentProps, Fragment} from 'react';
+import {Link} from 'react-router';
 import styled from '@emotion/styled';
 
 import Duration from 'sentry/components/duration';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import Placeholder from 'sentry/components/placeholder';
+import Tag, {Background} from 'sentry/components/tag';
 import TimeSince from 'sentry/components/timeSince';
-import {IconCalendar, IconClock, IconFire} from 'sentry/icons';
+import {IconCalendar, IconClock} from 'sentry/icons';
+import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import useProjects from 'sentry/utils/useProjects';
 import {useRouteContext} from 'sentry/utils/useRouteContext';
@@ -17,20 +20,27 @@ type Props = {
 
 function ReplayMetaData({replayRecord}: Props) {
   const {
+    location: {pathname, query},
     params: {replaySlug},
   } = useRouteContext();
   const {projects} = useProjects();
   const [slug] = replaySlug.split(':');
 
+  const errorsTabHref = {
+    pathname,
+    query: {
+      ...query,
+      t_main: 'console',
+      f_c_logLevel: 'error',
+      f_c_search: undefined,
+    },
+  };
+
   return (
     <KeyMetrics>
       {replayRecord ? (
         <ProjectBadge
-          project={
-            projects.find(p => p.id === replayRecord.projectId) || {
-              slug,
-            }
-          }
+          project={projects.find(p => p.id === replayRecord.projectId) || {slug}}
           avatarSize={16}
         />
       ) : (
@@ -39,30 +49,32 @@ function ReplayMetaData({replayRecord}: Props) {
 
       <KeyMetricData>
         {replayRecord ? (
-          <React.Fragment>
+          <Fragment>
             <IconCalendar color="gray300" />
             <TimeSince date={replayRecord.startedAt} shorten />
-          </React.Fragment>
+          </Fragment>
         ) : (
           <HeaderPlaceholder />
         )}
       </KeyMetricData>
       <KeyMetricData>
         {replayRecord ? (
-          <React.Fragment>
+          <Fragment>
             <IconClock color="gray300" />
             <Duration seconds={replayRecord?.duration} abbreviation exact />
-          </React.Fragment>
+          </Fragment>
         ) : (
           <HeaderPlaceholder />
         )}
       </KeyMetricData>
       <KeyMetricData>
         {replayRecord ? (
-          <React.Fragment>
-            <IconFire color="red300" />
-            {replayRecord?.countErrors}
-          </React.Fragment>
+          <Fragment>
+            <ErrorTag to={errorsTabHref} icon={null} type="error">
+              {replayRecord?.countErrors}
+            </ErrorTag>
+            <Link to={errorsTabHref}>{t('Errors')}</Link>
+          </Fragment>
         ) : (
           <HeaderPlaceholder />
         )}
@@ -71,11 +83,9 @@ function ReplayMetaData({replayRecord}: Props) {
   );
 }
 
-export const HeaderPlaceholder = styled(function HeaderPlaceholder(
-  props: React.ComponentProps<typeof Placeholder>
-) {
-  return <Placeholder width="80px" height="19px" {...props} />;
-})`
+export const HeaderPlaceholder = styled((props: ComponentProps<typeof Placeholder>) => (
+  <Placeholder width="80px" height="19px" {...props} />
+))`
   background-color: ${p => p.theme.background};
 `;
 
@@ -98,4 +108,15 @@ const KeyMetricData = styled('div')`
   line-height: ${p => p.theme.text.lineHeightBody};
 `;
 
+const ErrorTag = styled(Tag)`
+  ${Background} {
+    background: ${p => p.theme.tag.error.iconColor};
+    padding: 0 ${space(0.75)};
+
+    span {
+      color: white !important;
+    }
+  }
+`;
+
 export default ReplayMetaData;