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

630 lines
19 KiB
TypeScript

import {createRef, Fragment, useEffect, useState} from 'react';
import styled from '@emotion/styled';
import type {Location} from 'history';
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 {Chunk} from 'sentry/components/events/contexts/chunk';
import {EventAttachments} from 'sentry/components/events/eventAttachments';
import {
isNotMarkMeasurement,
isNotPerformanceScoreMeasurement,
TraceEventCustomPerformanceMetric,
} from 'sentry/components/events/eventCustomPerformanceMetrics';
import {Entries} from 'sentry/components/events/eventEntries';
import {EventEvidence} from 'sentry/components/events/eventEvidence';
import {EventExtraData} from 'sentry/components/events/eventExtraData';
import {EventSdk} from 'sentry/components/events/eventSdk';
import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
import {Breadcrumbs} from 'sentry/components/events/interfaces/breadcrumbs';
import type {SpanDetailProps} from 'sentry/components/events/interfaces/spans/newTraceDetailsSpanDetails';
import NewTraceDetailsSpanDetail, {
SpanDetailContainer,
SpanDetails,
} from 'sentry/components/events/interfaces/spans/newTraceDetailsSpanDetails';
import {
getFormattedTimeRangeWithLeadingAndTrailingZero,
getSpanOperation,
} from 'sentry/components/events/interfaces/spans/utils';
import {generateStats} from 'sentry/components/events/opsBreakdown';
import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration';
import {DataSection} from 'sentry/components/events/styles';
import FileSize from 'sentry/components/fileSize';
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
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 PerformanceDuration from 'sentry/components/performanceDuration';
import QuestionTooltip from 'sentry/components/questionTooltip';
import {generateIssueEventTarget} from 'sentry/components/quickTrace/utils';
import {Tooltip} from 'sentry/components/tooltip';
import {PAGE_URL_PARAM} from 'sentry/constants/pageFilters';
import {IconChevron, IconOpen} from 'sentry/icons';
import {t, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {EntryBreadcrumbs, EventTransaction, Organization} from 'sentry/types';
import {EntryType} from 'sentry/types';
import {objectIsEmpty} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import getDynamicText from 'sentry/utils/getDynamicText';
import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import useProjects from 'sentry/utils/useProjects';
import {isCustomMeasurement} from 'sentry/views/dashboards/utils';
import {CustomMetricsEventData} from 'sentry/views/ddm/customMetricsEventData';
import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
import {ProfileContext, ProfilesProvider} from 'sentry/views/profiling/profilesProvider';
import DetailPanel from 'sentry/views/starfish/components/detailPanel';
import {transactionSummaryRouteWithQuery} from '../transactionSummary/utils';
import type {EventDetail} from './newTraceDetailsContent';
import {Row, Tags} from './styles';
type DetailPanelProps = {
detail: EventDetail | SpanDetailProps | undefined;
onClose: () => void;
};
type EventDetailProps = {
detail: EventDetail;
location: Location;
organization: Organization;
};
function OpsBreakdown({event}: {event: EventTransaction}) {
const [showingAll, setShowingAll] = useState(false);
const breakdown = event && generateStats(event, {type: 'no_filter'});
if (!breakdown) {
return null;
}
const renderText = showingAll ? t('Show less') : t('Show more') + '...';
return (
breakdown && (
<Row
title={
<FlexBox style={{gap: '5px'}}>
{t('Ops Breakdown')}
<QuestionTooltip
title={t('Applicable to the children of this event only')}
size="xs"
/>
</FlexBox>
}
>
<div style={{display: 'flex', flexDirection: 'column', gap: space(0.25)}}>
{breakdown.slice(0, showingAll ? breakdown.length : 5).map(currOp => {
const {name, percentage, totalInterval} = currOp;
const operationName = typeof name === 'string' ? name : t('Other');
const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞';
return (
<div key={operationName}>
{operationName}:{' '}
<PerformanceDuration seconds={totalInterval} abbreviation /> ({pctLabel}%)
</div>
);
})}
{breakdown.length > 5 && (
<a onClick={() => setShowingAll(prev => !prev)}>{renderText}</a>
)}
</div>
</Row>
)
);
}
function BreadCrumbsSection({
event,
organization,
}: {
event: EventTransaction;
organization: Organization;
}) {
const [showBreadCrumbs, setShowBreadCrumbs] = useState(false);
const breadCrumbsContainerRef = createRef<HTMLDivElement>();
useEffect(() => {
setTimeout(() => {
if (showBreadCrumbs) {
breadCrumbsContainerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}
}, 100);
}, [showBreadCrumbs, breadCrumbsContainerRef]);
const matchingEntry: EntryBreadcrumbs | undefined = event?.entries.find(
(entry): entry is EntryBreadcrumbs => entry.type === EntryType.BREADCRUMBS
);
if (!matchingEntry) {
return null;
}
const renderText = showBreadCrumbs ? t('Hide Breadcrumbs') : t('Show Breadcrumbs');
const chevron = <IconChevron size="xs" direction={showBreadCrumbs ? 'up' : 'down'} />;
return (
<Fragment>
<a
style={{display: 'flex', alignItems: 'center', gap: space(0.5)}}
onClick={() => {
setShowBreadCrumbs(prev => !prev);
}}
>
{renderText} {chevron}
</a>
<div ref={breadCrumbsContainerRef}>
{showBreadCrumbs && (
<Breadcrumbs
hideTitle
data={matchingEntry.data}
event={event}
organization={organization}
/>
)}
</div>
</Fragment>
);
}
function EventDetails({detail, organization, location}: EventDetailProps) {
const {projects} = useProjects();
if (!detail.event) {
return <LoadingIndicator />;
}
const {user, contexts, projectSlug} = detail.event;
const {feedback} = contexts ?? {};
const eventJsonUrl = `/api/0/projects/${organization.slug}/${detail.traceFullDetailedEvent.project_slug}/events/${detail.traceFullDetailedEvent.event_id}/json/`;
const project = projects.find(proj => proj.slug === detail.event?.projectSlug);
const {errors, performance_issues} = detail.traceFullDetailedEvent;
const hasIssues = errors.length + performance_issues.length > 0;
const startTimestamp = Math.min(
detail.traceFullDetailedEvent.start_timestamp,
detail.traceFullDetailedEvent.timestamp
);
const endTimestamp = Math.max(
detail.traceFullDetailedEvent.start_timestamp,
detail.traceFullDetailedEvent.timestamp
);
const {start: startTimeWithLeadingZero, end: endTimeWithLeadingZero} =
getFormattedTimeRangeWithLeadingAndTrailingZero(startTimestamp, endTimestamp);
const duration = (endTimestamp - startTimestamp) * 1000;
const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
const measurementNames = Object.keys(detail.traceFullDetailedEvent.measurements ?? {})
.filter(name => isCustomMeasurement(`measurements.${name}`))
.filter(isNotMarkMeasurement)
.filter(isNotPerformanceScoreMeasurement)
.sort();
const renderMeasurements = () => {
if (!detail.event) {
return null;
}
const {measurements} = detail.event;
const measurementKeys = Object.keys(measurements ?? {})
.filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
.sort();
if (!measurements || measurementKeys.length <= 0) {
return null;
}
return (
<Fragment>
{measurementKeys.map(measurement => (
<Row
key={measurement}
title={WEB_VITAL_DETAILS[`measurements.${measurement}`]?.name}
>
<PerformanceDuration
milliseconds={Number(measurements[measurement].value.toFixed(3))}
abbreviation
/>
</Row>
))}
</Fragment>
);
};
const renderGoToProfileButton = () => {
if (!detail.traceFullDetailedEvent.profile_id) {
return null;
}
const target = generateProfileFlamechartRoute({
orgSlug: organization.slug,
projectSlug: detail.traceFullDetailedEvent.project_slug,
profileId: detail.traceFullDetailedEvent.profile_id,
});
function handleOnClick() {
trackAnalytics('profiling_views.go_to_flamegraph', {
organization,
source: 'performance.trace_view',
});
}
return (
<StyledButton size="xs" to={target} onClick={handleOnClick}>
{t('View Profile')}
</StyledButton>
);
};
return (
<Wrapper>
<Actions>
<Button
size="sm"
icon={<IconOpen />}
href={eventJsonUrl}
external
onClick={() =>
trackAnalytics('performance_views.event_details.json_button_click', {
organization,
})
}
>
{t('JSON')} (<FileSize bytes={detail.event?.size} />)
</Button>
</Actions>
<Title>
<Tooltip title={detail.traceFullDetailedEvent.project_slug}>
<ProjectBadge
project={
project ? project : {slug: detail.traceFullDetailedEvent.project_slug}
}
avatarSize={50}
hideName
/>
</Tooltip>
<div>
<div>{t('Event')}</div>
<TransactionOp>
{' '}
{detail.traceFullDetailedEvent['transaction.op']}
</TransactionOp>
</div>
</Title>
{hasIssues && (
<Alert
system
defaultExpanded
type="error"
expand={[
...detail.traceFullDetailedEvent.errors,
...detail.traceFullDetailedEvent.performance_issues,
].map(error => (
<ErrorMessageContent key={error.event_id}>
<ErrorDot level={error.level} />
<ErrorLevel>{error.level}</ErrorLevel>
<ErrorTitle>
<Link to={generateIssueEventTarget(error, organization)}>
{error.title}
</Link>
</ErrorTitle>
</ErrorMessageContent>
))}
>
<ErrorMessageTitle>
{tn(
'%s issue occurred in this transaction.',
'%s issues occurred in this transaction.',
detail.traceFullDetailedEvent.errors.length +
detail.traceFullDetailedEvent.performance_issues.length
)}
</ErrorMessageTitle>
</Alert>
)}
<StyledTable className="table key-value">
<tbody>
<Row title={<TransactionIdTitle>{t('Event ID')}</TransactionIdTitle>}>
{detail.traceFullDetailedEvent.event_id}
<CopyToClipboardButton
borderless
size="zero"
iconSize="xs"
text={`${window.location.href.replace(window.location.hash, '')}#txn-${
detail.traceFullDetailedEvent.event_id
}`}
/>
</Row>
<Row title={t('Description')}>
<Link
to={transactionSummaryRouteWithQuery({
orgSlug: organization.slug,
transaction: detail.traceFullDetailedEvent.transaction,
query: omit(location.query, Object.values(PAGE_URL_PARAM)),
projectID: String(detail.traceFullDetailedEvent.project_id),
})}
>
{detail.traceFullDetailedEvent.transaction}
</Link>
</Row>
{detail.traceFullDetailedEvent.profile_id && (
<Row title="Profile ID" extra={renderGoToProfileButton()}>
{detail.traceFullDetailedEvent.profile_id}
</Row>
)}
<Row title="Duration">{durationString}</Row>
<Row title="Date Range">
{getDynamicText({
fixed: 'Mar 19, 2021 11:06:27 AM UTC',
value: (
<Fragment>
<DateTime date={startTimestamp * 1000} />
{` (${startTimeWithLeadingZero})`}
</Fragment>
),
})}
<br />
{getDynamicText({
fixed: 'Mar 19, 2021 11:06:28 AM UTC',
value: (
<Fragment>
<DateTime date={endTimestamp * 1000} />
{` (${endTimeWithLeadingZero})`}
</Fragment>
),
})}
</Row>
<OpsBreakdown event={detail.event} />
{renderMeasurements()}
<Tags
enableHiding
location={location}
organization={organization}
transaction={detail.traceFullDetailedEvent}
/>
{measurementNames.length > 0 && (
<tr>
<td className="key">{t('Measurements')}</td>
<td className="value">
<Measurements>
{measurementNames.map(name => {
return (
detail.event && (
<TraceEventCustomPerformanceMetric
key={name}
event={detail.event}
name={name}
location={location}
organization={organization}
source={undefined}
isHomepage={false}
/>
)
);
})}
</Measurements>
</td>
</tr>
)}
</tbody>
</StyledTable>
{project && <EventEvidence event={detail.event} project={project} />}
{projectSlug && (
<Entries
definedEvent={detail.event}
projectSlug={projectSlug}
group={undefined}
organization={organization}
isShare={false}
hideBeforeReplayEntries
hideBreadCrumbs
/>
)}
{!objectIsEmpty(feedback) && (
<Chunk
key="feedback"
type="feedback"
alias="feedback"
group={undefined}
event={detail.event}
value={feedback}
/>
)}
{user && !objectIsEmpty(user) && (
<Chunk
key="user"
type="user"
alias="user"
group={undefined}
event={detail.event}
value={user}
/>
)}
<EventExtraData event={detail.event} />
<EventSdk sdk={detail.event.sdk} meta={detail.event._meta?.sdk} />
{detail.event._metrics_summary ? (
<CustomMetricsEventData
metricsSummary={detail.event._metrics_summary}
startTimestamp={detail.event.startTimestamp}
/>
) : null}
<BreadCrumbsSection event={detail.event} organization={organization} />
{projectSlug && <EventAttachments event={detail.event} projectSlug={projectSlug} />}
{project && <EventViewHierarchy event={detail.event} project={project} />}
{projectSlug && (
<EventRRWebIntegration
event={detail.event}
orgId={organization.slug}
projectSlug={projectSlug}
/>
)}
</Wrapper>
);
}
function SpanDetailsBody({
detail,
organization,
}: {
detail: SpanDetailProps;
organization: Organization;
}) {
const {projects} = useProjects();
const project = projects.find(proj => proj.slug === detail.event?.projectSlug);
const profileId = detail?.event?.contexts?.profile?.profile_id ?? null;
return (
<Wrapper>
<Title>
<Tooltip title={detail.event.projectSlug}>
<ProjectBadge
project={project ? project : {slug: detail.event.projectSlug || ''}}
avatarSize={50}
hideName
/>
</Tooltip>
<div>
<div>{t('Span')}</div>
<TransactionOp> {getSpanOperation(detail.span)}</TransactionOp>
</div>
</Title>
{detail.event.projectSlug && (
<ProfilesProvider
orgSlug={organization.slug}
projectSlug={detail.event.projectSlug}
profileId={profileId || ''}
>
<ProfileContext.Consumer>
{profiles => (
<ProfileGroupProvider
type="flamechart"
input={profiles?.type === 'resolved' ? profiles.data : null}
traceID={profileId || ''}
>
<NewTraceDetailsSpanDetail {...detail} />
</ProfileGroupProvider>
)}
</ProfileContext.Consumer>
</ProfilesProvider>
)}
</Wrapper>
);
}
export function isEventDetail(
detail: EventDetail | SpanDetailProps
): detail is EventDetail {
return !('span' in detail);
}
function TraceViewDetailPanel({detail, onClose}: DetailPanelProps) {
const organization = useOrganization();
const location = useLocation();
return (
<PageAlertProvider>
<DetailPanel
detailKey={detail && detail.openPanel === 'open' ? 'open' : undefined}
onClose={onClose}
>
{detail &&
(isEventDetail(detail) ? (
<EventDetails
location={location}
organization={organization}
detail={detail}
/>
) : (
<SpanDetailsBody organization={organization} detail={detail} />
))}
</DetailPanel>
</PageAlertProvider>
);
}
const Wrapper = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(2)};
${DataSection} {
padding: 0;
}
${SpanDetails} {
padding: 0;
}
${SpanDetailContainer} {
border-bottom: none;
}
`;
const FlexBox = styled('div')`
display: flex;
align-items: center;
`;
const Actions = styled('div')`
display: flex;
align-items: center;
justify-content: flex-end;
`;
const Title = styled(FlexBox)`
gap: ${space(2)};
`;
const TransactionOp = styled('div')`
font-size: 25px;
font-weight: bold;
max-width: 600px;
${p => p.theme.overflowEllipsis}
`;
const TransactionIdTitle = styled('a')`
display: flex;
color: ${p => p.theme.textColor};
:hover {
color: ${p => p.theme.textColor};
}
`;
const Measurements = styled('div')`
display: flex;
flex-wrap: wrap;
gap: ${space(1)};
padding-top: 10px;
`;
const StyledButton = styled(Button)`
position: absolute;
top: ${space(0.75)};
right: ${space(0.5)};
`;
const StyledTable = styled('table')`
margin-bottom: 0 !important;
`;
export default TraceViewDetailPanel;