mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
2ad909955a
commit
69cf33cbe6
5 changed files with 89 additions and 23 deletions
5
.changeset/olive-hounds-double.md
Normal file
5
.changeset/olive-hounds-double.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: show inline span durations in trace timeline
|
||||
|
|
@ -820,6 +820,7 @@ export function DBTraceWaterfallChartContainer({
|
|||
minWidthPerc: 1,
|
||||
isError,
|
||||
markers,
|
||||
showDuration: type !== SourceKind.Log,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue