mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
1043 lines
30 KiB
TypeScript
1043 lines
30 KiB
TypeScript
import 'intersection-observer'; // this is a polyfill
|
|
|
|
import {Component, createRef, Fragment} from 'react';
|
|
import {browserHistory} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import {withProfiler} from '@sentry/react';
|
|
import type {Location} from 'history';
|
|
|
|
import Count from 'sentry/components/count';
|
|
import {
|
|
FREQUENCY_BOX_WIDTH,
|
|
SpanFrequencyBox,
|
|
} from 'sentry/components/events/interfaces/spans/spanFrequencyBox';
|
|
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,
|
|
ErrorBadge,
|
|
MetricsBadge,
|
|
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 {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 {hasDDMExperimentalFeature} from 'sentry/utils/metrics/features';
|
|
import toPercent from 'sentry/utils/number/toPercent';
|
|
import type {QuickTraceContextChildrenProps} 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 {decodeScalar} from 'sentry/utils/queryString';
|
|
import {StyledZoomIcon} from 'sentry/views/performance/traceDetails/newTraceDetailsTransactionBar';
|
|
import {ProfileContext} from 'sentry/views/profiling/profilesProvider';
|
|
|
|
import * as DividerHandlerManager from './dividerHandlerManager';
|
|
import type {SpanDetailProps} from './newTraceDetailsSpanDetails';
|
|
import {withScrollbarManager} from './scrollbarManager';
|
|
import type {SpanBarProps} from './spanBar';
|
|
import SpanBarCursorGuide from './spanBarCursorGuide';
|
|
import {MeasurementMarker} from './styles';
|
|
import type {AggregateSpanType, GapSpanType, ProcessedSpanType} from './types';
|
|
import {GroupType} from './types';
|
|
import type {SpanGeneratedBoundsType, SpanViewBoundsType, VerticalMark} from './utils';
|
|
import {
|
|
durationlessBrowserOps,
|
|
formatSpanTreeLabel,
|
|
getMeasurementBounds,
|
|
getMeasurements,
|
|
getSpanID,
|
|
getSpanOperation,
|
|
getSpanSubTimings,
|
|
isEventFromBrowserJavaScriptSDK,
|
|
isGapSpan,
|
|
isOrphanSpan,
|
|
isOrphanTreeDepth,
|
|
parseTraceDetailsURLHash,
|
|
shouldLimitAffectedToTiming,
|
|
spanTargetHash,
|
|
transactionTargetHash,
|
|
unwrapTreeDepth,
|
|
} from './utils';
|
|
|
|
export const MARGIN_LEFT = 0;
|
|
const SPAN_BAR_HEIGHT = 24;
|
|
|
|
export type NewTraceDetailsSpanBarProps = SpanBarProps & {
|
|
location: Location;
|
|
quickTrace: QuickTraceContextChildrenProps;
|
|
measurements?: Map<number, VerticalMark>;
|
|
onRowClick?: (detailKey: SpanDetailProps | undefined) => void;
|
|
};
|
|
|
|
type State = {
|
|
isIntersecting: boolean;
|
|
};
|
|
|
|
export class NewTraceDetailsSpanBar extends Component<
|
|
NewTraceDetailsSpanBarProps,
|
|
State
|
|
> {
|
|
state = {
|
|
isIntersecting: false,
|
|
};
|
|
|
|
componentDidMount() {
|
|
const {didAnchoredSpanMount, markAnchoredSpanIsMounted} = this.props;
|
|
|
|
this._mounted = true;
|
|
this.updateHighlightedState();
|
|
this.connectObservers();
|
|
|
|
// If span is anchored scroll to span bar and open it's detail panel
|
|
if (this.isHighlighted && this.props.onRowClick) {
|
|
this.props.onRowClick(this.getSpanDetailsProps());
|
|
|
|
// Needs a little delay after bar is rendered, to achieve
|
|
// scrollto bar functionality for spans that exist much further down the
|
|
// react virtualized list.
|
|
if (!didAnchoredSpanMount()) {
|
|
setTimeout(() => {
|
|
this.scrollIntoView();
|
|
}, 100);
|
|
}
|
|
|
|
markAnchoredSpanIsMounted?.();
|
|
}
|
|
|
|
if (this.spanRowDOMRef.current) {
|
|
this.props.storeSpanBar(this);
|
|
}
|
|
|
|
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} = this.props;
|
|
|
|
if (isGapSpan(span)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Readonly<NewTraceDetailsSpanBarProps>): void {
|
|
if (this.props.location.query !== prevProps.location.query) {
|
|
this.updateHighlightedState();
|
|
}
|
|
|
|
if (this.props.quickTrace !== prevProps.quickTrace) {
|
|
const relatedErrors = this.getRelatedErrors(this.props.quickTrace);
|
|
if (
|
|
this.isHighlighted &&
|
|
this.props.onRowClick &&
|
|
relatedErrors &&
|
|
relatedErrors.length > 0
|
|
) {
|
|
this.props.onRowClick(this.getSpanDetailsProps());
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
hashSpanId: string | undefined = undefined;
|
|
isHighlighted: boolean = false;
|
|
|
|
updateHighlightedState = () => {
|
|
const hashValues = parseTraceDetailsURLHash(this.props.location.hash);
|
|
this.hashSpanId = hashValues?.spanId;
|
|
this.isHighlighted = !!(
|
|
!isGapSpan(this.props.span) &&
|
|
this.hashSpanId &&
|
|
this.hashSpanId === this.props.span.span_id
|
|
);
|
|
|
|
// TODO Abdullah Khan: Converting the component to a functional component will help us get rid
|
|
// of the forcedUpdate.
|
|
this.forceUpdate();
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
scrollIntoView = () => {
|
|
const element = this.spanRowDOMRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const boundingRect = element.getBoundingClientRect();
|
|
const offset = boundingRect.top + window.scrollY - 40;
|
|
window.scrollTo(0, offset);
|
|
};
|
|
|
|
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, measurements} = this.props;
|
|
|
|
if (this.isHighlighted) {
|
|
return null;
|
|
}
|
|
|
|
const spanMeasurements = measurements ?? getMeasurements(event, generateBounds);
|
|
|
|
return (
|
|
<Fragment>
|
|
{Array.from(spanMeasurements.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() {
|
|
const observer = new IntersectionObserver(([entry]) =>
|
|
this.setState({isIntersecting: entry.isIntersecting}, () => {
|
|
// Scrolls the next(invisible) bar from the virtualized list,
|
|
// by it's height. Allows us to look for anchored span bars occuring
|
|
// at the bottom of the span tree.
|
|
if (
|
|
this.hashSpanId &&
|
|
!this.props.didAnchoredSpanMount() &&
|
|
!this.state.isIntersecting
|
|
) {
|
|
window.scrollBy(0, SPAN_BAR_HEIGHT);
|
|
}
|
|
})
|
|
);
|
|
|
|
this.intersectionObserver = observer;
|
|
if (this.spanRowDOMRef.current) {
|
|
observer.observe(this.spanRowDOMRef.current);
|
|
}
|
|
}
|
|
|
|
disconnectObservers() {
|
|
if (this.intersectionObserver) {
|
|
this.intersectionObserver.disconnect();
|
|
}
|
|
}
|
|
|
|
renderDivider(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) {
|
|
if (this.isHighlighted) {
|
|
// 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;
|
|
}
|
|
|
|
renderMetricsBadge(span: NewTraceDetailsSpanBarProps['span']): React.ReactNode {
|
|
const hasMetrics =
|
|
'_metrics_summary' in span && Object.keys(span._metrics_summary ?? {}).length > 0;
|
|
|
|
return hasMetrics && hasDDMExperimentalFeature(this.props.organization) ? (
|
|
<MetricsBadge />
|
|
) : 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"
|
|
>
|
|
<StyledZoomIcon
|
|
isZoomIn={!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>
|
|
);
|
|
}
|
|
|
|
getSpanDetailsProps() {
|
|
const {
|
|
span,
|
|
organization,
|
|
event,
|
|
isRoot,
|
|
trace,
|
|
resetCellMeasureCache,
|
|
quickTrace,
|
|
location,
|
|
} = this.props;
|
|
const openPanel = decodeScalar(location.query.openPanel);
|
|
const errors = this.getRelatedErrors(quickTrace);
|
|
const transactions = this.getChildTransactions(quickTrace);
|
|
|
|
return {
|
|
span: span as ProcessedSpanType,
|
|
organization,
|
|
event: event as EventTransaction,
|
|
isRoot: !!isRoot,
|
|
openPanel,
|
|
trace,
|
|
childTransactions: transactions,
|
|
relatedErrors: errors,
|
|
scrollToHash: this.scrollIntoView,
|
|
resetCellMeasureCache,
|
|
};
|
|
}
|
|
|
|
handleRowClick() {
|
|
const {span, event, location, markAnchoredSpanIsMounted} = this.props;
|
|
const spanDetailProps = this.getSpanDetailsProps();
|
|
if (this.props.onRowClick && !isGapSpan(span)) {
|
|
markAnchoredSpanIsMounted?.();
|
|
const isTransactionEvent = event.type === EventOrGroupType.TRANSACTION;
|
|
if (isTransactionEvent) {
|
|
browserHistory.push({
|
|
...location,
|
|
hash: `${transactionTargetHash(event.eventID)}${spanTargetHash(span.span_id)}`,
|
|
query: {
|
|
...location.query,
|
|
openPanel: 'open',
|
|
},
|
|
});
|
|
spanDetailProps.openPanel = 'open';
|
|
}
|
|
this.props.onRowClick(spanDetailProps);
|
|
}
|
|
}
|
|
renderHeader({
|
|
dividerHandlerChildrenProps,
|
|
}: {
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps;
|
|
}) {
|
|
const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps;
|
|
const {quickTrace} = this.props;
|
|
const errors = this.getRelatedErrors(quickTrace);
|
|
const transactions = this.getChildTransactions(quickTrace);
|
|
return (
|
|
<RowCellContainer showDetail={this.isHighlighted}>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
showDetail={this.isHighlighted}
|
|
style={{
|
|
width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
|
|
paddingTop: 0,
|
|
}}
|
|
onClick={() => this.handleRowClick()}
|
|
ref={this.spanTitleRef}
|
|
>
|
|
{this.renderTitle(errors)}
|
|
</RowCell>
|
|
<DividerContainer>
|
|
{this.renderDivider(dividerHandlerChildrenProps)}
|
|
{this.renderMetricsBadge(this.props.span)}
|
|
{this.renderErrorBadge(errors)}
|
|
{this.renderEmbeddedTransactionsBadge(transactions)}
|
|
{this.renderMissingInstrumentationProfileBadge()}
|
|
</DividerContainer>
|
|
<RowCell
|
|
data-type="span-row-cell"
|
|
showDetail={this.isHighlighted}
|
|
showStriping={this.props.spanNumber % 2 !== 0}
|
|
style={{
|
|
width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
|
|
}}
|
|
onClick={() => this.handleRowClick()}
|
|
>
|
|
{this.renderSpanBarRectangles()}
|
|
{this.renderMeasurements()}
|
|
<SpanBarCursorGuide />
|
|
</RowCell>
|
|
{!this.isHighlighted && (
|
|
<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.isHighlighted}
|
|
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.isHighlighted}
|
|
data-test-id={`span-row-${spanNumber}`}
|
|
>
|
|
<Fragment>
|
|
<DividerHandlerManager.Consumer>
|
|
{(
|
|
dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
|
|
) =>
|
|
this.renderHeader({
|
|
dividerHandlerChildrenProps,
|
|
})
|
|
}
|
|
</DividerHandlerManager.Consumer>
|
|
</Fragment>
|
|
</Row>
|
|
{this.renderEmbeddedChildrenState()}
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const StyledIconWarning = styled(IconWarning)`
|
|
margin-left: ${space(0.25)};
|
|
margin-bottom: ${space(0.25)};
|
|
`;
|
|
|
|
const Regroup = styled('span')``;
|
|
|
|
export const NewTraceDetailsProfiledSpanBar = withProfiler(
|
|
withScrollbarManager(NewTraceDetailsSpanBar)
|
|
);
|