diff --git a/.changeset/compact-trace-side-panel.md b/.changeset/compact-trace-side-panel.md new file mode 100644 index 0000000000..feb0782f49 --- /dev/null +++ b/.changeset/compact-trace-side-panel.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +fix: start the trace waterfall higher in the side panel + +Trace spans no longer repeat their name in a box under the panel title, or the +service as a chip next to the metadata row that already shows it. The +"⌘/Ctrl + scroll to zoom" hint moves into the timeline's top-left corner +instead of taking its own row. Long span names that the title truncates keep +the box. Log panels keep their body box and service chip. diff --git a/packages/app/src/components/DBHighlightedAttributesList.tsx b/packages/app/src/components/DBHighlightedAttributesList.tsx index 90c01f64de..c5efbe0ae3 100644 --- a/packages/app/src/components/DBHighlightedAttributesList.tsx +++ b/packages/app/src/components/DBHighlightedAttributesList.tsx @@ -15,6 +15,7 @@ export type HighlightedAttribute = { lucene?: string; }; +// Renders without an outer margin; callers set the spacing around the list. export function DBHighlightedAttributesList({ attributes, }: { @@ -41,7 +42,7 @@ export function DBHighlightedAttributesList({ const hiddenAttributesCount = attributes.length - sortedAttributes.length; return ( - + {sortedAttributes.map(({ displayedKey, value, sql, lucene, source }) => ( = { SPAN_KIND_CONSUMER: 'Consumer', }; +// Longest span name the header trusts a lone title breadcrumb to show in full. +// Kept well under that crumb's 120 character cap (MAX_LABEL_LENGTH_SINGLE in +// SidePanelBreadcrumbs.tsx) because CSS can still ellipsize a long title in a +// narrow drawer; longer names keep the body paper. +const TITLE_FITS_MAX_LENGTH = 40; + +/** + * Attribute expressions shown as chips in the side panel header. Log panels + * also get the service name, kept from before highlightedRowAttributeExpressions + * existed; trace panels skip it because the metadata row already shows it. + */ +export function getHeaderHighlightExpressions(source: TSource) { + const expressions: NonNullable< + (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] + > = []; + if ( + (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) && + source.highlightedRowAttributeExpressions + ) { + expressions.push(...source.highlightedRowAttributeExpressions); + } + if (isLogSource(source) && source.serviceNameExpression) { + expressions.push({ sqlExpression: source.serviceNameExpression }); + } + return expressions; +} + +/** + * Whether the panel title already shows a trace span's name in full, so the + * header can skip the body paper that would only repeat it. Only a lone + * breadcrumb counts: in a trail the crumbs share one row and can cut the name + * off, and after View Trace the title is the originating log. Long names keep + * the paper too. + */ +export function isSpanNameShownInTitle( + source: TSource, + spanName: string | undefined, + breadcrumbLabels: string[], +) { + return ( + source.kind === SourceKind.Trace && + spanName != null && + spanName.length <= TITLE_FITS_MAX_LENGTH && + breadcrumbLabels.length === 1 && + breadcrumbLabels[0] === spanName + ); +} + type DBRowSidePanelProps = { source: TSource; rowId: string | undefined; @@ -498,31 +546,10 @@ export const DBRowSidePanelInner = ({ }, [activeRowId, mainContent, resolvedFrameLabels]); const highlightedAttributeValues = useMemo(() => { - const attributeExpressions: NonNullable< - (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] - > = []; - if ( - (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) && - source.highlightedRowAttributeExpressions - ) { - attributeExpressions.push(...source.highlightedRowAttributeExpressions); - } - - // Add service name expression to all sources, to maintain compatibility with - // the behavior prior to the addition of highlightedRowAttributeExpressions - if ( - (isLogSource(source) || isTraceSource(source)) && - source.serviceNameExpression - ) { - attributeExpressions.push({ - sqlExpression: source.serviceNameExpression, - }); - } - return rowData ? getHighlightedAttributesFromData( source, - attributeExpressions, + getHeaderHighlightExpressions(source), rowData.data || [], rowData.meta || [], ) @@ -868,6 +895,12 @@ export const DBRowSidePanelInner = ({ clearSpanSelection, ]); + const isBodyShownInTitle = isSpanNameShownInTitle( + source, + mainContent, + allBreadcrumbs.map(crumb => crumb.label), + ); + const availableTabs = useMemo(() => { const tabs: Tab[] = []; if (hasOverviewPanel && !sourceIsTrace) tabs.push(Tab.Overview); @@ -1075,6 +1108,7 @@ export const DBRowSidePanelInner = ({ attributes={highlightedAttributeValues} mainContent={mainContent} mainContentHeader={mainContentColumn} + bodyShownInTitle={isBodyShownInTitle} severityText={severityText} rowData={normalizedRow} /> diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index a253d71fe9..8995a9ab7c 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -24,12 +24,17 @@ export default function DBRowSidePanelHeader({ // source has neither body nor implicit column configured), the body // paper is suppressed entirely; the highlighted attributes still render. bodyConfigured = true, + // When `true`, the panel title already shows `mainContent` in full (a + // trace span's name, for example), so the body paper is omitted rather + // than repeating the title directly below it. + bodyShownInTitle = false, severityText, rowData, }: { mainContent?: string; mainContentHeader?: string; bodyConfigured?: boolean; + bodyShownInTitle?: boolean; attributes?: HighlightedAttribute[]; severityText?: string; rowData?: Record; @@ -85,7 +90,7 @@ export default function DBRowSidePanelHeader({ return ( <> - {!bodyConfigured ? null : mainContent ? ( + {!bodyConfigured || bodyShownInTitle ? null : mainContent ? ( )} - - - + {attributesWithDefault.length > 0 && ( + + + + )} ); } diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 7811018e2f..15c7206f71 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -1394,12 +1394,7 @@ export function DBTraceWaterfallChartContainer({ return ( <> {showMinimap && ( - - - + )} {isFilterExpanded && (
@@ -1618,7 +1613,11 @@ export function DBTraceWaterfallChartContainer({ {!isFetching && !error && highlightedAttributeValues?.length > 0 && ( - + + + )}
- - - ⌘/Ctrl + scroll to zoom - - -
+ {/* The corner above the label column is otherwise empty, so the + zoom hint lives here instead of in its own row above the chart. + It reuses the tick label class and inherits the axis font so it + reads as part of the axis header. */}
+ > + + ⌘/Ctrl + scroll to zoom + +
{rowVirtualizer.getVirtualItems().map(virtualRow => { const row = rows[virtualRow.index]; diff --git a/packages/app/src/components/TimelineChart/__tests__/TimelineChart.test.tsx b/packages/app/src/components/TimelineChart/__tests__/TimelineChart.test.tsx new file mode 100644 index 0000000000..10ca8856fb --- /dev/null +++ b/packages/app/src/components/TimelineChart/__tests__/TimelineChart.test.tsx @@ -0,0 +1,39 @@ +import { screen } from '@testing-library/react'; + +import { TimelineChart } from '@/components/TimelineChart/TimelineChart'; +import type { TTimelineEvent } from '@/components/TimelineChart/TimelineChartRowEvents'; + +function makeRow(id: string, start: number, end: number) { + const event: TTimelineEvent = { + id, + start, + end, + tooltip: id, + color: 'white', + backgroundColor: 'gray', + body: {id}, + minWidthPx: 1, + }; + return { id, label: {id}, events: [event] }; +} + +describe('TimelineChart', () => { + it('shows the zoom hint in the corner above the labels, inside the chart', () => { + renderWithMantine( + , + ); + + const hint = screen.getByTestId('timeline-zoom-hint'); + expect(hint).toHaveTextContent('⌘/Ctrl + scroll to zoom'); + // Lives in the corner cell above the span labels rather than in a row of + // its own above the chart. + expect(hint.closest('[data-testid="timeline-corner"]')).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.compactHeader.test.ts b/packages/app/src/components/__tests__/DBRowSidePanel.compactHeader.test.ts new file mode 100644 index 0000000000..6f7b43f221 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.compactHeader.test.ts @@ -0,0 +1,72 @@ +import { + getHeaderHighlightExpressions, + isSpanNameShownInTitle, +} from '@/components/DBRowSidePanel'; +import { makeLogSource, makeTraceSource } from '@/llm/__fixtures__/sources'; + +describe('isSpanNameShownInTitle', () => { + const trace = makeTraceSource(); + + it('is true when the lone title crumb is a short span name', () => { + expect( + isSpanNameShownInTitle(trace, 'GET /checkout', ['GET /checkout']), + ).toBe(true); + }); + + it('allows names up to 40 characters', () => { + const name = 'x'.repeat(40); + expect(isSpanNameShownInTitle(trace, name, [name])).toBe(true); + }); + + it('keeps the body for longer names the title can cut off', () => { + const name = 'x'.repeat(41); + expect(isSpanNameShownInTitle(trace, name, [name])).toBe(false); + }); + + it('keeps the body in a breadcrumb trail, where crumbs share the row', () => { + // A hop between two spans with the same name is still a trail. + expect( + isSpanNameShownInTitle(trace, 'GET /checkout', [ + 'GET /checkout', + 'GET /checkout', + ]), + ).toBe(false); + }); + + it('keeps the body when the title is not the span name', () => { + expect(isSpanNameShownInTitle(trace, 'GET /checkout', ['Trace'])).toBe( + false, + ); + }); + + it('keeps the body when the span has no body value', () => { + expect(isSpanNameShownInTitle(trace, undefined, ['Trace'])).toBe(false); + }); + + it('keeps the body on log panels', () => { + expect( + isSpanNameShownInTitle(makeLogSource(), 'a log line', ['a log line']), + ).toBe(false); + }); +}); + +describe('getHeaderHighlightExpressions', () => { + it('does not repeat the service as a chip on trace panels', () => { + expect(getHeaderHighlightExpressions(makeTraceSource())).toEqual([]); + }); + + it('still highlights the service on log panels', () => { + expect(getHeaderHighlightExpressions(makeLogSource())).toEqual([ + { sqlExpression: 'ServiceName' }, + ]); + }); + + it('keeps highlights configured on the source', () => { + const highlight = { sqlExpression: "SpanAttributes['http.method']" }; + expect( + getHeaderHighlightExpressions( + makeTraceSource({ highlightedRowAttributeExpressions: [highlight] }), + ), + ).toEqual([highlight]); + }); +}); diff --git a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx index 0bd32b7f3c..9b49e56c3c 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import type { HighlightedAttribute } from '@/components/DBHighlightedAttributesList'; import DBRowSidePanelHeader from '@/components/DBRowSidePanelHeader'; +import { makeTraceSource } from '@/llm/__fixtures__/sources'; // Stub modules that touch user preferences, AI APIs, or DOM observers; the // behavior under test is the body-paper visibility logic, not those deps. @@ -22,7 +24,9 @@ jest.mock('../AISummarizeButton', () => ({ })); jest.mock('../DBHighlightedAttributesList', () => ({ - DBHighlightedAttributesList: () => null, + DBHighlightedAttributesList: ({ attributes }: { attributes: unknown[] }) => ( +
{attributes.length}
+ ), })); jest.mock('../DBRowSidePanel', () => ({ @@ -84,4 +88,47 @@ describe('DBRowSidePanelHeader: body section (HDX-4373)', () => { renderWithMantine(); expect(screen.queryByText('No body for this event.')).toBeInTheDocument(); }); + + it('omits the body paper when the panel title already shows the body', () => { + renderWithMantine( + , + ); + expect(screen.queryByText('GET /checkout')).not.toBeInTheDocument(); + expect(screen.queryByText('SpanName')).not.toBeInTheDocument(); + expect( + screen.queryByText('No body for this event.'), + ).not.toBeInTheDocument(); + }); +}); + +describe('DBRowSidePanelHeader: highlighted attributes', () => { + const attribute: HighlightedAttribute = { + source: makeTraceSource(), + displayedKey: 'http.method', + value: 'GET', + sql: "SpanAttributes['http.method']", + }; + + it('renders the attribute list when there are attributes', () => { + renderWithMantine( + , + ); + expect(screen.getByTestId('highlighted-attributes')).toHaveTextContent('1'); + }); + + it('renders nothing for the attribute list when there are none', () => { + renderWithMantine( + , + ); + expect( + screen.queryByTestId('highlighted-attributes'), + ).not.toBeInTheDocument(); + }); });