diff --git a/packages/scenes/src/behaviors/SceneQueryController.test.ts b/packages/scenes/src/behaviors/SceneQueryController.test.ts index 1973295d7..b3083b09f 100644 --- a/packages/scenes/src/behaviors/SceneQueryController.test.ts +++ b/packages/scenes/src/behaviors/SceneQueryController.test.ts @@ -2,8 +2,9 @@ import { LoadingState } from '@grafana/schema'; import { Observable } from 'rxjs'; import { SceneObject } from '../core/types'; import { TestScene } from '../variables/TestScene'; -import { QueryResultWithState, SceneQueryController } from './SceneQueryController'; +import { SceneQueryController } from './SceneQueryController'; import { registerQueryWithController } from '../querying/registerQueryWithController'; +import { QueryResultWithState } from './types'; describe('SceneQueryController', () => { let controller: SceneQueryController; diff --git a/packages/scenes/src/behaviors/SceneQueryController.ts b/packages/scenes/src/behaviors/SceneQueryController.ts index 36df469ad..7af82d2ad 100644 --- a/packages/scenes/src/behaviors/SceneQueryController.ts +++ b/packages/scenes/src/behaviors/SceneQueryController.ts @@ -1,47 +1,28 @@ import { SceneObjectBase } from '../core/SceneObjectBase'; -import { SceneObject, SceneObjectState, SceneStatelessBehavior } from '../core/types'; -import { DataQueryRequest } from '@grafana/data'; -import { LoadingState } from '@grafana/schema'; - -export interface SceneQueryStateControllerState extends SceneObjectState { - isRunning: boolean; -} - -export interface SceneQueryControllerLike extends SceneObject { - isQueryController: true; - cancelAll(): void; - - queryStarted(entry: SceneQueryControllerEntry): void; - queryCompleted(entry: SceneQueryControllerEntry): void; -} +import { SceneObject, SceneStatelessBehavior } from '../core/types'; +import { writeSceneLog } from '../utils/writeSceneLog'; +import { SceneRenderProfiler } from './SceneRenderProfiler'; +import { SceneQueryControllerEntry, SceneQueryControllerLike, SceneQueryStateControllerState } from './types'; export function isQueryController(s: SceneObject | SceneStatelessBehavior): s is SceneQueryControllerLike { return 'isQueryController' in s; } -export interface QueryResultWithState { - state: LoadingState; -} - -export interface SceneQueryControllerEntry { - request?: DataQueryRequest; - type: SceneQueryControllerEntryType; - origin: SceneObject; - cancel?: () => void; -} - -export type SceneQueryControllerEntryType = 'data' | 'annotations' | 'variable' | 'alerts'; - export class SceneQueryController extends SceneObjectBase implements SceneQueryControllerLike { public isQueryController: true = true; + private profiler = new SceneRenderProfiler(this); #running = new Set(); - public constructor() { - super({ isRunning: false }); + #tryCompleteProfileFrameId: number | null = null; + + // lastFrameTime: number = 0; + + public constructor(state: Partial = {}) { + super({ ...state, isRunning: false }); // Clear running state on deactivate this.addActivationHandler(() => { @@ -49,10 +30,19 @@ export class SceneQueryController }); } + public runningQueriesCount = () => { + return this.#running.size; + }; + public startProfile(source: SceneObject) { + if (!this.state.enableProfiling) { + return; + } + this.profiler.startProfile(source.constructor.name); + } + public queryStarted(entry: SceneQueryControllerEntry) { this.#running.add(entry); - - this.changeRunningQueryCount(1); + this.changeRunningQueryCount(1, entry); if (!this.state.isRunning) { this.setState({ isRunning: true }); @@ -73,11 +63,35 @@ export class SceneQueryController } } - private changeRunningQueryCount(dir: 1 | -1) { + private changeRunningQueryCount(dir: 1 | -1, entry?: SceneQueryControllerEntry) { /** * Used by grafana-image-renderer to know when all queries are completed. */ (window as any).__grafanaRunningQueryCount = ((window as any).__grafanaRunningQueryCount ?? 0) + dir; + + // console.log('\tRunning queries:', (window as any).__grafanaRunningQueryCount); + if (dir === 1 && this.state.enableProfiling) { + if (entry) { + // Collect profile crumbs, variables, annotations, queries and plugins + this.profiler.addCrumb(`${entry.origin.constructor.name}/${entry.type}`); + } + if (this.profiler.isTailRecording()) { + writeSceneLog(this.constructor.name, 'New query started, cancelling tail recording'); + this.profiler.cancelTailRecording(); + } + } + + if (this.state.enableProfiling) { + // Delegate to next frame to check if all queries are completed + // This is to account for scenarios when there's "yet another" query that's started + if (this.#tryCompleteProfileFrameId) { + cancelAnimationFrame(this.#tryCompleteProfileFrameId); + } + + this.#tryCompleteProfileFrameId = requestAnimationFrame(() => { + this.profiler.tryCompletingProfile(); + }); + } } public cancelAll() { diff --git a/packages/scenes/src/behaviors/SceneRenderProfiler.test.ts b/packages/scenes/src/behaviors/SceneRenderProfiler.test.ts new file mode 100644 index 000000000..0276558d1 --- /dev/null +++ b/packages/scenes/src/behaviors/SceneRenderProfiler.test.ts @@ -0,0 +1,69 @@ +import { calculateNetworkTime, processRecordedSpans } from './SceneRenderProfiler'; + +describe('calculateNetworkTime', () => { + it('should return the duration of a single request', () => { + const requests: PerformanceResourceTiming[] = [{ startTime: 0, responseEnd: 100 } as PerformanceResourceTiming]; + expect(calculateNetworkTime(requests)).toBe(100); + }); + + it('should return the total time for non-overlapping requests', () => { + const requests: PerformanceResourceTiming[] = [ + { startTime: 0, responseEnd: 100 } as PerformanceResourceTiming, + { startTime: 200, responseEnd: 300 } as PerformanceResourceTiming, + ]; + expect(calculateNetworkTime(requests)).toBe(200); + }); + + it('should merge overlapping requests and return the correct total time', () => { + const requests: PerformanceResourceTiming[] = [ + { startTime: 0, responseEnd: 100 } as PerformanceResourceTiming, + { startTime: 50, responseEnd: 150 } as PerformanceResourceTiming, + { startTime: 200, responseEnd: 300 } as PerformanceResourceTiming, + ]; + expect(calculateNetworkTime(requests)).toBe(250); + }); + + it('should handle multiple overlapping intervals', () => { + const requests: PerformanceResourceTiming[] = [ + { startTime: 0, responseEnd: 200 } as PerformanceResourceTiming, + { startTime: 100, responseEnd: 300 } as PerformanceResourceTiming, + { startTime: 250, responseEnd: 350 } as PerformanceResourceTiming, + ]; + expect(calculateNetworkTime(requests)).toBe(350); + }); + + it('should handle multiple overlapping intervals in wrong order', () => { + const requests: PerformanceResourceTiming[] = [ + { startTime: 100, responseEnd: 300 } as PerformanceResourceTiming, + { startTime: 0, responseEnd: 200 } as PerformanceResourceTiming, + { startTime: 250, responseEnd: 350 } as PerformanceResourceTiming, + ]; + expect(calculateNetworkTime(requests)).toBe(350); + }); + + it('should correctly calculate time with gaps between requests', () => { + const requests: PerformanceResourceTiming[] = [ + { startTime: 0, responseEnd: 100 } as PerformanceResourceTiming, + { startTime: 150, responseEnd: 250 } as PerformanceResourceTiming, + { startTime: 300, responseEnd: 400 } as PerformanceResourceTiming, + ]; + expect(calculateNetworkTime(requests)).toBe(300); + }); +}); + +describe('processRecordedSpans', () => { + it('should return the whole array if the last element is greater than 30', () => { + const spans = [10, 10, 40]; + expect(processRecordedSpans(spans)).toEqual([10, 10, 40]); + }); + + it('should return up to the last element greater than 30', () => { + const spans = [10, 20, 30, 40, 60, 10]; + expect(processRecordedSpans(spans)).toEqual([10, 20, 30, 40, 60]); + }); + + it('should return only the first element if all are below or equal to 30', () => { + const spans = [10, 20, 15, 5]; + expect(processRecordedSpans(spans)).toEqual([10]); + }); +}); diff --git a/packages/scenes/src/behaviors/SceneRenderProfiler.ts b/packages/scenes/src/behaviors/SceneRenderProfiler.ts new file mode 100644 index 000000000..208ed8d46 --- /dev/null +++ b/packages/scenes/src/behaviors/SceneRenderProfiler.ts @@ -0,0 +1,198 @@ +import { writeSceneLog } from '../utils/writeSceneLog'; +import { SceneQueryControllerLike } from './types'; + +const POST_STORM_WINDOW = 2000; // Time after last query to observe slow frames +const SPAN_THRESHOLD = 30; // Frames longer than this will be considered slow + +export class SceneRenderProfiler { + #profileInProgress: { + // Profile origin, i.e. scene refresh picker + origin: string; + crumbs: string[]; + } | null = null; + + #profileStartTs: number | null = null; + #trailAnimationFrameId: number | null = null; + + // Will keep measured lengths trailing frames + #recordedTrailingSpans: number[] = []; + + lastFrameTime: number = 0; + + public constructor(private queryController: SceneQueryControllerLike) {} + + public startProfile(name: string) { + if (this.#trailAnimationFrameId) { + cancelAnimationFrame(this.#trailAnimationFrameId); + this.#trailAnimationFrameId = null; + + writeSceneLog(this.constructor.name, 'New profile: Stopped recording frames'); + } + + this.#profileInProgress = { origin: name, crumbs: [] }; + this.#profileStartTs = performance.now(); + writeSceneLog(this.constructor.name, 'Profile started:', this.#profileInProgress, this.#profileStartTs); + } + + private recordProfileTail(measurementStartTime: number, profileStartTs: number) { + this.#trailAnimationFrameId = requestAnimationFrame(() => + this.measureTrailingFrames(measurementStartTime, measurementStartTime, profileStartTs) + ); + } + + private measureTrailingFrames = (measurementStartTs: number, lastFrameTime: number, profileStartTs: number) => { + const currentFrameTime = performance.now(); + const frameLength = currentFrameTime - lastFrameTime; + this.#recordedTrailingSpans.push(frameLength); + + if (currentFrameTime - measurementStartTs! < POST_STORM_WINDOW) { + this.#trailAnimationFrameId = requestAnimationFrame(() => + this.measureTrailingFrames(measurementStartTs, currentFrameTime, profileStartTs) + ); + } else { + const slowFrames = processRecordedSpans(this.#recordedTrailingSpans); + const slowFramesTime = slowFrames.reduce((acc, val) => acc + val, 0); + + writeSceneLog( + this.constructor.name, + 'Profile tail recorded, slow frames duration:', + slowFramesTime, + slowFrames, + this.#profileInProgress + ); + + this.#recordedTrailingSpans = []; + + const profileDuration = measurementStartTs - profileStartTs; + + writeSceneLog( + this.constructor.name, + 'Stoped recording, total measured time (network included):', + profileDuration + slowFramesTime + ); + this.#trailAnimationFrameId = null; + + // performance.measure('DashboardInteraction tail', { + // start: measurementStartTs, + // end: measurementStartTs + n, + // }); + + const profileEndTs = profileStartTs + profileDuration + slowFramesTime; + + performance.measure('DashboardInteraction', { + start: profileStartTs, + end: profileEndTs, + }); + + const networkDuration = captureNetwork(profileStartTs, profileEndTs); + + if (this.queryController.state.onProfileComplete) { + this.queryController.state.onProfileComplete({ + origin: this.#profileInProgress!.origin, + crumbs: this.#profileInProgress!.crumbs, + duration: profileDuration + slowFramesTime, + networkDuration, + // @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, + }); + } + // @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() { + writeSceneLog(this.constructor.name, 'Trying to complete profile', this.#profileInProgress); + + if (this.queryController.runningQueriesCount() === 0 && this.#profileInProgress) { + writeSceneLog(this.constructor.name, 'All queries completed, stopping profile'); + this.recordProfileTail(performance.now(), this.#profileStartTs!); + } + } + + public isTailRecording() { + return Boolean(this.#trailAnimationFrameId); + } + public cancelTailRecording() { + if (this.#trailAnimationFrameId) { + cancelAnimationFrame(this.#trailAnimationFrameId); + this.#trailAnimationFrameId = null; + writeSceneLog(this.constructor.name, 'Cancelled recording frames, new profile started'); + } + } + + public addCrumb(crumb: string) { + if (this.#profileInProgress) { + this.#profileInProgress.crumbs.push(crumb); + } + } +} + +export function processRecordedSpans(spans: number[]) { + // identify last span in spans that's bigger than SPAN_THRESHOLD + for (let i = spans.length - 1; i >= 0; i--) { + if (spans[i] > SPAN_THRESHOLD) { + return spans.slice(0, i + 1); + } + } + return [spans[0]]; +} + +function captureNetwork(startTs: number, endTs: number) { + const entries = performance.getEntriesByType('resource'); + performance.clearResourceTimings(); + const networkEntries = entries.filter((entry) => entry.startTime >= startTs && entry.startTime <= endTs); + for (const entry of networkEntries) { + performance.measure('Network entry ' + entry.name, { + start: entry.startTime, + end: entry.responseEnd, + }); + } + + return calculateNetworkTime(networkEntries); +} + +// Will calculate total time spent on Network +export function calculateNetworkTime(requests: PerformanceResourceTiming[]): number { + if (requests.length === 0) { + return 0; + } + + // Step 1: Sort the requests by startTs + requests.sort((a, b) => a.startTime - b.startTime); + + // Step 2: Initialize variables + let totalNetworkTime = 0; + let currentStart = requests[0].startTime; + let currentEnd = requests[0].responseEnd; + + // Step 3: Iterate through the sorted list and merge overlapping intervals + for (let i = 1; i < requests.length; i++) { + if (requests[i].startTime <= currentEnd) { + // Overlapping intervals, merge them + currentEnd = Math.max(currentEnd, requests[i].responseEnd); + } else { + // Non-overlapping interval, add the duration to total time + totalNetworkTime += currentEnd - currentStart; + + // Update current interval + currentStart = requests[i].startTime; + currentEnd = requests[i].responseEnd; + } + } + + // Step 4: Add the last interval + totalNetworkTime += currentEnd - currentStart; + + return totalNetworkTime; +} diff --git a/packages/scenes/src/behaviors/types.ts b/packages/scenes/src/behaviors/types.ts new file mode 100644 index 000000000..8d1ce301d --- /dev/null +++ b/packages/scenes/src/behaviors/types.ts @@ -0,0 +1,43 @@ +import { LoadingState } from '@grafana/schema'; +import { SceneObject, SceneObjectState } from '../core/types'; +import { DataQueryRequest } from '@grafana/data'; + +export interface QueryResultWithState { + state: LoadingState; +} + +export interface SceneQueryControllerEntry { + request?: DataQueryRequest; + type: SceneQueryControllerEntryType; + origin: SceneObject; + cancel?: () => void; +} + +export type SceneQueryControllerEntryType = 'data' | 'annotations' | 'variable' | 'alerts' | 'plugin'; + +export interface SceneInteractionProfileEvent { + origin: string; + duration: number; + networkDuration: number; + jsHeapSizeLimit: number; + usedJSHeapSize: number; + totalJSHeapSize: number; + crumbs: string[]; + // add more granular data,i.e. network times? slow frames? +} + +export interface SceneQueryStateControllerState extends SceneObjectState { + isRunning: boolean; + enableProfiling?: boolean; + onProfileComplete?(event: SceneInteractionProfileEvent): void; +} + +export interface SceneQueryControllerLike extends SceneObject { + isQueryController: true; + cancelAll(): void; + + queryStarted(entry: SceneQueryControllerEntry): void; + queryCompleted(entry: SceneQueryControllerEntry): void; + startProfile(source: SceneObject): void; + runningQueriesCount(): number; +} diff --git a/packages/scenes/src/components/SceneRefreshPicker.tsx b/packages/scenes/src/components/SceneRefreshPicker.tsx index 25cf1aef2..a4124b307 100644 --- a/packages/scenes/src/components/SceneRefreshPicker.tsx +++ b/packages/scenes/src/components/SceneRefreshPicker.tsx @@ -84,6 +84,9 @@ export class SceneRefreshPicker extends SceneObjectBase public onRefresh = () => { const queryController = sceneGraph.getQueryController(this); + + queryController?.startProfile(this); + if (queryController?.state.isRunning) { queryController.cancelAll(); return; @@ -187,6 +190,8 @@ export class SceneRefreshPicker extends SceneObjectBase this._intervalTimer = setInterval(() => { if (this.isTabVisible()) { + const queryController = sceneGraph.getQueryController(this); + queryController?.startProfile(this); timeRange.onRefresh(); } else { this._autoRefreshBlocked = true; @@ -223,7 +228,9 @@ export function SceneRefreshPickerRenderer({ model }: SceneComponentProps { + model.onRefresh(); + }} primary={primary} onIntervalChanged={model.onIntervalChanged} isLoading={isRunning} diff --git a/packages/scenes/src/components/VizPanel/VizPanel.tsx b/packages/scenes/src/components/VizPanel/VizPanel.tsx index cf5fcf4c1..09e1a1ccf 100644 --- a/packages/scenes/src/components/VizPanel/VizPanel.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanel.tsx @@ -36,6 +36,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 { registerQueryWithController, wrapPromiseInStateObservable } from '../../querying/registerQueryWithController'; export interface VizPanelState extends SceneObjectState { /** @@ -158,7 +159,16 @@ export class VizPanel extends Scene const { importPanelPlugin } = getPluginImportUtils(); try { - const result = await importPanelPlugin(pluginId); + const panelPromise = importPanelPlugin(pluginId); + + const queryControler = sceneGraph.getQueryController(this); + if (queryControler && queryControler.state.enableProfiling) { + wrapPromiseInStateObservable(panelPromise) + .pipe(registerQueryWithController({ type: 'plugin', origin: this })) + .subscribe(() => {}); + } + + const result = await panelPromise; this._pluginLoaded(result, overwriteOptions, overwriteFieldConfig, isAfterPluginChange); } catch (err: unknown) { this._pluginLoaded(getPanelPluginNotFound(pluginId)); diff --git a/packages/scenes/src/core/SceneTimeRange.tsx b/packages/scenes/src/core/SceneTimeRange.tsx index 0a05be0f2..35f0ca6d9 100644 --- a/packages/scenes/src/core/SceneTimeRange.tsx +++ b/packages/scenes/src/core/SceneTimeRange.tsx @@ -10,6 +10,7 @@ import { parseUrlParam } from '../utils/parseUrlParam'; import { evaluateTimeRange } from '../utils/evaluateTimeRange'; import { config, locationService, RefreshEvent } from '@grafana/runtime'; import { isValid } from '../utils/date'; +import { getQueryController } from './sceneGraph/getQueryController'; export class SceneTimeRange extends SceneObjectBase implements SceneTimeRangeLike { protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to', 'timezone', 'time', 'time.window'] }); @@ -161,6 +162,8 @@ export class SceneTimeRange extends SceneObjectBase impleme // Only update if time range actually changed if (update.from !== this.state.from || update.to !== this.state.to) { + const queryController = getQueryController(this); + queryController?.startProfile(this); this._urlSync.performBrowserHistoryAction(() => { this.setState(update); }); diff --git a/packages/scenes/src/core/sceneGraph/getQueryController.ts b/packages/scenes/src/core/sceneGraph/getQueryController.ts new file mode 100644 index 000000000..5afb5f2be --- /dev/null +++ b/packages/scenes/src/core/sceneGraph/getQueryController.ts @@ -0,0 +1,23 @@ +import { isQueryController } from '../../behaviors/SceneQueryController'; +import { SceneQueryControllerLike } from '../../behaviors/types'; +import { SceneObject } from '../types'; + +/** + * Returns the closest query controller undefined if none found + */ +export function getQueryController(sceneObject: SceneObject): SceneQueryControllerLike | undefined { + let parent: SceneObject | undefined = sceneObject; + + while (parent) { + if (parent.state.$behaviors) { + for (const behavior of parent.state.$behaviors) { + if (isQueryController(behavior)) { + return behavior; + } + } + } + parent = parent.parent; + } + + return undefined; +} diff --git a/packages/scenes/src/core/sceneGraph/index.ts b/packages/scenes/src/core/sceneGraph/index.ts index e038d45c6..a65e0d7b5 100644 --- a/packages/scenes/src/core/sceneGraph/index.ts +++ b/packages/scenes/src/core/sceneGraph/index.ts @@ -1,4 +1,5 @@ import { lookupVariable } from '../../variables/lookupVariable'; +import { getQueryController } from './getQueryController'; import { getTimeRange } from './getTimeRange'; import { findByKey, @@ -13,8 +14,6 @@ import { interpolate, getAncestor, findDescendents, - getQueryController, - getUrlSyncManager, } from './sceneGraph'; export const sceneGraph = { @@ -31,7 +30,6 @@ export const sceneGraph = { findObject, findAllObjects, getAncestor, - findDescendents, getQueryController, - getUrlSyncManager, + findDescendents, }; diff --git a/packages/scenes/src/core/sceneGraph/sceneGraph.ts b/packages/scenes/src/core/sceneGraph/sceneGraph.ts index 72c5402d1..eee5fcced 100644 --- a/packages/scenes/src/core/sceneGraph/sceneGraph.ts +++ b/packages/scenes/src/core/sceneGraph/sceneGraph.ts @@ -7,7 +7,6 @@ import { VariableCustomFormatterFn, SceneVariables } from '../../variables/types import { isDataLayer, SceneDataLayerProvider, SceneDataProvider, SceneLayout, SceneObject } from '../types'; import { lookupVariable } from '../../variables/lookupVariable'; import { getClosest } from './utils'; -import { SceneQueryControllerLike, isQueryController } from '../../behaviors/SceneQueryController'; import { VariableInterpolation } from '@grafana/runtime'; import { QueryVariable } from '../../variables/variants/query/QueryVariable'; import { UrlSyncManagerLike } from '../../services/UrlSyncManager'; @@ -264,26 +263,6 @@ export function findDescendents(scene: SceneObject, desce return targetScenes.filter(isDescendentType); } -/** - * Returns the closest query controller undefined if none found - */ -export function getQueryController(sceneObject: SceneObject): SceneQueryControllerLike | undefined { - let parent: SceneObject | undefined = sceneObject; - - while (parent) { - if (parent.state.$behaviors) { - for (const behavior of parent.state.$behaviors) { - if (isQueryController(behavior)) { - return behavior; - } - } - } - parent = parent.parent; - } - - return undefined; -} - /** * Returns the closest SceneObject that has a state property with the * name urlSyncManager that is of type UrlSyncManager diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 3b2c4a371..e290962e7 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -45,7 +45,8 @@ export type { SceneQueryControllerLike, SceneQueryControllerEntryType, SceneQueryControllerEntry, -} from './behaviors/SceneQueryController'; + SceneInteractionProfileEvent, +} from './behaviors/types'; export * from './variables/types'; export { VariableDependencyConfig } from './variables/VariableDependencyConfig'; diff --git a/packages/scenes/src/querying/SceneQueryRunner.test.ts b/packages/scenes/src/querying/SceneQueryRunner.test.ts index 673713b64..eb938011c 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.test.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.test.ts @@ -30,13 +30,14 @@ import { TestSceneWithRequestEnricher } from '../utils/test/TestSceneWithRequest import { AdHocFiltersVariable } from '../variables/adhoc/AdHocFiltersVariable'; import { emptyPanelData } from '../core/SceneDataNode'; import { GroupByVariable } from '../variables/groupby/GroupByVariable'; -import { SceneQueryController, SceneQueryStateControllerState } from '../behaviors/SceneQueryController'; +import { SceneQueryController } from '../behaviors/SceneQueryController'; import { activateFullSceneTree } from '../utils/test/activateFullSceneTree'; import { SceneDeactivationHandler, SceneObjectState } from '../core/types'; import { LocalValueVariable } from '../variables/variants/LocalValueVariable'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { ExtraQueryDescriptor, ExtraQueryProvider } from './ExtraQueryProvider'; import { SafeSerializableSceneObject } from '../utils/SafeSerializableSceneObject'; +import { SceneQueryStateControllerState } from '../behaviors/types'; import { config } from '@grafana/runtime'; const getDataSourceMock = jest.fn().mockReturnValue({ diff --git a/packages/scenes/src/querying/layers/annotations/AnnotationsDataLayer.test.ts b/packages/scenes/src/querying/layers/annotations/AnnotationsDataLayer.test.ts index 4dd2d90f4..3144fa481 100644 --- a/packages/scenes/src/querying/layers/annotations/AnnotationsDataLayer.test.ts +++ b/packages/scenes/src/querying/layers/annotations/AnnotationsDataLayer.test.ts @@ -15,7 +15,6 @@ import { config, RefreshEvent } from '@grafana/runtime'; let mockedEvents: Array> = []; const getDataSourceMock = jest.fn().mockReturnValue({ - // getRef: () => ({ uid: 'test' }), annotations: { prepareAnnotation: (q: AnnotationQuery) => q, prepareQuery: (q: AnnotationQuery) => q, @@ -130,7 +129,6 @@ describe.each(['11.1.2', '11.1.1'])('AnnotationsDataLayer', (v) => { }); describe('variables support', () => { - beforeEach(() => {}); describe('When query is using variable that is still loading', () => { it('Should not executed query on activate', async () => { const variable = new TestVariable({ name: 'A', value: '1' }); diff --git a/packages/scenes/src/querying/registerQueryWithController.ts b/packages/scenes/src/querying/registerQueryWithController.ts index 72646faef..ab7a4cbac 100644 --- a/packages/scenes/src/querying/registerQueryWithController.ts +++ b/packages/scenes/src/querying/registerQueryWithController.ts @@ -1,7 +1,7 @@ -import { Observable } from 'rxjs'; +import { Observable, catchError, from, map } from 'rxjs'; import { LoadingState } from '@grafana/schema'; import { sceneGraph } from '../core/sceneGraph'; -import { QueryResultWithState, SceneQueryControllerEntry } from '../behaviors/SceneQueryController'; +import { QueryResultWithState, SceneQueryControllerEntry } from '../behaviors/types'; /** * Will look for a scene object with a behavior that is a SceneQueryController and register the query with it. @@ -46,3 +46,29 @@ export function registerQueryWithController(entr }); }; } + +// Wraps an arbitrary Promise in an observble that emits Promise state +export function wrapPromiseInStateObservable(promise: Promise): Observable { + return new Observable((observer) => { + // Emit 'loading' state initially + observer.next({ state: LoadingState.Loading }); + + // Convert the promise to an observable + const promiseObservable = from(promise); + + // Subscribe to the promise observable + promiseObservable + .pipe( + map(() => ({ state: LoadingState.Done })), + + catchError(() => { + observer.next({ state: LoadingState.Error }); + return []; + }) + ) + .subscribe({ + next: (result) => observer.next(result), + complete: () => observer.complete(), + }); + }); +} diff --git a/packages/scenes/src/utils/getDataSource.ts b/packages/scenes/src/utils/getDataSource.ts index 7c6d5b821..285c0b2a2 100644 --- a/packages/scenes/src/utils/getDataSource.ts +++ b/packages/scenes/src/utils/getDataSource.ts @@ -2,6 +2,9 @@ import { DataSourceApi, ScopedVars } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { runtimeDataSources } from '../querying/RuntimeDataSource'; +import { registerQueryWithController, wrapPromiseInStateObservable } from '../querying/registerQueryWithController'; +import { SceneObject } from '../core/types'; +import { sceneGraph } from '../core/sceneGraph'; export async function getDataSource( datasource: DataSourceRef | undefined | null, @@ -18,5 +21,22 @@ export async function getDataSource( return datasource as DataSourceApi; } - return await getDataSourceSrv().get(datasource as string, scopedVars); + const dsPromise = getDataSourceSrv().get(datasource as string, scopedVars); + + if (scopedVars.__sceneObject && scopedVars.__sceneObject.value.valueOf()) { + const queryControler = sceneGraph.getQueryController(scopedVars.__sceneObject.value.valueOf() as SceneObject); + if (queryControler && queryControler.state.enableProfiling) { + wrapPromiseInStateObservable(dsPromise) + .pipe( + registerQueryWithController({ + type: 'plugin', + origin: scopedVars.__sceneObject.value.valueOf() as SceneObject, + }) + ) + .subscribe(() => {}); + } + } + + const result = await dsPromise; + return result; } diff --git a/packages/scenes/src/variables/components/VariableValueSelect.tsx b/packages/scenes/src/variables/components/VariableValueSelect.tsx index c4cd6eb5c..11372d5fd 100644 --- a/packages/scenes/src/variables/components/VariableValueSelect.tsx +++ b/packages/scenes/src/variables/components/VariableValueSelect.tsx @@ -18,6 +18,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { getOptionSearcher } from './getOptionSearcher'; +import { sceneGraph } from '../../core/sceneGraph'; const filterNoOp = () => true; @@ -51,7 +52,7 @@ export function VariableValueSelect({ model }: SceneComponentProps getOptionSearcher(options, includeAll), [options, includeAll]); const onInputChange = (value: string, { action }: InputActionMeta) => { @@ -98,6 +99,7 @@ export function VariableValueSelect({ model }: SceneComponentProps { model.changeValueTo(newValue.value!, newValue.label!); + queryController?.startProfile(model); if (hasCustomValue !== newValue.__isNew__) { setHasCustomValue(newValue.__isNew__); @@ -122,6 +124,7 @@ export function VariableValueSelectMulti({ model }: SceneComponentProps getOptionSearcher(options, includeAll), [options, includeAll]); @@ -177,6 +180,7 @@ export function VariableValueSelectMulti({ model }: SceneComponentProps { model.changeValueTo(uncommittedValue); + queryController?.startProfile(model); }} filterOption={filterNoOp} data-testid={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${uncommittedValue}`)} diff --git a/packages/scenes/src/variables/variants/TestVariable.tsx b/packages/scenes/src/variables/variants/TestVariable.tsx index 1c25db054..44cb97b54 100644 --- a/packages/scenes/src/variables/variants/TestVariable.tsx +++ b/packages/scenes/src/variables/variants/TestVariable.tsx @@ -11,7 +11,7 @@ import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } f import { VariableRefresh } from '@grafana/data'; import { getClosest } from '../../core/sceneGraph/utils'; import { SceneVariableSet } from '../sets/SceneVariableSet'; -import { SceneQueryControllerEntry } from '../../behaviors/SceneQueryController'; +import { SceneQueryControllerEntry } from '../../behaviors/types'; export interface TestVariableState extends MultiValueVariableState { query: string;