import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import StackTraceContent from 'sentry/components/events/interfaces/crashContent/stackTrace/content'; import StackTraceContentV2 from 'sentry/components/events/interfaces/crashContent/stackTrace/contentV2'; import StackTraceContentV3 from 'sentry/components/events/interfaces/crashContent/stackTrace/contentV3'; import {isStacktraceNewestFirst} from 'sentry/components/events/interfaces/utils'; import {Body, Hovercard} from 'sentry/components/hovercard'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; import space from 'sentry/styles/space'; import {Organization, PlatformType} from 'sentry/types'; import {EntryType, Event} from 'sentry/types/event'; import {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {isNativePlatform} from 'sentry/utils/platform'; import useApi from 'sentry/utils/useApi'; import findBestThread from './events/interfaces/threads/threadSelector/findBestThread'; import getThreadStacktrace from './events/interfaces/threads/threadSelector/getThreadStacktrace'; const REQUEST_DELAY = 100; const HOVERCARD_CONTENT_DELAY = 400; export const STACKTRACE_PREVIEW_TOOLTIP_DELAY = 1000; function getStacktrace(event: Event): StacktraceType | null { const exceptionsWithStacktrace = event.entries .find(e => e.type === EntryType.EXCEPTION) ?.data?.values.filter(({stacktrace}) => defined(stacktrace)) ?? []; const exceptionStacktrace: StacktraceType | undefined = isStacktraceNewestFirst() ? exceptionsWithStacktrace[exceptionsWithStacktrace.length - 1]?.stacktrace : exceptionsWithStacktrace[0]?.stacktrace; if (exceptionStacktrace) { return exceptionStacktrace; } const threads = event.entries.find(e => e.type === EntryType.THREADS)?.data?.values ?? []; const bestThread = findBestThread(threads); if (!bestThread) { return null; } const bestThreadStacktrace = getThreadStacktrace(false, bestThread); if (bestThreadStacktrace) { return bestThreadStacktrace; } return null; } function StackTracePreviewContent({ event, stacktrace, orgFeatures = [], groupingCurrentLevel, }: { event: Event; stacktrace: StacktraceType; groupingCurrentLevel?: number; orgFeatures?: string[]; }) { const includeSystemFrames = useMemo(() => { return stacktrace?.frames?.every(frame => !frame.inApp) ?? false; }, [stacktrace]); const framePlatform = stacktrace?.frames?.find(frame => !!frame.platform)?.platform; const platform = (framePlatform ?? event.platform ?? 'other') as PlatformType; const newestFirst = isStacktraceNewestFirst(); const commonProps = { data: stacktrace, expandFirstFrame: false, includeSystemFrames, platform, newestFirst, event, isHoverPreviewed: true, }; if (orgFeatures.includes('native-stack-trace-v2') && isNativePlatform(platform)) { return ( ); } if (orgFeatures.includes('grouping-stacktrace-ui')) { return ( ); } return ; } type StackTracePreviewProps = { children: React.ReactNode; issueId: string; organization: Organization; className?: string; eventId?: string; groupingCurrentLevel?: number; projectSlug?: string; }; function StackTracePreview(props: StackTracePreviewProps): React.ReactElement { const api = useApi(); const [loadingVisible, setLoadingVisible] = useState(false); const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading'); const [event, setEvent] = useState(null); const delayTimeoutRef = useRef(undefined); const loaderTimeoutRef = useRef(undefined); useEffect(() => { return () => { window.clearTimeout(loaderTimeoutRef.current); window.clearTimeout(delayTimeoutRef.current); }; }, []); const fetchData = useCallback(async () => { // Data is already loaded if (event) { return; } // These are required props to load data if (!props.issueId && !props.eventId && !props.projectSlug) { return; } loaderTimeoutRef.current = window.setTimeout( () => setLoadingVisible(true), HOVERCARD_CONTENT_DELAY ); try { const evt = await api.requestPromise( props.eventId && props.projectSlug ? `/projects/${props.organization.slug}/${props.projectSlug}/events/${props.eventId}/` : `/issues/${props.issueId}/events/latest/?collapse=stacktraceOnly` ); window.clearTimeout(loaderTimeoutRef.current); setEvent(evt); setStatus('loaded'); setLoadingVisible(false); } catch { window.clearTimeout(loaderTimeoutRef.current); setEvent(null); setStatus('error'); setLoadingVisible(false); } }, [ event, api, props.organization.slug, props.projectSlug, props.eventId, props.issueId, ]); const handleMouseEnter = useCallback(() => { window.clearTimeout(delayTimeoutRef.current); delayTimeoutRef.current = window.setTimeout(fetchData, REQUEST_DELAY); }, [fetchData]); const handleMouseLeave = useCallback(() => { window.clearTimeout(delayTimeoutRef.current); delayTimeoutRef.current = undefined; }, []); // Not sure why we need to stop propagation, maybe to prevent the // hovercard from closing? If we are doing this often, maybe it should be // part of the hovercard component. const handleStackTracePreviewClick = useCallback( (e: React.MouseEvent) => void e.stopPropagation(), [] ); const stacktrace = useMemo(() => (event ? getStacktrace(event) : null), [event]); return ( ) : status === 'error' ? ( {t('Failed to load stack trace.')} ) : !stacktrace ? ( {t('There is no stack trace available for this issue.')} ) : !event ? null : (
) } displayTimeout={200} position="right" state={ status === 'loading' && loadingVisible ? 'loading' : !stacktrace ? 'empty' : 'done' } tipBorderColor="border" tipColor="background" > {props.children}
); } export {StackTracePreview}; const StacktraceHovercard = styled(Hovercard)<{state: 'loading' | 'empty' | 'done'}>` /* Lower z-index to match the modals (10000 vs 10002) to allow stackTraceLinkModal be on top of stack trace preview. */ z-index: ${p => p.theme.zIndex.modal}; width: ${p => { if (p.state === 'loading') { return 'auto'; } if (p.state === 'empty') { return '340px'; } return '700px'; }}; ${Body} { padding: 0; max-height: 300px; overflow-y: auto; overscroll-behavior: contain; border-bottom-left-radius: ${p => p.theme.borderRadius}; border-bottom-right-radius: ${p => p.theme.borderRadius}; } .traceback { margin-bottom: 0; border: 0; box-shadow: none; } .loading { margin: 0 auto; .loading-indicator { /** * Overriding the .less file - for default 64px loader we have the width of border set to 6px * For 32px we therefore need 3px to keep the same thickness ratio */ border-width: 3px; } } @media (max-width: ${p => p.theme.breakpoints.large}) { display: none; } `; const NoStackTraceWrapper = styled('div')` color: ${p => p.theme.subText}; padding: ${space(1.5)}; font-size: ${p => p.theme.fontSizeMedium}; display: flex; align-items: center; justify-content: center; min-height: 56px; `;