Browse Source

feat(performance-trace-details): Adding trace level information. (#61526)

This pr adds trace level information under the feature flag
`organizations:performance-trace-details`:
- User info + browser
- Event/Issues/Total Duration
- Web vitals (will add CTAs later)
- Trace level tag summary (we can add show tags functionality later)
- Service breakdown + replay preview

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdkhan14 1 year ago
parent
commit
85781853c0

+ 187 - 0
static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx

@@ -0,0 +1,187 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {generateStats} from 'sentry/components/events/opsBreakdown';
+import {DividerSpacer} from 'sentry/components/performance/waterfall/miniHeader';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {space} from 'sentry/styles/space';
+import {EventTransaction, Organization} from 'sentry/types';
+import {getDuration} from 'sentry/utils/formatters';
+import toPercent from 'sentry/utils/number/toPercent';
+import {TraceType} from 'sentry/views/performance/traceDetails/newTraceDetailsContent';
+import {TraceInfo} from 'sentry/views/performance/traceDetails/types';
+
+import ReplayPreview from '../../eventReplay/replayPreview';
+
+import * as DividerHandlerManager from './dividerHandlerManager';
+
+type PropType = {
+  event: EventTransaction | undefined;
+  organization: Organization;
+  traceInfo: TraceInfo;
+  traceType: TraceType;
+  traceViewHeaderRef: React.RefObject<HTMLDivElement>;
+};
+
+function ServiceBreakdown({
+  rootEvent,
+  displayBreakdown,
+}: {
+  displayBreakdown: boolean;
+  rootEvent: EventTransaction;
+}) {
+  if (!displayBreakdown) {
+    return (
+      <BreakDownWrapper>
+        <BreakDownRow>
+          <div>{t('server side')}</div>
+          <FlexBox>
+            <span>{'N/A'}</span>
+          </FlexBox>
+        </BreakDownRow>
+        <BreakDownRow>
+          <div>{t('client side')}</div>
+          <FlexBox>
+            <span>{'N/A'}</span>
+          </FlexBox>
+        </BreakDownRow>
+      </BreakDownWrapper>
+    );
+  }
+
+  const totalDuration = rootEvent.endTimestamp - rootEvent.startTimestamp;
+  const breakdown = generateStats(rootEvent, {type: 'no_filter'});
+  const httpOp = breakdown.find(obj => obj.name === 'http.client');
+  const httpDuration = httpOp?.totalInterval ?? 0;
+  const serverSidePct = ((httpDuration / totalDuration) * 100).toFixed();
+  const clientSidePct = 100 - Number(serverSidePct);
+
+  return httpDuration ? (
+    <BreakDownWrapper>
+      <BreakDownRow>
+        <div>{t('server side')}</div>
+        <FlexBox>
+          <Dur>{getDuration(httpDuration, 2, true)}</Dur>
+          <Pct>{serverSidePct}%</Pct>
+        </FlexBox>
+      </BreakDownRow>
+      <BreakDownRow>
+        <div>{t('client side')}</div>
+        <FlexBox>
+          <Dur>{getDuration(totalDuration - httpDuration, 2, true)}</Dur>
+          <Pct>{clientSidePct}%</Pct>
+        </FlexBox>
+      </BreakDownRow>
+    </BreakDownWrapper>
+  ) : null;
+}
+
+function TraceViewHeader(props: PropType) {
+  const {event} = props;
+  if (!event) {
+    return null;
+  }
+
+  const opsBreakdown = generateStats(event, {type: 'no_filter'});
+  const httpOp = opsBreakdown.find(obj => obj.name === 'http.client');
+  const hasServiceBreakdown = httpOp && props.traceType === TraceType.ONE_ROOT;
+
+  return (
+    <HeaderContainer ref={props.traceViewHeaderRef} hasProfileMeasurementsChart={false}>
+      <DividerHandlerManager.Consumer>
+        {dividerHandlerChildrenProps => {
+          const {dividerPosition} = dividerHandlerChildrenProps;
+          return (
+            <Fragment>
+              <OperationsBreakdown
+                style={{
+                  width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
+                }}
+              >
+                {props.event && (
+                  <ServiceBreakdown
+                    displayBreakdown={!!hasServiceBreakdown}
+                    rootEvent={props.event}
+                  />
+                )}
+              </OperationsBreakdown>
+              <DividerSpacer
+                style={{
+                  position: 'absolute',
+                  top: 0,
+                  left: `calc(${toPercent(dividerPosition)} - 0.5px)`,
+                  height: `100px`,
+                }}
+              />
+              <div
+                style={{
+                  overflow: 'hidden',
+                  position: 'relative',
+                  height: '100px',
+                  width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
+                  left: `calc(${toPercent(dividerPosition)} + 0.5px)`,
+                }}
+              >
+                {event.contexts.replay?.replay_id && (
+                  <ReplayPreview
+                    replaySlug={event.contexts.replay?.replay_id ?? ''}
+                    orgSlug={props.organization.slug}
+                    eventTimestampMs={props.traceInfo.startTimestamp}
+                  />
+                )}
+              </div>
+            </Fragment>
+          );
+        }}
+      </DividerHandlerManager.Consumer>
+    </HeaderContainer>
+  );
+}
+
+const HeaderContainer = styled('div')<{hasProfileMeasurementsChart: boolean}>`
+  width: 100%;
+  left: 0;
+  top: ${p => (ConfigStore.get('demoMode') ? p.theme.demo.headerSize : 0)};
+  z-index: ${p => p.theme.zIndex.traceView.minimapContainer};
+  background-color: ${p => p.theme.background};
+  border-bottom: 1px solid ${p => p.theme.border};
+  height: 100px;
+  border-top-left-radius: ${p => p.theme.borderRadius};
+  border-top-right-radius: ${p => p.theme.borderRadius};
+`;
+
+const OperationsBreakdown = styled('div')`
+  height: 100px;
+  position: absolute;
+  left: 0;
+  top: 0;
+  overflow: hidden;
+`;
+
+const Dur = styled('div')`
+  color: ${p => p.theme.gray300};
+  font-variant-numeric: tabular-nums;
+`;
+
+const Pct = styled('div')`
+  min-width: 40px;
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+`;
+
+const FlexBox = styled('div')`
+  display: flex;
+`;
+
+const BreakDownWrapper = styled(FlexBox)`
+  flex-direction: column;
+  padding: ${space(2)};
+`;
+
+const BreakDownRow = styled(FlexBox)`
+  align-items: center;
+  justify-content: space-between;
+`;
+
+export default TraceViewHeader;

+ 1 - 1
static/app/components/events/interfaces/spans/spanBar.tsx

@@ -562,7 +562,7 @@ export class SpanBar extends Component<SpanBarProps, SpanBarState> {
             // TODO Abdullah Khan: A little bit hacky, but ensures that the toggled tree aligns
             // with the rest of the span tree, in the trace view.
             if (this.props.fromTraceView) {
-              this.props.updateHorizontalScrollState(1);
+              this.props.updateHorizontalScrollState(0.5);
             }
           }}
         >

+ 157 - 152
static/app/components/events/opsBreakdown.tsx

@@ -17,6 +17,7 @@ import {
   EntrySpans,
   EntryType,
   Event,
+  EventTransaction,
 } from 'sentry/types/event';
 
 type StartTimestamp = number;
@@ -52,194 +53,198 @@ type Props = {
   topN?: number;
 };
 
-function OpsBreakdown({
-  event,
-  operationNameFilters,
-  hideHeader = false,
-  topN = TOP_N_SPANS,
-}: Props) {
-  const transactionEvent =
-    event.type === 'transaction' || event.type === 'aggregateTransaction'
-      ? event
-      : undefined;
+export function generateStats(
+  transactionEvent: EventTransaction | AggregateEventTransaction,
+  operationNameFilters: ActiveOperationFilter,
+  topN?: number
+): OpBreakdownType {
+  if (!transactionEvent) {
+    return [];
+  }
 
-  function generateStats(): OpBreakdownType {
-    if (!transactionEvent) {
-      return [];
-    }
+  const traceContext: TraceContextType | undefined = transactionEvent?.contexts?.trace;
 
-    const traceContext: TraceContextType | undefined = transactionEvent?.contexts?.trace;
+  if (!traceContext) {
+    return [];
+  }
 
-    if (!traceContext) {
-      return [];
+  const spanEntry = transactionEvent.entries.find(
+    (entry: EntrySpans | any): entry is EntrySpans => {
+      return entry.type === EntryType.SPANS;
     }
+  );
+
+  let spans: RawSpanType[] = spanEntry?.data ?? [];
+
+  const rootSpan = {
+    op: traceContext.op,
+    timestamp: transactionEvent.endTimestamp,
+    start_timestamp: transactionEvent.startTimestamp,
+    trace_id: traceContext.trace_id || '',
+    span_id: traceContext.span_id || '',
+    data: {},
+  };
+
+  spans =
+    spans.length > 0
+      ? spans
+      : // if there are no descendent spans, then use the transaction root span
+        [rootSpan];
+
+  // Filter spans by operation name
+  if (operationNameFilters.type === 'active_filter') {
+    spans = [...spans, rootSpan];
+    spans = spans.filter(span => {
+      const operationName = getSpanOperation(span);
+
+      const shouldFilterOut =
+        typeof operationName === 'string' &&
+        !operationNameFilters.operationNames.has(operationName);
+
+      return !shouldFilterOut;
+    });
+  }
 
-    const spanEntry = transactionEvent.entries.find(
-      (entry: EntrySpans | any): entry is EntrySpans => {
-        return entry.type === EntryType.SPANS;
+  const operationNameIntervals = spans.reduce(
+    (intervals: Partial<OperationNameIntervals>, span: RawSpanType) => {
+      let startTimestamp = span.start_timestamp;
+      const endTimestamp = span.timestamp;
+
+      if (!span.exclusive_time) {
+        return intervals;
       }
-    );
 
-    let spans: RawSpanType[] = spanEntry?.data ?? [];
-
-    const rootSpan = {
-      op: traceContext.op,
-      timestamp: transactionEvent.endTimestamp,
-      start_timestamp: transactionEvent.startTimestamp,
-      trace_id: traceContext.trace_id || '',
-      span_id: traceContext.span_id || '',
-      data: {},
-    };
-
-    spans =
-      spans.length > 0
-        ? spans
-        : // if there are no descendent spans, then use the transaction root span
-          [rootSpan];
-
-    // Filter spans by operation name
-    if (operationNameFilters.type === 'active_filter') {
-      spans = [...spans, rootSpan];
-      spans = spans.filter(span => {
-        const operationName = getSpanOperation(span);
-
-        const shouldFilterOut =
-          typeof operationName === 'string' &&
-          !operationNameFilters.operationNames.has(operationName);
-
-        return !shouldFilterOut;
-      });
-    }
+      if (endTimestamp < startTimestamp) {
+        // reverse timestamps
+        startTimestamp = span.timestamp;
+      }
 
-    const operationNameIntervals = spans.reduce(
-      (intervals: Partial<OperationNameIntervals>, span: RawSpanType) => {
-        let startTimestamp = span.start_timestamp;
-        const endTimestamp = span.timestamp;
+      // invariant: startTimestamp <= endTimestamp
 
-        if (!span.exclusive_time) {
-          return intervals;
-        }
+      let operationName = span.op;
 
-        if (endTimestamp < startTimestamp) {
-          // reverse timestamps
-          startTimestamp = span.timestamp;
-        }
+      if (typeof operationName !== 'string') {
+        // a span with no operation name is considered an 'unknown' op
+        operationName = 'unknown';
+      }
+
+      const cover: TimeWindowSpan = [
+        startTimestamp,
+        startTimestamp + span.exclusive_time / 1000,
+      ];
+
+      const operationNameInterval = intervals[operationName];
 
-        // invariant: startTimestamp <= endTimestamp
+      if (!Array.isArray(operationNameInterval)) {
+        intervals[operationName] = [cover];
 
-        let operationName = span.op;
+        return intervals;
+      }
 
-        if (typeof operationName !== 'string') {
-          // a span with no operation name is considered an 'unknown' op
-          operationName = 'unknown';
-        }
+      operationNameInterval.push(cover);
 
-        const cover: TimeWindowSpan = [
-          startTimestamp,
-          startTimestamp + span.exclusive_time / 1000,
-        ];
+      intervals[operationName] = mergeInterval(operationNameInterval);
 
-        const operationNameInterval = intervals[operationName];
+      return intervals;
+    },
+    {}
+  ) as OperationNameIntervals;
 
-        if (!Array.isArray(operationNameInterval)) {
-          intervals[operationName] = [cover];
+  const operationNameCoverage = Object.entries(operationNameIntervals).reduce(
+    (
+      acc: Partial<OperationNameCoverage>,
+      [operationName, intervals]: [OperationName, TimeWindowSpan[]]
+    ) => {
+      const duration = intervals.reduce((sum: number, [start, end]) => {
+        return sum + Math.abs(end - start);
+      }, 0);
 
-          return intervals;
-        }
+      acc[operationName] = duration;
 
-        operationNameInterval.push(cover);
+      return acc;
+    },
+    {}
+  ) as OperationNameCoverage;
 
-        intervals[operationName] = mergeInterval(operationNameInterval);
+  const sortedOpsBreakdown = Object.entries(operationNameCoverage).sort(
+    (first: [OperationName, Duration], second: [OperationName, Duration]) => {
+      const firstDuration = first[1];
+      const secondDuration = second[1];
 
-        return intervals;
-      },
-      {}
-    ) as OperationNameIntervals;
-
-    const operationNameCoverage = Object.entries(operationNameIntervals).reduce(
-      (
-        acc: Partial<OperationNameCoverage>,
-        [operationName, intervals]: [OperationName, TimeWindowSpan[]]
-      ) => {
-        const duration = intervals.reduce((sum: number, [start, end]) => {
-          return sum + Math.abs(end - start);
-        }, 0);
-
-        acc[operationName] = duration;
-
-        return acc;
-      },
-      {}
-    ) as OperationNameCoverage;
-
-    const sortedOpsBreakdown = Object.entries(operationNameCoverage).sort(
-      (first: [OperationName, Duration], second: [OperationName, Duration]) => {
-        const firstDuration = first[1];
-        const secondDuration = second[1];
-
-        if (firstDuration === secondDuration) {
-          return 0;
-        }
-
-        if (firstDuration < secondDuration) {
-          // sort second before first
-          return 1;
-        }
-
-        // otherwise, sort first before second
-        return -1;
+      if (firstDuration === secondDuration) {
+        return 0;
+      }
+
+      if (firstDuration < secondDuration) {
+        // sort second before first
+        return 1;
       }
-    );
 
-    const breakdown = sortedOpsBreakdown
-      .slice(0, topN)
-      .map(([operationName, duration]: [OperationName, Duration]): OpStats => {
-        return {
-          name: operationName,
-          // percentage to be recalculated after the ops breakdown group is decided
-          percentage: 0,
-          totalInterval: duration,
-        };
-      });
-
-    const other = sortedOpsBreakdown.slice(topN).reduce(
-      (accOther: OpStats, [_operationName, duration]: [OperationName, Duration]) => {
-        accOther.totalInterval += duration;
-
-        return accOther;
-      },
-      {
-        name: OtherOperation,
+      // otherwise, sort first before second
+      return -1;
+    }
+  );
+
+  const breakdown = sortedOpsBreakdown
+    .slice(0, topN)
+    .map(([operationName, duration]: [OperationName, Duration]): OpStats => {
+      return {
+        name: operationName,
         // percentage to be recalculated after the ops breakdown group is decided
         percentage: 0,
-        totalInterval: 0,
-      }
-    );
+        totalInterval: duration,
+      };
+    });
 
-    if (other.totalInterval > 0) {
-      breakdown.push(other);
+  const other = sortedOpsBreakdown.slice(topN).reduce(
+    (accOther: OpStats, [_operationName, duration]: [OperationName, Duration]) => {
+      accOther.totalInterval += duration;
+
+      return accOther;
+    },
+    {
+      name: OtherOperation,
+      // percentage to be recalculated after the ops breakdown group is decided
+      percentage: 0,
+      totalInterval: 0,
     }
+  );
 
-    // calculate breakdown total duration
+  if (other.totalInterval > 0) {
+    breakdown.push(other);
+  }
 
-    const total = breakdown.reduce((sum: number, operationNameGroup) => {
-      return sum + operationNameGroup.totalInterval;
-    }, 0);
+  // calculate breakdown total duration
 
-    // recalculate percentage values
+  const total = breakdown.reduce((sum: number, operationNameGroup) => {
+    return sum + operationNameGroup.totalInterval;
+  }, 0);
 
-    breakdown.forEach(operationNameGroup => {
-      operationNameGroup.percentage = operationNameGroup.totalInterval / total;
-    });
+  // recalculate percentage values
 
-    return breakdown;
-  }
+  breakdown.forEach(operationNameGroup => {
+    operationNameGroup.percentage = operationNameGroup.totalInterval / total;
+  });
+
+  return breakdown;
+}
+
+function OpsBreakdown({
+  event,
+  operationNameFilters,
+  hideHeader = false,
+  topN = TOP_N_SPANS,
+}: Props) {
+  const transactionEvent =
+    event.type === 'transaction' || event.type === 'aggregateTransaction'
+      ? event
+      : undefined;
 
   if (!transactionEvent) {
     return null;
   }
 
-  const breakdown = generateStats();
+  const breakdown = generateStats(transactionEvent, operationNameFilters, topN);
 
   const contents = breakdown.map(currOp => {
     const {name, percentage, totalInterval} = currOp;

+ 212 - 83
static/app/views/performance/traceDetails/newTraceDetailsContent.tsx

@@ -6,31 +6,36 @@ import {Alert} from 'sentry/components/alert';
 import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import ButtonBar from 'sentry/components/buttonBar';
 import DiscoverButton from 'sentry/components/discoverButton';
+import EventVitals from 'sentry/components/events/eventVitals';
 import * as Layout from 'sentry/components/layouts/thirds';
 import ExternalLink from 'sentry/components/links/externalLink';
 import LoadingError from 'sentry/components/loadingError';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import TimeSince from 'sentry/components/timeSince';
-import {t, tct, tn} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
+import {EventTransaction, Organization} from 'sentry/types';
+import {generateQueryWithTag} from 'sentry/utils';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import EventView from 'sentry/utils/discover/eventView';
+import {formatTagKey} from 'sentry/utils/discover/fields';
 import {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
 import {getDuration} from 'sentry/utils/formatters';
-import getDynamicText from 'sentry/utils/getDynamicText';
 import {
   TraceError,
   TraceFullDetailed,
   TraceMeta,
 } from 'sentry/utils/performance/quickTrace/types';
+import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
 import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import Tags from 'sentry/views/discover/tags';
 import Breadcrumb from 'sentry/views/performance/breadcrumb';
 import {MetaData} from 'sentry/views/performance/transactionDetails/styles';
 
-import {TraceDetailHeader} from './styles';
+import {BrowserDisplay} from '../transactionDetails/eventMetas';
+
+import NewTraceView from './newTraceDetailsTraceView';
 import TraceNotFound from './traceNotFound';
-import TraceView from './traceView';
 import {TraceInfo} from './types';
 import {getTraceInfo, hasTraceData, isRootTransaction} from './utils';
 
@@ -47,7 +52,28 @@ type Props = Pick<RouteComponentProps<{traceSlug: string}, {}>, 'params' | 'loca
   orphanErrors?: TraceError[];
 };
 
+export enum TraceType {
+  ONE_ROOT = 'one_root',
+  NO_ROOT = 'no_root',
+  MULTIPLE_ROOTS = 'multiple_roots',
+  BROKEN_SUBTRACES = 'broken_subtraces',
+  ONLY_ERRORS = 'only_errors',
+  EMPTY_TRACE = 'empty_trace',
+}
+
 function NewTraceDetailsContent(props: Props) {
+  const root = props.traces && props.traces[0];
+  const {data: rootEvent, isLoading: isRootEventLoading} = useApiQuery<EventTransaction>(
+    [
+      `/organizations/${props.organization.slug}/events/${root?.project_slug}:${root?.event_id}/`,
+    ],
+    {
+      staleTime: Infinity,
+      retry: true,
+      enabled: !!(props.traces && props.traces.length > 0),
+    }
+  );
+
   const renderTraceLoading = () => {
     return (
       <LoadingContainer>
@@ -67,46 +93,54 @@ function NewTraceDetailsContent(props: Props) {
     const performanceIssues =
       meta?.performance_issues ?? traceInfo.performanceIssues.size;
     return (
-      <TraceDetailHeader>
-        <GuideAnchor target="trace_view_guide_breakdown">
+      <TraceHeaderContainer>
+        {rootEvent && (
+          <TraceHeaderRow>
+            <MetaData
+              headingText={t('User')}
+              tooltipText=""
+              bodyText={rootEvent?.user?.email ?? rootEvent?.user?.name ?? '\u2014'}
+              subtext={null}
+            />
+            <MetaData
+              headingText={t('Browser')}
+              tooltipText=""
+              bodyText={<BrowserDisplay event={rootEvent} showVersion />}
+              subtext={null}
+            />
+          </TraceHeaderRow>
+        )}
+        <TraceHeaderRow>
+          <GuideAnchor target="trace_view_guide_breakdown">
+            <MetaData
+              headingText={t('Events')}
+              tooltipText=""
+              bodyText={meta?.transactions ?? traceInfo.transactions.size}
+              subtext={null}
+            />
+          </GuideAnchor>
           <MetaData
-            headingText={t('Event Breakdown')}
-            tooltipText={t(
-              'The number of transactions and issues there are in this trace.'
-            )}
-            bodyText={tct('[transactions]  |  [errors]', {
-              transactions: tn(
-                '%s Transaction',
-                '%s Transactions',
-                meta?.transactions ?? traceInfo.transactions.size
-              ),
-              errors: tn('%s Issue', '%s Issues', errors + performanceIssues),
-            })}
-            subtext={tn(
-              'Across %s project',
-              'Across %s projects',
-              meta?.projects ?? traceInfo.projects.size
+            headingText={t('Issues')}
+            tooltipText=""
+            bodyText={errors + performanceIssues}
+            subtext={null}
+          />
+          <MetaData
+            headingText={t('Total Duration')}
+            tooltipText=""
+            bodyText={getDuration(
+              traceInfo.endTimestamp - traceInfo.startTimestamp,
+              2,
+              true
             )}
+            subtext={null}
           />
-        </GuideAnchor>
-        <MetaData
-          headingText={t('Total Duration')}
-          tooltipText={t('The time elapsed between the start and end of this trace.')}
-          bodyText={getDuration(
-            traceInfo.endTimestamp - traceInfo.startTimestamp,
-            2,
-            true
-          )}
-          subtext={getDynamicText({
-            value: <TimeSince date={(traceInfo.endTimestamp || 0) * 1000} />,
-            fixed: '5 days ago',
-          })}
-        />
-      </TraceDetailHeader>
+        </TraceHeaderRow>
+      </TraceHeaderContainer>
     );
   };
 
-  const renderTraceWarnings = () => {
+  const getTraceType = (): TraceType => {
     const {traces, orphanErrors} = props;
 
     const {roots, orphans} = (traces ?? []).reduce(
@@ -121,54 +155,131 @@ function NewTraceDetailsContent(props: Props) {
       {roots: 0, orphans: 0}
     );
 
+    if (roots === 0 && orphans > 0) {
+      return TraceType.NO_ROOT;
+    }
+
+    if (roots === 1 && orphans > 0) {
+      return TraceType.BROKEN_SUBTRACES;
+    }
+
+    if (roots > 1) {
+      return TraceType.MULTIPLE_ROOTS;
+    }
+
+    if (orphanErrors && orphanErrors.length > 1) {
+      return TraceType.ONLY_ERRORS;
+    }
+
+    if (roots === 1) {
+      return TraceType.ONE_ROOT;
+    }
+
+    if (roots === 0 && orphans === 0) {
+      return TraceType.EMPTY_TRACE;
+    }
+
+    throw new Error('Unknown trace type');
+  };
+
+  const renderTraceWarnings = () => {
     let warning: React.ReactNode = null;
+    const traceType = getTraceType();
 
-    if (roots === 0 && orphans > 0) {
-      warning = (
-        <Alert type="info" showIcon>
-          <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
-            {t(
-              'A root transaction is missing. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
+    switch (traceType) {
+      case TraceType.NO_ROOT:
+        warning = (
+          <Alert type="info" showIcon>
+            <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
+              {t(
+                'A root transaction is missing. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
+              )}
+            </ExternalLink>
+          </Alert>
+        );
+        break;
+      case TraceType.BROKEN_SUBTRACES:
+        warning = (
+          <Alert type="info" showIcon>
+            <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
+              {t(
+                'This trace has broken subtraces. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
+              )}
+            </ExternalLink>
+          </Alert>
+        );
+        break;
+      case TraceType.MULTIPLE_ROOTS:
+        warning = (
+          <Alert type="info" showIcon>
+            <ExternalLink href="https://docs.sentry.io/product/sentry-basics/tracing/trace-view/#multiple-roots">
+              {t('Multiple root transactions have been found with this trace ID.')}
+            </ExternalLink>
+          </Alert>
+        );
+        break;
+      case TraceType.ONLY_ERRORS:
+        warning = (
+          <Alert type="info" showIcon>
+            {tct(
+              "The good news is we know these errors are related to each other. The bad news is that we can't tell you more than that. If you haven't already, [tracingLink: configure performance monitoring for your SDKs] to learn more about service interactions.",
+              {
+                tracingLink: (
+                  <ExternalLink href="https://docs.sentry.io/product/performance/getting-started/" />
+                ),
+              }
             )}
-          </ExternalLink>
-        </Alert>
-      );
-    } else if (roots === 1 && orphans > 0) {
-      warning = (
-        <Alert type="info" showIcon>
-          <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
-            {t(
-              'This trace has broken subtraces. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
-            )}
-          </ExternalLink>
-        </Alert>
-      );
-    } else if (roots > 1) {
-      warning = (
-        <Alert type="info" showIcon>
-          <ExternalLink href="https://docs.sentry.io/product/sentry-basics/tracing/trace-view/#multiple-roots">
-            {t('Multiple root transactions have been found with this trace ID.')}
-          </ExternalLink>
-        </Alert>
-      );
-    } else if (orphanErrors && orphanErrors.length > 1) {
-      warning = (
-        <Alert type="info" showIcon>
-          {tct(
-            "The good news is we know these errors are related to each other. The bad news is that we can't tell you more than that. If you haven't already, [tracingLink: configure performance monitoring for your SDKs] to learn more about service interactions.",
-            {
-              tracingLink: (
-                <ExternalLink href="https://docs.sentry.io/product/performance/getting-started/" />
-              ),
-            }
-          )}
-        </Alert>
-      );
+          </Alert>
+        );
+        break;
+      default:
     }
 
     return warning;
   };
 
+  const renderFooter = () => {
+    const {traceEventView, organization, location, meta, traces, orphanErrors} = props;
+    const traceInfo = traces ? getTraceInfo(traces, orphanErrors) : undefined;
+    const orphanErrorsCount = orphanErrors?.length ?? 0;
+    const transactionsCount = meta?.transactions ?? traceInfo?.transactions.size ?? 0;
+    const totalNumOfEvents = transactionsCount + orphanErrorsCount;
+    const webVitals = Object.keys(rootEvent?.measurements ?? {})
+      .filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
+      .sort();
+
+    return (
+      rootEvent && (
+        <TraceHeaderWrapper>
+          {webVitals.length > 0 && (
+            <div style={{flex: 1}}>
+              <EventVitals event={rootEvent} />
+            </div>
+          )}
+          <div style={{flex: 1, maxWidth: '800px'}}>
+            <Tags
+              generateUrl={(key: string, value: string) => {
+                const url = traceEventView.getResultsViewUrlTarget(
+                  organization.slug,
+                  false
+                );
+                url.query = generateQueryWithTag(url.query, {
+                  key: formatTagKey(key),
+                  value,
+                });
+                return url;
+              }}
+              totalValues={totalNumOfEvents}
+              eventView={traceEventView}
+              organization={organization}
+              location={location}
+            />
+          </div>
+        </TraceHeaderWrapper>
+      )
+    );
+  };
+
   const renderContent = () => {
     const {
       dateSelected,
@@ -186,7 +297,7 @@ function NewTraceDetailsContent(props: Props) {
     if (!dateSelected) {
       return renderTraceRequiresDateRangeSelection();
     }
-    if (isLoading) {
+    if (isLoading || isRootEventLoading) {
       return renderTraceLoading();
     }
 
@@ -211,7 +322,9 @@ function NewTraceDetailsContent(props: Props) {
         {traceInfo && renderTraceHeader(traceInfo)}
         <Margin>
           <VisuallyCompleteWithData id="PerformanceDetails-TraceView" hasData={hasData}>
-            <TraceView
+            <NewTraceView
+              traceType={getTraceType()}
+              rootEvent={rootEvent}
               traceInfo={traceInfo}
               location={location}
               organization={organization}
@@ -220,10 +333,10 @@ function NewTraceDetailsContent(props: Props) {
               traces={traces || []}
               meta={meta}
               orphanErrors={orphanErrors || []}
-              handleLimitChange={props.handleLimitChange}
             />
           </VisuallyCompleteWithData>
         </Margin>
+        {renderFooter()}
       </Fragment>
     );
   };
@@ -276,8 +389,24 @@ const LoadingContainer = styled('div')`
   text-align: center;
 `;
 
+const TraceHeaderContainer = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const TraceHeaderRow = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(2)};
+`;
+
 const Margin = styled('div')`
   margin-top: ${space(2)};
 `;
 
+const TraceHeaderWrapper = styled('div')`
+  display: flex;
+  gap: ${space(2)};
+`;
 export default NewTraceDetailsContent;

+ 498 - 0
static/app/views/performance/traceDetails/newTraceDetailsTraceView.tsx

@@ -0,0 +1,498 @@
+import {createRef, Fragment, useEffect} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+
+import * as DividerHandlerManager from 'sentry/components/events/interfaces/spans/dividerHandlerManager';
+import MeasurementsPanel from 'sentry/components/events/interfaces/spans/measurementsPanel';
+import TraceViewHeader from 'sentry/components/events/interfaces/spans/newTraceDetailsHeader';
+import * as ScrollbarManager from 'sentry/components/events/interfaces/spans/scrollbarManager';
+import {
+  boundsGenerator,
+  getMeasurements,
+} from 'sentry/components/events/interfaces/spans/utils';
+import Panel from 'sentry/components/panels/panel';
+import {MessageRow} from 'sentry/components/performance/waterfall/messageRow';
+import {
+  DividerSpacer,
+  ScrollbarContainer,
+  VirtualScrollbar,
+  VirtualScrollbarGrip,
+} from 'sentry/components/performance/waterfall/miniHeader';
+import {pickBarColor} from 'sentry/components/performance/waterfall/utils';
+import {tct} from 'sentry/locale';
+import {EventTransaction, Organization} from 'sentry/types';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import EventView from 'sentry/utils/discover/eventView';
+import toPercent from 'sentry/utils/number/toPercent';
+import {
+  TraceError,
+  TraceFullDetailed,
+  TraceMeta,
+} from 'sentry/utils/performance/quickTrace/types';
+import {
+  TraceDetailBody,
+  TraceViewContainer,
+  TraceViewHeaderContainer,
+} from 'sentry/views/performance/traceDetails/styles';
+import TransactionGroup from 'sentry/views/performance/traceDetails/transactionGroup';
+import {TraceInfo, TreeDepth} from 'sentry/views/performance/traceDetails/types';
+import {
+  getTraceInfo,
+  hasTraceData,
+  isRootTransaction,
+} from 'sentry/views/performance/traceDetails/utils';
+
+import LimitExceededMessage from './limitExceededMessage';
+import {TraceType} from './newTraceDetailsContent';
+import TraceNotFound from './traceNotFound';
+
+type AccType = {
+  lastIndex: number;
+  numberOfHiddenTransactionsAbove: number;
+  renderedChildren: React.ReactNode[];
+};
+
+type Props = Pick<RouteComponentProps<{}, {}>, 'location'> & {
+  meta: TraceMeta | null;
+  organization: Organization;
+  rootEvent: EventTransaction | undefined;
+  traceEventView: EventView;
+  traceSlug: string;
+  traceType: TraceType;
+  traces: TraceFullDetailed[];
+  filteredEventIds?: Set<string>;
+  handleLimitChange?: (newLimit: number) => void;
+  orphanErrors?: TraceError[];
+  traceInfo?: TraceInfo;
+};
+
+function TraceHiddenMessage({
+  isVisible,
+  numberOfHiddenTransactionsAbove,
+  numberOfHiddenErrorsAbove,
+}: {
+  isVisible: boolean;
+  numberOfHiddenErrorsAbove: number;
+  numberOfHiddenTransactionsAbove: number;
+}) {
+  if (
+    !isVisible ||
+    (numberOfHiddenTransactionsAbove < 1 && numberOfHiddenErrorsAbove < 1)
+  ) {
+    return null;
+  }
+
+  const numOfTransaction = <strong>{numberOfHiddenTransactionsAbove}</strong>;
+  const numOfErrors = <strong>{numberOfHiddenErrorsAbove}</strong>;
+
+  const hiddenTransactionsMessage =
+    numberOfHiddenTransactionsAbove < 1
+      ? ''
+      : numberOfHiddenTransactionsAbove === 1
+      ? tct('[numOfTransaction] hidden transaction', {
+          numOfTransaction,
+        })
+      : tct('[numOfTransaction] hidden transactions', {
+          numOfTransaction,
+        });
+
+  const hiddenErrorsMessage =
+    numberOfHiddenErrorsAbove < 1
+      ? ''
+      : numberOfHiddenErrorsAbove === 1
+      ? tct('[numOfErrors] hidden error', {
+          numOfErrors,
+        })
+      : tct('[numOfErrors] hidden errors', {
+          numOfErrors,
+        });
+
+  return (
+    <MessageRow>
+      <span key="trace-info-message">
+        {hiddenTransactionsMessage}
+        {hiddenErrorsMessage && hiddenTransactionsMessage && ', '}
+        {hiddenErrorsMessage}
+      </span>
+    </MessageRow>
+  );
+}
+
+function isRowVisible(
+  row: TraceFullDetailed | TraceError,
+  filteredEventIds?: Set<string>
+): boolean {
+  return filteredEventIds ? filteredEventIds.has(row.event_id) : true;
+}
+
+function generateBounds(traceInfo: TraceInfo) {
+  return boundsGenerator({
+    traceStartTimestamp: traceInfo.startTimestamp,
+    traceEndTimestamp: traceInfo.endTimestamp,
+    viewStart: 0,
+    viewEnd: 1,
+  });
+}
+
+export default function NewTraceView({
+  location,
+  meta,
+  organization,
+  traces,
+  traceSlug,
+  traceEventView,
+  filteredEventIds,
+  orphanErrors,
+  traceType,
+  handleLimitChange,
+  ...props
+}: Props) {
+  const sentryTransaction = Sentry.getCurrentHub().getScope()?.getTransaction();
+  const sentrySpan = sentryTransaction?.startChild({
+    op: 'trace.render',
+    description: 'trace-view-content',
+  });
+  const hasOrphanErrors = orphanErrors && orphanErrors.length > 0;
+  const onlyOrphanErrors = hasOrphanErrors && (!traces || traces.length === 0);
+
+  useEffect(() => {
+    trackAnalytics('performance_views.trace_view.view', {
+      organization,
+    });
+  }, [organization]);
+
+  function renderTransaction(
+    transaction: TraceFullDetailed,
+    {
+      continuingDepths,
+      isOrphan,
+      isLast,
+      index,
+      numberOfHiddenTransactionsAbove,
+      traceInfo,
+      hasGuideAnchor,
+    }: {
+      continuingDepths: TreeDepth[];
+      hasGuideAnchor: boolean;
+      index: number;
+      isLast: boolean;
+      isOrphan: boolean;
+      numberOfHiddenTransactionsAbove: number;
+      traceInfo: TraceInfo;
+    }
+  ) {
+    const {children, event_id: eventId} = transaction;
+    // Add 1 to the generation to make room for the "root trace"
+    const generation = transaction.generation + 1;
+
+    const isVisible = isRowVisible(transaction, filteredEventIds);
+
+    const accumulated: AccType = children.reduce(
+      (acc: AccType, child: TraceFullDetailed, idx: number) => {
+        const isLastChild = idx === children.length - 1;
+        const hasChildren = child.children.length > 0;
+
+        const result = renderTransaction(child, {
+          continuingDepths:
+            !isLastChild && hasChildren
+              ? [...continuingDepths, {depth: generation, isOrphanDepth: isOrphan}]
+              : continuingDepths,
+          isOrphan,
+          isLast: isLastChild,
+          index: acc.lastIndex + 1,
+          numberOfHiddenTransactionsAbove: acc.numberOfHiddenTransactionsAbove,
+          traceInfo,
+          hasGuideAnchor: false,
+        });
+
+        acc.lastIndex = result.lastIndex;
+        acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove;
+        acc.renderedChildren.push(result.transactionGroup);
+
+        return acc;
+      },
+      {
+        renderedChildren: [],
+        lastIndex: index,
+        numberOfHiddenTransactionsAbove: isVisible
+          ? 0
+          : numberOfHiddenTransactionsAbove + 1,
+      }
+    );
+
+    return {
+      transactionGroup: (
+        <Fragment key={eventId}>
+          <TraceHiddenMessage
+            isVisible={isVisible}
+            numberOfHiddenTransactionsAbove={numberOfHiddenTransactionsAbove}
+            numberOfHiddenErrorsAbove={0}
+          />
+          <TransactionGroup
+            location={location}
+            traceViewRef={traceViewRef}
+            organization={organization}
+            traceInfo={traceInfo}
+            transaction={{
+              ...transaction,
+              generation,
+            }}
+            measurements={
+              traces && traces.length > 0
+                ? getMeasurements(traces[0], generateBounds(traceInfo))
+                : undefined
+            }
+            generateBounds={generateBounds(traceInfo)}
+            continuingDepths={continuingDepths}
+            isOrphan={isOrphan}
+            isLast={isLast}
+            index={index}
+            isVisible={isVisible}
+            hasGuideAnchor={hasGuideAnchor}
+            renderedChildren={accumulated.renderedChildren}
+            barColor={pickBarColor(transaction['transaction.op'])}
+          />
+        </Fragment>
+      ),
+      lastIndex: accumulated.lastIndex,
+      numberOfHiddenTransactionsAbove: accumulated.numberOfHiddenTransactionsAbove,
+    };
+  }
+
+  const traceViewRef = createRef<HTMLDivElement>();
+  const virtualScrollbarContainerRef = createRef<HTMLDivElement>();
+
+  if (!hasTraceData(traces, orphanErrors)) {
+    return (
+      <TraceNotFound
+        meta={meta}
+        traceEventView={traceEventView}
+        traceSlug={traceSlug}
+        location={location}
+        organization={organization}
+      />
+    );
+  }
+
+  const traceInfo = props.traceInfo || getTraceInfo(traces);
+
+  const accumulator: {
+    index: number;
+    numberOfHiddenTransactionsAbove: number;
+    traceInfo: TraceInfo;
+    transactionGroups: React.ReactNode[];
+  } = {
+    index: 1,
+    numberOfHiddenTransactionsAbove: 0,
+    traceInfo,
+    transactionGroups: [],
+  };
+
+  let lastIndex: number = 0;
+  const {transactionGroups, numberOfHiddenTransactionsAbove} = traces.reduce(
+    (acc, trace, index) => {
+      const isLastTransaction = index === traces.length - 1;
+      const hasChildren = trace.children.length > 0;
+      const isNextChildOrphaned =
+        !isLastTransaction && traces[index + 1].parent_span_id !== null;
+
+      const result = renderTransaction(trace, {
+        ...acc,
+        // if the root of a subtrace has a parent_span_id, then it must be an orphan
+        isOrphan: !isRootTransaction(trace),
+        isLast: isLastTransaction && !hasOrphanErrors,
+        continuingDepths:
+          (!isLastTransaction && hasChildren) || hasOrphanErrors
+            ? [{depth: 0, isOrphanDepth: isNextChildOrphaned || Boolean(hasOrphanErrors)}]
+            : [],
+        hasGuideAnchor: index === 0,
+      });
+
+      acc.index = result.lastIndex + 1;
+      lastIndex = Math.max(lastIndex, result.lastIndex);
+      acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove;
+      acc.transactionGroups.push(result.transactionGroup);
+      return acc;
+    },
+    accumulator
+  );
+
+  // Build transaction groups for orphan errors
+  let numOfHiddenErrorsAbove = 0;
+  let totalNumOfHiddenErrors = 0;
+  if (hasOrphanErrors) {
+    orphanErrors.forEach((error, index) => {
+      const isLastError = index === orphanErrors.length - 1;
+      const isVisible = isRowVisible(error, filteredEventIds);
+      const currentHiddenCount = numOfHiddenErrorsAbove;
+
+      if (!isVisible) {
+        numOfHiddenErrorsAbove += 1;
+        totalNumOfHiddenErrors += 1;
+      } else {
+        numOfHiddenErrorsAbove = 0;
+      }
+
+      transactionGroups.push(
+        <Fragment key={error.event_id}>
+          <TraceHiddenMessage
+            isVisible={isVisible}
+            numberOfHiddenTransactionsAbove={
+              index === 0 ? numberOfHiddenTransactionsAbove : 0
+            }
+            numberOfHiddenErrorsAbove={index > 0 ? currentHiddenCount : 0}
+          />
+          <TransactionGroup
+            location={location}
+            organization={organization}
+            traceViewRef={traceViewRef}
+            traceInfo={traceInfo}
+            transaction={{
+              ...error,
+              generation: 1,
+            }}
+            generateBounds={generateBounds(traceInfo)}
+            measurements={
+              traces && traces.length > 0
+                ? getMeasurements(traces[0], generateBounds(traceInfo))
+                : undefined
+            }
+            continuingDepths={[]}
+            isOrphan
+            isLast={isLastError}
+            index={lastIndex + index + 1}
+            isVisible={isVisible}
+            hasGuideAnchor={index === 0 && transactionGroups.length === 0}
+            renderedChildren={[]}
+          />
+        </Fragment>
+      );
+    });
+  }
+
+  const bounds = generateBounds(traceInfo);
+  const measurements =
+    traces.length > 0 && Object.keys(traces[0].measurements ?? {}).length > 0
+      ? getMeasurements(traces[0], bounds)
+      : undefined;
+
+  const traceView = (
+    <TraceDetailBody>
+      <DividerHandlerManager.Provider interactiveLayerRef={traceViewRef}>
+        <DividerHandlerManager.Consumer>
+          {({dividerPosition}) => (
+            <ScrollbarManager.Provider
+              dividerPosition={dividerPosition}
+              interactiveLayerRef={virtualScrollbarContainerRef}
+              isEmbedded
+            >
+              <StyledTracePanel>
+                <TraceViewHeader
+                  traceInfo={traceInfo}
+                  traceType={traceType}
+                  traceViewHeaderRef={traceViewRef}
+                  organization={organization}
+                  event={props.rootEvent}
+                />
+                <TraceViewHeaderContainer>
+                  <ScrollbarManager.Consumer>
+                    {({virtualScrollbarRef, scrollBarAreaRef, onDragStart, onScroll}) => {
+                      return (
+                        <ScrollbarContainer
+                          ref={virtualScrollbarContainerRef}
+                          style={{
+                            // the width of this component is shrunk to compensate for half of the width of the divider line
+                            width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
+                          }}
+                          onScroll={onScroll}
+                        >
+                          <div
+                            style={{
+                              width: 0,
+                              height: '1px',
+                            }}
+                            ref={scrollBarAreaRef}
+                          />
+                          <VirtualScrollbar
+                            data-type="virtual-scrollbar"
+                            ref={virtualScrollbarRef}
+                            onMouseDown={onDragStart}
+                          >
+                            <VirtualScrollbarGrip />
+                          </VirtualScrollbar>
+                        </ScrollbarContainer>
+                      );
+                    }}
+                  </ScrollbarManager.Consumer>
+                  <DividerSpacer />
+                  {measurements ? (
+                    <MeasurementsPanel
+                      measurements={measurements}
+                      generateBounds={bounds}
+                      dividerPosition={dividerPosition}
+                    />
+                  ) : null}
+                </TraceViewHeaderContainer>
+                <TraceViewContainer ref={traceViewRef}>
+                  <TransactionGroup
+                    location={location}
+                    organization={organization}
+                    traceInfo={traceInfo}
+                    transaction={{
+                      traceSlug,
+                      generation: 0,
+                      'transaction.duration':
+                        traceInfo.endTimestamp - traceInfo.startTimestamp,
+                      children: traces,
+                      start_timestamp: traceInfo.startTimestamp,
+                      timestamp: traceInfo.endTimestamp,
+                    }}
+                    measurements={measurements}
+                    generateBounds={bounds}
+                    continuingDepths={[]}
+                    isOrphan={false}
+                    isLast={false}
+                    index={0}
+                    isVisible
+                    hasGuideAnchor={false}
+                    renderedChildren={transactionGroups}
+                    barColor={pickBarColor('')}
+                    onlyOrphanErrors={onlyOrphanErrors}
+                    traceViewRef={traceViewRef}
+                    numOfOrphanErrors={orphanErrors?.length}
+                  />
+                  <TraceHiddenMessage
+                    isVisible
+                    numberOfHiddenTransactionsAbove={numberOfHiddenTransactionsAbove}
+                    numberOfHiddenErrorsAbove={totalNumOfHiddenErrors}
+                  />
+                  <LimitExceededMessage
+                    traceInfo={traceInfo}
+                    organization={organization}
+                    traceEventView={traceEventView}
+                    meta={meta}
+                    handleLimitChange={handleLimitChange}
+                  />
+                </TraceViewContainer>
+              </StyledTracePanel>
+            </ScrollbarManager.Provider>
+          )}
+        </DividerHandlerManager.Consumer>
+      </DividerHandlerManager.Provider>
+    </TraceDetailBody>
+  );
+
+  sentrySpan?.finish();
+
+  return traceView;
+}
+
+export const StyledTracePanel = styled(Panel)`
+  height: 100%;
+  overflow-x: visible;
+
+  ${TraceViewContainer} {
+    overflow-x: visible;
+  }
+`;

+ 3 - 1
static/app/views/performance/traceDetails/newTraceDetailsTransactionBar.tsx

@@ -772,7 +772,9 @@ function NewTraceDetailsTransactionBar(props: Props) {
         </RowCell>
         <DividerContainer>
           {renderDivider(dividerHandlerChildrenProps)}
-          {!isTraceRoot(transaction) && renderEmbeddedTransactionsBadge()}
+          {!isTraceRoot(transaction) &&
+            !isTraceError(transaction) &&
+            renderEmbeddedTransactionsBadge()}
         </DividerContainer>
         <RowCell
           data-test-id="transaction-row-duration"

+ 10 - 2
static/app/views/performance/transactionDetails/eventMetas.tsx

@@ -230,7 +230,13 @@ const IconContainer = styled('div')`
   margin-top: ${space(0.25)};
 `;
 
-function BrowserDisplay({event}: {event: Event}) {
+export function BrowserDisplay({
+  event,
+  showVersion = false,
+}: {
+  event: Event;
+  showVersion?: boolean;
+}) {
   const icon = generateIconName(
     event.contexts.browser?.name,
     event.contexts.browser?.version
@@ -240,7 +246,9 @@ function BrowserDisplay({event}: {event: Event}) {
       <IconContainer>
         <ContextIcon name={icon} />
       </IconContainer>
-      <span>{event.contexts.browser?.name}</span>
+      <span>
+        {event.contexts.browser?.name} {showVersion && event.contexts.browser?.version}
+      </span>
     </BrowserCenter>
   );
 }

+ 9 - 7
static/app/views/performance/transactionDetails/styles.tsx

@@ -9,8 +9,8 @@ type MetaDataProps = {
   bodyText: React.ReactNode;
   headingText: string;
   subtext: React.ReactNode;
-  tooltipText: string;
   badge?: 'alpha' | 'beta' | 'new';
+  tooltipText?: string;
 };
 
 export function MetaData({
@@ -24,12 +24,14 @@ export function MetaData({
     <HeaderInfo>
       <StyledSectionHeading>
         {headingText}
-        <QuestionTooltip
-          position="top"
-          size="xs"
-          containerDisplayMode="block"
-          title={tooltipText}
-        />
+        {tooltipText && (
+          <QuestionTooltip
+            position="top"
+            size="xs"
+            containerDisplayMode="block"
+            title={tooltipText}
+          />
+        )}
         {badge && <StyledFeatureBadge type={badge} />}
       </StyledSectionHeading>
       <SectionBody>{bodyText}</SectionBody>