Event Tracking: Add event tracking for expression queries (#110983)

* chore: add event tracking for expressions

* chore: fix lint

* chore: cleanup

* fix: update commenet

* chore: prune suppressions

* feedback

* update events

* chore: more pr feedback

* chore: only add __expr__ query types to event tracking

* chore: make it work with v2 dashboard spec!

* chore: linter!

* chore: tests!
This commit is contained in:
Alex Spencer
2025-10-20 09:25:43 +01:00
committed by GitHub
parent 7604653fd8
commit e478ee2e5f
7 changed files with 318 additions and 11 deletions
-5
View File
@@ -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
@@ -83,6 +83,7 @@ export function useSaveDashboard(isCopy = false) {
trackDashboardSceneCreatedOrSaved(!!options.isNew, scene, {
name: saveModel.title || '',
url: resultData.url || '',
expression_types: scene.getExpressionTypes(saveModel),
});
}
@@ -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<DashboardSceneState>) {
const scene = new DashboardScene({
title: 'hello',
@@ -736,6 +736,71 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
return dashboardSceneGraph.getVizPanels(this);
}
public getExpressionTypes(saveModel?: Dashboard | DashboardV2Spec): string[] | undefined {
const model = saveModel ?? this.getSaveModel();
const expressionTypes = new Set<string>();
// 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;
};
@@ -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);
@@ -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 (
<Trans i18nKey="expressions.expression-query-editor.helper-text-sql">
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
&quot;A&quot;, &quot;B&quot;) are available as tables and referenced by query-name. Fields are available as
columns, as returned from the data source.
</Trans>
);
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]);
@@ -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(() => {