diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx index dd3d30d60f95..e0cda318120b 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx @@ -78,6 +78,9 @@ interface AttributesTreeProps< columnCount?: number; config?: AttributesTreeRowConfig; getAdjustedAttributeKey?: (attribute: TraceItemResponseAttribute) => string; + // If provided, renders the returned node immediately after the attribute key + // (e.g. an icon indicating the attribute's state). Return null for no suffix. + getAttributeKeySuffix?: (content: AttributesTreeContent) => React.ReactNode; getCustomActions?: (content: AttributesTreeContent) => MenuItemProps[]; } @@ -102,6 +105,7 @@ interface AttributesTreeRowProps< attributeKey: string; content: AttributesTreeContent; config?: AttributesTreeRowConfig; + getAttributeKeySuffix?: (content: AttributesTreeContent) => React.ReactNode; getCustomActions?: (content: AttributesTreeContent) => MenuItemProps[]; isLast?: boolean; spacerCount?: number; @@ -172,6 +176,7 @@ function getAttributesTreeRows({ isLast = false, config = {}, getCustomActions, + getAttributeKeySuffix, }: AttributesTreeRowProps & AttributesFieldRender & { uniqueKey: string; @@ -189,6 +194,7 @@ function getAttributesTreeRows({ config, rendererExtra, getCustomActions, + getAttributeKeySuffix, }); return rows.concat(branchRows); }, @@ -206,6 +212,7 @@ function getAttributesTreeRows({ isLast={isLast} config={config} getCustomActions={getCustomActions} + getAttributeKeySuffix={getAttributeKeySuffix} />, ...subtreeRows, ]; @@ -223,6 +230,7 @@ function AttributesTreeColumns({ config = {}, getCustomActions, getAdjustedAttributeKey, + getAttributeKeySuffix, }: AttributesTreeColumnsProps) { const assembledColumns = useMemo(() => { if (!attributes) { @@ -253,6 +261,7 @@ function AttributesTreeColumns({ rendererExtra: renderExtra, config, getCustomActions, + getAttributeKeySuffix, }) ); @@ -299,6 +308,7 @@ function AttributesTreeColumns({ config, getCustomActions, getAdjustedAttributeKey, + getAttributeKeySuffix, ]); return {assembledColumns}; @@ -328,12 +338,14 @@ function AttributesTreeRow({ isLast = false, config = {}, getCustomActions, + getAttributeKeySuffix, ...props }: AttributesTreeRowProps) { const theme = useTheme(); const originalAttribute = content.originalAttribute; const hasErrors = false; // No error handling in this simplified version const hasStem = !isLast && isEmptyObject(content.subtree); + const keySuffix = getAttributeKeySuffix?.(content); if (!originalAttribute) { return ( @@ -372,6 +384,7 @@ function AttributesTreeRow({ data-test-id={`tree-key-${content.originalAttribute?.original_attribute_key}`} > {attributeKey} + {keySuffix} diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index e95a84f2a948..16e99b164380 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -51,6 +51,10 @@ import { import {TraceStateProvider} from './traceState/traceStateProvider'; import {ErrorsOnlyWarnings} from './traceTypeWarnings/errorsOnlyWarnings'; import {TraceMetaDataHeader} from './traceHeader'; +import { + getTraceAdditionalAttributes, + useTracePinnedAttribute, +} from './tracePinnedAttribute'; import {useInitialTraceMetricData} from './useInitialTraceMetricData'; import {useTraceEventView} from './useTraceEventView'; import {useTraceQueryParams} from './useTraceQueryParams'; @@ -137,16 +141,19 @@ function TraceViewImplInner({traceSlug}: {traceSlug: string}) { metaMetricsCount === undefined ? metricsData : {count: metaMetricsCount}; const hideTraceWaterfallIfEmpty = (logsData?.length ?? 0) > 0; + const {pinnedAttribute} = useTracePinnedAttribute(); + // Request the pinned attribute only when the trace endpoint does not already + // return it. Sorted for a stable react-query key so toggling unrelated state + // never triggers a refetch. + const additionalAttributes = useMemo( + () => getTraceAdditionalAttributes(pinnedAttribute), + [pinnedAttribute] + ); + const trace = useTrace({ traceSlug, timestamp: queryParams.timestamp, - additionalAttributes: [ - 'thread.id', - 'tags[performance.timeOrigin,number]', - 'gen_ai.operation.type', - 'http.response.status_code', - 'span.status', - ], + additionalAttributes, }); const tree = useTraceTree({traceSlug, trace, replay: null}); diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 08b32113b06f..0949e5f8930f 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -61,6 +61,12 @@ import { type RovingTabIndexUserActions, } from './traceState/traceRovingTabIndex'; import {useTraceState, useTraceStateDispatch} from './traceState/traceStateProvider'; +import { + PINNED_COLUMN_WIDTH, + TracePinnedAttributeColumn, + TracePinnedAttributeHeader, + useTracePinnedAttribute, +} from './tracePinnedAttribute'; import type {TraceReducerState} from './traceState'; const traceIssueIconBackgroundStyles = css` @@ -156,6 +162,7 @@ export function Trace({ const organization = useOrganization(); const traceState = useTraceState(); const traceDispatch = useTraceStateDispatch(); + const {pinnedAttribute} = useTracePinnedAttribute(); const {theme: colorMode} = useLegacyStore(ConfigStore); const rerenderRef = useRef(rerender); @@ -182,6 +189,14 @@ export function Trace({ TRACE_WATERFALL_TIME_COMPRESSION_FEATURE ); + // Reset the pinned column's horizontal scroll when the pinned attribute changes + // so a new (potentially shorter) attribute isn't rendered mid-scroll, and keep + // the tree's scroll clamp aware of the width the pinned column covers. + useLayoutEffect(() => { + manager.setPinnedColumnWidth(pinnedAttribute ? PINNED_COLUMN_WIDTH : 0); + manager.resetPinnedColumnScroll(); + }, [manager, pinnedAttribute]); + const visibleTraceItems = useMemo( () => snapshotVisibleTraceItems(trace.list, forceRerender), [trace.list, forceRerender] @@ -410,6 +425,7 @@ export function Trace({ onRowKeyDown={onRowKeyDown} tree={trace} trace_id={trace_id} + pinnedAttribute={pinnedAttribute} /> ); }, @@ -431,14 +447,21 @@ export function Trace({ theme, trace.type, forceRerender, + pinnedAttribute, ] ); + // While the trace is loading the virtualized list renders TraceLoadingRow, + // which has no pinned attribute cell. Derive the loading state once so the + // pinned column header and border line only render alongside real rows, + // never orphaned above the loading placeholders. + const isTraceLoading = trace.type !== 'trace' || isLoading; + const render = useMemo(() => { - return trace.type !== 'trace' || isLoading + return isTraceLoading ? (r: any) => renderLoadingRow(r) : (r: any) => renderVirtualizedRow(r); - }, [isLoading, renderLoadingRow, renderVirtualizedRow, trace.type]); + }, [isTraceLoading, renderLoadingRow, renderVirtualizedRow]); const traceNode = trace.root.children[0]; const traceStartTimestamp = traceNode?.space?.[0]; @@ -458,7 +481,7 @@ export function Trace({ className={` ${trace.root.space[1] === 0 ? 'Empty' : ''} ${trace.indicators.length > 0 ? 'WithIndicators' : ''} - ${trace.type !== 'trace' || isLoading ? 'Loading' : ''} + ${isTraceLoading ? 'Loading' : ''} ${ConfigStore.get('theme')}`} >
+ {pinnedAttribute && !isTraceLoading ? ( + +
+ + + ) : null}
{ const indicatorTimestamp = manager.intervals[i] ?? 0; - if (trace.type !== 'trace' || isLoading) { + if (isTraceLoading) { return null; } @@ -539,8 +568,7 @@ export function Trace({
); })} - {trace.type === 'trace' && - !isLoading && + {!isTraceLoading && timeCompression.gaps.map((gap, i) => ( void; onZoomIn: (event: React.MouseEvent, node: BaseNode, value: boolean) => void; organization: Organization; + pinnedAttribute: string | null; previouslyFocusedNodeRef: React.MutableRefObject; projects: Record; searchResultsIteratorIndex: number | null; @@ -690,6 +719,14 @@ function RenderTraceRow(props: { paddingLeft: TraceTree.Depth(node) * props.manager.row_depth_padding, }; + const pinnedColumns = props.pinnedAttribute ? ( + + ) : null; + const rowProps: TraceRowProps = { onExpand, onZoomIn, @@ -715,6 +752,7 @@ function RenderTraceRow(props: { registerListColumnRef, registerSpanColumnRef, registerSpanArrowRef, + pinnedColumns, }; return node.renderWaterfallRow(rowProps); @@ -1516,6 +1554,15 @@ const TraceStylingWrapper = styled('div')` .TraceLeftColumn { box-shadow: inset 0px 0 0px 1px ${p => p.theme.tokens.focus.default} !important; } + + /* The opaque pinned column covers the row's inset outline, so redraw its + top and bottom edges (the left/right edges are already the pinned column + line and the divider). */ + .TracePinnedColumn { + box-shadow: + inset 0 1px 0 0 ${p => p.theme.tokens.focus.default}, + inset 0 -1px 0 0 ${p => p.theme.tokens.focus.default}; + } } &.Highlight, @@ -1527,6 +1574,12 @@ const TraceStylingWrapper = styled('div')` .TraceRightColumn.Odd { background-color: transparent !important; } + + /* The pinned column is opaque (it scrolls over the tree), so match the + row's focused background instead of leaving a plain block behind. */ + .TracePinnedColumn { + background-color: var(--row-background-focused); + } } &:focus, @@ -1540,6 +1593,11 @@ const TraceStylingWrapper = styled('div')` .TraceRightColumn.Odd { background-color: transparent !important; } + .TracePinnedColumn { + box-shadow: + inset 0 1px 0 0 var(--row-outline), + inset 0 -1px 0 0 var(--row-outline); + } } &.SearchResult { @@ -1548,6 +1606,10 @@ const TraceStylingWrapper = styled('div')` .TraceRightColumn { background-color: transparent; } + + .TracePinnedColumn { + background-color: ${p => p.theme.colors.yellow100}; + } } &.Autogrouped { @@ -1593,6 +1655,10 @@ const TraceStylingWrapper = styled('div')` padding-left: 0 !important; } } + + .TracePinnedColumn { + background-color: ${p => p.theme.tokens.background.secondary}; + } } } @@ -1645,6 +1711,106 @@ const TraceStylingWrapper = styled('div')` } } + /* + * The pinned attribute column is absolutely positioned at the right edge of the + * tree (just left of the divider). It consumes no flex width, so the span-bar + * coordinate system and divider math are untouched. It must be a direct child + * of .TraceRow (never inside .TraceLeftColumn, which is horizontally scrolled). + * + * The left border is drawn once as a full-height line (.TracePinnedColumnLine) + * so it stays continuous below the last row, matching the full-height divider on + * the right. The per-row cells and header therefore do not draw their own left + * border. + */ + .TracePinnedColumnLine { + position: absolute; + top: 0; + height: 100%; + width: 1px; + /* The header and this line are positioned against the wrapper (full width), + while the per-row cells live in the scrollbar-narrowed scroll container. + Offset by listFraction * scrollbarWidth so they align with the cells. */ + right: calc( + var(--span-column-width) * 100% + var(--list-column-width) * + var(--trace-scrollbar-width, 0px) + ${PINNED_COLUMN_WIDTH}px + ); + /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ + background-color: ${p => p.theme.tokens.border.primary}; + z-index: 10; + pointer-events: none; + } + + .TracePinnedColumnHeader { + position: absolute; + top: 0; + height: 38px; + right: calc( + var(--span-column-width) * 100% + var(--list-column-width) * + var(--trace-scrollbar-width, 0px) + ); + z-index: 10; + display: flex; + align-items: center; + gap: ${p => p.theme.space.xs}; + padding-left: ${p => p.theme.space.md}; + padding-right: ${p => p.theme.space.xs}; + background-color: ${p => p.theme.tokens.background.primary}; + border-right: 1px solid ${p => p.theme.tokens.border.primary}; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + color: ${p => p.theme.tokens.content.secondary}; + font-size: ${p => p.theme.font.size.sm}; + + .TracePinnedColumnHeaderLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; + color: ${p => p.theme.tokens.content.primary}; + font-weight: ${p => p.theme.font.weight.sans.medium}; + } + } + + .TracePinnedColumn { + position: absolute; + top: 0; + height: 100%; + right: calc(var(--span-column-width) * 100%); + z-index: 2; + display: flex; + align-items: center; + overflow: hidden; + background-color: ${p => p.theme.tokens.background.primary}; + /* Keep the column's text uniform instead of inheriting the row's semantic + (error/warning) color. The background still tracks the row's focused, + search, and collapsed states (see the row-state rules above) so the column + matches the row rather than leaving a plain block behind. */ + color: ${p => p.theme.tokens.content.primary}; + + /* Inner element the view manager translates so the whole column scrolls + horizontally together. Horizontal padding lives here (not on the cell) so + the cell's clientWidth equals the full column width and the manager's + max-scroll math reveals the trailing edge of the value. */ + .TracePinnedColumnInner { + display: flex; + align-items: center; + height: 100%; + white-space: nowrap; + padding-left: ${p => p.theme.space.md}; + padding-right: ${p => p.theme.space.sm}; + will-change: transform; + transform-origin: left center; + } + + .TracePinnedColumnValue { + white-space: nowrap; + + &.Empty { + color: ${p => p.theme.tokens.content.secondary}; + } + } + } + .TraceBar { position: absolute; height: 16px; diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.spec.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.spec.tsx new file mode 100644 index 000000000000..888173a58660 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.spec.tsx @@ -0,0 +1,108 @@ +import {LocationFixture} from 'sentry-fixture/locationFixture'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {ThemeFixture} from 'sentry-fixture/theme'; + +import { + render, + screen, + userEvent, + waitFor, + within, +} from 'sentry-test/reactTestingLibrary'; + +import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; +import {makeEAPSpan} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeTestUtils'; +import {DEFAULT_TRACE_VIEW_PREFERENCES} from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences'; +import {TraceStateProvider} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; + +import {AttributesContent} from './attributes'; + +describe('AttributesContent pin action', () => { + const organization = OrganizationFixture(); + const project = ProjectFixture(); + const theme = ThemeFixture(); + const location = LocationFixture(); + + const attributes: TraceItemResponseAttribute[] = [ + {name: 'environment', type: 'str', value: 'production'}, + ]; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + function renderContent(query: Record = {}) { + const node = new EapSpanNode(null, makeEAPSpan({}), {organization}); + + return render( + + + , + { + organization, + initialRouterConfig: {location: {pathname: '/trace/', query}}, + } + ); + } + + async function openAttributeMenu() { + const row = (await screen.findAllByTestId('attribute-tree-row'))[0]!; + await userEvent.hover(row); + await userEvent.click( + within(row).getByRole('button', {name: 'Attribute Actions Menu'}) + ); + } + + it('pins the attribute as a column from the actions menu', async () => { + const {router} = renderContent(); + + await openAttributeMenu(); + await userEvent.click(await screen.findByText('Pin attribute')); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBe('environment'); + }); + }); + + it('shows "Unpin attribute" when the attribute is already pinned', async () => { + renderContent({pinnedAttribute: 'environment'}); + + await openAttributeMenu(); + + expect(await screen.findByText('Unpin attribute')).toBeInTheDocument(); + expect(screen.queryByText('Pin attribute')).not.toBeInTheDocument(); + }); + + it('unpins the attribute when "Unpin attribute" is clicked', async () => { + const {router} = renderContent({pinnedAttribute: 'environment'}); + + await openAttributeMenu(); + await userEvent.click(await screen.findByText('Unpin attribute')); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBeUndefined(); + }); + }); + + it('shows a pin indicator on the pinned attribute row', async () => { + renderContent({pinnedAttribute: 'environment'}); + + expect(await screen.findByTestId('pinned-attribute-indicator')).toBeInTheDocument(); + }); + + it('does not show a pin indicator when nothing is pinned', async () => { + renderContent(); + + expect(await screen.findByText('production')).toBeInTheDocument(); + expect(screen.queryByTestId('pinned-attribute-indicator')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx index 5fc6da92a624..4552f8d80e40 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx @@ -6,11 +6,14 @@ import type {Location, LocationDescriptorObject} from 'history'; import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; +import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {SearchBar as BaseSearchBar} from 'sentry/components/searchBar'; import {StructuredData} from 'sentry/components/structuredEventData'; +import {IconPin} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; @@ -25,7 +28,10 @@ import {looksLikeAJSONObject} from 'sentry/utils/string/looksLikeAJSONObject'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {AssertionFailureTree} from 'sentry/views/alerts/rules/uptime/assertions/assertionFailure/assertionFailureTree'; -import type {AttributesFieldRendererProps} from 'sentry/views/explore/components/traceItemAttributes/attributesTree'; +import type { + AttributesFieldRendererProps, + AttributesTreeContent, +} from 'sentry/views/explore/components/traceItemAttributes/attributesTree'; import {AttributesTree} from 'sentry/views/explore/components/traceItemAttributes/attributesTree'; import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; import {makeReplaysPathname} from 'sentry/views/explore/replays/pathnames'; @@ -41,6 +47,7 @@ import { } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import type {UptimeCheckNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/uptimeCheckNode'; +import {useTracePinnedAttribute} from 'sentry/views/performance/newTraceDetails/tracePinnedAttribute'; import {useTraceState} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils'; @@ -102,6 +109,7 @@ export function AttributesContent({ const currentLocation = useLocation(); const navigate = useNavigate(); const traceState = useTraceState(); + const {pinnedAttribute, setPinnedAttribute} = useTracePinnedAttribute(); const columnCount = traceState.preferences.layout === 'drawer left' || traceState.preferences.layout === 'drawer right' @@ -227,6 +235,47 @@ export function AttributesContent({ customRenderers[attribute] = truncatedTextRenderer; } + // Render a pin icon next to the pinned attribute's name so it's identifiable + // at a glance in the drawer. + const getAttributeKeySuffix = (content: AttributesTreeContent) => { + if (content.originalAttribute?.original_attribute_key !== pinnedAttribute) { + return null; + } + return ( + + + + + + ); + }; + + // Compose the shared explore actions with a pin/unpin action that toggles the + // pinned attribute column on the waterfall. Only the attribute key is required + // to pin (a value of 0/false is still pinnable). + const getCustomActions = (content: AttributesTreeContent): MenuItemProps[] => { + const actions = getTraceAttributesTreeActions({ + location, + organization, + projectIds: findSpanAttributeValue(attributes, 'project_id'), + })(content); + + const attributeKey = content.originalAttribute?.original_attribute_key; + if (!attributeKey) { + return actions; + } + + const isPinned = attributeKey === pinnedAttribute; + return [ + ...actions, + { + key: 'pin-attribute', + label: isPinned ? t('Unpin attribute') : t('Pin attribute'), + onAction: () => setPinnedAttribute(isPinned ? null : attributeKey), + }, + ]; + }; + return (
) : ( @@ -269,6 +315,14 @@ const StyledLink = styled(Link)` } `; +const PinnedAttributeIndicator = styled('span')` + display: inline-flex; + align-items: center; + margin-left: ${p => p.theme.space.xs}; + color: ${p => p.theme.tokens.content.secondary}; + vertical-align: middle; +`; + const NoAttributesMessage = styled('div')` display: flex; justify-content: center; diff --git a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx index 2f37d02e6305..a4e624e855a7 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx @@ -23,6 +23,7 @@ import { import {DOMAIN_VIEW_TITLES} from 'sentry/views/insights/pages/types'; import type {DomainView} from 'sentry/views/insights/pages/useFilters'; import {ModuleName} from 'sentry/views/insights/types'; +import {PINNED_ATTRIBUTE_QUERY_KEY} from 'sentry/views/performance/newTraceDetails/tracePinnedAttribute'; import {Tab} from 'sentry/views/performance/transactionSummary/tabs'; import {getTransactionSummaryBaseUrl} from 'sentry/views/performance/transactionSummary/utils'; import {getPerformanceBaseUrl} from 'sentry/views/performance/utils'; @@ -88,7 +89,9 @@ function getBreadCrumbTarget(pathname: string, query: Location['query']) { return { pathname, // Remove traceView specific query parameters that are not needed when navigating back. - query: {...omit(query, ['node', 'fov', 'timestamp', 'eventId'])}, + query: { + ...omit(query, ['node', 'fov', 'timestamp', 'eventId', PINNED_ATTRIBUTE_QUERY_KEY]), + }, }; } diff --git a/static/app/views/performance/newTraceDetails/traceHeader/index.spec.tsx b/static/app/views/performance/newTraceDetails/traceHeader/index.spec.tsx index 4e330c878756..a852a66c442a 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/index.spec.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/index.spec.tsx @@ -96,6 +96,28 @@ describe('TraceMetaDataHeader', () => { expect(breadcrumbsItems[0]).toHaveTextContent(/trace-slug/); }); + it('strips the pinnedAttribute query param from breadcrumb links', () => { + useLocationMock.mockReturnValue( + LocationFixture({ + pathname: '/organizations/org-slug/traces/trace/123', + query: { + source: TraceViewSources.PERFORMANCE_TRANSACTION_SUMMARY, + transaction: 'transaction-name', + pinnedAttribute: 'span.description', + }, + }) + ); + const props = {...baseProps} as TraceMetadataHeaderProps; + render(); + + const breadcrumbsLinks = screen.getAllByTestId('breadcrumb-link'); + // pinnedAttribute is trace-view specific and must not leak into the link. + expect(breadcrumbsLinks[0]).toHaveAttribute( + 'href', + '/organizations/org-slug/insights/summary?source=performance_transaction_summary&transaction=transaction-name' + ); + }); + it('should show insights from transaction summary with perf removal feature', () => { useLocationMock.mockReturnValue( LocationFixture({ diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx new file mode 100644 index 000000000000..bc49f15f452c --- /dev/null +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx @@ -0,0 +1,210 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import { + act, + render, + renderHookWithProviders, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAttributes/utils'; +import {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; +import {makeEAPSpan} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeTestUtils'; +import { + getTraceAdditionalAttributes, + TracePinnedAttributeColumn, + TracePinnedAttributeHeader, + useTracePinnedAttribute, +} from 'sentry/views/performance/newTraceDetails/tracePinnedAttribute'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; + +function makeNode(additional_attributes?: Record) { + return new EapSpanNode(null, makeEAPSpan({additional_attributes}), { + organization: OrganizationFixture(), + }); +} + +// The column only calls registerPinnedColumnRef, so a minimal stub suffices. +const manager = { + registerPinnedColumnRef: () => {}, +} as unknown as VirtualizedViewManager; + +describe('useTracePinnedAttribute', () => { + it('reads the pinned attribute from the URL', () => { + const {result} = renderHookWithProviders(useTracePinnedAttribute, { + initialRouterConfig: { + location: {pathname: '/trace/', query: {pinnedAttribute: 'span.duration'}}, + }, + }); + + expect(result.current.pinnedAttribute).toBe('span.duration'); + }); + + it('returns null when nothing is pinned', () => { + const {result} = renderHookWithProviders(useTracePinnedAttribute, { + initialRouterConfig: {location: {pathname: '/trace/'}}, + }); + + expect(result.current.pinnedAttribute).toBeNull(); + }); + + it('sets the pinned attribute in the URL, preserving other params', async () => { + const {result, router} = renderHookWithProviders(useTracePinnedAttribute, { + initialRouterConfig: {location: {pathname: '/trace/', query: {foo: 'bar'}}}, + }); + + act(() => result.current.setPinnedAttribute('span.op')); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBe('span.op'); + }); + expect(router.location.query.foo).toBe('bar'); + }); + + it('clears the pinned attribute from the URL, preserving other params', async () => { + const {result, router} = renderHookWithProviders(useTracePinnedAttribute, { + initialRouterConfig: { + location: { + pathname: '/trace/', + query: {pinnedAttribute: 'span.op', foo: 'bar'}, + }, + }, + }); + + act(() => result.current.setPinnedAttribute(null)); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBeUndefined(); + }); + expect(router.location.query.foo).toBe('bar'); + }); +}); + +describe('getTraceAdditionalAttributes', () => { + it('returns the default set, sorted, when nothing is pinned', () => { + const result = getTraceAdditionalAttributes(null); + expect(result).toEqual([...result].sort()); + expect(result).toContain('span.status'); + }); + + it('requests a pinned attribute the trace response does not already include', () => { + expect(getTraceAdditionalAttributes('custom.attribute')).toContain( + 'custom.attribute' + ); + }); + + it('does not request a pinned attribute already in the default set', () => { + const withPin = getTraceAdditionalAttributes('span.status'); + const withoutPin = getTraceAdditionalAttributes(null); + expect(withPin).toEqual(withoutPin); + }); + + it('does not request a pinned attribute the trace response returns natively', () => { + // These are returned as native span fields, keyed as they appear in the drawer. + expect(getTraceAdditionalAttributes('span.op')).not.toContain('span.op'); + expect(getTraceAdditionalAttributes('span.description')).not.toContain( + 'span.description' + ); + expect(getTraceAdditionalAttributes('measurements.lcp')).not.toContain( + 'measurements.lcp' + ); + }); +}); + +describe('TracePinnedAttributeColumn', () => { + it('renders the attribute value for the node', () => { + const node = makeNode({'http.response.status_code': 200}); + + render( + + ); + + expect(screen.getByText('200')).toBeInTheDocument(); + }); + + it('renders a placeholder when the node has no value for the attribute', () => { + const node = makeNode(); + + render( + + ); + + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('reads native span fields for attributes not in additional_attributes', () => { + // span.op / span.description are native span fields, not in additional_attributes. + const node = new EapSpanNode( + null, + makeEAPSpan({op: 'db.query', description: 'SELECT * FROM users'}), + {organization: OrganizationFixture()} + ); + + render( + + ); + expect(screen.getByText('db.query')).toBeInTheDocument(); + }); + + it('reads span.description from the native span field', () => { + const node = new EapSpanNode( + null, + makeEAPSpan({description: 'SELECT * FROM users'}), + {organization: OrganizationFixture()} + ); + + render( + + ); + expect(screen.getByText('SELECT * FROM users')).toBeInTheDocument(); + }); +}); + +describe('TracePinnedAttributeHeader', () => { + it('renders the prettified attribute name', () => { + render(, { + initialRouterConfig: { + location: { + pathname: '/trace/', + query: {pinnedAttribute: 'http.response.status_code'}, + }, + }, + }); + + expect( + screen.getByText(prettifyAttributeName('http.response.status_code')) + ).toBeInTheDocument(); + }); + + it('unpins the attribute when the remove button is clicked', async () => { + const {router} = render(, { + initialRouterConfig: { + location: {pathname: '/trace/', query: {pinnedAttribute: 'span.op'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Remove pinned column'})); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBeUndefined(); + }); + }); +}); diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx new file mode 100644 index 000000000000..23e395cba868 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -0,0 +1,258 @@ +import {useCallback} from 'react'; + +import {Button} from '@sentry/scraps/button'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {IconClose} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {decodeScalar} from 'sentry/utils/queryString'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAttributes/utils'; +import {isEAPSpan} from 'sentry/views/performance/newTraceDetails/traceGuards'; +import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {BaseNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/baseNode'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; + +/** + * URL query param that holds the single pinned attribute key. The URL is the + * source of truth so the pinned column is shareable and survives a reload. + */ +export const PINNED_ATTRIBUTE_QUERY_KEY = 'pinnedAttribute'; + +/** + * Width (in px) of the pinned attribute column that sits between the trace tree + * and the duration waterfall. Both the per-row cells and the header use this. + */ +export const PINNED_COLUMN_WIDTH = 160; + +const EMPTY_VALUE = '—'; + +/** + * Attributes that are always requested from the trace endpoint so the waterfall + * can render things like http errors and gen_ai enrichment. + */ +const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ + 'thread.id', + 'tags[performance.timeOrigin,number]', + 'gen_ai.operation.type', + 'http.response.status_code', + 'span.status', +]; + +/** + * Scalar attributes the trace endpoint returns as native span fields, keyed by + * the attribute name as it appears in the span details drawer — i.e. what a user + * actually pins (`span.description`, NOT the backend column name `description`) — + * and mapped to the corresponding field on the span value. Used both to keep + * these out of the additional_attributes request and to read their value for the + * pinned column. Mirrors `src/sentry/snuba/spans_rpc.py` `run_trace_query`. + */ +const NATIVE_ATTRIBUTE_FIELDS: Record = { + 'span.op': 'op', + 'span.name': 'name', + 'span.description': 'description', + 'span.duration': 'duration', + transaction: 'transaction', + is_transaction: 'is_transaction', + 'sdk.name': 'sdk_name', +}; + +/** + * Measurements, web vitals, and mobile vitals the trace endpoint always returns + * (mirrors `src/sentry/snuba/spans_rpc.py`). Kept as an exact set — not a prefix + * match — so that custom measurements not returned by the endpoint are still + * requested. Drift only costs a redundant refetch or an empty cell, never a + * crash. + */ +const TRACE_RESPONSE_MEASUREMENT_ATTRIBUTES = new Set([ + 'measurements.time_to_initial_display', + 'measurements.time_to_full_display', + 'measurements.app_start_cold', + 'measurements.app_start_warm', + 'measurements.frames_slow_rate', + 'measurements.frames_frozen_rate', + 'measurements.lcp', + 'measurements.score.ratio.lcp', + 'measurements.fcp', + 'measurements.score.ratio.fcp', + 'measurements.inp', + 'measurements.score.ratio.inp', + 'measurements.cls', + 'measurements.score.ratio.cls', + 'measurements.ttfb', + 'measurements.score.ratio.ttfb', + 'browser.web_vital.lcp.value', + 'browser.web_vital.cls.value', + 'browser.web_vital.inp.value', + 'browser.web_vital.ttfb.value', + 'browser.web_vital.fcp.value', + 'app.vitals.start.cold.value', + 'app.vitals.start.warm.value', + 'app.vitals.ttid.value', + 'app.vitals.ttfd.value', +]); + +/** + * Whether the trace endpoint already returns this attribute (as a native span + * field or one of the always-requested default attributes). Pinning such an + * attribute must NOT add it to the additional_attributes request. + */ +function isTraceResponseAttribute(key: string): boolean { + return ( + key in NATIVE_ATTRIBUTE_FIELDS || + TRACE_RESPONSE_MEASUREMENT_ATTRIBUTES.has(key) || + DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES.includes(key) + ); +} + +/** + * Builds the additional_attributes request for the trace endpoint: the default + * set plus the pinned attribute, unless the pinned attribute is already included + * in the trace response. Sorted for a stable react-query key so toggling + * unrelated state never triggers a refetch. + */ +export function getTraceAdditionalAttributes(pinnedAttribute: string | null): string[] { + const attributes = new Set(DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES); + if (pinnedAttribute && !isTraceResponseAttribute(pinnedAttribute)) { + attributes.add(pinnedAttribute); + } + return Array.from(attributes).sort(); +} + +/** + * Resolves a pinned attribute's value for a node, checking the requested + * additional attributes first and then falling back to the native span fields + * the trace endpoint always returns (so attributes excluded from the request + * still render). + */ +function getPinnedAttributeValue( + node: BaseNode, + key: string +): string | number | boolean | undefined { + const additionalValue = node.attributes?.[key]; + if (additionalValue !== undefined) { + return additionalValue; + } + + const value = node.value; + if (!isEAPSpan(value)) { + return undefined; + } + + const nativeField = NATIVE_ATTRIBUTE_FIELDS[key]; + if (nativeField) { + const nativeValue = value[nativeField]; + return typeof nativeValue === 'string' || + typeof nativeValue === 'number' || + typeof nativeValue === 'boolean' + ? nativeValue + : undefined; + } + if (key.startsWith('measurements.')) { + return value.measurements?.[key]; + } + if (key.startsWith('browser.web_vital.')) { + return value.browser_web_vital?.[key]; + } + if (key.startsWith('app.vitals.')) { + return value.mobile_app_vital?.[key]; + } + return undefined; +} + +interface UseTracePinnedAttribute { + pinnedAttribute: string | null; + setPinnedAttribute: (attribute: string | null) => void; +} + +/** + * Reads and writes the pinned attribute from the URL. Uses `useLocation` (which + * is reactive to navigation) rather than `useTraceQueryParams` (which memoizes + * on mount and does not react to URL changes). + */ +export function useTracePinnedAttribute(): UseTracePinnedAttribute { + const location = useLocation(); + const navigate = useNavigate(); + + const pinnedAttribute = + decodeScalar(location.query[PINNED_ATTRIBUTE_QUERY_KEY]) || null; + + const setPinnedAttribute = useCallback( + (attribute: string | null) => { + const query = {...location.query}; + if (attribute) { + query[PINNED_ATTRIBUTE_QUERY_KEY] = attribute; + } else { + delete query[PINNED_ATTRIBUTE_QUERY_KEY]; + } + navigate({pathname: location.pathname, query}, {replace: true}); + }, + [location.pathname, location.query, navigate] + ); + + return {pinnedAttribute, setPinnedAttribute}; +} + +/** + * A single cell in the pinned attribute column, rendered once per waterfall row. + * Reads the value straight off the node's loaded attributes. Renders a muted + * placeholder when the span has no value for the pinned attribute. + * + * The value lives in an inner element that the view manager translates so the + * whole column scrolls horizontally as one unit (mirroring the tree column). + */ +export function TracePinnedAttributeColumn({ + node, + pinnedAttribute, + manager, +}: { + manager: VirtualizedViewManager; + node: BaseNode; + pinnedAttribute: string; +}) { + const value = getPinnedAttributeValue(node, pinnedAttribute); + const hasValue = value !== undefined && value !== null && value !== ''; + const displayValue = hasValue ? String(value) : EMPTY_VALUE; + + return ( +
+
+ + {displayValue} + +
+
+ ); +} + +/** + * The header cell for the pinned attribute column. Shows the prettified + * attribute name and an unpin button. Rendered once, in the waterfall header. + */ +export function TracePinnedAttributeHeader({pinnedAttribute}: {pinnedAttribute: string}) { + const {setPinnedAttribute} = useTracePinnedAttribute(); + const label = prettifyAttributeName(pinnedAttribute); + + return ( +
+ + {label} + +
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx index 3683a19d6635..f639c22202f2 100644 --- a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx +++ b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx @@ -88,6 +88,14 @@ export class VirtualizedViewManager { scrolling_source: 'list' | 'fake scrollbar' | null = null; start_virtualized_index = 0; + // Shared horizontal scroll offset for the pinned attribute column. Applied to + // every cell's inner element so the whole column scrolls as one unit. + pinned_column_translate = 0; + // Width of the pinned attribute column (0 when none). The column overlays the + // right edge of the tree, so this is subtracted from the tree's visible width + // when clamping its horizontal scroll and sizing the horizontal scrollbar. + pinned_column_width = 0; + // HTML refs that we need to keep track of such // that rendering can be done programmatically reset_zoom_button: HTMLButtonElement | null = null; @@ -205,6 +213,8 @@ export class VirtualizedViewManager { this.onDividerMouseUp = this.onDividerMouseUp.bind(this); this.onDividerMouseMove = this.onDividerMouseMove.bind(this); this.onSyncedScrollbarScroll = this.onSyncedScrollbarScroll.bind(this); + this.onPinnedColumnScroll = this.onPinnedColumnScroll.bind(this); + this.registerPinnedColumnRef = this.registerPinnedColumnRef.bind(this); this.onWheel = this.onWheel.bind(this); this.onWheelEnd = this.onWheelEnd.bind(this); this.onWheelStart = this.onWheelStart.bind(this); @@ -1023,8 +1033,12 @@ export class VirtualizedViewManager { } clampRowTransform(transform: number): number { + // The pinned attribute column overlays the right edge of the tree, so the + // tree's visible width is reduced by it. Subtracting it here lets the tree + // scroll far enough to reveal content hidden behind the pinned column. const columnWidth = - this.columns.list.width * this.view.trace_container_physical_space.width; + this.columns.list.width * this.view.trace_container_physical_space.width - + this.pinned_column_width; const max = this.row_measurer.max - columnWidth + this.ROW_PADDING_PX; if (this.row_measurer.queue.length > 0) { @@ -1046,6 +1060,120 @@ export class VirtualizedViewManager { return transform; } + // Registers a pinned attribute column cell. Applies the current shared scroll + // offset (so cells mounted mid-scroll stay in sync) and wires up the wheel + // listener that scrolls the whole column together. Returns a cleanup that + // removes the listener when the (virtualized) cell unmounts, so remounts and + // Strict Mode don't leave handlers attached to detached DOM. + registerPinnedColumnRef(ref: HTMLElement | null) { + if (!ref) { + return; + } + const inner = ref.children[0] as HTMLElement | undefined; + if (inner) { + inner.style.transform = `translateX(${this.pinned_column_translate}px)`; + } + ref.addEventListener('wheel', this.onPinnedColumnScroll, {passive: false}); + return () => { + ref.removeEventListener('wheel', this.onPinnedColumnScroll); + }; + } + + // Max leftward scroll (a positive number) needed to reveal the widest currently + // rendered pinned value. Computed from visible cells so it adapts as the pinned + // attribute changes without needing to reset stored measurements. + getPinnedColumnMaxScroll(): number { + let max = 0; + const cells = document.querySelectorAll('.TraceRow .TracePinnedColumn'); + for (const cell of cells) { + const inner = cell.children[0] as HTMLElement | undefined; + if (inner) { + max = Math.max(max, inner.scrollWidth - cell.clientWidth); + } + } + return max; + } + + clampPinnedColumnTransform(transform: number): number { + if (transform > 0) { + return 0; + } + const max = this.getPinnedColumnMaxScroll(); + if (transform < -max) { + return -max; + } + return transform; + } + + onPinnedColumnScroll(event: WheelEvent) { + // Holding shift key allows for horizontal scrolling + const distance = event.shiftKey + ? getHorizontalDelta(event.deltaX, event.deltaY) + : event.deltaX; + + if ( + event.shiftKey || + (!event.shiftKey && Math.abs(event.deltaX) > Math.abs(event.deltaY)) + ) { + // Prevents firing back/forward navigation + event.preventDefault(); + } else { + // Let vertical wheel scroll the list as usual + return; + } + + const newTransform = this.clampPinnedColumnTransform( + this.pinned_column_translate - distance + ); + if (newTransform === this.pinned_column_translate) { + return; + } + this.pinned_column_translate = newTransform; + this.applyPinnedColumnTransform(); + } + + applyPinnedColumnTransform() { + const inners = document.querySelectorAll( + '.TraceRow .TracePinnedColumn > div' + ); + for (const inner of inners) { + inner.style.transform = `translateX(${this.pinned_column_translate}px)`; + } + } + + // Resets the pinned column scroll offset, e.g. when the pinned attribute changes. + resetPinnedColumnScroll() { + this.pinned_column_translate = 0; + this.applyPinnedColumnTransform(); + } + + // Updates the pinned column width (0 when unpinned) and re-clamps the tree's + // horizontal scroll so its content is reachable around the now (un)covered area. + setPinnedColumnWidth(width: number) { + if (this.pinned_column_width === width) { + return; + } + this.pinned_column_width = width; + + const clamped = this.clampRowTransform(this.columns.list.translate[0]); + if (clamped !== this.columns.list.translate[0]) { + this.columns.list.translate[0] = clamped; + const rows = document.querySelectorAll( + '.TraceRow .TraceLeftColumn > div' + ); + for (const row of rows) { + row.style.transform = `translateX(${clamped}px)`; + } + if (this.horizontal_scrollbar_container) { + this.horizontal_scrollbar_container.scrollLeft = -Math.round(clamped); + } + } + + // Refresh the horizontal scrollbar width, which also depends on the pinned + // column width. + this.draw(); + } + getCompressedView(): {left: number; right: number; width: number} { if (this._compressedViewCache) { return this._compressedViewCache; @@ -2182,6 +2310,13 @@ export class VirtualizedViewManager { ); this.last_span_column_width = options.span_list_width; } + // Exposed so wrapper-positioned overlays (the pinned column header + border + // line) can offset by the vertical scrollbar width and stay aligned with the + // per-row cells, which live inside the scrollbar-narrowed scroll container. + if (this.last_scrollbar_width_var !== this.scrollbar_width) { + container.style.setProperty('--trace-scrollbar-width', `${this.scrollbar_width}px`); + this.last_scrollbar_width_var = this.scrollbar_width; + } if (this.indicator_container) { const correction = @@ -2206,8 +2341,13 @@ export class VirtualizedViewManager { ) / 10; if (this.horizontal_scrollbar_container) { + // The pinned column covers the right edge of the tree, so the scrollbar's + // viewport (and thus its scroll range) is the tree width minus that column. this.horizontal_scrollbar_container.style.width = - (dividerPosition / this.view.trace_container_physical_space.width) * 100 + '%'; + ((dividerPosition - this.pinned_column_width) / + this.view.trace_container_physical_space.width) * + 100 + + '%'; } if (this.divider) { @@ -2218,6 +2358,7 @@ export class VirtualizedViewManager { last_list_column_width = 0; last_span_column_width = 0; + last_scrollbar_width_var = -1; drawInvisibleBars() { for (let i = 0; i < this.invisible_bars.length; i++) { const invisible_bar = this.invisible_bars[i]; diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx index 24a649586226..f3564700b773 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx @@ -77,6 +77,7 @@ export function TraceAutogroupedRow(
+ {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceCollapsedRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceCollapsedRow.tsx index d700d53ef785..ff91e239623b 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceCollapsedRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceCollapsedRow.tsx @@ -61,6 +61,7 @@ export function TraceCollapsedRow(props: TraceRowProps) { : null} + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceEAPSpanRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceEAPSpanRow.tsx index 14d308775c1e..5e01823c7f7f 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceEAPSpanRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceEAPSpanRow.tsx @@ -153,6 +153,7 @@ export function TraceEAPSpanRow(props: TraceRowProps) { + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx index eb4df65455a9..b0570b769f77 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx @@ -75,6 +75,7 @@ export function TraceErrorRow(props: TraceRowProps) { ) : null} + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx index ed18348190b0..cdd4efcd788d 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx @@ -56,6 +56,7 @@ export function TraceMissingInstrumentationRow( + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx index fde1a09d2051..10840ad03607 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx @@ -64,6 +64,7 @@ export function TraceRootRow(props: TraceRowProps) { className={props.spanColumnClassName} onDoubleClick={props.onRowDoubleClick} /> + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx index 108d5e6cf3ad..40df861a8923 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx @@ -33,6 +33,11 @@ export interface TraceRowProps { onRowKeyDown: (e: React.KeyboardEvent) => void; onSpanArrowClick: (e: React.MouseEvent) => void; onZoomIn: (e: React.MouseEvent) => void; + /** + * The pinned attribute column, rendered as a direct child of `.TraceRow` + * (never inside `.TraceLeftColumn`). Null when no attribute is pinned. + */ + pinnedColumns: React.ReactNode; previouslyFocusedNodeRef: React.MutableRefObject; projects: Record; registerListColumnRef: (e: HTMLDivElement | null) => void; diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx index e4a02c9d9e87..8ac7e1b8b8bd 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx @@ -108,6 +108,7 @@ export function TraceSpanRow(props: TraceRowProps) { + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx index bc280a4e798c..565abcb4c11e 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx @@ -105,6 +105,7 @@ export function TraceTransactionRow(props: TraceRowProps) { + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckNode.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckNode.tsx index ba6fc3f53d84..b6361850d4f4 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckNode.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckNode.tsx @@ -102,6 +102,7 @@ export function TraceUptimeCheckNodeRow(props: TraceRowProps) { + {props.pinnedColumns} ); } diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckTimingNode.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckTimingNode.tsx index 86b047e27fc0..0f4cc1aeda5f 100644 --- a/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckTimingNode.tsx +++ b/static/app/views/performance/newTraceDetails/traceRow/traceUptimeCheckTimingNode.tsx @@ -102,6 +102,7 @@ export function TraceUptimeCheckTimingNodeRow( + {props.pinnedColumns} ); }