grafana/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx

664 lines
21 KiB
TypeScript

// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import memoizeOne from 'memoize-one';
import * as React from 'react';
import { RefObject } from 'react';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui';
import { PEER_SERVICE } from '../constants/tag-keys';
import { CriticalPathSection, SpanBarOptions, SpanLinkFunc, TNil } from '../types';
import TTraceTimeline from '../types/TTraceTimeline';
import { TraceLog, TraceSpan, Trace, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace';
import { getColorByKey } from '../utils/color-generator';
import ListView from './ListView';
import SpanBarRow from './SpanBarRow';
import { TraceFlameGraphs } from './SpanDetail';
import DetailState from './SpanDetail/DetailState';
import SpanDetailRow from './SpanDetailRow';
import {
createViewedBoundsFunc,
findServerChildSpan,
isErrorSpan,
isKindClient,
spanContainsErredSpan,
ViewedBoundsFunctionType,
} from './utils';
const getStyles = stylesFactory(() => {
return {
rowsWrapper: css`
width: 100%;
`,
row: css`
width: 100%;
`,
scrollToTopButton: css`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
position: absolute;
bottom: 30px;
right: 30px;
z-index: 1;
`,
};
});
type RowState = {
isDetail: boolean;
span: TraceSpan;
spanIndex: number;
};
type TVirtualizedTraceViewOwnProps = {
currentViewRangeTime: [number, number];
timeZone: TimeZone;
findMatchesIDs: Set<string> | TNil;
trace: Trace;
traceToProfilesOptions?: TraceToProfilesOptions;
spanBarOptions: SpanBarOptions | undefined;
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
childrenToggle: (spanID: string) => void;
detailLogItemToggle: (spanID: string, log: TraceLog) => void;
detailLogsToggle: (spanID: string) => void;
detailWarningsToggle: (spanID: string) => void;
detailStackTracesToggle: (spanID: string) => void;
detailReferencesToggle: (spanID: string) => void;
detailReferenceItemToggle: (spanID: string, reference: TraceSpanReference) => void;
detailProcessToggle: (spanID: string) => void;
detailTagsToggle: (spanID: string) => void;
detailToggle: (spanID: string) => void;
setSpanNameColumnWidth: (width: number) => void;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2;
createSpanLink?: SpanLinkFunc;
scrollElement?: Element;
focusedSpanId?: string;
focusedSpanIdForSearch: string;
showSpanFilterMatchesOnly: boolean;
showCriticalPathSpansOnly: boolean;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
datasourceType: string;
headerHeight: number;
criticalPath: CriticalPathSection[];
traceFlameGraphs: TraceFlameGraphs;
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
redrawListView: {};
setRedrawListView: (redraw: {}) => void;
};
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TTraceTimeline;
// export for tests
export const DEFAULT_HEIGHTS = {
bar: 28,
detail: 161,
detailWithLogs: 197,
};
const NUM_TICKS = 5;
const BUFFER_SIZE = 33;
function generateRowStates(
spans: TraceSpan[] | TNil,
childrenHiddenIDs: Set<string>,
detailStates: Map<string, DetailState | TNil>,
findMatchesIDs: Set<string> | TNil,
showSpanFilterMatchesOnly: boolean,
showCriticalPathSpansOnly: boolean,
criticalPath: CriticalPathSection[]
): RowState[] {
if (!spans) {
return [];
}
if (showSpanFilterMatchesOnly && findMatchesIDs) {
spans = spans.filter((span) => findMatchesIDs.has(span.spanID));
}
if (showCriticalPathSpansOnly && criticalPath) {
spans = spans.filter((span) => criticalPath.find((section) => section.spanId === span.spanID));
}
let collapseDepth = null;
const rowStates = [];
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
const { spanID, depth } = span;
let hidden = false;
if (collapseDepth != null) {
if (depth >= collapseDepth) {
hidden = true;
} else {
collapseDepth = null;
}
}
if (hidden) {
continue;
}
if (childrenHiddenIDs.has(spanID)) {
collapseDepth = depth + 1;
}
rowStates.push({
span,
isDetail: false,
spanIndex: i,
});
if (detailStates.has(spanID)) {
rowStates.push({
span,
isDetail: true,
spanIndex: i,
});
}
}
return rowStates;
}
function getClipping(currentViewRange: [number, number]) {
const [zoomStart, zoomEnd] = currentViewRange;
return {
left: zoomStart > 0,
right: zoomEnd < 1,
};
}
function generateRowStatesFromTrace(
trace: Trace | TNil,
childrenHiddenIDs: Set<string>,
detailStates: Map<string, DetailState | TNil>,
findMatchesIDs: Set<string> | TNil,
showSpanFilterMatchesOnly: boolean,
showCriticalPathSpansOnly: boolean,
criticalPath: CriticalPathSection[]
): RowState[] {
return trace
? generateRowStates(
trace.spans,
childrenHiddenIDs,
detailStates,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath
)
: [];
}
function childSpansMap(trace: Trace | TNil) {
const childSpansMap = new Map<string, string[]>();
if (!trace) {
return childSpansMap;
}
trace.spans.forEach((span) => {
if (span.childSpanIds.length) {
childSpansMap.set(span.spanID, span.childSpanIds);
}
});
return childSpansMap;
}
const memoizedGenerateRowStates = memoizeOne(generateRowStatesFromTrace);
const memoizedViewBoundsFunc = memoizeOne(createViewedBoundsFunc, isEqual);
const memoizedGetClipping = memoizeOne(getClipping, isEqual);
const memoizedChildSpansMap = memoizeOne(childSpansMap);
// export from tests
export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTraceViewProps> {
listView: ListView | TNil;
hasScrolledToSpan = false;
componentDidMount() {
this.scrollToSpan(this.props.headerHeight, this.props.focusedSpanId);
}
shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) {
// If any prop updates, VirtualizedTraceViewImpl should update.
let key: keyof VirtualizedTraceViewProps;
for (key in nextProps) {
if (nextProps[key] !== this.props[key]) {
return true;
}
}
return false;
}
componentDidUpdate(prevProps: Readonly<VirtualizedTraceViewProps>) {
const { headerHeight, focusedSpanId, focusedSpanIdForSearch } = this.props;
if (!this.hasScrolledToSpan) {
this.scrollToSpan(headerHeight, focusedSpanId);
this.hasScrolledToSpan = true;
}
if (focusedSpanId !== prevProps.focusedSpanId) {
this.scrollToSpan(headerHeight, focusedSpanId);
}
if (focusedSpanIdForSearch !== prevProps.focusedSpanIdForSearch) {
this.scrollToSpan(headerHeight, focusedSpanIdForSearch);
}
}
getRowStates(): RowState[] {
const {
childrenHiddenIDs,
detailStates,
trace,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath,
} = this.props;
return memoizedGenerateRowStates(
trace,
childrenHiddenIDs,
detailStates,
findMatchesIDs,
showSpanFilterMatchesOnly,
showCriticalPathSpansOnly,
criticalPath
);
}
getClipping(): { left: boolean; right: boolean } {
const { currentViewRangeTime } = this.props;
return memoizedGetClipping(currentViewRangeTime);
}
getViewedBounds(): ViewedBoundsFunctionType {
const { currentViewRangeTime, trace } = this.props;
const [zoomStart, zoomEnd] = currentViewRangeTime;
return memoizedViewBoundsFunc({
min: trace.startTime,
max: trace.endTime,
viewStart: zoomStart,
viewEnd: zoomEnd,
});
}
getChildSpansMap() {
return memoizedChildSpansMap(this.props.trace);
}
getAccessors() {
const lv = this.listView;
if (!lv) {
throw new Error('ListView unavailable');
}
return {
getViewRange: this.getViewRange,
getSearchedSpanIDs: this.getSearchedSpanIDs,
getCollapsedChildren: this.getCollapsedChildren,
getViewHeight: lv.getViewHeight,
getBottomRowIndexVisible: lv.getBottomVisibleIndex,
getTopRowIndexVisible: lv.getTopVisibleIndex,
getRowPosition: lv.getRowPosition,
mapRowIndexToSpanIndex: this.mapRowIndexToSpanIndex,
mapSpanIndexToRowIndex: this.mapSpanIndexToRowIndex,
};
}
getViewRange = () => this.props.currentViewRangeTime;
getSearchedSpanIDs = () => this.props.findMatchesIDs;
getCollapsedChildren = () => this.props.childrenHiddenIDs;
mapRowIndexToSpanIndex = (index: number) => this.getRowStates()[index].spanIndex;
mapSpanIndexToRowIndex = (index: number) => {
const max = this.getRowStates().length;
for (let i = 0; i < max; i++) {
const { spanIndex } = this.getRowStates()[i];
if (spanIndex === index) {
return i;
}
}
throw new Error(`unable to find row for span index: ${index}`);
};
setListView = (listView: ListView | TNil) => {
this.listView = listView;
};
// use long form syntax to avert flow error
// https://github.com/facebook/flow/issues/3076#issuecomment-290944051
getKeyFromIndex = (index: number) => {
const { isDetail, span } = this.getRowStates()[index];
return `${span.traceID}--${span.spanID}--${isDetail ? 'detail' : 'bar'}`;
};
getIndexFromKey = (key: string) => {
const parts = key.split('--');
const _traceID = parts[0];
const _spanID = parts[1];
const _isDetail = parts[2] === 'detail';
const max = this.getRowStates().length;
for (let i = 0; i < max; i++) {
const { span, isDetail } = this.getRowStates()[i];
if (span.spanID === _spanID && span.traceID === _traceID && isDetail === _isDetail) {
return i;
}
}
return -1;
};
getRowHeight = (index: number) => {
const { span, isDetail } = this.getRowStates()[index];
if (!isDetail) {
return DEFAULT_HEIGHTS.bar;
}
if (Array.isArray(span.logs) && span.logs.length) {
return DEFAULT_HEIGHTS.detailWithLogs;
}
return DEFAULT_HEIGHTS.detail;
};
renderRow = (key: string, style: React.CSSProperties, index: number, attrs: {}) => {
const { isDetail, span, spanIndex } = this.getRowStates()[index];
// Compute the list of currently visible span IDs to pass to the row renderers.
const start = Math.max((this.listView?.getTopVisibleIndex() || 0) - BUFFER_SIZE, 0);
const end = (this.listView?.getBottomVisibleIndex() || 0) + BUFFER_SIZE;
const visibleSpanIds = this.getVisibleSpanIds(start, end);
return isDetail
? this.renderSpanDetailRow(span, key, style, attrs, visibleSpanIds)
: this.renderSpanBarRow(span, spanIndex, key, style, attrs, visibleSpanIds);
};
scrollToSpan = (headerHeight: number, spanID?: string) => {
if (spanID == null) {
return;
}
const i = this.getRowStates().findIndex((row) => row.span.spanID === spanID);
if (i >= 0) {
this.listView?.scrollToIndex(i, headerHeight);
}
};
renderSpanBarRow(
span: TraceSpan,
spanIndex: number,
key: string,
style: React.CSSProperties,
attrs: {},
visibleSpanIds: string[]
) {
const { spanID, childSpanIds } = span;
const { serviceName } = span.process;
const {
childrenHiddenIDs,
childrenToggle,
detailStates,
detailToggle,
findMatchesIDs,
spanNameColumnWidth,
trace,
spanBarOptions,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
createSpanLink,
focusedSpanId,
focusedSpanIdForSearch,
showSpanFilterMatchesOnly,
theme,
datasourceType,
criticalPath,
} = this.props;
// to avert flow error
if (!trace) {
return null;
}
const color = getColorByKey(serviceName, theme);
const isCollapsed = childrenHiddenIDs.has(spanID);
const isDetailExpanded = detailStates.has(spanID);
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
const isFocused = spanID === focusedSpanId || spanID === focusedSpanIdForSearch;
const showErrorIcon = isErrorSpan(span) || (isCollapsed && spanContainsErredSpan(trace.spans, spanIndex));
// Check for direct child "server" span if the span is a "client" span.
let rpc = null;
if (isCollapsed) {
const rpcSpan = findServerChildSpan(trace.spans.slice(spanIndex));
if (rpcSpan) {
const rpcViewBounds = this.getViewedBounds()(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
rpc = {
color: getColorByKey(rpcSpan.process.serviceName, theme),
operationName: rpcSpan.operationName,
serviceName: rpcSpan.process.serviceName,
viewEnd: rpcViewBounds.end,
viewStart: rpcViewBounds.start,
};
}
}
const peerServiceKV = span.tags.find((kv) => kv.key === PEER_SERVICE);
// Leaf, kind == client and has peer.service.tag, is likely a client span that does a request
// to an uninstrumented/external service
let noInstrumentedServer = null;
if (!span.hasChildren && peerServiceKV && isKindClient(span)) {
noInstrumentedServer = {
serviceName: peerServiceKV.value,
color: getColorByKey(peerServiceKV.value, theme),
};
}
const prevSpan = spanIndex > 0 ? trace.spans[spanIndex - 1] : null;
const allChildSpanIds = [spanID, ...childSpanIds];
// This function called recursively to find all descendants of a span
const findAllDescendants = (currentChildSpanIds: string[]) => {
currentChildSpanIds.forEach((eachId) => {
const childrenOfCurrent = this.getChildSpansMap().get(eachId);
if (childrenOfCurrent?.length) {
allChildSpanIds.push(...childrenOfCurrent);
findAllDescendants(childrenOfCurrent);
}
});
};
findAllDescendants(childSpanIds);
const criticalPathSections = criticalPath?.filter((each) => {
if (isCollapsed) {
return allChildSpanIds.includes(each.spanId);
}
return each.spanId === spanID;
});
const styles = getStyles();
return (
<div className={styles.row} key={key} style={style} {...attrs}>
<SpanBarRow
clippingLeft={this.getClipping().left}
clippingRight={this.getClipping().right}
color={color}
spanBarOptions={spanBarOptions}
columnDivision={spanNameColumnWidth}
isChildrenExpanded={!isCollapsed}
isDetailExpanded={isDetailExpanded}
isMatchingFilter={isMatchingFilter}
isFocused={isFocused}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
numTicks={NUM_TICKS}
onDetailToggled={detailToggle}
onChildrenToggled={childrenToggle}
rpc={rpc}
noInstrumentedServer={noInstrumentedServer}
showErrorIcon={showErrorIcon}
getViewedBounds={this.getViewedBounds()}
traceStartTime={trace.startTime}
span={span}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
datasourceType={datasourceType}
showServiceName={prevSpan === null || prevSpan.process.serviceName !== span.process.serviceName}
visibleSpanIds={visibleSpanIds}
criticalPath={criticalPathSections}
/>
</div>
);
}
renderSpanDetailRow(span: TraceSpan, key: string, style: React.CSSProperties, attrs: {}, visibleSpanIds: string[]) {
const { spanID } = span;
const { serviceName } = span.process;
const {
detailLogItemToggle,
detailLogsToggle,
detailProcessToggle,
detailReferencesToggle,
detailReferenceItemToggle,
detailWarningsToggle,
detailStackTracesToggle,
detailStates,
detailTagsToggle,
detailToggle,
spanNameColumnWidth,
trace,
traceToProfilesOptions,
timeZone,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
linksGetter,
createSpanLink,
focusedSpanId,
createFocusSpanLink,
theme,
datasourceType,
traceFlameGraphs,
setTraceFlameGraphs,
setRedrawListView,
} = this.props;
const detailState = detailStates.get(spanID);
if (!trace || !detailState) {
return null;
}
const color = getColorByKey(serviceName, theme);
const styles = getStyles();
return (
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
<SpanDetailRow
color={color}
columnDivision={spanNameColumnWidth}
onDetailToggled={detailToggle}
detailState={detailState}
linksGetter={linksGetter}
logItemToggle={detailLogItemToggle}
logsToggle={detailLogsToggle}
processToggle={detailProcessToggle}
referenceItemToggle={detailReferenceItemToggle}
referencesToggle={detailReferencesToggle}
warningsToggle={detailWarningsToggle}
stackTracesToggle={detailStackTracesToggle}
span={span}
traceToProfilesOptions={traceToProfilesOptions}
timeZone={timeZone}
tagsToggle={detailTagsToggle}
traceStartTime={trace.startTime}
traceDuration={trace.duration}
traceName={trace.traceName}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
focusedSpanId={focusedSpanId}
createFocusSpanLink={createFocusSpanLink}
datasourceType={datasourceType}
visibleSpanIds={visibleSpanIds}
traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs}
setRedrawListView={setRedrawListView}
/>
</div>
);
}
scrollToTop = () => {
const { topOfViewRef, datasourceType, trace } = this.props;
topOfViewRef?.current?.scrollIntoView({ behavior: 'smooth' });
reportInteraction('grafana_traces_trace_view_scroll_to_top_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
numServices: trace.services.length,
numSpans: trace.spans.length,
});
};
getVisibleSpanIds = memoizeOne((start: number, end: number) => {
const spanIds = [];
for (let i = start; i < end; i++) {
const rowState = this.getRowStates()[i];
if (rowState?.span) {
spanIds.push(rowState.span.spanID);
}
}
return spanIds;
});
render() {
const styles = getStyles();
const { scrollElement, redrawListView } = this.props;
return (
<>
<ListView
ref={this.setListView}
dataLength={this.getRowStates().length}
itemHeightGetter={this.getRowHeight}
itemRenderer={this.renderRow}
viewBuffer={BUFFER_SIZE}
viewBufferMin={BUFFER_SIZE}
itemsWrapperClassName={styles.rowsWrapper}
getKeyFromIndex={this.getKeyFromIndex}
getIndexFromKey={this.getIndexFromKey}
windowScroller={false}
scrollElement={scrollElement}
redraw={redrawListView}
/>
{this.props.topOfViewRef && ( // only for panel as explore uses content outline to scroll to top
<ToolbarButton
className={styles.scrollToTopButton}
onClick={this.scrollToTop}
title="Scroll to top"
icon="arrow-up"
></ToolbarButton>
)}
</>
);
}
}
export default withTheme2(UnthemedVirtualizedTraceView);