mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-05 13:22:00 +00:00
Compare commits
3 Commits
SIG_3522
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e325ba8b3 | ||
|
|
884f516766 | ||
|
|
4bcbb4ffc3 |
@@ -0,0 +1,4 @@
|
||||
.timeline-v3-container {
|
||||
// flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
100
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
100
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={timelineHeight}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
<line
|
||||
x1="0"
|
||||
y1={timelineHeight}
|
||||
x2={width}
|
||||
y2={timelineHeight}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={2 * Math.floor(timelineHeight / 4)}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line
|
||||
y1={3 * Math.floor(timelineHeight / 4)}
|
||||
y2={timelineHeight + 0.5}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
78
frontend/src/components/TimelineV3/utils.ts
Normal file
78
frontend/src/components/TimelineV3/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
export { getMinimumIntervalsBasedOnWidth };
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (intervalSpread * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let tempBaseSpread = baseSpread;
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (tempBaseSpread && intervals.length < 20) {
|
||||
let intervalTime: number;
|
||||
|
||||
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
|
||||
intervalTime = elapsedIntervals + tempBaseSpread;
|
||||
tempBaseSpread = 0;
|
||||
} else {
|
||||
intervalTime = elapsedIntervals + intervalSpreadNormalized;
|
||||
tempBaseSpread -= intervalSpreadNormalized;
|
||||
}
|
||||
|
||||
elapsedIntervals = intervalTime;
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 16px;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
background: var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
margin-left: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.drafting {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: #fff;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-slate-400);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.trace-details-header {
|
||||
.previous-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-right: none;
|
||||
|
||||
.drafting {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: move to new css module name system
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Typography } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
function TraceDetailsHeader(): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
if (isSpaNavigate) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { FlamegraphCanvasProps } from './types';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const { spans, traceMetadata, selectedSpan } = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [scrollTop] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO: see if this can be removed as once loaded the view start and end ts will not change
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime]);
|
||||
|
||||
const _totalHeight = spans.length * ROW_HEIGHT;
|
||||
|
||||
const { drawFlamegraph } = useFlamegraphDraw({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
selectedSpanId: selectedSpan?.spanId,
|
||||
hoveredSpanId: '',
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphCanvas;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
|
||||
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
|
||||
enum TraceFlamegraphState {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
|
||||
}
|
||||
|
||||
interface TraceFlamegraphProps {
|
||||
serviceExecTime: Record<string, number>;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
function TraceFlamegraph(props: TraceFlamegraphProps): JSX.Element {
|
||||
const { selectedSpan } = props;
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
const flamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (data?.payload?.spans && data.payload.spans.length > 0) {
|
||||
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphState.ERROR;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return TraceFlamegraphState.NO_DATA;
|
||||
}
|
||||
return TraceFlamegraphState.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
const spans = useMemo(() => data?.payload?.spans || [], [
|
||||
data?.payload?.spans,
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (flamegraphState) {
|
||||
case TraceFlamegraphState.LOADING:
|
||||
return <div>Loading...</div>;
|
||||
case TraceFlamegraphState.ERROR:
|
||||
return <div>Error loading flamegraph</div>;
|
||||
case TraceFlamegraphState.NO_DATA:
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
case TraceFlamegraphState.SUCCESS:
|
||||
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
selectedSpan={selectedSpan}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Fetching the trace...</div>;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
firstSpanAtFetchLevel,
|
||||
flamegraphState,
|
||||
selectedSpan,
|
||||
spans,
|
||||
traceId,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const ROW_HEIGHT = 24;
|
||||
export const SPAN_BAR_HEIGHT = 22;
|
||||
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
|
||||
export const EVENT_DOT_SIZE = 6;
|
||||
|
||||
export const LABEL_FONT = '11px Inter, sans-serif';
|
||||
export const LABEL_PADDING_X = 8;
|
||||
export const MIN_WIDTH_FOR_DURATION = 30;
|
||||
export const MIN_WIDTH_FOR_NAME = 80;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useCanvasSetup(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
onDraw: () => void,
|
||||
): void {
|
||||
const updateCanvasSize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${viewportHeight}px`;
|
||||
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(viewportHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
onDraw();
|
||||
}
|
||||
}, [canvasRef, containerRef, onDraw]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
updateCanvasSize();
|
||||
|
||||
// when dpr changes, update the canvas size
|
||||
const dprQuery = window.matchMedia('(resolution: 1dppx)');
|
||||
dprQuery.addEventListener('change', updateCanvasSize);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
dprQuery.removeEventListener('change', updateCanvasSize);
|
||||
};
|
||||
}, [containerRef, updateCanvasSize]);
|
||||
|
||||
useEffect(() => {
|
||||
onDraw();
|
||||
}, [onDraw]);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { ROW_HEIGHT } from '../constants';
|
||||
import { SpanRect } from '../types';
|
||||
import { clamp, drawSpanBar, getSpanColor } from '../utils';
|
||||
|
||||
interface UseFlamegraphDrawArgs {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
spans: FlamegraphSpan[][];
|
||||
viewStartTs: number;
|
||||
viewEndTs: number;
|
||||
scrollTop: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
interface UseFlamegraphDrawResult {
|
||||
drawFlamegraph: () => void;
|
||||
spanRectsRef: RefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
const OVERSCAN_ROWS = 4;
|
||||
|
||||
interface DrawLevelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
levelSpans: FlamegraphSpan[];
|
||||
levelIndex: number;
|
||||
y: number;
|
||||
viewStartTs: number;
|
||||
timeSpan: number;
|
||||
cssWidth: number;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
spanRectsArray: SpanRect[];
|
||||
}
|
||||
|
||||
function drawLevel(args: DrawLevelArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
} = args;
|
||||
|
||||
const viewEndTs = viewStartTs + timeSpan;
|
||||
|
||||
for (let i = 0; i < levelSpans.length; i++) {
|
||||
const span = levelSpans[i];
|
||||
const spanStartMs = span.timestamp;
|
||||
const spanEndMs = span.timestamp + span.durationNano / 1e6;
|
||||
|
||||
// Time culling -- skip spans entirely outside the visible time window
|
||||
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
|
||||
let width = rightEdge - leftOffset;
|
||||
|
||||
// Clamp to visible x-range
|
||||
if (leftOffset < 0) {
|
||||
width += leftOffset;
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rightEdge > cssWidth) {
|
||||
width = cssWidth - Math.max(0, leftOffset);
|
||||
if (width <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum 1px width so tiny spans remain visible
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const color = getSpanColor({
|
||||
span,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span,
|
||||
x: Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlamegraphDraw(
|
||||
args: UseFlamegraphDrawArgs,
|
||||
): UseFlamegraphDrawResult {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
} = args;
|
||||
|
||||
const spanRectsRef = useRef<SpanRect[]>([]);
|
||||
|
||||
const drawFlamegraph = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const timeSpan = viewEndTs - viewStartTs;
|
||||
if (timeSpan <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = canvas.width / dpr;
|
||||
|
||||
// ---- Vertical clipping window ----
|
||||
const viewportHeight = container.clientHeight;
|
||||
|
||||
const firstLevel = Math.max(
|
||||
0,
|
||||
Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN_ROWS,
|
||||
);
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
ctx.clearRect(0, 0, cssWidth, viewportHeight);
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drawLevel({
|
||||
ctx,
|
||||
levelSpans,
|
||||
levelIndex,
|
||||
y: levelIndex * ROW_HEIGHT - scrollTop,
|
||||
viewStartTs,
|
||||
timeSpan,
|
||||
cssWidth,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
spanRectsArray,
|
||||
});
|
||||
}
|
||||
|
||||
spanRectsRef.current = spanRectsArray;
|
||||
}, [
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
]);
|
||||
|
||||
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
|
||||
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
|
||||
// compute level from mouseY / ROW_HEIGHT and scan only that row.
|
||||
// Further: binary search within a level by x (spans are sorted by start time)
|
||||
// to reduce hover cost from O(N) to O(log N).
|
||||
return { drawFlamegraph, spanRectsRef };
|
||||
}
|
||||
25
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts
Normal file
25
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
export interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface FlamegraphCanvasProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
export interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
230
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts
Normal file
230
frontend/src/pages/TraceDetailsV3/TraceFlamegraph/utils.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import Color from 'color';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import {
|
||||
EVENT_DOT_SIZE,
|
||||
LABEL_FONT,
|
||||
LABEL_PADDING_X,
|
||||
MIN_WIDTH_FOR_DURATION,
|
||||
MIN_WIDTH_FOR_NAME,
|
||||
SPAN_BAR_HEIGHT,
|
||||
SPAN_BAR_Y_OFFSET,
|
||||
} from './constants';
|
||||
import { SpanRect } from './types';
|
||||
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
span: FlamegraphSpan;
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, selectedSpanId, hoveredSpanId, isDarkMode } = args;
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
|
||||
if (selectedSpanId === span.spanId || hoveredSpanId === span.spanId) {
|
||||
const colorObj = Color(color);
|
||||
color = isDarkMode ? colorObj.lighten(0.7).hex() : colorObj.darken(0.7).hex();
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
interface DrawEventDotArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
isError: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export function drawEventDot(args: DrawEventDotArgs): void {
|
||||
const { ctx, x, y, isError, isDarkMode } = args;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
|
||||
if (isError) {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
|
||||
} else {
|
||||
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
|
||||
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
|
||||
}
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
const half = EVENT_DOT_SIZE / 2;
|
||||
ctx.fillRect(-half, -half, EVENT_DOT_SIZE, EVENT_DOT_SIZE);
|
||||
ctx.strokeRect(-half, -half, EVENT_DOT_SIZE, EVENT_DOT_SIZE);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface DrawSpanBarArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
levelIndex: number;
|
||||
spanRectsArray: SpanRect[];
|
||||
color: string;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
const {
|
||||
ctx,
|
||||
span,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
color,
|
||||
isDarkMode,
|
||||
} = args;
|
||||
|
||||
const spanY = y + SPAN_BAR_Y_OFFSET;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, spanY, width, SPAN_BAR_HEIGHT, 2);
|
||||
ctx.fill();
|
||||
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
const spanDurationMs = span.durationNano / 1e6;
|
||||
if (spanDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
|
||||
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + SPAN_BAR_HEIGHT / 2;
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: eventX,
|
||||
y: eventY,
|
||||
isError: event.isError,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
drawSpanLabel({ ctx, span, x, y: spanY, width, isDarkMode });
|
||||
}
|
||||
|
||||
export function formatDuration(durationNano: number): string {
|
||||
const durationMs = durationNano / 1e6;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
|
||||
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
|
||||
}
|
||||
|
||||
interface DrawSpanLabelArgs {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
function drawSpanLabel(args: DrawSpanLabelArgs): void {
|
||||
const { ctx, span, x, y, width, isDarkMode } = args;
|
||||
|
||||
if (width < MIN_WIDTH_FOR_DURATION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = formatDuration(span.durationNano);
|
||||
const name = span.name;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Clip text to span bar bounds
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, SPAN_BAR_HEIGHT);
|
||||
ctx.clip();
|
||||
|
||||
ctx.font = LABEL_FONT;
|
||||
ctx.fillStyle = isDarkMode ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const textY = y + SPAN_BAR_HEIGHT / 2;
|
||||
const leftX = x + LABEL_PADDING_X;
|
||||
const rightX = x + width - LABEL_PADDING_X;
|
||||
const availableWidth = width - LABEL_PADDING_X * 2;
|
||||
|
||||
const durationWidth = ctx.measureText(duration).width;
|
||||
|
||||
if (width >= MIN_WIDTH_FOR_NAME) {
|
||||
const minGap = 6;
|
||||
const nameSpace = availableWidth - durationWidth - minGap;
|
||||
|
||||
// Duration right-aligned
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(duration, rightX, textY);
|
||||
|
||||
// Name left-aligned, truncated to fit remaining space
|
||||
if (nameSpace > 20) {
|
||||
ctx.textAlign = 'left';
|
||||
const truncatedName = truncateText(ctx, name, nameSpace);
|
||||
ctx.fillText(truncatedName, leftX, textY);
|
||||
}
|
||||
} else {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(duration, rightX, textY);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function truncateText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): string {
|
||||
const ellipsis = '...';
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
|
||||
}
|
||||
57
frontend/src/pages/TraceDetailsV3/index.tsx
Normal file
57
frontend/src/pages/TraceDetailsV3/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/resizable';
|
||||
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
|
||||
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV2URLProps>();
|
||||
const [selectedSpan, _setSelectedSpan] = useState<Span>();
|
||||
const [uncollapsedNodes] = useState<string[]>([]);
|
||||
|
||||
const { data: traceData } = useGetTraceV2({
|
||||
traceId,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
selectedSpanId: '',
|
||||
isSelectedSpanIDUnCollapsed: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - 90px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TraceDetailsHeader />
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
autoSaveId="trace-details-v3-layout"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ResizablePanel defaultSize={40} minSize={20} maxSize={80}>
|
||||
<TraceFlamegraph
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap || {}}
|
||||
startTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
endTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
selectedSpan={selectedSpan}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={60} minSize={20}>
|
||||
<div />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsV3;
|
||||
Reference in New Issue
Block a user