feat: show inline span durations in trace timeline (#1886)

## Summary

Adds duration labels next to each **span** bar in the trace waterfall timeline so users can read the duration at a glance without hovering the tooltip.

## Changes

### Timeline chart

- **TimelineChartRowEvents**
  - Renders a duration label (e.g. `12ms`, `1.2s`) **outside** each bar using `position: absolute`, so it doesn’t affect layout.
  - **Placement:** Label is on the **right** of the bar when most of the bar is before the timeline midpoint, and on the **left** when most of the bar is past the midpoint (based on bar center vs. `maxVal / 2`), to keep it in the emptier side.
  - Uses existing `renderMs()` from `TimelineChart/utils` for formatting.
  - Wraps each bar in a container with `overflow: visible` so the duration label is not clipped by the bar’s `text-truncate` (overflow hidden).
  - **TTimelineEvent** now supports optional `showDuration?: boolean`. When `false`, the duration label is hidden (default is to show).

### DBTraceWaterfallChart

- When building timeline events, sets `showDuration: type !== SourceKind.Log` so **log** rows do not show a duration (only spans do).

## Screenshots
<img width="1293" height="1187" alt="Screenshot 2026-03-11 at 18 36 34" src="https://github.com/user-attachments/assets/da04c317-a0bd-45d7-b0cc-f298564fb850" />


## Testing

- Open a trace with multiple spans and at least one correlated log; confirm duration appears beside span bars (left or right by midpoint) and does not appear beside log rows.

### References

- Closes HDX-3671
This commit is contained in:
Karl Power 2026-03-18 16:56:02 +01:00 committed by GitHub
parent 2ad909955a
commit 69cf33cbe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 89 additions and 23 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: show inline span durations in trace timeline

View file

@ -820,6 +820,7 @@ export function DBTraceWaterfallChartContainer({
minWidthPerc: 1,
isError,
markers,
showDuration: type !== SourceKind.Log,
},
],
};

View file

@ -5,6 +5,7 @@ import {
TimelineSpanEventMarker,
type TTimelineSpanEventMarker,
} from './TimelineSpanEventMarker';
import { renderMs } from './utils';
export type TTimelineEvent = {
id: string;
@ -17,6 +18,7 @@ export type TTimelineEvent = {
minWidthPerc?: number;
isError?: boolean;
markers?: TTimelineSpanEventMarker[];
showDuration?: boolean;
};
type TimelineChartRowProps = {
@ -57,6 +59,12 @@ export const TimelineChartRowEvents = memo(function ({
const percMarginLeft =
scale * (((e.start - lastEventEnd) / maxVal) * 100);
const durationMs = e.end - e.start;
const barCenter = (e.start + e.end) / 2;
const timelineMidpoint = maxVal / 2;
// Duration on left when majority of bar is past halfway, otherwise on right
const durationOnRight = barCenter <= timelineMidpoint;
return (
<Tooltip
key={e.id}
@ -72,32 +80,60 @@ export const TimelineChartRowEvents = memo(function ({
}}
>
<div
onMouseEnter={() => onEventHover?.(e.id)}
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
style={{
userSelect: 'none',
position: 'relative',
minWidth: `${percWidth.toFixed(6)}%`,
width: `${percWidth.toFixed(6)}%`,
marginLeft: `${percMarginLeft.toFixed(6)}%`,
position: 'relative',
borderRadius: 2,
fontSize: height * 0.5,
color: e.color,
backgroundColor: e.backgroundColor,
// overflow: 'visible',
}}
>
<div style={{ margin: 'auto' }} className="px-2">
{e.body}
<div
onMouseEnter={() => onEventHover?.(e.id)}
className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity"
style={{
userSelect: 'none',
width: '100%',
position: 'relative',
borderRadius: 2,
fontSize: height * 0.5,
color: e.color,
backgroundColor: e.backgroundColor,
}}
>
<div style={{ margin: 'auto' }} className="px-2">
{e.body}
</div>
{e.markers?.map((marker, idx) => (
<TimelineSpanEventMarker
key={`${e.id}-marker-${idx}`}
marker={marker}
eventStart={e.start}
eventEnd={e.end}
height={height}
/>
))}
</div>
{e.markers?.map((marker, idx) => (
<TimelineSpanEventMarker
key={`${e.id}-marker-${idx}`}
marker={marker}
eventStart={e.start}
eventEnd={e.end}
height={height}
/>
))}
{!!e.showDuration && (
<span
style={{
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
alignItems: 'center',
fontSize: height * 0.5,
color: 'var(--color-text)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
...(durationOnRight
? { left: '100%', marginLeft: 4 }
: { right: '100%', marginRight: 4 }),
}}
>
{renderMs(durationMs)}
</span>
)}
</div>
</Tooltip>
);

View file

@ -1,10 +1,6 @@
import { calculateInterval, renderMs } from '../utils';
describe('renderMs', () => {
it('returns "0ms" for 0', () => {
expect(renderMs(0)).toBe('0ms');
});
it('formats sub-second values as ms', () => {
expect(renderMs(500)).toBe('500ms');
expect(renderMs(999)).toBe('999ms');
@ -25,6 +21,26 @@ describe('renderMs', () => {
expect(renderMs(1500)).toBe('1.500s');
expect(renderMs(1234.567)).toBe('1.235s');
});
it('returns "0µs" for 0', () => {
expect(renderMs(0)).toBe('0µs');
});
it('formats sub-millisecond values as µs', () => {
expect(renderMs(0.001)).toBe('1µs');
expect(renderMs(0.5)).toBe('500µs');
expect(renderMs(0.999)).toBe('999µs');
});
it('rounds sub-millisecond values to nearest µs', () => {
expect(renderMs(0.0005)).toBe('1µs');
expect(renderMs(0.9994)).toBe('999µs');
});
it('falls through to ms when µs rounds to 1000', () => {
// 0.9995ms rounds to 1000µs, so it should render as 1ms instead
expect(renderMs(0.9995)).toBe('1ms');
});
});
describe('calculateInterval', () => {

View file

@ -1,4 +1,12 @@
export function renderMs(ms: number) {
if (ms < 1) {
const µsRounded = Math.round(ms * 1000);
if (µsRounded !== 1000) {
return `${µsRounded}µs`;
}
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}