import {Fragment, useCallback, useEffect, 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';
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 {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
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 {Tooltip} from 'sentry/components/tooltip';
import {IconArrow} from 'sentry/icons/iconArrow';
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 {PageFilters} from 'sentry/types/core';
import type {MRI} from 'sentry/types/metrics';
import type {Organization} from 'sentry/types/organization';
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 {useApiQuery} from 'sentry/utils/queryClient';
import {decodeInteger, decodeList, decodeScalar} 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 {type Field, FIELDS, SORTS} from './data';
import {
BREAKDOWN_SLICES,
ProjectRenderer,
SpanBreakdownSliceRenderer,
SpanDescriptionRenderer,
SpanIdRenderer,
SpanTimeRenderer,
TraceBreakdownContainer,
TraceBreakdownRenderer,
TraceIdRenderer,
TraceIssuesRenderer,
} from './fieldRenderers';
import {TracesChart} from './tracesChart';
import {TracesSearchBar} from './tracesSearchBar';
import {
ALL_PROJECTS,
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
function usePageParams(location) {
const queries = useMemo(() => {
return decodeList(location.query.query);
}, [location.query.query]);
const metricsMax = decodeScalar(location.query.metricsMax);
const metricsMin = decodeScalar(location.query.metricsMin);
const metricsOp = decodeScalar(location.query.metricsOp);
const metricsQuery = decodeScalar(location.query.metricsQuery);
const mri = decodeScalar(location.query.mri);
return {
queries,
metricsMax,
metricsMin,
metricsOp,
metricsQuery,
mri,
};
}
export function Content() {
const location = useLocation();
const organization = useOrganization();
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 sortByTimestamp = organization.features.includes(
'performance-trace-explorer-sorting'
);
const tracesQuery = useTraces({
limit,
query: queries,
sort: sortByTimestamp ? '-timestamp' : undefined,
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 = sortByTimestamp ? rawData : normalizeTraces(rawData);
return (
,
code:
,
}
)}
position="bottom"
>
{hasMetric && (
}
>
{tct('The metric query [metricQuery] is filtering the results below.', {
metricQuery: (
{getFormattedMQL({mri: mri as MRI, op: metricsOp, 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')}
{sortByTimestamp ? : null}
{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 [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]);
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}
/>
{trace.project ? (
) : null}
{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)) ?? ''
)
}
/>
);
}
export type SpanResult = Record;
export interface TraceResult {
breakdowns: TraceBreakdownResult[];
duration: number;
end: number;
matchingSpans: number;
name: string | null;
numErrors: number;
numOccurrences: number;
numSpans: number;
project: string | null;
slices: number;
start: number;
trace: string;
}
interface TraceBreakdownBase {
duration: number; // Contains the accurate duration for display. Start and end may be quantized.
end: number;
opCategory: string | null;
sdkName: string | null;
sliceEnd: number;
sliceStart: number;
sliceWidth: number;
start: number;
}
type TraceBreakdownProject = TraceBreakdownBase & {
kind: 'project';
project: string;
};
type TraceBreakdownMissing = TraceBreakdownBase & {
kind: 'missing';
project: null;
};
export type TraceBreakdownResult = TraceBreakdownProject | TraceBreakdownMissing;
interface TraceResults {
data: TraceResult[];
meta: any;
}
interface UseTracesOptions {
datetime?: PageFilters['datetime'];
enabled?: boolean;
limit?: number;
metricsMax?: string;
metricsMin?: string;
metricsOp?: string;
metricsQuery?: string;
mri?: string;
query?: string | string[];
sort?: '-timestamp';
}
function useTraces({
datetime,
enabled,
limit,
mri,
metricsMax,
metricsMin,
metricsOp,
metricsQuery,
query,
sort,
}: UseTracesOptions) {
const organization = useOrganization();
const {projects} = useProjects();
const {selection} = usePageFilters();
const path = `/organizations/${organization.slug}/traces/`;
const endpointOptions = {
query: {
project: selection.projects,
environment: selection.environments,
...(datetime ?? normalizeDateTimeParams(selection.datetime)),
query,
sort,
per_page: limit,
breakdownSlices: BREAKDOWN_SLICES,
mri,
metricsMax,
metricsMin,
metricsOp,
metricsQuery,
},
};
const serializedEndpointOptions = JSON.stringify(endpointOptions);
let queries: string[] = [];
if (Array.isArray(query)) {
queries = query;
} else if (query !== undefined) {
queries = [query];
}
useEffect(() => {
trackAnalytics('trace_explorer.search_request', {
organization,
queries,
});
// `queries` is already included as a dep in serializedEndpointOptions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serializedEndpointOptions, organization]);
const result = useApiQuery([path, endpointOptions], {
staleTime: 0,
refetchOnWindowFocus: false,
retry: false,
enabled,
});
useEffect(() => {
if (result.status === 'success') {
const project_slugs = [...new Set(result.data.data.map(trace => trace.project))];
const project_platforms = projects
.filter(p => project_slugs.includes(p.slug))
.map(p => p.platform ?? '');
trackAnalytics('trace_explorer.search_success', {
organization,
queries,
has_data: result.data.data.length > 0,
num_traces: result.data.data.length,
num_missing_trace_root: result.data.data.filter(trace => trace.name === null)
.length,
project_platforms,
});
} else if (result.status === 'error') {
const response = result.error.responseJSON;
const error =
typeof response?.detail === 'string'
? response?.detail
: response?.detail?.message;
trackAnalytics('trace_explorer.search_failure', {
organization,
queries,
error: error ?? '',
});
}
// result.status is tied to result.data. No need to explicitly
// include result.data as an additional dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serializedEndpointOptions, result.status, organization]);
return result;
}
interface SpanResults {
data: SpanResult[];
meta: any;
}
interface UseTraceSpansOptions {
fields: F[];
trace: TraceResult;
datetime?: PageFilters['datetime'];
enabled?: boolean;
limit?: number;
metricsMax?: string;
metricsMin?: string;
metricsOp?: string;
metricsQuery?: string;
mri?: string;
query?: string | string[];
sort?: string[];
}
function useTraceSpans({
fields,
trace,
datetime,
enabled,
limit,
mri,
metricsMax,
metricsMin,
metricsOp,
metricsQuery,
query,
sort,
}: UseTraceSpansOptions) {
const organization = useOrganization();
const {selection} = usePageFilters();
const path = `/organizations/${organization.slug}/trace/${trace.trace}/spans/`;
const endpointOptions = {
query: {
environment: selection.environments,
...(datetime ?? normalizeDateTimeParams(selection.datetime)),
field: fields,
query,
sort,
per_page: limit,
breakdownSlices: BREAKDOWN_SLICES,
maxSpansPerTrace: 10,
mri,
metricsMax,
metricsMin,
metricsOp,
metricsQuery,
},
};
const result = useApiQuery>([path, endpointOptions], {
staleTime: 0,
refetchOnWindowFocus: false,
retry: false,
enabled,
});
return result;
}
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: repeat(1, min-content) auto repeat(2, min-content) 85px 112px 66px;
`;
const SpanPanelContent = styled('div')`
width: 100%;
display: grid;
grid-template-columns: repeat(1, min-content) 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 Description = styled('div')`
${p => p.theme.overflowEllipsis};
display: flex;
flex-direction: row;
align-items: center;
gap: ${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;
`;
const Code = styled('code')`
color: ${p => p.theme.red400};
`;