diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c478f4e251b..ae4979a2750 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3243,11 +3243,6 @@ "count": 2 } }, - "public/app/features/expressions/ExpressionQueryEditor.tsx": { - "react/no-unescaped-entities": { - "count": 4 - } - }, "public/app/features/expressions/guards.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts index 7bfc54a752b..e9bb81b026d 100644 --- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -83,6 +83,7 @@ export function useSaveDashboard(isCopy = false) { trackDashboardSceneCreatedOrSaved(!!options.isNew, scene, { name: saveModel.title || '', url: resultData.url || '', + expression_types: scene.getExpressionTypes(saveModel), }); } diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 20212b5c8eb..7454ad46eb3 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -22,6 +22,7 @@ import { LocalValueVariable, } from '@grafana/scenes'; import { Dashboard, DashboardCursorSync, LibraryPanel } from '@grafana/schema'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import appEvents from 'app/core/app_events'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; @@ -905,8 +906,229 @@ describe('DashboardScene', () => { expect(scene.managedResourceCannotBeEdited()).toBe(false); }); }); + + describe('getExpressionTypes', () => { + it('should extract SQL expression type from V1 and V2 dashboards', () => { + const scene = buildTestScene(); + const saveModel = createV1DashboardWithExpressions(['sql']); + const saveModelV2 = createV2DashboardWithExpressions(['sql']); + + const result = scene.getExpressionTypes(saveModel); + const resultV2 = scene.getExpressionTypes(saveModelV2); + + expect(result).toEqual(['sql']); + expect(resultV2).toEqual(['sql']); + }); + + it('should extract multiple expression types from V1 and V2 dashboards', () => { + const scene = buildTestScene(); + const saveModel = createV1DashboardWithExpressions(['sql', 'reduce', 'math']); + const saveModelV2 = createV2DashboardWithExpressions(['sql', 'reduce', 'math']); + + const result = scene.getExpressionTypes(saveModel); + const resultV2 = scene.getExpressionTypes(saveModelV2); + + expect(result).toEqual(['sql', 'reduce', 'math']); + expect(resultV2).toEqual(['sql', 'reduce', 'math']); + }); + + it('should deduplicate expression types', () => { + const scene = buildTestScene(); + const saveModel = createV1DashboardWithExpressions(['sql', 'sql', 'reduce']); + const saveModelV2 = createV2DashboardWithExpressions(['sql', 'sql', 'reduce']); + + const result = scene.getExpressionTypes(saveModel); + const resultV2 = scene.getExpressionTypes(saveModelV2); + + expect(result).toEqual(['sql', 'reduce']); + expect(resultV2).toEqual(['sql', 'reduce']); + }); + + it('should return undefined when no expressions exist for V1 and V2 dashboards', () => { + const scene = buildTestScene(); + const saveModel = createV1DashboardWithExpressions([]); + const saveModelV2 = createV2DashboardWithExpressions([]); + + const result = scene.getExpressionTypes(saveModel); + const resultV2 = scene.getExpressionTypes(saveModelV2); + + expect(result).toBeUndefined(); + expect(resultV2).toBeUndefined(); + }); + + it('should return undefined for empty dashboard', () => { + const scene = buildTestScene(); + const saveModel = { panels: [] } as unknown as Dashboard; + + const result = scene.getExpressionTypes(saveModel); + + expect(result).toBeUndefined(); + }); + + it('should skip non-expression datasources', () => { + const scene = buildTestScene(); + const saveModel = { + panels: [ + { + type: 'timeseries', + targets: [ + { + datasource: { type: 'prometheus', uid: 'prometheus-uid' }, + type: 'instant', + }, + ], + }, + ], + } as unknown as Dashboard; + + const result = scene.getExpressionTypes(saveModel); + + expect(result).toBeUndefined(); + }); + + it('should skip LibraryPanel elements in V2', () => { + const scene = buildTestScene(); + const saveModel = { + elements: { + 'lib-panel-1': { + kind: 'LibraryPanel', + spec: { + id: 1, + title: 'Library Panel', + libraryPanel: { + uid: 'some-library-panel', + name: 'Some Library Panel', + }, + }, + }, + }, + } as unknown as DashboardV2Spec; + + const result = scene.getExpressionTypes(saveModel); + + expect(result).toBeUndefined(); + }); + }); }); +function createV1DashboardWithExpressions(expressionTypes: string[]): Dashboard { + return { + title: 'Test Dashboard', + schemaVersion: 30, + panels: [ + { + id: 1, + type: 'timeseries', + targets: [ + { + refId: 'A', + datasource: { type: 'prometheus', uid: 'prometheus-uid' }, + }, + ...expressionTypes.map((type, i) => ({ + refId: String.fromCharCode(66 + i), // B, C, D... + datasource: { type: '__expr__', uid: '__expr__' }, + type, + })), + ], + }, + ], + }; +} + +function createV2DashboardWithExpressions(expressionTypes: string[]): DashboardV2Spec { + return { + title: 'Test Dashboard V2', + annotations: [], + cursorSync: 'Off', + editable: true, + links: [], + preload: false, + tags: [], + timeSettings: { + timezone: 'browser', + from: 'now-6h', + to: 'now', + autoRefresh: '', + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + hideTimepicker: false, + fiscalYearStartMonth: 0, + }, + variables: [], + layout: { + kind: 'GridLayout', + spec: { + items: [], + }, + }, + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + id: 1, + title: 'Panel', + description: '', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + hidden: false, + query: { + kind: 'DataQuery', + group: 'prometheus', + version: 'v0', + datasource: { + name: 'prometheus-uid', + }, + spec: {}, + }, + refId: 'A', + }, + }, + ...expressionTypes.map((type, i) => ({ + kind: 'PanelQuery' as const, + spec: { + hidden: false, + query: { + kind: 'DataQuery' as const, + group: '__expr__', + version: 'v0' as const, + datasource: { + name: '__expr__', + }, + spec: { + type, + }, + }, + refId: String.fromCharCode(66 + i), // B, C, D... + }, + })), + ], + queryOptions: {}, + transformations: [], + }, + }, + vizConfig: { + kind: 'VizConfig', + version: '1.0.0', + group: 'timeseries', + spec: { + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + }, + }; +} + function buildTestScene(overrides?: Partial) { const scene = new DashboardScene({ title: 'hello', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 61b5e7aa948..0cabdf459a2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -736,6 +736,71 @@ export class DashboardScene extends SceneObjectBase impleme return dashboardSceneGraph.getVizPanels(this); } + public getExpressionTypes(saveModel?: Dashboard | DashboardV2Spec): string[] | undefined { + const model = saveModel ?? this.getSaveModel(); + + const expressionTypes = new Set(); + + // Handle V1 dashboards + if ('panels' in model && model.panels) { + for (const panel of model.panels) { + // Skip panels without targets (e.g., row panels) + if (!('targets' in panel) || !panel.targets?.length) { + continue; + } + + for (const target of panel.targets) { + // Only count if it's actually an expression query + const datasourceUid = + target?.datasource && typeof target.datasource === 'object' && 'uid' in target.datasource + ? target.datasource.uid + : undefined; + + const targetType = target?.type; + if (datasourceUid === '__expr__' && typeof targetType === 'string' && targetType) { + expressionTypes.add(targetType); + } + } + } + } + + // Handle V2 dashboards + if ('elements' in model && model.elements) { + for (const element of Object.values(model.elements)) { + // Check if element is a Panel (not LibraryPanel) + if (element.kind !== 'Panel') { + continue; + } + + const queries = element.spec.data?.spec?.queries; + if (!Array.isArray(queries)) { + continue; + } + + for (const query of queries) { + const querySpec = query?.spec?.query; + if (!querySpec || typeof querySpec !== 'object') { + continue; + } + + const datasource = querySpec.datasource; + const datasourceName = + datasource && typeof datasource === 'object' && 'name' in datasource ? datasource.name : undefined; + + const spec = querySpec.spec; + const queryType = spec && typeof spec === 'object' && 'type' in spec ? spec.type : undefined; + + if (datasourceName === '__expr__' && typeof queryType === 'string' && queryType) { + expressionTypes.add(queryType); + } + } + } + } + + // Return array of expression types or undefined if no expressions + return expressionTypes.size > 0 ? Array.from(expressionTypes) : undefined; + } + public onSetScrollRef = (scrollElement: ScrollRefElement): void => { this._scrollRef = scrollElement; }; diff --git a/public/app/features/dashboard-scene/utils/tracking.ts b/public/app/features/dashboard-scene/utils/tracking.ts index 21f770daca0..1b625612274 100644 --- a/public/app/features/dashboard-scene/utils/tracking.ts +++ b/public/app/features/dashboard-scene/utils/tracking.ts @@ -51,7 +51,7 @@ export const trackDashboardSceneEditButtonClicked = (dashboardUid?: string) => { export function trackDashboardSceneCreatedOrSaved( isNew: boolean, dashboard: DashboardScene, - initialProperties: { name: string; url: string } + initialProperties: { name: string; url: string; expression_types?: string[] } ) { // url values for dashboard library experiment const urlParams = new URLSearchParams(window.location.search); diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index bb5b98bc382..f8e3ad50abb 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { DataSourceApi, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; import { Button, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from './components/ClassicConditions'; @@ -30,9 +31,9 @@ const getExpressionHelpText = (type: ExpressionQueryType): PopoverContent | stri case ExpressionQueryType.sql: return ( - Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie "A", "B") - are available as tables and referenced by query-name. Fields are available as columns, as returned from the - data source. + Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie + "A", "B") are available as tables and referenced by query-name. Fields are available as + columns, as returned from the data source. ); default: @@ -84,6 +85,22 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { const styles = useStyles2(getStyles); + const initialExpressionRef = useRef(query.expression); + const hasTrackedAddExpression = useRef(false); + + useEffect(() => { + // Only track if 1) query has a type, and 2) we haven't tracked yet for this component instance, and + // 3) initial expression was empty (indicating a new expression, not editing existing) + if (query.type && !hasTrackedAddExpression.current && !initialExpressionRef.current) { + reportInteraction('dashboards_expression_interaction', { + action: 'add_expression', + expression_type: query.type, + context: 'panel_query_section', + }); + hasTrackedAddExpression.current = true; + } + }, [query.type, query.refId]); + useEffect(() => { setCachedExpression(query.type, query.expression); }, [query.expression, query.type, setCachedExpression]); diff --git a/public/app/features/expressions/components/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpr.tsx index ab2d25ab5d3..8ae4d704962 100644 --- a/public/app/features/expressions/components/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpr.tsx @@ -6,6 +6,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; +import { reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema/dist/esm/index'; import { formatSQL } from '@grafana/sql'; import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; @@ -162,10 +163,16 @@ LIMIT }; const executeQuery = useCallback(() => { - if (query.expression && onRunQuery) { + if (onRunQuery) { + reportInteraction('dashboards_expression_interaction', { + action: 'execute_expression', + expression_type: 'sql', + context: 'expression_editor', + }); + onRunQuery(); } - }, [query.expression, onRunQuery]); + }, [onRunQuery]); // Set up resize observer to handle container resizing useEffect(() => {