diff --git a/static/app/views/explore/hooks/useMetricOptions.spec.tsx b/static/app/views/explore/hooks/useMetricOptions.spec.tsx index 9d56b9e4eccd46..786eb3253819ed 100644 --- a/static/app/views/explore/hooks/useMetricOptions.spec.tsx +++ b/static/app/views/explore/hooks/useMetricOptions.spec.tsx @@ -50,9 +50,9 @@ describe('useMetricOptions', () => { it('fetches metric options from tracemetrics dataset', async () => { const mockData = { data: [ + {['metric.name']: 'metric.c', ['metric.type']: 'counter'}, {['metric.name']: 'metric.a', ['metric.type']: 'distribution'}, {['metric.name']: 'metric.b', ['metric.type']: 'distribution'}, - {['metric.name']: 'metric.c', ['metric.type']: 'counter'}, ], }; @@ -63,9 +63,8 @@ describe('useMetricOptions', () => { match: [ MockApiClient.matchQuery({ dataset: DiscoverDatasets.TRACEMETRICS, - field: ['metric.name', 'metric.type', 'count(metric.name)'], + field: ['metric.name', 'metric.type', 'metric.unit', 'count(metric.name)'], referrer: 'api.explore.metric-options', - orderby: 'metric.name', }), ], }); @@ -86,9 +85,13 @@ describe('useMetricOptions', () => { ); }); - it('sorts metrics alphabetically by name', () => { + it('sorts metrics alphabetically by name', async () => { const mockData = { - data: [], + data: [ + {['metric.name']: 'metric.z', ['metric.type']: 'counter'}, + {['metric.name']: 'metric.a', ['metric.type']: 'distribution'}, + {['metric.name']: 'metric.m', ['metric.type']: 'gauge'}, + ], }; const mockRequest = MockApiClient.addMockResponse({ @@ -97,19 +100,31 @@ describe('useMetricOptions', () => { body: mockData, }); - renderHookWithProviders(useMetricOptions, { + const {result} = renderHookWithProviders(useMetricOptions, { ...context, }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockRequest).toHaveBeenCalledTimes(1); expect(mockRequest).toHaveBeenCalledWith( `/organizations/${organization.slug}/events/`, expect.objectContaining({ query: expect.objectContaining({ - orderby: 'metric.name', + dataset: 'tracemetrics', + field: ['metric.name', 'metric.type', 'metric.unit', 'count(metric.name)'], + referrer: 'api.explore.metric-options', }), }) ); + + await waitFor(() => + expect(result.current.data?.data).toEqual([ + {['metric.name']: 'metric.a', ['metric.type']: 'distribution'}, + {['metric.name']: 'metric.m', ['metric.type']: 'gauge'}, + {['metric.name']: 'metric.z', ['metric.type']: 'counter'}, + ]) + ); }); it('handles empty response', async () => { diff --git a/static/app/views/explore/hooks/useMetricOptions.tsx b/static/app/views/explore/hooks/useMetricOptions.tsx index 5cdf368b3fd36a..cc7cd85c4c9ded 100644 --- a/static/app/views/explore/hooks/useMetricOptions.tsx +++ b/static/app/views/explore/hooks/useMetricOptions.tsx @@ -1,54 +1,62 @@ -import {useMemo} from 'react'; +import {useEffect, useMemo} from 'react'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import type {PageFilters} from 'sentry/types/core'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; - -interface EventsMetricResult { - data: Array<{ - ['metric.name']: string; - ['metric.type']: string; - }>; - meta?: { - fields?: Record; - }; -} +import { + TraceMetricKnownFieldKey, + type TraceMetricEventsResult, +} from 'sentry/views/explore/metrics/types'; interface UseMetricOptionsProps { datetime?: PageFilters['datetime']; enabled?: boolean; + orgSlug?: string; projectIds?: PageFilters['projects']; + search?: string; } function metricOptionsQueryKey({ orgSlug, projectIds, datetime, -}: { - orgSlug: string; - datetime?: PageFilters['datetime']; - projectIds?: number[]; -}): ApiQueryKey { + search, +}: UseMetricOptionsProps = {}): ApiQueryKey { + const searchValue = new MutableSearch(''); + if (search) { + searchValue.addStringContainsFilter( + `${TraceMetricKnownFieldKey.METRIC_NAME}:${search}` + ); + } const query: Record = { dataset: DiscoverDatasets.TRACEMETRICS, - field: ['metric.name', 'metric.type', 'count(metric.name)'], + field: [ + TraceMetricKnownFieldKey.METRIC_NAME, + TraceMetricKnownFieldKey.METRIC_TYPE, + TraceMetricKnownFieldKey.METRIC_UNIT, + `count(${TraceMetricKnownFieldKey.METRIC_NAME})`, + ], + query: searchValue.formatString(), referrer: 'api.explore.metric-options', - orderby: 'metric.name', }; if (projectIds?.length) { query.project = projectIds.map(String); } - if (datetime) { + if (search && datetime) { + // If searching we use the full filters in order to not miss the result. Object.entries(normalizeDateTimeParams(datetime)).forEach(([key, value]) => { if (value !== undefined) { query[key] = value as string | string[]; } }); + } else { + query.statsPeriod = '24h'; // Default to a much smaller time window if not searching. } return [`/organizations/${orgSlug}/events/`, {query}]; @@ -59,6 +67,7 @@ function metricOptionsQueryKey({ * This is used to populate metric selection options in the Explore interface. */ export function useMetricOptions({ + search, projectIds, datetime, enabled = true, @@ -69,18 +78,39 @@ export function useMetricOptions({ const queryKey = useMemo( () => metricOptionsQueryKey({ + search, orgSlug: organization.slug, projectIds: projectIds ?? selection.projects, datetime: datetime ?? selection.datetime, }), - [organization.slug, projectIds, selection.projects, selection.datetime, datetime] + [ + organization.slug, + projectIds, + search, + selection.projects, + selection.datetime, + datetime, + ] ); - return useApiQuery(queryKey, { + const result = useApiQuery(queryKey, { staleTime: 5 * 60 * 1000, // Cache for 5 minutes refetchOnWindowFocus: false, refetchOnMount: false, retry: false, enabled, }); + + // This replaces order-by metric.name as that will never be performant over large time periods with high numbers of metrics. + useEffect(() => { + if (result.data?.data) { + result.data.data.sort((a, b) => { + return a[TraceMetricKnownFieldKey.METRIC_NAME].localeCompare( + b[TraceMetricKnownFieldKey.METRIC_NAME] + ); + }); + } + }, [result.data]); + + return result; } diff --git a/static/app/views/explore/metrics/constants.tsx b/static/app/views/explore/metrics/constants.tsx index 8fd09847687e36..f65f3b03b601a8 100644 --- a/static/app/views/explore/metrics/constants.tsx +++ b/static/app/views/explore/metrics/constants.tsx @@ -1,6 +1,7 @@ import type {SelectOption} from 'sentry/components/core/compactSelect'; import { TraceMetricKnownFieldKey, + VirtualTableSampleColumnKey, type TraceMetricFieldKey, } from 'sentry/views/explore/metrics/types'; @@ -13,6 +14,17 @@ const AlwaysHiddenTraceMetricFields: TraceMetricFieldKey[] = [ TraceMetricKnownFieldKey.OLD_PROJECT_ID, ]; +export const AlwaysPresentTraceMetricFields: TraceMetricFieldKey[] = [ + TraceMetricKnownFieldKey.ID, + TraceMetricKnownFieldKey.PROJECT_ID, + TraceMetricKnownFieldKey.TRACE, + TraceMetricKnownFieldKey.SPAN_ID, + TraceMetricKnownFieldKey.OLD_SPAN_ID, + TraceMetricKnownFieldKey.METRIC_TYPE, + TraceMetricKnownFieldKey.METRIC_NAME, + TraceMetricKnownFieldKey.TIMESTAMP, +]; + /** * These are fields that should be hidden in metric details view when receiving all data from the API. */ @@ -26,6 +38,7 @@ export const HiddenTraceMetricDetailFields: TraceMetricFieldKey[] = [ TraceMetricKnownFieldKey.TRACE_FLAGS, TraceMetricKnownFieldKey.METRIC_NAME, TraceMetricKnownFieldKey.METRIC_TYPE, + TraceMetricKnownFieldKey.CLIENT_SAMPLE_RATE, ]; export const HiddenTraceMetricSearchFields: TraceMetricFieldKey[] = [ @@ -38,6 +51,31 @@ export const HiddenTraceMetricGroupByFields: TraceMetricFieldKey[] = [ ...HiddenTraceMetricSearchFields, ]; +export const TraceSamplesTableStatColumns: VirtualTableSampleColumnKey[] = [ + VirtualTableSampleColumnKey.LOGS, + VirtualTableSampleColumnKey.SPANS, + VirtualTableSampleColumnKey.ERRORS, +]; + +export const TraceSamplesTableColumns: Array< + TraceMetricFieldKey | VirtualTableSampleColumnKey +> = [ + VirtualTableSampleColumnKey.EXPAND_ROW, + TraceMetricKnownFieldKey.TIMESTAMP, + TraceMetricKnownFieldKey.TRACE, + ...TraceSamplesTableStatColumns, + TraceMetricKnownFieldKey.METRIC_VALUE, +]; + +export const TraceSamplesTableEmbeddedColumns: Array< + TraceMetricFieldKey | VirtualTableSampleColumnKey +> = [ + VirtualTableSampleColumnKey.EXPAND_ROW, + TraceMetricKnownFieldKey.TIMESTAMP, + TraceMetricKnownFieldKey.METRIC_NAME, + TraceMetricKnownFieldKey.METRIC_VALUE, +]; + export const OPTIONS_BY_TYPE: Record>> = { counter: [ { diff --git a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx index 271aec05200466..0c962ec35cd708 100644 --- a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx @@ -51,7 +51,6 @@ describe('useMetricSamplesTable', () => { fields: [], limit: 100, ingestionDelaySeconds: 0, - enabled: true, }), { additionalWrapper: MockMetricQueryParamsContext, diff --git a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx index b8fe337a7947cc..136a4af6c55c08 100644 --- a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx @@ -1,46 +1,186 @@ import {useCallback, useMemo} from 'react'; import moment from 'moment-timezone'; -import type {PageFilters} from 'sentry/types/core'; -import type {NewQuery} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; -import EventView from 'sentry/utils/discover/eventView'; +import type {EventsMetaType} from 'sentry/utils/discover/eventView'; +import type EventView from 'sentry/utils/discover/eventView'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; +import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; import type {RPCQueryExtras} from 'sentry/views/explore/hooks/useProgressiveQuery'; -import {useProgressiveQuery} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import { + SAMPLING_MODE, + useProgressiveQuery, +} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {AlwaysPresentTraceMetricFields} from 'sentry/views/explore/metrics/constants'; import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import { + useMetricsFrozenSearch, + useMetricsFrozenTracePeriod, +} from 'sentry/views/explore/metrics/metricsFrozenContext'; +import type {TraceMetricEventsResponseItem} from 'sentry/views/explore/metrics/types'; import { useQueryParamsQuery, useQueryParamsSortBys, } from 'sentry/views/explore/queryParams/context'; -import { - DATE_FORMAT, - useSpansQuery, -} from 'sentry/views/insights/common/queries/useSpansQuery'; +import {getEventView} from 'sentry/views/insights/common/queries/useDiscover'; +import {getStaleTimeForEventView} from 'sentry/views/insights/common/queries/useSpansQuery'; import {INGESTION_DELAY} from 'sentry/views/insights/settings'; +const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ'; const MILLISECONDS_PER_SECOND = 1000; interface UseMetricSamplesTableOptions { - enabled: boolean; fields: string[]; limit: number; - traceMetric: TraceMetric; + disabled?: boolean; ingestionDelaySeconds?: number; queryExtras?: RPCQueryExtras; + traceMetric?: TraceMetric; } interface MetricSamplesTableResult { - eventView: EventView; + result: { + data: TraceMetricEventsResponseItem[] | undefined; + isFetched: boolean; + isFetching: boolean; + meta?: EventsMetaType; + }; + error?: Error; + eventView?: EventView; + isError?: boolean; + isFetching?: boolean; + isPending?: boolean; + meta?: EventsMetaType; +} + +function useMetricsQueryKey({ + limit, + traceMetric: _traceMetric, + fields, + ingestionDelaySeconds = INGESTION_DELAY, + referrer, + queryExtras, +}: { fields: string[]; - result: ReturnType>; + limit: number; + referrer: string; + ingestionDelaySeconds?: number; + queryExtras?: RPCQueryExtras; + traceMetric?: TraceMetric; +}) { + const organization = useOrganization(); + const query = useQueryParamsQuery(); + const frozenSearch = useMetricsFrozenSearch(); + const frozenTracePeriod = useMetricsFrozenTracePeriod(); + const sortBys = useQueryParamsSortBys(); + const {selection, isReady: pageFiltersReady} = usePageFilters(); + const location = useLocation(); + + const fieldsToUse = useMemo( + () => Array.from(new Set([...AlwaysPresentTraceMetricFields, ...fields])), + [fields] + ); + const queryString = useMemo(() => { + const queryStr = query; + const frozenSearchStr = frozenSearch?.formatString() ?? ''; + + const parts = [frozenSearchStr, queryStr].filter(Boolean); + + if (parts.length === 0) { + return ''; + } + if (parts.length === 1) { + return parts[0]; + } + + return parts.join(' '); + }, [query, frozenSearch]); + + const adjustedDatetime = useMemo(() => { + const baseDatetime = frozenTracePeriod + ? { + start: frozenTracePeriod.start ?? null, + end: frozenTracePeriod.end ?? null, + period: frozenTracePeriod.period ?? null, + utc: selection.datetime.utc, + } + : selection.datetime; + + const {start, end, period, utc} = baseDatetime; + + const periodMs = period ? intervalToMilliseconds(period) : 0; + if (period && periodMs > ingestionDelaySeconds * MILLISECONDS_PER_SECOND && !end) { + const startTime = moment().subtract(periodMs, 'milliseconds'); + const delayedEndTime = moment().subtract(ingestionDelaySeconds, 'seconds'); + + return { + start: startTime.format(DATE_FORMAT), + end: delayedEndTime.format(DATE_FORMAT), + period: null, + utc, + }; + } + + return {start, end, period, utc}; + }, [selection.datetime, frozenTracePeriod, ingestionDelaySeconds]); + + const pageFilters = { + ...selection, + datetime: adjustedDatetime, + }; + const dataset = DiscoverDatasets.TRACEMETRICS; + + const eventView = getEventView( + queryString, + fieldsToUse, + sortBys.slice(), + pageFilters, + dataset, + pageFilters.projects + ); + + if (queryString) { + eventView.query = queryString; + } + + const eventViewPayload = eventView.getEventsAPIPayload(location); + + const orderby = sortBys.map(formatSort); + + const params = { + query: { + ...eventViewPayload, + orderby: orderby.length > 0 ? orderby : undefined, + per_page: limit, + referrer, + sampling: queryExtras?.samplingMode ?? SAMPLING_MODE.NORMAL, + caseInsensitive: queryExtras?.caseInsensitive, + disableAggregateExtrapolation: queryExtras?.disableAggregateExtrapolation + ? '1' + : undefined, + }, + pageFiltersReady, + eventView, + }; + + const queryKey: ApiQueryKey = [`/organizations/${organization.slug}/events/`, params]; + + return { + queryKey, + other: { + eventView, + pageFiltersReady, + }, + }; } export function useMetricSamplesTable({ - enabled, + disabled, limit, traceMetric, fields, @@ -48,7 +188,7 @@ export function useMetricSamplesTable({ queryExtras, }: UseMetricSamplesTableOptions) { const canTriggerHighAccuracy = useCallback( - (result: ReturnType['result']) => { + (result: MetricSamplesTableResult['result']) => { const canGoToHigherAccuracyTier = result.meta?.dataScanned === 'partial'; const hasData = defined(result.data) && result.data.length > 0; return !hasData && canGoToHigherAccuracyTier; @@ -59,7 +199,7 @@ export function useMetricSamplesTable({ return useProgressiveQuery({ queryHookImplementation: useMetricSamplesTableImpl, queryHookArgs: { - enabled, + enabled: !disabled, limit, traceMetric, fields, @@ -82,63 +222,33 @@ function useMetricSamplesTableImpl({ fields, ingestionDelaySeconds = INGESTION_DELAY, queryExtras, -}: UseMetricSamplesTableOptions): MetricSamplesTableResult { - const {selection} = usePageFilters(); - const query = useQueryParamsQuery(); - const sortBys = useQueryParamsSortBys(); - - // Calculate adjusted datetime values with ingestion delay applied - // This is memoized separately to prevent recalculating on every render - const adjustedDatetime: PageFilters['datetime'] = useMemo(() => { - const {start, end, period, utc} = selection.datetime; - - // Only apply delay for relative time periods (statsPeriod), not absolute timestamps - // because absolute timestamps are explicitly set by the user - const periodMs = period ? intervalToMilliseconds(period) : 0; - if (period && periodMs > ingestionDelaySeconds * MILLISECONDS_PER_SECOND && !end) { - const startTime = moment().subtract(periodMs, 'milliseconds'); - const delayedEndTime = moment().subtract(ingestionDelaySeconds, 'seconds'); - - return { - start: startTime.format(DATE_FORMAT), - end: delayedEndTime.format(DATE_FORMAT), - period: null, // Clear period since we now have explicit timestamps - utc, - }; - } - - return {start, end, period, utc}; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selection.datetime.period, ingestionDelaySeconds]); // Only recalc when period or delay changes - - const eventView = useMemo(() => { - const discoverQuery: NewQuery = { - id: undefined, - name: 'Explore - Metric Samples', - fields, - orderby: sortBys.map(formatSort), - query, - version: 2, - dataset: DiscoverDatasets.TRACEMETRICS, - }; - - return EventView.fromNewQueryWithPageFilters(discoverQuery, { - ...selection, - datetime: adjustedDatetime, - }); - }, [fields, query, selection, adjustedDatetime, sortBys]); - - const result = useSpansQuery({ - enabled: enabled && Boolean(traceMetric.name), - eventView, - initialData: [], +}: UseMetricSamplesTableOptions & {enabled: boolean}): MetricSamplesTableResult { + const {queryKey, other} = useMetricsQueryKey({ limit, + traceMetric, + fields, + ingestionDelaySeconds, referrer: 'api.explore.metric-samples-table', - trackResponseAnalytics: false, queryExtras, }); - return useMemo(() => { - return {eventView, fields, result}; - }, [eventView, fields, result]); + const result = useApiQuery<{data: any[]; meta?: EventsMetaType}>(queryKey, { + enabled, + staleTime: getStaleTimeForEventView(other.eventView), + }); + + return { + error: result.error ?? undefined, + isError: result.isError, + isFetching: result.isFetching, + isPending: result.isPending, + meta: result.data?.meta, + result: { + data: result.data?.data, + isFetched: result.isFetched, + isFetching: result.isFetching, + meta: result.data?.meta, + }, + eventView: other?.eventView, + }; } diff --git a/static/app/views/explore/metrics/hooks/useMetricTraceDetail.tsx b/static/app/views/explore/metrics/hooks/useMetricTraceDetail.tsx index 47b6a2c104eca4..73f31575e2e0c9 100644 --- a/static/app/views/explore/metrics/hooks/useMetricTraceDetail.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricTraceDetail.tsx @@ -1,4 +1,3 @@ -import usePageFilters from 'sentry/utils/usePageFilters'; import {useTraceItemDetails} from 'sentry/views/explore/hooks/useTraceItemDetails'; import {TraceItemDataset} from 'sentry/views/explore/types'; @@ -8,13 +7,12 @@ export function useMetricTraceDetail(props: { traceId: string; enabled?: boolean; }) { - const {isReady: pageFiltersReady} = usePageFilters(); return useTraceItemDetails({ traceItemId: String(props.metricId), projectId: props.projectId, traceId: props.traceId, traceItemType: TraceItemDataset.TRACEMETRICS, referrer: 'api.explore.metric-trace-detail', - enabled: props.enabled && pageFiltersReady, + enabled: props.enabled, }); } diff --git a/static/app/views/explore/metrics/hooks/useTraceTelemetry.tsx b/static/app/views/explore/metrics/hooks/useTraceTelemetry.tsx index 3db0ea462a26be..7141a5b0919682 100644 --- a/static/app/views/explore/metrics/hooks/useTraceTelemetry.tsx +++ b/static/app/views/explore/metrics/hooks/useTraceTelemetry.tsx @@ -8,6 +8,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; +import {useMetricsFrozenTracePeriod} from 'sentry/views/explore/metrics/metricsFrozenContext'; import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery'; interface UseTraceTelemetryOptions { @@ -33,8 +34,24 @@ export function useTraceTelemetry({ }: UseTraceTelemetryOptions): TraceTelemetryResult { const organization = useOrganization(); const {selection} = usePageFilters(); + const frozenTracePeriod = useMetricsFrozenTracePeriod(); const location = useLocation(); + const pageFilters = useMemo(() => { + if (frozenTracePeriod) { + return { + ...selection, + datetime: { + start: frozenTracePeriod.start ?? null, + end: frozenTracePeriod.end ?? null, + period: frozenTracePeriod.period ?? null, + utc: selection.datetime.utc, + }, + }; + } + return selection; + }, [selection, frozenTracePeriod]); + // Query for error count const errorsEventView = useMemo(() => { const traceFilter = new MutableSearch('').addFilterValueList('trace', traceIds); @@ -47,8 +64,8 @@ export function useTraceTelemetry({ version: 2, dataset: DiscoverDatasets.ERRORS, }; - return EventView.fromNewQueryWithPageFilters(discoverQuery, selection); - }, [traceIds, selection]); + return EventView.fromNewQueryWithPageFilters(discoverQuery, pageFilters); + }, [traceIds, pageFilters]); const errorsResult = useDiscoverQuery({ eventView: errorsEventView, @@ -75,8 +92,8 @@ export function useTraceTelemetry({ dataset: DiscoverDatasets.SPANS, }; - return EventView.fromNewQueryWithPageFilters(discoverQuery, selection); - }, [traceIds, selection]); + return EventView.fromNewQueryWithPageFilters(discoverQuery, pageFilters); + }, [traceIds, pageFilters]); const spansResult = useSpansQuery({ enabled: enabled && spansEventView !== null, @@ -101,8 +118,8 @@ export function useTraceTelemetry({ dataset: DiscoverDatasets.OURLOGS, }; - return EventView.fromNewQueryWithPageFilters(discoverQuery, selection); - }, [traceIds, selection]); + return EventView.fromNewQueryWithPageFilters(discoverQuery, pageFilters); + }, [traceIds, pageFilters]); const logsResult = useSpansQuery({ enabled: enabled && logsEventView !== null, diff --git a/static/app/views/explore/metrics/metricInfoTabs/aggregatesTab.tsx b/static/app/views/explore/metrics/metricInfoTabs/aggregatesTab.tsx index c61c3a3752f16e..550a96764d9598 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/aggregatesTab.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/aggregatesTab.tsx @@ -205,10 +205,9 @@ export function AggregatesTab({traceMetric}: AggregatesTabProps) { {fields.map((field, j) => ( ` justify-content: ${p => (p.isSticky ? 'flex-end' : 'flex-start')}; - padding: 0 4px; + padding: ${p => (p.noPadding ? 0 : p.theme.space.lg)}; + padding-top: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + padding-bottom: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; ${p => p.isSticky && css` @@ -267,9 +268,13 @@ const StickyCompatibleStyledHeaderCell = styled(StyledSimpleTableHeaderCell)<{ const StickyCompatibleStyledRowCell = styled(StyledSimpleTableRowCell)<{ isSticky: boolean; - offset: string; + offset?: string; }>` - padding-left: ${p => p.offset}; + ${p => + p.offset && + css` + padding-left: ${p.offset}; + `} ${p => p.isSticky && css` diff --git a/static/app/views/explore/metrics/metricInfoTabs/samplesTab/metricDetails.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricDetails.tsx similarity index 86% rename from static/app/views/explore/metrics/metricInfoTabs/samplesTab/metricDetails.tsx rename to static/app/views/explore/metrics/metricInfoTabs/metricDetails.tsx index 5d2bb83f957435..9be6eb4e565963 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/samplesTab/metricDetails.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricDetails.tsx @@ -22,6 +22,7 @@ import { DetailsContent, MetricsDetailsWrapper, } from 'sentry/views/explore/metrics/metricInfoTabs/metricInfoTabStyles'; +import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; export function MetricDetails({ dataRow, @@ -36,11 +37,13 @@ export function MetricDetails({ const getActions = useLogAttributesTreeActions({embedded: false}); const traceDetailResult = useMetricTraceDetail({ - metricId: String(dataRow.id ?? ''), - projectId: String(dataRow['project.id'] ?? ''), - traceId: String(dataRow.trace ?? ''), + metricId: String(dataRow[TraceMetricKnownFieldKey.ID] ?? ''), + projectId: String(dataRow[TraceMetricKnownFieldKey.PROJECT_ID] ?? ''), + traceId: String(dataRow[TraceMetricKnownFieldKey.TRACE] ?? ''), enabled: - defined(dataRow.id) && defined(dataRow['project.id']) && defined(dataRow.trace), + defined(dataRow[TraceMetricKnownFieldKey.ID]) && + defined(dataRow[TraceMetricKnownFieldKey.PROJECT_ID]) && + defined(dataRow[TraceMetricKnownFieldKey.TRACE]), }); const {data, isPending, isError} = traceDetailResult; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx index 461bde56c811ab..ee583c53efbfb1 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx @@ -51,21 +51,37 @@ export const WrappingText = styled('div')` align-items: center; `; -export const ExpandedRowContainer = styled('div')` +export const ExpandedRowContainer = styled('div')<{embedded?: boolean}>` grid-column: 1 / -1; border-bottom: 1px solid ${p => p.theme.innerBorder}; + + ${p => + p.embedded && + css` + padding: ${p.theme.space.xs} ${p.theme.space.sm}; + `} `; export const StyledSimpleTableRowCell = styled(SimpleTable.RowCell)<{ - hasPadding?: boolean; + embedded?: boolean; + noPadding?: boolean; }>` - padding: ${p => (p.hasPadding ? p.theme.space.xs : 0)}; + padding: ${p => (p.noPadding ? 0 : p.theme.space.lg)}; + padding-top: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + padding-bottom: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + font-size: ${p => p.theme.fontSize.sm}; `; -export const StyledSimpleTableHeaderCell = styled(SimpleTable.HeaderCell)` +export const StyledSimpleTableHeaderCell = styled(SimpleTable.HeaderCell)<{ + noPadding?: boolean; +}>` font-size: ${p => p.theme.fontSize.sm}; + padding: ${p => (p.noPadding ? 0 : p.theme.space.lg)}; + padding-top: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + padding-bottom: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; `; + export const StyledSimpleTableBody = styled('div')` position: relative; overflow-y: auto; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx new file mode 100644 index 00000000000000..6e00fe0fb929e1 --- /dev/null +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx @@ -0,0 +1,138 @@ +import {useMemo} from 'react'; +import styled from '@emotion/styled'; + +import EmptyStateWarning from 'sentry/components/emptyStateWarning'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import {IconWarning} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import { + TraceSamplesTableColumns, + TraceSamplesTableEmbeddedColumns, +} from 'sentry/views/explore/metrics/constants'; +import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; +import {useTraceTelemetry} from 'sentry/views/explore/metrics/hooks/useTraceTelemetry'; +import { + StyledSimpleTable, + StyledSimpleTableBody, + TransparentLoadingMask, +} from 'sentry/views/explore/metrics/metricInfoTabs/metricInfoTabStyles'; +import {MetricsSamplesTableHeader} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader'; +import {SampleTableRow} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow'; +import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; +import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils'; + +const RESULT_LIMIT = 50; +const TWO_MINUTE_DELAY = 120; +const MAX_TELEMETRY_WIDTH = 40; + +export const SAMPLES_PANEL_MIN_WIDTH = 350; + +interface MetricsSamplesTableProps { + embedded?: boolean; + traceMetric?: TraceMetric; +} + +export function MetricsSamplesTable({ + traceMetric, + embedded = false, +}: MetricsSamplesTableProps) { + const columns = embedded ? TraceSamplesTableEmbeddedColumns : TraceSamplesTableColumns; + const fields = columns.filter(c => getMetricTableColumnType(c) !== 'stat'); + + const { + result: {data}, + meta = {fields: {}, units: {}}, + error, + isFetching, + } = useMetricSamplesTable({ + disabled: embedded ? false : !traceMetric?.name, + limit: RESULT_LIMIT, + traceMetric, + fields, + ingestionDelaySeconds: TWO_MINUTE_DELAY, + }); + + const traceIds = useMemo(() => { + if (!data || embedded) { + return []; + } + return data.map(row => row[TraceMetricKnownFieldKey.TRACE]).filter(Boolean); + }, [data, embedded]); + + const {data: telemetryData} = useTraceTelemetry({ + enabled: Boolean(traceMetric?.name) && traceIds.length > 0 && !embedded, + traceIds, + }); + + return ( + + {isFetching && } + + + {error ? ( + + + + ) : data?.length ? ( + data.map((row, i) => ( + + )) + ) : isFetching ? ( + + + + ) : ( + + +

{t('No samples found')}

+
+
+ )} +
+
+ ); +} + +const SimpleTableWithHiddenColumns = styled(StyledSimpleTable)<{ + embedded: boolean; + numColumns: number; +}>` + grid-template-columns: repeat(${p => p.numColumns}, min-content) 1fr; + + ${p => + !p.embedded && + ` + @container (max-width: ${SAMPLES_PANEL_MIN_WIDTH + MAX_TELEMETRY_WIDTH * 3}px) { + grid-template-columns: repeat(${p.numColumns - 1}, min-content) 1fr; + + [data-column-name='errors'] { + display: none; + } + } + + @container (max-width: ${SAMPLES_PANEL_MIN_WIDTH + MAX_TELEMETRY_WIDTH * 2}px) { + grid-template-columns: repeat(${p.numColumns - 2}, min-content) 1fr; + + [data-column-name='spans'] { + display: none; + } + } + + @container (max-width: ${SAMPLES_PANEL_MIN_WIDTH + MAX_TELEMETRY_WIDTH * 1}px) { + grid-template-columns: repeat(${p.numColumns - 3}, min-content) 1fr; + + [data-column-name='logs'] { + display: none; + } + } + `} +`; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx new file mode 100644 index 00000000000000..dd82e829aaf754 --- /dev/null +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx @@ -0,0 +1,124 @@ +import type {ReactNode} from 'react'; + +import {Tooltip} from 'sentry/components/core/tooltip'; +import {IconFire, IconSpan, IconTerminal} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import { + NumericSimpleTableHeaderCell, + StyledSimpleTableHeader, + StyledSimpleTableHeaderCell, +} from 'sentry/views/explore/metrics/metricInfoTabs/metricInfoTabStyles'; +import { + TraceMetricKnownFieldKey, + VirtualTableSampleColumnKey, + type SampleTableColumnKey, +} from 'sentry/views/explore/metrics/types'; +import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils'; +import {useQueryParamsSortBys} from 'sentry/views/explore/queryParams/context'; + +const ICON_HEADERS = { + [VirtualTableSampleColumnKey.LOGS]: , + [VirtualTableSampleColumnKey.SPANS]: , + [VirtualTableSampleColumnKey.ERRORS]: , +}; + +interface MetricsSamplesTableHeaderProps { + columns: SampleTableColumnKey[]; +} + +export function MetricsSamplesTableHeader({columns}: MetricsSamplesTableHeaderProps) { + const sorts = useQueryParamsSortBys(); + + return ( + + {columns.map((field, i) => { + const columnType = getMetricTableColumnType(field); + const label = getFieldLabel(field); + + return ( + s.field === field)?.kind} + > + {columnType === 'stat' + ? ICON_HEADERS[field as keyof typeof ICON_HEADERS] + : null} + {columnType === 'metric_value' ? label : null} + {columnType === 'value' ? label : null} + + ); + })} + + ); +} + +function FieldHeaderCellWrapper({ + field, + children, + index, + sort, +}: { + children: ReactNode; + field: SampleTableColumnKey; + index: number; + sort?: 'asc' | 'desc'; +}) { + const columnType = getMetricTableColumnType(field); + const label = getFieldLabel(field); + const hasPadding = field !== VirtualTableSampleColumnKey.EXPAND_ROW; + + if (columnType === 'stat') { + return ( + + + {children} + + + ); + } + + if (columnType === 'metric_value') { + return ( + + + {children} + + + ); + } + + return ( + + + {children} + + + ); +} + +function getFieldLabel(field: SampleTableColumnKey): ReactNode { + const fieldLabels: Record ReactNode> = { + [VirtualTableSampleColumnKey.EXPAND_ROW]: () => null, + [TraceMetricKnownFieldKey.TRACE]: () => t('Trace'), + [TraceMetricKnownFieldKey.METRIC_VALUE]: () => t('Value'), + [TraceMetricKnownFieldKey.TIMESTAMP]: () => t('Timestamp'), + [TraceMetricKnownFieldKey.METRIC_NAME]: () => t('Metric'), + [VirtualTableSampleColumnKey.LOGS]: () => t('Logs'), + [VirtualTableSampleColumnKey.SPANS]: () => t('Spans'), + [VirtualTableSampleColumnKey.ERRORS]: () => t('Errors'), + }; + return fieldLabels[field]?.() ?? null; +} diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx new file mode 100644 index 00000000000000..657f5f036821ba --- /dev/null +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx @@ -0,0 +1,328 @@ +import {useRef, useState, type ReactNode} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {Button} from 'sentry/components/core/button'; +import {Link} from 'sentry/components/core/link'; +import {Tooltip} from 'sentry/components/core/tooltip'; +import Count from 'sentry/components/count'; +import {IconChevron} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; +import type {EventsMetaType} from 'sentry/utils/discover/eventView'; +import type {ColumnValueType} from 'sentry/utils/discover/fields'; +import {getShortEventId} from 'sentry/utils/events'; +import {FieldValueType} from 'sentry/utils/fields'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import type {TableColumn} from 'sentry/views/discover/table/types'; +import {TimestampRenderer} from 'sentry/views/explore/logs/fieldRenderers'; +import {getLogColors} from 'sentry/views/explore/logs/styles'; +import {SeverityLevel} from 'sentry/views/explore/logs/utils'; +import {useTraceTelemetry} from 'sentry/views/explore/metrics/hooks/useTraceTelemetry'; +import {MetricDetails} from 'sentry/views/explore/metrics/metricInfoTabs/metricDetails'; +import { + ExpandedRowContainer, + NumericSimpleTableRowCell, + StickyTableRow, + StyledSimpleTableRowCell, + WrappingText, +} from 'sentry/views/explore/metrics/metricInfoTabs/metricInfoTabStyles'; +import {stripMetricParamsFromLocation} from 'sentry/views/explore/metrics/metricQuery'; +import {MetricTypeBadge} from 'sentry/views/explore/metrics/metricToolbar/metricSelector'; +import { + TraceMetricKnownFieldKey, + VirtualTableSampleColumnKey, + type SampleTableColumnKey, + type TraceMetricEventsResponseItem, + type TraceMetricTypeValue, +} from 'sentry/views/explore/metrics/types'; +import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils'; +import {FieldRenderer} from 'sentry/views/explore/tables/fieldRenderer'; +import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs'; +import {TraceLayoutTabKeys} from 'sentry/views/performance/newTraceDetails/useTraceLayoutTabs'; +import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils'; + +const MAX_TELEMETRY_WIDTH = 40; +const VALUE_COLUMN_MIN_WIDTH = '50px'; +interface SampleTableRowProps { + columns: SampleTableColumnKey[]; + meta: EventsMetaType; + row: TraceMetricEventsResponseItem; + telemetryData: ReturnType['data']; + embedded?: boolean; + ref?: (element: HTMLElement | null) => void; +} + +function FieldCellWrapper({ + field, + row, + children, + index, + embedded = false, +}: { + children: ReactNode; + field: SampleTableColumnKey; + index: number; + row: TraceMetricEventsResponseItem; + embedded?: boolean; +}) { + const columnType = getMetricTableColumnType(field); + const hasPadding = field !== VirtualTableSampleColumnKey.EXPAND_ROW; + if (columnType === 'stat') { + return ( + + {children} + + ); + } + if (columnType === 'metric_value') { + return ( + + + {children} + + + ); + } + return ( + + {children} + + ); +} + +export function SampleTableRow({ + row, + telemetryData, + columns, + meta, + embedded = false, + ref, +}: SampleTableRowProps) { + const organization = useOrganization(); + const {selection} = usePageFilters(); + const location = useLocation(); + const theme = useTheme(); + const [isExpanded, setIsExpanded] = useState(false); + const measureRef = useRef(null); + + const traceId = row[TraceMetricKnownFieldKey.TRACE]; + const telemetry = telemetryData?.get?.(traceId); + + const renderExpandRowCell = () => { + return ( +