import {Fragment, useCallback} from 'react'; import styled from '@emotion/styled'; import * as qs from 'query-string'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import Link from 'sentry/components/links/link'; import {SpanSearchQueryBuilder} from 'sentry/components/performance/spanSearchQueryBuilder'; 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 {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert'; import {decodeList, 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 {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {computeAxisMax} from 'sentry/views/insights/common/components/chart'; import DetailPanel from 'sentry/views/insights/common/components/detailPanel'; import {MetricReadout} from 'sentry/views/insights/common/components/metricReadout'; import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout'; import {ReadoutRibbon} from 'sentry/views/insights/common/components/ribbon'; import {getTimeSpentExplanation} from 'sentry/views/insights/common/components/tableCells/timeSpentCell'; import { useSpanMetrics, useSpansIndexed, } from 'sentry/views/insights/common/queries/useDiscover'; import {useSpanMetricsSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries'; import {AverageValueMarkLine} from 'sentry/views/insights/common/utils/averageValueMarkLine'; import {findSampleFromDataPoint} from 'sentry/views/insights/common/utils/findDataPoint'; import { DataTitles, getThroughputTitle, } from 'sentry/views/insights/common/views/spans/types'; import {useSampleScatterPlotSeries} from 'sentry/views/insights/common/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries'; import {DurationChart} from 'sentry/views/insights/http/components/charts/durationChart'; import {ResponseCodeCountChart} from 'sentry/views/insights/http/components/charts/responseCodeCountChart'; import {SpanSamplesTable} from 'sentry/views/insights/http/components/tables/spanSamplesTable'; import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/insights/http/data/definitions'; import {useSpanSamples} from 'sentry/views/insights/http/queries/useSpanSamples'; import {Referrer} from 'sentry/views/insights/http/referrers'; import {BASE_FILTERS} from 'sentry/views/insights/http/settings'; import decodePanel from 'sentry/views/insights/http/utils/queryParameterDecoders/panel'; import decodeResponseCodeClass from 'sentry/views/insights/http/utils/queryParameterDecoders/responseCodeClass'; import {useDebouncedState} from 'sentry/views/insights/http/utils/useDebouncedState'; import {useDomainViewFilters} from 'sentry/views/insights/pages/useFilters'; import { ModuleName, SpanFunction, SpanIndexedField, SpanMetricsField, type SpanMetricsQueryFilters, } from 'sentry/views/insights/types'; import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs'; import {getTransactionSummaryBaseUrl} from 'sentry/views/performance/transactionSummary/utils'; export function HTTPSamplesPanel() { const navigate = useNavigate(); const location = useLocation(); const query = useLocationQuery({ fields: { project: decodeScalar, domain: decodeScalar, transaction: decodeScalar, transactionMethod: decodeScalar, panel: decodePanel, responseCodeClass: decodeResponseCodeClass, spanSearchQuery: decodeScalar, [SpanMetricsField.USER_GEO_SUBREGION]: decodeList, }, }); const organization = useOrganization(); const {view} = useDomainViewFilters(); const {projects} = useProjects(); const {selection} = usePageFilters(); 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, }); navigate({ 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, }); navigate({ pathname: location.pathname, query: { ...location.query, responseCodeClass: newResponseCodeClass.value, }, }); }; const isPanelOpen = Boolean(detailKey); const ADDITONAL_FILTERS = { 'span.domain': query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain), transaction: query.transaction, ...(query[SpanMetricsField.USER_GEO_SUBREGION].length > 0 ? { [SpanMetricsField.USER_GEO_SUBREGION]: `[${query[SpanMetricsField.USER_GEO_SUBREGION].join(',')}]`, } : {}), }; // The ribbon is above the data selectors, and not affected by them. So, it has its own filters. const ribbonFilters: SpanMetricsQueryFilters = { ...BASE_FILTERS, ...ADDITONAL_FILTERS, ...new MutableSearch(query.spanSearchQuery).filters, }; // These filters are for the charts and samples tables const filters: SpanMetricsQueryFilters = { ...BASE_FILTERS, ...ADDITONAL_FILTERS, ...new MutableSearch(query.spanSearchQuery).filters, }; 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, sorts: [ { kind: 'desc', field: 'count()', }, ], enabled: isPanelOpen && query.panel === 'status', referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART, }); const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]); const { data: durationSamplesData, isFetching: isDurationSamplesDataFetching, error: durationSamplesDataError, refetch: refetchDurationSpanSamples, } = useSpanSamples({ search, 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, } = useSpansIndexed( { search, 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.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES ); const sampledSpanDataSeries = useSampleScatterPlotSeries( durationSamplesData, domainTransactionMetrics?.[0]?.['avg(span.self_time)'], highlightedSpanId ); const handleSearch = (newSpanSearchQuery: string) => { navigate({ pathname: location.pathname, query: { ...location.query, spanSearchQuery: newSpanSearchQuery, }, }); if (query.panel === 'duration') { refetchDurationSpanSamples(); } else { refetchResponseCodeSpanSamples(); } }; const handleClose = () => { navigate({ pathname: location.pathname, query: { ...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={`${getTransactionSummaryBaseUrl(organization.slug, view)}?${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} filters={filters} /> )} {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: {}, }} referrer={TraceViewSources.REQUESTS_MODULE} /> )} {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'), }, ]; // TODO - copy of static/app/views/starfish/views/spanSummaryPage/sampleList/index.tsx const HeaderContainer = styled('div')` display: grid; grid-template-rows: auto auto auto; align-items: center; @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-rows: auto; grid-template-columns: auto 1fr; } `; const Title = styled('h4')` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0; `; const PanelControls = styled('div')` display: flex; justify-content: space-between; gap: ${space(2)}; `;