import {Fragment, useCallback, useMemo, useState} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import debounce from 'lodash/debounce';
import omit from 'lodash/omit';
import moment from 'moment-timezone';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import Count from 'sentry/components/count';
import EmptyStateWarning, {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
import * as Layout from 'sentry/components/layouts/thirds';
import ExternalLink from 'sentry/components/links/externalLink';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
import Panel from 'sentry/components/panels/panel';
import PanelHeader from 'sentry/components/panels/panelHeader';
import PanelItem from 'sentry/components/panels/panelItem';
import PerformanceDuration from 'sentry/components/performanceDuration';
import {IconChevron} from 'sentry/icons/iconChevron';
import {IconClose} from 'sentry/icons/iconClose';
import {IconWarning} from 'sentry/icons/iconWarning';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {MetricAggregation, MRI} from 'sentry/types/metrics';
import type {Organization} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import {browserHistory} from 'sentry/utils/browserHistory';
import {getUtcDateString} from 'sentry/utils/dates';
import {getFormattedMQL} from 'sentry/utils/metrics';
import {decodeInteger, decodeList} from 'sentry/utils/queryString';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import useProjects from 'sentry/utils/useProjects';
import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
import {usePageParams} from './hooks/usePageParams';
import type {TraceResult} from './hooks/useTraces';
import {useTraces} from './hooks/useTraces';
import type {SpanResult} from './hooks/useTraceSpans';
import {useTraceSpans} from './hooks/useTraceSpans';
import {type Field, FIELDS, SORTS} from './data';
import {
Description,
ProjectBadgeWrapper,
ProjectsRenderer,
SpanBreakdownSliceRenderer,
SpanDescriptionRenderer,
SpanIdRenderer,
SpanTimeRenderer,
TraceBreakdownContainer,
TraceBreakdownRenderer,
TraceIdRenderer,
TraceIssuesRenderer,
} from './fieldRenderers';
import {TracesChart} from './tracesChart';
import {TracesSearchBar} from './tracesSearchBar';
import {
areQueriesEmpty,
getSecondaryNameFromSpan,
getStylingSliceName,
normalizeTraces,
} from './utils';
const DEFAULT_PER_PAGE = 50;
const SPAN_PROPS_DOCS_URL =
'https://docs.sentry.io/concepts/search/searchable-properties/spans/';
const ONE_MINUTE = 60 * 1000; // in milliseconds
export function Content() {
const location = useLocation();
const limit = useMemo(() => {
return decodeInteger(location.query.perPage, DEFAULT_PER_PAGE);
}, [location.query.perPage]);
const {queries, metricsMax, metricsMin, metricsOp, metricsQuery, mri} =
usePageParams(location);
const hasMetric = metricsOp && mri;
const removeMetric = useCallback(() => {
browserHistory.push({
...location,
query: omit(location.query, [
'mri',
'metricsOp',
'metricsQuery',
'metricsMax',
'metricsMin',
]),
});
}, [location]);
const handleSearch = useCallback(
(searchIndex: number, searchQuery: string) => {
const newQueries = [...queries];
if (newQueries.length === 0) {
// In the odd case someone wants to add search bars before any query has been made, we add both the default one shown and a new one.
newQueries[0] = '';
}
newQueries[searchIndex] = searchQuery;
browserHistory.push({
...location,
query: {
...location.query,
cursor: undefined,
query: typeof searchQuery === 'string' ? newQueries : queries,
},
});
},
[location, queries]
);
const handleClearSearch = useCallback(
(searchIndex: number) => {
const newQueries = [...queries];
if (typeof newQueries[searchIndex] !== undefined) {
delete newQueries[searchIndex];
browserHistory.push({
...location,
query: {
...location.query,
cursor: undefined,
query: newQueries,
},
});
return true;
}
return false;
},
[location, queries]
);
const tracesQuery = useTraces({
limit,
query: queries,
mri: hasMetric ? mri : undefined,
metricsMax: hasMetric ? metricsMax : undefined,
metricsMin: hasMetric ? metricsMin : undefined,
metricsOp: hasMetric ? metricsOp : undefined,
metricsQuery: hasMetric ? metricsQuery : undefined,
});
const isLoading = tracesQuery.isFetching;
const isError = !isLoading && tracesQuery.isError;
const isEmpty = !isLoading && !isError && (tracesQuery?.data?.data?.length ?? 0) === 0;
const rawData = !isLoading && !isError ? tracesQuery?.data?.data : undefined;
const data = normalizeTraces(rawData);
return (
{hasMetric && (
}
>
{tct('The metric query [metricQuery] is filtering the results below.', {
metricQuery: (
{getFormattedMQL({
mri: mri as MRI,
aggregation: metricsOp as MetricAggregation,
query: metricsQuery,
})}
),
})}
)}
{isError && typeof tracesQuery.error?.responseJSON?.detail === 'string' ? (
{tracesQuery.error?.responseJSON?.detail}
) : null}
{t('Trace ID')}
{t('Trace Root')}
{areQueriesEmpty(queries) ? t('Total Spans') : t('Matching Spans')}
{t('Timeline')}
{t('Duration')}
{t('Timestamp')}
{t('Issues')}
{isLoading && (
)}
{isError && ( // TODO: need an error state
)}
{isEmpty && (
{t('No trace results found')}
{tct('Try adjusting your filters or refer to [docSearchProps].', {
docSearchProps: (
{t('docs for search properties')}
),
})}
)}
{data?.map((trace, i) => (
))}
);
}
function TraceRow({defaultExpanded, trace}: {defaultExpanded; trace: TraceResult}) {
const {selection} = usePageFilters();
const {projects} = useProjects();
const [expanded, setExpanded] = useState(defaultExpanded);
const [highlightedSliceName, _setHighlightedSliceName] = useState('');
const location = useLocation();
const organization = useOrganization();
const queries = useMemo(() => {
return decodeList(location.query.query);
}, [location.query.query]);
const setHighlightedSliceName = useMemo(
() =>
debounce(sliceName => _setHighlightedSliceName(sliceName), 100, {
leading: true,
}),
[_setHighlightedSliceName]
);
const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
const selectedProjects = useMemo(() => {
const selectedProjectIds = new Set(
selection.projects.map(project => project.toString())
);
return new Set(
projects
.filter(project => selectedProjectIds.has(project.id))
.map(project => project.slug)
);
}, [projects, selection.projects]);
const traceProjects = useMemo(() => {
const seenProjects: Set = new Set();
const leadingProjects: string[] = [];
const trailingProjects: string[] = [];
for (let i = 0; i < trace.breakdowns.length; i++) {
const project = trace.breakdowns[i].project;
if (!defined(project) || seenProjects.has(project)) {
continue;
}
seenProjects.add(project);
// Priotize projects that are selected in the page filters
if (selectedProjects.has(project)) {
leadingProjects.push(project);
} else {
trailingProjects.push(project);
}
}
return [...leadingProjects, ...trailingProjects];
}, [selectedProjects, trace]);
return (
}
aria-label={t('Toggle trace details')}
aria-expanded={expanded}
size="zero"
borderless
onClick={() =>
trackAnalytics('trace_explorer.toggle_trace_details', {
organization,
expanded,
})
}
/>
trackAnalytics('trace_explorer.open_trace', {
organization,
})
}
location={location}
/>
0
? traceProjects
: trace.project
? [trace.project]
: []
}
/>
{trace.name ? (
{trace.name}
) : (
{t('Missing Trace Root')}
)}
{areQueriesEmpty(queries) ? (
) : (
tct('[numerator][space]of[space][denominator]', {
numerator: ,
denominator: ,
space: ,
})
)}
setHighlightedSliceName('')}
>
trackAnalytics('trace_explorer.open_in_issues', {
organization,
})
}
/>
{expanded && (
)}
);
}
function SpanTable({
trace,
setHighlightedSliceName,
}: {
setHighlightedSliceName: (sliceName: string) => void;
trace: TraceResult;
}) {
const location = useLocation();
const organization = useOrganization();
const {queries, metricsMax, metricsMin, metricsOp, metricsQuery, mri} =
usePageParams(location);
const hasMetric = metricsOp && mri;
const spansQuery = useTraceSpans({
trace,
fields: [
...FIELDS,
...SORTS.map(field =>
field.startsWith('-') ? (field.substring(1) as Field) : (field as Field)
),
],
datetime: {
// give a 1 minute buffer on each side so that start != end
start: getUtcDateString(moment(trace.start - ONE_MINUTE)),
end: getUtcDateString(moment(trace.end + ONE_MINUTE)),
period: null,
utc: true,
},
limit: 10,
query: queries,
sort: SORTS,
mri: hasMetric ? mri : undefined,
metricsMax: hasMetric ? metricsMax : undefined,
metricsMin: hasMetric ? metricsMin : undefined,
metricsOp: hasMetric ? metricsOp : undefined,
metricsQuery: hasMetric ? metricsQuery : undefined,
});
const isLoading = spansQuery.isFetching;
const isError = !isLoading && spansQuery.isError;
const hasData = !isLoading && !isError && (spansQuery?.data?.data?.length ?? 0) > 0;
const spans = spansQuery.data?.data ?? [];
return (
{t('Span ID')}
{t('Span Description')}
{t('Span Duration')}
{t('Timestamp')}
{isLoading && (
)}
{isError && ( // TODO: need an error state
)}
{spans.map(span => (
))}
{hasData && spans.length < trace.matchingSpans && (
{tct('[more][space]more [matching]spans can be found in the trace.', {
more: ,
space: ,
matching: areQueriesEmpty(queries) ? '' : 'matching ',
})}
)}
);
}
function SpanRow({
organization,
span,
trace,
setHighlightedSliceName,
}: {
organization: Organization;
setHighlightedSliceName: (sliceName: string) => void;
span: SpanResult;
trace: TraceResult;
}) {
const theme = useTheme();
return (
trackAnalytics('trace_explorer.open_trace_span', {
organization,
})
}
/>
setHighlightedSliceName('')}>
setHighlightedSliceName(
getStylingSliceName(span.project, getSecondaryNameFromSpan(span)) ?? ''
)
}
/>
);
}
const LayoutMain = styled(Layout.Main)`
display: flex;
flex-direction: column;
gap: ${space(2)};
`;
const StyledPanel = styled(Panel)`
margin-bottom: 0px;
`;
const TracePanelContent = styled('div')`
width: 100%;
display: grid;
grid-template-columns: 116px auto repeat(2, min-content) 85px 112px 66px;
`;
const SpanPanelContent = styled('div')`
width: 100%;
display: grid;
grid-template-columns: 100px auto repeat(1, min-content) 160px 85px;
`;
const StyledPanelHeader = styled(PanelHeader)<{align: 'left' | 'right'}>`
white-space: nowrap;
justify-content: ${p => (p.align === 'left' ? 'flex-start' : 'flex-end')};
`;
const EmptyStateText = styled('div')<{size: 'fontSizeExtraLarge' | 'fontSizeMedium'}>`
color: ${p => p.theme.gray300};
font-size: ${p => p.theme[p.size]};
padding-bottom: ${space(1)};
`;
const StyledPanelItem = styled(PanelItem)<{
align?: 'left' | 'center' | 'right';
overflow?: boolean;
span?: number;
}>`
align-items: center;
padding: ${space(1)} ${space(2)};
${p => (p.align === 'left' ? 'justify-content: flex-start;' : null)}
${p => (p.align === 'right' ? 'justify-content: flex-end;' : null)}
${p => (p.overflow ? p.theme.overflowEllipsis : null)};
${p =>
p.align === 'center'
? `
justify-content: space-around;`
: p.align === 'left' || p.align === 'right'
? `text-align: ${p.align};`
: undefined}
${p => p.span && `grid-column: auto / span ${p.span};`}
white-space: nowrap;
`;
const MoreMatchingSpans = styled(StyledPanelItem)`
color: ${p => p.theme.gray300};
`;
const WrappingText = styled('div')`
width: 100%;
${p => p.theme.overflowEllipsis};
`;
const StyledSpanPanelItem = styled(StyledPanelItem)`
&:nth-child(10n + 1),
&:nth-child(10n + 2),
&:nth-child(10n + 3),
&:nth-child(10n + 4),
&:nth-child(10n + 5) {
background-color: ${p => p.theme.backgroundSecondary};
}
`;
const SpanTablePanelItem = styled(StyledPanelItem)`
background-color: ${p => p.theme.gray100};
`;
const BreakdownPanelItem = styled(StyledPanelItem)<{highlightedSliceName: string}>`
${p =>
p.highlightedSliceName
? `--highlightedSlice-${p.highlightedSliceName}-opacity: 1.0;
--highlightedSlice-${p.highlightedSliceName}-saturate: saturate(1.0) contrast(1.0);
--highlightedSlice-${p.highlightedSliceName}-transform: translateY(0px);
`
: null}
${p =>
p.highlightedSliceName
? `
--defaultSlice-opacity: 1.0;
--defaultSlice-saturate: saturate(0.7) contrast(0.9) brightness(1.2);
--defaultSlice-transform: translateY(0px);
`
: `
--defaultSlice-opacity: 1.0;
--defaultSlice-saturate: saturate(1.0) contrast(1.0);
--defaultSlice-transform: translateY(0px);
`}
`;
const EmptyValueContainer = styled('span')`
color: ${p => p.theme.gray300};
`;
const StyledAlert = styled(Alert)`
margin-bottom: 0;
`;
const StyledCloseButton = styled(IconClose)`
cursor: pointer;
`;