diff --git a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx index c4566aff9..ee3fa28fe 100644 --- a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx +++ b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx @@ -13,7 +13,7 @@ import type { QueryType, TimeSeriesQueryDefinition } from '@perses-dev/spec'; import type { ReactElement } from 'react'; -import { createContext, useCallback, useContext, useMemo } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; import type { AlertsQueryDefinition } from '../alerts-queries'; import { useAlertsQueries } from '../alerts-queries'; @@ -123,18 +123,6 @@ export function DataQueriesProvider(props: DataQueriesProviderProps): ReactEleme ...transformQueryResults(jsonResults, jsonQueries), ]; - if (queryOptions?.enabled) { - for (const result of mergedQueryResults) { - if (!result.isLoading && !result.isFetching && !result.error) { - usageMetrics.markQuery(result.definition, 'success'); - } else if (result.error) { - usageMetrics.markQuery(result.definition, 'error'); - } else { - usageMetrics.markQuery(result.definition, 'pending'); - } - } - } - return { queryDefinitions: definitions, queryResults: mergedQueryResults, @@ -159,10 +147,24 @@ export function DataQueriesProvider(props: DataQueriesProviderProps): ReactEleme traceQueries, traceResults, definitions, - queryOptions?.enabled, refetchAll, - usageMetrics, ]); + useEffect(() => { + if (queryOptions?.enabled) { + // Register the complete batch before completing any cached query. + for (const result of ctx.queryResults) { + usageMetrics.markQuery(result.definition, 'pending'); + } + for (const result of ctx.queryResults) { + if (result.error) { + usageMetrics.markQuery(result.definition, 'error'); + } else if (!result.isLoading && !result.isFetching) { + usageMetrics.markQuery(result.definition, 'success'); + } + } + } + }, [ctx.queryResults, queryOptions?.enabled, usageMetrics]); + return {children}; } diff --git a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.usage-metrics.test.tsx b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.usage-metrics.test.tsx new file mode 100644 index 000000000..661633a9b --- /dev/null +++ b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.usage-metrics.test.tsx @@ -0,0 +1,102 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FetchProvider } from '@perses-dev/client'; +import type { QueryDefinition } from '@perses-dev/spec'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { StrictMode, Suspense } from 'react'; + +import { UsageMetricsContext, UsageMetricsProvider } from '../UsageMetricsProvider'; +import { DataQueriesProvider } from './DataQueriesProvider'; + +const mocks = vi.hoisted(() => ({ + timeSeriesResults: [] as unknown[], + noResults: [] as unknown[], +})); + +vi.mock('../time-series-queries', () => ({ useTimeSeriesQueries: (): unknown[] => mocks.timeSeriesResults })); +vi.mock('../trace-queries', () => ({ useTraceQueries: (): unknown[] => mocks.noResults })); +vi.mock('../profile-queries', () => ({ useProfileQueries: (): unknown[] => mocks.noResults })); +vi.mock('../log-queries', () => ({ useLogQueries: (): unknown[] => mocks.noResults })); +vi.mock('../alerts-queries', () => ({ useAlertsQueries: (): unknown[] => mocks.noResults })); +vi.mock('../silences-queries', () => ({ useSilencesQueries: (): unknown[] => mocks.noResults })); +vi.mock('../json-queries', () => ({ useJsonQueries: (): unknown[] => mocks.noResults })); + +const cachedQuery: QueryDefinition = { kind: 'TimeSeriesQuery', spec: { plugin: { kind: 'Cached', spec: {} } } }; +const slowQuery: QueryDefinition = { kind: 'TimeSeriesQuery', spec: { plugin: { kind: 'Slow', spec: {} } } }; +const definitions = [cachedQuery, slowQuery]; +const queryOptions = { enabled: true }; + +const done = { data: {}, isFetching: false, isLoading: false, error: null }; +const loading = { data: undefined, isFetching: true, isLoading: true, error: null }; + +const markQuery = vi.fn(); +const usageMetrics = { project: 'project', dashboard: 'dashboard', markQuery }; +const neverResolves = new Promise(() => undefined); +function Suspend(): ReactElement { + throw neverResolves; +} + +describe('DataQueriesProvider usage metrics', () => { + afterEach(() => { + mocks.timeSeriesResults = []; + vi.restoreAllMocks(); + }); + + it('waits for the whole batch when a cached query completes first', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000); + const fetchFn = vi.fn().mockResolvedValue({ ok: true }); + function Dashboard(): ReactElement { + return ( + + + + + + + + ); + } + + mocks.timeSeriesResults = [done, loading]; + const { rerender } = render(); + expect(fetchFn).not.toHaveBeenCalled(); + + mocks.timeSeriesResults = [done, { ...done, data: undefined, error: new Error('boom') }]; + rerender(); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn).toHaveBeenCalledWith( + '/api/v1/view', + expect.objectContaining({ + body: JSON.stringify({ project: 'project', dashboard: 'dashboard', render_time: 0, render_errors: 1 }), + }), + ); + }); + + it('does not record queries from a render that is never committed', () => { + markQuery.mockClear(); + mocks.timeSeriesResults = [done, loading]; + render( + + + + + + + , + ); + + expect(markQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/plugin-system/src/runtime/UsageMetricsProvider.test.tsx b/plugin-system/src/runtime/UsageMetricsProvider.test.tsx new file mode 100644 index 000000000..92dd401e7 --- /dev/null +++ b/plugin-system/src/runtime/UsageMetricsProvider.test.tsx @@ -0,0 +1,108 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FetchProvider } from '@perses-dev/client'; +import type { QueryDefinition } from '@perses-dev/spec'; +import { act, renderHook } from '@testing-library/react'; +import type { PropsWithChildren, ReactElement } from 'react'; +import { StrictMode, useMemo } from 'react'; + +import { UsageMetricsContext, UsageMetricsProvider, useUsageMetrics } from './UsageMetricsProvider'; + +const query: QueryDefinition = { kind: 'TimeSeriesQuery', spec: { plugin: { kind: 'TestQuery', spec: {} } } }; +const secondQuery: QueryDefinition = { ...query, spec: { plugin: { kind: 'OtherQuery', spec: {} } } }; + +describe('UsageMetricsProvider', () => { + it('keeps pending queries across rerenders and submits only once, including zero-duration renders', () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: true }); + vi.spyOn(Date, 'now').mockReturnValue(1000); + function Wrapper({ children }: PropsWithChildren): ReactElement { + return ( + + + + {children} + + + + ); + } + try { + const { result, rerender } = renderHook(() => useUsageMetrics(), { wrapper: Wrapper }); + expect(fetchFn).not.toHaveBeenCalled(); + act(() => { + result.current.markQuery(query, 'pending'); + result.current.markQuery(secondQuery, 'pending'); + result.current.markQuery(query, 'success'); + }); + rerender(); + expect(fetchFn).not.toHaveBeenCalled(); + act(() => result.current.markQuery(secondQuery, 'error')); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn).toHaveBeenCalledWith( + '/prefix/api/v1/view', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ project: 'project', dashboard: 'dashboard', render_time: 0, render_errors: 1 }), + }), + ); + act(() => { + result.current.markQuery(secondQuery, 'pending'); + result.current.markQuery(secondQuery, 'success'); + }); + expect(fetchFn).toHaveBeenCalledTimes(1); + } finally { + vi.restoreAllMocks(); + } + }); + + it('logs a warning when the metrics cannot be submitted', async () => { + const error = new Error('network down'); + const fetchFn = vi.fn().mockRejectedValue(error); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + function Wrapper({ children }: PropsWithChildren): ReactElement { + return ( + + + {children} + + + ); + } + try { + const { result } = renderHook(() => useUsageMetrics(), { wrapper: Wrapper }); + await act(async () => result.current.markQuery(query, 'success')); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('Failed to submit dashboard usage metrics', error); + } finally { + vi.restoreAllMocks(); + } + }); + + it('supports directly supplied context values', () => { + const markQuery = vi.fn(); + const stats = { project: 'project', dashboard: 'dashboard', markQuery }; + function Wrapper({ children }: PropsWithChildren): ReactElement { + const value = useMemo(() => stats, []); + return {children}; + } + const { result } = renderHook(() => useUsageMetrics(), { wrapper: Wrapper }); + act(() => result.current.markQuery(query, 'success')); + expect(markQuery).toHaveBeenCalledWith(query, 'success'); + }); + + it('is safe without a provider', () => { + const { result } = renderHook(() => useUsageMetrics()); + expect(() => result.current.markQuery(query, 'success')).not.toThrow(); + }); +}); diff --git a/plugin-system/src/runtime/UsageMetricsProvider.tsx b/plugin-system/src/runtime/UsageMetricsProvider.tsx index 5e6fb2e93..8aa6e3d3f 100644 --- a/plugin-system/src/runtime/UsageMetricsProvider.tsx +++ b/plugin-system/src/runtime/UsageMetricsProvider.tsx @@ -15,19 +15,15 @@ import type { FetchFn } from '@perses-dev/client'; import { useFetch } from '@perses-dev/client'; import type { QueryDefinition } from '@perses-dev/spec'; import type { ReactElement, ReactNode } from 'react'; -import { createContext, useContext, useState } from 'react'; +import { createContext, useContext, useCallback, useMemo, useRef, useState } from 'react'; type QueryState = 'pending' | 'success' | 'error'; interface UsageMetrics { project: string; dashboard: string; - startRenderTime: number; - renderDurationMs: number; - renderErrorCount: number; - pendingQueries: Map; apiPrefix?: string; - fetchFn: FetchFn; + markQuery: (definition: QueryDefinition, state: QueryState) => void; } interface UsageMetricsProps { @@ -41,47 +37,40 @@ interface UseUsageMetricsResults { markQuery: (definition: QueryDefinition, state: QueryState) => void; } +interface RenderMetrics { + submitted: boolean; + renderErrorCount: number; + pendingQueries: Map; +} + +interface SubmitMetricsParams { + project: string; + dashboard: string; + apiPrefix?: string; + renderDurationMs: number; + renderErrorCount: number; + fetchFn: FetchFn; +} + export const UsageMetricsContext = createContext(undefined); export const useUsageMetricsContext = (): UsageMetrics | undefined => { return useContext(UsageMetricsContext); }; +/** Records query transitions after commit or from an event handler. No-op without a provider. */ export const useUsageMetrics = (): UseUsageMetricsResults => { const ctx = useUsageMetricsContext(); - - return { - markQuery: (definition: QueryDefinition, newState: QueryState): void => { - if (ctx === undefined) { - return; - } - recordQuery(ctx, definition, newState); + const markQuery = useCallback( + (definition: QueryDefinition, state: QueryState): void => { + ctx?.markQuery(definition, state); }, - }; + [ctx], + ); + return useMemo(() => ({ markQuery }), [markQuery]); }; -function recordQuery(ctx: UsageMetrics, definition: QueryDefinition, newState: QueryState): void { - const definitionKey = JSON.stringify(definition); - if (ctx.pendingQueries.has(definitionKey) && newState === 'pending') { - // Never allow transitions back to pending, to avoid re-sending stats on a re-render. - return; - } - - if (ctx.pendingQueries.get(definitionKey) !== newState) { - ctx.pendingQueries.set(definitionKey, newState); - if (newState === 'error') { - ctx.renderErrorCount += 1; - } - - const allDone = [...ctx.pendingQueries.values()].every((p) => p !== 'pending'); - if (ctx.renderDurationMs === 0 && allDone) { - ctx.renderDurationMs = Date.now() - ctx.startRenderTime; - submitMetrics(ctx); - } - } -} - -const submitMetrics = async (stats: UsageMetrics): Promise => { +const submitMetrics = async (stats: SubmitMetricsParams): Promise => { await stats.fetchFn(`${stats.apiPrefix ?? ''}/api/v1/view`, { method: 'POST', headers: { @@ -96,20 +85,53 @@ const submitMetrics = async (stats: UsageMetrics): Promise => { }); }; -export const UsageMetricsProvider = ({ apiPrefix, project, dashboard, children }: UsageMetricsProps): ReactElement => { +export const UsageMetricsProvider = (props: UsageMetricsProps): ReactElement => { + // Remounting on project/dashboard changes starts a fresh render measurement. + return ; +}; + +function UsageMetricsSession({ apiPrefix, project, dashboard, children }: UsageMetricsProps): ReactElement { const { fetch } = useFetch(); const [startRenderTime] = useState(() => Date.now()); - - const ctx: UsageMetrics = { - project: project, - dashboard: dashboard, + const metricsRef = useRef({ + submitted: false, renderErrorCount: 0, - startRenderTime, - renderDurationMs: 0, pendingQueries: new Map(), - apiPrefix, - fetchFn: fetch, - }; + }); + + const markQuery = useCallback( + (definition: QueryDefinition, newState: QueryState): void => { + const metrics = metricsRef.current; + // Metrics are sent once per dashboard, so later transitions have nothing left to record. + if (metrics.submitted) return; + const definitionKey = JSON.stringify(definition); + // Never allow transitions back to pending, to avoid re-sending stats on a re-render. + if (metrics.pendingQueries.has(definitionKey) && newState === 'pending') return; + if (metrics.pendingQueries.get(definitionKey) === newState) return; + metrics.pendingQueries.set(definitionKey, newState); + if (newState === 'error') metrics.renderErrorCount += 1; + const allDone = [...metrics.pendingQueries.values()].every((state) => state !== 'pending'); + if (allDone) { + metrics.submitted = true; + submitMetrics({ + project, + dashboard, + apiPrefix, + renderDurationMs: Date.now() - startRenderTime, + renderErrorCount: metrics.renderErrorCount, + fetchFn: fetch, + }).catch((error: unknown) => { + console.warn('Failed to submit dashboard usage metrics', error); + }); + } + }, + [project, dashboard, startRenderTime, apiPrefix, fetch], + ); + + const ctx = useMemo( + () => ({ project, dashboard, apiPrefix, markQuery }), + [project, dashboard, apiPrefix, markQuery], + ); return {children}; -}; +}