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:
@@ -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
|
||||
"A", "B") 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(() => {
|
||||
|
||||
Reference in New Issue
Block a user