From 75afa5330fad92b6cb7ad60a5bcb7dd5a3f16b8e Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 23 Sep 2026 23:30:04 +0200 Subject: [PATCH 1/3] [ENHANCEMENT] Keep usage metrics state across renders Own the render metrics accumulator in the provider, submit metrics only once per dashboard, and record query states after commit instead of during render. Signed-off-by: Guillaume LADORME --- .../DataQueriesProvider.tsx | 34 +++--- .../src/runtime/UsageMetricsProvider.test.tsx | 85 +++++++++++++ .../src/runtime/UsageMetricsProvider.tsx | 112 ++++++++++-------- 3 files changed, 169 insertions(+), 62 deletions(-) create mode 100644 plugin-system/src/runtime/UsageMetricsProvider.test.tsx diff --git a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx index c4566aff9..b7f215109 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,26 @@ 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.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'); + } + } + } + }, [ctx.queryResults, queryOptions?.enabled, usageMetrics]); + return {children}; } diff --git a/plugin-system/src/runtime/UsageMetricsProvider.test.tsx b/plugin-system/src/runtime/UsageMetricsProvider.test.tsx new file mode 100644 index 000000000..e6eb87f2d --- /dev/null +++ b/plugin-system/src/runtime/UsageMetricsProvider.test.tsx @@ -0,0 +1,85 @@ +// 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('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..104ee470a 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,49 @@ 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; + 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 (!metrics.submitted && allDone) { + metrics.submitted = true; + void submitMetrics({ + project, + dashboard, + apiPrefix, + renderDurationMs: Date.now() - startRenderTime, + renderErrorCount: metrics.renderErrorCount, + fetchFn: fetch, + }); + } + }, + [project, dashboard, startRenderTime, apiPrefix, fetch], + ); + + const ctx = useMemo( + () => ({ project, dashboard, apiPrefix, markQuery }), + [project, dashboard, apiPrefix, markQuery], + ); return {children}; -}; +} From 4232b1c6e85e11f39a291bb8bfb6fe0306e74288 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Fri, 2 Oct 2026 16:58:17 +0200 Subject: [PATCH 2/3] [ENHANCEMENT] Skip usage metrics work after submission and warn on failure Return early from markQuery once metrics are submitted, log a warning when the submission request fails, and cover the DataQueriesProvider recording (batch registration, no recording from uncommitted renders). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guillaume LADORME --- ...DataQueriesProvider.usage-metrics.test.tsx | 102 ++++++++++++++++++ .../src/runtime/UsageMetricsProvider.test.tsx | 23 ++++ .../src/runtime/UsageMetricsProvider.tsx | 8 +- 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.usage-metrics.test.tsx 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 index e6eb87f2d..92dd401e7 100644 --- a/plugin-system/src/runtime/UsageMetricsProvider.test.tsx +++ b/plugin-system/src/runtime/UsageMetricsProvider.test.tsx @@ -66,6 +66,29 @@ describe('UsageMetricsProvider', () => { } }); + 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 }; diff --git a/plugin-system/src/runtime/UsageMetricsProvider.tsx b/plugin-system/src/runtime/UsageMetricsProvider.tsx index 104ee470a..8aa6e3d3f 100644 --- a/plugin-system/src/runtime/UsageMetricsProvider.tsx +++ b/plugin-system/src/runtime/UsageMetricsProvider.tsx @@ -102,6 +102,8 @@ function UsageMetricsSession({ apiPrefix, project, dashboard, children }: UsageM 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; @@ -109,15 +111,17 @@ function UsageMetricsSession({ apiPrefix, project, dashboard, children }: UsageM metrics.pendingQueries.set(definitionKey, newState); if (newState === 'error') metrics.renderErrorCount += 1; const allDone = [...metrics.pendingQueries.values()].every((state) => state !== 'pending'); - if (!metrics.submitted && allDone) { + if (allDone) { metrics.submitted = true; - void submitMetrics({ + 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); }); } }, From b327a51bbbf7e5adcd3ffddad03605ab31fc8209 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 27 Oct 2026 09:38:54 +0100 Subject: [PATCH 3/3] [ENHANCEMENT] Drop redundant pending mark in DataQueriesProvider Every result is already registered as pending before the completion loop, and markQuery ignores repeated states, so only success and error need handling there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guillaume LADORME --- .../runtime/DataQueriesProvider/DataQueriesProvider.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx index b7f215109..ee3fa28fe 100644 --- a/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx +++ b/plugin-system/src/runtime/DataQueriesProvider/DataQueriesProvider.tsx @@ -157,12 +157,10 @@ export function DataQueriesProvider(props: DataQueriesProviderProps): ReactEleme usageMetrics.markQuery(result.definition, 'pending'); } for (const result of ctx.queryResults) { - if (!result.isLoading && !result.isFetching && !result.error) { - usageMetrics.markQuery(result.definition, 'success'); - } else if (result.error) { + if (result.error) { usageMetrics.markQuery(result.definition, 'error'); - } else { - usageMetrics.markQuery(result.definition, 'pending'); + } else if (!result.isLoading && !result.isFetching) { + usageMetrics.markQuery(result.definition, 'success'); } } }