import {Component, Fragment} from 'react'; import type {Theme} from '@emotion/react'; import type {Location, LocationDescriptor} from 'history'; import DropdownLink from 'sentry/components/dropdownLink'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import type { ErrorDestination, TransactionDestination, } from 'sentry/components/quickTrace/utils'; import { generateSingleErrorTarget, generateTraceTarget, isQuickTraceEvent, } from 'sentry/components/quickTrace/utils'; import {Tooltip} from 'sentry/components/tooltip'; import {backend, frontend, mobile, serverless} from 'sentry/data/platformCategories'; import {IconFire} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import type {OrganizationSummary} from 'sentry/types'; import type {Event} from 'sentry/types/event'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getDocsPlatform} from 'sentry/utils/docs'; import getDuration from 'sentry/utils/duration/getDuration'; import localStorage from 'sentry/utils/localStorage'; import type { QuickTrace as QuickTraceType, QuickTraceEvent, TraceError, TracePerformanceIssue, } from 'sentry/utils/performance/quickTrace/types'; import {isTraceError, parseQuickTrace} from 'sentry/utils/performance/quickTrace/utils'; import Projects from 'sentry/utils/projects'; const FRONTEND_PLATFORMS: string[] = [...frontend, ...mobile]; const BACKEND_PLATFORMS: string[] = [...backend, ...serverless]; import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; import { DropdownContainer, DropdownItem, DropdownItemSubContainer, DropdownMenuHeader, ErrorNodeContent, EventNode, ExternalDropdownLink, QuickTraceContainer, QuickTraceValue, SectionSubtext, SingleEventHoverText, TraceConnector, } from './styles'; const TOOLTIP_PREFIX = { root: 'root', ancestors: 'ancestor', parent: 'parent', current: '', children: 'child', descendants: 'descendant', }; type QuickTraceProps = Pick< EventNodeSelectorProps, 'anchor' | 'errorDest' | 'transactionDest' > & { event: Event; location: Location; organization: OrganizationSummary; quickTrace: QuickTraceType; }; export default function QuickTrace({ event, quickTrace, location, organization, anchor, errorDest, transactionDest, }: QuickTraceProps) { let parsedQuickTrace; const traceSlug = event.contexts?.trace?.trace_id ?? ''; const noTrace = {'\u2014'}; try { if (quickTrace.orphanErrors && quickTrace.orphanErrors.length > 0) { const orphanError = quickTrace.orphanErrors.find(e => e.event_id === event.id); if (!orphanError) { return noTrace; } parsedQuickTrace = { current: orphanError, }; } else { parsedQuickTrace = parseQuickTrace(quickTrace, event, organization); } } catch (error) { return noTrace; } const traceLength = quickTrace.trace?.length || quickTrace.orphanErrors?.length; const {root, ancestors, parent, children, descendants, current} = parsedQuickTrace; const nodes: React.ReactNode[] = []; const isOrphanErrorNode = traceLength === 1 && isTraceError(current); const currentNode = ( ); if (root) { nodes.push( ); nodes.push(); } if (isOrphanErrorNode) { nodes.push( ); nodes.push(); nodes.push(currentNode); return {nodes}; } if (ancestors?.length) { nodes.push( ); nodes.push(); } if (parent) { nodes.push( ); nodes.push(); } if (traceLength === 1) { nodes.push( {({projects}) => { const project = projects.find(p => p.slug === current.project_slug); if (project?.platform) { if (BACKEND_PLATFORMS.includes(project.platform as string)) { return ( {currentNode} ); } if (FRONTEND_PLATFORMS.includes(project.platform as string)) { return ( {currentNode} ); } } return currentNode; }} ); } else { nodes.push(currentNode); } if (children?.length) { nodes.push(); nodes.push( ); } if (descendants?.length) { nodes.push(); nodes.push( ); } return {nodes}; } function handleNode(key: string, organization: OrganizationSummary) { trackAnalytics('quick_trace.node.clicked', { organization: organization.id, node_key: key, }); } function handleDropdownItem( key: string, organization: OrganizationSummary, extra: boolean ) { const eventKey = extra ? 'quick_trace.dropdown.clicked_extra' : 'quick_trace.dropdown.clicked'; trackAnalytics(eventKey, { organization: organization.id, node_key: key, }); } type EventNodeSelectorProps = { anchor: 'left' | 'right'; currentEvent: Event; errorDest: ErrorDestination; events: QuickTraceEvent[]; location: Location; nodeKey: keyof typeof TOOLTIP_PREFIX; organization: OrganizationSummary; text: React.ReactNode; traceSlug: string; transactionDest: TransactionDestination; isOrphanErrorNode?: boolean; numEvents?: number; }; function EventNodeSelector({ traceSlug, location, organization, events = [], text, currentEvent, nodeKey, anchor, errorDest, transactionDest, isOrphanErrorNode, numEvents = 5, }: EventNodeSelectorProps) { let errors: TraceError[] = events.flatMap(event => event.errors ?? []); let perfIssues: TracePerformanceIssue[] = events.flatMap( event => event.performance_issues ?? [] ); let type: keyof Theme['tag'] = nodeKey === 'current' ? 'black' : 'white'; const hasErrors = errors.length > 0 || perfIssues.length > 0; if (hasErrors || isOrphanErrorNode) { type = nodeKey === 'current' ? 'error' : 'warning'; text = ( {text} ); if (isOrphanErrorNode) { return ( {text} ); } } const isError = currentEvent.hasOwnProperty('groupID') && currentEvent.groupID !== null; // make sure to exclude the current event from the dropdown events = events.filter( event => event.event_id !== currentEvent.id || // if the current event is a perf issue, we don't want to filter out the matching txn (event.event_id === currentEvent.id && isError) ); errors = errors.filter(error => error.event_id !== currentEvent.id); perfIssues = perfIssues.filter( issue => issue.event_id !== currentEvent.id || // if the current event is a txn, we don't want to filter out the matching perf issue (issue.event_id === currentEvent.id && !isError) ); const totalErrors = errors.length + perfIssues.length; if (events.length + totalErrors === 0) { return ( {text} ); } if (events.length + totalErrors === 1) { /** * When there is only 1 event, clicking the node should take the user directly to * the event without additional steps. */ const hoverText = totalErrors ? ( t('View the error for this Transaction') ) : ( ); const target = errors.length ? generateSingleErrorTarget(errors[0], organization, location, errorDest) : perfIssues.length ? generateSingleErrorTarget(perfIssues[0], organization, location, errorDest) : generateLinkToEventInTraceView({ traceSlug, eventId: events[0].event_id, projectSlug: events[0].project_slug, timestamp: events[0].timestamp, location, organization: { slug: organization.slug, features: organization.features, }, transactionName: events[0].transaction, type: transactionDest, }); return ( handleNode(nodeKey, organization)} type={type} /> ); } /** * When there is more than 1 event, clicking the node should expand a dropdown to * allow the user to select which event to go to. */ const hoverText = tct('View [eventPrefix] [eventType]', { eventPrefix: TOOLTIP_PREFIX[nodeKey], eventType: errors.length && events.length ? 'events' : events.length ? 'transactions' : 'errors', }); return ( } anchorRight={anchor === 'right'} > {totalErrors > 0 && ( {tn('Related Issue', 'Related Issues', totalErrors)} )} {[...errors, ...perfIssues].slice(0, numEvents).map(error => { const target = generateSingleErrorTarget( error, organization, location, errorDest, 'related-issues-of-trace' ); return ( handleDropdownItem(nodeKey, organization, false)} organization={organization} anchor={anchor} /> ); })} {events.length > 0 && ( {tn('Transaction', 'Transactions', events.length)} )} {events.slice(0, numEvents).map(event => { const target = generateLinkToEventInTraceView({ traceSlug, timestamp: event.timestamp, projectSlug: event.project_slug, eventId: event.event_id, location, organization: { slug: organization.slug, features: organization.features, }, type: transactionDest, transactionName: event.transaction, }); return ( handleDropdownItem(nodeKey, organization, false)} allowDefaultEvent organization={organization} subtext={getDuration( event['transaction.duration'] / 1000, event['transaction.duration'] < 1000 ? 0 : 2, true )} anchor={anchor} /> ); })} {(errors.length > numEvents || events.length > numEvents) && ( handleDropdownItem(nodeKey, organization, true)} > {t('View all events')} )} ); } type DropdownNodeProps = { anchor: 'left' | 'right'; event: TraceError | QuickTraceEvent | TracePerformanceIssue; organization: OrganizationSummary; allowDefaultEvent?: boolean; onSelect?: (eventKey: any) => void; subtext?: string; to?: LocationDescriptor; }; function DropdownNodeItem({ event, onSelect, to, allowDefaultEvent, organization, subtext, anchor, }: DropdownNodeProps) { return ( {({projects}) => { const project = projects.find(p => p.slug === event.project_slug); return ( ); }} {isQuickTraceEvent(event) ? ( ) : ( )} {subtext && {subtext}} ); } type EventNodeProps = { hoverText: React.ReactNode; text: React.ReactNode; onClick?: (eventKey: any) => void; to?: LocationDescriptor; type?: keyof Theme['tag']; }; function StyledEventNode({text, hoverText, to, onClick, type = 'white'}: EventNodeProps) { return ( {text} ); } type MissingServiceProps = Pick & { connectorSide: 'left' | 'right'; platform: string; }; type MissingServiceState = { hideMissing: boolean; }; const HIDE_MISSING_SERVICE_KEY = 'quick-trace:hide-missing-services'; // 30 days const HIDE_MISSING_EXPIRES = 1000 * 60 * 60 * 24 * 30; function readHideMissingServiceState() { const value = localStorage.getItem(HIDE_MISSING_SERVICE_KEY); if (value === null) { return false; } const expires = parseInt(value, 10); const now = new Date().getTime(); return expires > now; } class MissingServiceNode extends Component { state: MissingServiceState = { hideMissing: readHideMissingServiceState(), }; dismissMissingService = () => { const {organization, platform} = this.props; const now = new Date().getTime(); localStorage.setItem( HIDE_MISSING_SERVICE_KEY, (now + HIDE_MISSING_EXPIRES).toString() ); this.setState({hideMissing: true}); trackAnalytics('quick_trace.missing_service.dismiss', { organization: organization.id, platform, }); }; trackExternalLink = () => { const {organization, platform} = this.props; trackAnalytics('quick_trace.missing_service.docs', { organization: organization.id, platform, }); }; render() { const {hideMissing} = this.state; const {anchor, connectorSide, platform} = this.props; if (hideMissing) { return null; } const docPlatform = getDocsPlatform(platform, true); const docsHref = docPlatform === null || docPlatform === 'javascript' ? 'https://docs.sentry.io/platforms/javascript/performance/connect-services/' : `https://docs.sentry.io/platforms/${docPlatform}/performance/connect-services`; return ( {connectorSide === 'left' && } } anchorRight={anchor === 'right'} > {t('Connect to a service')} {t('Dismiss')} {connectorSide === 'right' && } ); } }