mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import type {LegacyRef, MutableRefObject} from 'react';
|
|
import {Fragment, useCallback, useEffect, useMemo, useRef} from 'react';
|
|
import {useTheme} from '@emotion/react';
|
|
import maxBy from 'lodash/maxBy';
|
|
|
|
import Count from 'sentry/components/count';
|
|
import {
|
|
FREQUENCY_BOX_WIDTH,
|
|
SpanFrequencyBox,
|
|
} from 'sentry/components/events/interfaces/spans/spanFrequencyBox';
|
|
import type {SpanBarType} from 'sentry/components/performance/waterfall/constants';
|
|
import {
|
|
getSpanBarColours,
|
|
ROW_HEIGHT,
|
|
} from 'sentry/components/performance/waterfall/constants';
|
|
import {
|
|
Row,
|
|
RowCell,
|
|
RowCellContainer,
|
|
} from 'sentry/components/performance/waterfall/row';
|
|
import {
|
|
DividerContainer,
|
|
DividerLine,
|
|
DividerLineGhostContainer,
|
|
} from 'sentry/components/performance/waterfall/rowDivider';
|
|
import {
|
|
RowTitle,
|
|
RowTitleContainer,
|
|
SpanGroupRowTitleContent,
|
|
} from 'sentry/components/performance/waterfall/rowTitle';
|
|
import {
|
|
TOGGLE_BORDER_BOX,
|
|
TreeToggle,
|
|
TreeToggleContainer,
|
|
} from 'sentry/components/performance/waterfall/treeConnector';
|
|
import type {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
|
|
import {EventOrGroupType} from 'sentry/types/event';
|
|
import {defined} from 'sentry/utils';
|
|
import toPercent from 'sentry/utils/number/toPercent';
|
|
import {PerformanceInteraction} from 'sentry/utils/performanceForSentry';
|
|
|
|
import * as DividerHandlerManager from './dividerHandlerManager';
|
|
import SpanBarCursorGuide from './spanBarCursorGuide';
|
|
import {MeasurementMarker} from './styles';
|
|
import type {AggregateSpanType, EnhancedSpan, ProcessedSpanType} from './types';
|
|
import type {SpanBoundsType, SpanGeneratedBoundsType, VerticalMark} from './utils';
|
|
import {getMeasurementBounds, getMeasurements, spanTargetHash} from './utils';
|
|
|
|
const MARGIN_LEFT = 0;
|
|
|
|
type Props = {
|
|
addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
|
|
didAnchoredSpanMount: () => boolean;
|
|
event: Readonly<EventTransaction | AggregateEventTransaction>;
|
|
generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
|
|
getCurrentLeftPos: () => number;
|
|
onWheel: (deltaX: number) => void;
|
|
removeContentSpanBarRef: (instance: HTMLDivElement | null) => void;
|
|
renderGroupSpansTitle: () => React.ReactNode;
|
|
renderSpanRectangles: () => React.ReactNode;
|
|
renderSpanTreeConnector: () => React.ReactNode;
|
|
span: Readonly<ProcessedSpanType>;
|
|
spanGrouping: EnhancedSpan[];
|
|
spanNumber: number;
|
|
toggleSpanGroup: () => void;
|
|
treeDepth: number;
|
|
measurements?: Map<number, VerticalMark>;
|
|
spanBarType?: SpanBarType;
|
|
};
|
|
|
|
function renderGroupedSpansToggler(props: Props) {
|
|
const {
|
|
treeDepth,
|
|
spanGrouping,
|
|
renderSpanTreeConnector,
|
|
toggleSpanGroup,
|
|
spanBarType,
|
|
event,
|
|
} = props;
|
|
|
|
const isAggregateEvent = event.type === EventOrGroupType.AGGREGATE_TRANSACTION;
|
|
|
|
const left =
|
|
treeDepth * (TOGGLE_BORDER_BOX / 2) +
|
|
MARGIN_LEFT +
|
|
(isAggregateEvent ? FREQUENCY_BOX_WIDTH : 0);
|
|
|
|
return (
|
|
<TreeToggleContainer style={{left: `${left}px`}} hasToggler>
|
|
{renderSpanTreeConnector()}
|
|
<TreeToggle
|
|
disabled={false}
|
|
isExpanded={false}
|
|
errored={false}
|
|
isSpanGroupToggler
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
toggleSpanGroup();
|
|
}}
|
|
spanBarType={spanBarType}
|
|
>
|
|
<Count value={spanGrouping.length} />
|
|
</TreeToggle>
|
|
</TreeToggleContainer>
|
|
);
|
|
}
|
|
|
|
function renderDivider(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) {
|
|
const {addDividerLineRef} = dividerHandlerChildrenProps;
|
|
|
|
return (
|
|
<DividerLine
|
|
ref={addDividerLineRef()}
|
|
style={{
|
|
position: 'absolute',
|
|
}}
|
|
onMouseEnter={() => {
|
|
dividerHandlerChildrenProps.setHover(true);
|
|
}}
|
|
onMouseLeave={() => {
|
|
dividerHandlerChildrenProps.setHover(false);
|
|
}}
|
|
onMouseOver={() => {
|
|
dividerHandlerChildrenProps.setHover(true);
|
|
}}
|
|
onMouseDown={dividerHandlerChildrenProps.onDragStart}
|
|
onClick={e => {
|
|
// we prevent the propagation of the clicks from this component to prevent
|
|
// the span detail from being opened.
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderMeasurements(
|
|
event: Readonly<EventTransaction | AggregateEventTransaction>,
|
|
generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType,
|
|
measurements: Map<number, VerticalMark> | undefined
|
|
) {
|
|
const barMeasurements = measurements ?? getMeasurements(event, generateBounds);
|
|
|
|
return (
|
|
<Fragment>
|
|
{Array.from(barMeasurements).map(([timestamp, verticalMark]) => {
|
|
const bounds = getMeasurementBounds(timestamp, generateBounds);
|
|
|
|
const shouldDisplay = defined(bounds.left) && defined(bounds.width);
|
|
|
|
if (!shouldDisplay || !bounds.isSpanVisibleInView) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<MeasurementMarker
|
|
key={String(timestamp)}
|
|
style={{
|
|
left: `clamp(0%, ${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
|
|
}}
|
|
failedThreshold={verticalMark.failedThreshold}
|
|
/>
|
|
);
|
|
})}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
export function SpanGroupBar(props: Props) {
|
|
const spanTitleRef: LegacyRef<HTMLDivElement> | null = useRef(null);
|
|
const spanContentRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
|
|
|
|
const {
|
|
onWheel,
|
|
addContentSpanBarRef,
|
|
removeContentSpanBarRef,
|
|
didAnchoredSpanMount,
|
|
spanGrouping,
|
|
toggleSpanGroup,
|
|
getCurrentLeftPos,
|
|
spanBarType,
|
|
event,
|
|
measurements,
|
|
} = props;
|
|
|
|
const theme = useTheme();
|
|
|
|
// On mount, it is necessary to set the left styling of the content here due to the span tree being virtualized.
|
|
// If we rely on the scrollBarManager to set the styling, it happens too late and awkwardly applies an animation.
|
|
const setTransformCallback = useCallback(
|
|
(ref: HTMLDivElement | null) => {
|
|
if (ref) {
|
|
spanContentRef.current = ref;
|
|
addContentSpanBarRef(ref);
|
|
const left = -getCurrentLeftPos();
|
|
ref.style.transform = `translateX(${left}px)`;
|
|
ref.style.transformOrigin = 'left';
|
|
return;
|
|
}
|
|
|
|
// If ref is null, this means the component is about to unmount
|
|
removeContentSpanBarRef(spanContentRef.current);
|
|
},
|
|
[addContentSpanBarRef, removeContentSpanBarRef, getCurrentLeftPos]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (location.hash && !didAnchoredSpanMount()) {
|
|
const anchoredSpanIndex = spanGrouping.findIndex(
|
|
span => spanTargetHash(span.span.span_id) === location.hash
|
|
);
|
|
|
|
// TODO: This doesn't always work.
|
|
// A potential fix is to just scroll to the Autogroup without expanding it if a span within it is anchored.
|
|
if (anchoredSpanIndex > -1) {
|
|
toggleSpanGroup();
|
|
window.scrollTo(0, window.scrollY + anchoredSpanIndex * ROW_HEIGHT);
|
|
}
|
|
}
|
|
}, [didAnchoredSpanMount, spanGrouping, toggleSpanGroup]);
|
|
|
|
useEffect(() => {
|
|
const currentRef = spanTitleRef.current;
|
|
const handleWheel = (e: WheelEvent) => {
|
|
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (Math.abs(e.deltaY) === Math.abs(e.deltaX)) {
|
|
return;
|
|
}
|
|
|
|
onWheel(e.deltaX);
|
|
};
|
|
|
|
if (currentRef) {
|
|
currentRef.addEventListener('wheel', handleWheel, {
|
|
passive: false,
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
if (currentRef) {
|
|
currentRef.removeEventListener('wheel', handleWheel);
|
|
}
|
|
};
|
|
}, [onWheel]);
|
|
|
|
// If this is an aggregate span waterfall, we will use the span with the highest frequency in the grouping to represent
|
|
// the value shown in the frequency box. Using a memo because otherwise this operation will fire on every vertical scroll tick
|
|
const mostFrequentSpanInGroup = useMemo(() => {
|
|
if (event.type !== EventOrGroupType.AGGREGATE_TRANSACTION) {
|
|
return null;
|
|
}
|
|
|
|
const spanObjects = spanGrouping.map(({span}) => span);
|
|
|
|
return maxBy(spanObjects, 'frequency');
|
|
}, [event, spanGrouping]);
|
|
|
|
return (
|
|
<DividerHandlerManager.Consumer>
|
|
{(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) => {
|
|
const {generateBounds, span, treeDepth, spanNumber} = props;
|
|
|
|
const {isSpanVisibleInView: isSpanVisible} = generateBounds({
|
|
startTimestamp: span.start_timestamp,
|
|
endTimestamp: span.timestamp,
|
|
});
|
|
|
|
const isAggregateEvent =
|
|
event.type === EventOrGroupType.AGGREGATE_TRANSACTION &&
|
|
mostFrequentSpanInGroup;
|
|
|
|
const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps;
|
|
const left =
|
|
treeDepth * (TOGGLE_BORDER_BOX / 2) +
|
|
MARGIN_LEFT +
|
|
(isAggregateEvent ? FREQUENCY_BOX_WIDTH : 0);
|
|
|
|
return (
|
|
<Row
|
|
visible={isSpanVisible}
|
|
showBorder={false}
|
|
data-test-id={`span-row-${spanNumber}`}
|
|
>
|
|
<RowCellContainer>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
style={{
|
|
width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
|
|
paddingTop: 0,
|
|
}}
|
|
onClick={() => {
|
|
PerformanceInteraction.startInteraction('SpanTreeToggle', 1000 * 10);
|
|
props.toggleSpanGroup();
|
|
}}
|
|
ref={spanTitleRef}
|
|
>
|
|
{isAggregateEvent && (
|
|
<SpanFrequencyBox span={mostFrequentSpanInGroup as AggregateSpanType} />
|
|
)}
|
|
<RowTitleContainer ref={setTransformCallback}>
|
|
{renderGroupedSpansToggler(props)}
|
|
<RowTitle
|
|
style={{
|
|
left: `${left}px`,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<SpanGroupRowTitleContent
|
|
color={getSpanBarColours(spanBarType, theme).primary}
|
|
>
|
|
{props.renderGroupSpansTitle()}
|
|
</SpanGroupRowTitleContent>
|
|
</RowTitle>
|
|
</RowTitleContainer>
|
|
</RowCell>
|
|
<DividerContainer>
|
|
{renderDivider(dividerHandlerChildrenProps)}
|
|
</DividerContainer>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
showStriping={spanNumber % 2 !== 0}
|
|
style={{
|
|
width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
|
|
}}
|
|
onClick={() => toggleSpanGroup()}
|
|
>
|
|
{props.renderSpanRectangles()}
|
|
{renderMeasurements(event, generateBounds, measurements)}
|
|
<SpanBarCursorGuide />
|
|
</RowCell>
|
|
<DividerLineGhostContainer
|
|
style={{
|
|
width: `calc(${toPercent(dividerPosition)} + 0.5px)`,
|
|
display: 'none',
|
|
}}
|
|
>
|
|
<DividerLine
|
|
ref={addGhostDividerLineRef()}
|
|
style={{
|
|
right: 0,
|
|
}}
|
|
className="hovering"
|
|
onClick={e => {
|
|
// the ghost divider line should not be interactive.
|
|
// we prevent the propagation of the clicks from this component to prevent
|
|
// the span detail from being opened.
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
</DividerLineGhostContainer>
|
|
</RowCellContainer>
|
|
</Row>
|
|
);
|
|
}}
|
|
</DividerHandlerManager.Consumer>
|
|
);
|
|
}
|