Feature: Add panel performance metrics to dashboard

This commit adds panel performance metrics to the dashboard.
This commit is contained in:
David Kaltschmidt
2025-12-05 16:43:30 +01:00
parent 7cd10aa49e
commit 6291c18ba1
7 changed files with 254 additions and 5 deletions
@@ -13,6 +13,7 @@ import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { panelLinksBehavior } from './PanelMenuBehavior';
import { PanelNotices } from './PanelNotices';
import { PanelPerformanceMetrics } from './PanelPerformanceMetrics';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
@@ -64,6 +65,7 @@ export class LibraryPanelBehavior extends SceneObjectBase<LibraryPanelBehaviorSt
})
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
let title;
if (config.featureToggles.preferLibraryPanelTitle) {
@@ -0,0 +1,191 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Icon, PanelChrome, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import {
getDashboardAnalyticsAggregator,
PanelAnalyticsMetrics,
} from '../../dashboard/services/DashboardAnalyticsAggregator';
import { getPanelIdForVizPanel } from '../utils/utils';
interface PanelPerformanceMetricsState extends SceneObjectState {
metrics?: PanelAnalyticsMetrics;
}
export class PanelPerformanceMetrics extends SceneObjectBase<PanelPerformanceMetricsState> {
static Component = PanelPerformanceMetricsRenderer;
constructor() {
super({});
this.addActivationHandler(this.onActivate);
}
private onActivate = () => {
const panel = this.parent;
if (!panel || !(panel instanceof VizPanel)) {
throw new Error('PanelPerformanceMetrics can be used only as title items for VizPanel');
}
const panelId = getPanelIdForVizPanel(panel);
if (!panelId) {
return;
}
const aggregator = getDashboardAnalyticsAggregator();
const panelIdStr = String(panelId);
// Subscribe to metrics updates - callback will be called with initial metrics if available
this._subs.add(
aggregator.subscribeToPanelMetrics(panelIdStr, (updatedMetrics) => {
this.setState({ metrics: updatedMetrics });
})
);
};
public getPanel() {
const panel = this.parent;
if (panel && panel instanceof VizPanel) {
return panel;
}
return null;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
function PanelPerformanceMetricsRenderer({ model }: SceneComponentProps<PanelPerformanceMetrics>) {
const panel = model.getPanel();
const styles = useStyles2(getStyles);
const { metrics } = model.useState();
const [fakeQueryTime, setFakeQueryTime] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number | null>(null);
// Get last operation times (most recent operation in each array)
const lastQueryTime =
metrics && metrics.queryOperations.length > 0
? metrics.queryOperations[metrics.queryOperations.length - 1].duration
: 0;
const lastRenderTime =
metrics && metrics.renderOperations.length > 0
? metrics.renderOperations[metrics.renderOperations.length - 1].duration
: 0;
const lastTransformTime =
metrics && metrics.transformationOperations.length > 0
? metrics.transformationOperations[metrics.transformationOperations.length - 1].duration
: 0;
// Manage fake timer for query time
useEffect(() => {
// If we have a real query time, stop the fake timer
if (lastQueryTime > 0) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setFakeQueryTime(0);
startTimeRef.current = null;
return;
}
// If no query operations yet, start the fake timer
if (metrics && metrics.queryOperations.length === 0) {
// Start timer if not already running
if (!intervalRef.current) {
startTimeRef.current = Date.now();
intervalRef.current = setInterval(() => {
if (startTimeRef.current) {
setFakeQueryTime(Date.now() - startTimeRef.current);
}
}, 166); // Update every 166ms for smooth counting
}
}
// Cleanup on unmount
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [lastQueryTime, metrics]);
// Use fake query time if real one is 0
const displayQueryTime = lastQueryTime > 0 ? lastQueryTime : fakeQueryTime;
const lastTotalTime = displayQueryTime + lastRenderTime + lastTransformTime;
// Don't render if panel or metrics are not available (and no fake timer running)
if (!panel || (!metrics && fakeQueryTime === 0)) {
return null;
}
// Show component if we have any metrics or fake query time is running
if (lastTotalTime === 0 && fakeQueryTime === 0) {
return null;
}
const renderMetricRow = (label: string, current: number) => {
return (
<div>
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
<strong>{label}:</strong> {formatDuration(current)}
</div>
);
};
const tooltipContent = (
<div>
{renderMetricRow('Query', displayQueryTime)}
{renderMetricRow('Render', lastRenderTime)}
{renderMetricRow('Transform', lastTransformTime)}
<div style={{ marginTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '8px' }}>
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
<strong>Total:</strong> {formatDuration(lastTotalTime)}
</div>
</div>
);
const metricsText = [
displayQueryTime > 0 && `Q:${formatDuration(displayQueryTime)}`,
lastRenderTime > 0 && `R:${formatDuration(lastRenderTime)}`,
lastTransformTime > 0 && `T:${formatDuration(lastTransformTime)}`,
]
.filter(Boolean)
.join(' ');
return (
<Tooltip content={tooltipContent}>
<PanelChrome.TitleItem className={styles.metrics}>
<Stack gap={1} alignItems={'center'}>
<Icon name="tachometer-fast" size="sm" />
<div>{metricsText}</div>
</Stack>
</PanelChrome.TitleItem>
</Tooltip>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
metrics: css({
color: theme.colors.text.link,
gap: theme.spacing(0.5),
whiteSpace: 'nowrap',
fontSize: theme.typography.bodySmall.fontSize,
'&:hover': {
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
},
}),
};
};
@@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { PanelNotices } from '../../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../../scene/PanelPerformanceMetrics';
import { VizPanelHeaderActions } from '../../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader';
import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem';
@@ -54,6 +55,7 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const queryOptions = panel.spec.data.spec.queryOptions;
const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride;
@@ -110,6 +112,7 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const vizPanelState: VizPanelState = {
key: getVizPanelKeyForPanelId(id ?? panel.spec.id),
@@ -46,6 +46,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../scene/PanelPerformanceMetrics';
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
@@ -312,8 +313,9 @@ export function createDashboardSceneFromDashboardModel(
// Create profiler once and reuse to avoid duplicate metadata setting
const dashboardProfiler = getDashboardSceneProfilerWithMetadata(oldModel.uid, oldModel.title);
// HACK always on
const enableProfiling =
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1;
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1 || true;
const queryController = new behaviors.SceneQueryController(
{
enableProfiling,
@@ -430,6 +432,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const timeOverrideShown = (panel.timeFrom || panel.timeShift) && !panel.hideTimeOverride;
@@ -23,6 +23,8 @@ import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../scene/PanelPerformanceMetrics';
import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
@@ -274,7 +276,11 @@ export function getDefaultVizPanel(): VizPanel {
title: newPanelTitle,
pluginId: defaultPluginId,
seriesLimit: config.panelSeriesLimit,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
titleItems: [
new VizPanelLinks({ menu: new VizPanelLinksMenu({}) }),
new PanelNotices(),
new PanelPerformanceMetrics(),
],
hoverHeaderOffset: 0,
$behaviors: [],
subHeader: new VizPanelSubHeader({
@@ -1,3 +1,5 @@
import { Subject, Subscription } from 'rxjs';
import { logMeasurement, reportInteraction } from '@grafana/runtime';
import { performanceUtils } from '@grafana/scenes';
@@ -13,7 +15,7 @@ import {
/**
* Panel metrics structure for analytics
*/
interface PanelAnalyticsMetrics {
export interface PanelAnalyticsMetrics {
panelId: string;
panelKey: string;
pluginId: string;
@@ -54,6 +56,7 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
private panelMetrics = new Map<string, PanelAnalyticsMetrics>();
private dashboardUID = '';
private dashboardTitle = '';
private panelMetricsSubject = new Subject<{ panelId: string; metrics: PanelAnalyticsMetrics }>();
public initialize(uid: string, title: string) {
// Clear previous dashboard data and set new context
@@ -67,6 +70,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
this.panelMetrics.clear();
this.dashboardUID = '';
this.dashboardTitle = '';
// Complete the subject to clean up subscriptions
this.panelMetricsSubject.complete();
}
/**
@@ -74,6 +79,7 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
*/
public clearMetrics() {
this.panelMetrics.clear();
// Note: We don't emit clear events as subscribers should handle empty metrics gracefully
}
/**
@@ -83,6 +89,37 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
return Array.from(this.panelMetrics.values());
}
/**
* Get panel metrics by panel ID
*/
public getPanelMetricsByPanelId(panelId: string): PanelAnalyticsMetrics | undefined {
for (const metrics of this.panelMetrics.values()) {
if (metrics.panelId === panelId) {
return metrics;
}
}
return undefined;
}
/**
* Subscribe to panel metrics updates for a specific panel ID
* Returns a subscription that emits when metrics for the given panel are updated
*/
public subscribeToPanelMetrics(panelId: string, callback: (metrics: PanelAnalyticsMetrics) => void): Subscription {
// Get initial metrics if available
const initialMetrics = this.getPanelMetricsByPanelId(panelId);
if (initialMetrics) {
callback(initialMetrics);
}
// Subscribe to future updates
return this.panelMetricsSubject.subscribe(({ panelId: updatedPanelId, metrics }) => {
if (updatedPanelId === panelId) {
callback(metrics);
}
});
}
// Dashboard-level events (we don't need to track these for panel analytics)
onDashboardInteractionStart = (data: performanceUtils.DashboardInteractionStartData): void => {
// Clear metrics when new dashboard interaction starts
@@ -100,13 +137,17 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
// Panel-level events
onPanelOperationStart = (data: performanceUtils.PanelPerformanceData): void => {
console.log('onPanelOperationStart', data.operation);
// Start events don't need aggregation, just ensure panel exists
this.ensurePanelExists(data.panelKey, data.panelId, data.pluginId, data.pluginVersion);
};
onPanelOperationComplete = (data: performanceUtils.PanelPerformanceData): void => {
console.log('onPanelOperationComplete', data.operation);
// Aggregate panel metrics without verbose logging (handled by ScenePerformanceLogger)
const panel = this.panelMetrics.get(data.panelKey);
// Ensure panel exists - it may not have been created by onPanelOperationStart if the panel
// was loaded from saved state or if start events were missed
let panel = this.panelMetrics.get(data.panelKey);
if (!panel) {
console.warn('Panel not found for operation completion:', data.panelKey);
return;
@@ -154,6 +195,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
panel.pluginLoadTime += duration;
break;
}
this.panelMetricsSubject.next({ panelId: data.panelId, metrics: panel });
};
// Query-level events
@@ -62,8 +62,9 @@ export function getDashboardSceneProfilerWithMetadata(uid: string, title: string
// Function to enable panel profiling for a specific dashboard
export function enablePanelProfilingForDashboard(dashboard: SceneObject, uid: string) {
// Check if panel profiling should be enabled for this dashboard
// HACK always on
const shouldEnablePanelProfiling =
config.dashboardPerformanceMetrics.findIndex((configUid) => configUid === '*' || configUid === uid) !== -1;
config.dashboardPerformanceMetrics.findIndex((configUid) => configUid === '*' || configUid === uid) !== -1 || true; // HACK always on
if (shouldEnablePanelProfiling) {
const profiler = getDashboardSceneProfiler();