From 2ee33f0e1ff97911804cecfb59b602af0810468c Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 3 Jul 2026 15:01:21 -0300 Subject: [PATCH 01/21] feat(trace): Add pinnable attribute column to trace waterfall Let users pin one span attribute from the drawer's Attributes section. The pinned attribute is loaded via the trace endpoint's additional_attributes param (unless already requested) and rendered as a new column between the trace tree and the duration waterfall, with a header cell to unpin it. The pinned attribute is stored in the URL (pinnedAttribute query param) so it is shareable and survives reload. The column is absolutely positioned at the divider boundary and consumes no flex width, leaving the span-bar coordinate system and divider math untouched. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/index.tsx | 36 ++++- .../performance/newTraceDetails/trace.tsx | 83 +++++++++++ .../span/eapSections/attributes.spec.tsx | 95 ++++++++++++ .../details/span/eapSections/attributes.tsx | 40 +++++- .../tracePinnedAttribute.spec.tsx | 135 ++++++++++++++++++ .../newTraceDetails/tracePinnedAttribute.tsx | 111 ++++++++++++++ .../traceRow/traceAutogroupedRow.tsx | 1 + .../traceRow/traceEAPSpanRow.tsx | 1 + .../traceRow/traceErrorRow.tsx | 1 + .../traceMissingInstrumentationRow.tsx | 1 + .../traceRow/traceRootNode.tsx | 1 + .../newTraceDetails/traceRow/traceRow.tsx | 5 + .../newTraceDetails/traceRow/traceSpanRow.tsx | 1 + .../traceRow/traceTransactionRow.tsx | 1 + .../traceRow/traceUptimeCheckNode.tsx | 1 + .../traceRow/traceUptimeCheckTimingNode.tsx | 1 + 16 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.spec.tsx create mode 100644 static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx create mode 100644 static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index e95a84f2a948..39417e9a4dfe 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -51,6 +51,7 @@ import { import {TraceStateProvider} from './traceState/traceStateProvider'; import {ErrorsOnlyWarnings} from './traceTypeWarnings/errorsOnlyWarnings'; import {TraceMetaDataHeader} from './traceHeader'; +import {useTracePinnedAttribute} from './tracePinnedAttribute'; import {useInitialTraceMetricData} from './useInitialTraceMetricData'; import {useTraceEventView} from './useTraceEventView'; import {useTraceQueryParams} from './useTraceQueryParams'; @@ -71,6 +72,19 @@ function decodeTraceSlug(maybeSlug: string | undefined): string { const TRACE_VIEW_PREFERENCES_KEY = 'trace-waterfall-preferences'; +/** + * Attributes that are always requested from the trace endpoint so the waterfall + * can render things like http errors and gen_ai enrichment. A pinned attribute + * that is already in this list does not trigger a refetch. + */ +export const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ + 'thread.id', + 'tags[performance.timeOrigin,number]', + 'gen_ai.operation.type', + 'http.response.status_code', + 'span.status', +]; + export default function TraceView() { const organization = useOrganization(); const params = useParams<{traceSlug?: string}>(); @@ -137,16 +151,24 @@ function TraceViewImplInner({traceSlug}: {traceSlug: string}) { metaMetricsCount === undefined ? metricsData : {count: metaMetricsCount}; const hideTraceWaterfallIfEmpty = (logsData?.length ?? 0) > 0; + const {pinnedAttribute} = useTracePinnedAttribute(); + // Merge the pinned attribute into the default request set (deduped + sorted so + // the react-query key is stable and toggling unrelated state never refetches). + // A pinned attribute already in the default set leaves the set unchanged, so no + // refetch happens ("unless already loaded"); a new one changes the key and the + // trace is refetched with the extra attribute. + const additionalAttributes = useMemo(() => { + const attributes = new Set(DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES); + if (pinnedAttribute) { + attributes.add(pinnedAttribute); + } + return Array.from(attributes).sort(); + }, [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..55eacc6fd6fb 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -61,6 +61,11 @@ import { type RovingTabIndexUserActions, } from './traceState/traceRovingTabIndex'; import {useTraceState, useTraceStateDispatch} from './traceState/traceStateProvider'; +import { + TracePinnedAttributeColumn, + TracePinnedAttributeHeader, + useTracePinnedAttribute, +} from './tracePinnedAttribute'; import type {TraceReducerState} from './traceState'; const traceIssueIconBackgroundStyles = css` @@ -156,6 +161,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); @@ -410,6 +416,7 @@ export function Trace({ onRowKeyDown={onRowKeyDown} tree={trace} trace_id={trace_id} + pinnedAttribute={pinnedAttribute} /> ); }, @@ -431,6 +438,7 @@ export function Trace({ theme, trace.type, forceRerender, + pinnedAttribute, ] ); @@ -468,6 +476,9 @@ export function Trace({
+ {pinnedAttribute ? ( + + ) : null}
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 +702,10 @@ function RenderTraceRow(props: { paddingLeft: TraceTree.Depth(node) * props.manager.row_depth_padding, }; + const pinnedColumns = props.pinnedAttribute ? ( + + ) : null; + const rowProps: TraceRowProps = { onExpand, onZoomIn, @@ -715,6 +731,7 @@ function RenderTraceRow(props: { registerListColumnRef, registerSpanColumnRef, registerSpanArrowRef, + pinnedColumns, }; return node.renderWaterfallRow(rowProps); @@ -1645,6 +1662,72 @@ 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). + */ + .TracePinnedColumnHeader { + position: absolute; + top: 0; + height: 38px; + right: calc(var(--span-column-width) * 100% + 3px); + z-index: 10; + display: flex; + align-items: center; + gap: ${p => p.theme.space.xs}; + padding: 0 ${p => p.theme.space.md}; + background-color: ${p => p.theme.tokens.background.primary}; + border-left: 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; + } + } + + .TracePinnedColumn { + position: absolute; + top: 0; + height: 100%; + right: calc(var(--span-column-width) * 100% + 3px); + z-index: 2; + display: flex; + align-items: center; + padding: 0 ${p => p.theme.space.md}; + background-color: ${p => p.theme.tokens.background.primary}; + border-left: 1px solid ${p => p.theme.tokens.border.primary}; + + .TracePinnedColumnValue { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; + + &.Empty { + color: ${p => p.theme.tokens.content.secondary}; + } + } + } + + .TraceRow:focus .TracePinnedColumn, + .TraceRow[tabindex='0'] .TracePinnedColumn, + .TraceRow.Highlight .TracePinnedColumn { + background-color: var(--row-background-focused); + } + + .TraceRow.SearchResult .TracePinnedColumn { + background-color: ${p => p.theme.colors.yellow100}; + } + .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..14ee4262f443 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.spec.tsx @@ -0,0 +1,95 @@ +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 as column')); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBe('environment'); + }); + }); + + it('shows "Unpin column" when the attribute is already pinned', async () => { + renderContent({pinnedAttribute: 'environment'}); + + await openAttributeMenu(); + + expect(await screen.findByText('Unpin column')).toBeInTheDocument(); + expect(screen.queryByText('Pin as column')).not.toBeInTheDocument(); + }); + + it('unpins the attribute when "Unpin column" is clicked', async () => { + const {router} = renderContent({pinnedAttribute: 'environment'}); + + await openAttributeMenu(); + await userEvent.click(await screen.findByText('Unpin column')); + + await waitFor(() => { + expect(router.location.query.pinnedAttribute).toBeUndefined(); + }); + }); +}); 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..0f57b12105db 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 @@ -7,6 +7,7 @@ import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; +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'; @@ -25,7 +26,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 +45,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 +107,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 +233,32 @@ export function AttributesContent({ customRenderers[attribute] = truncatedTextRenderer; } + // 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 column') : t('Pin as column'), + onAction: () => setPinnedAttribute(isPinned ? null : attributeKey), + }, + ]; + }; + return (
) : ( 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..b7d252576d08 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx @@ -0,0 +1,135 @@ +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 { + TracePinnedAttributeColumn, + TracePinnedAttributeHeader, + useTracePinnedAttribute, +} from 'sentry/views/performance/newTraceDetails/tracePinnedAttribute'; + +function makeNode(additional_attributes?: Record) { + return new EapSpanNode(null, makeEAPSpan({additional_attributes}), { + organization: OrganizationFixture(), + }); +} + +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('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(); + }); +}); + +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..9b2e0b0f1461 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -0,0 +1,111 @@ +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 type {BaseNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/baseNode'; + +/** + * 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 = '—'; + +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. + */ +export function TracePinnedAttributeColumn({ + node, + pinnedAttribute, +}: { + node: BaseNode; + pinnedAttribute: string; +}) { + const value = node.attributes?.[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/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/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} ); } From c76a7c2d9f7fcebccafdecfa5e7836eb3c51a9d4 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 3 Jul 2026 15:13:15 -0300 Subject: [PATCH 02/21] perf(trace): Skip requesting pinned attributes already in the trace response The trace endpoint already returns a fixed set of native span fields (span.op, span.duration, measurements, web vitals, etc.) plus the default additional attributes. Pinning one of those previously added it to the additional_attributes request, triggering a redundant full-trace refetch for data already present. Exclude those keys from the request and resolve the pinned column's value from the native span fields when the attribute is not requested, so excluded attributes still render. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/index.tsx | 37 ++--- .../tracePinnedAttribute.spec.tsx | 40 +++++ .../newTraceDetails/tracePinnedAttribute.tsx | 155 +++++++++++++++++- 3 files changed, 205 insertions(+), 27 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index 39417e9a4dfe..16e99b164380 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -51,7 +51,10 @@ import { import {TraceStateProvider} from './traceState/traceStateProvider'; import {ErrorsOnlyWarnings} from './traceTypeWarnings/errorsOnlyWarnings'; import {TraceMetaDataHeader} from './traceHeader'; -import {useTracePinnedAttribute} from './tracePinnedAttribute'; +import { + getTraceAdditionalAttributes, + useTracePinnedAttribute, +} from './tracePinnedAttribute'; import {useInitialTraceMetricData} from './useInitialTraceMetricData'; import {useTraceEventView} from './useTraceEventView'; import {useTraceQueryParams} from './useTraceQueryParams'; @@ -72,19 +75,6 @@ function decodeTraceSlug(maybeSlug: string | undefined): string { const TRACE_VIEW_PREFERENCES_KEY = 'trace-waterfall-preferences'; -/** - * Attributes that are always requested from the trace endpoint so the waterfall - * can render things like http errors and gen_ai enrichment. A pinned attribute - * that is already in this list does not trigger a refetch. - */ -export const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ - 'thread.id', - 'tags[performance.timeOrigin,number]', - 'gen_ai.operation.type', - 'http.response.status_code', - 'span.status', -]; - export default function TraceView() { const organization = useOrganization(); const params = useParams<{traceSlug?: string}>(); @@ -152,18 +142,13 @@ function TraceViewImplInner({traceSlug}: {traceSlug: string}) { const hideTraceWaterfallIfEmpty = (logsData?.length ?? 0) > 0; const {pinnedAttribute} = useTracePinnedAttribute(); - // Merge the pinned attribute into the default request set (deduped + sorted so - // the react-query key is stable and toggling unrelated state never refetches). - // A pinned attribute already in the default set leaves the set unchanged, so no - // refetch happens ("unless already loaded"); a new one changes the key and the - // trace is refetched with the extra attribute. - const additionalAttributes = useMemo(() => { - const attributes = new Set(DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES); - if (pinnedAttribute) { - attributes.add(pinnedAttribute); - } - return Array.from(attributes).sort(); - }, [pinnedAttribute]); + // 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, diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx index b7d252576d08..2876aa5d13e4 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx @@ -13,6 +13,7 @@ import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAt import {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import {makeEAPSpan} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeTestUtils'; import { + getTraceAdditionalAttributes, TracePinnedAttributeColumn, TracePinnedAttributeHeader, useTracePinnedAttribute, @@ -75,6 +76,34 @@ describe('useTracePinnedAttribute', () => { }); }); +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', () => { + // span.op / span.duration are returned as native span fields. + expect(getTraceAdditionalAttributes('span.op')).not.toContain('span.op'); + 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}); @@ -101,6 +130,17 @@ describe('TracePinnedAttributeColumn', () => { expect(screen.getByText('—')).toBeInTheDocument(); }); + + it('reads native span fields for attributes not in additional_attributes', () => { + // span.op is returned as a native span field, not in additional_attributes. + const node = new EapSpanNode(null, makeEAPSpan({op: 'db.query'}), { + organization: OrganizationFixture(), + }); + + render(); + + expect(screen.getByText('db.query')).toBeInTheDocument(); + }); }); describe('TracePinnedAttributeHeader', () => { diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx index 9b2e0b0f1461..be0d87905263 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -9,6 +9,8 @@ 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'; /** @@ -25,6 +27,157 @@ 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. + */ +export const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ + 'thread.id', + 'tags[performance.timeOrigin,number]', + 'gen_ai.operation.type', + 'http.response.status_code', + 'span.status', +]; + +/** + * Attributes the trace endpoint already returns as native span fields, so they + * do NOT need to be requested via additional_attributes. Mirrors the attribute + * list in `src/sentry/snuba/spans_rpc.py` `run_trace_query`. If this drifts from + * the backend the only cost is a redundant refetch (stale entry) or an empty + * cell (removed entry), never a crash. + */ +const TRACE_RESPONSE_NATIVE_ATTRIBUTES = new Set([ + 'parent_span', + 'description', + 'span.op', + 'span.name', + 'is_transaction', + 'transaction.span_id', + 'transaction.event_id', + 'transaction', + 'precise.start_ts', + 'precise.finish_ts', + 'project.id', + 'profile.id', + 'profiler.id', + 'span.duration', + 'sdk.name', + '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', +]); + +/** + * Native trace-response attribute keys that map to a top-level field on the span + * value (as opposed to living in a measurements/vital sub-dict). Used to read a + * pinned attribute's value when it is not requested via additional_attributes. + */ +const NATIVE_ATTRIBUTE_FIELDS: Record = { + 'span.op': 'op', + 'span.name': 'name', + 'span.duration': 'duration', + description: 'description', + transaction: 'transaction', + 'sdk.name': 'sdk_name', + 'profile.id': 'profile_id', + 'profiler.id': 'profiler_id', + 'project.id': 'project_id', + is_transaction: 'is_transaction', + 'transaction.event_id': 'transaction_id', + parent_span: 'parent_span_id', + 'precise.start_ts': 'start_timestamp', + 'precise.finish_ts': 'end_timestamp', +}; + +/** + * Whether the trace endpoint already returns this attribute (either 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. + */ +export function isTraceResponseAttribute(key: string): boolean { + return ( + TRACE_RESPONSE_NATIVE_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). + */ +export 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; @@ -70,7 +223,7 @@ export function TracePinnedAttributeColumn({ node: BaseNode; pinnedAttribute: string; }) { - const value = node.attributes?.[pinnedAttribute]; + const value = getPinnedAttributeValue(node, pinnedAttribute); const hasValue = value !== undefined && value !== null && value !== ''; const displayValue = hasValue ? String(value) : EMPTY_VALUE; From 793dffaa963086984102b4d641abf02891e0794f Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 3 Jul 2026 15:20:17 -0300 Subject: [PATCH 03/21] fix(trace): Key native pinned attributes by their drawer name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exclusion set used the backend column names from spans_rpc.py (e.g. description), but the drawer pins the user-facing attribute name (span.description per SpanFields). As a result span.description — and other span-prefixed native fields — were still added to the additional_attributes request even though the trace response already includes them. Key the native-attribute map by the drawer/user-facing names so both the request exclusion and the column value lookup use the same key, and keep measurements as an exact set so custom measurements are still requested. Refs EXP-1077 Co-Authored-By: Claude --- .../tracePinnedAttribute.spec.tsx | 27 +++++-- .../newTraceDetails/tracePinnedAttribute.tsx | 74 +++++++------------ 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx index 2876aa5d13e4..749d96822773 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx @@ -96,8 +96,11 @@ describe('getTraceAdditionalAttributes', () => { }); it('does not request a pinned attribute the trace response returns natively', () => { - // span.op / span.duration are returned as native span fields. + // 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' ); @@ -132,15 +135,27 @@ describe('TracePinnedAttributeColumn', () => { }); it('reads native span fields for attributes not in additional_attributes', () => { - // span.op is returned as a native span field, not in additional_attributes. - const node = new EapSpanNode(null, makeEAPSpan({op: 'db.query'}), { - organization: OrganizationFixture(), - }); + // 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', () => { diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx index be0d87905263..4d77187360ba 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -40,28 +40,31 @@ export const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ ]; /** - * Attributes the trace endpoint already returns as native span fields, so they - * do NOT need to be requested via additional_attributes. Mirrors the attribute - * list in `src/sentry/snuba/spans_rpc.py` `run_trace_query`. If this drifts from - * the backend the only cost is a redundant refetch (stale entry) or an empty - * cell (removed entry), never a crash. + * 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 TRACE_RESPONSE_NATIVE_ATTRIBUTES = new Set([ - 'parent_span', - 'description', - 'span.op', - 'span.name', - 'is_transaction', - 'transaction.span_id', - 'transaction.event_id', - 'transaction', - 'precise.start_ts', - 'precise.finish_ts', - 'project.id', - 'profile.id', - 'profiler.id', - 'span.duration', - 'sdk.name', +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', @@ -90,35 +93,14 @@ const TRACE_RESPONSE_NATIVE_ATTRIBUTES = new Set([ ]); /** - * Native trace-response attribute keys that map to a top-level field on the span - * value (as opposed to living in a measurements/vital sub-dict). Used to read a - * pinned attribute's value when it is not requested via additional_attributes. - */ -const NATIVE_ATTRIBUTE_FIELDS: Record = { - 'span.op': 'op', - 'span.name': 'name', - 'span.duration': 'duration', - description: 'description', - transaction: 'transaction', - 'sdk.name': 'sdk_name', - 'profile.id': 'profile_id', - 'profiler.id': 'profiler_id', - 'project.id': 'project_id', - is_transaction: 'is_transaction', - 'transaction.event_id': 'transaction_id', - parent_span: 'parent_span_id', - 'precise.start_ts': 'start_timestamp', - 'precise.finish_ts': 'end_timestamp', -}; - -/** - * Whether the trace endpoint already returns this attribute (either as a native - * span field or one of the always-requested default attributes). Pinning such an + * 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. */ export function isTraceResponseAttribute(key: string): boolean { return ( - TRACE_RESPONSE_NATIVE_ATTRIBUTES.has(key) || + key in NATIVE_ATTRIBUTE_FIELDS || + TRACE_RESPONSE_MEASUREMENT_ATTRIBUTES.has(key) || DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES.includes(key) ); } From 24d4cffb2b4743215d4a5b9e6e8b83fa0dee29b2 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 3 Jul 2026 15:26:50 -0300 Subject: [PATCH 04/21] fix(trace): Close gap between pinned column and the divider The pinned attribute column sat 3px left of the divider with a full space.md right inset, leaving a visible gap between the value and the divider line. Anchor the column flush to the divider (it is below the divider's z-index, so drag still works) and tighten the right padding. Refs EXP-1077 Co-Authored-By: Claude --- static/app/views/performance/newTraceDetails/trace.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 55eacc6fd6fb..94350ddbaec6 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1672,12 +1672,13 @@ const TraceStylingWrapper = styled('div')` position: absolute; top: 0; height: 38px; - right: calc(var(--span-column-width) * 100% + 3px); + right: calc(var(--span-column-width) * 100%); z-index: 10; display: flex; align-items: center; gap: ${p => p.theme.space.xs}; - padding: 0 ${p => p.theme.space.md}; + padding-left: ${p => p.theme.space.md}; + padding-right: ${p => p.theme.space.xs}; background-color: ${p => p.theme.tokens.background.primary}; border-left: 1px solid ${p => p.theme.tokens.border.primary}; border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; @@ -1697,11 +1698,12 @@ const TraceStylingWrapper = styled('div')` position: absolute; top: 0; height: 100%; - right: calc(var(--span-column-width) * 100% + 3px); + right: calc(var(--span-column-width) * 100%); z-index: 2; display: flex; align-items: center; - padding: 0 ${p => p.theme.space.md}; + padding-left: ${p => p.theme.space.md}; + padding-right: ${p => p.theme.space.sm}; background-color: ${p => p.theme.tokens.background.primary}; border-left: 1px solid ${p => p.theme.tokens.border.primary}; From e8bd5d151193b4a7f1cb113dbed7a3fa37d17f3c Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:22:10 -0300 Subject: [PATCH 05/21] fix(trace): Add right border to pinned attribute column header The header cell only had a left border. Unlike the per-row cells (whose right edge is covered by the divider), the header sits in the top band above the divider and needs its own right border to close the column outline. Refs EXP-1077 Co-Authored-By: Claude --- static/app/views/performance/newTraceDetails/trace.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 94350ddbaec6..b959d38a04fb 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1681,6 +1681,7 @@ const TraceStylingWrapper = styled('div')` padding-right: ${p => p.theme.space.xs}; background-color: ${p => p.theme.tokens.background.primary}; border-left: 1px solid ${p => p.theme.tokens.border.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}; From 4444b6901ce961cf0658946928cfefb819160574 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:31:32 -0300 Subject: [PATCH 06/21] fix(trace): Draw pinned column left border full height The pinned column's left border was drawn per-row, so it stopped after the last row while the divider on the right continued full height, leaving the left edge open below the rows. Draw the left border once as a full-height line (mirroring the divider) and drop the per-row and header left borders so they don't double up. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/trace.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index b959d38a04fb..7967957f6510 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -62,6 +62,7 @@ import { } from './traceState/traceRovingTabIndex'; import {useTraceState, useTraceStateDispatch} from './traceState/traceStateProvider'; import { + PINNED_COLUMN_WIDTH, TracePinnedAttributeColumn, TracePinnedAttributeHeader, useTracePinnedAttribute, @@ -477,7 +478,10 @@ export function Trace({
{pinnedAttribute ? ( - + +
+ + ) : null}
p.theme.tokens.border.primary}; + z-index: 10; + pointer-events: none; + } + .TracePinnedColumnHeader { position: absolute; top: 0; @@ -1680,7 +1701,6 @@ const TraceStylingWrapper = styled('div')` padding-left: ${p => p.theme.space.md}; padding-right: ${p => p.theme.space.xs}; background-color: ${p => p.theme.tokens.background.primary}; - border-left: 1px solid ${p => p.theme.tokens.border.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}; @@ -1706,7 +1726,6 @@ const TraceStylingWrapper = styled('div')` padding-left: ${p => p.theme.space.md}; padding-right: ${p => p.theme.space.sm}; background-color: ${p => p.theme.tokens.background.primary}; - border-left: 1px solid ${p => p.theme.tokens.border.primary}; .TracePinnedColumnValue { overflow: hidden; From 2a808dab4f849cbcaf6595bc59d330db4cde0bc8 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:40:39 -0300 Subject: [PATCH 07/21] feat(trace): Horizontally scroll the pinned attribute column as one unit Pinned values wider than the column were only reachable via the hover tooltip. Wire up horizontal scrolling for the whole column at once (mirroring the tree column): the view manager holds a single shared translate that is applied to every cell's inner element and to cells mounted mid-scroll, so the column moves together rather than one row at a time. Shift+wheel or horizontal wheel scrolls; vertical wheel still scrolls the list. Keep the horizontal padding on the inner element so the cell's clientWidth equals the full column width and the max-scroll clamp reveals the trailing edge of the value instead of stopping a few pixels short. Reset the offset when the pinned attribute changes. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/trace.tsx | 34 +++++-- .../tracePinnedAttribute.spec.tsx | 24 ++++- .../newTraceDetails/tracePinnedAttribute.tsx | 26 ++++-- .../traceRenderers/virtualizedViewManager.tsx | 88 +++++++++++++++++++ 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 7967957f6510..2f7e394a6c2a 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -189,6 +189,12 @@ 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. + useLayoutEffect(() => { + manager.resetPinnedColumnScroll(); + }, [manager, pinnedAttribute]); + const visibleTraceItems = useMemo( () => snapshotVisibleTraceItems(trace.list, forceRerender), [trace.list, forceRerender] @@ -707,7 +713,11 @@ function RenderTraceRow(props: { }; const pinnedColumns = props.pinnedAttribute ? ( - + ) : null; const rowProps: TraceRowProps = { @@ -1723,16 +1733,26 @@ const TraceStylingWrapper = styled('div')` z-index: 2; display: flex; align-items: center; - padding-left: ${p => p.theme.space.md}; - padding-right: ${p => p.theme.space.sm}; + overflow: hidden; background-color: ${p => p.theme.tokens.background.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 { - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - min-width: 0; - flex: 1 1 auto; &.Empty { color: ${p => p.theme.tokens.content.secondary}; diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx index 749d96822773..bc49f15f452c 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.spec.tsx @@ -18,6 +18,7 @@ import { 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}), { @@ -25,6 +26,11 @@ function makeNode(additional_attributes?: Record) { }); } +// 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, { @@ -115,6 +121,7 @@ describe('TracePinnedAttributeColumn', () => { ); @@ -128,6 +135,7 @@ describe('TracePinnedAttributeColumn', () => { ); @@ -142,7 +150,13 @@ describe('TracePinnedAttributeColumn', () => { {organization: OrganizationFixture()} ); - render(); + render( + + ); expect(screen.getByText('db.query')).toBeInTheDocument(); }); @@ -153,7 +167,13 @@ describe('TracePinnedAttributeColumn', () => { {organization: OrganizationFixture()} ); - render(); + render( + + ); expect(screen.getByText('SELECT * FROM users')).toBeInTheDocument(); }); }); diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx index 4d77187360ba..83c5aec7883b 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -12,6 +12,7 @@ import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAt 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 @@ -197,11 +198,16 @@ export function useTracePinnedAttribute(): UseTracePinnedAttribute { * 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; }) { @@ -210,13 +216,19 @@ export function TracePinnedAttributeColumn({ const displayValue = hasValue ? String(value) : EMPTY_VALUE; return ( -
- - {displayValue} - +
+
+ + {displayValue} + +
); } diff --git a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx index 3683a19d6635..919c1f6b3e9d 100644 --- a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx +++ b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx @@ -88,6 +88,10 @@ 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; + // HTML refs that we need to keep track of such // that rendering can be done programmatically reset_zoom_button: HTMLButtonElement | null = null; @@ -205,6 +209,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); @@ -1046,6 +1052,88 @@ 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. + 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}); + } + + // 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(); + } + getCompressedView(): {left: number; right: number; width: number} { if (this._compressedViewCache) { return this._compressedViewCache; From 746c2efe5f3d960178a8f4c7c3f4748fe339a92d Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:43:37 -0300 Subject: [PATCH 08/21] fix(trace): Make pinned column text uniform and bold the header name The pinned column inherited the row's semantic text color (error/warning) and matched the row's focus/search highlight background, making it visually inconsistent. Force a uniform content color and drop the row-state background overrides so the column reads as one column, and bold the pinned attribute name in the header. Refs EXP-1077 Co-Authored-By: Claude --- .../views/performance/newTraceDetails/trace.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 2f7e394a6c2a..3b1a4b6e2e34 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1722,6 +1722,8 @@ const TraceStylingWrapper = styled('div')` 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}; } } @@ -1735,6 +1737,9 @@ const TraceStylingWrapper = styled('div')` align-items: center; overflow: hidden; background-color: ${p => p.theme.tokens.background.primary}; + /* Keep the column visually uniform instead of inheriting the row's semantic + (error/warning) text color or highlight background. */ + 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 @@ -1760,16 +1765,6 @@ const TraceStylingWrapper = styled('div')` } } - .TraceRow:focus .TracePinnedColumn, - .TraceRow[tabindex='0'] .TracePinnedColumn, - .TraceRow.Highlight .TracePinnedColumn { - background-color: var(--row-background-focused); - } - - .TraceRow.SearchResult .TracePinnedColumn { - background-color: ${p => p.theme.colors.yellow100}; - } - .TraceBar { position: absolute; height: 16px; From ba63343669ced2e1124f6ab89cd9803fecb3869c Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:50:11 -0300 Subject: [PATCH 09/21] fix(trace): Account for pinned column width in tree horizontal scroll The pinned attribute column overlays the right edge of the trace tree, so the tree could no longer scroll far enough to reveal content hidden behind it. Treat the tree's visible width as the list column width minus the pinned column width when clamping its horizontal scroll and sizing the horizontal scrollbar, and re-clamp when the pinned attribute is toggled. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/trace.tsx | 4 +- .../traceRenderers/virtualizedViewManager.tsx | 44 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 3b1a4b6e2e34..5b84e0b7f6e6 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -190,8 +190,10 @@ export function Trace({ ); // Reset the pinned column's horizontal scroll when the pinned attribute changes - // so a new (potentially shorter) attribute isn't rendered mid-scroll. + // 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]); diff --git a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx index 919c1f6b3e9d..65362656649a 100644 --- a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx +++ b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx @@ -91,6 +91,10 @@ export class VirtualizedViewManager { // 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 @@ -1029,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) { @@ -1134,6 +1142,33 @@ export class VirtualizedViewManager { 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; @@ -2294,8 +2329,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) { From 652daa402a216564ce67d6a584163b6a499827e6 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 07:54:34 -0300 Subject: [PATCH 10/21] feat(trace): Show a pin indicator on the pinned attribute in the drawer The drawer's attribute table gave no indication of which attribute was pinned as a column. Add a pin icon next to the pinned attribute's value (composing with any existing renderer) so the pinned attribute is identifiable at a glance. Refs EXP-1077 Co-Authored-By: Claude --- .../span/eapSections/attributes.spec.tsx | 13 +++++++++++++ .../details/span/eapSections/attributes.tsx | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) 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 index 14ee4262f443..9f7499a2255e 100644 --- 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 @@ -92,4 +92,17 @@ describe('AttributesContent pin action', () => { 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 0f57b12105db..b9bbb8b8d829 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 @@ -3,15 +3,17 @@ import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import type {Location, LocationDescriptorObject} from 'history'; -import {Stack} from '@sentry/scraps/layout'; +import {Flex, 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'; @@ -233,6 +235,20 @@ export function AttributesContent({ customRenderers[attribute] = truncatedTextRenderer; } + // Indicate the pinned attribute with a pin icon next to its value, composing + // with any existing renderer for that attribute. + if (pinnedAttribute) { + const existingRenderer = customRenderers[pinnedAttribute]; + customRenderers[pinnedAttribute] = (props: CustomRenderersProps) => ( + + {existingRenderer ? existingRenderer(props) : props.basicRendered} + + + + + ); + } + // 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). From 29167af0d37688580e6ba43d3629c18fd7fda99c Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 08:02:49 -0300 Subject: [PATCH 11/21] feat(trace): Move pinned attribute indicator next to the attribute name Move the pin indicator from the attribute value to the attribute name. Add a generic getAttributeKeySuffix render prop to AttributesTree that renders content after the key, and use it from the trace drawer to show the pin icon next to the pinned attribute's name. Refs EXP-1077 Co-Authored-By: Claude --- .../traceItemAttributes/attributesTree.tsx | 13 ++++++++ .../details/span/eapSections/attributes.tsx | 30 ++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) 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/traceDrawer/details/span/eapSections/attributes.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx index b9bbb8b8d829..58b7e2e7507e 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 @@ -3,7 +3,7 @@ import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import type {Location, LocationDescriptorObject} from 'history'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -235,19 +235,20 @@ export function AttributesContent({ customRenderers[attribute] = truncatedTextRenderer; } - // Indicate the pinned attribute with a pin icon next to its value, composing - // with any existing renderer for that attribute. - if (pinnedAttribute) { - const existingRenderer = customRenderers[pinnedAttribute]; - customRenderers[pinnedAttribute] = (props: CustomRenderersProps) => ( - - {existingRenderer ? existingRenderer(props) : props.basicRendered} + // 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 @@ -296,6 +297,7 @@ export function AttributesContent({ organization, }} getCustomActions={getCustomActions} + getAttributeKeySuffix={getAttributeKeySuffix} />
) : ( @@ -313,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: text-bottom; +`; + const NoAttributesMessage = styled('div')` display: flex; justify-content: center; From 76b2c10ec52673549babe7fd78838c4d693e413d Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 08:08:07 -0300 Subject: [PATCH 12/21] fix(trace): Vertically center the pinned attribute pin icon The pin indicator used vertical-align: text-bottom, which sat it below the attribute name. Use vertical-align: middle so it centers with the key text. Refs EXP-1077 Co-Authored-By: Claude --- .../traceDrawer/details/span/eapSections/attributes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 58b7e2e7507e..94dfa3811aed 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 @@ -320,7 +320,7 @@ const PinnedAttributeIndicator = styled('span')` align-items: center; margin-left: ${p => p.theme.space.xs}; color: ${p => p.theme.tokens.content.secondary}; - vertical-align: text-bottom; + vertical-align: middle; `; const NoAttributesMessage = styled('div')` From 8a02883fc2311b6955db040bf95badb934a94fed Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 09:35:19 -0300 Subject: [PATCH 13/21] fix(trace): Align pinned column header with cells across scrollbar width The pinned column header and left-border line are positioned against the trace wrapper (full width), while the per-row cells live inside the vertically scrolling container, which is narrower by the scrollbar width. With a space-reserving scrollbar this offset them by listFraction * scrollbarWidth. Expose the scrollbar width as a --trace-scrollbar-width CSS variable and add that offset to the header and line, so they line up with the cells regardless of scrollbar style. Refs EXP-1077 Co-Authored-By: Claude --- .../app/views/performance/newTraceDetails/trace.tsx | 13 +++++++++++-- .../traceRenderers/virtualizedViewManager.tsx | 8 ++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 5b84e0b7f6e6..c4776919bc1e 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1694,7 +1694,13 @@ const TraceStylingWrapper = styled('div')` top: 0; height: 100%; width: 1px; - right: calc(var(--span-column-width) * 100% + ${PINNED_COLUMN_WIDTH}px); + /* 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; @@ -1705,7 +1711,10 @@ const TraceStylingWrapper = styled('div')` position: absolute; top: 0; height: 38px; - right: calc(var(--span-column-width) * 100%); + right: calc( + var(--span-column-width) * 100% + var(--list-column-width) * + var(--trace-scrollbar-width, 0px) + ); z-index: 10; display: flex; align-items: center; diff --git a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx index 65362656649a..7c10af3ef5d6 100644 --- a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx +++ b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx @@ -2305,6 +2305,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 = @@ -2346,6 +2353,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]; From 8efb80fa8685c9e79d813d99ed05432158c5b5ca Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 09:35:30 -0300 Subject: [PATCH 14/21] fix(trace): Render pinned column cell on collapsed rows Collapsed rows did not render a pinned column cell, so the column showed the collapsed row's background instead of the uniform column background over those rows. Render the cell (a placeholder for these aggregate rows) for column continuity, matching the root row. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/traceRow/traceCollapsedRow.tsx | 1 + 1 file changed, 1 insertion(+) 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}
); } From 8044d445d5e5267feaf314656a099dbdf861a970 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 09:39:03 -0300 Subject: [PATCH 15/21] fix(trace): Strip pinned attribute param from header breadcrumb links The pinnedAttribute query param is specific to the trace waterfall and is meaningless on breadcrumb destinations (Traces, insights, dashboards, etc.). Add it to the trace-view-only params already omitted from breadcrumb targets so it doesn't leak into the Traces (and other) back-navigation links. Refs EXP-1077 Co-Authored-By: Claude --- .../traceHeader/breadcrumbs.tsx | 5 ++++- .../traceHeader/index.spec.tsx | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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({ From bcfe03c737aaba5bc2d58b18490799f34bf1ff40 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 09:56:52 -0300 Subject: [PATCH 16/21] chore(trace): Drop unused exports on pinned attribute helpers knip flagged three helpers in tracePinnedAttribute as unused exports. They are only referenced within the module, so remove the export keyword rather than the code itself. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/tracePinnedAttribute.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx index 83c5aec7883b..23e395cba868 100644 --- a/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx +++ b/static/app/views/performance/newTraceDetails/tracePinnedAttribute.tsx @@ -32,7 +32,7 @@ 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. */ -export const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ +const DEFAULT_TRACE_ADDITIONAL_ATTRIBUTES = [ 'thread.id', 'tags[performance.timeOrigin,number]', 'gen_ai.operation.type', @@ -98,7 +98,7 @@ const TRACE_RESPONSE_MEASUREMENT_ATTRIBUTES = new Set([ * field or one of the always-requested default attributes). Pinning such an * attribute must NOT add it to the additional_attributes request. */ -export function isTraceResponseAttribute(key: string): boolean { +function isTraceResponseAttribute(key: string): boolean { return ( key in NATIVE_ATTRIBUTE_FIELDS || TRACE_RESPONSE_MEASUREMENT_ATTRIBUTES.has(key) || @@ -126,7 +126,7 @@ export function getTraceAdditionalAttributes(pinnedAttribute: string | null): st * the trace endpoint always returns (so attributes excluded from the request * still render). */ -export function getPinnedAttributeValue( +function getPinnedAttributeValue( node: BaseNode, key: string ): string | number | boolean | undefined { From c16b708a693095e7a30db9fea8458f4b9055f075 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 10:02:05 -0300 Subject: [PATCH 17/21] fix(trace): Remove pinned column wheel listener on unmount registerPinnedColumnRef added a wheel listener when a pinned cell mounted but returned early on the null ref, so unmounting virtualized cells never removed the handler. Remounts and Strict Mode could leave listeners attached to detached DOM. Return a cleanup that removes the listener, keeping mount and unmount symmetric. Refs EXP-1077 Co-Authored-By: Claude --- .../traceRenderers/virtualizedViewManager.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx index 7c10af3ef5d6..f639c22202f2 100644 --- a/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx +++ b/static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx @@ -1062,7 +1062,9 @@ export class VirtualizedViewManager { // 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. + // 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; @@ -1072,6 +1074,9 @@ export class VirtualizedViewManager { 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 From ee0907b8759fe9496596cf470533af49efde98cf Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 10:10:53 -0300 Subject: [PATCH 18/21] fix(trace): Match pinned column background to row highlight states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned attribute column is opaque (it scrolls over the tree), so it kept a plain background while the rest of the row picked up the focused, search-result, and collapsed backgrounds — leaving the pinned cell as a mismatched block. Paint the column with the same background in each of those row states. Text stays uniform so it still doesn't inherit the row's error/warning color. Refs EXP-1077 Co-Authored-By: Claude --- .../performance/newTraceDetails/trace.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index c4776919bc1e..83da58788d71 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1560,6 +1560,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, @@ -1581,6 +1587,10 @@ const TraceStylingWrapper = styled('div')` .TraceRightColumn { background-color: transparent; } + + .TracePinnedColumn { + background-color: ${p => p.theme.colors.yellow100}; + } } &.Autogrouped { @@ -1626,6 +1636,10 @@ const TraceStylingWrapper = styled('div')` padding-left: 0 !important; } } + + .TracePinnedColumn { + background-color: ${p => p.theme.tokens.background.secondary}; + } } } @@ -1748,8 +1762,10 @@ const TraceStylingWrapper = styled('div')` align-items: center; overflow: hidden; background-color: ${p => p.theme.tokens.background.primary}; - /* Keep the column visually uniform instead of inheriting the row's semantic - (error/warning) text color or highlight background. */ + /* 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 From f9083ad80763f6653854ef7907be5b143c35802a Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 10:12:52 -0300 Subject: [PATCH 19/21] fix(trace): Continue row highlight border across pinned column The opaque pinned column sits on top of the row and covered the top and bottom of the row's inset highlight/focus outline, leaving a gap in the blue border. Redraw the top and bottom edges on the pinned cell in the Highlight and focus states; its left and right edges are already drawn by the pinned column line and the divider. Refs EXP-1077 Co-Authored-By: Claude --- .../views/performance/newTraceDetails/trace.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 83da58788d71..3e0a6ab20951 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -1549,6 +1549,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, @@ -1579,6 +1588,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 { From dc3ba161b170b15226f50353e66cbaed3a170a63 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 6 Jul 2026 10:39:51 -0300 Subject: [PATCH 20/21] fix(trace): Hide pinned attribute header while loading The pinned column header and its border line rendered whenever a pinned attribute was set, but the virtualized list swaps in TraceLoadingRow (which has no pinned cell) while the trace loads. During initial load or a refetch after pinning, the header and line were left orphaned above placeholder rows with no matching column cells. Derive the loading condition once and reuse it so the header and line only render alongside real rows. The pinned column is an absolutely-positioned overlay, so hiding it during load reveals the tree underneath rather than leaving an empty gap. Co-Authored-By: Claude Opus 4.8 --- .../performance/newTraceDetails/trace.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 3e0a6ab20951..0949e5f8930f 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -451,11 +451,17 @@ export function Trace({ ] ); + // 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]; @@ -475,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 ? ( + {pinnedAttribute && !isTraceLoading ? (
@@ -543,7 +549,7 @@ export function Trace({ {manager.interval_bars.map((_, i) => { const indicatorTimestamp = manager.intervals[i] ?? 0; - if (trace.type !== 'trace' || isLoading) { + if (isTraceLoading) { return null; } @@ -562,8 +568,7 @@ export function Trace({
); })} - {trace.type === 'trace' && - !isLoading && + {!isTraceLoading && timeCompression.gaps.map((gap, i) => ( Date: Mon, 6 Jul 2026 10:54:47 -0300 Subject: [PATCH 21/21] fix(trace): Rename pin column action to pin attribute Change the attribute actions menu copy from "Pin as column"/"Unpin column" to "Pin attribute"/"Unpin attribute" to better describe the action. Co-Authored-By: Claude Opus 4.8 --- .../details/span/eapSections/attributes.spec.tsx | 12 ++++++------ .../details/span/eapSections/attributes.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) 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 index 9f7499a2255e..888173a58660 100644 --- 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 @@ -66,27 +66,27 @@ describe('AttributesContent pin action', () => { const {router} = renderContent(); await openAttributeMenu(); - await userEvent.click(await screen.findByText('Pin as column')); + await userEvent.click(await screen.findByText('Pin attribute')); await waitFor(() => { expect(router.location.query.pinnedAttribute).toBe('environment'); }); }); - it('shows "Unpin column" when the attribute is already pinned', async () => { + it('shows "Unpin attribute" when the attribute is already pinned', async () => { renderContent({pinnedAttribute: 'environment'}); await openAttributeMenu(); - expect(await screen.findByText('Unpin column')).toBeInTheDocument(); - expect(screen.queryByText('Pin as column')).not.toBeInTheDocument(); + expect(await screen.findByText('Unpin attribute')).toBeInTheDocument(); + expect(screen.queryByText('Pin attribute')).not.toBeInTheDocument(); }); - it('unpins the attribute when "Unpin column" is clicked', async () => { + it('unpins the attribute when "Unpin attribute" is clicked', async () => { const {router} = renderContent({pinnedAttribute: 'environment'}); await openAttributeMenu(); - await userEvent.click(await screen.findByText('Unpin column')); + await userEvent.click(await screen.findByText('Unpin attribute')); await waitFor(() => { expect(router.location.query.pinnedAttribute).toBeUndefined(); 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 94dfa3811aed..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 @@ -270,7 +270,7 @@ export function AttributesContent({ ...actions, { key: 'pin-attribute', - label: isPinned ? t('Unpin column') : t('Pin as column'), + label: isPinned ? t('Unpin attribute') : t('Pin attribute'), onAction: () => setPinnedAttribute(isPinned ? null : attributeKey), }, ];