import {Fragment, useCallback} from 'react'; import styled from '@emotion/styled'; import * as qs from 'query-string'; import Feature from 'sentry/components/acl/feature'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import SearchBar from 'sentry/components/events/searchBar'; import Link from 'sentry/components/links/link'; import {SegmentedControl} from 'sentry/components/segmentedControl'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert'; import {decodeScalar} from 'sentry/utils/queryString'; import { EMPTY_OPTION_VALUE, escapeFilterValue, MutableSearch, } from 'sentry/utils/tokenizeSearch'; import useLocationQuery from 'sentry/utils/url/useLocationQuery'; 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 useRouter from 'sentry/utils/useRouter'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import {AverageValueMarkLine} from 'sentry/views/performance/charts/averageValueMarkLine'; import {DurationChart} from 'sentry/views/performance/http/charts/durationChart'; import {ResponseCodeCountChart} from 'sentry/views/performance/http/charts/responseCodeCountChart'; import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/performance/http/data/definitions'; import {useSpanSamples} from 'sentry/views/performance/http/data/useSpanSamples'; import decodePanel from 'sentry/views/performance/http/queryParameterDecoders/panel'; import decodeResponseCodeClass from 'sentry/views/performance/http/queryParameterDecoders/responseCodeClass'; import {Referrer} from 'sentry/views/performance/http/referrers'; import {BASE_FILTERS} from 'sentry/views/performance/http/settings'; import {SpanSamplesTable} from 'sentry/views/performance/http/tables/spanSamplesTable'; import {useDebouncedState} from 'sentry/views/performance/http/useDebouncedState'; import {MetricReadout} from 'sentry/views/performance/metricReadout'; import * as ModuleLayout from 'sentry/views/performance/moduleLayout'; import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags'; import {computeAxisMax} from 'sentry/views/starfish/components/chart'; import DetailPanel from 'sentry/views/starfish/components/detailPanel'; import {getTimeSpentExplanation} from 'sentry/views/starfish/components/tableCells/timeSpentCell'; import {useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover'; import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useDiscoverSeries'; import {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans'; import {useSpanMetricsTopNSeries} from 'sentry/views/starfish/queries/useSpanMetricsTopNSeries'; import { ModuleName, SpanFunction, SpanIndexedField, SpanMetricsField, type SpanMetricsQueryFilters, } from 'sentry/views/starfish/types'; import {findSampleFromDataPoint} from 'sentry/views/starfish/utils/chart/findDataPoint'; import {DataTitles, getThroughputTitle} from 'sentry/views/starfish/views/spans/types'; import {useSampleScatterPlotSeries} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries'; export function HTTPSamplesPanel() { const router = useRouter(); const location = useLocation(); const query = useLocationQuery({ fields: { project: decodeScalar, domain: decodeScalar, transaction: decodeScalar, transactionMethod: decodeScalar, panel: decodePanel, responseCodeClass: decodeResponseCodeClass, spanSearchQuery: decodeScalar, }, }); const organization = useOrganization(); const {projects} = useProjects(); const {selection} = usePageFilters(); const supportedTags = useSpanFieldSupportedTags(); const project = projects.find(p => query.project === p.id); const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState( undefined, [], SAMPLE_HOVER_DEBOUNCE ); // `detailKey` controls whether the panel is open. If all required properties are available, concat them to make a key, otherwise set to `undefined` and hide the panel const detailKey = query.transaction ? [query.domain, query.transactionMethod, query.transaction].filter(Boolean).join(':') : undefined; const handlePanelChange = newPanelName => { trackAnalytics('performance_views.sample_spans.filter_updated', { filter: 'panel', new_state: newPanelName, organization, source: ModuleName.HTTP, }); router.replace({ pathname: location.pathname, query: { ...location.query, panel: newPanelName, }, }); }; const handleResponseCodeClassChange = newResponseCodeClass => { trackAnalytics('performance_views.sample_spans.filter_updated', { filter: 'status_code', new_state: newResponseCodeClass.value, organization, source: ModuleName.HTTP, }); router.replace({ pathname: location.pathname, query: { ...location.query, responseCodeClass: newResponseCodeClass.value, }, }); }; const isPanelOpen = Boolean(detailKey); // The ribbon is above the data selectors, and not affected by them. So, it has its own filters. const ribbonFilters: SpanMetricsQueryFilters = { ...BASE_FILTERS, 'span.domain': query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain), transaction: query.transaction, }; // These filters are for the charts and samples tables const filters: SpanMetricsQueryFilters = { ...BASE_FILTERS, 'span.domain': query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain), transaction: query.transaction, }; const responseCodeInRange = query.responseCodeClass ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code => code.startsWith(query.responseCodeClass) ) : []; if (responseCodeInRange.length > 0) { // TODO: Allow automatic array parameter concatenation filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`; } const search = MutableSearch.fromQueryObject(filters); const { data: domainTransactionMetrics, isFetching: areDomainTransactionMetricsFetching, } = useSpanMetrics( { search: MutableSearch.fromQueryObject(ribbonFilters), fields: [ `${SpanFunction.SPM}()`, `avg(${SpanMetricsField.SPAN_SELF_TIME})`, `sum(${SpanMetricsField.SPAN_SELF_TIME})`, 'http_response_rate(3)', 'http_response_rate(4)', 'http_response_rate(5)', `${SpanFunction.TIME_SPENT_PERCENTAGE}()`, ], enabled: isPanelOpen, }, Referrer.SAMPLES_PANEL_METRICS_RIBBON ); const { isFetching: isDurationDataFetching, data: durationData, error: durationError, } = useSpanMetricsSeries( { search, yAxis: [`avg(span.self_time)`], enabled: isPanelOpen && query.panel === 'duration', }, Referrer.SAMPLES_PANEL_DURATION_CHART ); const { isFetching: isResponseCodeDataLoading, data: responseCodeData, error: responseCodeError, } = useSpanMetricsTopNSeries({ search, fields: ['span.status_code', 'count()'], yAxis: ['count()'], topEvents: 5, enabled: isPanelOpen && query.panel === 'status', referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART, }); // NOTE: Due to some data confusion, the `domain` column in the spans table can either be `null` or `""`. Searches like `"!has:span.domain"` are turned into the ClickHouse clause `isNull(domain)`, and do not match the empty string. We need a query that matches empty strings _and_ null_ which is `(!has:domain OR domain:[""])`. This hack can be removed in August 2024, once https://github.com/getsentry/snuba/pull/5780 has been deployed for 90 days and all `""` domains have fallen out of the data retention window. Also, `null` domains will become more rare as people upgrade the JS SDK to versions that populate the `server.address` span attribute const sampleSpansSearch = MutableSearch.fromQueryObject({ ...filters, 'span.domain': undefined, }); // filter by key-value filters specified in the search bar query sampleSpansSearch.addStringMultiFilter(query.spanSearchQuery); if (query.domain === '') { sampleSpansSearch.addOp('('); sampleSpansSearch.addFilterValue('!has', 'span.domain'); sampleSpansSearch.addOp('OR'); // HACK: Use `addOp` to add the condition `'span.domain:[""]'` and avoid escaping the double quotes. Ideally there'd be a way to specify this explicitly, but this whole thing is a hack anyway. Once a plain `!has:span.domain` condition works, this is not necessary sampleSpansSearch.addOp('span.domain:[""]'); sampleSpansSearch.addOp(')'); } else { sampleSpansSearch.addFilterValue('span.domain', query.domain); } const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]); const { data: durationSamplesData, isFetching: isDurationSamplesDataFetching, error: durationSamplesDataError, refetch: refetchDurationSpanSamples, } = useSpanSamples({ search: sampleSpansSearch, fields: [ SpanIndexedField.TRACE, SpanIndexedField.TRANSACTION_ID, SpanIndexedField.SPAN_DESCRIPTION, SpanIndexedField.RESPONSE_CODE, ], min: 0, max: durationAxisMax, enabled: isPanelOpen && query.panel === 'duration' && durationAxisMax > 0, referrer: Referrer.SAMPLES_PANEL_DURATION_SAMPLES, }); const { data: responseCodeSamplesData, isFetching: isResponseCodeSamplesDataFetching, error: responseCodeSamplesDataError, refetch: refetchResponseCodeSpanSamples, } = useIndexedSpans({ search: sampleSpansSearch, fields: [ SpanIndexedField.PROJECT, SpanIndexedField.TRACE, SpanIndexedField.TRANSACTION_ID, SpanIndexedField.ID, SpanIndexedField.TIMESTAMP, SpanIndexedField.SPAN_DESCRIPTION, SpanIndexedField.RESPONSE_CODE, ], sorts: [SPAN_SAMPLES_SORT], limit: SPAN_SAMPLE_LIMIT, enabled: isPanelOpen && query.panel === 'status', referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES, }); const sampledSpanDataSeries = useSampleScatterPlotSeries( durationSamplesData, domainTransactionMetrics?.[0]?.['avg(span.self_time)'], highlightedSpanId ); const handleSearch = (newSpanSearchQuery: string) => { router.replace({ pathname: location.pathname, query: { ...query, spanSearchQuery: newSpanSearchQuery, }, }); }; const handleClose = () => { router.replace({ pathname: router.location.pathname, query: { ...router.location.query, transaction: undefined, transactionMethod: undefined, }, }); }; const handleOpen = useCallback(() => { if (query.transaction) { trackAnalytics('performance_views.sample_spans.opened', { organization, source: ModuleName.HTTP, }); } }, [organization, query.transaction]); return ( {project && ( )} <Link to={normalizeUrl( `/organizations/${organization.slug}/performance/summary?${qs.stringify( { project: query.project, transaction: query.transaction, } )}` )} > {query.transaction && query.transactionMethod && !query.transaction.startsWith(query.transactionMethod) ? `${query.transactionMethod} ${query.transaction}` : query.transaction} </Link> {t('By Duration')} {t('By Response Code')} {query.panel === 'duration' && ( { const firstHighlight = highlights[0]; if (!firstHighlight) { setHighlightedSpanId(undefined); return; } const sample = findSampleFromDataPoint< (typeof durationSamplesData)[0] >( firstHighlight.dataPoint, durationSamplesData, SpanIndexedField.SPAN_SELF_TIME ); setHighlightedSpanId(sample?.span_id); }} isLoading={isDurationDataFetching} error={durationError} /> )} {query.panel === 'status' && ( )} {query.panel === 'duration' && ( setHighlightedSpanId(sample.span_id)} onSampleMouseOut={() => setHighlightedSpanId(undefined)} error={durationSamplesDataError} // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually meta={{ fields: { 'span.response_code': 'number', }, units: {}, }} /> )} {query.panel === 'status' && ( )} ); } const SAMPLE_HOVER_DEBOUNCE = 10; const SPAN_SAMPLE_LIMIT = 10; // This is functionally a random sort, which is what we want const SPAN_SAMPLES_SORT = { field: 'span_id', kind: 'desc' as const, }; const SpanSummaryProjectAvatar = styled(ProjectAvatar)` padding-right: ${space(1)}; `; const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [ { value: '', label: t('All'), }, { value: '2', label: t('2XXs'), }, { value: '3', label: t('3XXs'), }, { value: '4', label: t('4XXs'), }, { value: '5', label: t('5XXs'), }, ]; const HeaderContainer = styled('div')` display: grid; grid-template-rows: auto auto auto; @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-rows: auto; grid-template-columns: auto 1fr auto; } `; const TitleContainer = styled('div')` width: 100%; position: relative; height: 40px; `; const Title = styled('h4')` position: absolute; bottom: 0; margin-bottom: 0; `; const MetricsRibbon = styled('div')` display: flex; flex-wrap: wrap; gap: ${space(4)}; `; const PanelControls = styled('div')` display: flex; justify-content: space-between; gap: ${space(2)}; `;