diff --git a/static/app/views/explore/hooks/useExploreTimeseries.tsx b/static/app/views/explore/hooks/useExploreTimeseries.tsx index 94f02727a9e83f..8df774f3d17cd6 100644 --- a/static/app/views/explore/hooks/useExploreTimeseries.tsx +++ b/static/app/views/explore/hooks/useExploreTimeseries.tsx @@ -124,7 +124,7 @@ function useExploreTimeseriesImpl({ }; } -function shouldTriggerHighAccuracy( +export function shouldTriggerHighAccuracy( data: ReturnType['data'], visualizes: readonly Visualize[], isTopN: boolean diff --git a/static/app/views/explore/metrics/hooks/testUtils.tsx b/static/app/views/explore/metrics/hooks/testUtils.tsx new file mode 100644 index 00000000000000..c9bf04cf05f020 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/testUtils.tsx @@ -0,0 +1,22 @@ +import type {ReactNode} from 'react'; + +import {defaultMetricQuery} from 'sentry/views/explore/metrics/metricQuery'; +import {MetricsQueryParamsProvider} from 'sentry/views/explore/metrics/metricsQueryParams'; +import {MultiMetricsQueryParamsProvider} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; + +export function MockMetricQueryParamsContext({children}: {children: ReactNode}) { + const mockQueryParams = defaultMetricQuery(); + return ( + + {}} + setTraceMetric={() => {}} + removeMetric={() => {}} + > + {children} + + + ); +} diff --git a/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.spec.tsx b/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.spec.tsx new file mode 100644 index 00000000000000..a8a06ae81e121a --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.spec.tsx @@ -0,0 +1,78 @@ +import {PageFilterStateFixture} from 'sentry-fixture/pageFilters'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import usePageFilters from 'sentry/utils/usePageFilters'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; +import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMetricAggregatesTable'; + +jest.mock('sentry/utils/usePageFilters'); + +describe('useMetricAggregatesTable', () => { + beforeEach(() => { + jest.mocked(usePageFilters).mockReturnValue(PageFilterStateFixture()); + jest.clearAllMocks(); + }); + + it('triggers the high accuracy request when there is no data and a partial scan', async () => { + const mockNormalRequestUrl = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: { + data: [], + meta: { + dataScanned: 'partial', + fields: {}, + }, + }, + method: 'GET', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.NORMAL; + }, + ], + }); + const mockHighAccuracyRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.HIGH_ACCURACY; + }, + ], + method: 'GET', + }); + renderHookWithProviders( + () => + useMetricAggregatesTable({ + metricName: 'test metric', + limit: 100, + enabled: true, + }), + { + additionalWrapper: MockMetricQueryParamsContext, + } + ); + + expect(mockNormalRequestUrl).toHaveBeenCalledTimes(1); + expect(mockNormalRequestUrl).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + + await waitFor(() => { + expect(mockHighAccuracyRequest).toHaveBeenCalledTimes(1); + }); + expect(mockHighAccuracyRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.HIGH_ACCURACY, + }), + }) + ); + }); +}); diff --git a/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.tsx b/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.tsx index b16263300337a6..1acf9d0ea0f52a 100644 --- a/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricAggregatesTable.tsx @@ -1,11 +1,16 @@ -import {useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; import type {NewQuery} from 'sentry/types/organization'; +import {defined} from 'sentry/utils'; import EventView from 'sentry/utils/discover/eventView'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import usePageFilters from 'sentry/utils/usePageFilters'; import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; +import { + useProgressiveQuery, + type RPCQueryExtras, +} from 'sentry/views/explore/hooks/useProgressiveQuery'; import {useMetricVisualize} from 'sentry/views/explore/metrics/metricsQueryParams'; import { useQueryParamsAggregateSortBys, @@ -18,6 +23,7 @@ interface UseMetricAggregatesTableOptions { enabled: boolean; limit: number; metricName: string; + queryExtras?: RPCQueryExtras; } interface MetricAggregatesTableResult { @@ -30,14 +36,30 @@ export function useMetricAggregatesTable({ enabled, limit, metricName, + queryExtras, }: UseMetricAggregatesTableOptions) { - return useMetricAggregatesTableImp({enabled, limit, metricName}); + const canTriggerHighAccuracy = useCallback( + (result: ReturnType['result']) => { + const canGoToHigherAccuracyTier = result.meta?.dataScanned === 'partial'; + const hasData = defined(result.data) && result.data.length > 0; + return !hasData && canGoToHigherAccuracyTier; + }, + [] + ); + return useProgressiveQuery({ + queryHookImplementation: useMetricAggregatesTableImp, + queryHookArgs: {enabled, limit, metricName, queryExtras}, + queryOptions: { + canTriggerHighAccuracy, + }, + }); } function useMetricAggregatesTableImp({ enabled, limit, metricName, + queryExtras, }: UseMetricAggregatesTableOptions): MetricAggregatesTableResult { const {selection} = usePageFilters(); const visualize = useMetricVisualize(); @@ -92,6 +114,7 @@ function useMetricAggregatesTableImp({ limit, referrer: 'api.explore.metric-aggregates-table', trackResponseAnalytics: false, + queryExtras, }); return useMemo(() => { diff --git a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx new file mode 100644 index 00000000000000..3c2d49f599f662 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.spec.tsx @@ -0,0 +1,80 @@ +import {PageFilterStateFixture} from 'sentry-fixture/pageFilters'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import usePageFilters from 'sentry/utils/usePageFilters'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; +import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; + +jest.mock('sentry/utils/usePageFilters'); + +describe('useMetricSamplesTable', () => { + beforeEach(() => { + jest.mocked(usePageFilters).mockReturnValue(PageFilterStateFixture()); + jest.clearAllMocks(); + }); + + it('triggers the high accuracy request when there is no data and a partial scan', async () => { + const mockNormalRequestUrl = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: { + data: [], + meta: { + dataScanned: 'partial', + fields: {}, + }, + }, + method: 'GET', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.NORMAL; + }, + ], + }); + const mockHighAccuracyRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.HIGH_ACCURACY; + }, + ], + method: 'GET', + }); + renderHookWithProviders( + () => + useMetricSamplesTable({ + metricName: 'test metric', + fields: [], + limit: 100, + ingestionDelaySeconds: 0, + enabled: true, + }), + { + additionalWrapper: MockMetricQueryParamsContext, + } + ); + + expect(mockNormalRequestUrl).toHaveBeenCalledTimes(1); + expect(mockNormalRequestUrl).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + + await waitFor(() => { + expect(mockHighAccuracyRequest).toHaveBeenCalledTimes(1); + }); + expect(mockHighAccuracyRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.HIGH_ACCURACY, + }), + }) + ); + }); +}); diff --git a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx index d35570da91b9ad..9d3c9bf332ab7f 100644 --- a/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricSamplesTable.tsx @@ -1,14 +1,17 @@ -import {useMemo} from 'react'; +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 {DiscoverDatasets} from 'sentry/utils/discover/types'; import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; 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 { useQueryParamsSearch, useQueryParamsSortBys, @@ -27,6 +30,7 @@ interface UseMetricSamplesTableOptions { limit: number; metricName: string; ingestionDelaySeconds?: number; + queryExtras?: RPCQueryExtras; } interface MetricSamplesTableResult { @@ -41,22 +45,40 @@ export function useMetricSamplesTable({ metricName, fields, ingestionDelaySeconds, + queryExtras, }: UseMetricSamplesTableOptions) { - return useMetricSamplesTableImp({ - enabled, - limit, - metricName, - fields, - ingestionDelaySeconds, + const canTriggerHighAccuracy = useCallback( + (result: ReturnType['result']) => { + const canGoToHigherAccuracyTier = result.meta?.dataScanned === 'partial'; + const hasData = defined(result.data) && result.data.length > 0; + return !hasData && canGoToHigherAccuracyTier; + }, + [] + ); + + return useProgressiveQuery({ + queryHookImplementation: useMetricSamplesTableImpl, + queryHookArgs: { + enabled, + limit, + metricName, + fields, + ingestionDelaySeconds, + queryExtras, + }, + queryOptions: { + canTriggerHighAccuracy, + }, }); } -function useMetricSamplesTableImp({ +function useMetricSamplesTableImpl({ enabled, limit, metricName, fields, ingestionDelaySeconds = INGESTION_DELAY, + queryExtras, }: UseMetricSamplesTableOptions): MetricSamplesTableResult { const {selection} = usePageFilters(); const searchQuery = useQueryParamsSearch(); @@ -118,6 +140,7 @@ function useMetricSamplesTableImp({ limit, referrer: 'api.explore.metric-samples-table', trackResponseAnalytics: false, + queryExtras, }); return useMemo(() => { diff --git a/static/app/views/explore/metrics/hooks/useMetricTimeseries.spec.tsx b/static/app/views/explore/metrics/hooks/useMetricTimeseries.spec.tsx new file mode 100644 index 00000000000000..680c8120629506 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricTimeseries.spec.tsx @@ -0,0 +1,82 @@ +import {PageFilterStateFixture} from 'sentry-fixture/pageFilters'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import usePageFilters from 'sentry/utils/usePageFilters'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; +import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries'; + +jest.mock('sentry/utils/usePageFilters'); + +describe('useMetricTimeseries', () => { + beforeEach(() => { + jest.mocked(usePageFilters).mockReturnValue(PageFilterStateFixture()); + jest.clearAllMocks(); + }); + + it('triggers the high accuracy request when there is no data and a partial scan', async () => { + const mockNormalRequestUrl = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: { + data: [[1745371800, [{count: 0}]]], + meta: { + dataScanned: 'partial', + accuracy: { + confidence: [], + sampleCount: [], + samplingRate: [], + }, + fields: {}, + }, + }, + method: 'GET', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.NORMAL; + }, + ], + }); + const mockHighAccuracyRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + match: [ + function (_url: string, options: Record) { + return options.query.sampling === SAMPLING_MODE.HIGH_ACCURACY; + }, + ], + method: 'GET', + }); + renderHookWithProviders( + () => + useMetricTimeseries({ + traceMetric: {name: 'test metric', type: 'counter'}, + enabled: true, + }), + { + additionalWrapper: MockMetricQueryParamsContext, + } + ); + + expect(mockNormalRequestUrl).toHaveBeenCalledTimes(1); + expect(mockNormalRequestUrl).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + + await waitFor(() => { + expect(mockHighAccuracyRequest).toHaveBeenCalledTimes(1); + }); + expect(mockHighAccuracyRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + sampling: SAMPLING_MODE.HIGH_ACCURACY, + }), + }) + ); + }); +}); diff --git a/static/app/views/explore/metrics/hooks/useMetricTimeseries.tsx b/static/app/views/explore/metrics/hooks/useMetricTimeseries.tsx new file mode 100644 index 00000000000000..5212c306d410cf --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricTimeseries.tsx @@ -0,0 +1,88 @@ +import {useCallback, useMemo} from 'react'; + +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; +import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; +import {shouldTriggerHighAccuracy} from 'sentry/views/explore/hooks/useExploreTimeseries'; +import { + useProgressiveQuery, + type RPCQueryExtras, +} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; +import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import {useMetricVisualize} from 'sentry/views/explore/metrics/metricsQueryParams'; +import { + useQueryParamsAggregateSortBys, + useQueryParamsGroupBys, + useQueryParamsSearch, +} from 'sentry/views/explore/queryParams/context'; +import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; + +interface UseMetricTimeseriesOptions { + enabled: boolean; + traceMetric: TraceMetric; + queryExtras?: RPCQueryExtras; +} + +export function useMetricTimeseries({ + traceMetric, + queryExtras, + enabled, +}: UseMetricTimeseriesOptions) { + const visualize = useMetricVisualize(); + const topEvents = useTopEvents(); + const canTriggerHighAccuracy = useCallback( + (result: ReturnType['result']) => { + return shouldTriggerHighAccuracy(result.data, [visualize], !!topEvents); + }, + [visualize, topEvents] + ); + return useProgressiveQuery({ + queryHookImplementation: useMetricTimeseriesImpl, + queryHookArgs: {traceMetric, queryExtras, enabled}, + queryOptions: { + canTriggerHighAccuracy, + }, + }); +} + +function useMetricTimeseriesImpl({ + traceMetric, + queryExtras, + enabled, +}: UseMetricTimeseriesOptions) { + const visualize = useMetricVisualize(); + const groupBys = useQueryParamsGroupBys(); + const [interval] = useChartInterval(); + const topEvents = useTopEvents(); + const searchQuery = useQueryParamsSearch(); + const sortBys = useQueryParamsAggregateSortBys(); + + const search = useMemo(() => { + const currentSearch = new MutableSearch(`metric.name:${traceMetric.name}`); + if (!searchQuery.isEmpty()) { + currentSearch.addStringFilter(searchQuery.formatString()); + } + return currentSearch; + }, [traceMetric.name, searchQuery]); + + const timeseriesResult = useSortedTimeSeries( + { + search, + yAxis: [visualize.yAxis], + interval, + fields: [...groupBys, visualize.yAxis], + enabled: enabled && Boolean(traceMetric.name), + topEvents, + orderby: sortBys.map(formatSort), + ...queryExtras, + }, + 'api.explore.metrics-stats', + DiscoverDatasets.TRACEMETRICS + ); + + return { + result: timeseriesResult, + }; +} diff --git a/static/app/views/explore/metrics/metricGraph.tsx b/static/app/views/explore/metrics/metricGraph.tsx index a1af43ca6f3930..7148c1065ee505 100644 --- a/static/app/views/explore/metrics/metricGraph.tsx +++ b/static/app/views/explore/metrics/metricGraph.tsx @@ -129,7 +129,7 @@ function Graph({onChartTypeChange, timeseriesResult, queryIndex, visualize}: Gra // We explicitly only want to show the confidence footer if we have // scanned partial data. const showConfidenceFooter = - chartInfo.dataScanned !== 'full' && !timeseriesResult.isLoading; + chartInfo.dataScanned !== 'full' && !timeseriesResult.isPending; return ( ) } diff --git a/static/app/views/explore/metrics/metricPanel.tsx b/static/app/views/explore/metrics/metricPanel.tsx index 61c192c17e294a..d5c49cda1cb1bd 100644 --- a/static/app/views/explore/metrics/metricPanel.tsx +++ b/static/app/views/explore/metrics/metricPanel.tsx @@ -1,24 +1,13 @@ -import {useMemo, useRef} from 'react'; +import {useRef} from 'react'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import SplitPanel from 'sentry/components/splitPanel'; -import {DiscoverDatasets} from 'sentry/utils/discover/types'; -import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useDimensions} from 'sentry/utils/useDimensions'; -import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; -import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; -import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; +import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries'; import {MetricsGraph} from 'sentry/views/explore/metrics/metricGraph'; import MetricInfoTabs from 'sentry/views/explore/metrics/metricInfoTabs'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; -import {useMetricVisualize} from 'sentry/views/explore/metrics/metricsQueryParams'; -import { - useQueryParamsAggregateSortBys, - useQueryParamsGroupBys, - useQueryParamsSearch, -} from 'sentry/views/explore/queryParams/context'; -import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; interface MetricPanelProps { queryIndex: number; @@ -29,36 +18,13 @@ const MIN_LEFT_WIDTH = 400; const MIN_RIGHT_WIDTH = 400; export function MetricPanel({traceMetric, queryIndex}: MetricPanelProps) { - const visualize = useMetricVisualize(); - const groupBys = useQueryParamsGroupBys(); const measureRef = useRef(null); const {width} = useDimensions({elementRef: measureRef}); - const [interval] = useChartInterval(); - const topEvents = useTopEvents(); - const searchQuery = useQueryParamsSearch(); - const sortBys = useQueryParamsAggregateSortBys(); - const search = useMemo(() => { - const currentSearch = new MutableSearch(`metric.name:${traceMetric.name}`); - if (!searchQuery.isEmpty()) { - currentSearch.addStringFilter(searchQuery.formatString()); - } - return currentSearch; - }, [traceMetric.name, searchQuery]); - - const timeseriesResult = useSortedTimeSeries( - { - search, - yAxis: [visualize.yAxis], - interval, - fields: [...groupBys, visualize.yAxis], - enabled: Boolean(traceMetric.name), - topEvents, - orderby: sortBys.map(formatSort), - }, - 'api.explore.metrics-stats', - DiscoverDatasets.TRACEMETRICS - ); + const {result: timeseriesResult} = useMetricTimeseries({ + traceMetric, + enabled: Boolean(traceMetric.name), + }); const hasSize = width > 0; // Default split is 60% of the available width, but not less than MIN_LEFT_WIDTH. diff --git a/static/app/views/insights/common/queries/useSpansQuery.tsx b/static/app/views/insights/common/queries/useSpansQuery.tsx index e8283284de4878..b8c6c46bd91b8a 100644 --- a/static/app/views/insights/common/queries/useSpansQuery.tsx +++ b/static/app/views/insights/common/queries/useSpansQuery.tsx @@ -268,7 +268,11 @@ function useWrappedDiscoverQueryBase({ const organization = useOrganization(); const queryExtras: Record = {}; - if (eventView.dataset === DiscoverDatasets.SPANS) { + if ( + [DiscoverDatasets.SPANS, DiscoverDatasets.TRACEMETRICS].includes( + eventView.dataset as DiscoverDatasets + ) + ) { if (samplingMode) { queryExtras.sampling = samplingMode; }