Compare commits

...

13 Commits

12 changed files with 365 additions and 114 deletions

View File

@@ -57,10 +57,13 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
// Use the same width-derived interval spread the ticks use, so the badge
// unit always matches the tick unit (narrow rulers pick fewer intervals).
const intervalSpread = spread / getMinimumIntervalsBasedOnWidth(width);
const unit = getIntervalUnit(intervalSpread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
}, [cursorXPercent, spread, offsetTimestamp, width]);
if (endTimestamp < startTimestamp) {
console.error(
@@ -94,12 +97,17 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
y={timelineHeight * 2 - 3}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
<line
y1={0}
y2={timelineHeight - 3}
stroke={strokeColor}
strokeWidth="1"
/>
</g>
))}
</svg>

View File

@@ -0,0 +1,63 @@
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
} from '../utils';
// A tick label looks like "200.00ms" / "1.10s" — grab the trailing unit name.
function unitOfLabel(label: string): string {
const match = label.match(/[a-z]+$/i);
return match ? match[0] : '';
}
describe('getMinimumIntervalsBasedOnWidth', () => {
it('returns fewer intervals for narrower rulers', () => {
expect(getMinimumIntervalsBasedOnWidth(500)).toBe(3);
expect(getMinimumIntervalsBasedOnWidth(700)).toBe(4);
expect(getMinimumIntervalsBasedOnWidth(900)).toBe(5);
expect(getMinimumIntervalsBasedOnWidth(1200)).toBe(6);
});
});
describe('getIntervalUnit', () => {
it('selects the unit from the interval spread', () => {
expect(getIntervalUnit(130, 0).name).toBe('ms');
expect(getIntervalUnit(1100, 0).name).toBe('s');
expect(getIntervalUnit(70_000, 0).name).toBe('m');
});
it('accounts for a large offset (deep-zoom labels stay readable)', () => {
// Cursor spread is tiny but the window starts 5,000,000ms into the trace.
expect(getIntervalUnit(100, 5_000_000).name).toBe('hr');
});
// Regression: the interval COUNT changes the chosen unit, so the crosshair
// badge must use the same width-derived count as the ticks. On a 5.5s trace a
// narrow ruler (5 intervals → 1100ms → "s") and a wide one (6 intervals →
// 916ms → "ms") pick different units.
it('can resolve to different units for the same spread at different counts', () => {
const spread = 5500;
expect(getIntervalUnit(spread / 5, 0).name).toBe('s');
expect(getIntervalUnit(spread / 6, 0).name).toBe('ms');
});
});
describe('badge/tick unit consistency', () => {
// The invariant the fix guarantees: when the badge and the ticks are fed the
// same width-derived intervalSpread, every tick label uses the badge's unit.
it.each([
{ spread: 1287, width: 900 },
{ spread: 5500, width: 900 }, // narrow → 5 intervals, the mismatch case
{ spread: 5500, width: 1200 }, // wide → 6 intervals
{ spread: 120_000, width: 700 },
])('spread=$spread width=$width', ({ spread, width }) => {
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalSpread = spread / minIntervals;
const badgeUnit = getIntervalUnit(intervalSpread, 0).name;
const intervals = getIntervals(intervalSpread, spread, 0);
intervals.forEach((interval) => {
expect(unitOfLabel(interval.label)).toBe(badgeUnit);
});
});
});

View File

@@ -10,14 +10,18 @@ export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
* Exported so crosshair labels use the same unit as the timeline ticks.
*
* Takes the already-computed `intervalSpread` (spread / minIntervals) rather
* than deriving it from a hardcoded interval count — the tick count is
* width-dependent (`getMinimumIntervalsBasedOnWidth`), so callers must pass the
* same `intervalSpread` the ticks use or the badge unit can diverge from the
* ticks (e.g. ms vs s) on narrower rulers like the waterfall.
*/
export function getIntervalUnit(
spread: number,
intervalSpread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {

View File

@@ -0,0 +1,25 @@
.container {
// Gutter matches the header/subHeader 16px; bottom gap before the panels.
padding: 0 16px 12px;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
color: inherit;
font-weight: 500;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { ArrowUpRight } from '@signozhq/icons';
import styles from './MissingSpansBanner.module.scss';
const MISSING_SPANS_DOCS_URL =
'https://signoz.io/docs/userguide/traces/#missing-spans';
function MissingSpansBanner(): JSX.Element | null {
// Session-only dismissal — not persisted, so the banner returns on reload.
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) {
return null;
}
// Wrapper owns the gutter: Callout is width:100%, so putting the gutter as a
// margin on it would overflow the parent by the margin width. Pad instead.
return (
<div className={styles.container}>
<Callout
type="info"
size="small"
showIcon
action="dismissible"
onClick={(): void => setIsDismissed(true)}
testId="missing-spans-banner"
title={
<span className={styles.title}>
This trace has missing spans
<a
className={styles.link}
href={MISSING_SPANS_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Learn More <ArrowUpRight size={14} />
</a>
</span>
}
/>
</div>
);
}
export default MissingSpansBanner;

View File

@@ -31,6 +31,7 @@ import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
@@ -48,6 +49,7 @@ export interface TraceMetadataForHeader {
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
}
interface TraceDetailsHeaderProps {
@@ -229,6 +231,8 @@ function TraceDetailsHeader({
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}
title="Preview fields"

View File

@@ -41,6 +41,7 @@
:global(.ant-collapse-header) {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-radius: 0 !important;
}
:global(.ant-collapse-content) {
@@ -98,6 +99,13 @@
flex-direction: column;
overflow: hidden;
// The flamegraph's ResizableBox above renders a 1px resize handle at its
// bottom edge; drop the header's own top border so the two don't stack
// into a double border at the flamegraph/waterfall juncture.
:global(.ant-collapse-header) {
border-top: none;
}
:global(.ant-collapse-item) {
flex: 1;
display: flex;

View File

@@ -49,59 +49,6 @@
flex-direction: column;
}
.missingSpans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
}
.leftInfo {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.rightInfo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
&:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.splitPanel {
flex: 1;
min-height: 0;
@@ -125,6 +72,9 @@
.sidebarHeader {
flex-shrink: 0;
// Matches the body `.sidebar` border-right so the span-name / timeline
// divider is continuous from the top of the container through the ruler.
border-right: 1px solid var(--l2-border);
}
.resizeHandleHeader {
@@ -170,7 +120,7 @@
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
border-right: 1px solid var(--l2-border);
// ResizableBox child renders with a global `.resizable-box__content` class
// — give it independent horizontal scrolling.
@@ -185,6 +135,18 @@
scrollbar-width: none;
}
// The drag handle is painted --l2-border by default, which would stack with
// our border-right into a 2px divider. Keep it as a hover-only affordance so
// the border-right stays the sole 1px divider (matching the header segment).
:global(.resizable-box__handle--right) {
background: transparent;
&:hover,
&:active {
background: var(--primary);
}
}
&::-webkit-scrollbar {
height: 0.3rem;
}
@@ -216,10 +178,12 @@
.treeRow:hover,
.treeRow.hoveredSpan {
border-radius: 4px;
// Left end of the row band — round only the outer (left) corners so the
// highlight joins the status + timeline segments into one continuous band.
border-radius: 4px 0 0 4px;
background: color-mix(
in srgb,
var(--l3-background) 20%,
var(--l3-background) 60%,
transparent
) !important;
@@ -262,20 +226,22 @@
--badge-border-width: 0px;
&.hoveredSpan {
border-radius: 4px;
// Middle segment of the row band — square so it butts up against the
// name and timeline segments (no rounded corner at the badge column).
border-radius: 0;
background: color-mix(
in srgb,
var(--l3-background) 20%,
var(--l3-background) 60%,
transparent
) !important;
}
&.isInterested,
&.isSelectedNonMatching {
border-radius: 4px;
border-radius: 0;
background: color-mix(
in srgb,
var(--l3-background) 40%,
var(--l3-background) 80%,
transparent
) !important;
}
@@ -309,20 +275,21 @@
&:hover,
&.hoveredSpan {
border-radius: 4px;
// Right end of the row band — round only the outer (right) corners.
border-radius: 0 4px 4px 0;
background: color-mix(
in srgb,
var(--l3-background) 20%,
var(--l3-background) 60%,
transparent
) !important;
}
&:has(.isInterested),
&:has(.isSelectedNonMatching) {
border-radius: 4px;
border-radius: 0 4px 4px 0;
background: color-mix(
in srgb,
var(--l3-background) 40%,
var(--l3-background) 80%,
transparent
) !important;
}
@@ -345,10 +312,11 @@
&.isInterested,
&.isSelectedNonMatching {
border-radius: 4px;
// Left end of the row band — outer (left) corners only.
border-radius: 4px 0 0 4px;
background: color-mix(
in srgb,
var(--l3-background) 40%,
var(--l3-background) 80%,
transparent
) !important;
}
@@ -471,7 +439,7 @@
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
@@ -599,6 +567,18 @@
opacity: 0.15;
}
// A dimmed span must still show the full-opacity hover state when hovered.
// These win over `.isDimmed` on specificity so brightness is restored across
// the whole row (name column, status cell, and timeline bar) on hover.
.treeRow:hover .isDimmed,
.treeRow.hoveredSpan .isDimmed,
.timelineRow:hover .isDimmed,
.timelineRow.hoveredSpan .isDimmed,
.statusCell:hover.isDimmed,
.statusCell.hoveredSpan.isDimmed {
opacity: 1;
}
.isHighlighted {
opacity: 1;
}

View File

@@ -33,14 +33,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
ChevronRight,
CircleAlert,
Link,
ListPlus,
} from '@signozhq/icons';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -551,7 +544,9 @@ function Success(props: ISuccessProps): JSX.Element {
cursorX,
onMouseMove: onCrosshairMove,
onMouseLeave: onCrosshairLeave,
} = useCrosshair({ containerRef: timelineAreaRef, enabled: false });
// Rows are padded 0 15px while `.timeline` spans full width — inset the
// crosshair by the same 15px so it aligns with the ruler ticks and bars.
} = useCrosshair({ containerRef: timelineAreaRef, insetX: 15 });
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
const applyHoverClass = useCallback((spanId: string | null): void => {
@@ -854,28 +849,6 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<div className={styles.root}>
{traceMetadata.hasMissingSpans && (
<div className={styles.missingSpans}>
<section className={styles.leftInfo}>
<CircleAlert size={14} />
<span className={styles.text}>This trace has missing spans</span>
</section>
<Button
variant="ghost"
color="secondary"
className={styles.rightInfo}
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
{isFetching && <div className={styles.loadingBar} />}
<div className={styles.splitPanel} ref={scrollContainerRef}>
{/* Sticky header row */}
@@ -994,8 +967,8 @@ function Success(props: ISuccessProps): JSX.Element {
transform: `translateY(${virtualRow.start}px)`,
}}
data-span-id={span.span_id}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (

View File

@@ -0,0 +1,96 @@
import { act, renderHook } from '@testing-library/react';
import { RefObject } from 'react';
import { useCrosshair } from '../useCrosshair';
// Container spanning [left, left+width]; getBoundingClientRect is all the hook reads.
function mockContainer(left: number, width: number): RefObject<HTMLElement> {
const el = document.createElement('div');
el.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left,
width,
top: 0,
height: 0,
x: left,
y: 0,
right: left + width,
bottom: 0,
toJSON: (): Record<string, unknown> => ({}),
}) as DOMRect,
);
return { current: el };
}
function move(clientX: number): React.MouseEvent {
return { clientX } as React.MouseEvent;
}
describe('useCrosshair', () => {
it('maps the cursor to 0 at the container edge with no inset', () => {
const containerRef = mockContainer(100, 1000);
const { result } = renderHook(() => useCrosshair({ containerRef }));
act(() => result.current.onMouseMove(move(600)));
expect(result.current.cursorX).toBe(500);
expect(result.current.cursorXPercent).toBeCloseTo(0.5);
});
it('offsets and rescales by insetX so 0% aligns with the content start', () => {
const containerRef = mockContainer(100, 1000); // content = [115, 1085], width 970
const { result } = renderHook(() =>
useCrosshair({ containerRef, insetX: 15 }),
);
// At the content start (left + inset) → 0ms, line sits at the inset.
act(() => result.current.onMouseMove(move(115)));
expect(result.current.cursorX).toBe(15);
expect(result.current.cursorXPercent).toBe(0);
// Halfway through the 970px content → 50%.
act(() => result.current.onMouseMove(move(600)));
expect(result.current.cursorX).toBe(500);
expect(result.current.cursorXPercent).toBeCloseTo(485 / 970);
});
it('clamps the dead padding zones to [0, 1]', () => {
const containerRef = mockContainer(100, 1000);
const { result } = renderHook(() =>
useCrosshair({ containerRef, insetX: 15 }),
);
// Left of the content (inside the left padding) → clamped to start.
act(() => result.current.onMouseMove(move(100)));
expect(result.current.cursorX).toBe(15);
expect(result.current.cursorXPercent).toBe(0);
// Right of the content (container right edge) → clamped to end.
act(() => result.current.onMouseMove(move(1100)));
expect(result.current.cursorXPercent).toBe(1);
});
it('resets on mouse leave', () => {
const containerRef = mockContainer(0, 800);
const { result } = renderHook(() => useCrosshair({ containerRef }));
act(() => result.current.onMouseMove(move(400)));
expect(result.current.cursorX).not.toBeNull();
act(() => result.current.onMouseLeave());
expect(result.current.cursorX).toBeNull();
expect(result.current.cursorXPercent).toBeNull();
});
it('is inert when disabled', () => {
const containerRef = mockContainer(0, 800);
const { result } = renderHook(() =>
useCrosshair({ containerRef, enabled: false }),
);
act(() => result.current.onMouseMove(move(400)));
expect(result.current.cursorX).toBeNull();
expect(result.current.cursorXPercent).toBeNull();
});
});

View File

@@ -3,6 +3,16 @@ import { RefObject, useCallback, useState } from 'react';
interface UseCrosshairArgs {
containerRef: RefObject<HTMLElement>;
enabled?: boolean;
/**
* Symmetric horizontal inset (px) of the content (ruler ticks + bars) inside
* the container: shifts the origin right by `insetX` and shrinks the usable
* width by `2 * insetX`. The waterfall pads its rows by 15px while the
* crosshair container spans the full width, so the crosshair must map the
* cursor into that inset content box to line up with 0ms/ticks/bars.
* Flamegraph pads its parent instead, so its container is already the content
* box → 0 (default).
*/
insetX?: number;
}
interface UseCrosshairReturn {
@@ -25,6 +35,7 @@ interface UseCrosshairReturn {
export function useCrosshair({
containerRef,
enabled = true,
insetX = 0,
}: UseCrosshairArgs): UseCrosshairReturn {
const [cursorX, setCursorX] = useState<number | null>(null);
const [cursorXPercent, setCursorXPercent] = useState<number | null>(null);
@@ -39,11 +50,18 @@ export function useCrosshair({
return;
}
const x = e.clientX - rect.left;
setCursorX(x);
setCursorXPercent(x / rect.width);
// Map the cursor into the inset content box so 0% aligns with the first
// tick / bar origin (not the container edge). Clamp so the dead padding
// zones don't produce a line/time before 0ms or past the end.
const contentWidth = Math.max(1, rect.width - insetX * 2);
const xInContent = Math.max(
0,
Math.min(e.clientX - rect.left - insetX, contentWidth),
);
setCursorX(xInContent + insetX);
setCursorXPercent(xInContent / contentWidth);
},
[containerRef, enabled],
[containerRef, enabled, insetX],
);
const onMouseLeave = useCallback((): void => {

View File

@@ -1,7 +1,13 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
@@ -34,6 +40,16 @@ import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
@@ -329,6 +345,7 @@ function TraceDetailsV3(): JSX.Element {
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
};
}, [traceData?.payload]);
@@ -388,6 +405,7 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
@@ -401,7 +419,13 @@ function TraceDetailsV3(): JSX.Element {
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
/>
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
@@ -442,6 +466,7 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}