Browse Source

feat(new-trace): Adding detail panel functionality. (#66015)

Adds trace detail panel with new trace tree implementation: 

![Screenshot 2024-02-28 at 7 49 48
PM](https://github.com/getsentry/sentry/assets/60121741/6d7a19a4-f3d9-46e5-b661-0536621d611f)

---------

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

+ 3 - 3
static/app/components/events/interfaces/spans/newTraceDetailsSpanBar.tsx

@@ -127,7 +127,7 @@ export class NewTraceDetailsSpanBar extends Component<
 
     // If span is anchored scroll to span bar and open it's detail panel
     if (this.isHighlighted && this.props.onRowClick) {
-      this.props.onRowClick(this.getSpanDetailsProps());
+      this.props.onRowClick(undefined);
 
       // Needs a little delay after bar is rendered, to achieve
       // scrollto bar functionality for spans that exist much further down the
@@ -180,7 +180,7 @@ export class NewTraceDetailsSpanBar extends Component<
         relatedErrors &&
         relatedErrors.length > 0
       ) {
-        this.props.onRowClick(this.getSpanDetailsProps());
+        this.props.onRowClick(undefined);
       }
     }
   }
@@ -840,7 +840,7 @@ export class NewTraceDetailsSpanBar extends Component<
         });
         spanDetailProps.openPanel = 'open';
       }
-      this.props.onRowClick(spanDetailProps);
+      this.props.onRowClick(undefined);
     }
   }
   renderHeader({

+ 8 - 15
static/app/components/events/interfaces/spans/newTraceDetailsSpanDetails.tsx

@@ -35,8 +35,8 @@ import EventView from 'sentry/utils/discover/eventView';
 import {generateEventSlug} from 'sentry/utils/discover/urls';
 import getDynamicText from 'sentry/utils/getDynamicText';
 import type {
-  QuickTraceEvent,
   TraceErrorOrIssue,
+  TraceFullDetailed,
 } from 'sentry/utils/performance/quickTrace/types';
 import {useLocation} from 'sentry/utils/useLocation';
 import useProjects from 'sentry/utils/useProjects';
@@ -51,7 +51,7 @@ import * as SpanEntryContext from './context';
 import {GapSpanDetails} from './gapSpanDetails';
 import InlineDocs from './inlineDocs';
 import {SpanProfileDetails} from './spanProfileDetails';
-import type {ParsedTraceType, ProcessedSpanType, RawSpanType} from './types';
+import type {ParsedTraceType, RawSpanType} from './types';
 import {rawSpanKeys} from './types';
 import type {SubTimingInfo} from './utils';
 import {
@@ -86,15 +86,12 @@ type TransactionResult = {
 };
 
 export type SpanDetailProps = {
-  childTransactions: QuickTraceEvent[] | null;
+  childTransactions: TraceFullDetailed[] | null;
   event: Readonly<EventTransaction>;
-  isRoot: boolean;
   openPanel: string | undefined;
   organization: Organization;
   relatedErrors: TraceErrorOrIssue[] | null;
-  resetCellMeasureCache: () => void;
-  scrollToHash: (hash: string) => void;
-  span: ProcessedSpanType;
+  span: RawSpanType;
   trace: Readonly<ParsedTraceType>;
 };
 
@@ -375,23 +372,19 @@ function NewTraceDetailsSpanDetail(props: SpanDetailProps) {
   }
 
   function renderSpanDetails() {
-    const {span, event, organization, resetCellMeasureCache, scrollToHash} = props;
+    const {span, event, organization} = props;
 
     if (isGapSpan(span)) {
       return (
         <SpanDetails>
           {organization.features.includes('profiling') ? (
-            <GapSpanDetails
-              event={event}
-              span={span}
-              resetCellMeasureCache={resetCellMeasureCache}
-            />
+            <GapSpanDetails event={event} span={span} resetCellMeasureCache={() => {}} />
           ) : (
             <InlineDocs
               orgSlug={organization.slug}
               platform={event.sdk?.name || ''}
               projectSlug={event?.projectSlug ?? ''}
-              resetCellMeasureCache={resetCellMeasureCache}
+              resetCellMeasureCache={() => {}}
             />
           )}
         </SpanDetails>
@@ -434,7 +427,7 @@ function NewTraceDetailsSpanDetail(props: SpanDetailProps) {
                     <SpanIdTitle
                       onClick={scrollToSpan(
                         span.span_id,
-                        scrollToHash,
+                        () => {},
                         location,
                         organization
                       )}

+ 25 - 0
static/app/views/performance/newTraceDetails/HighlightedRowContext.tsx

@@ -0,0 +1,25 @@
+import React, {type Dispatch, type SetStateAction, useState} from 'react';
+
+import type {TraceTree, TraceTreeNode} from './traceTree';
+
+type State = {
+  node: TraceTreeNode<TraceTree.NodeValue> | undefined;
+  setNode: Dispatch<SetStateAction<TraceTreeNode<TraceTree.NodeValue> | undefined>>;
+};
+
+export const HighLightedRowContext = React.createContext<State>({
+  node: undefined,
+  setNode: () => {},
+});
+
+export function HighLightedRowContextProvider({children}) {
+  const [node, setNode] = useState<TraceTreeNode<TraceTree.NodeValue> | undefined>(
+    undefined
+  );
+
+  return (
+    <HighLightedRowContext.Provider value={{node, setNode}}>
+      {children}
+    </HighLightedRowContext.Provider>
+  );
+}

+ 20 - 15
static/app/views/performance/newTraceDetails/index.tsx

@@ -29,6 +29,8 @@ import useProjects from 'sentry/utils/useProjects';
 
 import Breadcrumb from '../breadcrumb';
 
+import {HighLightedRowContextProvider} from './HighlightedRowContext';
+import TraceDetailPanel from './newTraceDetailPanel';
 import Trace from './trace';
 import {TraceFooter} from './traceFooter';
 import TraceHeader from './traceHeader';
@@ -201,21 +203,24 @@ function TraceViewContent(props: TraceViewContentProps) {
       </Layout.Header>
       <Layout.Body>
         <Layout.Main fullWidth>
-          {traceType ? <TraceWarnings type={traceType} /> : null}
-          <TraceHeader
-            rootEventResults={rootEventResults}
-            metaResults={props.metaResults}
-            organization={props.organization}
-            traces={props.traceSplitResult}
-          />
-          <Trace trace={tree} trace_id={props.traceSlug} />
-          <TraceFooter
-            rootEventResults={rootEventResults}
-            organization={props.organization}
-            location={props.location}
-            traces={props.traceSplitResult}
-            traceEventView={props.traceEventView}
-          />
+          <HighLightedRowContextProvider>
+            {traceType ? <TraceWarnings type={traceType} /> : null}
+            <TraceHeader
+              rootEventResults={rootEventResults}
+              metaResults={props.metaResults}
+              organization={props.organization}
+              traces={props.traceSplitResult}
+            />
+            <Trace trace={tree} trace_id={props.traceSlug} />
+            <TraceFooter
+              rootEventResults={rootEventResults}
+              organization={props.organization}
+              location={props.location}
+              traces={props.traceSplitResult}
+              traceEventView={props.traceEventView}
+            />
+            <TraceDetailPanel />
+          </HighLightedRowContextProvider>
         </Layout.Main>
       </Layout.Body>
     </Fragment>

+ 632 - 0
static/app/views/performance/newTraceDetails/newTraceDetailPanel.tsx

@@ -0,0 +1,632 @@
+import {createRef, Fragment, useContext, useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+import omit from 'lodash/omit';
+
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
+import DateTime from 'sentry/components/dateTime';
+import {Chunk} from 'sentry/components/events/contexts/chunk';
+import {EventAttachments} from 'sentry/components/events/eventAttachments';
+import {
+  isNotMarkMeasurement,
+  isNotPerformanceScoreMeasurement,
+  TraceEventCustomPerformanceMetric,
+} from 'sentry/components/events/eventCustomPerformanceMetrics';
+import {Entries} from 'sentry/components/events/eventEntries';
+import {EventEvidence} from 'sentry/components/events/eventEvidence';
+import {EventExtraData} from 'sentry/components/events/eventExtraData';
+import {EventSdk} from 'sentry/components/events/eventSdk';
+import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
+import {Breadcrumbs} from 'sentry/components/events/interfaces/breadcrumbs';
+import NewTraceDetailsSpanDetail, {
+  SpanDetailContainer,
+  SpanDetails,
+} from 'sentry/components/events/interfaces/spans/newTraceDetailsSpanDetails';
+import {
+  getFormattedTimeRangeWithLeadingAndTrailingZero,
+  getSpanOperation,
+  parseTrace,
+} from 'sentry/components/events/interfaces/spans/utils';
+import {generateStats} from 'sentry/components/events/opsBreakdown';
+import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration';
+import {DataSection} from 'sentry/components/events/styles';
+import FileSize from 'sentry/components/fileSize';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {
+  ErrorDot,
+  ErrorLevel,
+  ErrorMessageContent,
+  ErrorMessageTitle,
+  ErrorTitle,
+} from 'sentry/components/performance/waterfall/rowDetails';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import {generateIssueEventTarget} from 'sentry/components/quickTrace/utils';
+import {Tooltip} from 'sentry/components/tooltip';
+import {PAGE_URL_PARAM} from 'sentry/constants/pageFilters';
+import {IconChevron, IconOpen} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {EntryBreadcrumbs, EventTransaction, Organization} from 'sentry/types';
+import {EntryType} from 'sentry/types';
+import {objectIsEmpty} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
+import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
+import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+import {isCustomMeasurement} from 'sentry/views/dashboards/utils';
+import {CustomMetricsEventData} from 'sentry/views/ddm/customMetricsEventData';
+import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
+import {ProfileContext, ProfilesProvider} from 'sentry/views/profiling/profilesProvider';
+import DetailPanel from 'sentry/views/starfish/components/detailPanel';
+
+import {Row, Tags} from '../traceDetails/styles';
+import {transactionSummaryRouteWithQuery} from '../transactionSummary/utils';
+
+import {isSpanNode, isTransactionNode} from './guards';
+import {HighLightedRowContext} from './HighlightedRowContext';
+import type {TraceTree, TraceTreeNode} from './traceTree';
+
+type EventDetailProps = {
+  location: Location;
+  node: TraceTreeNode<TraceTree.Transaction>;
+  organization: Organization;
+};
+
+function OpsBreakdown({event}: {event: EventTransaction}) {
+  const [showingAll, setShowingAll] = useState(false);
+  const breakdown = event && generateStats(event, {type: 'no_filter'});
+
+  if (!breakdown) {
+    return null;
+  }
+
+  const renderText = showingAll ? t('Show less') : t('Show more') + '...';
+  return (
+    breakdown && (
+      <Row
+        title={
+          <FlexBox style={{gap: '5px'}}>
+            {t('Ops Breakdown')}
+            <QuestionTooltip
+              title={t('Applicable to the children of this event only')}
+              size="xs"
+            />
+          </FlexBox>
+        }
+      >
+        <div style={{display: 'flex', flexDirection: 'column', gap: space(0.25)}}>
+          {breakdown.slice(0, showingAll ? breakdown.length : 5).map(currOp => {
+            const {name, percentage, totalInterval} = currOp;
+
+            const operationName = typeof name === 'string' ? name : t('Other');
+            const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞';
+
+            return (
+              <div key={operationName}>
+                {operationName}:{' '}
+                <PerformanceDuration seconds={totalInterval} abbreviation /> ({pctLabel}%)
+              </div>
+            );
+          })}
+          {breakdown.length > 5 && (
+            <a onClick={() => setShowingAll(prev => !prev)}>{renderText}</a>
+          )}
+        </div>
+      </Row>
+    )
+  );
+}
+
+function BreadCrumbsSection({
+  event,
+  organization,
+}: {
+  event: EventTransaction;
+  organization: Organization;
+}) {
+  const [showBreadCrumbs, setShowBreadCrumbs] = useState(false);
+  const breadCrumbsContainerRef = createRef<HTMLDivElement>();
+
+  useEffect(() => {
+    setTimeout(() => {
+      if (showBreadCrumbs) {
+        breadCrumbsContainerRef.current?.scrollIntoView({
+          behavior: 'smooth',
+          block: 'end',
+        });
+      }
+    }, 100);
+  }, [showBreadCrumbs, breadCrumbsContainerRef]);
+
+  const matchingEntry: EntryBreadcrumbs | undefined = event?.entries.find(
+    (entry): entry is EntryBreadcrumbs => entry.type === EntryType.BREADCRUMBS
+  );
+
+  if (!matchingEntry) {
+    return null;
+  }
+
+  const renderText = showBreadCrumbs ? t('Hide Breadcrumbs') : t('Show Breadcrumbs');
+  const chevron = <IconChevron size="xs" direction={showBreadCrumbs ? 'up' : 'down'} />;
+  return (
+    <Fragment>
+      <a
+        style={{display: 'flex', alignItems: 'center', gap: space(0.5)}}
+        onClick={() => {
+          setShowBreadCrumbs(prev => !prev);
+        }}
+      >
+        {renderText} {chevron}
+      </a>
+      <div ref={breadCrumbsContainerRef}>
+        {showBreadCrumbs && (
+          <Breadcrumbs
+            hideTitle
+            data={matchingEntry.data}
+            event={event}
+            organization={organization}
+          />
+        )}
+      </div>
+    </Fragment>
+  );
+}
+function EventDetails({node, organization, location}: EventDetailProps) {
+  const {projects} = useProjects();
+  const {data: event} = useApiQuery<EventTransaction>(
+    [
+      `/organizations/${organization.slug}/events/${node.value.project_slug}:${node.value.event_id}/`,
+      {
+        query: {
+          referrer: 'trace-details-summary',
+        },
+      },
+    ],
+    {
+      staleTime: 0,
+      enabled: !!node,
+    }
+  );
+
+  if (!event) {
+    return <LoadingIndicator />;
+  }
+
+  const {user, contexts, projectSlug} = event;
+  const {feedback} = contexts ?? {};
+  const eventJsonUrl = `/api/0/projects/${organization.slug}/${node.value.project_slug}/events/${node.value.event_id}/json/`;
+  const project = projects.find(proj => proj.slug === event?.projectSlug);
+  const {errors, performance_issues} = node.value;
+  const hasIssues = errors.length + performance_issues.length > 0;
+  const startTimestamp = Math.min(node.value.start_timestamp, node.value.timestamp);
+  const endTimestamp = Math.max(node.value.start_timestamp, node.value.timestamp);
+  const {start: startTimeWithLeadingZero, end: endTimeWithLeadingZero} =
+    getFormattedTimeRangeWithLeadingAndTrailingZero(startTimestamp, endTimestamp);
+  const duration = (endTimestamp - startTimestamp) * 1000;
+  const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
+  const measurementNames = Object.keys(node.value.measurements ?? {})
+    .filter(name => isCustomMeasurement(`measurements.${name}`))
+    .filter(isNotMarkMeasurement)
+    .filter(isNotPerformanceScoreMeasurement)
+    .sort();
+
+  const renderMeasurements = () => {
+    if (!event) {
+      return null;
+    }
+
+    const {measurements} = event;
+
+    const measurementKeys = Object.keys(measurements ?? {})
+      .filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
+      .sort();
+
+    if (!measurements || measurementKeys.length <= 0) {
+      return null;
+    }
+
+    return (
+      <Fragment>
+        {measurementKeys.map(measurement => (
+          <Row
+            key={measurement}
+            title={WEB_VITAL_DETAILS[`measurements.${measurement}`]?.name}
+          >
+            <PerformanceDuration
+              milliseconds={Number(measurements[measurement].value.toFixed(3))}
+              abbreviation
+            />
+          </Row>
+        ))}
+      </Fragment>
+    );
+  };
+
+  const renderGoToProfileButton = () => {
+    if (!node.value.profile_id) {
+      return null;
+    }
+
+    const target = generateProfileFlamechartRoute({
+      orgSlug: organization.slug,
+      projectSlug: node.value.project_slug,
+      profileId: node.value.profile_id,
+    });
+
+    function handleOnClick() {
+      trackAnalytics('profiling_views.go_to_flamegraph', {
+        organization,
+        source: 'performance.trace_view',
+      });
+    }
+
+    return (
+      <StyledButton size="xs" to={target} onClick={handleOnClick}>
+        {t('View Profile')}
+      </StyledButton>
+    );
+  };
+
+  return (
+    <Wrapper>
+      <Actions>
+        <Button
+          size="sm"
+          icon={<IconOpen />}
+          href={eventJsonUrl}
+          external
+          onClick={() =>
+            trackAnalytics('performance_views.event_details.json_button_click', {
+              organization,
+            })
+          }
+        >
+          {t('JSON')} (<FileSize bytes={event?.size} />)
+        </Button>
+      </Actions>
+
+      <Title>
+        <Tooltip title={node.value.project_slug}>
+          <ProjectBadge
+            project={project ? project : {slug: node.value.project_slug}}
+            avatarSize={50}
+            hideName
+          />
+        </Tooltip>
+        <div>
+          <div>{t('Event')}</div>
+          <TransactionOp> {node.value['transaction.op']}</TransactionOp>
+        </div>
+      </Title>
+
+      {hasIssues && (
+        <Alert
+          system
+          defaultExpanded
+          type="error"
+          expand={[...node.value.errors, ...node.value.performance_issues].map(error => (
+            <ErrorMessageContent key={error.event_id}>
+              <ErrorDot level={error.level} />
+              <ErrorLevel>{error.level}</ErrorLevel>
+              <ErrorTitle>
+                <Link to={generateIssueEventTarget(error, organization)}>
+                  {error.title}
+                </Link>
+              </ErrorTitle>
+            </ErrorMessageContent>
+          ))}
+        >
+          <ErrorMessageTitle>
+            {tn(
+              '%s issue occurred in this transaction.',
+              '%s issues occurred in this transaction.',
+              node.value.errors.length + node.value.performance_issues.length
+            )}
+          </ErrorMessageTitle>
+        </Alert>
+      )}
+
+      <StyledTable className="table key-value">
+        <tbody>
+          <Row title={<TransactionIdTitle>{t('Event ID')}</TransactionIdTitle>}>
+            {node.value.event_id}
+            <CopyToClipboardButton
+              borderless
+              size="zero"
+              iconSize="xs"
+              text={`${window.location.href.replace(window.location.hash, '')}#txn-${
+                node.value.event_id
+              }`}
+            />
+          </Row>
+          <Row title={t('Description')}>
+            <Link
+              to={transactionSummaryRouteWithQuery({
+                orgSlug: organization.slug,
+                transaction: node.value.transaction,
+                query: omit(location.query, Object.values(PAGE_URL_PARAM)),
+                projectID: String(node.value.project_id),
+              })}
+            >
+              {node.value.transaction}
+            </Link>
+          </Row>
+          {node.value.profile_id && (
+            <Row title="Profile ID" extra={renderGoToProfileButton()}>
+              {node.value.profile_id}
+            </Row>
+          )}
+          <Row title="Duration">{durationString}</Row>
+          <Row title="Date Range">
+            {getDynamicText({
+              fixed: 'Mar 19, 2021 11:06:27 AM UTC',
+              value: (
+                <Fragment>
+                  <DateTime date={startTimestamp * 1000} />
+                  {` (${startTimeWithLeadingZero})`}
+                </Fragment>
+              ),
+            })}
+            <br />
+            {getDynamicText({
+              fixed: 'Mar 19, 2021 11:06:28 AM UTC',
+              value: (
+                <Fragment>
+                  <DateTime date={endTimestamp * 1000} />
+                  {` (${endTimeWithLeadingZero})`}
+                </Fragment>
+              ),
+            })}
+          </Row>
+
+          <OpsBreakdown event={event} />
+
+          {renderMeasurements()}
+
+          <Tags
+            enableHiding
+            location={location}
+            organization={organization}
+            transaction={node.value}
+          />
+
+          {measurementNames.length > 0 && (
+            <tr>
+              <td className="key">{t('Measurements')}</td>
+              <td className="value">
+                <Measurements>
+                  {measurementNames.map(name => {
+                    return (
+                      event && (
+                        <TraceEventCustomPerformanceMetric
+                          key={name}
+                          event={event}
+                          name={name}
+                          location={location}
+                          organization={organization}
+                          source={undefined}
+                          isHomepage={false}
+                        />
+                      )
+                    );
+                  })}
+                </Measurements>
+              </td>
+            </tr>
+          )}
+        </tbody>
+      </StyledTable>
+      {project && <EventEvidence event={event} project={project} />}
+      {projectSlug && (
+        <Entries
+          definedEvent={event}
+          projectSlug={projectSlug}
+          group={undefined}
+          organization={organization}
+          isShare={false}
+          hideBeforeReplayEntries
+          hideBreadCrumbs
+        />
+      )}
+      {!objectIsEmpty(feedback) && (
+        <Chunk
+          key="feedback"
+          type="feedback"
+          alias="feedback"
+          group={undefined}
+          event={event}
+          value={feedback}
+        />
+      )}
+      {user && !objectIsEmpty(user) && (
+        <Chunk
+          key="user"
+          type="user"
+          alias="user"
+          group={undefined}
+          event={event}
+          value={user}
+        />
+      )}
+      <EventExtraData event={event} />
+      <EventSdk sdk={event.sdk} meta={event._meta?.sdk} />
+      {event._metrics_summary ? (
+        <CustomMetricsEventData
+          metricsSummary={event._metrics_summary}
+          startTimestamp={event.startTimestamp}
+        />
+      ) : null}
+      <BreadCrumbsSection event={event} organization={organization} />
+      {projectSlug && <EventAttachments event={event} projectSlug={projectSlug} />}
+      {project && <EventViewHierarchy event={event} project={project} />}
+      {projectSlug && (
+        <EventRRWebIntegration
+          event={event}
+          orgId={organization.slug}
+          projectSlug={projectSlug}
+        />
+      )}
+    </Wrapper>
+  );
+}
+
+function SpanDetailsBody({
+  node,
+  organization,
+}: {
+  node: TraceTreeNode<TraceTree.Span>;
+  organization: Organization;
+}) {
+  const {projects} = useProjects();
+  const {event, relatedErrors, childTxn, ...span} = node.value;
+  const project = projects.find(proj => proj.slug === event?.projectSlug);
+  const profileId = event?.contexts?.profile?.profile_id ?? null;
+
+  return (
+    <Wrapper>
+      <Title>
+        <Tooltip title={event.projectSlug}>
+          <ProjectBadge
+            project={project ? project : {slug: event.projectSlug || ''}}
+            avatarSize={50}
+            hideName
+          />
+        </Tooltip>
+        <div>
+          <div>{t('Span')}</div>
+          <TransactionOp> {getSpanOperation(span)}</TransactionOp>
+        </div>
+      </Title>
+      {event.projectSlug && (
+        <ProfilesProvider
+          orgSlug={organization.slug}
+          projectSlug={event.projectSlug}
+          profileId={profileId || ''}
+        >
+          <ProfileContext.Consumer>
+            {profiles => (
+              <ProfileGroupProvider
+                type="flamechart"
+                input={profiles?.type === 'resolved' ? profiles.data : null}
+                traceID={profileId || ''}
+              >
+                <NewTraceDetailsSpanDetail
+                  relatedErrors={relatedErrors}
+                  childTransactions={childTxn ? [childTxn] : []}
+                  event={event}
+                  openPanel="open"
+                  organization={organization}
+                  span={span}
+                  trace={parseTrace(event)}
+                />
+              </ProfileGroupProvider>
+            )}
+          </ProfileContext.Consumer>
+        </ProfilesProvider>
+      )}
+    </Wrapper>
+  );
+}
+
+function TraceDetailPanel() {
+  const organization = useOrganization();
+  const location = useLocation();
+  const {node, setNode} = useContext(HighLightedRowContext);
+
+  if (node && !(isTransactionNode(node) || isSpanNode(node))) {
+    return null;
+  }
+
+  return (
+    <PageAlertProvider>
+      <DetailPanel
+        detailKey={node ? 'open' : undefined}
+        skipCloseOnOutsideClick
+        onClose={() => setNode(undefined)}
+      >
+        {node &&
+          (isTransactionNode(node) ? (
+            <EventDetails location={location} organization={organization} node={node} />
+          ) : (
+            <SpanDetailsBody organization={organization} node={node} />
+          ))}
+      </DetailPanel>
+    </PageAlertProvider>
+  );
+}
+
+const Wrapper = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+
+  ${DataSection} {
+    padding: 0;
+  }
+
+  ${SpanDetails} {
+    padding: 0;
+  }
+
+  ${SpanDetailContainer} {
+    border-bottom: none;
+  }
+`;
+
+const FlexBox = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+const Actions = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
+const Title = styled(FlexBox)`
+  gap: ${space(2)};
+`;
+
+const TransactionOp = styled('div')`
+  font-size: 25px;
+  font-weight: bold;
+  max-width: 600px;
+  ${p => p.theme.overflowEllipsis}
+`;
+
+const TransactionIdTitle = styled('a')`
+  display: flex;
+  color: ${p => p.theme.textColor};
+  :hover {
+    color: ${p => p.theme.textColor};
+  }
+`;
+
+const Measurements = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${space(1)};
+  padding-top: 10px;
+`;
+
+const StyledButton = styled(Button)`
+  position: absolute;
+  top: ${space(0.75)};
+  right: ${space(0.5)};
+`;
+
+const StyledTable = styled('table')`
+  margin-bottom: 0 !important;
+`;
+
+export default TraceDetailPanel;

+ 14 - 1
static/app/views/performance/newTraceDetails/trace.tsx

@@ -2,6 +2,7 @@ import type React from 'react';
 import {
   Fragment,
   useCallback,
+  useContext,
   useEffect,
   useLayoutEffect,
   useMemo,
@@ -25,6 +26,7 @@ import {space} from 'sentry/styles/space';
 import type {Project} from 'sentry/types';
 import {getDuration} from 'sentry/utils/formatters';
 import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
@@ -37,6 +39,7 @@ import {
   isTraceNode,
   isTransactionNode,
 } from './guards';
+import {HighLightedRowContext} from './HighlightedRowContext';
 import {ParentAutogroupNode, type TraceTree, type TraceTreeNode} from './traceTree';
 import {VirtualizedViewManager} from './virtualizedViewManager';
 
@@ -170,6 +173,8 @@ function Trace({trace, trace_id}: TraceProps) {
   const api = useApi();
   const {projects} = useProjects();
   const organization = useOrganization();
+  const location = useLocation();
+  const {node: highlightedNode, setNode} = useContext(HighLightedRowContext);
   const viewManager = useMemo(() => {
     return new VirtualizedViewManager({
       list: {width: 0.5},
@@ -266,9 +271,17 @@ function Trace({trace, trace_id}: TraceProps) {
           node: node.path,
         },
       });
+
+      if (highlightedNode === node) {
+        setNode(undefined);
+      } else {
+        setNode(node);
+      }
+
+      // TODO JonasBa: replace context logic with reducer here
       dispatch({type: 'go to index', index, node});
     },
-    []
+    [location.pathname, location.search, setNode, highlightedNode]
   );
 
   const onRowKeyDown = useCallback(

+ 12 - 2
static/app/views/performance/newTraceDetails/traceTree.spec.tsx

@@ -42,19 +42,24 @@ function makeTransaction(overrides: Partial<TraceFullDetailed> = {}): TraceFullD
     transaction: 'transaction',
     'transaction.op': '',
     'transaction.status': '',
+    performance_issues: [],
+    errors: [],
     ...overrides,
   } as TraceFullDetailed;
 }
 
-function makeSpan(overrides: Partial<RawSpanType> = {}): RawSpanType {
+function makeSpan(overrides: Partial<RawSpanType> = {}): TraceTree.Span {
   return {
     op: '',
     description: '',
     span_id: '',
     start_timestamp: 0,
     timestamp: 10,
+    event: makeEvent(),
+    relatedErrors: [],
+    childTxn: undefined,
     ...overrides,
-  } as RawSpanType;
+  } as TraceTree.Span;
 }
 
 function makeTraceError(
@@ -496,6 +501,7 @@ describe('TraceTree', () => {
 
     const node = TraceTree.FromSpans(
       root,
+      makeEvent(),
       [
         makeSpan({start_timestamp: 0, op: '1', span_id: '1'}),
         makeSpan({start_timestamp: 1, op: '2', span_id: '2', parent_span_id: '1'}),
@@ -547,6 +553,7 @@ describe('TraceTree', () => {
 
     const node = TraceTree.FromSpans(
       root,
+      makeEvent(),
       [
         makeSpan({start_timestamp: 0, timestamp: 0.1, op: 'span', span_id: 'none'}),
         makeSpan({
@@ -596,6 +603,7 @@ describe('TraceTree', () => {
 
     const node = TraceTree.FromSpans(
       root,
+      makeEvent(),
       [
         makeSpan({start_timestamp: 0, timestamp: 0.1, op: 'span', span_id: 'none'}),
         makeSpan({
@@ -625,6 +633,7 @@ describe('TraceTree', () => {
     const date = new Date().getTime();
     const node = TraceTree.FromSpans(
       root,
+      makeEvent(),
       [
         makeSpan({
           start_timestamp: date,
@@ -664,6 +673,7 @@ describe('TraceTree', () => {
     const date = new Date().getTime();
     const node = TraceTree.FromSpans(
       root,
+      makeEvent(),
       [
         makeSpan({
           start_timestamp: date,

+ 31 - 3
static/app/views/performance/newTraceDetails/traceTree.tsx

@@ -4,6 +4,7 @@ import type {Organization} from 'sentry/types';
 import type {Event, EventTransaction} from 'sentry/types/event';
 import type {
   TraceError as TraceErrorType,
+  TraceErrorOrIssue,
   TraceFullDetailed,
   TraceSplitResults,
 } from 'sentry/utils/performance/quickTrace/types';
@@ -98,7 +99,11 @@ import {
 
 export declare namespace TraceTree {
   type Transaction = TraceFullDetailed;
-  type Span = RawSpanType;
+  type Span = RawSpanType & {
+    childTxn: Transaction | undefined;
+    event: EventTransaction;
+    relatedErrors: TraceErrorOrIssue[];
+  };
   type Trace = TraceSplitResults<Transaction>;
   type TraceError = TraceErrorType;
 
@@ -377,6 +382,7 @@ export class TraceTree {
 
   static FromSpans(
     parent: TraceTreeNode<TraceTree.NodeValue>,
+    data: Event,
     spans: RawSpanType[],
     options: {sdk: string | undefined} | undefined
   ): TraceTreeNode<TraceTree.NodeValue> {
@@ -415,7 +421,13 @@ export class TraceTree {
 
     for (const span of spans) {
       const childTxn = transactionsToSpanMap.get(span.span_id);
-      const node: TraceTreeNode<TraceTree.Span> = new TraceTreeNode(null, span, {
+      const spanNodeValue: TraceTree.Span = {
+        ...span,
+        event: data as EventTransaction,
+        relatedErrors: childTxn ? getRelatedErrorsOrIssues(span, childTxn.value) : [],
+        childTxn: childTxn?.value,
+      };
+      const node: TraceTreeNode<TraceTree.Span> = new TraceTreeNode(null, spanNodeValue, {
         event_id: undefined,
         project_slug: undefined,
       });
@@ -725,7 +737,7 @@ export class TraceTree {
         spans.data.sort((a, b) => a.start_timestamp - b.start_timestamp);
       }
 
-      TraceTree.FromSpans(node, spans.data, {sdk: data.sdk?.name});
+      TraceTree.FromSpans(node, data, spans.data, {sdk: data.sdk?.name});
 
       const spanChildren = node.getVisibleChildren();
       this._list.splice(index + 1, 0, ...spanChildren);
@@ -1317,6 +1329,22 @@ function nodeToId(n: TraceTreeNode<TraceTree.NodeValue>): TraceTree.NodePath {
   throw new Error('Not implemented');
 }
 
+function getRelatedErrorsOrIssues(
+  span: RawSpanType,
+  currentEvent: TraceTree.Transaction
+): TraceErrorOrIssue[] {
+  const performanceIssues = currentEvent.performance_issues.filter(
+    issue =>
+      issue.span.some(id => id === span.span_id) ||
+      issue.suspect_spans.some(suspectSpanId => suspectSpanId === span.span_id)
+  );
+
+  return [
+    ...currentEvent.errors.filter(error => error.span === span.span_id),
+    ...performanceIssues, // Spans can be shown when embedded in performance issues
+  ];
+}
+
 function printNode(t: TraceTreeNode<TraceTree.NodeValue>, offset: number): string {
   // +1 because we may be printing from the root which is -1 indexed
   const padding = '  '.repeat(t.depth + offset);

+ 9 - 2
static/app/views/starfish/components/detailPanel.tsx

@@ -16,6 +16,7 @@ type DetailProps = {
   detailKey?: string;
   onClose?: () => void;
   onOpen?: () => void;
+  skipCloseOnOutsideClick?: boolean;
 };
 
 type DetailState = {
@@ -24,7 +25,13 @@ type DetailState = {
 
 const SLIDEOUT_STORAGE_KEY = 'starfish-panel-slideout-direction';
 
-export default function Detail({children, detailKey, onClose, onOpen}: DetailProps) {
+export default function Detail({
+  children,
+  detailKey,
+  onClose,
+  onOpen,
+  skipCloseOnOutsideClick = false,
+}: DetailProps) {
   const [state, setState] = useState<DetailState>({collapsed: true});
   const [slidePosition, setSlidePosition] = useLocalStorageState<'right' | 'bottom'>(
     SLIDEOUT_STORAGE_KEY,
@@ -43,7 +50,7 @@ export default function Detail({children, detailKey, onClose, onOpen}: DetailPro
 
   const panelRef = useRef<HTMLDivElement>(null);
   useOnClickOutside(panelRef, () => {
-    if (!state.collapsed) {
+    if (!state.collapsed && !skipCloseOnOutsideClick) {
       onClose?.();
       setState({collapsed: true});
     }