diff --git a/src/sentry/static/sentry/app/components/events/eventEntries.jsx b/src/sentry/static/sentry/app/components/events/eventEntries.jsx index 38a6f108ee3db6..d2b9bf0637c49f 100644 --- a/src/sentry/static/sentry/app/components/events/eventEntries.jsx +++ b/src/sentry/static/sentry/app/components/events/eventEntries.jsx @@ -32,6 +32,7 @@ import SentryTypes from 'app/sentryTypes'; import StacktraceInterface from 'app/components/events/interfaces/stacktrace'; import TemplateInterface from 'app/components/events/interfaces/template'; import ThreadsInterface from 'app/components/events/interfaces/threads'; +import SpansInterface from 'app/components/events/interfaces/spans'; import withApi from 'app/utils/withApi'; import withOrganization from 'app/utils/withOrganization'; @@ -48,6 +49,7 @@ export const INTERFACES = { breadcrumbs: BreadcrumbsInterface, threads: ThreadsInterface, debugmeta: DebugMetaInterface, + spans: SpansInterface, }; class EventEntries extends React.Component { diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx new file mode 100644 index 00000000000000..41c30cbfb35999 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/dragManager.tsx @@ -0,0 +1,214 @@ +import React from 'react'; + +import {rectOfContent, clamp} from './utils'; + +// we establish the minimum window size so that the window size of 0% is not possible +const MINIMUM_WINDOW_SIZE = 0.5 / 100; // 0.5% window size + +enum ViewHandleType { + Left, + Right, +} + +export type DragManagerChildrenProps = { + isDragging: boolean; + + // left-side handle + + onLeftHandleDragStart: (event: React.MouseEvent) => void; + leftHandlePosition: number; // between 0 to 1 + viewWindowStart: number; // between 0 to 1 + + // right-side handle + + onRightHandleDragStart: (event: React.MouseEvent) => void; + rightHandlePosition: number; // between 0 to 1 + viewWindowEnd: number; // between 0 to 1 +}; + +type DragManagerProps = { + children: (props: DragManagerChildrenProps) => JSX.Element; + interactiveLayerRef: React.RefObject; +}; + +type DragManagerState = { + isDragging: boolean; + currentDraggingHandle: ViewHandleType | undefined; + leftHandlePosition: number; + rightHandlePosition: number; + + viewWindowStart: number; + viewWindowEnd: number; +}; + +class DragManager extends React.Component { + state: DragManagerState = { + isDragging: false, + currentDraggingHandle: void 0, + leftHandlePosition: 0, // positioned on the left-most side at 0% + rightHandlePosition: 1, // positioned on the right-most side at 100% + + viewWindowStart: 0, + viewWindowEnd: 1, + }; + + previousUserSelect: string | null = null; + + hasInteractiveLayer = (): boolean => { + return !!this.props.interactiveLayerRef.current; + }; + + onDragStart = (viewHandle: ViewHandleType) => ( + event: React.MouseEvent + ) => { + if ( + this.state.isDragging || + event.type !== 'mousedown' || + !this.hasInteractiveLayer() + ) { + return; + } + + // prevent the user from selecting things outside the minimap when dragging + // the mouse cursor outside the minimap + + this.previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + + // attach event listeners so that the mouse cursor can drag outside of the + // minimap + window.addEventListener('mousemove', this.onDragMove); + window.addEventListener('mouseup', this.onDragEnd); + + // indicate drag has begun + + this.setState({ + isDragging: true, + currentDraggingHandle: viewHandle, + }); + }; + + onLeftHandleDragStart = (event: React.MouseEvent) => { + this.onDragStart(ViewHandleType.Left)(event); + }; + + onRightHandleDragStart = (event: React.MouseEvent) => { + this.onDragStart(ViewHandleType.Right)(event); + }; + + onDragMove = (event: MouseEvent) => { + if ( + !this.state.isDragging || + event.type !== 'mousemove' || + !this.hasInteractiveLayer() + ) { + return; + } + + const rect = rectOfContent(this.props.interactiveLayerRef.current!); + + // mouse x-coordinate relative to the interactive layer's left side + const rawMouseX = (event.pageX - rect.x) / rect.width; + + switch (this.state.currentDraggingHandle) { + case ViewHandleType.Left: { + const min = 0; + const max = this.state.rightHandlePosition - MINIMUM_WINDOW_SIZE; + + this.setState({ + // clamp rawMouseX to be within [0, rightHandlePosition - MINIMUM_WINDOW_SIZE] + leftHandlePosition: clamp(rawMouseX, min, max), + }); + break; + } + case ViewHandleType.Right: { + const min = this.state.leftHandlePosition + MINIMUM_WINDOW_SIZE; + const max = 1; + + this.setState({ + // clamp rawMouseX to be within [leftHandlePosition + MINIMUM_WINDOW_SIZE, 1] + rightHandlePosition: clamp(rawMouseX, min, max), + }); + break; + } + default: { + throw Error('this.state.currentDraggingHandle is undefined'); + } + } + }; + + onDragEnd = (event: MouseEvent) => { + if ( + !this.state.isDragging || + event.type !== 'mouseup' || + !this.hasInteractiveLayer() + ) { + return; + } + + // remove listeners that were attached in onDragStart + + window.removeEventListener('mousemove', this.onDragMove); + window.removeEventListener('mouseup', this.onDragEnd); + + // restore body styles + + document.body.style.userSelect = this.previousUserSelect; + this.previousUserSelect = null; + + // indicate drag has ended + + switch (this.state.currentDraggingHandle) { + case ViewHandleType.Left: { + this.setState(state => { + return { + isDragging: false, + currentDraggingHandle: void 0, + + // commit leftHandlePosition to be viewWindowStart + viewWindowStart: state.leftHandlePosition, + }; + }); + break; + } + case ViewHandleType.Right: { + this.setState(state => { + return { + isDragging: false, + currentDraggingHandle: void 0, + + // commit rightHandlePosition to be viewWindowEnd + viewWindowEnd: state.rightHandlePosition, + }; + }); + break; + } + default: { + throw Error('this.state.currentDraggingHandle is undefined'); + } + } + + this.setState({ + isDragging: false, + currentDraggingHandle: void 0, + }); + }; + + render() { + const childrenProps = { + isDragging: this.state.isDragging, + + onLeftHandleDragStart: this.onLeftHandleDragStart, + leftHandlePosition: this.state.leftHandlePosition, + viewWindowStart: this.state.viewWindowStart, + + onRightHandleDragStart: this.onRightHandleDragStart, + rightHandlePosition: this.state.rightHandlePosition, + viewWindowEnd: this.state.viewWindowEnd, + }; + + return this.props.children(childrenProps); + } +} + +export default DragManager; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx new file mode 100644 index 00000000000000..63fbb6fe7ff462 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import {t} from 'app/locale'; +import SentryTypes from 'app/sentryTypes'; + +import {Panel, PanelHeader, PanelBody} from 'app/components/panels'; + +import {SpanEntry, SentryEvent} from './types'; +import TransactionView from './transactionView'; + +type SpansInterfacePropTypes = { + event: SentryEvent; +} & SpanEntry; + +class SpansInterface extends React.Component { + static propTypes = { + event: SentryTypes.Event.isRequired, + }; + render() { + const {event} = this.props; + + return ( + + + {t('Trace View')} + + + + + + ); + } +} + +export default SpansInterface; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx new file mode 100644 index 00000000000000..bf62f7a6799f90 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/minimap.tsx @@ -0,0 +1,613 @@ +import React from 'react'; +import styled from 'react-emotion'; + +import space from 'app/styles/space'; + +import { + rectOfContent, + clamp, + rectRelativeTo, + rectOfElement, + toPercent, + getHumanDuration, +} from './utils'; +import {DragManagerChildrenProps} from './dragManager'; +import {ParsedTraceType, TickAlignment} from './types'; + +const MINIMAP_HEIGHT = 75; +const TIME_AXIS_HEIGHT = 30; + +type MinimapProps = { + traceViewRef: React.RefObject; + minimapInteractiveRef: React.RefObject; + dragProps: DragManagerChildrenProps; + trace: ParsedTraceType; +}; + +type MinimapState = { + showCursorGuide: boolean; + mousePageX: number | undefined; + startViewHandleX: number; +}; + +class Minimap extends React.Component { + state: MinimapState = { + showCursorGuide: false, + mousePageX: void 0, + startViewHandleX: 100, + }; + + minimapRef = React.createRef(); + + componentDidMount() { + this.drawMinimap(); + } + + drawMinimap = () => { + const canvas = this.minimapRef.current; + const traceViewDOM = this.props.traceViewRef.current; + + if (!canvas || !traceViewDOM) { + return; + } + + const canvasContext = canvas.getContext('2d'); + + if (!canvasContext) { + return; + } + + const rootRect = rectOfContent(traceViewDOM); + + const scaleX = canvas.clientWidth / rootRect.width; + const scaleY = canvas.clientHeight / rootRect.height; + + // https://www.html5rocks.com/en/tutorials/canvas/hidpi/ + // we consider the devicePixelRatio (dpr) factor so that the canvas looks decent on hidpi screens + // such as retina macbooks + const devicePixelRatio = window.devicePixelRatio || 1; + + const resizeCanvas = (width: number, height: number) => { + // scale the canvas up by the dpr factor + canvas.width = width * devicePixelRatio; + canvas.height = height * devicePixelRatio; + + // scale the canvas down by the dpr factor thru CSS + canvas.style.width = '100%'; + canvas.style.height = `${height}px`; + }; + + resizeCanvas(rootRect.width * scaleX, rootRect.height * scaleY); + + canvasContext.setTransform(1, 0, 0, 1, 0, 0); + canvasContext.clearRect(0, 0, canvas.width, canvas.height); + canvasContext.scale(scaleX, scaleY); + + // scale canvas operations by the dpr factor + canvasContext.scale(devicePixelRatio, devicePixelRatio); + + const black = (pc: number) => `rgba(0,0,0,${pc / 100})`; + const back = black(0); + + const drawRect = ( + rect: {x: number; y: number; width: number; height: number}, + colour: string + ) => { + if (colour) { + canvasContext.beginPath(); + canvasContext.rect(rect.x, rect.y, rect.width, rect.height); + canvasContext.fillStyle = colour; + canvasContext.fill(); + } + }; + + // draw background + + drawRect(rectRelativeTo(rootRect, rootRect), back); + + // draw the spans + + Array.from(traceViewDOM.querySelectorAll('[data-span="true"]')).forEach( + el => { + const backgroundColor = window.getComputedStyle(el).backgroundColor || black(10); + drawRect(rectRelativeTo(rectOfElement(el), rootRect), backgroundColor); + } + ); + }; + + renderMinimapCursorGuide = () => { + if (!this.state.showCursorGuide || !this.state.mousePageX) { + return null; + } + + const minimapCanvas = this.props.minimapInteractiveRef.current; + + if (!minimapCanvas) { + return null; + } + + const rect = rectOfContent(minimapCanvas); + + // clamp mouseLeft to be within [0, 100] + const mouseLeft = clamp( + ((this.state.mousePageX - rect.x) / rect.width) * 100, + 0, + 100 + ); + + return ( + + ); + }; + + renderViewHandles = ({ + isDragging, + onLeftHandleDragStart, + leftHandlePosition, + viewWindowStart, + onRightHandleDragStart, + rightHandlePosition, + viewWindowEnd, + }: DragManagerChildrenProps) => { + const leftHandleGhost = isDragging ? ( + + + + + ) : null; + + const leftHandle = ( + + + + + ); + + const rightHandle = ( + + + + + ); + + const rightHandleGhost = isDragging ? ( + + + + + ) : null; + + return ( + + {leftHandleGhost} + {rightHandleGhost} + {leftHandle} + {rightHandle} + + ); + }; + + renderFog = (dragProps: DragManagerChildrenProps) => { + return ( + + + + + ); + }; + + renderDurationGuide = () => { + if (!this.state.showCursorGuide || !this.state.mousePageX) { + return null; + } + + const minimapCanvas = this.props.minimapInteractiveRef.current; + + if (!minimapCanvas) { + return null; + } + + const rect = rectOfContent(minimapCanvas); + + // clamp mouseLeft to be within [0, 1] + const mouseLeft = clamp((this.state.mousePageX - rect.x) / rect.width, 0, 1); + + const {trace} = this.props; + + const duration = + mouseLeft * Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); + + const style = {top: 0, left: `calc(${mouseLeft * 100}% + 4px)`}; + + const alignLeft = (1 - mouseLeft) * rect.width <= 100; + + return ( + + {getHumanDuration(duration)} + + ); + }; + + renderTimeAxis = () => { + const {trace} = this.props; + + const duration = Math.abs(trace.traceEndTimestamp - trace.traceStartTimestamp); + + const firstTick = ( + + ); + + const secondTick = ( + + ); + + const thirdTick = ( + + ); + + const fourthTick = ( + + ); + + const lastTick = ( + + ); + + return ( + + {firstTick} + {secondTick} + {thirdTick} + {fourthTick} + {lastTick} + + {this.renderTimeAxisCursorGuide()} + + {this.renderDurationGuide()} + + ); + }; + + renderTimeAxisCursorGuide = () => { + if (!this.state.showCursorGuide || !this.state.mousePageX) { + return null; + } + + const minimapCanvas = this.props.minimapInteractiveRef.current; + + if (!minimapCanvas) { + return null; + } + + const rect = rectOfContent(minimapCanvas); + + // clamp mouseLeft to be within [0, 100] + const mouseLeft = clamp( + ((this.state.mousePageX - rect.x) / rect.width) * 100, + 0, + 100 + ); + + return ( + + ); + }; + + render() { + return ( + + + +
{ + this.setState({ + showCursorGuide: true, + mousePageX: event.pageX, + }); + }} + onMouseLeave={() => { + this.setState({showCursorGuide: false, mousePageX: void 0}); + }} + onMouseMove={event => { + this.setState({ + showCursorGuide: true, + mousePageX: event.pageX, + }); + }} + > + + {this.renderFog(this.props.dragProps)} + {this.renderMinimapCursorGuide()} + {this.renderViewHandles(this.props.dragProps)} + + {this.renderTimeAxis()} +
+
+
+ ); + } +} + +const TimeAxis = styled('div')` + width: 100%; + position: absolute; + left: 0; + top: ${MINIMAP_HEIGHT}px; + + border-top: 1px solid #d1cad8; + + height: ${TIME_AXIS_HEIGHT}px; + background-color: #faf9fb; + + color: #9585a3; + font-size: 10px; + font-weight: 500; +`; + +const TickLabelContainer = styled('div')` + height: ${TIME_AXIS_HEIGHT}px; + + position: absolute; + top: 0; + + user-select: none; +`; + +const TickText = styled('span')` + line-height: 1; + + position: absolute; + bottom: 8px; + white-space: nowrap; + + ${({align}: {align: TickAlignment}) => { + switch (align) { + case TickAlignment.Center: { + return 'transform: translateX(-50%)'; + } + case TickAlignment.Left: { + return null; + } + + case TickAlignment.Right: { + return 'transform: translateX(-100%)'; + } + + default: { + throw Error(`Invalid tick alignment: ${align}`); + } + } + }}; +`; + +const TickMarker = styled('div')` + width: 1px; + height: 5px; + + background-color: #d1cad8; + + position: absolute; + top: 0; + left: 0; + + transform: translateX(-50%); +`; + +const TickLabel = (props: { + style: React.CSSProperties; + hideTickMarker?: boolean; + align?: TickAlignment; + duration: number; +}) => { + const {style, duration, hideTickMarker = false, align = TickAlignment.Center} = props; + + return ( + + {hideTickMarker ? null : } + {getHumanDuration(duration)} + + ); +}; + +const DurationGuideBox = styled('div')` + position: absolute; + + background-color: rgba(255, 255, 255, 1); + padding: 4px; + + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.1); + + height: 16px; + + line-height: 1; + vertical-align: middle; + + transform: translateY(50%); + + white-space: nowrap; + + ${({alignLeft}: {alignLeft: boolean}) => { + if (!alignLeft) { + return null; + } + + return 'transform: translateY(50%) translateX(-100%) translateX(-8px);'; + }}; +`; + +const MinimapContainer = styled('div')` + width: 100%; + position: relative; + left: 0; + border-bottom: 1px solid #d1cad8; + + height: ${MINIMAP_HEIGHT + TIME_AXIS_HEIGHT + 1}px; +`; + +const MinimapBackground = styled('canvas')` + height: ${MINIMAP_HEIGHT}px; + width: 100%; + position: absolute; + top: 0; + left: 0; +`; + +const InteractiveLayer = styled('svg')` + height: ${MINIMAP_HEIGHT}px; + width: 100%; + position: relative; + left: 0; +`; + +const ViewHandle = styled('rect')` + fill: #6c5fc7; + + cursor: col-resize; + + height: 20px; + + ${({isDragging}: {isDragging: boolean}) => { + if (isDragging) { + return ` + width: 5px; + transform: translate(-2.5px, ${MINIMAP_HEIGHT - 20}px); + `; + } + + return ` + width: 3px; + transform: translate(-1.5px, ${MINIMAP_HEIGHT - 20}px); + `; + }}; + + &:hover { + width: 5px; + transform: translate(-2.5px, ${MINIMAP_HEIGHT - 20}px); + } +`; + +const Fog = styled('rect')` + fill: rgba(241, 245, 251, 0.5); +`; + +export default Minimap; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx new file mode 100644 index 00000000000000..1f44cc61cd688a --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/spanDetail.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import styled from 'react-emotion'; +import {get, map} from 'lodash'; + +import DateTime from 'app/components/dateTime'; +import Pills from 'app/components/pills'; +import Pill from 'app/components/pill'; +import space from 'app/styles/space'; + +import {SpanType} from './types'; + +type PropTypes = { + span: Readonly; +}; + +const SpanDetail = (props: PropTypes) => { + const {span} = props; + + const startTimestamp: number = span.start_timestamp; + const endTimestamp: number = span.timestamp; + + const duration = (endTimestamp - startTimestamp) * 1000; + const durationString = `${duration.toFixed(3)} ms`; + + return ( + { + // prevent toggling the span detail + event.stopPropagation(); + }} + > + + + {span.span_id} + {span.trace_id} + {span.parent_span_id || ''} + {get(span, 'description', '')} + + + + {` (${startTimestamp})`} + + + + + + {` (${endTimestamp})`} + + + {durationString} + {span.op || ''} + + {String(!!span.same_process_as_parent)} + + + {map(get(span, 'data', {}), (value, key) => { + return ( + + {JSON.stringify(value, null, 4) || ''} + + ); + })} + {JSON.stringify(span, null, 4)} + +
+
+ ); +}; + +const SpanDetailContainer = styled('div')` + border-bottom: 1px solid #d1cad8; + padding: ${space(2)}; + background-color: #fff; + + cursor: auto; +`; + +const Row = ({ + title, + keep, + children, +}: { + title: string; + keep?: boolean; + children: JSX.Element | string; +}) => { + if (!keep && !children) { + return null; + } + + return ( + + {title} + +
+          {children}
+        
+ + + ); +}; + +const Tags = ({span}: {span: SpanType}) => { + const tags: {[tag_name: string]: string} | undefined = get(span, 'tags'); + + if (!tags) { + return null; + } + + const keys = Object.keys(tags); + + if (keys.length <= 0) { + return null; + } + + return ( + + Tags + + + {keys.map((key, index) => { + return ; + })} + + + + ); +}; + +export default SpanDetail; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx new file mode 100644 index 00000000000000..25a1b51391d23a --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx @@ -0,0 +1,547 @@ +import React from 'react'; +import styled from 'react-emotion'; +import {get} from 'lodash'; + +import space from 'app/styles/space'; +import Count from 'app/components/count'; + +import {SpanType, SpanChildrenLookupType, ParsedTraceType} from './types'; +import { + toPercent, + boundsGenerator, + SpanBoundsType, + SpanGeneratedBoundsType, + getHumanDuration, +} from './utils'; +import {DragManagerChildrenProps} from './dragManager'; +import SpanDetail from './spanDetail'; + +type RenderedSpanTree = { + spanTree: JSX.Element | null; + numOfHiddenSpansAbove: number; +}; + +type SpanTreeProps = { + traceViewRef: React.RefObject; + trace: ParsedTraceType; + dragProps: DragManagerChildrenProps; +}; + +class SpanTree extends React.Component { + renderSpan = ({ + treeDepth, + numOfHiddenSpansAbove, + spanID, + traceID, + lookup, + span, + generateBounds, + pickSpanBarColour, + }: { + treeDepth: number; + numOfHiddenSpansAbove: number; + spanID: string; + traceID: string; + span: Readonly; + lookup: Readonly; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + pickSpanBarColour: () => string; + }): RenderedSpanTree => { + const spanBarColour: string = pickSpanBarColour(); + + const spanChildren: Array = get(lookup, spanID, []); + + const bounds = generateBounds({ + startTimestamp: span.start_timestamp, + endTimestamp: span.timestamp, + }); + + const isCurrentSpanHidden = bounds.end <= 0 || bounds.start >= 1; + + type AccType = { + renderedSpanChildren: Array; + numOfHiddenSpansAbove: number; + }; + + const reduced: AccType = spanChildren.reduce( + (acc: AccType, spanChild) => { + const key = `${traceID}${spanChild.span_id}`; + + const results = this.renderSpan({ + treeDepth: treeDepth + 1, + numOfHiddenSpansAbove: acc.numOfHiddenSpansAbove, + span: spanChild, + spanID: spanChild.span_id, + traceID, + lookup, + generateBounds, + pickSpanBarColour, + }); + + acc.renderedSpanChildren.push( + {results.spanTree} + ); + + acc.numOfHiddenSpansAbove = results.numOfHiddenSpansAbove; + + return acc; + }, + { + renderedSpanChildren: [], + numOfHiddenSpansAbove: isCurrentSpanHidden ? numOfHiddenSpansAbove + 1 : 0, + } + ); + + const showHiddenSpansMessage = !isCurrentSpanHidden && numOfHiddenSpansAbove > 0; + + const hiddenSpansMessage = showHiddenSpansMessage ? ( + + Number of hidden spans: {numOfHiddenSpansAbove} + + ) : null; + + return { + numOfHiddenSpansAbove: reduced.numOfHiddenSpansAbove, + spanTree: ( + + {hiddenSpansMessage} + + + ), + }; + }; + + renderRootSpan = (): RenderedSpanTree => { + const {dragProps, trace} = this.props; + + // TODO: ideally this should be provided + const rootSpan: SpanType = { + trace_id: trace.traceID, + parent_span_id: void 0, + span_id: trace.rootSpanID, + start_timestamp: trace.traceStartTimestamp, + timestamp: trace.traceEndTimestamp, + same_process_as_parent: true, + op: 'transaction', + data: {}, + }; + + const COLORS = ['#e9e7f7', '#fcefde', '#fffbee', '#f1f5fb']; + let current_index = 0; + + const pickSpanBarColour = () => { + const next_colour = COLORS[current_index]; + + current_index++; + current_index = current_index % COLORS.length; + + return next_colour; + }; + + const generateBounds = boundsGenerator({ + traceStartTimestamp: trace.traceStartTimestamp, + traceEndTimestamp: trace.traceEndTimestamp, + viewStart: dragProps.viewWindowStart, + viewEnd: dragProps.viewWindowEnd, + }); + + return this.renderSpan({ + treeDepth: 0, + numOfHiddenSpansAbove: 0, + span: rootSpan, + spanID: rootSpan.span_id, + traceID: rootSpan.trace_id, + lookup: trace.lookup, + generateBounds, + pickSpanBarColour, + }); + }; + + render() { + const {spanTree, numOfHiddenSpansAbove} = this.renderRootSpan(); + + const hiddenSpansMessage = + numOfHiddenSpansAbove > 0 ? ( + + Number of hidden spans: {numOfHiddenSpansAbove} + + ) : null; + + return ( + + {spanTree} + {hiddenSpansMessage} + + ); + } +} + +type SpanPropTypes = { + span: Readonly; + generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType; + treeDepth: number; + numOfSpanChildren: number; + renderedSpanChildren: Array; + spanBarColour: string; +}; + +type SpanState = { + displayDetail: boolean; + showSpanTree: boolean; +}; + +class Span extends React.Component { + state: SpanState = { + displayDetail: false, + showSpanTree: true, + }; + + toggleSpanTree = () => { + this.setState(state => { + return { + showSpanTree: !state.showSpanTree, + }; + }); + }; + + toggleDisplayDetail = () => { + this.setState(state => { + return { + displayDetail: !state.displayDetail, + }; + }); + }; + + renderDetail = ({isVisible}: {isVisible: boolean}) => { + if (!this.state.displayDetail || !isVisible) { + return null; + } + + const {span} = this.props; + + return ; + }; + + getBounds = () => { + const {span, generateBounds} = this.props; + + return generateBounds({ + startTimestamp: span.start_timestamp, + endTimestamp: span.timestamp, + }); + }; + + renderSpanTreeToggler = ({left}: {left: number}) => { + const {numOfSpanChildren} = this.props; + + const chevron = this.state.showSpanTree ? : ; + + if (numOfSpanChildren <= 0) { + return null; + } + + return ( + + { + event.stopPropagation(); + + this.toggleSpanTree(); + }} + > + + + +
+ {chevron} +
+
+
+ ); + }; + + renderTitle = () => { + const {span, treeDepth} = this.props; + + const op = span.op ? {`${span.op} \u2014 `} : ''; + const description = get(span, 'description', span.span_id); + + const MARGIN_LEFT = 8; + const TOGGLE_BUTTON_MARGIN_RIGHT = 8; + const TOGGLE_BUTTON_MAX_WIDTH = 40; + + const left = + treeDepth * (TOGGLE_BUTTON_MAX_WIDTH + TOGGLE_BUTTON_MARGIN_RIGHT) + MARGIN_LEFT; + + return ( + + {this.renderSpanTreeToggler({left})} + + + {op} + {description} + + + + ); + }; + + renderSpanChildren = () => { + if (!this.state.showSpanTree) { + return null; + } + + return this.props.renderedSpanChildren; + }; + + render() { + const {span, spanBarColour} = 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 isVisible = bounds.end > 0 && bounds.start < 1; + + return ( + + { + this.toggleDisplayDetail(); + }} + > + + {this.renderTitle()} + {durationString} + {this.renderDetail({isVisible})} + + {this.renderSpanChildren()} + + ); + } +} + +const TraceViewContainer = styled('div')` + overflow-x: hidden; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +`; + +const SPAN_ROW_HEIGHT = 25; + +const SpanRow = styled('div')` + position: relative; + overflow: hidden; + + cursor: pointer; + transition: background-color 0.15s ease-in-out; + + &:last-child { + & > [data-component='span-detail'] { + border-bottom: none !important; + } + } + + &:hover { + background-color: rgba(189, 180, 199, 0.1); + + & > [data-span='true'] { + transition: border-color 0.15s ease-in-out; + border: 1px solid rgba(0, 0, 0, 0.1); + } + } +`; + +const SpanRowMessage = styled(SpanRow)` + cursor: auto; + + color: #4a3e56; + font-size: 12px; + line-height: ${SPAN_ROW_HEIGHT}px; + + padding-left: ${space(1)}; + padding-right: ${space(1)}; + + background-color: #f1f5fb !important; + + outline: 1px solid #c9d4ea; + + z-index: 99999; +`; + +const SpanBarTitleContainer = styled('div')` + display: flex; + align-items: center; + + height: ${SPAN_ROW_HEIGHT}px; + position: absolute; + left: 0; + top: 0; + width: 100%; +`; + +const SpanBarTitle = styled('div')` + position: relative; + top: 0; + + height: ${SPAN_ROW_HEIGHT}px; + line-height: ${SPAN_ROW_HEIGHT}px; + + color: #4a3e56; + font-size: 12px; + + user-select: none; + + white-space: nowrap; +`; + +const SpanTreeTogglerContainer = styled('div')` + position: relative; + top: 0; + + height: 15px; + + max-width: 40px; + width: 40px; + min-width: 40px; + + margin-right: 8px; + + z-index: 999999; + + user-select: none; + + display: flex; + justify-content: flex-end; +`; + +const SpanTreeToggler = styled('div')` + position: relative; + + white-space: nowrap; + + height: 15px; + min-width: 25px; + + padding-left: 4px; + padding-right: 4px; + + display: flex; + flex-wrap: nowrap; + align-items: center; + align-content: center; + justify-content: center; + + > span { + flex-grow: 999; + } + + border-radius: 99px; + border: 1px solid #6e5f7d; + + background: #fbfaf9; + transition: all 0.15s ease-in-out; + + font-size: 9px; + line-height: 0; + color: #6e5f7d; + + &:hover { + background: #6e5f7d; + border: 1px solid #452650; + color: #ffffff; + + & svg path { + stroke: #fff; + } + } +`; + +const Duration = styled('div')` + position: absolute; + right: 0; + top: 0; + height: ${SPAN_ROW_HEIGHT}px; + line-height: ${SPAN_ROW_HEIGHT}px; + + color: #9585a3; + font-size: 12px; + padding-right: ${space(1)}; + + user-select: none; +`; + +const SpanBar = styled('div')` + position: relative; + min-height: ${SPAN_ROW_HEIGHT - 4}px; + height: ${SPAN_ROW_HEIGHT - 4}px; + max-height: ${SPAN_ROW_HEIGHT - 4}px; + + margin-top: 2px; + margin-bottom: 2px; + border-radius: 3px; + + overflow: hidden; + + user-select: none; + + padding: 4px; + + transition: border-color 0.15s ease-in-out; + border: 1px solid rgba(0, 0, 0, 0); +`; + +const ChevronOpen = props => ( + + + +); + +const ChevronClosed = props => ( + + + +); + +export default SpanTree; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx new file mode 100644 index 00000000000000..5437ab5901e287 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/transactionView.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import {get, set, isNumber, forEach} from 'lodash'; + +import {t} from 'app/locale'; +import EmptyStateWarning from 'app/components/emptyStateWarning'; + +import DragManager, {DragManagerChildrenProps} from './dragManager'; +import SpanTree from './spanTree'; +import {SpanType, SpanEntry, SentryEvent, ParsedTraceType} from './types'; +import {isValidSpanID} from './utils'; +import TraceViewMinimap from './minimap'; + +type TraceContextType = { + type: 'trace'; + span_id: string; + trace_id: string; +}; + +type TransactionViewProps = { + event: Readonly; +}; + +type TransactionViewState = { + renderMinimap: boolean; +}; + +class TransactionView extends React.Component< + TransactionViewProps, + TransactionViewState +> { + minimapInteractiveRef = React.createRef(); + traceViewRef = React.createRef(); + + state: TransactionViewState = { + renderMinimap: false, + }; + + componentDidMount() { + if (this.traceViewRef.current) { + // eslint-disable-next-line react/no-did-mount-set-state + this.setState({ + renderMinimap: true, + }); + } + } + + renderMinimap = (dragProps: DragManagerChildrenProps, parsedTrace: ParsedTraceType) => { + if (!this.state.renderMinimap) { + return null; + } + + return ( + + ); + }; + + getTraceContext = () => { + const {event} = this.props; + + const traceContext: TraceContextType | undefined = get(event, 'contexts.trace'); + + return traceContext; + }; + + parseTrace = (): ParsedTraceType => { + const {event} = this.props; + + const spanEntry: SpanEntry | undefined = event.entries.find( + (entry: {type: string}) => entry.type === 'spans' + ); + + const spans: Array = get(spanEntry, 'data', []); + + const traceContext = this.getTraceContext(); + const traceID = (traceContext && traceContext.trace_id) || ''; + const rootSpanID = (traceContext && traceContext.span_id) || ''; + + if (!spanEntry || spans.length <= 0) { + return { + lookup: {}, + traceStartTimestamp: event.startTimestamp, + traceEndTimestamp: event.endTimestamp, + traceID, + rootSpanID, + }; + } + + // we reduce spans to become an object mapping span ids to their children + + const init: ParsedTraceType = { + lookup: {}, + traceStartTimestamp: event.startTimestamp, + traceEndTimestamp: event.endTimestamp, + traceID, + rootSpanID, + }; + + const reduced: ParsedTraceType = spans.reduce((acc, span) => { + if (!isValidSpanID(span.parent_span_id)) { + return acc; + } + + const spanChildren: Array = get(acc.lookup, span.parent_span_id!, []); + + spanChildren.push(span); + + set(acc.lookup, span.parent_span_id!, spanChildren); + + if (!acc.traceStartTimestamp || span.start_timestamp < acc.traceStartTimestamp) { + acc.traceStartTimestamp = span.start_timestamp; + } + + // establish trace end timestamp + + const hasEndTimestamp = isNumber(span.timestamp); + + if (!acc.traceEndTimestamp) { + if (hasEndTimestamp) { + acc.traceEndTimestamp = span.timestamp; + return acc; + } + + acc.traceEndTimestamp = span.start_timestamp; + return acc; + } + + if (hasEndTimestamp && span.timestamp! > acc.traceEndTimestamp) { + acc.traceEndTimestamp = span.timestamp; + return acc; + } + + if (span.start_timestamp > acc.traceEndTimestamp) { + acc.traceEndTimestamp = span.start_timestamp; + } + + return acc; + }, init); + + // sort span children by their start timestamps in ascending order + + forEach(reduced.lookup, spanChildren => { + spanChildren.sort((firstSpan, secondSpan) => { + if (firstSpan.start_timestamp < secondSpan.start_timestamp) { + return -1; + } + + if (firstSpan.start_timestamp === secondSpan.start_timestamp) { + return 0; + } + + return 1; + }); + }); + + return reduced; + }; + + render() { + if (!this.getTraceContext()) { + return ( + +

{t('There is no trace for this transaction')}

+
+ ); + } + + const parsedTrace = this.parseTrace(); + + return ( + + {(dragProps: DragManagerChildrenProps) => { + return ( + + {this.renderMinimap(dragProps, parsedTrace)} + + + ); + }} + + ); + } +} + +export default TransactionView; diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx new file mode 100644 index 00000000000000..a2dc2308903339 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/types.tsx @@ -0,0 +1,38 @@ +export type SpanType = { + trace_id: string; + parent_span_id?: string; + span_id: string; + start_timestamp: number; + timestamp: number; // this is essentially end_timestamp + same_process_as_parent: boolean; + op?: string; + description?: string; + data: Object; +}; + +export type SpanEntry = { + type: 'spans'; + data: Array; +}; + +export type SentryEvent = { + entries: Array; + startTimestamp: number; + endTimestamp: number; +}; + +export type SpanChildrenLookupType = {[span_id: string]: Array}; + +export type ParsedTraceType = { + lookup: SpanChildrenLookupType; + traceID: string; + rootSpanID: string; + traceStartTimestamp: number; + traceEndTimestamp: number; +}; + +export enum TickAlignment { + Left, + Right, + Center, +} diff --git a/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx b/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx new file mode 100644 index 00000000000000..e5e5bff5bdc640 --- /dev/null +++ b/src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx @@ -0,0 +1,125 @@ +import {isString, isNumber} from 'lodash'; + +type Rect = { + // x and y are left/top coords respectively + x: number; + y: number; + width: number; + height: number; +}; + +// get position of element relative to top/left of document +const getOffsetOfElement = (element: HTMLElement) => { + // left and top are relative to viewport + const {left, top} = element.getBoundingClientRect(); + + // get values that the document is currently scrolled by + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + return {x: left + scrollLeft, y: top + scrollTop}; +}; + +export const rectOfContent = (element: HTMLElement): Rect => { + const {x, y} = getOffsetOfElement(element); + + // offsets for the border and any scrollbars (clientLeft and clientTop), + // and if the element was scrolled (scrollLeft and scrollTop) + // + // NOTE: clientLeft and clientTop does not account for any margins nor padding + const contentOffsetLeft = element.clientLeft - element.scrollLeft; + const contentOffsetTop = element.clientTop - element.scrollTop; + + return { + x: x + contentOffsetLeft, + y: y + contentOffsetTop, + width: element.scrollWidth, + height: element.scrollHeight, + }; +}; + +export const rectRelativeTo = (rect: Rect, pos = {x: 0, y: 0}): Rect => { + return { + x: rect.x - pos.x, + y: rect.y - pos.y, + width: rect.width, + height: rect.height, + }; +}; + +export const rectOfElement = (element: HTMLElement): Rect => { + const {x, y} = getOffsetOfElement(element); + return { + x, + y, + width: element.offsetWidth, + height: element.offsetHeight, + }; +}; + +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export const isValidSpanID = (maybeSpanID: any) => { + return isString(maybeSpanID) && maybeSpanID.length > 0; +}; + +export const toPercent = (value: number) => { + return `${(value * 100).toFixed(3)}%`; +}; + +export type SpanBoundsType = {startTimestamp: number; endTimestamp: number}; +export type SpanGeneratedBoundsType = {start: number; end: number}; + +export const boundsGenerator = (bounds: { + traceStartTimestamp: number; + traceEndTimestamp: number; + viewStart: number; // in [0, 1] + viewEnd: number; // in [0, 1] +}) => { + const {traceEndTimestamp, traceStartTimestamp, viewStart, viewEnd} = bounds; + + // viewStart and viewEnd are percentage values (%) of the view window relative to the left + // side of the trace view minimap + + // invariant: viewStart <= viewEnd + + // duration of the entire trace in seconds + const duration = traceEndTimestamp - traceStartTimestamp; + + const viewStartTimestamp = traceStartTimestamp + viewStart * duration; + const viewEndTimestamp = traceEndTimestamp - (1 - viewEnd) * duration; + const viewDuration = viewEndTimestamp - viewStartTimestamp; + + return (spanBounds: SpanBoundsType): SpanGeneratedBoundsType => { + const {startTimestamp, endTimestamp} = spanBounds; + + const start = (startTimestamp - viewStartTimestamp) / viewDuration; + + if (!isNumber(endTimestamp)) { + return { + start, + end: 1, + }; + } + + return { + start, + end: (endTimestamp - viewStartTimestamp) / viewDuration, + }; + }; +}; + +export const getHumanDuration = (duration: number): string => { + // note: duration is assumed to be in seconds + + const durationMS = duration * 1000; + return `${durationMS.toFixed(3)} ms`; +};