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/metrics/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} tags={detail.traceFullDetailedEvent.tags ?? []} event={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;