import {Component, Fragment} from 'react'; import {Location, LocationDescriptor} from 'history'; import DropdownLink from 'sentry/components/dropdownLink'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import { ErrorDestination, generateSingleErrorTarget, generateSingleTransactionTarget, generateTraceTarget, isQuickTraceEvent, TransactionDestination, } 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 {OrganizationSummary} from 'sentry/types'; import {Event} from 'sentry/types/event'; import {trackAnalyticsEvent} from 'sentry/utils/analytics'; import {getDocsPlatform} from 'sentry/utils/docs'; import {getDuration} from 'sentry/utils/formatters'; import localStorage from 'sentry/utils/localStorage'; import { QuickTrace as QuickTraceType, QuickTraceEvent, TraceError, } from 'sentry/utils/performance/quickTrace/types'; import {parseQuickTrace} from 'sentry/utils/performance/quickTrace/utils'; import Projects from 'sentry/utils/projects'; import {Theme} from 'sentry/utils/theme'; const FRONTEND_PLATFORMS: string[] = [...frontend, ...mobile]; const BACKEND_PLATFORMS: string[] = [...backend, ...serverless]; 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; try { parsedQuickTrace = parseQuickTrace(quickTrace, event, organization); } catch (error) { return {'\u2014'}; } const traceLength = quickTrace.trace && quickTrace.trace.length; const {root, ancestors, parent, children, descendants, current} = parsedQuickTrace; const nodes: React.ReactNode[] = []; if (root) { nodes.push( ); nodes.push(); } if (ancestors?.length) { nodes.push( ); nodes.push(); } if (parent) { nodes.push( ); nodes.push(); } const currentNode = ( ); 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) { trackAnalyticsEvent({ eventKey: 'quick_trace.node.clicked', eventName: 'Quick Trace: Node clicked', organization_id: parseInt(organization.id, 10), node_key: key, }); } function handleDropdownItem( key: string, organization: OrganizationSummary, extra: boolean ) { trackAnalyticsEvent({ eventKey: 'quick_trace.dropdown.clicked' + (extra ? '_extra' : ''), eventName: 'Quick Trace: Dropdown clicked', organization_id: parseInt(organization.id, 10), 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; transactionDest: TransactionDestination; numEvents?: number; }; function EventNodeSelector({ location, organization, events = [], text, currentEvent, nodeKey, anchor, errorDest, transactionDest, numEvents = 5, }: EventNodeSelectorProps) { let errors: TraceError[] = events.flatMap(event => event.errors ?? []); let type: keyof Theme['tag'] = nodeKey === 'current' ? 'black' : 'white'; const hasErrors = errors.length > 0; if (hasErrors) { type = nodeKey === 'current' ? 'error' : 'warning'; text = ( {text} ); } // make sure to exclude the current event from the dropdown events = events.filter(event => event.event_id !== currentEvent.id); errors = errors.filter(error => error.event_id !== currentEvent.id); if (events.length + errors.length === 0) { return {text}; } if (events.length + errors.length === 1) { /** * When there is only 1 event, clicking the node should take the user directly to * the event without additional steps. */ const hoverText = errors.length ? ( t('View the error for this Transaction') ) : ( ); const target = errors.length ? generateSingleErrorTarget(errors[0], organization, location, errorDest) : generateSingleTransactionTarget( events[0], organization, location, transactionDest ); return ( handleNode(nodeKey, organization)} type={type} shouldOffset={hasErrors} /> ); } /** * 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'} > {errors.length > 0 && ( {tn('Related Error', 'Related Errors', errors.length)} )} {errors.slice(0, numEvents).map(error => { const target = generateSingleErrorTarget( error, organization, location, errorDest ); 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 = generateSingleTransactionTarget( event, organization, location, transactionDest ); 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; 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; shouldOffset?: boolean; to?: LocationDescriptor; type?: keyof Theme['tag']; }; function StyledEventNode({ text, hoverText, to, onClick, type = 'white', shouldOffset = false, }: 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}); trackAnalyticsEvent({ eventKey: 'quick_trace.missing_service.dismiss', eventName: 'Quick Trace: Missing Service Dismissed', organization_id: parseInt(organization.id, 10), platform, }); }; trackExternalLink = () => { const {organization, platform} = this.props; trackAnalyticsEvent({ eventKey: 'quick_trace.missing_service.docs', eventName: 'Quick Trace: Missing Service Clicked', organization_id: parseInt(organization.id, 10), 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/connecting-services`; return ( {connectorSide === 'left' && } } anchorRight={anchor === 'right'} > {t('Connect to a service')} {t('Dismiss')} {connectorSide === 'right' && } ); } }