import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import {Button} from 'sentry/components/button'; import { CompactSelect, SelectOption, SelectSection, } from 'sentry/components/compactSelect'; import ErrorBoundary from 'sentry/components/errorBoundary'; import {EventDataSection} from 'sentry/components/events/eventDataSection'; import EventReplay from 'sentry/components/events/eventReplay'; import {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types'; import {IconSort} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Organization} from 'sentry/types'; import {BreadcrumbLevelType, RawCrumb} from 'sentry/types/breadcrumbs'; import {EntryType, Event} from 'sentry/types/event'; import {defined} from 'sentry/utils'; import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import SearchBarAction from '../searchBarAction'; import Level from './breadcrumb/level'; import Type from './breadcrumb/type'; import Breadcrumbs from './breadcrumbs'; import {getVirtualCrumb, transformCrumbs} from './utils'; type SelectOptionWithLevels = SelectOption & {levels?: BreadcrumbLevelType[]}; type Props = { data: { values: Array; }; event: Event; organization: Organization; projectSlug: string; isShare?: boolean; }; enum BreadcrumbSort { NEWEST = 'newest', OLDEST = 'oldest', } const EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY = 'event-breadcrumb-sort'; const sortOptions = [ {label: t('Newest'), value: BreadcrumbSort.NEWEST}, {label: t('Oldest'), value: BreadcrumbSort.OLDEST}, ]; function BreadcrumbsContainer({data, event, organization, projectSlug, isShare}: Props) { const [searchTerm, setSearchTerm] = useState(''); const [filterSelections, setFilterSelections] = useState[]>([]); const [displayRelativeTime, setDisplayRelativeTime] = useState(false); const [sort, setSort] = useLocalStorageState( EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY, BreadcrumbSort.NEWEST ); const entryIndex = event.entries.findIndex( entry => entry.type === EntryType.BREADCRUMBS ); const initialBreadcrumbs = useMemo(() => { let crumbs = data.values; // Add the (virtual) breadcrumb based on the error or message event if possible. const virtualCrumb = getVirtualCrumb(event); if (virtualCrumb) { crumbs = [...crumbs, virtualCrumb]; } return transformCrumbs(crumbs); }, [data, event]); const relativeTime = useMemo(() => { return initialBreadcrumbs[initialBreadcrumbs.length - 1]?.timestamp ?? ''; }, [initialBreadcrumbs]); const filterOptions = useMemo(() => { const typeOptions = getFilterTypes(initialBreadcrumbs); const levels = getFilterLevels(typeOptions); const options: SelectSection[] = []; if (typeOptions.length) { options.push({ key: 'types', label: t('Types'), options: typeOptions.map(typeOption => omit(typeOption, 'levels')), }); } if (levels.length) { options.push({ key: 'levels', label: t('Levels'), options: levels, }); } return options; }, [initialBreadcrumbs]); function getFilterTypes(crumbs: ReturnType) { const filterTypes: SelectOptionWithLevels[] = []; for (const index in crumbs) { const breadcrumb = crumbs[index]; const foundFilterType = filterTypes.findIndex( f => f.value === `type-${breadcrumb.type}` ); if (foundFilterType === -1) { filterTypes.push({ value: `type-${breadcrumb.type}`, leadingItems: , label: breadcrumb.description, levels: breadcrumb?.level ? [breadcrumb.level] : [], }); continue; } if ( breadcrumb?.level && !filterTypes[foundFilterType].levels?.includes(breadcrumb.level) ) { filterTypes[foundFilterType].levels?.push(breadcrumb.level); } } return filterTypes; } function getFilterLevels(types: SelectOptionWithLevels[]) { const filterLevels: SelectOption[] = []; for (const indexType in types) { for (const indexLevel in types[indexType].levels) { const level = types[indexType].levels?.[indexLevel]; if (filterLevels.some(f => f.value === `level-${level}`)) { continue; } filterLevels.push({ value: `level-${level}`, textValue: level, label: ( ), }); } } return filterLevels; } function applySearchTerm(breadcrumbs: BreadcrumbWithMeta[], newSearchTerm: string) { if (!newSearchTerm.trim()) { return breadcrumbs; } // Slightly hacky, but it works // the string is being `stringify`d here in order to match exactly the same `stringify`d string of the loop const searchFor = JSON.stringify(newSearchTerm) // it replaces double backslash generate by JSON.stringify with single backslash .replace(/((^")|("$))/g, '') .toLocaleLowerCase(); return breadcrumbs.filter(({breadcrumb}) => Object.keys( pick(breadcrumb, ['type', 'category', 'message', 'level', 'timestamp', 'data']) ).some(key => { const info = breadcrumb[key]; if (!defined(info) || !String(info).trim()) { return false; } return JSON.stringify(info) .replace(/((^")|("$))/g, '') .toLocaleLowerCase() .trim() .includes(searchFor); }) ); } function applySelectedFilters( breadcrumbs: BreadcrumbWithMeta[], selectedFilterOptions: SelectOption[] ) { const checkedTypeOptions = new Set( selectedFilterOptions .filter(option => option.value.startsWith('type-')) .map(option => option.value.split('-')[1]) ); const checkedLevelOptions = new Set( selectedFilterOptions .filter(option => option.value.startsWith('level-')) .map(option => option.value.split('-')[1]) ); if (!![...checkedTypeOptions].length && !![...checkedLevelOptions].length) { return breadcrumbs.filter( ({breadcrumb}) => checkedTypeOptions.has(breadcrumb.type) && checkedLevelOptions.has(breadcrumb.level) ); } if ([...checkedTypeOptions].length) { return breadcrumbs.filter(({breadcrumb}) => checkedTypeOptions.has(breadcrumb.type) ); } if ([...checkedLevelOptions].length) { return breadcrumbs.filter(({breadcrumb}) => checkedLevelOptions.has(breadcrumb.level) ); } return breadcrumbs; } const displayedBreadcrumbs = useMemo(() => { const breadcrumbsWithMeta = initialBreadcrumbs.map((breadcrumb, index) => ({ breadcrumb, meta: event._meta?.entries?.[entryIndex]?.data?.values?.[index], })); const filteredBreadcrumbs = applySearchTerm( applySelectedFilters(breadcrumbsWithMeta, filterSelections), searchTerm ); // Breadcrumbs come back from API sorted oldest -> newest. // Need to `reverse()` instead of sort by timestamp because crumbs with // exact same timestamp will appear out of order. return sort === BreadcrumbSort.NEWEST ? [...filteredBreadcrumbs].reverse() : filteredBreadcrumbs; }, [ entryIndex, event._meta?.entries, filterSelections, initialBreadcrumbs, searchTerm, sort, ]); function getEmptyMessage() { if (displayedBreadcrumbs.length) { return {}; } if (searchTerm && !displayedBreadcrumbs.length) { const hasActiveFilter = filterSelections.length > 0; return { emptyMessage: t('Sorry, no breadcrumbs match your search query'), emptyAction: hasActiveFilter ? ( ) : ( ), }; } return { emptyMessage: t('There are no breadcrumbs to be displayed'), }; } const replayId = getReplayIdFromEvent(event); const showReplay = !isShare && organization.features.includes('session-replay'); const actions = ( , size: 'sm', }} onChange={selectedOption => { setSort(selectedOption.value); }} value={sort} options={sortOptions} /> ); return ( {showReplay ? ( {actions} ) : null} setDisplayRelativeTime(old => !old)} displayRelativeTime={displayRelativeTime} searchTerm={searchTerm} relativeTime={relativeTime} /> ); } export {BreadcrumbsContainer as Breadcrumbs}; const SearchAndSortWrapper = styled('div')<{isFullWidth?: boolean}>` display: grid; grid-template-columns: 1fr auto; gap: ${space(1)}; @media (max-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: 1fr; } margin-bottom: ${p => (p.isFullWidth ? space(1) : 0)}; `; const LevelWrap = styled('span')` height: ${p => p.theme.text.lineHeightBody}em; display: flex; align-items: center; `;