Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/compact-trace-side-panel.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand All @@ -41,7 +42,7 @@ export function DBHighlightedAttributesList({
const hiddenAttributesCount = attributes.length - sortedAttributes.length;

return (
<Flex wrap="wrap" gap="2px" mb="md" align="baseline">
<Flex wrap="wrap" gap="2px" align="baseline">
{sortedAttributes.map(({ displayedKey, value, sql, lucene, source }) => (
<EventTag
displayedKey={displayedKey}
Expand Down
78 changes: 56 additions & 22 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,54 @@ const SPAN_KIND_LABELS: Record<string, string> = {
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;
Expand Down Expand Up @@ -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 || [],
)
Expand Down Expand Up @@ -868,6 +895,12 @@ export const DBRowSidePanelInner = ({
clearSpanSelection,
]);

const isBodyShownInTitle = isSpanNameShownInTitle(
source,
mainContent,
allBreadcrumbs.map(crumb => crumb.label),
);

const availableTabs = useMemo<Tab[]>(() => {
const tabs: Tab[] = [];
if (hasOverviewPanel && !sourceIsTrace) tabs.push(Tab.Overview);
Expand Down Expand Up @@ -1075,6 +1108,7 @@ export const DBRowSidePanelInner = ({
attributes={highlightedAttributeValues}
mainContent={mainContent}
mainContentHeader={mainContentColumn}
bodyShownInTitle={isBodyShownInTitle}
severityText={severityText}
rowData={normalizedRow}
/>
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/components/DBRowSidePanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
Expand Down Expand Up @@ -85,7 +90,7 @@ export default function DBRowSidePanelHeader({

return (
<>
{!bodyConfigured ? null : mainContent ? (
{!bodyConfigured || bodyShownInTitle ? null : mainContent ? (
<Paper
p="xs"
mt="sm"
Expand Down Expand Up @@ -135,9 +140,11 @@ export default function DBRowSidePanelHeader({
</Paper>
)}
<AISummarizeButton rowData={rowData} severityText={severityText} />
<Box mt="xs">
<DBHighlightedAttributesList attributes={attributesWithDefault} />
</Box>
{attributesWithDefault.length > 0 && (
<Box mt="xs">
<DBHighlightedAttributesList attributes={attributesWithDefault} />
</Box>
)}
</>
);
}
13 changes: 6 additions & 7 deletions packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1394,12 +1394,7 @@ export function DBTraceWaterfallChartContainer({
return (
<>
{showMinimap && (
<Box mb="md">
<TimelineMinimap
rows={timelineRows}
controller={viewportController}
/>
</Box>
<TimelineMinimap rows={timelineRows} controller={viewportController} />
)}
{isFilterExpanded && (
<form onSubmit={handleSubmit(onSubmitFilters)}>
Expand Down Expand Up @@ -1618,7 +1613,11 @@ export function DBTraceWaterfallChartContainer({
</Group>
</Group>
{!isFetching && !error && highlightedAttributeValues?.length > 0 && (
<DBHighlightedAttributesList attributes={highlightedAttributeValues} />
<Box mb="xs">
<DBHighlightedAttributesList
attributes={highlightedAttributeValues}
/>
</Box>
)}
<div
ref={timelineWrapperRef}
Expand Down
33 changes: 23 additions & 10 deletions packages/app/src/components/TimelineChart/TimelineChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import cx from 'classnames';
import { Flex, Kbd, Text } from '@mantine/core';
import { Flex, Text } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';

import {
Expand Down Expand Up @@ -77,7 +77,12 @@ type TimelineChartProps = {
const MIN_VIEWPORT_FRAC = 0.02;

const axisHeight = 24;
const rowsMarginTop = 32;
// Gap between the axis and the first row. The hover time label
// (TimelineMouseCursor, styled in TimelineChart.module.scss) reaches about 37px
// below the top of the chart; the first row starts at
// axisHeight + rowsMarginTop = 44px, which keeps it clear with some headroom.
// Revisit this if the label's padding or offset grows.
const rowsMarginTop = 20;
const maxScale = 100;

export const TimelineChart = memo(function (props: TimelineChartProps) {
Expand Down Expand Up @@ -404,12 +409,6 @@ export const TimelineChart = memo(function (props: TimelineChartProps) {

return (
<Flex h={maxHeight} mah={maxHeight} direction="column">
<Flex justify="end" mb="sm">
<Text>
<Kbd>⌘/Ctrl</Kbd> + <Kbd>scroll</Kbd> to zoom
</Text>
</Flex>

<div className={styles.timelineViewport}>
<div className={styles.timelineContainer} ref={timelineRef}>
<div
Expand All @@ -426,15 +425,29 @@ export const TimelineChart = memo(function (props: TimelineChartProps) {
labelWidth={labelWidth}
/>

{/* 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. */}
<div
aria-hidden
className={styles.timelineCorner}
data-testid="timeline-corner"
style={{
width: labelWidth,
height: `${axisHeight + rowsMarginTop}px`,
marginTop: `-${axisHeight + rowsMarginTop}px`,
}}
/>
>
<Text
inherit
c="dimmed"
truncate="end"
className={styles.xAxisTickLabel}
data-testid="timeline-zoom-hint"
>
⌘/Ctrl + scroll to zoom
</Text>
</div>

{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index];
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <span>{id}</span>,
minWidthPx: 1,
};
return { id, label: <span>{id}</span>, events: [event] };
}

describe('TimelineChart', () => {
it('shows the zoom hint in the corner above the labels, inside the chart', () => {
renderWithMantine(
<TimelineChart
initialScrollRowIndex={-1}
labelWidth={300}
maxHeight={400}
rowHeight={22}
rows={[makeRow('root', 0, 100), makeRow('child', 10, 40)]}
onEventClick={jest.fn()}
/>,
);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Loading
Loading