mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1246 lines
37 KiB
TypeScript
1246 lines
37 KiB
TypeScript
import 'intersection-observer'; // this is a polyfill
|
|
|
|
import {Component, createRef, Fragment} from 'react';
|
|
import type {CellMeasurerCache, List as ReactVirtualizedList} from 'react-virtualized';
|
|
import styled from '@emotion/styled';
|
|
import {withProfiler} from '@sentry/react';
|
|
|
|
import Count from 'sentry/components/count';
|
|
import AggregateSpanDetail from 'sentry/components/events/interfaces/spans/aggregateSpanDetail';
|
|
import {
|
|
FREQUENCY_BOX_WIDTH,
|
|
SpanFrequencyBox,
|
|
} from 'sentry/components/events/interfaces/spans/spanFrequencyBox';
|
|
import type {SpanBarType} from 'sentry/components/performance/waterfall/constants';
|
|
import {ROW_HEIGHT} from 'sentry/components/performance/waterfall/constants';
|
|
import {MessageRow} from 'sentry/components/performance/waterfall/messageRow';
|
|
import {
|
|
Row,
|
|
RowCell,
|
|
RowCellContainer,
|
|
} from 'sentry/components/performance/waterfall/row';
|
|
import {DurationPill, RowRectangle} from 'sentry/components/performance/waterfall/rowBar';
|
|
import {
|
|
DividerContainer,
|
|
DividerLine,
|
|
DividerLineGhostContainer,
|
|
EmbeddedTransactionBadge,
|
|
ErrorBadge,
|
|
ProfileBadge,
|
|
} from 'sentry/components/performance/waterfall/rowDivider';
|
|
import {
|
|
RowTitle,
|
|
RowTitleContainer,
|
|
RowTitleContent,
|
|
} from 'sentry/components/performance/waterfall/rowTitle';
|
|
import {
|
|
ConnectorBar,
|
|
TOGGLE_BORDER_BOX,
|
|
TreeConnector,
|
|
TreeToggle,
|
|
TreeToggleContainer,
|
|
TreeToggleIcon,
|
|
} from 'sentry/components/performance/waterfall/treeConnector';
|
|
import {
|
|
getDurationDisplay,
|
|
getHumanDuration,
|
|
lightenBarColor,
|
|
} from 'sentry/components/performance/waterfall/utils';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {IconWarning} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization} from 'sentry/types';
|
|
import type {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
|
|
import {EventOrGroupType} from 'sentry/types/event';
|
|
import {defined} from 'sentry/utils';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import {generateEventSlug} from 'sentry/utils/discover/urls';
|
|
import toPercent from 'sentry/utils/number/toPercent';
|
|
import type {QuickTraceContextChildrenProps} from 'sentry/utils/performance/quickTrace/quickTraceContext';
|
|
import {QuickTraceContext} from 'sentry/utils/performance/quickTrace/quickTraceContext';
|
|
import type {
|
|
QuickTraceEvent,
|
|
TraceErrorOrIssue,
|
|
TraceFull,
|
|
} from 'sentry/utils/performance/quickTrace/types';
|
|
import {isTraceTransaction} from 'sentry/utils/performance/quickTrace/utils';
|
|
import {PerformanceInteraction} from 'sentry/utils/performanceForSentry';
|
|
import {ProfileContext} from 'sentry/views/profiling/profilesProvider';
|
|
|
|
import {
|
|
MINIMAP_CONTAINER_HEIGHT,
|
|
MINIMAP_SPAN_BAR_HEIGHT,
|
|
NUM_OF_SPANS_FIT_IN_MINI_MAP,
|
|
} from './constants';
|
|
import * as DividerHandlerManager from './dividerHandlerManager';
|
|
import type {NewTraceDetailsSpanBar} from './newTraceDetailsSpanBar';
|
|
import type {ScrollbarManagerChildrenProps} from './scrollbarManager';
|
|
import {withScrollbarManager} from './scrollbarManager';
|
|
import SpanBarCursorGuide from './spanBarCursorGuide';
|
|
import SpanDetail from './spanDetail';
|
|
import {MeasurementMarker} from './styles';
|
|
import type {
|
|
AggregateSpanType,
|
|
FetchEmbeddedChildrenState,
|
|
GapSpanType,
|
|
ParsedTraceType,
|
|
ProcessedSpanType,
|
|
SpanType,
|
|
TreeDepthType,
|
|
} from './types';
|
|
import {GroupType} from './types';
|
|
import type {SpanBoundsType, SpanGeneratedBoundsType, SpanViewBoundsType} from './utils';
|
|
import {
|
|
durationlessBrowserOps,
|
|
formatSpanTreeLabel,
|
|
getMeasurementBounds,
|
|
getMeasurements,
|
|
getSpanID,
|
|
getSpanOperation,
|
|
getSpanSubTimings,
|
|
isEventFromBrowserJavaScriptSDK,
|
|
isGapSpan,
|
|
isOrphanSpan,
|
|
isOrphanTreeDepth,
|
|
shouldLimitAffectedToTiming,
|
|
spanTargetHash,
|
|
unwrapTreeDepth,
|
|
} from './utils';
|
|
|
|
// TODO: maybe use babel-plugin-preval
|
|
// for (let i = 0; i <= 1.0; i += 0.01) {
|
|
// INTERSECTION_THRESHOLDS.push(i);
|
|
// }
|
|
const INTERSECTION_THRESHOLDS: Array<number> = [
|
|
0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.11, 0.12, 0.13, 0.14,
|
|
0.15, 0.16, 0.17, 0.18, 0.19, 0.2, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27, 0.28, 0.29,
|
|
0.3, 0.31, 0.32, 0.33, 0.34, 0.35, 0.36, 0.37, 0.38, 0.39, 0.4, 0.41, 0.42, 0.43, 0.44,
|
|
0.45, 0.46, 0.47, 0.48, 0.49, 0.5, 0.51, 0.52, 0.53, 0.54, 0.55, 0.56, 0.57, 0.58, 0.59,
|
|
0.6, 0.61, 0.62, 0.63, 0.64, 0.65, 0.66, 0.67, 0.68, 0.69, 0.7, 0.71, 0.72, 0.73, 0.74,
|
|
0.75, 0.76, 0.77, 0.78, 0.79, 0.8, 0.81, 0.82, 0.83, 0.84, 0.85, 0.86, 0.87, 0.88, 0.89,
|
|
0.9, 0.91, 0.92, 0.93, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99, 1.0,
|
|
];
|
|
|
|
export const MARGIN_LEFT = 0;
|
|
|
|
export type SpanBarProps = ScrollbarManagerChildrenProps & {
|
|
addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
|
|
addExpandedSpan: (span: Readonly<ProcessedSpanType>, callback?: () => void) => void;
|
|
cellMeasurerCache: CellMeasurerCache;
|
|
continuingTreeDepths: Array<TreeDepthType>;
|
|
didAnchoredSpanMount: () => boolean;
|
|
event: Readonly<EventTransaction | AggregateEventTransaction>;
|
|
fetchEmbeddedChildrenState: FetchEmbeddedChildrenState;
|
|
generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
|
|
getCurrentLeftPos: () => number;
|
|
isEmbeddedTransactionTimeAdjusted: boolean;
|
|
isSpanExpanded: (span: Readonly<ProcessedSpanType>) => boolean;
|
|
isSpanInEmbeddedTree: boolean;
|
|
listRef: React.RefObject<ReactVirtualizedList>;
|
|
numOfSpanChildren: number;
|
|
numOfSpans: number;
|
|
onWheel: (deltaX: number) => void;
|
|
organization: Organization;
|
|
removeContentSpanBarRef: (instance: HTMLDivElement | null) => void;
|
|
removeExpandedSpan: (span: Readonly<ProcessedSpanType>, callback?: () => void) => void;
|
|
resetCellMeasureCache: () => void;
|
|
showEmbeddedChildren: boolean;
|
|
showSpanTree: boolean;
|
|
span: ProcessedSpanType | AggregateSpanType;
|
|
spanNumber: number;
|
|
storeSpanBar: (spanBar: SpanBar | NewTraceDetailsSpanBar) => void;
|
|
toggleEmbeddedChildren:
|
|
| (((orgSlug: string, eventSlugs: string[]) => void) | undefined)
|
|
| undefined;
|
|
toggleSpanGroup: (() => void) | undefined;
|
|
toggleSpanTree: () => void;
|
|
trace: Readonly<ParsedTraceType>;
|
|
treeDepth: number;
|
|
fromTraceView?: boolean;
|
|
groupOccurrence?: number;
|
|
groupType?: GroupType;
|
|
isLast?: boolean;
|
|
isRoot?: boolean;
|
|
markAnchoredSpanIsMounted?: () => void;
|
|
measure?: () => void;
|
|
spanBarColor?: string;
|
|
spanBarType?: SpanBarType;
|
|
toggleSiblingSpanGroup?: ((span: SpanType, occurrence: number) => void) | undefined;
|
|
};
|
|
|
|
type SpanBarState = {
|
|
showDetail: boolean;
|
|
};
|
|
|
|
export class SpanBar extends Component<SpanBarProps, SpanBarState> {
|
|
state: SpanBarState = {
|
|
showDetail: false,
|
|
};
|
|
|
|
componentDidMount() {
|
|
this._mounted = true;
|
|
if (this.spanRowDOMRef.current) {
|
|
this.props.storeSpanBar(this);
|
|
this.connectObservers();
|
|
}
|
|
|
|
if (this.spanTitleRef.current) {
|
|
this.spanTitleRef.current.addEventListener('wheel', this.handleWheel, {
|
|
passive: false,
|
|
});
|
|
}
|
|
|
|
// 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.
|
|
if (this.spanContentRef) {
|
|
this.props.addContentSpanBarRef(this.spanContentRef);
|
|
const left = -this.props.getCurrentLeftPos();
|
|
this.spanContentRef.style.transform = `translateX(${left}px)`;
|
|
this.spanContentRef.style.transformOrigin = 'left';
|
|
}
|
|
|
|
const {
|
|
span,
|
|
markAnchoredSpanIsMounted,
|
|
addExpandedSpan,
|
|
isSpanExpanded,
|
|
measure,
|
|
didAnchoredSpanMount,
|
|
} = this.props;
|
|
|
|
if (isGapSpan(span)) {
|
|
return;
|
|
}
|
|
|
|
if (spanTargetHash(span.span_id) === location.hash && !didAnchoredSpanMount()) {
|
|
this.scrollIntoView();
|
|
markAnchoredSpanIsMounted?.();
|
|
addExpandedSpan(span);
|
|
return;
|
|
}
|
|
|
|
if (isSpanExpanded(span)) {
|
|
this.setState({showDetail: true}, measure);
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this._mounted = false;
|
|
this.disconnectObservers();
|
|
|
|
if (this.spanTitleRef.current) {
|
|
this.spanTitleRef.current.removeEventListener('wheel', this.handleWheel);
|
|
}
|
|
|
|
const {span} = this.props;
|
|
if (isGapSpan(span)) {
|
|
return;
|
|
}
|
|
|
|
this.props.removeContentSpanBarRef(this.spanContentRef);
|
|
}
|
|
|
|
spanRowDOMRef = createRef<HTMLDivElement>();
|
|
spanTitleRef = createRef<HTMLDivElement>();
|
|
|
|
spanContentRef: HTMLDivElement | null = null;
|
|
intersectionObserver?: IntersectionObserver = void 0;
|
|
zoomLevel: number = 1; // assume initial zoomLevel is 100%
|
|
_mounted: boolean = false;
|
|
|
|
handleWheel = (event: WheelEvent) => {
|
|
// https://stackoverflow.com/q/57358640
|
|
// https://github.com/facebook/react/issues/14856
|
|
if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) {
|
|
return;
|
|
}
|
|
|
|
const {onWheel} = this.props;
|
|
onWheel(event.deltaX);
|
|
};
|
|
|
|
toggleDisplayDetail = () => {
|
|
this.setState(
|
|
state => ({
|
|
showDetail: !state.showDetail,
|
|
}),
|
|
() => {
|
|
const {measure, span, addExpandedSpan, removeExpandedSpan} = this.props;
|
|
|
|
this.state.showDetail
|
|
? addExpandedSpan(span, measure)
|
|
: removeExpandedSpan(span, measure);
|
|
}
|
|
);
|
|
};
|
|
|
|
scrollIntoView = () => {
|
|
const {addExpandedSpan, span, measure} = this.props;
|
|
|
|
const element = this.spanRowDOMRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
this.setState({showDetail: true}, () => {
|
|
addExpandedSpan(span, measure);
|
|
|
|
const boundingRect = element.getBoundingClientRect();
|
|
// The extra 1 pixel is necessary so that the span is recognized as in view by the IntersectionObserver
|
|
const offset = boundingRect.top + window.scrollY - MINIMAP_CONTAINER_HEIGHT - 1;
|
|
window.scrollTo(0, offset);
|
|
});
|
|
};
|
|
|
|
renderDetail({
|
|
isVisible,
|
|
transactions,
|
|
errors,
|
|
}: {
|
|
errors: TraceErrorOrIssue[] | null;
|
|
isVisible: boolean;
|
|
transactions: QuickTraceEvent[] | null;
|
|
}) {
|
|
const {span, organization, isRoot, trace, event} = this.props;
|
|
|
|
if (!this.state.showDetail || !isVisible) {
|
|
return null;
|
|
}
|
|
|
|
const isAggregateEvent = event.type === EventOrGroupType.AGGREGATE_TRANSACTION;
|
|
|
|
if (isAggregateEvent) {
|
|
return (
|
|
<AggregateSpanDetail
|
|
span={span as AggregateSpanType}
|
|
organization={organization}
|
|
event={event}
|
|
isRoot={!!isRoot}
|
|
trace={trace}
|
|
childTransactions={transactions}
|
|
relatedErrors={errors}
|
|
scrollToHash={this.scrollIntoView}
|
|
resetCellMeasureCache={this.props.resetCellMeasureCache}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SpanDetail
|
|
span={span as ProcessedSpanType}
|
|
organization={organization}
|
|
event={event as EventTransaction}
|
|
isRoot={!!isRoot}
|
|
trace={trace}
|
|
childTransactions={transactions}
|
|
relatedErrors={errors}
|
|
scrollToHash={this.scrollIntoView}
|
|
resetCellMeasureCache={this.props.resetCellMeasureCache}
|
|
/>
|
|
);
|
|
}
|
|
|
|
getBounds(bounds?: SpanGeneratedBoundsType): SpanViewBoundsType {
|
|
const {event, span, generateBounds} = this.props;
|
|
|
|
bounds ??= generateBounds({
|
|
startTimestamp: span.start_timestamp,
|
|
endTimestamp: span.timestamp,
|
|
});
|
|
|
|
const shouldHideSpanWarnings = isEventFromBrowserJavaScriptSDK(event);
|
|
|
|
switch (bounds.type) {
|
|
case 'TRACE_TIMESTAMPS_EQUAL': {
|
|
return {
|
|
warning: t('Trace times are equal'),
|
|
left: void 0,
|
|
width: void 0,
|
|
isSpanVisibleInView: bounds.isSpanVisibleInView,
|
|
};
|
|
}
|
|
case 'INVALID_VIEW_WINDOW': {
|
|
return {
|
|
warning: t('Invalid view window'),
|
|
left: void 0,
|
|
width: void 0,
|
|
isSpanVisibleInView: bounds.isSpanVisibleInView,
|
|
};
|
|
}
|
|
case 'TIMESTAMPS_EQUAL': {
|
|
const warning =
|
|
shouldHideSpanWarnings &&
|
|
'op' in span &&
|
|
span.op &&
|
|
durationlessBrowserOps.includes(span.op)
|
|
? void 0
|
|
: t('Equal start and end times');
|
|
return {
|
|
warning,
|
|
left: bounds.start,
|
|
width: 0.00001,
|
|
isSpanVisibleInView: bounds.isSpanVisibleInView,
|
|
};
|
|
}
|
|
case 'TIMESTAMPS_REVERSED': {
|
|
return {
|
|
warning: t('Reversed start and end times'),
|
|
left: bounds.start,
|
|
width: bounds.end - bounds.start,
|
|
isSpanVisibleInView: bounds.isSpanVisibleInView,
|
|
};
|
|
}
|
|
case 'TIMESTAMPS_STABLE': {
|
|
return {
|
|
warning: void 0,
|
|
left: bounds.start,
|
|
width: bounds.end - bounds.start,
|
|
isSpanVisibleInView: bounds.isSpanVisibleInView,
|
|
};
|
|
}
|
|
default: {
|
|
const _exhaustiveCheck: never = bounds;
|
|
return _exhaustiveCheck;
|
|
}
|
|
}
|
|
}
|
|
|
|
renderMeasurements() {
|
|
const {event, generateBounds} = this.props;
|
|
|
|
if (this.state.showDetail) {
|
|
return null;
|
|
}
|
|
|
|
const measurements = getMeasurements(event, generateBounds);
|
|
|
|
return (
|
|
<Fragment>
|
|
{Array.from(measurements.values()).map(verticalMark => {
|
|
const mark = Object.values(verticalMark.marks)[0];
|
|
const {timestamp} = mark;
|
|
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>
|
|
);
|
|
}
|
|
|
|
renderSpanTreeConnector({hasToggler}: {hasToggler: boolean}) {
|
|
const {
|
|
isLast,
|
|
isRoot,
|
|
treeDepth: spanTreeDepth,
|
|
continuingTreeDepths,
|
|
span,
|
|
showSpanTree,
|
|
} = this.props;
|
|
|
|
const spanID = getSpanID(span);
|
|
|
|
if (isRoot) {
|
|
if (hasToggler) {
|
|
return (
|
|
<ConnectorBar
|
|
style={{right: '15px', height: '10px', bottom: '-5px', top: 'auto'}}
|
|
key={`${spanID}-last`}
|
|
orphanBranch={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const connectorBars: Array<React.ReactNode> = continuingTreeDepths.map(treeDepth => {
|
|
const depth: number = unwrapTreeDepth(treeDepth);
|
|
|
|
if (depth === 0) {
|
|
// do not render a connector bar at depth 0,
|
|
// if we did render a connector bar, this bar would be placed at depth -1
|
|
// which does not exist.
|
|
return null;
|
|
}
|
|
const left = ((spanTreeDepth - depth) * (TOGGLE_BORDER_BOX / 2) + 2) * -1;
|
|
|
|
return (
|
|
<ConnectorBar
|
|
style={{left}}
|
|
key={`${spanID}-${depth}`}
|
|
orphanBranch={isOrphanTreeDepth(treeDepth)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
if (hasToggler && showSpanTree) {
|
|
// if there is a toggle button, we add a connector bar to create an attachment
|
|
// between the toggle button and any connector bars below the toggle button
|
|
connectorBars.push(
|
|
<ConnectorBar
|
|
style={{
|
|
right: '15px',
|
|
height: `${ROW_HEIGHT / 2}px`,
|
|
bottom: isLast ? `-${ROW_HEIGHT / 2 + 2}px` : '0',
|
|
top: 'auto',
|
|
}}
|
|
key={`${spanID}-last-bottom`}
|
|
orphanBranch={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TreeConnector
|
|
isLast={isLast}
|
|
hasToggler={hasToggler}
|
|
orphanBranch={isOrphanSpan(span)}
|
|
>
|
|
{connectorBars}
|
|
</TreeConnector>
|
|
);
|
|
}
|
|
|
|
renderSpanTreeToggler({left, errored}: {errored: boolean; left: number}) {
|
|
const {numOfSpanChildren, isRoot, showSpanTree} = this.props;
|
|
|
|
const chevron = <TreeToggleIcon direction={showSpanTree ? 'up' : 'down'} />;
|
|
|
|
if (numOfSpanChildren <= 0) {
|
|
return (
|
|
<TreeToggleContainer style={{left: `${left}px`}}>
|
|
{this.renderSpanTreeConnector({hasToggler: false})}
|
|
</TreeToggleContainer>
|
|
);
|
|
}
|
|
|
|
const chevronElement = !isRoot ? <div>{chevron}</div> : null;
|
|
|
|
return (
|
|
<TreeToggleContainer style={{left: `${left}px`}} hasToggler>
|
|
{this.renderSpanTreeConnector({hasToggler: true})}
|
|
<TreeToggle
|
|
disabled={!!isRoot}
|
|
isExpanded={showSpanTree}
|
|
errored={errored}
|
|
onClick={event => {
|
|
event.stopPropagation();
|
|
|
|
if (isRoot) {
|
|
return;
|
|
}
|
|
|
|
PerformanceInteraction.startInteraction('SpanTreeToggle', 1000 * 10);
|
|
this.props.toggleSpanTree();
|
|
|
|
// TODO Abdullah Khan: A little bit hacky, but ensures that the toggled tree aligns
|
|
// with the rest of the span tree, in the trace view.
|
|
if (this.props.fromTraceView) {
|
|
this.props.updateHorizontalScrollState(0.5);
|
|
}
|
|
}}
|
|
>
|
|
<Count value={numOfSpanChildren} />
|
|
{chevronElement}
|
|
</TreeToggle>
|
|
</TreeToggleContainer>
|
|
);
|
|
}
|
|
|
|
renderTitle(errors: TraceErrorOrIssue[] | null) {
|
|
const {
|
|
span,
|
|
spanBarType,
|
|
treeDepth,
|
|
groupOccurrence,
|
|
toggleSpanGroup,
|
|
toggleSiblingSpanGroup,
|
|
addContentSpanBarRef,
|
|
removeContentSpanBarRef,
|
|
groupType,
|
|
event,
|
|
} = this.props;
|
|
|
|
let titleFragments: React.ReactNode[] = [];
|
|
|
|
if (
|
|
typeof toggleSpanGroup === 'function' ||
|
|
typeof toggleSiblingSpanGroup === 'function'
|
|
) {
|
|
titleFragments.push(
|
|
<Regroup
|
|
key={`regroup-${span.timestamp}`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (groupType === GroupType.SIBLINGS && 'op' in span) {
|
|
toggleSiblingSpanGroup?.(span, groupOccurrence ?? 0);
|
|
} else {
|
|
toggleSpanGroup?.();
|
|
}
|
|
}}
|
|
>
|
|
<a
|
|
href="#regroup"
|
|
onClick={e => {
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
{t('Regroup')}
|
|
</a>
|
|
</Regroup>
|
|
);
|
|
}
|
|
|
|
const spanOperationName = getSpanOperation(span);
|
|
if (spanOperationName) {
|
|
titleFragments.push(spanOperationName);
|
|
}
|
|
|
|
titleFragments = titleFragments.flatMap(current => [current, ' \u2014 ']);
|
|
|
|
const isAggregateEvent = event.type === EventOrGroupType.AGGREGATE_TRANSACTION;
|
|
|
|
const left =
|
|
treeDepth * (TOGGLE_BORDER_BOX / 2) +
|
|
MARGIN_LEFT +
|
|
(isAggregateEvent ? FREQUENCY_BOX_WIDTH : 0);
|
|
|
|
const errored = Boolean(errors && errors.length > 0);
|
|
|
|
return (
|
|
<Fragment>
|
|
{isAggregateEvent && (
|
|
<SpanFrequencyBox span={span as AggregateSpanType | GapSpanType} />
|
|
)}
|
|
<RowTitleContainer
|
|
data-debug-id="SpanBarTitleContainer"
|
|
ref={ref => {
|
|
if (!ref) {
|
|
removeContentSpanBarRef(this.spanContentRef);
|
|
return;
|
|
}
|
|
|
|
addContentSpanBarRef(ref);
|
|
this.spanContentRef = ref;
|
|
}}
|
|
>
|
|
{this.renderSpanTreeToggler({left, errored})}
|
|
<RowTitle
|
|
style={{
|
|
left: `${left}px`,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<RowTitleContent
|
|
errored={errored}
|
|
data-test-id={`row-title-content${spanBarType ? `-${spanBarType}` : ''}`}
|
|
>
|
|
<strong>{titleFragments}</strong>
|
|
{formatSpanTreeLabel(span)}
|
|
</RowTitleContent>
|
|
</RowTitle>
|
|
</RowTitleContainer>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
connectObservers() {
|
|
if (!this.spanRowDOMRef.current) {
|
|
return;
|
|
}
|
|
|
|
this.disconnectObservers();
|
|
|
|
// We track intersections events between the span bar's DOM element
|
|
// and the viewport's (root) intersection area. the intersection area is sized to
|
|
// exclude the minimap. See below.
|
|
//
|
|
// By default, the intersection observer's root intersection is the viewport.
|
|
// We adjust the margins of this root intersection area to exclude the minimap's
|
|
// height. The minimap's height is always fixed.
|
|
//
|
|
// VIEWPORT (ancestor element used for the intersection events)
|
|
// +--+-------------------------+--+
|
|
// | | | |
|
|
// | | MINIMAP | |
|
|
// | | | |
|
|
// | +-------------------------+ | ^
|
|
// | | | | |
|
|
// | | SPANS | | | ROOT
|
|
// | | | | | INTERSECTION
|
|
// | | | | | OBSERVER
|
|
// | | | | | HEIGHT
|
|
// | | | | |
|
|
// | | | | |
|
|
// | | | | |
|
|
// | +-------------------------+ | |
|
|
// | | |
|
|
// +-------------------------------+ v
|
|
|
|
this.intersectionObserver = new IntersectionObserver(
|
|
entries =>
|
|
entries.forEach(entry => {
|
|
if (!this._mounted) {
|
|
return;
|
|
}
|
|
|
|
const shouldMoveMinimap = this.props.numOfSpans > NUM_OF_SPANS_FIT_IN_MINI_MAP;
|
|
|
|
if (!shouldMoveMinimap) {
|
|
return;
|
|
}
|
|
const spanNumber = this.props.spanNumber;
|
|
|
|
const minimapSlider = document.getElementById('minimap-background-slider');
|
|
|
|
if (!minimapSlider) {
|
|
return;
|
|
}
|
|
|
|
// NOTE: THIS IS HACKY.
|
|
//
|
|
// IntersectionObserver.rootMargin is un-affected by the browser's zoom level.
|
|
// The margins of the intersection area needs to be adjusted.
|
|
// Thus, IntersectionObserverEntry.rootBounds may not be what we expect.
|
|
//
|
|
// We address this below.
|
|
//
|
|
// Note that this function was called whenever an intersection event occurred wrt
|
|
// the thresholds.
|
|
//
|
|
if (entry.rootBounds) {
|
|
// After we create the IntersectionObserver instance with rootMargin set as:
|
|
// -${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px
|
|
//
|
|
// we can introspect the rootBounds to infer the zoomlevel.
|
|
//
|
|
// we always expect entry.rootBounds.top to equal MINIMAP_CONTAINER_HEIGHT
|
|
|
|
const actualRootTop = Math.ceil(entry.rootBounds.top);
|
|
|
|
if (actualRootTop !== MINIMAP_CONTAINER_HEIGHT && actualRootTop > 0) {
|
|
// we revert the actualRootTop value by the current zoomLevel factor
|
|
const normalizedActualTop = actualRootTop / this.zoomLevel;
|
|
|
|
const zoomLevel = MINIMAP_CONTAINER_HEIGHT / normalizedActualTop;
|
|
this.zoomLevel = zoomLevel;
|
|
|
|
// we reconnect the observers; the callback functions may be invoked
|
|
this.connectObservers();
|
|
|
|
// NOTE: since we cannot guarantee that the callback function is invoked on
|
|
// the newly connected observers, we continue running this function.
|
|
}
|
|
}
|
|
|
|
// root refers to the root intersection rectangle used for the IntersectionObserver
|
|
const rectRelativeToRoot = entry.boundingClientRect as DOMRect;
|
|
|
|
const bottomYCoord = rectRelativeToRoot.y + rectRelativeToRoot.height;
|
|
|
|
// refers to if the rect is out of view from the viewport
|
|
const isOutOfViewAbove = rectRelativeToRoot.y < 0 && bottomYCoord < 0;
|
|
|
|
if (isOutOfViewAbove) {
|
|
return;
|
|
}
|
|
|
|
const relativeToMinimap = {
|
|
top: rectRelativeToRoot.y - MINIMAP_CONTAINER_HEIGHT,
|
|
bottom: bottomYCoord - MINIMAP_CONTAINER_HEIGHT,
|
|
};
|
|
|
|
const rectBelowMinimap =
|
|
relativeToMinimap.top > 0 && relativeToMinimap.bottom > 0;
|
|
|
|
if (rectBelowMinimap) {
|
|
const {span} = this.props;
|
|
if ('type' in span) {
|
|
return;
|
|
}
|
|
|
|
// if the first span is below the minimap, we scroll the minimap
|
|
// to the top. this addresses spurious scrolling to the top of the page
|
|
if (spanNumber <= 1) {
|
|
minimapSlider.style.top = '0px';
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const inAndAboveMinimap = relativeToMinimap.bottom <= 0;
|
|
|
|
if (inAndAboveMinimap) {
|
|
const {span} = this.props;
|
|
if ('type' in span) {
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// invariant: spanNumber >= 1
|
|
|
|
const numberOfMovedSpans = spanNumber - 1;
|
|
const totalHeightOfHiddenSpans = numberOfMovedSpans * MINIMAP_SPAN_BAR_HEIGHT;
|
|
const currentSpanHiddenRatio = 1 - entry.intersectionRatio;
|
|
|
|
const panYPixels =
|
|
totalHeightOfHiddenSpans + currentSpanHiddenRatio * MINIMAP_SPAN_BAR_HEIGHT;
|
|
|
|
// invariant: this.props.numOfSpans - spanNumberToStopMoving + 1 = NUM_OF_SPANS_FIT_IN_MINI_MAP
|
|
|
|
const spanNumberToStopMoving =
|
|
this.props.numOfSpans + 1 - NUM_OF_SPANS_FIT_IN_MINI_MAP;
|
|
|
|
if (spanNumber > spanNumberToStopMoving) {
|
|
// if the last span bar appears on the minimap, we do not want the minimap
|
|
// to keep panning upwards
|
|
minimapSlider.style.top = `-${
|
|
spanNumberToStopMoving * MINIMAP_SPAN_BAR_HEIGHT
|
|
}px`;
|
|
return;
|
|
}
|
|
|
|
minimapSlider.style.top = `-${panYPixels}px`;
|
|
}),
|
|
{
|
|
threshold: INTERSECTION_THRESHOLDS,
|
|
rootMargin: `-${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px`,
|
|
}
|
|
);
|
|
|
|
this.intersectionObserver.observe(this.spanRowDOMRef.current);
|
|
}
|
|
|
|
disconnectObservers() {
|
|
if (this.intersectionObserver) {
|
|
this.intersectionObserver.disconnect();
|
|
}
|
|
}
|
|
|
|
renderDivider(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) {
|
|
if (this.state.showDetail) {
|
|
// Mock component to preserve layout spacing
|
|
return (
|
|
<DividerLine
|
|
showDetail
|
|
style={{
|
|
position: 'absolute',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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={event => {
|
|
// we prevent the propagation of the clicks from this component to prevent
|
|
// the span detail from being opened.
|
|
event.stopPropagation();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
getRelatedErrors(
|
|
quickTrace: QuickTraceContextChildrenProps
|
|
): TraceErrorOrIssue[] | null {
|
|
if (!quickTrace) {
|
|
return null;
|
|
}
|
|
|
|
const {span, isSpanInEmbeddedTree} = this.props;
|
|
const {currentEvent} = quickTrace;
|
|
|
|
if (
|
|
isGapSpan(span) ||
|
|
!currentEvent ||
|
|
!isTraceTransaction<TraceFull>(currentEvent)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const performanceIssues = currentEvent.performance_issues.filter(
|
|
issue =>
|
|
issue.span.some(id => id === span.span_id) ||
|
|
issue.suspect_spans.some(suspectSpanId => suspectSpanId === span.span_id)
|
|
);
|
|
|
|
return [
|
|
...currentEvent.errors.filter(error => error.span === span.span_id),
|
|
...(isSpanInEmbeddedTree ? [] : performanceIssues), // Spans can be shown when embedded in performance issues
|
|
];
|
|
}
|
|
|
|
getChildTransactions(
|
|
quickTrace: QuickTraceContextChildrenProps
|
|
): QuickTraceEvent[] | null {
|
|
if (!quickTrace) {
|
|
return null;
|
|
}
|
|
|
|
const {span} = this.props;
|
|
const {trace} = quickTrace;
|
|
|
|
if (isGapSpan(span) || !trace) {
|
|
return null;
|
|
}
|
|
|
|
return trace.filter(({parent_span_id}) => parent_span_id === span.span_id);
|
|
}
|
|
|
|
renderErrorBadge(errors: TraceErrorOrIssue[] | null): React.ReactNode {
|
|
return errors?.length ? <ErrorBadge /> : null;
|
|
}
|
|
|
|
renderEmbeddedTransactionsBadge(
|
|
transactions: QuickTraceEvent[] | null
|
|
): React.ReactNode {
|
|
const {toggleEmbeddedChildren, organization, showEmbeddedChildren} = this.props;
|
|
|
|
if (transactions && transactions.length >= 1) {
|
|
return (
|
|
<Tooltip
|
|
title={
|
|
<span>
|
|
{showEmbeddedChildren
|
|
? t('This span is showing a direct child. Remove transaction to hide')
|
|
: t('This span has a direct child. Add transaction to view')}
|
|
</span>
|
|
}
|
|
position="top"
|
|
containerDisplayMode="block"
|
|
>
|
|
<EmbeddedTransactionBadge
|
|
inTraceView={!!this.props.fromTraceView}
|
|
expanded={showEmbeddedChildren}
|
|
onClick={() => {
|
|
if (toggleEmbeddedChildren) {
|
|
const eventKey = showEmbeddedChildren
|
|
? 'span_view.embedded_child.hide'
|
|
: 'span_view.embedded_child.show';
|
|
trackAnalytics(eventKey, {organization});
|
|
|
|
const eventSlugs = transactions.map(transaction =>
|
|
generateEventSlug({
|
|
id: transaction.event_id,
|
|
project: transaction.project_slug,
|
|
})
|
|
);
|
|
|
|
toggleEmbeddedChildren(organization.slug, eventSlugs);
|
|
}
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
renderMissingInstrumentationProfileBadge(): React.ReactNode {
|
|
const {organization, span} = this.props;
|
|
|
|
if (!organization.features.includes('profiling')) {
|
|
return null;
|
|
}
|
|
|
|
if (!isGapSpan(span)) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ProfileContext.Consumer>
|
|
{profiles => {
|
|
if (profiles?.type !== 'resolved') {
|
|
return null;
|
|
}
|
|
return <ProfileBadge />;
|
|
}}
|
|
</ProfileContext.Consumer>
|
|
);
|
|
}
|
|
|
|
renderWarningText() {
|
|
let warningText = this.getBounds().warning;
|
|
|
|
if (this.props.isEmbeddedTransactionTimeAdjusted) {
|
|
const embeddedWarningText = t(
|
|
'All child span timestamps have been adjusted to account for mismatched client and server clocks.'
|
|
);
|
|
|
|
warningText = warningText
|
|
? `${warningText}. ${embeddedWarningText}`
|
|
: embeddedWarningText;
|
|
}
|
|
|
|
if (!warningText) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Tooltip containerDisplayMode="flex" title={warningText}>
|
|
<StyledIconWarning size="xs" />
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
renderHeader({
|
|
dividerHandlerChildrenProps,
|
|
errors,
|
|
transactions,
|
|
}: {
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps;
|
|
errors: TraceErrorOrIssue[] | null;
|
|
transactions: QuickTraceEvent[] | null;
|
|
}) {
|
|
const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps;
|
|
|
|
return (
|
|
<RowCellContainer showDetail={this.state.showDetail}>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
showDetail={this.state.showDetail}
|
|
style={{
|
|
width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
|
|
paddingTop: 0,
|
|
}}
|
|
onClick={() => {
|
|
this.toggleDisplayDetail();
|
|
}}
|
|
ref={this.spanTitleRef}
|
|
>
|
|
{this.renderTitle(errors)}
|
|
</RowCell>
|
|
<DividerContainer>
|
|
{this.renderDivider(dividerHandlerChildrenProps)}
|
|
{this.renderErrorBadge(errors)}
|
|
{this.renderEmbeddedTransactionsBadge(transactions)}
|
|
{this.renderMissingInstrumentationProfileBadge()}
|
|
</DividerContainer>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
showDetail={this.state.showDetail}
|
|
showStriping={this.props.spanNumber % 2 !== 0}
|
|
style={{
|
|
width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
|
|
}}
|
|
onClick={() => {
|
|
this.toggleDisplayDetail();
|
|
}}
|
|
>
|
|
{this.renderSpanBarRectangles()}
|
|
{this.renderMeasurements()}
|
|
<SpanBarCursorGuide />
|
|
</RowCell>
|
|
{!this.state.showDetail && (
|
|
<DividerLineGhostContainer
|
|
style={{
|
|
width: `calc(${toPercent(dividerPosition)} + 0.5px)`,
|
|
display: 'none',
|
|
}}
|
|
>
|
|
<DividerLine
|
|
ref={addGhostDividerLineRef()}
|
|
style={{
|
|
right: 0,
|
|
}}
|
|
className="hovering"
|
|
onClick={event => {
|
|
// 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.
|
|
event.stopPropagation();
|
|
}}
|
|
/>
|
|
</DividerLineGhostContainer>
|
|
)}
|
|
</RowCellContainer>
|
|
);
|
|
}
|
|
|
|
renderSpanBarRectangles() {
|
|
const {span, spanBarColor, spanBarType, generateBounds} = this.props;
|
|
const startTimestamp: number = span.start_timestamp;
|
|
const endTimestamp: number = span.timestamp;
|
|
const duration = Math.abs(endTimestamp - startTimestamp);
|
|
const durationString = getHumanDuration(duration);
|
|
const bounds = this.getBounds();
|
|
const displaySpanBar = defined(bounds.left) && defined(bounds.width);
|
|
if (!displaySpanBar) {
|
|
return null;
|
|
}
|
|
|
|
const subTimings = getSpanSubTimings(span);
|
|
const hasSubTimings = !!subTimings;
|
|
|
|
const subSpans = hasSubTimings
|
|
? subTimings.map(timing => {
|
|
const timingGeneratedBounds = generateBounds(timing);
|
|
const timingBounds = this.getBounds(timingGeneratedBounds);
|
|
const isAffectedSubTiming = shouldLimitAffectedToTiming(timing);
|
|
return (
|
|
<RowRectangle
|
|
key={timing.name}
|
|
spanBarType={isAffectedSubTiming ? spanBarType : undefined}
|
|
style={{
|
|
backgroundColor: lightenBarColor(
|
|
getSpanOperation(span),
|
|
timing.colorLighten
|
|
),
|
|
left: `min(${toPercent(timingBounds.left || 0)}, calc(100% - 1px))`,
|
|
width: toPercent(timingBounds.width || 0),
|
|
}}
|
|
/>
|
|
);
|
|
})
|
|
: null;
|
|
|
|
const durationDisplay = getDurationDisplay(bounds);
|
|
|
|
return (
|
|
<Fragment>
|
|
{subSpans}
|
|
<RowRectangle
|
|
spanBarType={spanBarType}
|
|
style={{
|
|
backgroundColor: hasSubTimings ? 'rgba(0,0,0,0.0)' : spanBarColor,
|
|
left: `min(${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
|
|
width: toPercent(bounds.width || 0),
|
|
}}
|
|
isHidden={hasSubTimings}
|
|
>
|
|
<DurationPill
|
|
durationDisplay={durationDisplay}
|
|
showDetail={this.state.showDetail}
|
|
spanBarType={spanBarType}
|
|
>
|
|
{durationString}
|
|
{this.renderWarningText()}
|
|
</DurationPill>
|
|
</RowRectangle>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
renderEmbeddedChildrenState() {
|
|
const {fetchEmbeddedChildrenState} = this.props;
|
|
|
|
switch (fetchEmbeddedChildrenState) {
|
|
case 'loading_embedded_transactions': {
|
|
return (
|
|
<MessageRow>
|
|
<span>{t('Loading embedded transaction')}</span>
|
|
</MessageRow>
|
|
);
|
|
}
|
|
case 'error_fetching_embedded_transactions': {
|
|
return (
|
|
<MessageRow>
|
|
<span>{t('Error loading embedded transaction')}</span>
|
|
</MessageRow>
|
|
);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const {spanNumber} = this.props;
|
|
const bounds = this.getBounds();
|
|
const {isSpanVisibleInView} = bounds;
|
|
|
|
return (
|
|
<Fragment>
|
|
<Row
|
|
ref={this.spanRowDOMRef}
|
|
visible={isSpanVisibleInView}
|
|
showBorder={this.state.showDetail}
|
|
data-test-id={`span-row-${spanNumber}`}
|
|
>
|
|
<QuickTraceContext.Consumer>
|
|
{quickTrace => {
|
|
const errors = this.getRelatedErrors(quickTrace);
|
|
const transactions = this.getChildTransactions(quickTrace);
|
|
return (
|
|
<Fragment>
|
|
<DividerHandlerManager.Consumer>
|
|
{(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) =>
|
|
this.renderHeader({
|
|
dividerHandlerChildrenProps,
|
|
errors,
|
|
transactions,
|
|
})
|
|
}
|
|
</DividerHandlerManager.Consumer>
|
|
|
|
{this.renderDetail({
|
|
isVisible: isSpanVisibleInView,
|
|
transactions,
|
|
errors,
|
|
})}
|
|
</Fragment>
|
|
);
|
|
}}
|
|
</QuickTraceContext.Consumer>
|
|
</Row>
|
|
{this.renderEmbeddedChildrenState()}
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const StyledIconWarning = styled(IconWarning)`
|
|
margin-left: ${space(0.25)};
|
|
margin-bottom: ${space(0.25)};
|
|
`;
|
|
|
|
const Regroup = styled('span')``;
|
|
|
|
export const ProfiledSpanBar = withProfiler(withScrollbarManager(SpanBar));
|