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}
{
traceAnalytics.trackShowInView(organization);
props.onTabScrollToNode(props.node);
}}
>
{t('Show in view')}
{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};