diff --git a/packages/scenes/src/behaviors/SceneInteractionTracker.ts b/packages/scenes/src/behaviors/SceneInteractionTracker.ts index 226268f21..e144d3104 100644 --- a/packages/scenes/src/behaviors/SceneInteractionTracker.ts +++ b/packages/scenes/src/behaviors/SceneInteractionTracker.ts @@ -1,6 +1,6 @@ import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneObject, SceneStatelessBehavior } from '../core/types'; -import { SceneRenderProfiler } from './SceneRenderProfiler'; +import { SceneRenderProfiler } from '../performance/SceneRenderProfiler'; import { SceneInteractionTrackerState } from './types'; export function isInteractionTracker(s: SceneObject | SceneStatelessBehavior): s is SceneInteractionTracker { diff --git a/packages/scenes/src/behaviors/SceneQueryController.test.ts b/packages/scenes/src/behaviors/SceneQueryController.test.ts index b3083b09f..79d9f6f1e 100644 --- a/packages/scenes/src/behaviors/SceneQueryController.test.ts +++ b/packages/scenes/src/behaviors/SceneQueryController.test.ts @@ -6,6 +6,13 @@ import { SceneQueryController } from './SceneQueryController'; import { registerQueryWithController } from '../querying/registerQueryWithController'; import { QueryResultWithState } from './types'; +// Mock crypto.randomUUID for generateOperationId +Object.defineProperty(global, 'crypto', { + value: { + randomUUID: jest.fn(() => 'test-uuid-1234-5678-9abc-def012345678'), + }, +}); + describe('SceneQueryController', () => { let controller: SceneQueryController; let scene: SceneObject; diff --git a/packages/scenes/src/behaviors/SceneQueryController.ts b/packages/scenes/src/behaviors/SceneQueryController.ts index 069aeec07..d5e4f7ede 100644 --- a/packages/scenes/src/behaviors/SceneQueryController.ts +++ b/packages/scenes/src/behaviors/SceneQueryController.ts @@ -1,7 +1,7 @@ import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneObject, SceneStatelessBehavior } from '../core/types'; import { writeSceneLog } from '../utils/writeSceneLog'; -import { SceneRenderProfiler } from './SceneRenderProfiler'; +import { SceneRenderProfiler } from '../performance/SceneRenderProfiler'; import { SceneQueryControllerEntry, SceneQueryControllerLike, SceneQueryStateControllerState } from './types'; export function isQueryController(s: SceneObject | SceneStatelessBehavior): s is SceneQueryControllerLike { diff --git a/packages/scenes/src/behaviors/types.ts b/packages/scenes/src/behaviors/types.ts index 6f8828214..f9edc5fc9 100644 --- a/packages/scenes/src/behaviors/types.ts +++ b/packages/scenes/src/behaviors/types.ts @@ -16,22 +16,7 @@ export interface SceneQueryControllerEntry { export type SceneQueryControllerEntryType = 'data' | 'annotations' | 'variable' | 'alerts' | 'plugin' | string; // Long Frame Detection types (re-exported from LongFrameDetector) -export type { LongFrameEvent, LongFrameCallback } from './LongFrameDetector'; - -export interface SceneInteractionProfileEvent { - origin: string; - duration: number; - networkDuration: number; - jsHeapSizeLimit: number; - usedJSHeapSize: number; - totalJSHeapSize: number; - crumbs: string[]; - startTs: number; - endTs: number; - longFramesCount: number; - longFramesTotalTime: number; - // add more granular data,i.e. network times? slow frames? -} +export type { LongFrameEvent, LongFrameCallback } from '../performance/LongFrameDetector'; export interface SceneComponentInteractionEvent { origin: string; @@ -49,7 +34,6 @@ export interface SceneInteractionTrackerState extends SceneObjectState { export interface SceneQueryStateControllerState extends SceneObjectState { isRunning: boolean; enableProfiling?: boolean; - onProfileComplete?(event: SceneInteractionProfileEvent): void; } export interface SceneQueryControllerLike extends SceneObject { diff --git a/packages/scenes/src/components/SceneRefreshPicker.tsx b/packages/scenes/src/components/SceneRefreshPicker.tsx index 15142624a..7826caff9 100644 --- a/packages/scenes/src/components/SceneRefreshPicker.tsx +++ b/packages/scenes/src/components/SceneRefreshPicker.tsx @@ -8,7 +8,7 @@ import { SceneObjectBase } from '../core/SceneObjectBase'; import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneObject, SceneObjectState, SceneObjectUrlValues } from '../core/types'; import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig'; -import { REFRESH_INTERACTION } from '../behaviors/SceneRenderProfiler'; +import { REFRESH_INTERACTION } from '../performance/interactionConstants'; export const DEFAULT_INTERVALS = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; diff --git a/packages/scenes/src/components/VizPanel/VizPanel.tsx b/packages/scenes/src/components/VizPanel/VizPanel.tsx index 9fda04e30..a6bb6449c 100644 --- a/packages/scenes/src/components/VizPanel/VizPanel.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanel.tsx @@ -37,6 +37,7 @@ import { cloneDeep, isArray, isEmpty, merge, mergeWith } from 'lodash'; import { UserActionEvent } from '../../core/events'; import { evaluateTimeRange } from '../../utils/evaluateTimeRange'; import { LiveNowTimer } from '../../behaviors/LiveNowTimer'; +import { VizPanelRenderProfiler } from '../../performance/VizPanelRenderProfiler'; import { registerQueryWithController, wrapPromiseInStateObservable } from '../../querying/registerQueryWithController'; import { SceneDataTransformer } from '../../querying/SceneDataTransformer'; import { SceneQueryRunner } from '../../querying/SceneQueryRunner'; @@ -140,6 +141,23 @@ export class VizPanel extends Scene }); } + /** + * Get the VizPanelRenderProfiler behavior if attached + */ + public getProfiler(): VizPanelRenderProfiler | undefined { + if (!this.state.$behaviors) { + return undefined; + } + + for (const behavior of this.state.$behaviors) { + if (behavior instanceof VizPanelRenderProfiler) { + return behavior; + } + } + + return undefined; + } + private _onActivate() { if (!this._plugin) { this._loadPlugin(this.state.pluginId); @@ -157,14 +175,21 @@ export class VizPanel extends Scene overwriteFieldConfig?: FieldConfigSource, isAfterPluginChange?: boolean ) { + const profiler = this.getProfiler(); const plugin = loadPanelPluginSync(pluginId); if (plugin) { + // Plugin was loaded from cache + const endPluginLoadCallback = profiler?.onPluginLoadStart(pluginId); + endPluginLoadCallback?.(plugin, true); this._pluginLoaded(plugin, overwriteOptions, overwriteFieldConfig, isAfterPluginChange); } else { const { importPanelPlugin } = getPluginImportUtils(); try { + // Start profiling plugin load - get end callback + const endPluginLoadCallback = profiler?.onPluginLoadStart(pluginId); + const panelPromise = importPanelPlugin(pluginId); const queryControler = sceneGraph.getQueryController(this); @@ -175,6 +200,10 @@ export class VizPanel extends Scene } const result = await panelPromise; + + // End profiling plugin load (not from cache) + endPluginLoadCallback?.(result, false); + this._pluginLoaded(result, overwriteOptions, overwriteFieldConfig, isAfterPluginChange); } catch (err: unknown) { this._pluginLoaded(getPanelPluginNotFound(pluginId)); @@ -457,8 +486,11 @@ export class VizPanel extends Scene * Called from the react render path to apply the field config to the data provided by the data provider */ public applyFieldConfig(rawData?: PanelData): PanelData { + const timestamp = performance.now(); const plugin = this._plugin; + const profiler = this.getProfiler(); + if (!plugin || plugin.meta.skipDataQuery || !rawData) { // TODO setup time range subscription instead return emptyPanelData; @@ -469,6 +501,9 @@ export class VizPanel extends Scene return this._dataWithFieldConfig; } + // Start profiling data processing - get end callback + const endFieldConfigCallback = profiler?.onFieldConfigStart(timestamp); + const pluginDataSupport: PanelPluginDataSupport = plugin.dataSupport || { alertStates: false, annotations: false }; const fieldConfigRegistry = plugin.fieldConfigRegistry; @@ -515,6 +550,12 @@ export class VizPanel extends Scene } this._prevData = rawData; + + // End profiling data processing + if (profiler) { + endFieldConfigCallback?.(performance.now()); + } + return this._dataWithFieldConfig; } diff --git a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx index 70a5dc453..e106a8e14 100644 --- a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx @@ -1,5 +1,5 @@ import { Trans } from '@grafana/i18n'; -import React, { RefCallback, useCallback, useEffect, useMemo } from 'react'; +import React, { RefCallback, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; import { useMeasure } from 'react-use'; // @ts-ignore @@ -51,6 +51,32 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { [setPanelAttention] ); + // S3.0 RENDER TRACKING: Simple timing for performance measurement + const profiler = useMemo(() => model.getProfiler(), [model]); + + // Capture render start time immediately when component function runs + const currentRenderStart = performance.now(); + + const endRenderCallbackRef = React.useRef<((endTimestamp: number, duration: number) => void) | null>(null); + + useLayoutEffect(() => { + if (profiler) { + const callback = profiler.onSimpleRenderStart(currentRenderStart); + endRenderCallbackRef.current = callback || null; + } + }); + + // Use useEffect (after DOM updates) to measure complete render cycle timing + useEffect(() => { + if (endRenderCallbackRef.current) { + const timestamp = performance.now(); + // Measure from component start to after DOM updates AND effects (complete render cycle) + const duration = timestamp - currentRenderStart; + endRenderCallbackRef.current(timestamp, duration); + endRenderCallbackRef.current = null; // Clear callback after use + } + }); + const plugin = model.getPlugin(); const { dragClass, dragClassCancel } = getDragClasses(model); diff --git a/packages/scenes/src/core/SceneTimeRange.tsx b/packages/scenes/src/core/SceneTimeRange.tsx index 9b6aea1ef..35b7ba88b 100644 --- a/packages/scenes/src/core/SceneTimeRange.tsx +++ b/packages/scenes/src/core/SceneTimeRange.tsx @@ -13,7 +13,7 @@ import { isValid } from '../utils/date'; import { getQueryController } from './sceneGraph/getQueryController'; import { writeSceneLog } from '../utils/writeSceneLog'; import { isEmpty } from 'lodash'; -import { TIME_RANGE_CHANGE_INTERACTION } from '../behaviors/SceneRenderProfiler'; +import { TIME_RANGE_CHANGE_INTERACTION } from '../performance/interactionConstants'; export class SceneTimeRange extends SceneObjectBase implements SceneTimeRangeLike { protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to', 'timezone', 'time', 'time.window'] }); diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 0bba41208..c1cc17175 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -29,6 +29,8 @@ export * from './core/types'; export * from './core/events'; export { sceneGraph } from './core/sceneGraph'; export * as behaviors from './behaviors'; +export * as performanceUtils from './performance'; +export { writePerformanceLog } from './utils/writePerformanceLog'; export * as dataLayers from './querying/layers'; export { SceneObjectBase, useSceneObjectState } from './core/SceneObjectBase'; @@ -53,9 +55,7 @@ export type { SceneQueryControllerLike, SceneQueryControllerEntryType, SceneQueryControllerEntry, - SceneInteractionProfileEvent, } from './behaviors/types'; -export { SceneRenderProfiler } from './behaviors/SceneRenderProfiler'; export * from './variables/types'; export { VariableDependencyConfig } from './variables/VariableDependencyConfig'; diff --git a/packages/scenes/src/behaviors/LongFrameDetector.test.ts b/packages/scenes/src/performance/LongFrameDetector.test.ts similarity index 100% rename from packages/scenes/src/behaviors/LongFrameDetector.test.ts rename to packages/scenes/src/performance/LongFrameDetector.test.ts diff --git a/packages/scenes/src/behaviors/LongFrameDetector.ts b/packages/scenes/src/performance/LongFrameDetector.ts similarity index 90% rename from packages/scenes/src/behaviors/LongFrameDetector.ts rename to packages/scenes/src/performance/LongFrameDetector.ts index 01ed11fce..e80936648 100644 --- a/packages/scenes/src/behaviors/LongFrameDetector.ts +++ b/packages/scenes/src/performance/LongFrameDetector.ts @@ -1,4 +1,4 @@ -import { writeSceneLog } from '../utils/writeSceneLog'; +import { writePerformanceLog } from '../utils/writePerformanceLog'; const LONG_FRAME_THRESHOLD = 50; // Threshold for both LoAF and manual tracking (ms) @@ -49,7 +49,7 @@ export class LongFrameDetector { */ public start(callback: LongFrameCallback): void { if (this.#isTracking) { - writeSceneLog('LongFrameDetector', 'Already tracking frames, stopping previous session'); + writePerformanceLog('LFD', 'Already tracking frames, stopping previous session'); this.stop(); } @@ -62,8 +62,8 @@ export class LongFrameDetector { this.startManualFrameTracking(); } - writeSceneLog( - 'LongFrameDetector', + writePerformanceLog( + 'LFD', `Started tracking with ${ this.isLoAFAvailable() ? 'LoAF API' : 'manual' } method, threshold: ${LONG_FRAME_THRESHOLD}ms` @@ -99,7 +99,7 @@ export class LongFrameDetector { */ private startLoAFTracking(): void { if (!this.isLoAFAvailable()) { - writeSceneLog('LongFrameDetector', 'LoAF API not available, falling back to manual tracking'); + writePerformanceLog('LFD', 'LoAF API not available, falling back to manual tracking'); this.startManualFrameTracking(); return; } @@ -138,13 +138,13 @@ export class LongFrameDetector { } } - writeSceneLog('LongFrameDetector', `Long frame detected (LoAF): ${entry.duration}ms at ${entry.startTime}ms`); + writePerformanceLog('LFD', `Long frame detected (LoAF): ${entry.duration}ms at ${entry.startTime}ms`); } }); this.#loafObserver.observe({ type: 'long-animation-frame', buffered: false }); } catch (error) { - writeSceneLog('LongFrameDetector', 'Failed to start LoAF tracking, falling back to manual:', error); + writePerformanceLog('LFD', 'Failed to start LoAF tracking, falling back to manual:', error); this.startManualFrameTracking(); } } @@ -156,7 +156,7 @@ export class LongFrameDetector { if (this.#loafObserver) { this.#loafObserver.disconnect(); this.#loafObserver = null; - writeSceneLog('LongFrameDetector', 'Stopped LoAF tracking'); + writePerformanceLog('LFD', 'Stopped LoAF tracking'); } } @@ -175,7 +175,7 @@ export class LongFrameDetector { if (this.#frameTrackingId) { cancelAnimationFrame(this.#frameTrackingId); this.#frameTrackingId = null; - writeSceneLog('LongFrameDetector', 'Stopped manual frame tracking'); + writePerformanceLog('LFD', 'Stopped manual frame tracking'); } } @@ -222,8 +222,8 @@ export class LongFrameDetector { } } - writeSceneLog( - 'LongFrameDetector', + writePerformanceLog( + 'LFD', `Long frame detected (manual): ${frameLength}ms (threshold: ${LONG_FRAME_THRESHOLD}ms)` ); } diff --git a/packages/scenes/src/performance/PanelProfilingManager.ts b/packages/scenes/src/performance/PanelProfilingManager.ts new file mode 100644 index 000000000..348ddc3a7 --- /dev/null +++ b/packages/scenes/src/performance/PanelProfilingManager.ts @@ -0,0 +1,90 @@ +import { Unsubscribable } from 'rxjs'; +import { VizPanel } from '../components/VizPanel/VizPanel'; +import { VizPanelRenderProfiler } from './VizPanelRenderProfiler'; +import { SceneObject } from '../core/types'; +import { sceneGraph } from '../core/sceneGraph'; + +export interface PanelProfilingConfig { + watchStateKey?: string; // State property to watch for structural changes (e.g., 'body', 'children') +} + +/** + * Manages VizPanelRenderProfiler instances for all panels in a scene object. + * Extracted from DashboardPanelProfilingBehavior to allow composition with SceneRenderProfiler. + */ +export class PanelProfilingManager { + private _sceneObject?: SceneObject; + private _subscriptions: Unsubscribable[] = []; + + public constructor(private _config: PanelProfilingConfig) {} + + /** + * Attach panel profiling to a scene object + */ + public attachToScene(sceneObject: SceneObject) { + this._sceneObject = sceneObject; + + // Subscribe to scene state changes to add profilers to new panels + const subscription = sceneObject.subscribeToState((newState: any, prevState: any) => { + // If watchStateKey is specified, only react to changes in that specific property + if (this._config.watchStateKey) { + if (newState[this._config.watchStateKey] !== prevState[this._config.watchStateKey]) { + this._attachProfilersToPanels(); + } + } else { + // Fallback: react to any state change + this._attachProfilersToPanels(); + } + }); + + this._subscriptions.push(subscription); + + // Attach profilers to existing panels + this._attachProfilersToPanels(); + } + + /** + * Attach VizPanelRenderProfiler to a specific panel if it doesn't already have one + * @param panel - The VizPanel to attach profiling to + */ + public attachProfilerToPanel(panel: VizPanel): void { + // Check if profiler already exists + const existingProfiler = panel.state.$behaviors?.find((b) => b instanceof VizPanelRenderProfiler); + + if (existingProfiler) { + return; // Already has a profiler + } + + // Add profiler behavior + const profiler = new VizPanelRenderProfiler(); + + panel.setState({ + $behaviors: [...(panel.state.$behaviors || []), profiler], + }); + } + + /** + * Attach VizPanelRenderProfiler to all VizPanels that don't already have one + */ + private _attachProfilersToPanels() { + if (!this._sceneObject) { + return; + } + + // Use scene graph to find all VizPanels in the scene + const panels = sceneGraph.findAllObjects(this._sceneObject, (obj) => obj instanceof VizPanel) as VizPanel[]; + + panels.forEach((panel) => { + this.attachProfilerToPanel(panel); + }); + } + + /** + * Clean up subscriptions and references + */ + public cleanup() { + this._subscriptions.forEach((sub) => sub.unsubscribe()); + this._subscriptions = []; + this._sceneObject = undefined; + } +} diff --git a/packages/scenes/src/performance/ScenePerformanceTracker.ts b/packages/scenes/src/performance/ScenePerformanceTracker.ts new file mode 100644 index 000000000..be65e0104 --- /dev/null +++ b/packages/scenes/src/performance/ScenePerformanceTracker.ts @@ -0,0 +1,239 @@ +/** + * Centralized performance tracking system using observer pattern. + * External systems (like Grafana) implement ScenePerformanceObserver to receive performance events. + */ + +/** Generate unique operation IDs for correlating start/complete events */ +export function generateOperationId(prefix = 'op'): string { + // Use crypto.randomUUID() for true uniqueness without global state + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + const uuid = crypto.randomUUID(); + return `${prefix}-${uuid}`; + } + + // Fallback for environments without crypto.randomUUID support + const randomPart = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + return `${prefix}-${randomPart}`; +} + +/** Base interface for all performance events */ +export interface BasePerformanceEvent { + operationId: string; // Unique identifier for correlating start/complete events + timestamp: number; + duration?: number; + error?: string; +} + +export interface DashboardInteractionStartData extends BasePerformanceEvent { + interactionType: string; + metadata?: Record; +} + +export interface DashboardInteractionMilestoneData extends BasePerformanceEvent { + interactionType: string; + milestone: string; + metadata?: Record; +} + +export interface DashboardInteractionCompleteData extends BasePerformanceEvent { + interactionType: string; + networkDuration?: number; + longFramesCount: number; + longFramesTotalTime: number; + metadata?: Record; +} + +/** Metadata interface for transform operations */ +export interface TransformMetadata { + transformationId: string; + transformationCount: number; + seriesTransformationCount: number; + annotationTransformationCount: number; + success?: boolean; + error?: string; +} + +/** Metadata interface for query operations */ +export interface QueryMetadata { + queryId: string; + queryType: string; +} + +/** Metadata interface for render operations */ +export interface RenderMetadata { + // Empty for now - can be extended later if needed +} + +/** Metadata interface for plugin load operations */ +export interface PluginLoadMetadata { + pluginId: string; + fromCache?: boolean; + pluginLoadTime?: number; +} + +/** Metadata interface for field config operations */ +export interface FieldConfigMetadata {} + +/** Base interface for panel performance events */ +interface BasePanelPerformanceData extends BasePerformanceEvent { + panelId: string; + panelKey: string; + pluginId: string; + pluginVersion?: string; + panelTitle?: string; +} + +/** Transform operation performance data */ +export interface PanelTransformPerformanceData extends BasePanelPerformanceData { + operation: 'transform'; + metadata: TransformMetadata; +} + +/** Query operation performance data */ +export interface PanelQueryPerformanceData extends BasePanelPerformanceData { + operation: 'query'; + metadata: QueryMetadata; +} + +/** Render operation performance data */ +export interface PanelRenderPerformanceData extends BasePanelPerformanceData { + operation: 'render'; + metadata: RenderMetadata; +} + +/** Plugin load operation performance data */ +export interface PanelPluginLoadPerformanceData extends BasePanelPerformanceData { + operation: 'plugin-load'; + metadata: PluginLoadMetadata; +} + +/** Field config operation performance data */ +export interface PanelFieldConfigPerformanceData extends BasePanelPerformanceData { + operation: 'fieldConfig'; + metadata: FieldConfigMetadata; +} + +/** Discriminated union of all panel performance data types */ +export type PanelPerformanceData = + | PanelTransformPerformanceData + | PanelQueryPerformanceData + | PanelRenderPerformanceData + | PanelPluginLoadPerformanceData + | PanelFieldConfigPerformanceData; + +/** Non-panel query performance data for dashboard queries (annotations, variables, etc.) */ +export interface QueryPerformanceData extends BasePerformanceEvent { + queryId: string; + queryType: string; + origin: string; // e.g., "AnnotationsDataLayer", "QueryVariable", "VizPanel/loadPlugin" +} + +/** + * Observer interface for performance monitoring + * External systems implement this to receive performance notifications + */ +export interface ScenePerformanceObserver { + // Dashboard-level events + onDashboardInteractionStart?(data: DashboardInteractionStartData): void; + onDashboardInteractionMilestone?(data: DashboardInteractionMilestoneData): void; + onDashboardInteractionComplete?(data: DashboardInteractionCompleteData): void; + + // Panel-level events + onPanelOperationStart?(data: PanelPerformanceData): void; + onPanelOperationComplete?(data: PanelPerformanceData): void; + + // Query-level events + onQueryStart?(data: QueryPerformanceData): void; + onQueryComplete?(data: QueryPerformanceData): void; +} + +/** + * Centralized performance tracker + * Manages observers and provides methods for scene objects to report performance events + */ +export class ScenePerformanceTracker { + private static instance: ScenePerformanceTracker | null = null; + private observers: ScenePerformanceObserver[] = []; + + public static getInstance(): ScenePerformanceTracker { + if (!ScenePerformanceTracker.instance) { + ScenePerformanceTracker.instance = new ScenePerformanceTracker(); + } + return ScenePerformanceTracker.instance; + } + + /** + * Register a performance observer + */ + public addObserver(observer: ScenePerformanceObserver): () => void { + this.observers.push(observer); + + // Return unsubscribe function + return () => { + const index = this.observers.indexOf(observer); + if (index > -1) { + this.observers.splice(index, 1); + } + }; + } + + /** + * Remove all observers (for testing) + */ + public clearObservers(): void { + this.observers = []; + } + + /** + * Get current observer count (for debugging) + */ + public getObserverCount(): number { + return this.observers.length; + } + + private notifyObservers(methodName: keyof ScenePerformanceObserver, data: T, errorContext: string): void { + this.observers.forEach((observer) => { + try { + const method = observer[methodName] as ((data: T) => void) | undefined; + method?.(data); + } catch (error) { + console.warn(`Error in ${errorContext} observer:`, error); + } + }); + } + + public notifyDashboardInteractionStart(data: DashboardInteractionStartData): void { + this.notifyObservers('onDashboardInteractionStart', data, 'dashboard interaction start'); + } + + public notifyDashboardInteractionMilestone(data: DashboardInteractionMilestoneData): void { + this.notifyObservers('onDashboardInteractionMilestone', data, 'dashboard interaction milestone'); + } + + public notifyDashboardInteractionComplete(data: DashboardInteractionCompleteData): void { + this.notifyObservers('onDashboardInteractionComplete', data, 'dashboard interaction complete'); + } + + public notifyPanelOperationStart(data: PanelPerformanceData): void { + this.notifyObservers('onPanelOperationStart', data, 'panel operation start'); + } + + public notifyPanelOperationComplete(data: PanelPerformanceData): void { + this.notifyObservers('onPanelOperationComplete', data, 'panel operation complete'); + } + + public notifyQueryStart(data: QueryPerformanceData): void { + this.notifyObservers('onQueryStart', data, 'query start'); + } + + public notifyQueryComplete(data: QueryPerformanceData): void { + this.notifyObservers('onQueryComplete', data, 'query complete'); + } +} + +/** + * Get the global performance tracker instance + */ +export function getScenePerformanceTracker(): ScenePerformanceTracker { + return ScenePerformanceTracker.getInstance(); +} diff --git a/packages/scenes/src/behaviors/SceneRenderProfiler.test.ts b/packages/scenes/src/performance/SceneRenderProfiler.test.ts similarity index 86% rename from packages/scenes/src/behaviors/SceneRenderProfiler.test.ts rename to packages/scenes/src/performance/SceneRenderProfiler.test.ts index a8e36b5cf..01de144e7 100644 --- a/packages/scenes/src/behaviors/SceneRenderProfiler.test.ts +++ b/packages/scenes/src/performance/SceneRenderProfiler.test.ts @@ -1,18 +1,34 @@ +import { calculateNetworkTime, processRecordedSpans, captureNetwork, SceneRenderProfiler } from './SceneRenderProfiler'; import { - calculateNetworkTime, - processRecordedSpans, - captureNetwork, - SceneRenderProfiler, ADHOC_KEYS_DROPDOWN_INTERACTION, ADHOC_VALUES_DROPDOWN_INTERACTION, GROUPBY_DIMENSIONS_INTERACTION, -} from './SceneRenderProfiler'; -import { SceneQueryControllerLike, SceneComponentInteractionEvent } from './types'; +} from './interactionConstants'; +import { SceneComponentInteractionEvent, SceneQueryControllerLike } from '../behaviors/types'; +// ScenePerformanceTracker imports handled via mocking // Mock writeSceneLog to prevent console noise in tests jest.mock('../utils/writeSceneLog', () => ({ writeSceneLog: jest.fn(), - writeSceneLogStyled: jest.fn(), +})); + +// Mock ScenePerformanceTracker +const mockTracker = { + addObserver: jest.fn(() => jest.fn()), // Returns unsubscribe function + removeObserver: jest.fn(), + notifyDashboardInteractionStart: jest.fn(), + notifyDashboardInteractionMilestone: jest.fn(), + notifyDashboardInteractionComplete: jest.fn(), + notifyPanelOperationStart: jest.fn(), + notifyPanelOperationComplete: jest.fn(), + notifyQueryStart: jest.fn(), + notifyQueryComplete: jest.fn(), + getObserverCount: jest.fn(() => 0), +}; + +jest.mock('./ScenePerformanceTracker', () => ({ + getScenePerformanceTracker: () => mockTracker, + generateOperationId: jest.fn((prefix: string) => `${prefix}-${Math.random().toString(36).substr(2, 9)}`), })); // Minimal mock query controller - only mocks what SceneRenderProfiler actually uses @@ -20,7 +36,6 @@ const createMockQueryController = (runningQueries = 0): SceneQueryControllerLike return { state: { isRunning: false, - onProfileComplete: jest.fn(), }, runningQueriesCount: jest.fn(() => runningQueries), } as unknown as SceneQueryControllerLike; @@ -41,6 +56,12 @@ describe('SceneRenderProfiler', () => { }); beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + mockTracker.notifyDashboardInteractionComplete.mockClear(); + mockTracker.notifyDashboardInteractionStart.mockClear(); + mockTracker.notifyDashboardInteractionMilestone.mockClear(); + // Setup mocks for each test global.document = { hidden: false, @@ -74,7 +95,8 @@ describe('SceneRenderProfiler', () => { it('should initialize with query controller and return initial state', () => { const mockController = createMockQueryController(); - const profiler = new SceneRenderProfiler(mockController); + const profiler = new SceneRenderProfiler(); + profiler.setQueryController(mockController); expect(profiler.isTailRecording()).toBe(false); profiler.cleanup(); }); @@ -136,7 +158,8 @@ describe('Long frame detection integration', () => { configurable: true, }); - profiler = new SceneRenderProfiler(mockQueryController); + profiler = new SceneRenderProfiler(); + profiler.setQueryController(mockQueryController); }); afterEach(() => { @@ -226,7 +249,8 @@ describe('SceneRenderProfiler integration tests', () => { }); mockQueryController = createMockQueryController(); - profiler = new SceneRenderProfiler(mockQueryController); + profiler = new SceneRenderProfiler(); + profiler.setQueryController(mockQueryController); }); afterEach(() => { @@ -335,16 +359,16 @@ describe('SceneRenderProfiler integration tests', () => { // Helper functions to reduce repetition const setupProfileTest = (testName: string) => { - const onProfileComplete = jest.fn(); - mockQueryController.state.onProfileComplete = onProfileComplete; + // Clear previous calls + mockTracker.notifyDashboardInteractionComplete.mockClear(); profiler.startProfile(testName); profiler.tryCompletingProfile(); expect(profiler.isTailRecording()).toBe(true); - return onProfileComplete; + return mockTracker.notifyDashboardInteractionComplete; }; const expectProfileCompletion = ( - onProfileComplete: jest.Mock, + notifyMock: jest.Mock, expected: { origin: string; duration: number; @@ -354,11 +378,11 @@ describe('SceneRenderProfiler integration tests', () => { } ) => { expect(profiler.isTailRecording()).toBe(false); - expect(onProfileComplete).toHaveBeenCalledWith( + expect(notifyMock).toHaveBeenCalledWith( expect.objectContaining({ - startTs: 1000, - endTs: 1000 + expected.duration, - ...expected, + interactionType: expected.origin, + duration: expected.duration, + timestamp: 1000 + expected.duration, // endTs equivalent }) ); }; @@ -369,9 +393,9 @@ describe('SceneRenderProfiler integration tests', () => { }); it('should complete full profile lifecycle with tail recording', async () => { - const onProfileComplete = setupProfileTest('dashboard-load'); + const notifyMock = setupProfileTest('dashboard-load'); simulateAnimationFrames(2000); - expectProfileCompletion(onProfileComplete, { origin: 'dashboard-load', duration: 16 }); + expectProfileCompletion(notifyMock, { origin: 'dashboard-load', duration: 16 }); }); it('should initiate tail recording and animation frames correctly', () => { @@ -381,7 +405,7 @@ describe('SceneRenderProfiler integration tests', () => { }); it('should cancel current profile when new profile starts during tail recording', () => { - const onProfileComplete = setupProfileTest('first-profile'); + const notifyMock = setupProfileTest('first-profile'); // Starting a new profile during tail recording should cancel the first profile profiler.startProfile('second-profile'); @@ -391,7 +415,7 @@ describe('SceneRenderProfiler integration tests', () => { expect(profiler.isTailRecording()).toBe(true); // Second profile now tail recording // The first profile should not complete because it was cancelled - expect(onProfileComplete).not.toHaveBeenCalled(); + expect(notifyMock).not.toHaveBeenCalled(); }); }); @@ -409,21 +433,18 @@ describe('SceneRenderProfiler integration tests', () => { ['slow frame at end', [15, 20, 25, 45], 105, 'all frames when last is slow: 15+20+25+45'], ])('should record %s correctly', (scenario, frameDurations, expectedDuration, description) => { const testName = scenario.replace(' ', '-') + '-test'; - const onProfileComplete = setupProfileTest(testName); + const notifyMock = setupProfileTest(testName); simulateVariableFrames(frameDurations); - expectProfileCompletion(onProfileComplete, { origin: testName, duration: expectedDuration }); + expectProfileCompletion(notifyMock, { origin: testName, duration: expectedDuration }); }); it('should verify slow frame time affects performance.measure end timestamp', () => { - const onProfileComplete = setupProfileTest('performance-measure-test'); + const notifyMock = setupProfileTest('performance-measure-test'); const frameDurations = [20, 50, 30, 40, 25]; // Expected duration: 140ms simulateVariableFrames(frameDurations); - expect(global.performance.measure).toHaveBeenCalledWith( - 'DashboardInteraction performance-measure-test', - expect.objectContaining({ start: 1000, end: 1140 }) - ); - expectProfileCompletion(onProfileComplete, { origin: 'performance-measure-test', duration: 140 }); + // Verify observer was notified instead of direct performance.measure call + expectProfileCompletion(notifyMock, { origin: 'performance-measure-test', duration: 140 }); }); }); @@ -476,14 +497,13 @@ describe('SceneRenderProfiler integration tests', () => { describe('Complex cancellation scenarios', () => { const expectNoCancelCallback = () => { - const onProfileComplete = jest.fn(); - mockQueryController.state.onProfileComplete = onProfileComplete; + mockTracker.notifyDashboardInteractionComplete.mockClear(); simulateAnimationFrames(2000); - expect(onProfileComplete).not.toHaveBeenCalled(); + expect(mockTracker.notifyDashboardInteractionComplete).not.toHaveBeenCalled(); }; it('should handle cancellation during different phases', () => { - mockQueryController.state.onProfileComplete = jest.fn(); + mockTracker.notifyDashboardInteractionComplete.mockClear(); // Before tail recording profiler.startProfile('cancel-before-tail'); @@ -569,7 +589,8 @@ describe('SceneRenderProfiler integration tests', () => { it('should handle profile cancellation via tab visibility', () => { const addEventListenerSpy = jest.spyOn(global.document, 'addEventListener'); try { - const freshProfiler = new SceneRenderProfiler(mockQueryController); + const freshProfiler = new SceneRenderProfiler(); + freshProfiler.setQueryController(mockQueryController); expect(addEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); freshProfiler.startProfile('interrupted-profile'); @@ -614,12 +635,11 @@ describe('SceneRenderProfiler integration tests', () => { profiler.tryCompletingProfile(); simulateAnimationFrames(3000, 16); // Trigger profile completion - // Verify performance.measure was called with the correct format - expect(global.performance.measure).toHaveBeenCalledWith( - 'DashboardInteraction measure-test', + // Verify observer was notified of dashboard interaction completion + expect(mockTracker.notifyDashboardInteractionComplete).toHaveBeenCalledWith( expect.objectContaining({ - start: 1000, // From mock setup - end: 1016, // From mock setup + interactionType: 'measure-test', + duration: expect.any(Number), }) ); }); @@ -672,12 +692,11 @@ describe('SceneRenderProfiler integration tests', () => { simulateAnimationFrames(3000, 16); // Trigger profile completion }).not.toThrow(); - // Verify other performance APIs are still called - expect(global.performance.measure).toHaveBeenCalledWith( - 'DashboardInteraction no-memory-test', + // Verify observer was notified of dashboard interaction completion + expect(mockTracker.notifyDashboardInteractionComplete).toHaveBeenCalledWith( expect.objectContaining({ - start: 1000, // From mock setup - end: 1016, // From mock setup + interactionType: 'no-memory-test', + duration: expect.any(Number), }) ); }); @@ -710,11 +729,10 @@ describe('SceneRenderProfiler integration tests', () => { // Verify all performance APIs were consumed correctly expect(global.performance.now).toHaveBeenCalled(); - expect(global.performance.measure).toHaveBeenCalledWith( - 'DashboardInteraction complex-integration-test', + expect(mockTracker.notifyDashboardInteractionComplete).toHaveBeenCalledWith( expect.objectContaining({ - start: 1000, // From mock setup - end: 1016, // From mock setup + interactionType: 'complex-integration-test', + duration: expect.any(Number), }) ); expect(global.performance.getEntriesByType).toHaveBeenCalledWith('resource'); @@ -740,11 +758,10 @@ describe('SceneRenderProfiler integration tests', () => { // Verify all network-related performance APIs were called correctly expect(global.performance.getEntriesByType).toHaveBeenCalledWith('resource'); expect(global.performance.clearResourceTimings).toHaveBeenCalled(); - expect(global.performance.measure).toHaveBeenCalledWith( - 'DashboardInteraction network-operations-test', + expect(mockTracker.notifyDashboardInteractionComplete).toHaveBeenCalledWith( expect.objectContaining({ - start: 1000, - end: 1016, + interactionType: 'network-operations-test', + duration: expect.any(Number), }) ); @@ -884,6 +901,56 @@ describe('captureNetwork', () => { }); }); +describe('S5.0: Panel Metrics Collection', () => { + it('should notify observer when profile completes', () => { + mockTracker.notifyDashboardInteractionComplete.mockClear(); + const mockQueryController = { + state: {}, + runningQueriesCount: () => 0, + }; + + const profiler = new SceneRenderProfiler(); + profiler.setQueryController(mockQueryController as any); + + // Start and complete a profile + profiler.startProfile('test-interaction'); + profiler.tryCompletingProfile(); + + // Wait for the profile to complete + setTimeout(() => { + // Verify that observer was notified when profile completes + expect(mockTracker.notifyDashboardInteractionComplete).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: 'test-interaction', + }) + ); + }, 0); + }); + + it('should handle no query controller gracefully', () => { + const profiler = new SceneRenderProfiler(); // No query controller + + // Verify profiler can be created without query controller + expect(profiler).toBeDefined(); + }); + + it('should handle observer pattern gracefully', () => { + const mockQueryController = { + state: {}, + }; + + const profiler = new SceneRenderProfiler(); + profiler.setQueryController(mockQueryController as any); + + // Verify profiler initializes without errors + expect(profiler).toBeDefined(); + + // Verify observer notifications work (mocked tracker should be called) + profiler.startProfile('test-interaction'); + expect(mockTracker.notifyDashboardInteractionStart).toHaveBeenCalled(); + }); +}); + describe('SceneRenderProfiler - Interaction Profiling', () => { let profiler: SceneRenderProfiler; let mockOnInteractionComplete: jest.Mock; diff --git a/packages/scenes/src/behaviors/SceneRenderProfiler.ts b/packages/scenes/src/performance/SceneRenderProfiler.ts similarity index 54% rename from packages/scenes/src/behaviors/SceneRenderProfiler.ts rename to packages/scenes/src/performance/SceneRenderProfiler.ts index 96a9fa6e4..f6e955d2c 100644 --- a/packages/scenes/src/behaviors/SceneRenderProfiler.ts +++ b/packages/scenes/src/performance/SceneRenderProfiler.ts @@ -1,5 +1,13 @@ -import { writeSceneLog, writeSceneLogStyled } from '../utils/writeSceneLog'; -import { SceneQueryControllerLike, LongFrameEvent, SceneComponentInteractionEvent } from './types'; +import { writePerformanceLog } from '../utils/writePerformanceLog'; +import { SceneQueryControllerLike, LongFrameEvent, SceneComponentInteractionEvent } from '../behaviors/types'; +import { + getScenePerformanceTracker, + generateOperationId, + DashboardInteractionCompleteData, +} from './ScenePerformanceTracker'; +import { PanelProfilingManager, PanelProfilingConfig } from './PanelProfilingManager'; +import { SceneObject } from '../core/types'; +import { VizPanel } from '../components/VizPanel/VizPanel'; import { LongFrameDetector } from './LongFrameDetector'; const POST_STORM_WINDOW = 2000; // Time after last query to observe slow frames @@ -25,8 +33,7 @@ const DEFAULT_LONG_FRAME_THRESHOLD = 30; // Threshold for tail recording slow fr export class SceneRenderProfiler { #profileInProgress: { - // Profile origin, i.e. scene refresh picker - origin: string; + origin: string; // Profile trigger (e.g., 'time_range_change') crumbs: string[]; } | null = null; @@ -38,7 +45,13 @@ export class SceneRenderProfiler { #profileStartTs: number | null = null; #trailAnimationFrameId: number | null = null; - // Will keep measured lengths trailing frames + // Generic metadata for observer notifications + private metadata: Record = {}; + + // Operation ID for correlating dashboard interaction events + #currentOperationId?: string; + + // Trailing frame measurements #recordedTrailingSpans: number[] = []; // Long frame tracking @@ -49,30 +62,56 @@ export class SceneRenderProfiler { #visibilityChangeHandler: (() => void) | null = null; #onInteractionComplete: ((event: SceneComponentInteractionEvent) => void) | null = null; - public constructor(private queryController?: SceneQueryControllerLike) { + // Panel profiling composition + private _panelProfilingManager?: PanelProfilingManager; + + // Query controller for monitoring query completion + private queryController?: SceneQueryControllerLike; + + public constructor(panelProfilingConfig?: PanelProfilingConfig) { this.#longFrameDetector = new LongFrameDetector(); this.setupVisibilityChangeHandler(); this.#interactionInProgress = null; + + // Compose with panel profiling manager if provided + if (panelProfilingConfig) { + this._panelProfilingManager = new PanelProfilingManager(panelProfilingConfig); + } + } + + /** Set generic metadata for observer notifications */ + public setMetadata(metadata: Record) { + this.metadata = { ...metadata }; } public setQueryController(queryController: SceneQueryControllerLike) { this.queryController = queryController; } + /** Attach panel profiling to a scene object */ + public attachPanelProfiling(sceneObject: SceneObject) { + this._panelProfilingManager?.attachToScene(sceneObject); + } + + /** Attach profiler to a specific panel */ + public attachProfilerToPanel(panel: VizPanel): void { + writePerformanceLog('SRP', 'Attaching profiler to panel', panel.state.key); + this._panelProfilingManager?.attachProfilerToPanel(panel); + } + public setInteractionCompleteHandler(handler?: (event: SceneComponentInteractionEvent) => void) { this.#onInteractionComplete = handler ?? null; } private setupVisibilityChangeHandler() { - // Ensure event listener is only added once if (this.#visibilityChangeHandler) { return; } - // Handle tab switching with Page Visibility API + // Cancel profiling when tab becomes inactive this.#visibilityChangeHandler = () => { if (document.hidden && this.#profileInProgress) { - writeSceneLog('SceneRenderProfiler', 'Tab became inactive, cancelling profile'); + writePerformanceLog('SRP', 'Tab became inactive, cancelling profile'); this.cancelProfile(); } }; @@ -83,7 +122,6 @@ export class SceneRenderProfiler { } public cleanup() { - // Remove event listener to prevent memory leaks if (this.#visibilityChangeHandler && typeof document !== 'undefined') { document.removeEventListener('visibilitychange', this.#visibilityChangeHandler); this.#visibilityChangeHandler = null; @@ -94,13 +132,15 @@ export class SceneRenderProfiler { // Cancel any ongoing profiling this.cancelProfile(); + + // Cleanup composed panel profiling manager + this._panelProfilingManager?.cleanup(); } public startProfile(name: string) { - // Only start profile if tab is active. This makes sure we don't start a profile when i.e. someone opens a dashboard in a new tab - // and doesn't interact with it. + // Skip profiling if tab is inactive if (document.hidden) { - writeSceneLog('SceneRenderProfiler', 'Tab is inactive, skipping profile', name); + writePerformanceLog('SRP', 'Tab is inactive, skipping profile', name); return; } @@ -119,7 +159,7 @@ export class SceneRenderProfiler { public startInteraction(interaction: string) { // Cancel any existing interaction recording if (this.#interactionInProgress) { - writeSceneLog('profile', 'Cancelled interaction:', this.#interactionInProgress); + writePerformanceLog('SRP', 'Cancelled interaction:', this.#interactionInProgress); this.#interactionInProgress = null; } @@ -128,7 +168,7 @@ export class SceneRenderProfiler { startTs: performance.now(), }; - writeSceneLog('SceneRenderProfiler', 'Started interaction:', interaction); + writePerformanceLog('SRP', 'Started interaction:', interaction); } public stopInteraction() { @@ -142,11 +182,10 @@ export class SceneRenderProfiler { // Capture network requests that occurred during the interaction const networkDuration = captureNetwork(this.#interactionInProgress.startTs, endTs); - writeSceneLog('SceneRenderProfiler', 'Completed interaction:'); - writeSceneLog('', ` ├─ Total time: ${interactionDuration.toFixed(1)}ms`); - writeSceneLog('', ` ├─ Network duration: ${networkDuration.toFixed(1)}ms`); - writeSceneLog('', ` ├─ StartTs: ${this.#interactionInProgress.startTs.toFixed(1)}ms`); - writeSceneLog('', ` └─ EndTs: ${endTs.toFixed(1)}ms`); + writePerformanceLog( + 'SRP', + `[INTERACTION] Complete: ${interactionDuration.toFixed(1)}ms total | ${networkDuration.toFixed(1)}ms network` + ); if (this.#onInteractionComplete && this.#profileInProgress) { this.#onInteractionComplete({ @@ -179,35 +218,25 @@ export class SceneRenderProfiler { } /** - * Starts a new profile for performance measurement. - * - * @param name - The origin/trigger of the profile (e.g., 'time_range_change', 'variable_value_changed') - * @param force - Whether this is a "forced" profile (true) or "clean" profile (false) - * - "forced": Started by canceling an existing profile that was recording trailing frames - * This happens when a new user interaction occurs before the previous one - * finished measuring its performance impact - * - "clean": Started when no profile is currently active + * Start new performance profile + * @param name - Profile trigger (e.g., 'time_range_change') + * @param force - True if canceling existing profile, false if starting clean */ private _startNewProfile(name: string, force = false) { + const profileType = force ? 'forced' : 'clean'; + writePerformanceLog('SRP', `[PROFILER] ${name} started (${profileType})`); this.#profileInProgress = { origin: name, crumbs: [] }; this.#profileStartTs = performance.now(); this.#longFramesCount = 0; this.#longFramesTotalTime = 0; - // Add performance mark for debugging in dev tools - if (typeof performance !== 'undefined' && performance.mark) { - const markName = `Dashboard Profile Start: ${name}`; - performance.mark(markName); - } - - // Log profile start in structured format - writeSceneLogStyled( - 'SceneRenderProfiler', - `Profile started[${force ? 'forced' : 'clean'}]`, - 'color: #FFCC00; font-weight: bold;' - ); - writeSceneLog('', ` ├─ Origin: ${this.#profileInProgress?.origin || 'unknown'}`); - writeSceneLog('', ` └─ Timestamp: ${this.#profileStartTs.toFixed(1)}ms`); + this.#currentOperationId = generateOperationId('dashboard'); + getScenePerformanceTracker().notifyDashboardInteractionStart({ + operationId: this.#currentOperationId, + interactionType: name, + timestamp: this.#profileStartTs, + metadata: this.metadata, + }); // Start long frame detection with callback this.#longFrameDetector.start((event: LongFrameEvent) => { @@ -248,86 +277,35 @@ export class SceneRenderProfiler { const slowFrames = processRecordedSpans(this.#recordedTrailingSpans); const slowFramesTime = slowFrames.reduce((acc, val) => acc + val, 0); - // Log tail recording in structured format - writeSceneLog( - 'SceneRenderProfiler', - `Profile tail recorded - Slow frames: ${slowFramesTime.toFixed(1)}ms (${slowFrames.length} frames)` + writePerformanceLog( + 'SRP', + 'Profile tail recorded, slow frames duration:', + slowFramesTime, + slowFrames, + this.#profileInProgress ); - writeSceneLog('', ` ├─ Origin: ${this.#profileInProgress?.origin || 'unknown'}`); - writeSceneLog('', ` └─ Crumbs:`, this.#profileInProgress?.crumbs || []); this.#recordedTrailingSpans = []; const profileDuration = measurementStartTs - profileStartTs; - // Add performance marks for debugging in dev tools - if (typeof performance !== 'undefined' && performance.mark) { - const profileName = this.#profileInProgress?.origin || 'unknown'; - const totalTime = profileDuration + slowFramesTime; - - // Mark profile completion - performance.mark(`Dashboard Profile End: ${profileName}`); - - // Add measure from start to end if possible - const startMarkName = `Dashboard Profile Start: ${profileName}`; - try { - performance.measure( - `Dashboard Profile: ${profileName} (${totalTime.toFixed(1)}ms)`, - startMarkName, - `Dashboard Profile End: ${profileName}` - ); - } catch { - // Start mark might not exist, create a simple end mark - performance.mark(`Dashboard Profile Complete: ${profileName} (${totalTime.toFixed(1)}ms)`); - } - - // Add measurements for slow frame details if significant - if (slowFrames.length > 0) { - const slowFramesMarkName = `Slow Frames Summary: ${slowFrames.length} frames (${slowFramesTime.toFixed( - 1 - )}ms)`; - performance.mark(slowFramesMarkName); - - // Create individual measurements for each slow frame during tail - slowFrames.forEach((frameTime, index) => { - if (frameTime > 16) { - // Only measure frames slower than 16ms (60fps) - try { - const frameStartTime = - this.#profileStartTs! + - profileDuration + - (index > 0 ? slowFrames.slice(0, index).reduce((sum, t) => sum + t, 0) : 0); - const frameId = `slow-frame-${index}`; - const frameStartMark = `${frameId}-start`; - const frameEndMark = `${frameId}-end`; - - performance.mark(frameStartMark, { startTime: frameStartTime }); - performance.mark(frameEndMark, { startTime: frameStartTime + frameTime }); - performance.measure(`Slow Frame ${index + 1}: ${frameTime.toFixed(1)}ms`, frameStartMark, frameEndMark); - } catch { - // Fallback if startTime not supported - performance.mark(`Slow Frame ${index + 1}: ${frameTime.toFixed(1)}ms`); - } - } - }); - } - } - - // Log performance summary in a structured format - const completionTimestamp = performance.now(); - writeSceneLog('SceneRenderProfiler', 'Profile completed'); - writeSceneLog('', ` ├─ Timestamp: ${completionTimestamp.toFixed(1)}ms`); - writeSceneLog('', ` ├─ Total time: ${(profileDuration + slowFramesTime).toFixed(1)}ms`); - writeSceneLog('', ` ├─ Slow frames: ${slowFramesTime}ms (${slowFrames.length} frames)`); - writeSceneLog('', ` └─ Long frames: ${this.#longFramesTotalTime}ms (${this.#longFramesCount} frames)`); - - // Stop long frame detection now that the profile is complete - this.#longFrameDetector.stop(); - writeSceneLogStyled( - 'SceneRenderProfiler', - `Stopped long frame detection - profile complete at ${completionTimestamp.toFixed(1)}ms`, - 'color: #00CC00; font-weight: bold;' + const slowFrameSummary = + slowFrames.length > 0 + ? `${slowFramesTime.toFixed(1)}ms slow frames[tail recording] (${slowFrames.length}) ⚠️` + : `${slowFramesTime.toFixed(1)}ms slow frames[tail recording] (${slowFrames.length})`; + + const longFrameSummary = + this.#longFramesCount > 0 + ? `${this.#longFramesTotalTime.toFixed(1)}ms long frames[LoAF] (${this.#longFramesCount}) ⚠️` + : `${this.#longFramesTotalTime.toFixed(1)}ms long frames[LoAF] (${this.#longFramesCount})`; + + writePerformanceLog( + 'SRP', + `[PROFILER] Complete: ${(profileDuration + slowFramesTime).toFixed( + 1 + )}ms total | ${slowFrameSummary} | ${longFrameSummary}` ); + this.#longFrameDetector.stop(); this.#trailAnimationFrameId = null; @@ -338,55 +316,34 @@ export class SceneRenderProfiler { return; } - performance.measure(`DashboardInteraction ${this.#profileInProgress.origin}`, { - start: profileStartTs, - end: profileEndTs, - }); - const networkDuration = captureNetwork(profileStartTs, profileEndTs); - if (this.queryController?.state.onProfileComplete && this.#profileInProgress) { - this.queryController.state.onProfileComplete({ - origin: this.#profileInProgress.origin, - crumbs: this.#profileInProgress.crumbs, + if (this.#profileInProgress) { + // Notify performance observers of dashboard interaction completion + const dashboardData: DashboardInteractionCompleteData = { + operationId: this.#currentOperationId || generateOperationId('dashboard-fallback'), + interactionType: this.#profileInProgress.origin, + timestamp: profileEndTs, duration: profileDuration + slowFramesTime, - networkDuration, - startTs: profileStartTs, - endTs: profileEndTs, + networkDuration: networkDuration, longFramesCount: this.#longFramesCount, longFramesTotalTime: this.#longFramesTotalTime, - // @ts-ignore - jsHeapSizeLimit: performance.memory ? performance.memory.jsHeapSizeLimit : 0, - // @ts-ignore - usedJSHeapSize: performance.memory ? performance.memory.usedJSHeapSize : 0, - // @ts-ignore - totalJSHeapSize: performance.memory ? performance.memory.totalJSHeapSize : 0, - }); + metadata: this.metadata, + }; + + const tracker = getScenePerformanceTracker(); + tracker.notifyDashboardInteractionComplete(dashboardData); this.#profileInProgress = null; this.#trailAnimationFrameId = null; } - // @ts-ignore - if (window.__runs) { - // @ts-ignore - window.__runs += `${Date.now()}, ${profileDuration + slowFramesTime}\n`; - } else { - // @ts-ignore - window.__runs = `${Date.now()}, ${profileDuration + slowFramesTime}\n`; - } } }; public tryCompletingProfile() { - if (!this.#profileInProgress) { - return; - } - - writeSceneLog('SceneRenderProfiler', 'Trying to complete profile', this.#profileInProgress); + writePerformanceLog('SRP', 'Trying to complete profile', this.#profileInProgress); if (this.queryController?.runningQueriesCount() === 0 && this.#profileInProgress) { - writeSceneLog('SceneRenderProfiler', 'All queries completed, starting tail measurement'); - // Note: Long frame detector continues running during tail measurement - // It will be stopped when the profile completely finishes + writePerformanceLog('SRP', 'All queries completed, stopping profile'); this.recordProfileTail(performance.now(), this.#profileStartTs!); } } @@ -399,14 +356,14 @@ export class SceneRenderProfiler { if (this.#trailAnimationFrameId) { cancelAnimationFrame(this.#trailAnimationFrameId); this.#trailAnimationFrameId = null; - writeSceneLog('SceneRenderProfiler', 'Cancelled recording frames, new profile started'); + writePerformanceLog('SRP', 'Cancelled recording frames, new profile started'); } } - // cancel profile public cancelProfile() { if (this.#profileInProgress) { - writeSceneLog('SceneRenderProfiler', 'Cancelling profile', this.#profileInProgress); + writePerformanceLog('SRP', 'Cancelling profile', this.#profileInProgress); + this.#profileInProgress = null; // Cancel any pending animation frame to prevent accessing null profileInProgress if (this.#trailAnimationFrameId) { @@ -415,7 +372,7 @@ export class SceneRenderProfiler { } // Stop long frame tracking this.#longFrameDetector.stop(); - writeSceneLog('SceneRenderProfiler', 'Stopped long frame detection - profile cancelled'); + writePerformanceLog('SRP', 'Stopped long frame detection - profile cancelled'); // Reset recorded spans to ensure complete cleanup this.#recordedTrailingSpans = []; this.#longFramesCount = 0; @@ -425,7 +382,14 @@ export class SceneRenderProfiler { public addCrumb(crumb: string) { if (this.#profileInProgress) { - writeSceneLog('SceneRenderProfiler', 'Adding crumb:', crumb); + // Notify performance observers of milestone + getScenePerformanceTracker().notifyDashboardInteractionMilestone({ + operationId: generateOperationId('dashboard-milestone'), + interactionType: this.#profileInProgress.origin, + timestamp: performance.now(), + milestone: crumb, + metadata: this.metadata, + }); this.#profileInProgress.crumbs.push(crumb); } } @@ -496,15 +460,3 @@ export function calculateNetworkTime(requests: PerformanceResourceTiming[]): num return totalNetworkTime; } - -export const REFRESH_INTERACTION = 'refresh'; -export const TIME_RANGE_CHANGE_INTERACTION = 'time_range_change'; -export const FILTER_ADDED_INTERACTION = 'filter_added'; -export const FILTER_REMOVED_INTERACTION = 'filter_removed'; -export const FILTER_CHANGED_INTERACTION = 'filter_changed'; -export const FILTER_RESTORED_INTERACTION = 'filter_restored'; -export const VARIABLE_VALUE_CHANGED_INTERACTION = 'variable_value_changed'; -export const SCOPES_CHANGED_INTERACTION = 'scopes_changed'; -export const ADHOC_KEYS_DROPDOWN_INTERACTION = 'adhoc_keys_dropdown'; -export const ADHOC_VALUES_DROPDOWN_INTERACTION = 'adhoc_values_dropdown'; -export const GROUPBY_DIMENSIONS_INTERACTION = 'groupby_dimensions'; diff --git a/packages/scenes/src/performance/VizPanelRenderProfiler.test.ts b/packages/scenes/src/performance/VizPanelRenderProfiler.test.ts new file mode 100644 index 000000000..22f3140dd --- /dev/null +++ b/packages/scenes/src/performance/VizPanelRenderProfiler.test.ts @@ -0,0 +1,532 @@ +import { VizPanel } from '../components/VizPanel/VizPanel'; +import { VizPanelRenderProfiler } from './VizPanelRenderProfiler'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; +import { SceneDataTransformer } from '../querying/SceneDataTransformer'; + +// Mock writeSceneLog +jest.mock('../utils/writeSceneLog', () => ({ + writeSceneLog: jest.fn(), +})); + +// Mock ScenePerformanceTracker for observer pattern +const mockScenePerformanceTracker = { + notifyQueryStart: jest.fn(), + notifyQueryComplete: jest.fn(), + notifyPanelOperationStart: jest.fn(), + notifyPanelOperationComplete: jest.fn(), +}; + +jest.mock('./ScenePerformanceTracker', () => ({ + getScenePerformanceTracker: () => mockScenePerformanceTracker, + generateOperationId: jest.fn().mockImplementation((type) => `${type}-${Date.now()}`), +})); + +// Mock plugin loading to prevent runtime errors +jest.mock('../components/VizPanel/registerRuntimePanelPlugin', () => ({ + loadPanelPluginSync: jest.fn().mockReturnValue({ + meta: { + id: 'timeseries', + info: { version: '1.0.0' }, + }, + fieldConfigDefaults: { + defaults: {}, + overrides: [], + }, + fieldConfigRegistry: { + getIfExists: jest.fn().mockReturnValue(undefined), + }, + dataSupport: { + annotations: false, + alertStates: false, + }, + }), +})); + +describe('VizPanelRenderProfiler', () => { + let panel: VizPanel; + let profiler: VizPanelRenderProfiler; + let performanceNowSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock performance.now() + performanceNowSpy = jest.spyOn(performance, 'now'); + performanceNowSpy.mockReturnValue(1000); + + // Create test panel + panel = new VizPanel({ + key: 'test-panel-1', + pluginId: 'timeseries', + title: 'Test Panel', + }); + + // Mock panel methods + jest.spyOn(panel, 'getLegacyPanelId').mockReturnValue(42); + jest.spyOn(panel, 'getPlugin').mockReturnValue({ + meta: { info: { version: '1.0.0' } }, + } as any); + + // Create profiler - now uses unified collector automatically + profiler = new VizPanelRenderProfiler(); + }); + + afterEach(() => { + performanceNowSpy.mockRestore(); + }); + + describe('Activation', () => { + it('should activate and extract panel information', () => { + // Attach profiler directly to the panel + panel.setState({ + $behaviors: [profiler], + }); + + panel.activate(); + + expect(panel.getLegacyPanelId).toHaveBeenCalled(); + expect(panel.getPlugin).toHaveBeenCalled(); + }); + + it('should handle missing panel gracefully', () => { + const profilerWithoutPanel = new VizPanelRenderProfiler(); + const layout = new SceneFlexLayout({ + $behaviors: [profilerWithoutPanel], + children: [], // Required property for SceneFlexLayout + }); + + expect(() => layout.activate()).not.toThrow(); + }); + }); + + describe('Plugin Loading', () => { + beforeEach(() => { + // Attach profiler directly to the panel + panel.setState({ + $behaviors: [profiler], + }); + panel.activate(); + }); + + it('should track plugin load start', () => { + const endPluginLoadCallback = profiler.onPluginLoadStart('timeseries'); + + // Verify observer notification was called for operation start + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('pluginLoad-'), + panelId: '42', // Uses panel's legacy ID + operation: 'plugin-load', + }) + ); + + // Should return a callback function + expect(endPluginLoadCallback).toBeInstanceOf(Function); + }); + + it('should track plugin load end', () => { + const endPluginLoadCallback = profiler.onPluginLoadStart('timeseries'); + performanceNowSpy.mockReturnValue(1100); // 100ms later + endPluginLoadCallback!({ meta: { id: 'timeseries' } }, false); + + // Verify observer notification was called for operation completion + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('pluginLoad-'), + panelId: '42', // Uses panel's legacy ID + operation: 'plugin-load', + }) + ); + }); + + it('should track plugin loaded from cache', () => { + const endPluginLoadCallback = profiler.onPluginLoadStart('timeseries'); + endPluginLoadCallback!({ meta: { id: 'timeseries' } }, true); + + // Verify observer notifications were called for both start and completion + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('pluginLoad-'), + operation: 'plugin-load', + }) + ); + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('pluginLoad-'), + operation: 'plugin-load', + metadata: expect.objectContaining({ + fromCache: true, + }), + }) + ); + }); + }); + + describe('Query Tracking', () => { + beforeEach(() => { + panel.setState({ + $behaviors: [profiler], + }); + panel.activate(); + }); + + it('should track query start and completion via modern API', () => { + const mockEntry = { + type: 'test-query', + origin: panel, + request: { requestId: 'test-request-1' }, + } as any; + const queryId = 'test-query-1'; + + const endQueryCallback = profiler.onQueryStarted(performance.now(), mockEntry, queryId); + + // Verify observer notification was called for query start (now uses panel operations) + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'test-query-1', + queryType: 'test-query', + }), + }) + ); + + // Should return a callback function + expect(endQueryCallback).toBeInstanceOf(Function); + + performanceNowSpy.mockReturnValue(1500); // 500ms later + endQueryCallback!(performance.now()); // Success case - no error + + // Verify observer notification was called for query completion (now uses panel operations) + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'test-query-1', + queryType: 'test-query', + }), + }) + ); + }); + }); + + describe('Field Config Processing', () => { + beforeEach(() => { + panel.setState({ + $behaviors: [profiler], + }); + panel.activate(); + }); + + it('should track field config processing', () => { + const endFieldConfigCallback = profiler.onFieldConfigStart(performance.now()); + + // Verify observer notification was called for operation start + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('fieldConfig-'), + panelId: '42', // Uses panel's legacy ID + operation: 'fieldConfig', + }) + ); + + performanceNowSpy.mockReturnValue(1050); // 50ms later + endFieldConfigCallback!(1000, 5); + + // Verify observer notification was called for operation completion + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('fieldConfig-'), + panelId: '42', // Uses panel's legacy ID + operation: 'fieldConfig', + }) + ); + }); + + it('should handle missing data metrics', () => { + const endFieldConfigCallback = profiler.onFieldConfigStart(performance.now()); + endFieldConfigCallback?.(performance.now()); + + // Observer pattern now handles all data metrics - methods should complete without errors + expect(profiler).toBeDefined(); + }); + }); + + describe('Panel State Changes', () => { + beforeEach(() => { + panel.setState({ + $behaviors: [profiler], + }); + panel.activate(); + }); + + it('should track plugin changes', () => { + const newPlugin = { + meta: { info: { version: '2.0.0' } }, + }; + jest.spyOn(panel, 'getPlugin').mockReturnValue(newPlugin as any); + + panel.setState({ pluginId: 'graph' }); + + // The profiler should update its internal plugin info + expect(panel.getPlugin).toHaveBeenCalled(); + }); + }); + + describe('S3.0 Lifecycle Integration', () => { + let mockQueryRunner: SceneQueryRunner; + + beforeEach(() => { + mockQueryRunner = new SceneQueryRunner({ + queries: [{ refId: 'A', expr: 'test_metric' }], + }); + + panel.setState({ + $behaviors: [profiler], + $data: mockQueryRunner, + }); + panel.activate(); + }); + + it('should track query execution via registerQueryWithController', () => { + // Test the new query tracking approach + const mockEntry = { + type: 'SceneQueryRunner/runQueries', + origin: panel, + request: { + requestId: 'test-query-123', + interval: '1s', + intervalMs: 1000, + range: { from: '2023-01-01', to: '2023-01-02', raw: { from: '2023-01-01', to: '2023-01-02' } }, + scopedVars: {}, + targets: [], + timezone: 'UTC', + app: 'grafana', + startTime: Date.now(), + }, + } as any; + + const endQueryCallback = profiler.onQueryStarted(performance.now(), mockEntry, 'test-query-123'); + + // Verify observer notification was called for query start (now uses panel operations) + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'test-query-123', + queryType: 'SceneQueryRunner/runQueries', + }), + }) + ); + + performanceNowSpy.mockReturnValue(1100); // 100ms later + endQueryCallback!(performance.now()); // Success case - no error + + // Verify observer notification was called for query completion (now uses panel operations) + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'test-query-123', + queryType: 'SceneQueryRunner/runQueries', + }), + }) + ); + }); + + it('should track multiple concurrent queries', () => { + // Test concurrent query tracking + const mockEntry1 = { + type: 'SceneQueryRunner/runQueries', + origin: panel, + request: { + requestId: 'query-1', + interval: '1s', + intervalMs: 1000, + range: { from: '2023-01-01', to: '2023-01-02', raw: { from: '2023-01-01', to: '2023-01-02' } }, + scopedVars: {}, + targets: [], + timezone: 'UTC', + app: 'grafana', + startTime: Date.now(), + }, + } as any; + const mockEntry2 = { + type: 'SceneQueryRunner/runQueries', + origin: panel, + request: { + requestId: 'query-2', + interval: '1s', + intervalMs: 1000, + range: { from: '2023-01-01', to: '2023-01-02', raw: { from: '2023-01-01', to: '2023-01-02' } }, + scopedVars: {}, + targets: [], + timezone: 'UTC', + app: 'grafana', + startTime: Date.now(), + }, + } as any; + + // Reset mock call count for this test + jest.clearAllMocks(); + + // Start first query + const endQuery1Callback = profiler.onQueryStarted(performance.now(), mockEntry1, 'query-1'); + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'query-1', + queryType: 'SceneQueryRunner/runQueries', + }), + }) + ); + + // Start second query (should also notify observer) + const endQuery2Callback = profiler.onQueryStarted(performance.now(), mockEntry2, 'query-2'); + expect(mockScenePerformanceTracker.notifyPanelOperationStart).toHaveBeenCalledTimes(2); + + // Complete first query + performanceNowSpy.mockReturnValue(1050); + endQuery1Callback!(performance.now()); // Success case - no error + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('query-'), + operation: 'query', + panelId: '42', + panelKey: 'test-panel-1', + metadata: expect.objectContaining({ + queryId: 'query-1', + queryType: 'SceneQueryRunner/runQueries', + }), + }) + ); + + // Complete second query + performanceNowSpy.mockReturnValue(1100); + endQuery2Callback!(performance.now()); // Success case - no error + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledTimes(2); + }); + + it('should get query count from SceneQueryRunner', () => { + // Test the _getQueryCount method + const queryRunner = new SceneQueryRunner({ + queries: [ + { refId: 'A', expr: 'metric1' }, + { refId: 'B', expr: 'metric2' }, + { refId: 'C', expr: 'metric3' }, + ], + }); + + panel.setState({ + $data: queryRunner, + }); + + // Call the private method via field config processing which uses it + const endFieldConfigCallback = profiler.onFieldConfigStart(performance.now()); + performanceNowSpy.mockReturnValue(1050); + endFieldConfigCallback!(1000, 5); + + // The query count should be included in observer notifications - verify they were called + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('fieldConfig-'), + panelId: '42', // Uses panel's legacy ID + operation: 'fieldConfig', + }) + ); + }); + + it('should get query count from SceneDataTransformer wrapping SceneQueryRunner', () => { + // Test query count with SceneDataTransformer + const queryRunner = new SceneQueryRunner({ + queries: [ + { refId: 'A', expr: 'metric1' }, + { refId: 'B', expr: 'metric2' }, + ], + }); + + const dataTransformer = new SceneDataTransformer({ + $data: queryRunner, + transformations: [], + }); + + panel.setState({ + $data: dataTransformer, + }); + + // Call field config processing which uses _getQueryCount + const endFieldConfigCallback = profiler.onFieldConfigStart(performance.now()); + performanceNowSpy.mockReturnValue(1050); + endFieldConfigCallback!(500, 2); + + // Should work without errors even with wrapped query runner - verify observer notifications + expect(mockScenePerformanceTracker.notifyPanelOperationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: expect.stringContaining('fieldConfig-'), + panelId: '42', // Uses panel's legacy ID + operation: 'fieldConfig', + }) + ); + }); + + it('should use callback pattern for simple render tracking', () => { + const deactivate = panel.activate(); + + const startTime = 1000; + performanceNowSpy.mockReturnValue(startTime); + + // Call onSimpleRenderStart and get the callback + const endRenderCallback = profiler.onSimpleRenderStart(startTime); + + // Should return a callback function + expect(endRenderCallback).toBeInstanceOf(Function); + + // Mock end time + const endTime = 1050; + const duration = 50; + performanceNowSpy.mockReturnValue(endTime); + + // Call the callback to complete the render + endRenderCallback!(endTime, duration); + + // Should work without errors - the callback handles all the internal logic + expect(performanceNowSpy).toHaveBeenCalled(); + + deactivate(); + }); + + it('should return undefined callback when panel key is missing', () => { + // Create profiler without activating panel (no panelKey) + const profilerNotActivated = new VizPanelRenderProfiler(); + + const callback = profilerNotActivated.onSimpleRenderStart(1000); + + // Should return undefined when panel not properly initialized + expect(callback).toBeUndefined(); + }); + + it('should handle cleanup gracefully', () => { + const deactivate = panel.activate(); + + // Deactivate should not throw errors + expect(() => deactivate()).not.toThrow(); + }); + }); +}); diff --git a/packages/scenes/src/performance/VizPanelRenderProfiler.ts b/packages/scenes/src/performance/VizPanelRenderProfiler.ts new file mode 100644 index 000000000..2198f01b8 --- /dev/null +++ b/packages/scenes/src/performance/VizPanelRenderProfiler.ts @@ -0,0 +1,419 @@ +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObjectState } from '../core/types'; +import { VizPanel } from '../components/VizPanel/VizPanel'; +import { writeSceneLog } from '../utils/writeSceneLog'; +import { sceneGraph } from '../core/sceneGraph'; +import { SceneQueryControllerEntry } from '../behaviors/types'; +import { QueryProfilerLike } from '../querying/registerQueryWithController'; +import { + QueryCompletionCallback, + PluginLoadCompletionCallback, + FieldConfigCompletionCallback, + RenderCompletionCallback, + DataTransformCompletionCallback, +} from './types'; +import { getScenePerformanceTracker, generateOperationId } from './ScenePerformanceTracker'; + +export interface VizPanelRenderProfilerState extends SceneObjectState {} + +/** + * Tracks performance metrics for individual VizPanel instances using observer pattern. + * + * Performance events are sent to ScenePerformanceTracker observers, which are consumed + * by Grafana's ScenePerformanceLogger and DashboardAnalyticsAggregator. + */ + +export class VizPanelRenderProfiler extends SceneObjectBase implements QueryProfilerLike { + private _panelKey?: string; + private _panelId?: string; + private _pluginId?: string; + private _pluginVersion?: string; + private _isTracking = false; + private _loadPluginStartTime?: number; + private _applyFieldConfigStartTime?: number; + private _activeQueries = new Map(); + + public constructor(state: Partial = {}) { + super({ + ...state, + }); + + this.addActivationHandler(() => { + return this._onActivate(); + }); + } + + private _onActivate() { + let panel: VizPanel | undefined; + + try { + panel = sceneGraph.getAncestor(this, VizPanel); + } catch (error) { + writeSceneLog('VizPanelRenderProfiler', 'Failed to find VizPanel ancestor', error); + return; + } + + if (!panel) { + writeSceneLog('VizPanelRenderProfiler', 'Not attached to a VizPanel'); + return; + } + + if (!panel.state.key) { + writeSceneLog('VizPanelRenderProfiler', 'Panel has no key, skipping tracking'); + return; + } + + this._panelKey = panel.state.key; + this._panelId = String(panel.getLegacyPanelId()); + this._pluginId = panel.state.pluginId; + const plugin = panel.getPlugin(); + this._pluginVersion = plugin?.meta?.info?.version; + + this._subs.add( + panel.subscribeToState((newState, prevState) => { + this._handlePanelStateChange(panel, newState, prevState); + }) + ); + + return () => { + this._cleanup(); + }; + } + + private _handlePanelStateChange(panel: VizPanel, newState: any, prevState: any) { + if (newState.pluginId !== prevState.pluginId) { + this._onPluginChange(panel, newState.pluginId); + } + } + + /** + * Track query execution with operation ID correlation + */ + public onQueryStarted( + timestamp: number, + entry: SceneQueryControllerEntry, + queryId: string + ): QueryCompletionCallback | null { + if (!this._panelKey) { + return null; + } + + this._activeQueries.set(queryId, { entry, startTime: timestamp }); + + const operationId = generateOperationId('query'); + + // ✅ Use panel operation tracking for panel queries + getScenePerformanceTracker().notifyPanelOperationStart({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + pluginVersion: this._pluginVersion, + operation: 'query', + timestamp, + metadata: { + queryId: queryId, + queryType: entry.type, + }, + }); + + // Return end callback with captured operationId and query context + return (endTimestamp: number, error?: any) => { + if (!this._panelKey) { + return; + } + + const queryInfo = this._activeQueries.get(queryId); + if (!queryInfo) { + return; + } + + const duration = endTimestamp - queryInfo.startTime; + this._activeQueries.delete(queryId); + + getScenePerformanceTracker().notifyPanelOperationComplete({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + pluginVersion: this._pluginVersion, + operation: 'query', + timestamp: endTimestamp, + duration: duration, + metadata: { + queryId: queryId, + queryType: entry.type, + }, + error: error ? error?.message || String(error) || 'Unknown error' : undefined, + }); + }; + } + + /** + * Track plugin loading with operation ID correlation + */ + public onPluginLoadStart(pluginId: string): PluginLoadCompletionCallback | null { + // Initialize early since plugin loading happens before _onActivate + if (!this._panelKey) { + let panel: VizPanel | undefined; + + try { + panel = sceneGraph.getAncestor(this, VizPanel); + } catch (error) { + return null; + } + + if (panel && !this._panelKey && panel.state.key) { + this._panelKey = panel.state.key; + this._panelId = String(panel.getLegacyPanelId()); + this._pluginId = pluginId; + } + } + + if (!this._panelKey) { + return null; + } + + if (!this._isTracking) { + this._startTracking(); + } + + this._loadPluginStartTime = performance.now(); + + const operationId = generateOperationId('pluginLoad'); + getScenePerformanceTracker().notifyPanelOperationStart({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'plugin-load', + timestamp: this._loadPluginStartTime, + metadata: { + pluginId, + }, + }); + + // Return end callback with captured operationId and panel context + return (plugin: any, fromCache = false) => { + if (!this._panelKey || !this._loadPluginStartTime) { + return; + } + + const duration = performance.now() - this._loadPluginStartTime; + + getScenePerformanceTracker().notifyPanelOperationComplete({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'plugin-load', + timestamp: performance.now(), + duration, + metadata: { + pluginId: this._pluginId!, + fromCache, + pluginLoadTime: duration, + }, + }); + + this._loadPluginStartTime = undefined; + }; + } + + /** + * Track field config processing with operation ID correlation + */ + public onFieldConfigStart(timestamp: number): FieldConfigCompletionCallback | null { + if (!this._panelKey) { + return null; + } + + this._applyFieldConfigStartTime = timestamp; + + const operationId = generateOperationId('fieldConfig'); + getScenePerformanceTracker().notifyPanelOperationStart({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'fieldConfig', + timestamp: this._applyFieldConfigStartTime, + metadata: {}, + }); + + // Return end callback with captured operationId and panel context + return (endTimestamp: number, dataPointsCount?: number, seriesCount?: number) => { + if (!this._panelKey || !this._applyFieldConfigStartTime) { + return; + } + + const duration = endTimestamp - this._applyFieldConfigStartTime; + + getScenePerformanceTracker().notifyPanelOperationComplete({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'fieldConfig', + timestamp: endTimestamp, + duration, + metadata: {}, + }); + + this._applyFieldConfigStartTime = undefined; + }; + } + + /** + * Get panel info for logging - truncates long titles for readability + */ + private _getPanelInfo(): string { + let panel: VizPanel | undefined; + + try { + panel = sceneGraph.getAncestor(this, VizPanel); + } catch (error) { + // If we can't find the panel, use fallback info + } + + let panelTitle = panel?.state.title || this._panelKey || 'No-key panel'; + + if (panelTitle.length > 30) { + panelTitle = panelTitle.substring(0, 27) + '...'; + } + + return `VizPanelRenderProfiler [${panelTitle}]`; + } + + /** + * Track simple render timing with operation ID correlation + */ + public onSimpleRenderStart(timestamp: number): RenderCompletionCallback | undefined { + if (!this._panelKey) { + return undefined; + } + + const operationId = generateOperationId('render'); + getScenePerformanceTracker().notifyPanelOperationStart({ + operationId, + panelId: this._panelId || 'unknown', + panelKey: this._panelKey, + pluginId: this._pluginId || 'unknown', + pluginVersion: this._pluginVersion, + operation: 'render', + timestamp, + metadata: {}, + }); + + // Return end callback with captured operationId and panel context + return (endTimestamp: number, duration: number) => { + if (!this._panelKey) { + return; + } + + getScenePerformanceTracker().notifyPanelOperationComplete({ + operationId, + panelId: this._panelId || 'unknown', + panelKey: this._panelKey, + pluginId: this._pluginId || 'unknown', + pluginVersion: this._pluginVersion, + operation: 'render', + duration, + timestamp: endTimestamp, + metadata: {}, + }); + }; + } + + /** Handle plugin changes */ + private _onPluginChange(panel: VizPanel, newPluginId: string) { + this._pluginId = newPluginId; + const plugin = panel.getPlugin(); + this._pluginVersion = plugin?.meta?.info?.version; + + writeSceneLog(this._getPanelInfo(), `Plugin changed to ${newPluginId}`); + } + + /** Start tracking this panel */ + private _startTracking() { + if (!this._panelKey || !this._pluginId || this._isTracking) { + return; + } + + this._isTracking = true; + } + + /** Cleanup when behavior is deactivated */ + private _cleanup() { + this._activeQueries.clear(); + this._isTracking = false; + writeSceneLog(this._getPanelInfo(), 'Cleaned up'); + } + + /** + * Track data transformation with operation ID correlation + */ + public onDataTransformStart( + timestamp: number, + transformationId: string, + metrics: { + transformationCount: number; + seriesTransformationCount: number; + annotationTransformationCount: number; + } + ): DataTransformCompletionCallback | null { + if (!this._panelKey) { + return null; + } + + const operationId = generateOperationId('transform'); + getScenePerformanceTracker().notifyPanelOperationStart({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'transform', + timestamp, + metadata: { + transformationId, + transformationCount: metrics.transformationCount, + seriesTransformationCount: metrics.seriesTransformationCount, + annotationTransformationCount: metrics.annotationTransformationCount, + }, + }); + + // Return end callback with captured operationId and panel context + return ( + endTimestamp: number, + duration: number, + success: boolean, + result?: { + outputSeriesCount?: number; + outputAnnotationsCount?: number; + error?: string; + } + ) => { + if (!this._panelKey) { + return; + } + + getScenePerformanceTracker().notifyPanelOperationComplete({ + operationId, + panelId: this._panelId!, + panelKey: this._panelKey, + pluginId: this._pluginId!, + operation: 'transform', + timestamp: endTimestamp, + duration, + metadata: { + transformationId, + transformationCount: metrics.transformationCount, + seriesTransformationCount: metrics.seriesTransformationCount, + annotationTransformationCount: metrics.annotationTransformationCount, + success, + error: result?.error || (!success ? 'Transform operation failed' : undefined), + }, + }); + }; + } +} diff --git a/packages/scenes/src/performance/index.ts b/packages/scenes/src/performance/index.ts new file mode 100644 index 000000000..d4de95738 --- /dev/null +++ b/packages/scenes/src/performance/index.ts @@ -0,0 +1,13 @@ +// Performance tracking exports - EXTERNAL API ONLY (used by Grafana) +export { SceneRenderProfiler } from './SceneRenderProfiler'; + +// Performance observer pattern - essential external API +export { + getScenePerformanceTracker, + type ScenePerformanceObserver, + type DashboardInteractionStartData, + type DashboardInteractionMilestoneData, + type DashboardInteractionCompleteData, + type PanelPerformanceData, + type QueryPerformanceData, +} from './ScenePerformanceTracker'; diff --git a/packages/scenes/src/performance/interactionConstants.ts b/packages/scenes/src/performance/interactionConstants.ts new file mode 100644 index 000000000..acea82b8a --- /dev/null +++ b/packages/scenes/src/performance/interactionConstants.ts @@ -0,0 +1,15 @@ +/** + * Constants for different types of dashboard interactions tracked by SceneRenderProfiler + */ + +export const REFRESH_INTERACTION = 'refresh'; +export const TIME_RANGE_CHANGE_INTERACTION = 'time_range_change'; +export const FILTER_ADDED_INTERACTION = 'filter_added'; +export const FILTER_REMOVED_INTERACTION = 'filter_removed'; +export const FILTER_CHANGED_INTERACTION = 'filter_changed'; +export const FILTER_RESTORED_INTERACTION = 'filter_restored'; +export const VARIABLE_VALUE_CHANGED_INTERACTION = 'variable_value_changed'; +export const SCOPES_CHANGED_INTERACTION = 'scopes_changed'; +export const ADHOC_KEYS_DROPDOWN_INTERACTION = 'adhoc_keys_dropdown'; +export const ADHOC_VALUES_DROPDOWN_INTERACTION = 'adhoc_values_dropdown'; +export const GROUPBY_DIMENSIONS_INTERACTION = 'groupby_dimensions'; diff --git a/packages/scenes/src/performance/types.ts b/packages/scenes/src/performance/types.ts new file mode 100644 index 000000000..2a167e2c8 --- /dev/null +++ b/packages/scenes/src/performance/types.ts @@ -0,0 +1,20 @@ +/** + * Performance tracking callback types + */ + +export type QueryCompletionCallback = (endTimestamp: number, error?: any) => void; +export type PluginLoadCompletionCallback = (plugin: any, fromCache?: boolean) => void; +export type FieldConfigCompletionCallback = ( + endTimestamp: number, + dataPointsCount?: number, + seriesCount?: number +) => void; +export type RenderCompletionCallback = (endTimestamp: number, duration: number) => void; +export type DataTransformCompletionCallback = ( + endTimestamp: number, + duration: number, + success: boolean, + result?: { + error?: string; + } +) => void; diff --git a/packages/scenes/src/querying/SceneDataTransformer.ts b/packages/scenes/src/querying/SceneDataTransformer.ts index 8311a9b86..c504c7cf1 100644 --- a/packages/scenes/src/querying/SceneDataTransformer.ts +++ b/packages/scenes/src/querying/SceneDataTransformer.ts @@ -15,6 +15,7 @@ import { SceneObjectBase } from '../core/SceneObjectBase'; import { CustomTransformerDefinition, SceneDataProvider, SceneDataProviderResult, SceneDataState } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; import { SceneDataLayerSet } from './SceneDataLayerSet'; +import { findPanelProfiler } from '../utils/findPanelProfiler'; export interface SceneDataTransformerState extends SceneDataState { /** @@ -103,6 +104,41 @@ export class SceneDataTransformer extends SceneObjectBase + ): { + transformationCount: number; + seriesTransformationCount: number; + annotationTransformationCount: number; + } { + const transformationCount = transformations.length; + + // Count transformations by topic (series vs annotations) + const seriesTransformationCount = transformations.filter((transformation) => { + if ('options' in transformation || 'topic' in transformation) { + return transformation.topic == null || transformation.topic === DataTopic.Series; + } + return true; // Custom transformations default to series + }).length; + + const annotationTransformationCount = transformations.filter((transformation) => { + if ('options' in transformation || 'topic' in transformation) { + return transformation.topic === DataTopic.Annotations; + } + return false; + }).length; + + return { + transformationCount, + seriesTransformationCount, + annotationTransformationCount, + }; + } + public cancelQuery() { this.getSourceData().cancelQuery?.(); } @@ -151,6 +187,24 @@ export class SceneDataTransformer extends SceneObjectBase void) + | null = null; + if (this.state.transformations.length === 0 || !data) { this._prevDataFromSource = data; this.setState({ data }); @@ -166,6 +220,29 @@ export class SceneDataTransformer extends SceneObjectBase { + if ('id' in t) { + // Standard DataTransformerConfig + return t.id; + } else { + // CustomTransformerDefinition + return 'customTransformation'; + } + }) + .join('+'); + transformationId = transformationTypes || 'no-transforms'; + + // Calculate transformation complexity metrics + const metrics = this._calculateTransformationMetrics(data, this.state.transformations); + + // Start the DataProcessing phase with centralized logging - get end callback + endTransformCallback = profiler.onDataTransformStart(timestamp, transformationId, metrics); + } + const interpolatedTransformations = this._interpolateVariablesInTransformationConfigs(data); const seriesTransformations = this._filterAndPrepareTransformationsByTopic( @@ -221,6 +298,17 @@ export class SceneDataTransformer extends SceneObjectBase { + const timestamp = performance.now(); + // S3.1: Performance tracking for transformation errors + const duration = timestamp - transformStartTime; + + if (endTransformCallback) { + // End the DataProcessing phase with centralized logging using callback + endTransformCallback(timestamp, duration, false, { + error: err.message || err, + }); + } + console.error('Error transforming data: ', err); const sourceErr = this.getSourceData().state.data?.errors || []; @@ -238,6 +326,15 @@ export class SceneDataTransformer extends SceneObjectBase { + const timestamp = performance.now(); + const duration = timestamp - transformStartTime; + if (endTransformCallback) { + // End the DataProcessing phase with centralized logging using callback + endTransformCallback(timestamp, duration, true, { + outputSeriesCount: transformedData.series.length, + outputAnnotationsCount: transformedData.annotations?.length || 0, + }); + } this.setState({ data: transformedData }); this._results.next({ origin: this, data: transformedData }); this._prevDataFromSource = data; diff --git a/packages/scenes/src/querying/SceneQueryRunner.test.ts b/packages/scenes/src/querying/SceneQueryRunner.test.ts index 8d83d8db7..cd83146ed 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.test.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.test.ts @@ -1,5 +1,12 @@ import { map, Observable, of } from 'rxjs'; +// Mock crypto.randomUUID for generateOperationId +Object.defineProperty(global, 'crypto', { + value: { + randomUUID: jest.fn(() => 'test-uuid-1234-5678-9abc-def012345678'), + }, +}); + import { DataQueryRequest, DataQueryResponse, diff --git a/packages/scenes/src/querying/SceneQueryRunner.ts b/packages/scenes/src/querying/SceneQueryRunner.ts index ae79b6f9b..d7c9beb98 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.ts @@ -39,8 +39,9 @@ import { isExtraQueryProvider, ExtraQueryDataProcessor, ExtraQueryProvider } fro import { passthroughProcessor, extraQueryProcessingOperator } from './extraQueryProcessingOperator'; import { filterAnnotations } from './layers/annotations/filterAnnotations'; import { getEnrichedDataRequest } from './getEnrichedDataRequest'; -import { registerQueryWithController } from './registerQueryWithController'; +import { registerQueryWithController, QueryProfilerLike } from './registerQueryWithController'; import { GroupByVariable } from '../variables/groupby/GroupByVariable'; +import { findPanelProfiler } from '../utils/findPanelProfiler'; import { AdHocFiltersVariable } from '../variables/adhoc/AdHocFiltersVariable'; import { SceneVariable } from '../variables/types'; import { DataLayersMerger } from './DataLayersMerger'; @@ -481,13 +482,18 @@ export class SceneQueryRunner extends SceneObjectBase implemen stream = forkJoin([stream, ...secondaryStreams]).pipe(op); } + const panelProfiler: QueryProfilerLike | undefined = findPanelProfiler(this); + stream = stream.pipe( - registerQueryWithController({ - type: 'SceneQueryRunner/runQueries', - request: primary, - origin: this, - cancel: () => this.cancelQuery(), - }) + registerQueryWithController( + { + type: 'SceneQueryRunner/runQueries', + request: primary, + origin: this, + cancel: () => this.cancelQuery(), + }, + panelProfiler + ) ); this._querySub = stream.subscribe(this.onDataReceived); diff --git a/packages/scenes/src/querying/registerQueryWithController.ts b/packages/scenes/src/querying/registerQueryWithController.ts index ab7a4cbac..6fb4f3400 100644 --- a/packages/scenes/src/querying/registerQueryWithController.ts +++ b/packages/scenes/src/querying/registerQueryWithController.ts @@ -2,11 +2,23 @@ import { Observable, catchError, from, map } from 'rxjs'; import { LoadingState } from '@grafana/schema'; import { sceneGraph } from '../core/sceneGraph'; import { QueryResultWithState, SceneQueryControllerEntry } from '../behaviors/types'; +import { getScenePerformanceTracker, generateOperationId } from '../performance/ScenePerformanceTracker'; + +// Import performance callback types +import { QueryCompletionCallback } from '../performance/types'; + +export interface QueryProfilerLike { + onQueryStarted(timestamp: number, entry: SceneQueryControllerEntry, queryId: string): QueryCompletionCallback | null; +} /** * Will look for a scene object with a behavior that is a SceneQueryController and register the query with it. + * Optionally accepts a panel profiler for direct query tracking callbacks. */ -export function registerQueryWithController(entry: SceneQueryControllerEntry) { +export function registerQueryWithController( + entry: SceneQueryControllerEntry, + profiler?: QueryProfilerLike +) { return (queryStream: Observable) => { const queryControler = sceneGraph.getQueryController(entry.origin); if (!queryControler) { @@ -18,6 +30,40 @@ export function registerQueryWithController(entr entry.cancel = () => observer.complete(); } + // Use existing request ID if available, otherwise generate one + const queryId = entry.request?.requestId || `${entry.type}-${Math.floor(performance.now()).toString(36)}`; + + const startTimestamp = performance.now(); + let endQueryCallback: QueryCompletionCallback | null = null; + + if (profiler) { + // Panel query: Use panel profiler + endQueryCallback = profiler.onQueryStarted(startTimestamp, entry, queryId); + } else { + // Non-panel query: Track directly with simple approach + const operationId = generateOperationId('query'); + getScenePerformanceTracker().notifyQueryStart({ + operationId, + queryId, + queryType: entry.type, + origin: entry.origin.constructor.name, + timestamp: startTimestamp, + }); + + // Create simple end callback for non-panel queries + endQueryCallback = (endTimestamp: number, error?: any) => { + getScenePerformanceTracker().notifyQueryComplete({ + operationId, + queryId, + queryType: entry.type, + origin: entry.origin.constructor.name, + timestamp: endTimestamp, + duration: endTimestamp - startTimestamp, + error: error ? error?.message || String(error) || 'Unknown error' : undefined, + }); + }; + } + queryControler.queryStarted(entry); let markedAsCompleted = false; @@ -26,11 +72,19 @@ export function registerQueryWithController(entr if (!markedAsCompleted && v.state !== LoadingState.Loading) { markedAsCompleted = true; queryControler.queryCompleted(entry); + endQueryCallback?.(performance.now()); // Success case - no error } observer.next(v); }, - error: (e) => observer.error(e), + error: (e) => { + if (!markedAsCompleted) { + markedAsCompleted = true; + queryControler.queryCompleted(entry); + endQueryCallback?.(performance.now(), e); // Error case - pass error + } + observer.error(e); + }, complete: () => { observer.complete(); }, @@ -41,6 +95,7 @@ export function registerQueryWithController(entr if (!markedAsCompleted) { queryControler.queryCompleted(entry); + endQueryCallback?.(performance.now()); // Cleanup case - no error } }; }); diff --git a/packages/scenes/src/utils/findPanelProfiler.ts b/packages/scenes/src/utils/findPanelProfiler.ts new file mode 100644 index 000000000..c61c847e1 --- /dev/null +++ b/packages/scenes/src/utils/findPanelProfiler.ts @@ -0,0 +1,22 @@ +import { sceneGraph } from '../core/sceneGraph'; +import { SceneObject } from '../core/types'; +import { VizPanel } from '../components/VizPanel/VizPanel'; +import { VizPanelRenderProfiler } from '../performance/VizPanelRenderProfiler'; + +/** + * Find VizPanelRenderProfiler for a scene object by traversing up to find VizPanel ancestor. + * @param sceneObject - Scene object to start search from + * @returns VizPanelRenderProfiler if found, undefined otherwise + */ +export function findPanelProfiler(sceneObject: SceneObject): VizPanelRenderProfiler | undefined { + try { + const panel = sceneGraph.getAncestor(sceneObject, VizPanel); + if (panel) { + const behaviors = panel.state.$behaviors || []; + return behaviors.find((b): b is VizPanelRenderProfiler => b instanceof VizPanelRenderProfiler); + } + } catch (error) { + // Continue without tracking if panel not found + } + return undefined; +} diff --git a/packages/scenes/src/utils/writePerformanceLog.ts b/packages/scenes/src/utils/writePerformanceLog.ts new file mode 100644 index 000000000..37922181a --- /dev/null +++ b/packages/scenes/src/utils/writePerformanceLog.ts @@ -0,0 +1,11 @@ +export function writePerformanceLog(logger: string, message: string, ...rest: unknown[]) { + let loggingEnabled = false; + + if (typeof window !== 'undefined') { + loggingEnabled = localStorage.getItem('grafana.debug.sceneProfiling') === 'true'; + } + + if (loggingEnabled) { + console.log(`${logger}: `, message, ...rest); + } +} diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx index 89dc90477..a94c1763e 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersCombobox.tsx @@ -52,7 +52,7 @@ import { FILTER_CHANGED_INTERACTION, ADHOC_KEYS_DROPDOWN_INTERACTION, ADHOC_VALUES_DROPDOWN_INTERACTION, -} from '../../../behaviors/SceneRenderProfiler'; +} from '../../../performance/interactionConstants'; import { getInteractionTracker } from '../../../core/sceneGraph/getInteractionTracker'; interface AdHocComboboxProps { diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx index 8c662f556..ccf4beffa 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -29,7 +29,7 @@ import { debounce, isEqual } from 'lodash'; import { getAdHocFiltersFromScopes } from './getAdHocFiltersFromScopes'; import { VariableDependencyConfig } from '../VariableDependencyConfig'; import { getQueryController } from '../../core/sceneGraph/getQueryController'; -import { FILTER_REMOVED_INTERACTION, FILTER_RESTORED_INTERACTION } from '../../behaviors/SceneRenderProfiler'; +import { FILTER_REMOVED_INTERACTION, FILTER_RESTORED_INTERACTION } from '../../performance/interactionConstants'; export interface AdHocFilterWithLabels = {}> extends AdHocVariableFilter { keyLabel?: string; diff --git a/packages/scenes/src/variables/components/VariableValueSelect.tsx b/packages/scenes/src/variables/components/VariableValueSelect.tsx index 2b43bdaef..93f3f71ca 100644 --- a/packages/scenes/src/variables/components/VariableValueSelect.tsx +++ b/packages/scenes/src/variables/components/VariableValueSelect.tsx @@ -19,7 +19,7 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { getOptionSearcher } from './getOptionSearcher'; import { sceneGraph } from '../../core/sceneGraph'; -import { VARIABLE_VALUE_CHANGED_INTERACTION } from '../../behaviors/SceneRenderProfiler'; +import { VARIABLE_VALUE_CHANGED_INTERACTION } from '../../performance/interactionConstants'; const filterNoOp = () => true; diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.tsx index 16ae0b53f..4a7bcedd7 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.tsx @@ -34,7 +34,7 @@ import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSeriali import { DefaultGroupByCustomIndicatorContainer } from './DefaultGroupByCustomIndicatorContainer'; import { GroupByValueContainer, GroupByContainerProps } from './GroupByValueContainer'; import { getInteractionTracker } from '../../core/sceneGraph/getInteractionTracker'; -import { GROUPBY_DIMENSIONS_INTERACTION } from '../../behaviors/SceneRenderProfiler'; +import { GROUPBY_DIMENSIONS_INTERACTION } from '../../performance/interactionConstants'; export interface GroupByVariableState extends MultiValueVariableState { /** Defaults to "Group" */ diff --git a/packages/scenes/src/variables/variants/MultiValueVariable.ts b/packages/scenes/src/variables/variants/MultiValueVariable.ts index 5371a48c5..152b38c21 100644 --- a/packages/scenes/src/variables/variants/MultiValueVariable.ts +++ b/packages/scenes/src/variables/variants/MultiValueVariable.ts @@ -20,7 +20,7 @@ import { formatRegistry } from '../interpolation/formatRegistry'; import { VariableFormatID } from '@grafana/schema'; import { SceneVariableSet } from '../sets/SceneVariableSet'; import { setBaseClassState } from '../../utils/utils'; -import { VARIABLE_VALUE_CHANGED_INTERACTION } from '../../behaviors/SceneRenderProfiler'; +import { VARIABLE_VALUE_CHANGED_INTERACTION } from '../../performance/interactionConstants'; import { getQueryController } from '../../core/sceneGraph/getQueryController'; export interface MultiValueVariableState extends SceneVariableState { diff --git a/packages/scenes/src/variables/variants/ScopesVariable.tsx b/packages/scenes/src/variables/variants/ScopesVariable.tsx index d97d1625e..5653f3da5 100644 --- a/packages/scenes/src/variables/variants/ScopesVariable.tsx +++ b/packages/scenes/src/variables/variants/ScopesVariable.tsx @@ -14,7 +14,7 @@ import { VariableFormatID, VariableHide } from '@grafana/schema'; import { SCOPES_VARIABLE_NAME } from '../constants'; import { isEqual } from 'lodash'; import { getQueryController } from '../../core/sceneGraph/getQueryController'; -import { SCOPES_CHANGED_INTERACTION } from '../../behaviors/SceneRenderProfiler'; +import { SCOPES_CHANGED_INTERACTION } from '../../performance/interactionConstants'; export interface ScopesVariableState extends SceneVariableState { /**