mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-26 18:32:35 +00:00
Compare commits
12 Commits
nv/4074
...
feat/poc-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec3754b65 | ||
|
|
c36f780964 | ||
|
|
e0e0870e23 | ||
|
|
ea406dac52 | ||
|
|
c6152b33e9 | ||
|
|
ca87244961 | ||
|
|
e41a85febd | ||
|
|
9d4154a117 | ||
|
|
4b9dd63e8c | ||
|
|
30e7736a74 | ||
|
|
6bd194dfb4 | ||
|
|
f27e3cabb2 |
@@ -14,10 +14,16 @@ interface ITimelineV2Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
}
|
||||
|
||||
function TimelineV2(props: ITimelineV2Props): JSX.Element {
|
||||
const { startTimestamp, endTimestamp, timelineHeight } = props;
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -30,8 +36,10 @@ function TimelineV2(props: ITimelineV2Props): JSX.Element {
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
setIntervals(getIntervals(intervalisedSpread, spread));
|
||||
}, [startTimestamp, endTimestamp, width]);
|
||||
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
|
||||
|
||||
setIntervals(intervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
|
||||
@@ -64,6 +64,71 @@ export const resolveTimeFromInterval = (
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number, // ms offset from trace start (e.g. viewStart - traceStart)
|
||||
): 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 = 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[] = [
|
||||
{
|
||||
// ✅ start label should reflect window start offset (relative to trace start)
|
||||
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;
|
||||
|
||||
// ✅ label time = window offset + elapsed time inside window
|
||||
const labelTime = offsetTimestamp + intervalTime;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
|
||||
export function getIntervalsOld(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
@@ -106,9 +171,10 @@ export function getIntervals(
|
||||
}
|
||||
elapsedIntervals = intervalTime;
|
||||
const interval: Interval = {
|
||||
label: `${toFixed(resolveTimeFromInterval(intervalTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(intervalTime + offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: (intervalTime / baseSpread) * 100,
|
||||
};
|
||||
intervals.push(interval);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Flamegraph Canvas POC Notes
|
||||
|
||||
## Overview
|
||||
This document tracks the proof-of-concept (POC) implementation of a canvas-based flamegraph rendering system, replacing the previous DOM-based approach.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Features
|
||||
|
||||
1. **Canvas-based Rendering**
|
||||
- Replaced DOM elements (`react-virtuoso` and `div.span-item`) with a single HTML5 Canvas
|
||||
- Implemented `drawFlamegraph` function to render all spans as rectangles on canvas
|
||||
- Added device pixel ratio (DPR) support for crisp rendering
|
||||
|
||||
2. **Time-window-based Zoom**
|
||||
- Replaced pixel-based zoom/pan with time-window-based approach (`viewStartTs`, `viewEndTs`)
|
||||
- Prevents pixelation by redrawing from data with new time bounds
|
||||
- Zoom anchors to cursor position
|
||||
- Horizontal zoom works correctly (min: 1/100th of trace, max: full trace)
|
||||
|
||||
3. **Drag to Pan**
|
||||
- Implemented drag-to-pan functionality for navigating the canvas
|
||||
- Differentiates between click (span selection) and drag (panning) based on distance moved
|
||||
- Prevents unwanted window zoom
|
||||
|
||||
4. **Minimap with 2D Navigation**
|
||||
- Canvas-based minimap showing density histogram (time × levels)
|
||||
- 2D brush overlay for both horizontal (time) and vertical (levels) navigation
|
||||
- Draggable brush to pan both dimensions
|
||||
- Bidirectional synchronization between main canvas and minimap
|
||||
|
||||
5. **Timeline Synchronization**
|
||||
- `TimelineV2` component synchronized with visible time window
|
||||
- Updates correctly during zoom and pan operations
|
||||
|
||||
6. **Hit Testing**
|
||||
- Implemented span rectangle tracking for click detection
|
||||
- Tooltip on hover
|
||||
- Span selection via click
|
||||
|
||||
### ❌ Known Issues / Not Working
|
||||
|
||||
1. **Vertical Zoom - NOT WORKING**
|
||||
- **Status**: Attempted implementation but not functioning correctly
|
||||
- **Issue**: When horizontal zoom reaches maximum (full trace width), vertical zoom cannot continue to zoom out further
|
||||
- **Attempted Solution**: Added `rowHeightScale` state to control vertical row spacing, but the implementation does not work as expected
|
||||
- **Impact**: Users cannot fully zoom out vertically to see all levels when horizontal zoom is at maximum
|
||||
- **Next Steps**: Needs further investigation and alternative approach
|
||||
|
||||
2. **Timeline Scale Alignment - NOT WORKING PROPERLY**
|
||||
- **Status**: Issue identified but not fully resolved
|
||||
- **Issue**: The timeline scale does not align properly when dragging/panning the canvas. The timeline aligns correctly during zoom operations, but not during drag/pan operations.
|
||||
- **Impact**: Timeline may show incorrect time values while dragging the canvas
|
||||
- **Attempted Solution**: Used refs (`viewStartTsRef`, `viewEndTsRef`) to track current time window and incremental delta calculation, but issue persists
|
||||
- **Next Steps**: Needs further investigation to ensure timeline stays synchronized during all interaction types
|
||||
|
||||
### 🔄 Pending / Future Work
|
||||
|
||||
1. **Performance Optimization**
|
||||
- Consider adding an interaction layer (separate canvas on top) for better performance
|
||||
- Optimize rendering for large traces
|
||||
|
||||
2. **Code Quality**
|
||||
- Reduce cognitive complexity of `drawFlamegraph` function (currently 26, target: 15)
|
||||
- Reduce cognitive complexity of `drawMinimap` function (currently 30, target: 15)
|
||||
|
||||
3. **Additional Features**
|
||||
- Keyboard shortcuts for navigation
|
||||
- Better zoom controls
|
||||
- Export functionality
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Key Files Modified
|
||||
- `frontend/src/container/PaginatedTraceFlamegraph/TraceFlamegraphStates/Success/Success.tsx` - Main rendering component
|
||||
- `frontend/src/container/PaginatedTraceFlamegraph/TraceFlamegraphStates/Success/Success.styles.scss` - Styles for canvas and minimap
|
||||
- `frontend/src/components/TimelineV2/TimelineV2.tsx` - Timeline synchronization
|
||||
|
||||
### Key Concepts
|
||||
- **Time-window-based zoom**: Instead of scaling canvas bitmap, redraw from data with new time bounds
|
||||
- **Device Pixel Ratio**: Render at DPR resolution for crisp display on high-DPI screens
|
||||
- **2D Minimap**: Shows density heatmap across both time (horizontal) and levels (vertical) dimensions
|
||||
- **Brush Navigation**: Draggable rectangle overlay for panning both dimensions
|
||||
|
||||
## Notes
|
||||
- This is a POC implementation - code quality and optimization can be improved after validation
|
||||
- Some linting warnings (cognitive complexity) are acceptable for POC phase
|
||||
- All changes should be validated before production use
|
||||
@@ -1,3 +1,5 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
|
||||
@@ -13,8 +15,13 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { TraceFlamegraphStates } from './constants';
|
||||
import Error from './TraceFlamegraphStates/Error/Error';
|
||||
import NoData from './TraceFlamegraphStates/NoData/NoData';
|
||||
import Success from './TraceFlamegraphStates/Success/Success';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessV2';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessV3_without_minimap_best';
|
||||
import Success from './TraceFlamegraphStates/Success/Success_zoom';
|
||||
|
||||
// import Success from './TraceFlamegraphStates/Success/Success_zoom_api';
|
||||
// import Success from './TraceFlamegraphStates/Success/SuccessCursor';
|
||||
// import Success from './TraceFlamegraphStates/Success/Success';
|
||||
import './PaginatedTraceFlamegraph.styles.scss';
|
||||
|
||||
interface ITraceFlamegraphProps {
|
||||
@@ -38,7 +45,6 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
@@ -46,6 +52,9 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: 100001,
|
||||
// boundaryStartTsMilli: 0,
|
||||
// boundarEndTsMilli: 10000,
|
||||
});
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&.trace-flamegraph-canvas {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trace-flamegraph-virtuoso {
|
||||
overflow-x: hidden;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import {
|
||||
@@ -23,6 +26,12 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
// Constants for rendering
|
||||
const ROW_HEIGHT = 24; // 18px height + 6px padding
|
||||
const SPAN_BAR_HEIGHT = 12;
|
||||
const EVENT_DOT_SIZE = 6;
|
||||
const SPAN_BAR_Y_OFFSET = 3; //
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -35,6 +44,14 @@ interface ISuccessProps {
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
@@ -48,6 +65,28 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const interactionCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const minimapRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
console.log('spans.length', spans.length);
|
||||
// Calculate total canvas height. this is coming less
|
||||
const totalHeight = spans.length * ROW_HEIGHT;
|
||||
|
||||
// Build a flat array of span rectangles for hit testing.
|
||||
// consider per level buckets to improve hit testing
|
||||
const spanRects = useRef<SpanRect[]>([]);
|
||||
|
||||
// Time window state (instead of zoom/pan in pixel space)
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
|
||||
const renderSpanLevel = useCallback(
|
||||
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
|
||||
@@ -157,16 +196,305 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
});
|
||||
}, [firstSpanAtFetchLevel, spans]);
|
||||
|
||||
// Draw a single event dot
|
||||
const drawEventDot = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
isError: boolean,
|
||||
): void => {
|
||||
// could be optimized:
|
||||
// ctx.beginPath();
|
||||
// ctx.moveTo(x, y - size/2);
|
||||
// ctx.lineTo(x + size/2, y);
|
||||
// ctx.lineTo(x, y + size/2);
|
||||
// ctx.lineTo(x - size/2, y);
|
||||
// ctx.closePath();
|
||||
// ctx.fill();
|
||||
// ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4); // 45 degrees
|
||||
|
||||
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;
|
||||
ctx.fillRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.strokeRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.restore();
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
// Get CSS color value from color string or CSS variable
|
||||
// const getColorValue = useCallback((color: string): string => {
|
||||
// // if (color.startsWith('var(')) {
|
||||
// // // For CSS variables, we need to get computed value
|
||||
// // const tempDiv = document.createElement('div');
|
||||
// // tempDiv.style.color = color;
|
||||
// // document.body.appendChild(tempDiv);
|
||||
// // const computedColor = window.getComputedStyle(tempDiv).color;
|
||||
// // document.body.removeChild(tempDiv);
|
||||
// // return computedColor;
|
||||
// // }
|
||||
// return color;
|
||||
// }, []);
|
||||
|
||||
// Get span color based on service, error state, and selection
|
||||
// separate this when introducing interaction canvas
|
||||
const getSpanColor = useCallback(
|
||||
(span: FlamegraphSpan): string => {
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
// else {
|
||||
// color = getColorValue(color);
|
||||
// }
|
||||
|
||||
// Apply selection/hover highlight
|
||||
//hover/selection highlight in getSpanColor forces base redraw. clipping necessary.
|
||||
if (selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId) {
|
||||
const colorObj = Color(color);
|
||||
color = isDarkMode
|
||||
? colorObj.lighten(0.7).hex()
|
||||
: colorObj.darken(0.7).hex();
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
[isDarkMode, selectedSpan, hoveredSpanId],
|
||||
);
|
||||
|
||||
// Draw a single span and its events
|
||||
const drawSpan = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
span: FlamegraphSpan,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
levelIndex: number,
|
||||
spanRectsArray: SpanRect[],
|
||||
): void => {
|
||||
const color = getSpanColor(span); // do not depend on hover/clicks
|
||||
const spanY = y + SPAN_BAR_Y_OFFSET;
|
||||
|
||||
// Draw span rectangle
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
// see if we can avoid roundRect as it is performance intensive
|
||||
ctx.roundRect(x, spanY, width, SPAN_BAR_HEIGHT, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Store rect for hit testing
|
||||
// consider per level buckets to improve hit testing
|
||||
// So hover can:
|
||||
// compute level from y
|
||||
// search only within that row
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
// Draw events
|
||||
// think about optimizing this.
|
||||
// if span is too small to draw events, skip drawing events???
|
||||
span.event?.forEach((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + SPAN_BAR_HEIGHT / 2;
|
||||
// LOD guard: skip events if span too narrow
|
||||
// if (width < EVENT_DOT_SIZE) {
|
||||
// return;
|
||||
// }
|
||||
drawEventDot(ctx, eventX, eventY, event.isError);
|
||||
});
|
||||
},
|
||||
[getSpanColor, drawEventDot],
|
||||
);
|
||||
|
||||
const drawFlamegraph = useCallback(() => {
|
||||
const canvas = baseCanvasRef.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 overscan = 4;
|
||||
|
||||
const firstLevel = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - overscan);
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / ROW_HEIGHT) + 2 * overscan;
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
// ---- Clear only visible region (recommended) ----
|
||||
const clearTop = firstLevel * ROW_HEIGHT;
|
||||
const clearHeight = (lastLevel - firstLevel + 1) * ROW_HEIGHT;
|
||||
ctx.clearRect(0, clearTop, cssWidth, clearHeight);
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const y = levelIndex * ROW_HEIGHT;
|
||||
|
||||
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 (already correct)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: minimum 1px width
|
||||
if (width > 0 && width < 1) {
|
||||
width = 1;
|
||||
}
|
||||
|
||||
drawSpan(
|
||||
ctx,
|
||||
span,
|
||||
Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spanRects.current = spanRectsArray;
|
||||
}, [spans, viewStartTs, viewEndTs, scrollTop, drawSpan]);
|
||||
|
||||
// Handle canvas resize with device pixel ratio
|
||||
useEffect(() => {
|
||||
const canvas = baseCanvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCanvasSize = (): void => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Set CSS size
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${totalHeight}px`;
|
||||
|
||||
// Set actual pixel size (accounting for DPR)
|
||||
// Only update if size actually changed to prevent unnecessary redraws
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(totalHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
// Redraw with current time window (preserves zoom/pan)
|
||||
drawFlamegraph();
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Initial size
|
||||
updateCanvasSize();
|
||||
|
||||
// Handle DPR changes (e.g., moving window between screens)
|
||||
const handleDPRChange = (): void => {
|
||||
updateCanvasSize();
|
||||
};
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.addEventListener('change', handleDPRChange);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.removeEventListener('change', handleDPRChange);
|
||||
};
|
||||
}, [drawFlamegraph, totalHeight]);
|
||||
|
||||
// Re-draw when data changes
|
||||
useEffect(() => {
|
||||
drawFlamegraph();
|
||||
}, [drawFlamegraph]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="trace-flamegraph">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="trace-flamegraph-virtuoso"
|
||||
data={spans}
|
||||
itemContent={renderSpanLevel}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
<div ref={containerRef} className="trace-flamegraph trace-flamegraph-canvas">
|
||||
<canvas ref={baseCanvasRef}></canvas>
|
||||
</div>
|
||||
<TimelineV2
|
||||
startTimestamp={traceMetadata.startTime}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,890 @@
|
||||
// @ts-nocheck
|
||||
/* eslint-disable */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import Color from 'color';
|
||||
import TimelineV2 from 'components/TimelineV2/TimelineV2';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface ISuccessProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
// Constants for rendering
|
||||
const ROW_HEIGHT = 24; // 18px height + 6px padding
|
||||
const SPAN_BAR_HEIGHT = 12;
|
||||
const EVENT_DOT_SIZE = 6;
|
||||
const SPAN_BAR_Y_OFFSET = 3; // Center the 12px bar in 18px row
|
||||
|
||||
interface SpanRect {
|
||||
span: FlamegraphSpan;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
setFirstSpanAtFetchLevel,
|
||||
traceMetadata,
|
||||
firstSpanAtFetchLevel,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
|
||||
const [tooltipContent, setTooltipContent] = useState<{
|
||||
content: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
|
||||
// Time window state (instead of zoom/pan in pixel space)
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState<boolean>(false);
|
||||
|
||||
// Refs to avoid stale state during rapid wheel events and dragging
|
||||
const viewStartRef = useRef(viewStartTs);
|
||||
const viewEndRef = useRef(viewEndTs);
|
||||
|
||||
useEffect(() => {
|
||||
viewStartRef.current = viewStartTs;
|
||||
viewEndRef.current = viewEndTs;
|
||||
}, [viewStartTs, viewEndTs]);
|
||||
|
||||
// Drag state in refs to avoid re-renders during drag
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragDistanceRef = useRef(0);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
// Scroll ref to avoid recreating getCanvasPointer on every scroll
|
||||
const scrollTopRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
// Build a flat array of span rectangles for hit testing
|
||||
const spanRects = useRef<SpanRect[]>([]);
|
||||
|
||||
// Get span color based on service, error state, and selection
|
||||
const getSpanColor = useCallback(
|
||||
(span: FlamegraphSpan): string => {
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
}
|
||||
|
||||
// Apply selection/hover highlight
|
||||
if (selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId) {
|
||||
const colorObj = Color(color);
|
||||
color = isDarkMode
|
||||
? colorObj.lighten(0.7).hex()
|
||||
: colorObj.darken(0.7).hex();
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
[isDarkMode, selectedSpan, hoveredSpanId],
|
||||
);
|
||||
|
||||
// Draw a single event dot
|
||||
const drawEventDot = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
isError: boolean,
|
||||
): void => {
|
||||
// could be optimized:
|
||||
// ctx.beginPath();
|
||||
// ctx.moveTo(x, y - size/2);
|
||||
// ctx.lineTo(x + size/2, y);
|
||||
// ctx.lineTo(x, y + size/2);
|
||||
// ctx.lineTo(x - size/2, y);
|
||||
// ctx.closePath();
|
||||
// ctx.fill();
|
||||
// ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4); // 45 degrees
|
||||
|
||||
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;
|
||||
ctx.fillRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.strokeRect(
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
-EVENT_DOT_SIZE / 2,
|
||||
EVENT_DOT_SIZE,
|
||||
EVENT_DOT_SIZE,
|
||||
);
|
||||
ctx.restore();
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
// Draw a single span and its events
|
||||
const drawSpan = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
span: FlamegraphSpan,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
levelIndex: number,
|
||||
spanRectsArray: SpanRect[],
|
||||
): void => {
|
||||
const color = getSpanColor(span); // do not depend on hover/clicks
|
||||
const spanY = y + SPAN_BAR_Y_OFFSET;
|
||||
|
||||
// Draw span rectangle
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, spanY, width, SPAN_BAR_HEIGHT, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Store rect for hit testing
|
||||
// consider per level buckets to improve hit testing
|
||||
// So hover can:
|
||||
// compute level from y
|
||||
// search only within that row
|
||||
spanRectsArray.push({
|
||||
span,
|
||||
x,
|
||||
y: spanY,
|
||||
width,
|
||||
height: SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
});
|
||||
|
||||
// Draw events
|
||||
// think about optimizing this.
|
||||
// if span is too small to draw events, skip drawing events???
|
||||
span.event?.forEach((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const eventX = x + (clampedOffset / 100) * width;
|
||||
const eventY = spanY + SPAN_BAR_HEIGHT / 2;
|
||||
|
||||
drawEventDot(ctx, eventX, eventY, event.isError);
|
||||
});
|
||||
},
|
||||
[getSpanColor, drawEventDot],
|
||||
);
|
||||
|
||||
// Draw the flamegraph on canvas
|
||||
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 overscan = 4;
|
||||
|
||||
const firstLevel = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - overscan);
|
||||
const visibleLevelCount =
|
||||
Math.ceil(viewportHeight / ROW_HEIGHT) + 2 * overscan;
|
||||
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
|
||||
|
||||
// ---- Clear only visible region (recommended) ----
|
||||
const clearTop = firstLevel * ROW_HEIGHT;
|
||||
const clearHeight = (lastLevel - firstLevel + 1) * ROW_HEIGHT;
|
||||
ctx.clearRect(0, clearTop, cssWidth, clearHeight);
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
|
||||
// ---- Draw only visible levels ----
|
||||
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
|
||||
const levelSpans = spans[levelIndex];
|
||||
if (!levelSpans) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const y = levelIndex * ROW_HEIGHT;
|
||||
|
||||
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 (already correct)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: minimum 1px width
|
||||
if (width > 0 && width < 1) {
|
||||
width = 1;
|
||||
}
|
||||
|
||||
drawSpan(
|
||||
ctx,
|
||||
span,
|
||||
Math.max(0, leftOffset),
|
||||
y,
|
||||
width,
|
||||
levelIndex,
|
||||
spanRectsArray,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spanRects.current = spanRectsArray;
|
||||
}, [spans, viewStartTs, viewEndTs, scrollTop, drawSpan]);
|
||||
|
||||
// Calculate total canvas height
|
||||
const totalHeight = spans.length * ROW_HEIGHT;
|
||||
|
||||
console.log('time: ', {
|
||||
start: traceMetadata.startTime,
|
||||
end: traceMetadata.endTime,
|
||||
});
|
||||
|
||||
// Initialize time window when trace metadata changes (only if not already set)
|
||||
useEffect(() => {
|
||||
// Only reset if we're at the default view (full trace)
|
||||
// This prevents resetting zoom/pan when metadata updates
|
||||
if (
|
||||
viewStartTs === traceMetadata.startTime &&
|
||||
viewEndTs === traceMetadata.endTime
|
||||
) {
|
||||
// Already at default, no need to update
|
||||
return;
|
||||
}
|
||||
// Only reset if the trace bounds have actually changed significantly
|
||||
const currentSpan = viewEndTs - viewStartTs;
|
||||
const fullSpan = traceMetadata.endTime - traceMetadata.startTime;
|
||||
// If we're zoomed in, preserve the zoom level relative to new bounds
|
||||
if (currentSpan < fullSpan * 0.99) {
|
||||
// We're zoomed in, adjust the window proportionally
|
||||
const ratio = currentSpan / fullSpan;
|
||||
const newSpan = (traceMetadata.endTime - traceMetadata.startTime) * ratio;
|
||||
const center = (viewStartTs + viewEndTs) / 2;
|
||||
const newStart = Math.max(
|
||||
traceMetadata.startTime,
|
||||
Math.min(center - newSpan / 2, traceMetadata.endTime - newSpan),
|
||||
);
|
||||
setViewStartTs(newStart);
|
||||
setViewEndTs(newStart + newSpan);
|
||||
} else {
|
||||
// We're at full view, reset to new full view
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
}
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime, viewStartTs, viewEndTs]);
|
||||
|
||||
// Handle canvas resize with device pixel ratio
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCanvasSize = (): void => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Set CSS size
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${totalHeight}px`;
|
||||
|
||||
// Set actual pixel size (accounting for DPR)
|
||||
// Only update if size actually changed to prevent unnecessary redraws
|
||||
const newWidth = Math.floor(rect.width * dpr);
|
||||
const newHeight = Math.floor(totalHeight * dpr);
|
||||
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
// Redraw with current time window (preserves zoom/pan)
|
||||
drawFlamegraph();
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateCanvasSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Initial size
|
||||
updateCanvasSize();
|
||||
|
||||
// Handle DPR changes (e.g., moving window between screens)
|
||||
const handleDPRChange = (): void => {
|
||||
updateCanvasSize();
|
||||
};
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.addEventListener('change', handleDPRChange);
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
window
|
||||
.matchMedia('(resolution: 1dppx)')
|
||||
.removeEventListener('change', handleDPRChange);
|
||||
};
|
||||
}, [drawFlamegraph, totalHeight]);
|
||||
|
||||
// Re-draw when data changes
|
||||
useEffect(() => {
|
||||
drawFlamegraph();
|
||||
}, [drawFlamegraph]);
|
||||
|
||||
// Find span at given canvas coordinates
|
||||
const findSpanAtPosition = useCallback((x: number, y: number):
|
||||
| SpanRect
|
||||
| undefined => {
|
||||
return spanRects.current.find(
|
||||
(spanRect) =>
|
||||
x >= spanRect.x &&
|
||||
x <= spanRect.x + spanRect.width &&
|
||||
y >= spanRect.y &&
|
||||
y <= spanRect.y + spanRect.height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Utility to convert client coordinates to CSS canvas coordinates
|
||||
const getCanvasPointer = useCallback((clientX: number, clientY: number): {
|
||||
cssX: number;
|
||||
cssY: number;
|
||||
cssWidth: number;
|
||||
} | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const cssWidth = canvas.width / dpr;
|
||||
|
||||
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
|
||||
|
||||
const cssY = clientY - rect.top + scrollTopRef.current;
|
||||
|
||||
return { cssX, cssY, cssWidth };
|
||||
}, []);
|
||||
|
||||
// Handle mouse move for hover and dragging
|
||||
const handleMouseMove = useCallback(
|
||||
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
console.log('event', { clientX: event.clientX, clientY: event.clientY });
|
||||
|
||||
// ---- Dragging (pan in time space) ----
|
||||
if (isDraggingRef.current && dragStartRef.current) {
|
||||
const deltaX = event.clientX - dragStartRef.current.x;
|
||||
const deltaY = event.clientY - dragStartRef.current.y;
|
||||
|
||||
console.log('delta', { deltaY, deltaX });
|
||||
|
||||
const totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
dragDistanceRef.current = totalDistance;
|
||||
|
||||
const timeSpan = viewEndRef.current - viewStartRef.current;
|
||||
const deltaTime = (deltaX / rect.width) * timeSpan;
|
||||
|
||||
const newStart = viewStartRef.current - deltaTime;
|
||||
|
||||
const clampedStart = Math.max(
|
||||
traceMetadata.startTime,
|
||||
Math.min(newStart, traceMetadata.endTime - timeSpan),
|
||||
);
|
||||
|
||||
const clampedEnd = clampedStart + timeSpan;
|
||||
|
||||
setViewStartTs(clampedStart);
|
||||
setViewEndTs(clampedEnd);
|
||||
|
||||
dragStartRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Hover ----
|
||||
const pointer = getCanvasPointer(event.clientX, event.clientY);
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cssX, cssY } = pointer;
|
||||
const hoveredSpan = findSpanAtPosition(cssX, cssY);
|
||||
|
||||
if (hoveredSpan) {
|
||||
setHoveredSpanId(hoveredSpan.span.spanId);
|
||||
setTooltipContent({
|
||||
content: hoveredSpan.span.name,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
canvas.style.cursor = 'pointer';
|
||||
} else {
|
||||
setHoveredSpanId('');
|
||||
setTooltipContent(null);
|
||||
// Set cursor based on space key state when not hovering
|
||||
canvas.style.cursor = isSpacePressed ? 'grab' : 'default';
|
||||
}
|
||||
},
|
||||
[findSpanAtPosition, traceMetadata, getCanvasPointer, isSpacePressed],
|
||||
);
|
||||
|
||||
// Handle key down for space key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
setIsSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
setIsSpacePressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
} // left click only
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
const wasDrag = dragDistanceRef.current > 5;
|
||||
|
||||
suppressClickRef.current = wasDrag; // 👈 key fix: suppress click after drag
|
||||
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
return wasDrag;
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
isOverFlamegraphRef.current = false;
|
||||
|
||||
setHoveredSpanId('');
|
||||
setTooltipContent(null);
|
||||
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
dragDistanceRef.current = 0;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
// Prevent click after drag
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false; // reset after suppressing once
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getCanvasPointer(event.clientX, event.clientY);
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cssX, cssY } = pointer;
|
||||
const clickedSpan = findSpanAtPosition(cssX, cssY);
|
||||
|
||||
if (!clickedSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const currentSpanId = searchParams.get('spanId');
|
||||
|
||||
if (currentSpanId !== clickedSpan.span.spanId) {
|
||||
searchParams.set('spanId', clickedSpan.span.spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[search, history, findSpanAtPosition, getCanvasPointer],
|
||||
);
|
||||
|
||||
const isOverFlamegraphRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Pinch zoom on trackpads often comes as ctrl+wheel
|
||||
if (isOverFlamegraphRef.current && e.ctrlKey) {
|
||||
e.preventDefault(); // stops browser zoom
|
||||
}
|
||||
};
|
||||
|
||||
// capture:true ensures we intercept early
|
||||
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'wheel',
|
||||
onWheel as any,
|
||||
{ capture: true } as any,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const wheelDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastCursorXRef = useRef(0);
|
||||
const lastCssWidthRef = useRef(1);
|
||||
const lastIsPinchRef = useRef(false);
|
||||
|
||||
const applyWheelZoom = useCallback(() => {
|
||||
rafRef.current = null;
|
||||
|
||||
const cssWidth = lastCssWidthRef.current || 1;
|
||||
const cursorX = lastCursorXRef.current;
|
||||
|
||||
const fullSpan = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const oldSpan = viewEndRef.current - viewStartRef.current;
|
||||
|
||||
// ✅ Different intensity for pinch vs scroll
|
||||
const zoomIntensityScroll = 0.0015;
|
||||
const zoomIntensityPinch = 0.01; // pinch needs stronger response
|
||||
const zoomIntensity = lastIsPinchRef.current
|
||||
? zoomIntensityPinch
|
||||
: zoomIntensityScroll;
|
||||
|
||||
const deltaY = wheelDeltaRef.current;
|
||||
wheelDeltaRef.current = 0;
|
||||
|
||||
// ✅ Smooth zoom using delta magnitude
|
||||
const zoomFactor = Math.exp(deltaY * zoomIntensity);
|
||||
const newSpan = oldSpan * zoomFactor;
|
||||
|
||||
console.log('newSpan', { cssWidth, newSpan, zoomFactor, oldSpan });
|
||||
|
||||
// ✅ Better minSpan clamp (absolute + pixel-based)
|
||||
const absoluteMinSpan = 5; // ms
|
||||
const pixelMinSpan = fullSpan / cssWidth; // ~1px of time
|
||||
const minSpan = Math.max(absoluteMinSpan, pixelMinSpan);
|
||||
const maxSpan = fullSpan;
|
||||
|
||||
const clampedSpan = Math.max(minSpan, Math.min(maxSpan, newSpan));
|
||||
|
||||
// ✅ Anchor preserving zoom (same as your original logic)
|
||||
const cursorRatio = Math.max(0, Math.min(cursorX / cssWidth, 1));
|
||||
const anchorTs = viewStartRef.current + cursorRatio * oldSpan;
|
||||
|
||||
const newViewStart = anchorTs - cursorRatio * clampedSpan;
|
||||
|
||||
const finalStart = Math.max(
|
||||
traceMetadata.startTime,
|
||||
Math.min(newViewStart, traceMetadata.endTime - clampedSpan),
|
||||
);
|
||||
const finalEnd = finalStart + clampedSpan;
|
||||
|
||||
console.log('finalStart', finalStart);
|
||||
console.log('finalEnd', finalEnd);
|
||||
setViewStartTs(finalStart);
|
||||
setViewEndTs(finalEnd);
|
||||
}, [traceMetadata]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const pointer = getCanvasPointer(event.clientX, event.clientY);
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('pointer', pointer);
|
||||
|
||||
const { cssX: cursorX, cssWidth } = pointer;
|
||||
|
||||
// ✅ Detect pinch on Chrome/Edge: ctrlKey true for trackpad pinch
|
||||
lastIsPinchRef.current = event.ctrlKey;
|
||||
|
||||
lastCssWidthRef.current = cssWidth;
|
||||
lastCursorXRef.current = cursorX;
|
||||
|
||||
// ✅ Accumulate deltas; apply once per frame
|
||||
wheelDeltaRef.current += event.deltaY;
|
||||
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(applyWheelZoom);
|
||||
}
|
||||
},
|
||||
[applyWheelZoom, getCanvasPointer],
|
||||
);
|
||||
|
||||
// Reset zoom and pan
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
}, [traceMetadata]);
|
||||
|
||||
// Handle scroll for pagination
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget;
|
||||
setScrollTop(target.scrollTop);
|
||||
|
||||
// Pagination logic
|
||||
if (spans.length < 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollPercentage = target.scrollTop / target.scrollHeight;
|
||||
const totalLevels = spans.length;
|
||||
|
||||
if (scrollPercentage === 0 && spans[0]?.[0]?.level !== 0) {
|
||||
setFirstSpanAtFetchLevel(spans[0][0].spanId);
|
||||
}
|
||||
|
||||
if (scrollPercentage >= 0.95 && spans[totalLevels - 1]?.[0]?.spanId) {
|
||||
setFirstSpanAtFetchLevel(spans[totalLevels - 1][0].spanId);
|
||||
}
|
||||
},
|
||||
[spans, setFirstSpanAtFetchLevel],
|
||||
);
|
||||
|
||||
// Auto-scroll to selected span
|
||||
useEffect(() => {
|
||||
if (!firstSpanAtFetchLevel || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levelIndex = spans.findIndex(
|
||||
(level) => level[0]?.spanId === firstSpanAtFetchLevel,
|
||||
);
|
||||
|
||||
if (levelIndex !== -1) {
|
||||
const targetScroll = levelIndex * ROW_HEIGHT;
|
||||
containerRef.current.scrollTop = targetScroll;
|
||||
setScrollTop(targetScroll);
|
||||
}
|
||||
}, [firstSpanAtFetchLevel, spans]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="trace-flamegraph trace-flamegraph-canvas"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(viewStartTs !== traceMetadata.startTime ||
|
||||
viewEndTs !== traceMetadata.endTime) && (
|
||||
<Button
|
||||
className="flamegraph-reset-zoom"
|
||||
size="small"
|
||||
onClick={handleResetZoom}
|
||||
title="Reset zoom and pan"
|
||||
>
|
||||
Reset View
|
||||
</Button>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: `${totalHeight}px`,
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => (isOverFlamegraphRef.current = true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={(e): void => e.preventDefault()}
|
||||
/>
|
||||
{tooltipContent && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${tooltipContent.x + 10}px`,
|
||||
top: `${tooltipContent.y - 10}px`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff',
|
||||
color: isDarkMode ? '#ffffff' : '#000000',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{tooltipContent.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimelineV2
|
||||
startTimestamp={viewStartTs}
|
||||
endTimestamp={viewEndTs}
|
||||
offsetTimestamp={viewStartTs - traceMetadata.startTime}
|
||||
timelineHeight={22}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
|
||||
// on drag on click is getting registered as a click
|
||||
// zoom and scale not matching
|
||||
// check minimap logic
|
||||
// use
|
||||
// const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
// useEffect(() => {
|
||||
// scrollTopRef.current = scrollTop;
|
||||
// }, [scrollTop]);
|
||||
|
||||
// fix clicks in interaction canvas
|
||||
|
||||
// Auto-scroll to selected span else on top(based on default span)
|
||||
// time bar line vertical
|
||||
// zoom handle vertical and horizontal scroll with proper defined thresholds
|
||||
// timeline should be in sync with the flamegraph. test with vertical line of time on event etc.
|
||||
// proper working interaction layer for clicks and hovers
|
||||
// hit testing should be efficient and accurate without flat spanRect
|
||||
|
||||
// Final Priority Order (Clean Summary)
|
||||
// ✅ Zoom (Horizontal + Vertical thresholds)
|
||||
// ✅ Timeline sync + vertical time dashed line
|
||||
// ✅ Minimap brush correctness
|
||||
// ✅ Auto-scroll behavior
|
||||
// ✅ Interaction layer separation
|
||||
// ✅ Efficient hit testing
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,9 @@ const useGetTraceFlamegraph = (
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.limit,
|
||||
props.boundaryStartTsMilli,
|
||||
props.boundarEndTsMilli,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface TraceDetailFlamegraphURLProps {
|
||||
export interface GetTraceFlamegraphPayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId: string;
|
||||
limit: number;
|
||||
boundaryStartTsMilli: number;
|
||||
boundarEndTsMilli: number;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
@@ -31,4 +34,6 @@ export interface GetTraceFlamegraphSuccessResponse {
|
||||
spans: FlamegraphSpan[][];
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
durationNano: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
|
||||
}
|
||||
}
|
||||
resultData.Rows = filteredRows
|
||||
case *qbtypes.ScalarData:
|
||||
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
|
||||
}
|
||||
|
||||
filteredData = append(filteredData, result)
|
||||
@@ -145,6 +147,39 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
|
||||
// Find column indices for server address fields
|
||||
serverColIndices := make([]int, 0)
|
||||
for i, col := range columns {
|
||||
if col.Name == derivedKeyHTTPHost {
|
||||
serverColIndices = append(serverColIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(serverColIndices) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
filtered := make([][]any, 0, len(data))
|
||||
for _, row := range data {
|
||||
includeRow := true
|
||||
for _, colIdx := range serverColIndices {
|
||||
if colIdx < len(row) {
|
||||
if strVal, ok := row[colIdx].(string); ok {
|
||||
if net.ParseIP(strVal) != nil {
|
||||
includeRow = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeRow {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func shouldIncludeRow(row *qbtypes.RawRow) bool {
|
||||
if row.Data != nil {
|
||||
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {
|
||||
|
||||
@@ -117,6 +117,59 @@ func TestFilterResponse(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should filter out IP addresses from scalar data",
|
||||
input: []*qbtypes.QueryRangeResponse{
|
||||
{
|
||||
Data: qbtypes.QueryData{
|
||||
Results: []any{
|
||||
&qbtypes.ScalarData{
|
||||
QueryName: "endpoints",
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: [][]any{
|
||||
{"192.168.1.1", 10},
|
||||
{"example.com", 20},
|
||||
{"10.0.0.1", 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*qbtypes.QueryRangeResponse{
|
||||
{
|
||||
Data: qbtypes.QueryData{
|
||||
Results: []any{
|
||||
&qbtypes.ScalarData{
|
||||
QueryName: "endpoints",
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: [][]any{
|
||||
{"example.com", 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1046,7 +1046,19 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
|
||||
}
|
||||
|
||||
processingPostCache := time.Now()
|
||||
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint := tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
|
||||
limit := min(req.Limit, tracedetail.MaxLimitToSelectAllSpans)
|
||||
selectAllSpans := totalSpans <= uint64(limit)
|
||||
|
||||
var (
|
||||
selectedSpans []*model.Span
|
||||
uncollapsedSpans []string
|
||||
rootServiceName, rootServiceEntryPoint string
|
||||
)
|
||||
if selectAllSpans {
|
||||
selectedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetAllSpans(traceRoots)
|
||||
} else {
|
||||
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
|
||||
}
|
||||
zap.L().Info("getWaterfallSpansForTraceWithMetadata: processing post cache", zap.Duration("duration", time.Since(processingPostCache)), zap.String("traceID", traceID))
|
||||
|
||||
// convert start timestamp to millis because right now frontend is expecting it in millis
|
||||
@@ -1059,7 +1071,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
|
||||
}
|
||||
|
||||
response.Spans = selectedSpans
|
||||
response.UncollapsedSpans = uncollapsedSpans
|
||||
response.UncollapsedSpans = uncollapsedSpans // ignoring if all spans are returning
|
||||
response.StartTimestampMillis = startTime / 1000000
|
||||
response.EndTimestampMillis = endTime / 1000000
|
||||
response.TotalSpansCount = totalSpans
|
||||
@@ -1068,6 +1080,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
|
||||
response.RootServiceEntryPoint = rootServiceEntryPoint
|
||||
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
|
||||
response.HasMissingSpans = hasMissingSpans
|
||||
response.HasMore = !selectAllSpans
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -1199,7 +1212,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
}
|
||||
}
|
||||
|
||||
selectedSpans = tracedetail.GetSelectedSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
|
||||
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
|
||||
traceCache := model.GetFlamegraphSpansForTraceCache{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
@@ -1216,12 +1229,20 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
}
|
||||
|
||||
processingPostCache := time.Now()
|
||||
selectedSpansForRequest := tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, startTime, endTime)
|
||||
zap.L().Info("getFlamegraphSpansForTrace: processing post cache", zap.Duration("duration", time.Since(processingPostCache)), zap.String("traceID", traceID))
|
||||
selectedSpansForRequest := selectedSpans
|
||||
limit := min(req.Limit, tracedetail.MaxLimitWithoutSampling)
|
||||
totalSpanCount := tracedetail.GetTotalSpanCount(selectedSpans)
|
||||
if totalSpanCount > uint64(limit) {
|
||||
boundaryStart, boundaryEnd := utils.MilliToNano(req.BoundaryStartTS), utils.MilliToNano(req.BoundaryEndTS)
|
||||
selectedSpansForRequest = tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, boundaryStart, boundaryEnd)
|
||||
}
|
||||
zap.L().Info("getFlamegraphSpansForTrace: processing post cache", zap.Duration("duration", time.Since(processingPostCache)), zap.String("traceID", traceID),
|
||||
zap.Uint64("totalSpanCount", totalSpanCount))
|
||||
|
||||
trace.Spans = selectedSpansForRequest
|
||||
trace.StartTimestampMillis = startTime / 1000000
|
||||
trace.EndTimestampMillis = endTime / 1000000
|
||||
trace.HasMore = totalSpanCount > uint64(limit)
|
||||
return trace, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH float64 = 50
|
||||
SPAN_LIMIT_PER_LEVEL int = 100
|
||||
TIMESTAMP_SAMPLING_BUCKET_COUNT int = 50
|
||||
flamegraphSpanLevelLimit float64 = 50
|
||||
flamegraphSpanLimitPerLevel int = 1000
|
||||
flamegraphSamplingBucketCount int = 500
|
||||
|
||||
MaxLimitWithoutSampling uint = 120_000
|
||||
)
|
||||
|
||||
func ContainsFlamegraphSpan(slice []*model.FlamegraphSpan, item *model.FlamegraphSpan) bool {
|
||||
@@ -52,7 +54,8 @@ func FindIndexForSelectedSpan(spans [][]*model.FlamegraphSpan, selectedSpanId st
|
||||
return selectedSpanLevel
|
||||
}
|
||||
|
||||
func GetSelectedSpansForFlamegraph(traceRoots []*model.FlamegraphSpan, spanIdToSpanNodeMap map[string]*model.FlamegraphSpan) [][]*model.FlamegraphSpan {
|
||||
// GetAllSpansForFlamegraph groups all spans as per their level
|
||||
func GetAllSpansForFlamegraph(traceRoots []*model.FlamegraphSpan, spanIdToSpanNodeMap map[string]*model.FlamegraphSpan) [][]*model.FlamegraphSpan {
|
||||
|
||||
var traceIdLevelledFlamegraph = map[string]map[int64][]*model.FlamegraphSpan{}
|
||||
selectedSpans := [][]*model.FlamegraphSpan{}
|
||||
@@ -100,7 +103,7 @@ func getLatencyAndTimestampBucketedSpans(spans []*model.FlamegraphSpan, selected
|
||||
})
|
||||
|
||||
// pick the top 5 latency spans
|
||||
for idx := range 5 {
|
||||
for idx := range 100 {
|
||||
sampledSpans = append(sampledSpans, spans[idx])
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ func getLatencyAndTimestampBucketedSpans(spans []*model.FlamegraphSpan, selected
|
||||
for _idx, span := range spans {
|
||||
if span.SpanID == selectedSpanID {
|
||||
idx = _idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx != -1 {
|
||||
@@ -117,17 +121,17 @@ func getLatencyAndTimestampBucketedSpans(spans []*model.FlamegraphSpan, selected
|
||||
}
|
||||
}
|
||||
|
||||
bucketSize := (endTime - startTime) / uint64(TIMESTAMP_SAMPLING_BUCKET_COUNT)
|
||||
bucketSize := (endTime - startTime) / uint64(flamegraphSamplingBucketCount)
|
||||
if bucketSize == 0 {
|
||||
bucketSize = 1
|
||||
}
|
||||
|
||||
bucketedSpans := make([][]*model.FlamegraphSpan, 50)
|
||||
bucketedSpans := make([][]*model.FlamegraphSpan, flamegraphSamplingBucketCount)
|
||||
|
||||
for _, span := range spans {
|
||||
if span.TimeUnixNano >= startTime && span.TimeUnixNano <= endTime {
|
||||
bucketIndex := int((span.TimeUnixNano - startTime) / bucketSize)
|
||||
if bucketIndex >= 0 && bucketIndex < 50 {
|
||||
if bucketIndex >= 0 && bucketIndex < flamegraphSamplingBucketCount {
|
||||
bucketedSpans[bucketIndex] = append(bucketedSpans[bucketIndex], span)
|
||||
}
|
||||
}
|
||||
@@ -156,8 +160,8 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
|
||||
selectedIndex = FindIndexForSelectedSpan(selectedSpans, selectedSpanID)
|
||||
}
|
||||
|
||||
lowerLimit := selectedIndex - int(SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH*0.4)
|
||||
upperLimit := selectedIndex + int(SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH*0.6)
|
||||
lowerLimit := selectedIndex - int(flamegraphSpanLevelLimit*0.4)
|
||||
upperLimit := selectedIndex + int(flamegraphSpanLevelLimit*0.6)
|
||||
|
||||
if lowerLimit < 0 {
|
||||
upperLimit = upperLimit - lowerLimit
|
||||
@@ -174,7 +178,7 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
|
||||
}
|
||||
|
||||
for i := lowerLimit; i < upperLimit; i++ {
|
||||
if len(selectedSpans[i]) > SPAN_LIMIT_PER_LEVEL {
|
||||
if len(selectedSpans[i]) > flamegraphSpanLimitPerLevel {
|
||||
_spans := getLatencyAndTimestampBucketedSpans(selectedSpans[i], selectedSpanID, i == selectedIndex, startTime, endTime)
|
||||
selectedSpansForRequest = append(selectedSpansForRequest, _spans)
|
||||
} else {
|
||||
@@ -184,3 +188,12 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
|
||||
|
||||
return selectedSpansForRequest
|
||||
}
|
||||
|
||||
func GetTotalSpanCount(spans [][]*model.FlamegraphSpan) uint64 {
|
||||
levelCount := len(spans)
|
||||
spanCount := uint64(0)
|
||||
for i := range levelCount {
|
||||
spanCount += uint64(len(spans[i]))
|
||||
}
|
||||
return spanCount
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
|
||||
var (
|
||||
SPAN_LIMIT_PER_REQUEST_FOR_WATERFALL float64 = 500
|
||||
|
||||
maxDepthForSelectedSpanChildren int = 5
|
||||
MaxLimitToSelectAllSpans uint = 10_000
|
||||
)
|
||||
|
||||
type Interval struct {
|
||||
@@ -88,8 +91,11 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
|
||||
return isPresentInSubtreeForTheNode, spansFromRootToNode
|
||||
}
|
||||
|
||||
func traverseTrace(span *model.Span, uncollapsedSpans []string, level uint64, isPartOfPreOrder bool, hasSibling bool, selectedSpanId string) []*model.Span {
|
||||
func traverseTrace(span *model.Span, uncollapsedSpans []string, level uint64, isPartOfPreOrder bool, hasSibling bool, selectedSpanId string,
|
||||
depthFromSelectedSpan int, isSelectedSpanIDUnCollapsed bool, selectAllSpan bool) ([]*model.Span, []string) {
|
||||
|
||||
preOrderTraversal := []*model.Span{}
|
||||
autoExpandedSpans := []string{}
|
||||
|
||||
// sort the children to maintain the order across requests
|
||||
sort.Slice(span.Children, func(i, j int) bool {
|
||||
@@ -126,15 +132,40 @@ func traverseTrace(span *model.Span, uncollapsedSpans []string, level uint64, is
|
||||
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
|
||||
}
|
||||
|
||||
nextDepthFromSelectedSpan := -1
|
||||
if span.SpanID == selectedSpanId && isSelectedSpanIDUnCollapsed {
|
||||
nextDepthFromSelectedSpan = 1
|
||||
} else if depthFromSelectedSpan >= 1 && depthFromSelectedSpan < maxDepthForSelectedSpanChildren {
|
||||
nextDepthFromSelectedSpan = depthFromSelectedSpan + 1
|
||||
}
|
||||
|
||||
for index, child := range span.Children {
|
||||
_childTraversal := traverseTrace(child, uncollapsedSpans, level+1, isPartOfPreOrder && slices.Contains(uncollapsedSpans, span.SpanID), index != (len(span.Children)-1), selectedSpanId)
|
||||
// A child is included in the pre-order output if its parent is uncollapsed
|
||||
// OR if the child falls within MAX_DEPTH_FOR_SELECTED_SPAN_CHILDREN levels
|
||||
// below the selected span.
|
||||
isChildWithinMaxDepth := nextDepthFromSelectedSpan >= 1
|
||||
isAlreadyUncollapsed := slices.Contains(uncollapsedSpans, span.SpanID)
|
||||
childIsPartOfPreOrder := isPartOfPreOrder && (isAlreadyUncollapsed || isChildWithinMaxDepth)
|
||||
if selectAllSpan {
|
||||
childIsPartOfPreOrder = true
|
||||
}
|
||||
|
||||
if isPartOfPreOrder && isChildWithinMaxDepth && !isAlreadyUncollapsed {
|
||||
if !slices.Contains(autoExpandedSpans, span.SpanID) {
|
||||
autoExpandedSpans = append(autoExpandedSpans, span.SpanID)
|
||||
}
|
||||
}
|
||||
|
||||
_childTraversal, _autoExpanded := traverseTrace(child, uncollapsedSpans, level+1, childIsPartOfPreOrder, index != (len(span.Children)-1), selectedSpanId,
|
||||
nextDepthFromSelectedSpan, isSelectedSpanIDUnCollapsed, selectAllSpan)
|
||||
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
|
||||
autoExpandedSpans = append(autoExpandedSpans, _autoExpanded...)
|
||||
nodeWithoutChildren.SubTreeNodeCount += child.SubTreeNodeCount + 1
|
||||
span.SubTreeNodeCount += child.SubTreeNodeCount + 1
|
||||
}
|
||||
|
||||
nodeWithoutChildren.SubTreeNodeCount += 1
|
||||
return preOrderTraversal
|
||||
return preOrderTraversal, autoExpandedSpans
|
||||
|
||||
}
|
||||
|
||||
@@ -168,7 +199,13 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
|
||||
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
|
||||
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
|
||||
|
||||
_preOrderTraversal := traverseTrace(rootNode, updatedUncollapsedSpans, 0, true, false, selectedSpanID)
|
||||
_preOrderTraversal, _autoExpanded := traverseTrace(rootNode, updatedUncollapsedSpans, 0, true, false, selectedSpanID, -1, isSelectedSpanIDUnCollapsed, false)
|
||||
// Merge auto-expanded spans into updatedUncollapsedSpans for returning in response
|
||||
for _, spanID := range _autoExpanded {
|
||||
if !slices.Contains(updatedUncollapsedSpans, spanID) {
|
||||
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spanID)
|
||||
}
|
||||
}
|
||||
_selectedSpanIndex := findIndexForSelectedSpanFromPreOrder(_preOrderTraversal, selectedSpanID)
|
||||
|
||||
if _selectedSpanIndex != -1 {
|
||||
@@ -212,3 +249,17 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
|
||||
|
||||
return preOrderTraversal[startIndex:endIndex], updatedUncollapsedSpans, rootServiceName, rootServiceEntryPoint
|
||||
}
|
||||
|
||||
func GetAllSpans(traceRoots []*model.Span) (spans []*model.Span, rootServiceName, rootEntryPoint string) {
|
||||
for _, root := range traceRoots {
|
||||
childSpans, _ := traverseTrace(root, nil, 0, true, false, "", -1, false, true)
|
||||
spans = append(spans, childSpans...)
|
||||
if rootServiceName == "" {
|
||||
rootServiceName = root.ServiceName
|
||||
}
|
||||
if rootEntryPoint == "" {
|
||||
rootEntryPoint = root.Name
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -333,10 +333,14 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
IsSelectedSpanIDUnCollapsed bool `json:"isSelectedSpanIDUnCollapsed"`
|
||||
UncollapsedSpans []string `json:"uncollapsedSpans"`
|
||||
Limit uint `json:"limit"`
|
||||
}
|
||||
|
||||
type GetFlamegraphSpansForTraceParams struct {
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
Limit uint `json:"limit"`
|
||||
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
|
||||
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
|
||||
}
|
||||
|
||||
type SpanFilterParams struct {
|
||||
|
||||
@@ -329,6 +329,7 @@ type GetWaterfallSpansForTraceWithMetadataResponse struct {
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
// this is needed for frontend and query service sync
|
||||
UncollapsedSpans []string `json:"uncollapsedSpans"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type GetFlamegraphSpansForTraceResponse struct {
|
||||
@@ -336,6 +337,7 @@ type GetFlamegraphSpansForTraceResponse struct {
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis"`
|
||||
DurationNano uint64 `json:"durationNano"`
|
||||
Spans [][]*FlamegraphSpan `json:"spans"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type OtelSpanRef struct {
|
||||
|
||||
@@ -17,3 +17,7 @@ func Elapsed(funcName string, args map[string]interface{}) func() {
|
||||
zap.L().Info("Elapsed time", zapFields...)
|
||||
}
|
||||
}
|
||||
|
||||
func MilliToNano(milliTS uint64) uint64 {
|
||||
return milliTS * 1000_000
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user