import {Component, Fragment} from 'react'; // eslint-disable-next-line no-restricted-imports import {withRouter, WithRouterProps} from 'react-router'; import styled from '@emotion/styled'; import map from 'lodash/map'; import omit from 'lodash/omit'; import {Client} from 'sentry/api'; import Feature from 'sentry/components/acl/feature'; import Alert from 'sentry/components/alert'; import Button from 'sentry/components/button'; import Clipboard from 'sentry/components/clipboard'; import DateTime from 'sentry/components/dateTime'; import DiscoverButton from 'sentry/components/discoverButton'; import FileSize from 'sentry/components/fileSize'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import { ErrorDot, ErrorLevel, ErrorMessageContent, ErrorMessageTitle, ErrorTitle, } from 'sentry/components/performance/waterfall/rowDetails'; import Pill from 'sentry/components/pill'; import Pills from 'sentry/components/pills'; import { generateIssueEventTarget, generateTraceTarget, } from 'sentry/components/quickTrace/utils'; import {ALL_ACCESS_PROJECTS, PAGE_URL_PARAM} from 'sentry/constants/pageFilters'; import {IconLink} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import space from 'sentry/styles/space'; import {Organization} from 'sentry/types'; import {EventTransaction} from 'sentry/types/event'; import {assert} from 'sentry/types/utils'; import {defined} from 'sentry/utils'; import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent'; import EventView from 'sentry/utils/discover/eventView'; import {generateEventSlug} from 'sentry/utils/discover/urls'; import getDynamicText from 'sentry/utils/getDynamicText'; import {QuickTraceEvent, TraceError} from 'sentry/utils/performance/quickTrace/types'; import withApi from 'sentry/utils/withApi'; import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils'; import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils'; import * as SpanEntryContext from './context'; import InlineDocs from './inlineDocs'; import {ParsedTraceType, ProcessedSpanType, rawSpanKeys, RawSpanType} from './types'; import { getCumulativeAlertLevelFromErrors, getTraceDateTimeRange, isGapSpan, isOrphanSpan, scrollToSpan, } from './utils'; const DEFAULT_ERRORS_VISIBLE = 5; const SIZE_DATA_KEYS = ['Encoded Body Size', 'Decoded Body Size', 'Transfer Size']; type TransactionResult = { id: string; 'project.name': string; 'trace.span': string; transaction: string; }; type Props = WithRouterProps & { api: Client; childTransactions: QuickTraceEvent[] | null; event: Readonly; isRoot: boolean; organization: Organization; relatedErrors: TraceError[] | null; scrollToHash: (hash: string) => void; span: Readonly; trace: Readonly; }; type State = { errorsOpened: boolean; }; class SpanDetail extends Component { state: State = { errorsOpened: false, }; componentDidMount() { const {span, organization} = this.props; if ('type' in span) { return; } trackAdvancedAnalyticsEvent('performance_views.event_details.open_span_details', { organization, operation: span.op ?? 'undefined', }); } renderTraversalButton(): React.ReactNode { if (!this.props.childTransactions) { // TODO: Amend size to use theme when we eventually refactor LoadingIndicator // 12px is consistent with theme.iconSizes['xs'] but theme returns a string. return ( ); } if (this.props.childTransactions.length <= 0) { return null; } const {span, trace, event, organization} = this.props; assert(!isGapSpan(span)); if (this.props.childTransactions.length === 1) { // Note: This is rendered by this.renderSpanChild() as a dedicated row return null; } const orgFeatures = new Set(organization.features); const {start, end} = getTraceDateTimeRange({ start: trace.traceStartTimestamp, end: trace.traceEndTimestamp, }); const childrenEventView = EventView.fromSavedQuery({ id: undefined, name: `Children from Span ID ${span.span_id}`, fields: [ 'transaction', 'project', 'trace.span', 'transaction.duration', 'timestamp', ], orderby: '-timestamp', query: `event.type:transaction trace:${span.trace_id} trace.parent_span:${span.span_id}`, projects: orgFeatures.has('global-views') ? [ALL_ACCESS_PROJECTS] : [Number(event.projectID)], version: 2, start, end, }); return ( {t('View Children')} ); } renderSpanChild(): React.ReactNode { const {childTransactions, organization, location} = this.props; if (!childTransactions || childTransactions.length !== 1) { return null; } const childTransaction = childTransactions[0]; const transactionResult: TransactionResult = { 'project.name': childTransaction.project_slug, transaction: childTransaction.transaction, 'trace.span': childTransaction.span_id, id: childTransaction.event_id, }; const eventSlug = generateSlug(transactionResult); const viewChildButton = ( {({getViewChildTransactionTarget}) => { const to = getViewChildTransactionTarget({ ...transactionResult, eventSlug, }); if (!to) { return null; } const target = transactionSummaryRouteWithQuery({ orgSlug: organization.slug, transaction: transactionResult.transaction, query: omit(location.query, Object.values(PAGE_URL_PARAM)), projectID: String(childTransaction.project_id), }); return ( {t('View Transaction')} {t('View Summary')} ); }} ); return ( {`${transactionResult.transaction} (${transactionResult['project.name']})`} ); } renderTraceButton() { const {span, organization, event} = this.props; if (isGapSpan(span)) { return null; } return ( {t('View Trace')} ); } renderViewSimilarSpansButton() { const {span, organization, location, event} = this.props; if (isGapSpan(span) || !span.op || !span.hash) { return null; } const transactionName = event.title; const target = spanDetailsRouteWithQuery({ orgSlug: organization.slug, transaction: transactionName, query: location.query, spanSlug: {op: span.op, group: span.hash}, projectID: event.projectID, }); return ( {t('View Similar Spans')} ); } renderOrphanSpanMessage() { const {span} = this.props; if (!isOrphanSpan(span)) { return null; } return ( {t( 'This is a span that has no parent span within this transaction. It has been attached to the transaction root span by default.' )} ); } toggleErrors = () => { this.setState(({errorsOpened}) => ({errorsOpened: !errorsOpened})); }; renderSpanErrorMessage() { const {span, organization, relatedErrors} = this.props; const {errorsOpened} = this.state; if (!relatedErrors || relatedErrors.length <= 0 || isGapSpan(span)) { return null; } const visibleErrors = errorsOpened ? relatedErrors : relatedErrors.slice(0, DEFAULT_ERRORS_VISIBLE); return ( {tn( 'An error event occurred in this span.', '%s error events occurred in this span.', relatedErrors.length )} {visibleErrors.map(error => ( {error.level} {error.title} ))} {relatedErrors.length > DEFAULT_ERRORS_VISIBLE && ( {errorsOpened ? t('Show less') : t('Show more')} )} ); } partitionSizes(data) { const sizeKeys = SIZE_DATA_KEYS.reduce((keys, key) => { if (data.hasOwnProperty(key)) { keys[key] = data[key]; } return keys; }, {}); const nonSizeKeys = {...data}; SIZE_DATA_KEYS.forEach(key => delete nonSizeKeys[key]); return { sizeKeys, nonSizeKeys, }; } renderSpanDetails() { const {span, event, location, organization, scrollToHash} = this.props; if (isGapSpan(span)) { return ( ); } const startTimestamp: number = span.start_timestamp; const endTimestamp: number = span.timestamp; const duration = (endTimestamp - startTimestamp) * 1000; const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`; const unknownKeys = Object.keys(span).filter(key => { return !rawSpanKeys.has(key as any); }); const {sizeKeys, nonSizeKeys} = this.partitionSizes(span?.data ?? {}); const allZeroSizes = SIZE_DATA_KEYS.map(key => sizeKeys[key]).every( value => value === 0 ); return ( {this.renderOrphanSpanMessage()} {this.renderSpanErrorMessage()} Span ID ) : ( Span ID ) } extra={this.renderTraversalButton()} > {span.span_id} {span.parent_span_id || ''} {this.renderSpanChild()} {span.trace_id} {span?.description ?? ''} {span.status || ''} {getDynamicText({ fixed: 'Mar 16, 2020 9:10:12 AM UTC', value: ( {` (${startTimestamp})`} ), })} {getDynamicText({ fixed: 'Mar 16, 2020 9:10:13 AM UTC', value: ( {` (${endTimestamp})`} ), })} {durationString}{span.op || ''} {span.same_process_as_parent !== undefined ? String(span.same_process_as_parent) : null} {defined(span.hash) ? String(span.hash) : null} {defined(span.exclusive_time) ? `${Number(span.exclusive_time.toFixed(3)).toLocaleString()}ms` : null} {allZeroSizes && ( The following sizes were not collected for security reasons. Check if the host serves the appropriate Timing-Allow-Origin header. You may have to enable this collection manually. )} {map(sizeKeys, (value, key) => ( {value >= 1024 && ( {` (${JSON.stringify(value, null, 4) || ''} B)`} )} ))} {map(nonSizeKeys, (value, key) => ( {JSON.stringify(value, null, 4) || ''} ))} {unknownKeys.map(key => ( {JSON.stringify(span[key], null, 4) || ''} ))}
); } render() { return ( { // prevent toggling the span detail event.stopPropagation(); }} > {this.renderSpanDetails()} ); } } const StyledDiscoverButton = styled(DiscoverButton)` position: absolute; top: ${space(0.75)}; right: ${space(0.5)}; `; const StyledButton = styled(Button)``; export const SpanDetailContainer = styled('div')` border-bottom: 1px solid ${p => p.theme.border}; cursor: auto; `; export const SpanDetails = styled('div')` padding: ${space(2)}; `; const ValueTd = styled('td')` position: relative; `; const StyledLoadingIndicator = styled(LoadingIndicator)` display: flex; align-items: center; height: ${space(2)}; margin: 0; `; const StyledText = styled('p')` font-size: ${p => p.theme.fontSizeMedium}; margin: ${space(2)} ${space(0)}; `; const TextTr = ({children}) => ( {children} ); const ErrorToggle = styled(Button)` margin-top: ${space(0.75)}; `; const SpanIdTitle = styled('a')` display: flex; color: ${p => p.theme.textColor}; :hover { color: ${p => p.theme.textColor}; } `; const StyledIconLink = styled(IconLink)` display: block; color: ${p => p.theme.gray300}; margin-left: ${space(1)}; `; export const Row = ({ title, keep, children, extra = null, }: { children: JSX.Element | string | null; title: JSX.Element | string | null; extra?: React.ReactNode; keep?: boolean; }) => { if (!keep && !children) { return null; } return ( {title} {children} {extra} ); }; export const Tags = ({span}: {span: RawSpanType}) => { const tags: {[tag_name: string]: string} | undefined = span?.tags; if (!tags) { return null; } const keys = Object.keys(tags); if (keys.length <= 0) { return null; } return ( Tags {keys.map((key, index) => ( ))} ); }; function generateSlug(result: TransactionResult): string { return generateEventSlug({ id: result.id, 'project.name': result['project.name'], }); } const ButtonGroup = styled('div')` display: flex; flex-direction: column; gap: ${space(0.5)}; `; const ValueRow = styled('div')` display: grid; grid-template-columns: auto min-content; gap: ${space(1)}; border-radius: 4px; background-color: ${p => p.theme.surface100}; margin: 2px; `; const StyledPre = styled('pre')` margin: 0 !important; background-color: transparent !important; `; const ButtonContainer = styled('div')` padding: 8px 10px; `; export default withApi(withRouter(SpanDetail));