import * as React from 'react'; import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import Alert from 'sentry/components/alert'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import ButtonBar from 'sentry/components/buttonBar'; import DiscoverFeature from 'sentry/components/discover/discoverFeature'; import DiscoverButton from 'sentry/components/discoverButton'; import * as AnchorLinkManager from 'sentry/components/events/interfaces/spans/anchorLinkManager'; import * as DividerHandlerManager from 'sentry/components/events/interfaces/spans/dividerHandlerManager'; import * as ScrollbarManager from 'sentry/components/events/interfaces/spans/scrollbarManager'; import * as Layout from 'sentry/components/layouts/thirds'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {MessageRow} from 'sentry/components/performance/waterfall/messageRow'; import { DividerSpacer, ScrollbarContainer, VirtualScrollbar, VirtualScrollbarGrip, } from 'sentry/components/performance/waterfall/miniHeader'; import { ErrorDot, ErrorLevel, ErrorMessageContent, ErrorTitle, } from 'sentry/components/performance/waterfall/rowDetails'; import {pickBarColor, toPercent} from 'sentry/components/performance/waterfall/utils'; import TimeSince from 'sentry/components/timeSince'; import {t, tct, tn} from 'sentry/locale'; import space from 'sentry/styles/space'; import {Organization} from 'sentry/types'; import {defined} from 'sentry/utils'; import DiscoverQuery from 'sentry/utils/discover/discoverQuery'; import EventView from 'sentry/utils/discover/eventView'; import {QueryError} from 'sentry/utils/discover/genericDiscoverQuery'; import {getDuration} from 'sentry/utils/formatters'; import {createFuzzySearch, Fuse} from 'sentry/utils/fuzzySearch'; import getDynamicText from 'sentry/utils/getDynamicText'; import {TraceFullDetailed, TraceMeta} from 'sentry/utils/performance/quickTrace/types'; import {filterTrace, reduceTrace} from 'sentry/utils/performance/quickTrace/utils'; import Breadcrumb from 'sentry/views/performance/breadcrumb'; import {MetaData} from 'sentry/views/performance/transactionDetails/styles'; import { TraceDetailBody, TraceDetailHeader, TracePanel, TraceSearchBar, TraceSearchContainer, TraceViewContainer, TraceViewHeaderContainer, } from './styles'; import TransactionGroup from './transactionGroup'; import {TraceInfo, TreeDepth} from './types'; import {getTraceInfo, isRootTransaction} from './utils'; type IndexedFusedTransaction = { indexed: string[]; transaction: TraceFullDetailed; }; type AccType = { lastIndex: number; numberOfHiddenTransactionsAbove: number; renderedChildren: React.ReactNode[]; }; type Props = Pick, 'params' | 'location'> & { dateSelected: boolean; error: QueryError | null; isLoading: boolean; meta: TraceMeta | null; organization: Organization; traceEventView: EventView; traceSlug: string; traces: TraceFullDetailed[] | null; }; type State = { filteredTransactionIds: Set | undefined; searchQuery: string | undefined; }; class TraceDetailsContent extends React.Component { state: State = { searchQuery: undefined, filteredTransactionIds: undefined, }; componentDidMount() { this.initFuse(); } componentDidUpdate(prevProps: Props) { if (this.props.traces !== prevProps.traces) { this.initFuse(); } } fuse: Fuse | null = null; traceViewRef = React.createRef(); virtualScrollbarContainerRef = React.createRef(); async initFuse() { if (defined(this.props.traces) && this.props.traces.length > 0) { const transformed: IndexedFusedTransaction[] = this.props.traces.flatMap(trace => reduceTrace( trace, (acc, transaction) => { const indexed: string[] = [ transaction['transaction.op'], transaction.transaction, transaction.project_slug, ]; acc.push({ transaction, indexed, }); return acc; }, [] ) ); this.fuse = await createFuzzySearch(transformed, { keys: ['indexed'], includeMatches: true, threshold: 0.6, location: 0, distance: 100, maxPatternLength: 32, }); } } renderTraceLoading() { return ; } renderTraceRequiresDateRangeSelection() { return ; } renderTraceNotFound() { const {meta, traceEventView, traceSlug, organization, location} = this.props; const transactions = meta?.transactions ?? 0; const errors = meta?.errors ?? 0; if (transactions === 0 && errors > 0) { const errorsEventView = traceEventView.withColumns([ {kind: 'field', field: 'project'}, {kind: 'field', field: 'title'}, {kind: 'field', field: 'issue.id'}, {kind: 'field', field: 'level'}, ]); errorsEventView.query = `trace:${traceSlug} !event.type:transaction `; return ( {({isLoading, tableData, error}) => { if (isLoading) { return ; } if (error) { return ( {tct( 'The trace cannot be shown when all events are errors. An error occurred when attempting to fetch these error events: [error]', {error: error.message} )} ); } return ( {t('The trace cannot be shown when all events are errors.')} {tableData?.data.map(data => ( {data.level} {data.title} ))} ); }} ); } return ; } handleTransactionFilter = (searchQuery: string) => { this.setState({searchQuery: searchQuery || undefined}, this.filterTransactions); }; filterTransactions = () => { const {traces} = this.props; const {filteredTransactionIds, searchQuery} = this.state; if (!searchQuery || traces === null || traces.length <= 0 || !defined(this.fuse)) { if (filteredTransactionIds !== undefined) { this.setState({ filteredTransactionIds: undefined, }); } return; } const fuseMatches = this.fuse .search(searchQuery) /** * Sometimes, there can be matches that don't include any * indices. These matches are often noise, so exclude them. */ .filter(({matches}) => matches?.length) .map(({item}) => item.transaction.event_id); /** * Fuzzy search on ids result in seemingly random results. So switch to * doing substring matches on ids to provide more meaningful results. */ const idMatches = traces .flatMap(trace => filterTrace( trace, ({event_id, span_id}) => event_id.includes(searchQuery) || span_id.includes(searchQuery) ) ) .map(transaction => transaction.event_id); this.setState({ filteredTransactionIds: new Set([...fuseMatches, ...idMatches]), }); }; renderSearchBar() { return ( ); } isTransactionVisible = (transaction: TraceFullDetailed): boolean => { const {filteredTransactionIds} = this.state; return filteredTransactionIds ? filteredTransactionIds.has(transaction.event_id) : true; }; renderTraceHeader(traceInfo: TraceInfo) { const {meta} = this.props; return ( , fixed: '5 days ago', })} /> ); } renderTraceWarnings() { const {traces} = this.props; const {roots, orphans} = (traces ?? []).reduce( (counts, trace) => { if (isRootTransaction(trace)) { counts.roots++; } else { counts.orphans++; } return counts; }, {roots: 0, orphans: 0} ); let warning: React.ReactNode = null; if (roots === 0 && orphans > 0) { warning = ( {t( 'A root transaction is missing. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.' )} ); } else if (roots === 1 && orphans > 0) { warning = ( {t( 'This trace has broken subtraces. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.' )} ); } else if (roots > 1) { warning = ( {t('Multiple root transactions have been found with this trace ID.')} ); } return warning; } renderInfoMessage({ isVisible, numberOfHiddenTransactionsAbove, }: { isVisible: boolean; numberOfHiddenTransactionsAbove: number; }) { const messages: React.ReactNode[] = []; if (isVisible) { if (numberOfHiddenTransactionsAbove === 1) { messages.push( {tct('[numOfTransaction] hidden transaction', { numOfTransaction: {numberOfHiddenTransactionsAbove}, })} ); } else if (numberOfHiddenTransactionsAbove > 1) { messages.push( {tct('[numOfTransaction] hidden transactions', { numOfTransaction: {numberOfHiddenTransactionsAbove}, })} ); } } if (messages.length <= 0) { return null; } return {messages}; } renderLimitExceededMessage(traceInfo: TraceInfo) { const {traceEventView, organization, meta} = this.props; const count = traceInfo.transactions.size; const totalTransactions = meta?.transactions ?? count; if (totalTransactions === null || count >= totalTransactions) { return null; } const target = traceEventView.getResultsViewUrlTarget(organization.slug); return ( {tct( 'Limited to a view of [count] transactions. To view the full list, [discover].', { count, discover: ( {({hasFeature}) => ( Open in Discover )} ), } )} ); } renderTransaction( transaction: TraceFullDetailed, { continuingDepths, isOrphan, isLast, index, numberOfHiddenTransactionsAbove, traceInfo, hasGuideAnchor, }: { continuingDepths: TreeDepth[]; hasGuideAnchor: boolean; index: number; isLast: boolean; isOrphan: boolean; numberOfHiddenTransactionsAbove: number; traceInfo: TraceInfo; } ) { const {location, organization} = this.props; const {children, event_id: eventId} = transaction; // Add 1 to the generation to make room for the "root trace" const generation = transaction.generation + 1; const isVisible = this.isTransactionVisible(transaction); const accumulated: AccType = children.reduce( (acc: AccType, child: TraceFullDetailed, idx: number) => { const isLastChild = idx === children.length - 1; const hasChildren = child.children.length > 0; const result = this.renderTransaction(child, { continuingDepths: !isLastChild && hasChildren ? [...continuingDepths, {depth: generation, isOrphanDepth: isOrphan}] : continuingDepths, isOrphan, isLast: isLastChild, index: acc.lastIndex + 1, numberOfHiddenTransactionsAbove: acc.numberOfHiddenTransactionsAbove, traceInfo, hasGuideAnchor: false, }); acc.lastIndex = result.lastIndex; acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove; acc.renderedChildren.push(result.transactionGroup); return acc; }, { renderedChildren: [], lastIndex: index, numberOfHiddenTransactionsAbove: isVisible ? 0 : numberOfHiddenTransactionsAbove + 1, } ); return { transactionGroup: ( {this.renderInfoMessage({ isVisible, numberOfHiddenTransactionsAbove, })} ), lastIndex: accumulated.lastIndex, numberOfHiddenTransactionsAbove: accumulated.numberOfHiddenTransactionsAbove, }; } renderTraceView(traceInfo: TraceInfo) { const sentryTransaction = Sentry.getCurrentHub().getScope()?.getTransaction(); const sentrySpan = sentryTransaction?.startChild({ op: 'trace.render', description: 'trace-view-content', }); const {location, organization, traces, traceSlug} = this.props; if (traces === null || traces.length <= 0) { return this.renderTraceNotFound(); } const accumulator: { index: number; numberOfHiddenTransactionsAbove: number; traceInfo: TraceInfo; transactionGroups: React.ReactNode[]; } = { index: 1, numberOfHiddenTransactionsAbove: 0, traceInfo, transactionGroups: [], }; const {transactionGroups, numberOfHiddenTransactionsAbove} = traces.reduce( (acc, trace, index) => { const isLastTransaction = index === traces.length - 1; const hasChildren = trace.children.length > 0; const isNextChildOrphaned = !isLastTransaction && traces[index + 1].parent_span_id !== null; const result = this.renderTransaction(trace, { ...acc, // if the root of a subtrace has a parent_span_idk, then it must be an orphan isOrphan: !isRootTransaction(trace), isLast: isLastTransaction, continuingDepths: !isLastTransaction && hasChildren ? [{depth: 0, isOrphanDepth: isNextChildOrphaned}] : [], hasGuideAnchor: index === 0, }); acc.index = result.lastIndex + 1; acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove; acc.transactionGroups.push(result.transactionGroup); return acc; }, accumulator ); const traceView = ( {({dividerPosition}) => ( {({ virtualScrollbarRef, scrollBarAreaRef, onDragStart, onScroll, }) => { return (
); }} {this.renderInfoMessage({ isVisible: true, numberOfHiddenTransactionsAbove, })} {this.renderLimitExceededMessage(traceInfo)} )} ); sentrySpan?.finish(); return traceView; } renderContent() { const {dateSelected, isLoading, error, traces} = this.props; if (!dateSelected) { return this.renderTraceRequiresDateRangeSelection(); } if (isLoading) { return this.renderTraceLoading(); } if (error !== null || traces === null || traces.length <= 0) { return this.renderTraceNotFound(); } const traceInfo = getTraceInfo(traces); return ( {this.renderTraceWarnings()} {this.renderTraceHeader(traceInfo)} {this.renderSearchBar()} {this.renderTraceView(traceInfo)} ); } render() { const {organization, location, traceEventView, traceSlug} = this.props; return ( {t('Trace ID: %s', traceSlug)} Open in Discover {this.renderContent()} ); } } const ErrorLabel = styled('div')` margin-bottom: ${space(1)}; `; export default TraceDetailsContent;