Files
2024-02-15 17:01:50 +00:00

781 lines
22 KiB
TypeScript

import {Fragment, useEffect, useState} from 'react';
import styled from '@emotion/styled';
import omit from 'lodash/omit';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
import DateTime from 'sentry/components/dateTime';
import DiscoverButton from 'sentry/components/discoverButton';
import SpanSummaryButton from 'sentry/components/events/interfaces/spans/spanSummaryButton';
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 {useTransactionProfileId} from 'sentry/components/profiling/transactionProfileIdProvider';
import {TransactionToProfileButton} from 'sentry/components/profiling/transactionToProfileButton';
import {
generateIssueEventTarget,
generateTraceTarget,
} from 'sentry/components/quickTrace/utils';
import {ALL_ACCESS_PROJECTS, PAGE_URL_PARAM} from 'sentry/constants/pageFilters';
import {t, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization} from 'sentry/types';
import type {EventTransaction} from 'sentry/types/event';
import {assert} from 'sentry/types/utils';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import EventView from 'sentry/utils/discover/eventView';
import {generateEventSlug} from 'sentry/utils/discover/urls';
import getDynamicText from 'sentry/utils/getDynamicText';
import type {
QuickTraceEvent,
TraceErrorOrIssue,
} from 'sentry/utils/performance/quickTrace/types';
import {useLocation} from 'sentry/utils/useLocation';
import useProjects from 'sentry/utils/useProjects';
import {CustomMetricsEventData} from 'sentry/views/ddm/customMetricsEventData';
import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
import {getPerformanceDuration} from 'sentry/views/performance/utils';
import {OpsDot} from '../../opsBreakdown';
import * as SpanEntryContext from './context';
import {GapSpanDetails} from './gapSpanDetails';
import InlineDocs from './inlineDocs';
import {SpanProfileDetails} from './spanProfileDetails';
import type {ParsedTraceType, ProcessedSpanType, RawSpanType} from './types';
import {rawSpanKeys} from './types';
import type {SubTimingInfo} from './utils';
import {
getCumulativeAlertLevelFromErrors,
getFormattedTimeRangeWithLeadingAndTrailingZero,
getSpanSubTimings,
getTraceDateTimeRange,
isErrorPerformanceError,
isGapSpan,
isHiddenDataKey,
isOrphanSpan,
scrollToSpan,
} from './utils';
const DEFAULT_ERRORS_VISIBLE = 5;
const SIZE_DATA_KEYS = [
'Encoded Body Size',
'Decoded Body Size',
'Transfer Size',
'http.request_content_length',
'http.response_content_length',
'http.decoded_response_content_length',
'http.response_transfer_size',
];
type TransactionResult = {
id: string;
'project.name': string;
'trace.span': string;
transaction: string;
};
type Props = {
childTransactions: QuickTraceEvent[] | null;
event: Readonly<EventTransaction>;
isRoot: boolean;
organization: Organization;
relatedErrors: TraceErrorOrIssue[] | null;
resetCellMeasureCache: () => void;
scrollToHash: (hash: string) => void;
span: ProcessedSpanType;
trace: Readonly<ParsedTraceType>;
};
function SpanDetail(props: Props) {
const [errorsOpened, setErrorsOpened] = useState(false);
const location = useLocation();
const profileId = useTransactionProfileId();
const {projects} = useProjects();
const project = projects.find(p => p.id === props.event.projectID);
useEffect(() => {
// Run on mount.
const {span, organization, event} = props;
if (!('op' in span)) {
return;
}
trackAnalytics('performance_views.event_details.open_span_details', {
organization,
operation: span.op ?? 'undefined',
origin: span.origin ?? 'undefined',
project_platform: event.platform ?? 'undefined',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function renderTraversalButton(): React.ReactNode {
if (!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 (
<StyledDiscoverButton size="xs" disabled>
<StyledLoadingIndicator size={12} />
</StyledDiscoverButton>
);
}
if (props.childTransactions.length <= 0) {
return null;
}
const {span, trace, event, organization} = props;
assert(!isGapSpan(span));
if (props.childTransactions.length === 1) {
// Note: This is rendered by renderSpanChild() as a dedicated row
return null;
}
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: organization.features.includes('global-views')
? [ALL_ACCESS_PROJECTS]
: [Number(event.projectID)],
version: 2,
start,
end,
});
return (
<StyledDiscoverButton
data-test-id="view-child-transactions"
size="xs"
to={childrenEventView.getResultsViewUrlTarget(organization.slug)}
>
{t('View Children')}
</StyledDiscoverButton>
);
}
function renderSpanChild(): React.ReactNode {
const {childTransactions, organization} = 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 = (
<SpanEntryContext.Consumer>
{({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 (
<ButtonGroup>
<StyledButton data-test-id="view-child-transaction" size="xs" to={to}>
{t('View Transaction')}
</StyledButton>
<StyledButton size="xs" to={target}>
{t('View Summary')}
</StyledButton>
</ButtonGroup>
);
}}
</SpanEntryContext.Consumer>
);
return (
<Row title="Child Transaction" extra={viewChildButton}>
{`${transactionResult.transaction} (${transactionResult['project.name']})`}
</Row>
);
}
function renderTraceButton() {
const {span, organization, event} = props;
if (isGapSpan(span)) {
return null;
}
return (
<StyledButton size="xs" to={generateTraceTarget(event, organization)}>
{t('View Trace')}
</StyledButton>
);
}
function renderSpanDetailActions() {
const {span, organization, event} = props;
if (isGapSpan(span) || !span.op || !span.hash) {
return null;
}
const transactionName = event.title;
return (
<ButtonGroup>
<SpanSummaryButton event={event} organization={organization} span={span} />
<StyledButton
size="xs"
to={spanDetailsRouteWithQuery({
orgSlug: organization.slug,
transaction: transactionName,
query: location.query,
spanSlug: {op: span.op, group: span.hash},
projectID: event.projectID,
})}
>
{t('View Similar Spans')}
</StyledButton>
</ButtonGroup>
);
}
function renderOrphanSpanMessage() {
const {span} = props;
if (!isOrphanSpan(span)) {
return null;
}
return (
<Alert type="info" showIcon system>
{t(
'This is a span that has no parent span within this transaction. It has been attached to the transaction root span by default.'
)}
</Alert>
);
}
function toggleErrors() {
setErrorsOpened(prevErrorsOpened => !prevErrorsOpened);
}
function renderSpanErrorMessage() {
const {span, organization, relatedErrors} = props;
if (!relatedErrors || relatedErrors.length <= 0 || isGapSpan(span)) {
return null;
}
const visibleErrors = errorsOpened
? relatedErrors
: relatedErrors.slice(0, DEFAULT_ERRORS_VISIBLE);
return (
<Alert type={getCumulativeAlertLevelFromErrors(relatedErrors)} system>
<ErrorMessageTitle>
{tn(
'%s error event or performance issue is associated with this span.',
'%s error events or performance issues are associated with this span.',
relatedErrors.length
)}
</ErrorMessageTitle>
<Fragment>
{visibleErrors.map(error => (
<ErrorMessageContent
key={error.event_id}
excludeLevel={isErrorPerformanceError(error)}
>
{isErrorPerformanceError(error) ? (
<ErrorDot level="error" />
) : (
<Fragment>
<ErrorDot level={error.level} />
<ErrorLevel>{error.level}</ErrorLevel>
</Fragment>
)}
<ErrorTitle>
<Link to={generateIssueEventTarget(error, organization)}>
{error.title}
</Link>
</ErrorTitle>
</ErrorMessageContent>
))}
</Fragment>
{relatedErrors.length > DEFAULT_ERRORS_VISIBLE && (
<ErrorToggle size="xs" onClick={toggleErrors}>
{errorsOpened ? t('Show less') : t('Show more')}
</ErrorToggle>
)}
</Alert>
);
}
function partitionSizes(data): {
nonSizeKeys: {[key: string]: unknown};
sizeKeys: {[key: string]: number};
} {
const sizeKeys = SIZE_DATA_KEYS.reduce((keys, key) => {
if (data.hasOwnProperty(key) && defined(data[key])) {
try {
keys[key] = parseInt(data[key], 10);
} catch (e) {
keys[key] = data[key];
}
}
return keys;
}, {});
const nonSizeKeys = {...data};
SIZE_DATA_KEYS.forEach(key => delete nonSizeKeys[key]);
return {
sizeKeys,
nonSizeKeys,
};
}
function renderProfileMessage() {
const {organization, span, event} = props;
if (!organization.features.includes('profiling') || isGapSpan(span)) {
return null;
}
return <SpanProfileDetails span={span} event={event} />;
}
function renderSpanDetails() {
const {span, event, organization, resetCellMeasureCache, scrollToHash} = props;
if (isGapSpan(span)) {
return (
<SpanDetails>
{organization.features.includes('profiling') ? (
<GapSpanDetails
event={event}
span={span}
resetCellMeasureCache={resetCellMeasureCache}
/>
) : (
<InlineDocs
orgSlug={organization.slug}
platform={event.sdk?.name || ''}
projectSlug={event?.projectSlug ?? ''}
resetCellMeasureCache={resetCellMeasureCache}
/>
)}
</SpanDetails>
);
}
const startTimestamp: number = span.start_timestamp;
const endTimestamp: number = span.timestamp;
const {start: startTimeWithLeadingZero, end: endTimeWithLeadingZero} =
getFormattedTimeRangeWithLeadingAndTrailingZero(startTimestamp, endTimestamp);
const duration = (endTimestamp - startTimestamp) * 1000;
const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
const unknownKeys = Object.keys(span).filter(key => {
return !isHiddenDataKey(key) && !rawSpanKeys.has(key as any);
});
const {sizeKeys, nonSizeKeys} = partitionSizes(span?.data ?? {});
const allZeroSizes = SIZE_DATA_KEYS.map(key => sizeKeys[key]).every(
value => value === 0
);
const timingKeys = getSpanSubTimings(span) ?? [];
return (
<Fragment>
{renderOrphanSpanMessage()}
{renderSpanErrorMessage()}
{renderProfileMessage()}
<SpanDetails>
<table className="table key-value">
<tbody>
<Row
title={
isGapSpan(span) ? (
<SpanIdTitle>Span ID</SpanIdTitle>
) : (
<SpanIdTitle
onClick={scrollToSpan(
span.span_id,
scrollToHash,
location,
organization
)}
>
Span ID
</SpanIdTitle>
)
}
extra={renderTraversalButton()}
>
{span.span_id}
<CopyToClipboardButton
borderless
size="zero"
iconSize="xs"
text={`${window.location.href.replace(window.location.hash, '')}#span-${
span.span_id
}`}
/>
</Row>
<Row title="Parent Span ID">{span.parent_span_id || ''}</Row>
{renderSpanChild()}
<Row title="Trace ID" extra={renderTraceButton()}>
{span.trace_id}
</Row>
{profileId && project?.slug && (
<Row
title="Profile ID"
extra={
<TransactionToProfileButton
event={event}
size="xs"
projectSlug={project.slug}
query={{
spanId: span.span_id,
}}
>
{t('View Profile')}
</TransactionToProfileButton>
}
>
{profileId}
</Row>
)}
<Row title="Description" extra={renderSpanDetailActions()}>
{span?.description ?? ''}
</Row>
<Row title="Status">{span.status || ''}</Row>
<Row title="Start Date">
{getDynamicText({
fixed: 'Mar 16, 2020 9:10:12 AM UTC',
value: (
<Fragment>
<DateTime date={startTimestamp * 1000} year seconds timeZone />
{` (${startTimeWithLeadingZero})`}
</Fragment>
),
})}
</Row>
<Row title="End Date">
{getDynamicText({
fixed: 'Mar 16, 2020 9:10:13 AM UTC',
value: (
<Fragment>
<DateTime date={endTimestamp * 1000} year seconds timeZone />
{` (${endTimeWithLeadingZero})`}
</Fragment>
),
})}
</Row>
<Row title="Duration">{durationString}</Row>
<Row title="Operation">{span.op || ''}</Row>
<Row title="Origin">
{span.origin !== undefined ? String(span.origin) : null}
</Row>
<Row title="Same Process as Parent">
{span.same_process_as_parent !== undefined
? String(span.same_process_as_parent)
: null}
</Row>
<Row title="Span Group">
{defined(span.hash) ? String(span.hash) : null}
</Row>
<Row title="Span Self Time">
{defined(span.exclusive_time)
? `${Number(span.exclusive_time.toFixed(3)).toLocaleString()}ms`
: null}
</Row>
{timingKeys.map(timing => (
<Row
title={timing.name}
key={timing.name}
prefix={<RowTimingPrefix timing={timing} />}
>
{getPerformanceDuration(Number(timing.duration) * 1000)}
</Row>
))}
<Tags span={span} />
{allZeroSizes && (
<TextTr>
The following sizes were not collected for security reasons. Check if
the host serves the appropriate
<ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin">
<span className="val-string">Timing-Allow-Origin</span>
</ExternalLink>
header. You may have to enable this collection manually.
</TextTr>
)}
{Object.entries(sizeKeys).map(([key, value]) => (
<Row title={key} key={key}>
<Fragment>
<FileSize bytes={value} />
{value >= 1024 && <span>{` (${maybeStringify(value)} B)`}</span>}
</Fragment>
</Row>
))}
{Object.entries(nonSizeKeys).map(([key, value]) =>
!isHiddenDataKey(key) ? (
<Row title={key} key={key}>
{maybeStringify(value)}
</Row>
) : null
)}
{unknownKeys.map(key => (
<Row title={key} key={key}>
{maybeStringify(span[key])}
</Row>
))}
</tbody>
</table>
{span._metrics_summary && (
<CustomMetricsEventData
metricsSummary={span._metrics_summary}
startTimestamp={span.start_timestamp}
/>
)}
</SpanDetails>
</Fragment>
);
}
return (
<SpanDetailContainer
data-component="span-detail"
onClick={event => {
// prevent toggling the span detail
event.stopPropagation();
}}
>
{renderSpanDetails()}
</SpanDetailContainer>
);
}
function RowTimingPrefix({timing}: {timing: SubTimingInfo}) {
return <OpsDot style={{backgroundColor: timing.color}} />;
}
function maybeStringify(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value, null, 4);
}
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)};
table.table.key-value td.key {
max-width: 280px;
}
`;
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)};
`;
function TextTr({children}) {
return (
<tr>
<td className="key" />
<ValueTd className="value">
<StyledText>{children}</StyledText>
</ValueTd>
</tr>
);
}
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};
}
`;
export function Row({
title,
keep,
children,
prefix,
extra = null,
}: {
children: React.ReactNode;
title: JSX.Element | string | null;
extra?: React.ReactNode;
keep?: boolean;
prefix?: JSX.Element;
}) {
if (!keep && !children) {
return null;
}
return (
<tr>
<td className="key">
<Flex>
{prefix}
{title}
</Flex>
</td>
<ValueTd className="value">
<ValueRow>
<StyledPre>
<span className="val-string">{children}</span>
</StyledPre>
<ButtonContainer>{extra}</ButtonContainer>
</ValueRow>
</ValueTd>
</tr>
);
}
export function 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 (
<tr>
<td className="key">Tags</td>
<td className="value">
<Pills style={{padding: '8px'}}>
{keys.map((key, index) => (
<Pill key={index} name={key} value={String(tags[key]) || ''} />
))}
</Pills>
</td>
</tr>
);
}
function generateSlug(result: TransactionResult): string {
return generateEventSlug({
id: result.id,
'project.name': result['project.name'],
});
}
const Flex = styled('div')`
display: flex;
align-items: center;
`;
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.surface200};
margin: 2px;
`;
const StyledPre = styled('pre')`
margin: 0 !important;
background-color: transparent !important;
`;
const ButtonContainer = styled('div')`
padding: 8px 10px;
`;
export default SpanDetail;