import {Fragment, type PropsWithChildren, useMemo} from 'react'; import styled from '@emotion/styled'; import type {LocationDescriptor} from 'history'; import {Button, LinkButton} from 'sentry/components/button'; import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton'; import { DropdownMenu, type DropdownMenuProps, type MenuItemProps, } from 'sentry/components/dropdownMenu'; import EventTagsDataSection from 'sentry/components/events/eventTagsAndScreenshot/tags'; import {generateStats} from 'sentry/components/events/opsBreakdown'; import {DataSection} from 'sentry/components/events/styles'; import FileSize from 'sentry/components/fileSize'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import KeyValueData, { CardPanel, type KeyValueDataContentProps, Subject, } from 'sentry/components/keyValueData'; import {LazyRender, type LazyRenderProps} from 'sentry/components/lazyRender'; import Link from 'sentry/components/links/link'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; import {pickBarColor} from 'sentry/components/performance/waterfall/utils'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {Tooltip} from 'sentry/components/tooltip'; import { IconChevron, IconCircleFill, IconFocus, IconJson, IconOpen, IconPanel, IconProfiling, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Event, EventTransaction} from 'sentry/types/event'; import type {KeyValueListData} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; import getDuration from 'sentry/utils/duration/getDuration'; import type {Color, ColorOrAlias} from 'sentry/utils/theme'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import {traceAnalytics} from '../../traceAnalytics'; import {useTransaction} from '../../traceApi/useTransaction'; import {useDrawerContainerRef} from '../../traceDrawer/details/drawerContainerRefContext'; import {makeTraceContinuousProfilingLink} from '../../traceDrawer/traceProfilingLink'; import { isAutogroupedNode, isMissingInstrumentationNode, isRootNode, isSpanNode, isTraceErrorNode, isTransactionNode, } from '../../traceGuards'; import type {MissingInstrumentationNode} from '../../traceModels/missingInstrumentationNode'; import type {ParentAutogroupNode} from '../../traceModels/parentAutogroupNode'; import type {SiblingAutogroupNode} from '../../traceModels/siblingAutogroupNode'; import {TraceTree} from '../../traceModels/traceTree'; import type {TraceTreeNode} from '../../traceModels/traceTreeNode'; import {useTraceState, useTraceStateDispatch} from '../../traceState/traceStateProvider'; import {useHasTraceNewUi} from '../../useHasTraceNewUi'; const DetailContainer = styled('div')<{hasNewTraceUi?: boolean}>` display: flex; flex-direction: column; gap: ${p => (p.hasNewTraceUi ? 0 : space(2))}; padding: ${space(1)}; ${DataSection} { padding: 0; } `; const FlexBox = styled('div')` display: flex; align-items: center; `; const Actions = styled(FlexBox)` gap: ${space(0.5)}; justify-content: end; width: 100%; `; const Title = styled(FlexBox)` gap: ${space(1)}; flex-grow: 1; overflow: hidden; > span { min-width: 30px; } `; const LegacyTitleText = styled('div')` ${p => p.theme.overflowEllipsis} `; const TitleText = styled('div')` font-size: ${p => p.theme.fontSizeExtraLarge}; font-weight: bold; `; function TitleWithTestId(props: PropsWithChildren<{}>) { return {props.children}; } function SubtitleWithCopyButton({text}: {text: string}) { return ( {text} ); } const SubTitleWrapper = styled(FlexBox)` ${p => p.theme.overflowEllipsis} `; const StyledSubTitleText = styled('span')` font-size: ${p => p.theme.fontSizeMedium}; color: ${p => p.theme.subText}; `; function TitleOp({text}: {text: string}) { return ( {text} } showOnlyOnOverflow isHoverable > {text} ); } const Type = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; `; const TitleOpText = styled('div')` font-size: 15px; font-weight: ${p => p.theme.fontWeightBold}; ${p => p.theme.overflowEllipsis} `; const Table = styled('table')` margin-bottom: 0 !important; td { overflow: hidden; } `; const IconTitleWrapper = styled(FlexBox)` gap: ${space(1)}; min-width: 30px; `; const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>` background-color: ${p => p.backgroundColor}; border-radius: ${p => p.theme.borderRadius}; padding: 0; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; min-width: 30px; svg { fill: ${p => p.theme.white}; width: 14px; height: 14px; } `; const LegacyHeaderContainer = styled(FlexBox)` justify-content: space-between; gap: ${space(3)}; container-type: inline-size; @container (max-width: 780px) { .DropdownMenu { display: block; } .Actions { display: none; } } @container (min-width: 781px) { .DropdownMenu { display: none; } } `; const HeaderContainer = styled(FlexBox)` align-items: baseline; justify-content: space-between; gap: ${space(3)}; margin-bottom: ${space(2)}; `; const DURATION_COMPARISON_STATUS_COLORS: { equal: {light: ColorOrAlias; normal: ColorOrAlias}; faster: {light: ColorOrAlias; normal: ColorOrAlias}; slower: {light: ColorOrAlias; normal: ColorOrAlias}; } = { faster: { light: 'green100', normal: 'green300', }, slower: { light: 'red100', normal: 'red300', }, equal: { light: 'gray100', normal: 'gray300', }, }; const MIN_PCT_DURATION_DIFFERENCE = 10; type DurationComparison = { deltaPct: number; deltaText: JSX.Element; status: 'faster' | 'slower' | 'equal'; } | null; const getDurationComparison = ( baseline: number | undefined, duration: number, baseDescription?: string ): DurationComparison => { if (!baseline) { return null; } const delta = duration - baseline; const deltaPct = Math.round(Math.abs((delta / baseline) * 100)); const status = delta > 0 ? 'slower' : delta < 0 ? 'faster' : 'equal'; const formattedBaseDuration = ( {getDuration(baseline, 2, true)} ); const deltaText = status === 'equal' ? tct(`equal to avg [formattedBaseDuration]`, { formattedBaseDuration, }) : status === 'faster' ? tct(`[deltaPct] faster than avg [formattedBaseDuration]`, { formattedBaseDuration, deltaPct: `${deltaPct}%`, }) : tct(`[deltaPct] slower than avg [formattedBaseDuration]`, { formattedBaseDuration, deltaPct: `${deltaPct}%`, }); return {deltaPct, status, deltaText}; }; type DurationProps = { baseline: number | undefined; duration: number; node: TraceTreeNode; baseDescription?: string; ratio?: number; }; function Duration(props: DurationProps) { if (typeof props.duration !== 'number' || Number.isNaN(props.duration)) { return {t('unknown')}; } // Since transactions have ms precision, we show 2 decimal places only if the duration is greater than 1 second. const precision = isTransactionNode(props.node) ? (props.duration > 1 ? 2 : 0) : 2; if (props.baseline === undefined || props.baseline === 0) { return ( {getDuration(props.duration, precision, true)} ); } const comparison = getDurationComparison( props.baseline, props.duration, props.baseDescription ); return ( {getDuration(props.duration, precision, true)}{' '} {props.ratio ? `(${(props.ratio * 100).toFixed()}%)` : null} {comparison && comparison.deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? ( {comparison.deltaText} ) : null} ); } function TableRow({ title, keep, children, prefix, extra = null, toolTipText, }: { children: React.ReactNode; title: JSX.Element | string | null; extra?: React.ReactNode; keep?: boolean; prefix?: JSX.Element; toolTipText?: string; }) { if (!keep && !children) { return null; } return ( {prefix} {title} {toolTipText ? : null} {children} {extra} ); } type HighlightProps = { avgDuration: number | undefined; bodyContent: React.ReactNode; headerContent: React.ReactNode; node: TraceTreeNode; project: Project | undefined; transaction: EventTransaction | undefined; }; function Highlights({ node, transaction: event, avgDuration, project, headerContent, bodyContent, }: HighlightProps) { if (!isTransactionNode(node)) { return null; } const startTimestamp = node.space[0]; const endTimestamp = node.space[0] + node.space[1]; const durationInSeconds = (endTimestamp - startTimestamp) / 1e3; const comparison = getDurationComparison( avgDuration, durationInSeconds, t('Average duration for this transaction over the last 24 hours') ); return ( {node.value['transaction.op']} {getDuration(durationInSeconds, 2, true)} {comparison && comparison.deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? ( {comparison.deltaText} ) : null} {headerContent} {bodyContent} {event ? : null} ); } function HighLightsOpsBreakdown({event}: {event: EventTransaction}) { const breakdown = generateStats(event, {type: 'no_filter'}); const spansCount = event.entries?.find(entry => entry.type === 'spans')?.data?.length ?? 0; return ( {tct('This transaction contains [spansCount] spans', { spansCount, })} {breakdown.slice(0, 5).map(currOp => { const {name, percentage} = currOp; const operationName = typeof name === 'string' ? name : t('Other'); const color = pickBarColor(operationName); const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞'; return ( {operationName} {pctLabel}% ); })} {breakdown.length > 5 ? ( {tct('+ [moreCount] more', {moreCount: breakdown.length - 5})} ) : null} ); } const HighlightsOpsBreakdownMoreCount = styled('div')` font-size: 12px; color: ${p => p.theme.subText}; `; const HighlightsOpPct = styled('div')` color: ${p => p.theme.subText}; font-size: 14px; `; const HighlightsSpanCount = styled('div')` margin-bottom: ${space(0.25)}; `; const HighlightsOpRow = styled(FlexBox)` font-size: 13px; gap: ${space(0.5)}; `; const HighlightsOpsBreakdownWrapper = styled(FlexBox)` align-items: flex-start; flex-direction: column; gap: ${space(0.25)}; `; const HiglightsDurationComparison = styled('div')<{status: string}>` white-space: nowrap; border-radius: 12px; color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]}; background-color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].light]}; border: solid 1px ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].light]}; font-size: ${p => p.theme.fontSizeExtraSmall}; padding: ${space(0.25)} ${space(1)}; display: inline-block; height: 21px; `; const HighlightsDurationWrapper = styled(FlexBox)` gap: ${space(1)}; margin-bottom: ${space(1)}; `; const HighlightDuration = styled('div')` font-size: ${p => p.theme.headerFontSize}; font-weight: 400; `; const HighlightOp = styled('div')` font-weight: bold; font-size: ${p => p.theme.fontSizeMedium}; line-height: normal; `; const StyledPanelHeader = styled(PanelHeader)` font-weight: normal; padding: 0; line-height: normal; text-transform: none; font-size: ${p => p.theme.fontSizeMedium}; overflow: hidden; `; const SectionDivider = styled('hr')` border-color: ${p => p.theme.translucentBorder}; margin: ${space(1.5)} 0; `; const VerticalLine = styled('div')` width: 1px; height: 100%; background-color: ${p => p.theme.border}; margin-top: ${space(0.5)}; `; const HighlightsWrapper = styled('div')` display: flex; align-items: stretch; gap: ${space(1)}; width: 100%; overflow: hidden; margin: ${space(1)} 0; `; const HighlightsLeftColumn = styled('div')` display: flex; flex-direction: column; justify-content: center; align-items: center; `; const HighlightsRightColumn = styled('div')` display: flex; flex-direction: column; justify-content: left; height: 100%; flex: 1; overflow: hidden; `; function IssuesLink({ node, children, }: { children: React.ReactNode; node: TraceTreeNode; }) { const organization = useOrganization(); const params = useParams<{traceSlug?: string}>(); const traceSlug = params.traceSlug?.trim() ?? ''; // Adding a buffer of 15mins for errors only traces, where there is no concept of // trace duration and start equals end timestamps. const buffer = node.space[1] > 0 ? 0 : 15 * 60 * 1000; return ( {children} ); } const LAZY_RENDER_PROPS: Partial = { observerOptions: {rootMargin: '50px'}, }; const DurationContainer = styled('span')` font-weight: ${p => p.theme.fontWeightBold}; margin-right: ${space(1)}; `; const Comparison = styled('span')<{status: 'faster' | 'slower' | 'equal'}>` color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]}; `; const Flex = styled('div')` display: flex; align-items: center; `; const TableValueRow = styled('div')` display: grid; grid-template-columns: auto min-content; gap: ${space(1)}; border-radius: 4px; background-color: ${p => p.theme.surface200}; margin: 2px; `; const StyledQuestionTooltip = styled(QuestionTooltip)` margin-left: ${space(0.5)}; `; const StyledPre = styled('pre')` margin: 0 !important; background-color: transparent !important; `; const TableRowButtonContainer = styled('div')` padding: 8px 10px; `; const ValueTd = styled('td')` position: relative; `; function getThreadIdFromNode( node: TraceTreeNode, transaction: EventTransaction | undefined ): string | undefined { if (isSpanNode(node) && node.value.data?.['thread.id']) { return node.value.data['thread.id']; } if (transaction) { return transaction.contexts?.trace?.data?.['thread.id']; } return undefined; } // Renders the dropdown menu list at the root trace drawer content container level, to prevent // being stacked under other content. function DropdownMenuWithPortal(props: DropdownMenuProps) { const drawerContainerRef = useDrawerContainerRef(); return ( ); } function TypeSafeBoolean(value: T | null | undefined): value is NonNullable { return value !== null && value !== undefined; } function PanelPositionDropDown({organization}: {organization: Organization}) { const traceState = useTraceState(); const traceDispatch = useTraceStateDispatch(); const options: MenuItemProps[] = []; const layoutOptions = traceState.preferences.drawer.layoutOptions; if (layoutOptions.includes('drawer left')) { options.push({ key: 'drawer-left', onAction: () => { traceAnalytics.trackLayoutChange('drawer left', organization); traceDispatch({type: 'set layout', payload: 'drawer left'}); }, leadingItems: , label: t('Left'), disabled: traceState.preferences.layout === 'drawer left', }); } if (layoutOptions.includes('drawer right')) { options.push({ key: 'drawer-right', onAction: () => { traceAnalytics.trackLayoutChange('drawer right', organization); traceDispatch({type: 'set layout', payload: 'drawer right'}); }, leadingItems: , label: t('Right'), disabled: traceState.preferences.layout === 'drawer right', }); } if (layoutOptions.includes('drawer bottom')) { options.push({ key: 'drawer-bottom', onAction: () => { traceAnalytics.trackLayoutChange('drawer bottom', organization); traceDispatch({type: 'set layout', payload: 'drawer bottom'}); }, leadingItems: , label: t('Bottom'), disabled: traceState.preferences.layout === 'drawer bottom', }); } return ( {t('Panel Position')}} trigger={triggerProps => ( } /> )} /> ); } function NodeActions(props: { node: TraceTreeNode; onTabScrollToNode: ( node: | TraceTreeNode | ParentAutogroupNode | SiblingAutogroupNode | MissingInstrumentationNode ) => void; organization: Organization; eventSize?: number | undefined; }) { const hasNewTraceUi = useHasTraceNewUi(); const organization = useOrganization(); const params = useParams<{traceSlug?: string}>(); const {data: transaction} = useTransaction({ node: isTransactionNode(props.node) ? props.node : null, organization, }); const profilerId: string = useMemo(() => { if (isTransactionNode(props.node)) { return props.node.value.profiler_id; } if (isSpanNode(props.node)) { return props.node.value.sentry_tags?.profiler_id ?? ''; } return ''; }, [props]); const profileLink = makeTraceContinuousProfilingLink(props.node, profilerId, { orgSlug: props.organization.slug, projectSlug: props.node.metadata.project_slug ?? '', traceId: params.traceSlug ?? '', threadId: getThreadIdFromNode(props.node, transaction), }); if (!hasNewTraceUi) { return ( ); } return ( { traceAnalytics.trackShowInView(props.organization); props.onTabScrollToNode(props.node); }} size="xs" aria-label={t('Show in view')} icon={} /> {isTransactionNode(props.node) ? ( traceAnalytics.trackViewEventJSON(props.organization)} href={`/api/0/projects/${props.organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`} size="xs" aria-label={t('JSON')} icon={} /> ) : null} {profileLink ? ( } /> ) : null} ); } const ActionButton = styled(Button)` border: none; background-color: transparent; box-shadow: none; transition: none !important; opacity: 0.8; height: 24px; max-height: 24px; &:hover { border: none; background-color: transparent; box-shadow: none; opacity: 1; } `; const ActionWrapper = styled('div')` display: flex; align-items: center; gap: ${space(0.25)}; `; function LegacyNodeActions(props: { node: TraceTreeNode; onTabScrollToNode: ( node: | TraceTreeNode | ParentAutogroupNode | SiblingAutogroupNode | MissingInstrumentationNode ) => void; profileLink: LocationDescriptor | null; profilerId: string; transaction: EventTransaction | undefined; eventSize?: number | undefined; }) { const navigate = useNavigate(); const organization = useOrganization(); const items = useMemo((): MenuItemProps[] => { const showInView: MenuItemProps = { key: 'show-in-view', label: t('Show in View'), onAction: () => { traceAnalytics.trackShowInView(organization); props.onTabScrollToNode(props.node); }, }; const eventId = props.node.metadata.event_id ?? TraceTree.ParentTransaction(props.node)?.metadata.event_id; const projectSlug = props.node.metadata.project_slug ?? TraceTree.ParentTransaction(props.node)?.metadata.project_slug; const eventSize = props.eventSize; const jsonDetails: MenuItemProps = { key: 'json-details', onAction: () => { traceAnalytics.trackViewEventJSON(organization); window.open( `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/json/`, '_blank' ); }, label: t('JSON') + (typeof eventSize === 'number' ? ` (${formatBytesBase10(eventSize, 0)})` : ''), }; const continuousProfileLink: MenuItemProps | null = props.profileLink ? { key: 'continuous-profile', onAction: () => { traceAnalytics.trackViewContinuousProfile(organization); navigate(props.profileLink!); }, label: t('Continuous Profile'), } : null; if (isTransactionNode(props.node)) { return [showInView, jsonDetails, continuousProfileLink].filter(TypeSafeBoolean); } if (isSpanNode(props.node)) { return [showInView, continuousProfileLink].filter(TypeSafeBoolean); } if (isMissingInstrumentationNode(props.node)) { return [showInView, continuousProfileLink].filter(TypeSafeBoolean); } if (isTraceErrorNode(props.node)) { return [showInView, continuousProfileLink].filter(TypeSafeBoolean); } if (isRootNode(props.node)) { return [showInView]; } if (isAutogroupedNode(props.node)) { return [showInView]; } return [showInView]; }, [props, navigate, organization]); return ( {props.profileLink ? ( {t('Continuous Profile')} ) : null} {isTransactionNode(props.node) ? ( } onClick={() => traceAnalytics.trackViewEventJSON(organization)} href={`/api/0/projects/${organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`} external > {t('JSON')} () ) : null} ( {t('Actions')} )} /> ); } const ActionsButtonTrigger = styled(Button)` svg { margin-left: ${space(0.5)}; width: 10px; height: 10px; } `; const ActionsContainer = styled('div')` display: flex; justify-content: end; align-items: center; gap: ${space(1)}; `; function EventTags({projectSlug, event}: {event: Event; projectSlug: string}) { const hasNewTraceUi = useHasTraceNewUi(); if (!hasNewTraceUi) { return ; } return ; } function LegacyEventTags({projectSlug, event}: {event: Event; projectSlug: string}) { return ( ); } const TagsWrapper = styled('div')` h3 { color: ${p => p.theme.textColor}; } `; export type SectionCardKeyValueList = KeyValueListData; function SectionCard({ items, title, disableTruncate, sortAlphabetically = false, itemProps = {}, }: { items: SectionCardKeyValueList; title: React.ReactNode; disableTruncate?: boolean; itemProps?: Partial; sortAlphabetically?: boolean; }) { const contentItems = items.map(item => ({item, ...itemProps})); return ( ); } // This is trace-view specific styling. The card is rendered in a number of different places // with tests failing otherwise, since @container queries are not supported by the version of // jsdom currently used by jest. const CardWrapper = styled('div')` ${CardPanel} { container-type: inline-size; } ${Subject} { @container (width < 350px) { max-width: 200px; } } `; function SectionCardGroup({children}: {children: React.ReactNode}) { return {children}; } function CopyableCardValueWithLink({ value, linkTarget, linkText, onClick, }: { value: React.ReactNode; linkTarget?: LocationDescriptor; linkText?: string; onClick?: () => void; }) { return ( {value} {typeof value === 'string' ? ( ) : null} {linkTarget && linkTarget ? ( {linkText} ) : null} ); } function TraceDataSection({event}: {event: EventTransaction}) { const traceData = event.contexts.trace?.data; if (!traceData) { return null; } return ( ({ key, subject: key, value, }))} title={t('Trace Data')} /> ); } const StyledCopyToClipboardButton = styled(CopyToClipboardButton)` transform: translateY(2px); `; const CardValueContainer = styled(FlexBox)` justify-content: space-between; gap: ${space(1)}; flex-wrap: wrap; `; const CardValueText = styled('span')` overflow-wrap: anywhere; `; export const CardContentSubject = styled('div')` grid-column: span 1; font-family: ${p => p.theme.text.familyMono}; word-wrap: break-word; `; const TraceDrawerComponents = { DetailContainer, FlexBox, Title: TitleWithTestId, Type, TitleOp, HeaderContainer, LegacyHeaderContainer, Highlights, Actions, NodeActions, Table, IconTitleWrapper, IconBorder, TitleText, LegacyTitleText, Duration, TableRow, LAZY_RENDER_PROPS, TableRowButtonContainer, TableValueRow, IssuesLink, SectionCard, CopyableCardValueWithLink, EventTags, SubtitleWithCopyButton, TraceDataSection, SectionCardGroup, DropdownMenuWithPortal, }; export {TraceDrawerComponents};