Dashboards: Add RS tracking for Dynamic Dashboards (#111102)

This commit is contained in:
Ida Štambuk
2025-10-01 11:14:12 +03:00
committed by GitHub
parent aeb62b7acc
commit 8f56f1df98
17 changed files with 1667 additions and 173 deletions
@@ -4,7 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Button, Menu, Stack, Text, useStyles2, Dropdown, Icon, IconButton } from '@grafana/ui';
import { trackDeleteDashboardElement } from 'app/features/dashboard/utils/tracking';
import { trackDeleteDashboardElement } from 'app/features/dashboard-scene/utils/tracking';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
@@ -20,7 +20,7 @@ import { isDashboardV2Resource, isDashboardV2Spec, isV2StoredVersion } from 'app
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking';
import { trackDashboardSceneLoaded } from 'app/features/dashboard-scene/utils/tracking';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { ProvisioningPreview } from 'app/features/provisioning/types';
import { dispatch } from 'app/store/store';
@@ -2,7 +2,7 @@ import { useAsyncFn } from 'react-use';
import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import appEvents from 'app/core/app_events';
@@ -15,6 +15,8 @@ import { useDispatch } from 'app/types/store';
import { updateDashboardUidLastUsedDatasource } from '../../dashboard/utils/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardInteractions } from '../utils/interactions';
import { trackDashboardSceneCreatedOrSaved } from '../utils/tracking';
export function useSaveDashboard(isCopy = false) {
const dispatch = useDispatch();
@@ -73,18 +75,14 @@ export function useSaveDashboard(isCopy = false) {
appEvents.publish(new DashboardSavedEvent());
notifyApp.success(t('dashboard-scene.use-save-dashboard.message-dashboard-saved', 'Dashboard saved'));
//Update local storage dashboard to handle things like last used datasource
updateDashboardUidLastUsedDatasource(resultData.uid);
if (isCopy) {
reportInteraction('grafana_dashboard_copied', {
name: saveModel.title,
url: resultData.url,
});
DashboardInteractions.dashboardCopied({ name: saveModel.title || '', url: resultData.url });
} else {
reportInteraction(`grafana_dashboard_${options.isNew ? 'created' : 'saved'}`, {
name: saveModel.title,
url: resultData.url,
trackDashboardSceneCreatedOrSaved(!!options.isNew, scene, {
name: saveModel.title || '',
url: resultData.url || '',
});
}
@@ -716,6 +716,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
return this.serializer.getTrackingInformation(this);
}
public getDynamicDashboardsTrackingInformation() {
return this.serializer.getDynamicDashboardsTrackingInformation(this);
}
public async onDashboardDelete() {
// Need to mark it non dirty to navigate away without unsaved changes warning
this.setState({ isDirty: false });
@@ -160,21 +160,30 @@ describe('NavToolbarActions', () => {
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is undefined', async () => {
setup();
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false });
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({
dashboardUid: 'dash-1',
outlineExpanded: false,
});
});
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is false', async () => {
localStorageMock.setItem('grafana.dashboard.edit-pane.outline.collapsed', 'false');
setup();
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: true });
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({
dashboardUid: 'dash-1',
outlineExpanded: true,
});
});
it('should call DashboardInteractions.editButtonClicked with outlineExpanded:false if grafana.dashboard.edit-pane.outline.collapsed is true', async () => {
localStorageMock.setItem('grafana.dashboard.edit-pane.outline.collapsed', 'true');
setup();
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false });
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({
dashboardUid: 'dash-1',
outlineExpanded: false,
});
});
});
});
@@ -321,7 +321,7 @@ export function ToolbarActions({ dashboard }: Props) {
render: () => (
<Button
onClick={() => {
trackDashboardSceneEditButtonClicked();
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
}}
tooltip={
@@ -347,7 +347,7 @@ export function ToolbarActions({ dashboard }: Props) {
render: () => (
<Button
onClick={() => {
trackDashboardSceneEditButtonClicked();
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
dashboard.setState({ editable: true, meta: { ...meta, canEdit: true } });
}}
@@ -22,7 +22,7 @@ export const EditDashboardSwitch = ({ dashboard }: ToolbarActionProps) => {
evt.stopPropagation();
if (!dashboard.state.isEditing) {
trackDashboardSceneEditButtonClicked();
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
} else {
DashboardInteractions.exitEditButtonClicked();
@@ -11,7 +11,7 @@ export const MakeDashboardEditableButton = ({ dashboard }: ToolbarActionProps) =
<Button
disabled={playlistSrv.state.isPlaying}
onClick={() => {
trackDashboardSceneEditButtonClicked();
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
dashboard.setState({ editable: true, meta: { ...dashboard.state.meta, canEdit: true } });
}}
@@ -12,7 +12,6 @@ import {
defaultSpec as defaultDashboardV2Spec,
defaultDataQueryKind,
defaultPanelSpec,
defaultTimeSettingsSpec,
GridLayoutKind,
PanelKind,
PanelSpec,
@@ -25,13 +24,14 @@ import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/Dashboard
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { getTestDashboardSceneFromSaveModel } from '../utils/test-utils';
import { findVizPanelByKey } from '../utils/utils';
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
import { getPanelElement, transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
import nestedDashboard from './testfiles/nested_dashboard.json';
import { getPanelElement } from './transformSaveModelSchemaV2ToScene';
import { transformSaveModelToScene } from './transformSaveModelToScene';
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
@@ -672,30 +672,38 @@ describe('DashboardSceneSerializer', () => {
expect(serializer.getTrackingInformation(dashboard)).toBe(undefined);
});
it('provides dashboard tracking information with from initial save model', () => {
const dashboard = setupV2({
timeSettings: {
nowDelay: '10s',
from: '',
to: '',
autoRefresh: '',
autoRefreshIntervals: [],
hideTimepicker: false,
fiscalYearStartMonth: 0,
timezone: '',
},
liveNow: true,
});
it('provides dashboard tracking information from initial save model', () => {
const dashboard = setupV2(nestedDashboard as Partial<DashboardV2Spec>);
expect(dashboard.getTrackingInformation()).toEqual({
uid: 'dashboard-test',
title: 'hello',
panels_count: 1,
panel_type__count: 1,
variable_type_custom_count: 1,
title: 'Cloudwatch ec2 new layout',
panels_count: 6,
schemaVersion: DASHBOARD_SCHEMA_VERSION,
settings_nowdelay: undefined,
settings_livenow: true,
schemaVersion: DASHBOARD_SCHEMA_VERSION,
variable_type_custom_count: 1,
variable_type_query_count: 1,
panel_type_timeseries_count: 6,
});
expect(dashboard.getDynamicDashboardsTrackingInformation()).toEqual({
panelCount: 6,
rowCount: 6,
tabCount: 4,
templateVariableCount: 2,
maxNestingLevel: 3,
dashStructure:
'[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]',
conditionalRenderRulesCount: 3,
autoLayoutCount: 3,
customGridLayoutCount: 2,
rowsLayoutCount: 4,
tabsLayoutCount: 2,
panelsByDatasourceType: {
cloudwatch: 5,
datasource: 1,
},
});
});
});
@@ -1478,87 +1486,5 @@ function setup(override: Partial<Dashboard> = {}) {
}
function setupV2(spec?: Partial<DashboardV2Spec>) {
const dashboard = transformSaveModelSchemaV2ToScene({
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'hello',
timeSettings: {
...defaultTimeSettingsSpec(),
autoRefresh: '10s',
from: 'now-1h',
to: 'now',
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Panel 1',
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
},
],
},
},
variables: [
{
kind: 'CustomVariable',
spec: {
name: 'app',
label: 'Query Variable',
description: 'A query variable',
skipUrlSync: false,
hide: 'dontHide',
options: [],
multi: false,
current: {
text: 'app1',
value: 'app1',
},
query: 'app1',
allValue: '',
includeAll: false,
allowCustomValue: true,
},
},
],
...spec,
},
apiVersion: 'v1',
metadata: {
name: 'dashboard-test',
resourceVersion: '1',
creationTimestamp: '2023-01-01T00:00:00Z',
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
});
const initialSaveModel = transformSceneToSaveModelSchemaV2(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
return dashboard;
return getTestDashboardSceneFromSaveModel(spec);
}
@@ -50,6 +50,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T, E = T | { error: unkn
) => DashboardChangeInfo;
onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void;
getTrackingInformation: (s: DashboardScene) => DashboardTrackingInfo | undefined;
getDynamicDashboardsTrackingInformation: (s: DashboardScene) => DynamicDashboardsTrackingInformation | undefined;
getSnapshotUrl: () => string | undefined;
getPanelIdForElement: (elementId: string) => number | undefined;
getElementIdForPanel: (panelId: number) => string | undefined;
@@ -59,7 +60,7 @@ export interface DashboardSceneSerializerLike<T, M, I = T, E = T | { error: unkn
getK8SMetadata: () => Partial<ObjectMeta> | undefined;
}
interface DashboardTrackingInfo {
export interface DashboardTrackingInfo {
uid?: string;
title?: string;
schemaVersion: number;
@@ -68,6 +69,34 @@ interface DashboardTrackingInfo {
settings_livenow?: boolean;
}
export interface DynamicDashboardsTrackingInformation {
panelCount: number;
rowCount: number;
tabCount: number;
templateVariableCount: number;
maxNestingLevel: number;
conditionalRenderRulesCount: number;
autoLayoutCount: number;
customGridLayoutCount: number;
rowsLayoutCount: number;
tabsLayoutCount: number;
dashStructure: string;
panelsByDatasourceType: Record<string, number>;
}
interface DynamicDashboardTrackingInformationStructureNode {
kind: string;
children?: DynamicDashboardTrackingInformationStructureNode[];
}
interface DynamicDashboardsTrackingInformationLayoutParsing
extends Omit<
DynamicDashboardsTrackingInformation,
'dashStructure' | 'panelsByDatasourceType' | 'templateVariableCount'
> {
dashStructure: DynamicDashboardTrackingInformationStructureNode[];
}
export interface DSReferencesMapping {
panels: Map<string, Set<string>>;
variables: Set<string>;
@@ -212,6 +241,11 @@ export class V1DashboardSerializer
return undefined;
}
getDynamicDashboardsTrackingInformation(): undefined {
// We don't have dynamic dashboards in V1 schema
return undefined;
}
getSnapshotUrl() {
return this.initialSaveModel?.snapshot?.originalUrl;
}
@@ -407,9 +441,15 @@ export class V2DashboardSerializer
const panelPluginIds =
'elements' in this.initialSaveModel
? Object.values(this.initialSaveModel.elements)
.filter((e) => e.kind === 'Panel')
.map((p) => p.spec.vizConfig.group)
? Object.values(this.initialSaveModel.elements).reduce<string[]>((acc, e) => {
if (e.kind !== 'Panel') {
return acc;
}
acc.push(e.spec.vizConfig.group);
return acc;
}, [])
: [];
const panels = getPanelPluginCounts(panelPluginIds);
const variables =
@@ -427,6 +467,56 @@ export class V2DashboardSerializer
};
}
getDynamicDashboardsTrackingInformation(): DynamicDashboardsTrackingInformation | undefined {
if (!this.initialSaveModel || !isDashboardV2Spec(this.initialSaveModel)) {
return undefined;
}
const dashStructure: DynamicDashboardTrackingInformationStructureNode[] = [];
const result = this._parseDynamicDashboardsLayouts(
{
autoLayoutCount: 0,
customGridLayoutCount: 0,
rowsLayoutCount: 0,
tabsLayoutCount: 0,
panelCount: 0,
rowCount: 0,
tabCount: 0,
maxNestingLevel: 0,
conditionalRenderRulesCount: 0,
dashStructure,
},
this.initialSaveModel.layout,
0,
dashStructure
);
return {
...result,
dashStructure: JSON.stringify(result.dashStructure),
templateVariableCount: this.initialSaveModel.variables?.length ?? 0,
panelsByDatasourceType: Object.values(this.initialSaveModel.elements).reduce<Record<string, number>>(
(panelsAcc, { kind, spec: panelSpec }) => {
if (kind !== 'Panel') {
return panelsAcc;
}
return panelSpec.data.spec.queries.reduce((queriesAcc, { spec: querySpec }) => {
if (!querySpec.query.datasource) {
return queriesAcc;
}
queriesAcc[querySpec.query.group] = queriesAcc[querySpec.query.group] ?? 0;
queriesAcc[querySpec.query.group]++;
return queriesAcc;
}, panelsAcc);
},
{}
),
};
}
getSnapshotUrl() {
return this.metadata?.annotations?.[AnnoKeyDashboardSnapshotOriginalUrl];
}
@@ -434,6 +524,62 @@ export class V2DashboardSerializer
async makeExportableExternally(s: DashboardScene) {
return await makeExportableV2(this.getSaveModel(s));
}
private _parseDynamicDashboardsLayouts(
result: DynamicDashboardsTrackingInformationLayoutParsing,
layout: DashboardV2Spec['layout'],
nestingLevel: number,
structureTarget: DynamicDashboardTrackingInformationStructureNode[]
): DynamicDashboardsTrackingInformationLayoutParsing {
result.maxNestingLevel = Math.max(result.maxNestingLevel, nestingLevel);
switch (layout.kind) {
case 'GridLayout':
result.customGridLayoutCount++;
result.panelCount += layout.spec.items.length;
structureTarget.push(...layout.spec.items.map(() => ({ kind: 'panel' })));
return result;
case 'AutoGridLayout':
result.autoLayoutCount++;
result.panelCount += layout.spec.items.length;
structureTarget.push(...layout.spec.items.map(() => ({ kind: 'panel' })));
result.conditionalRenderRulesCount = layout.spec.items.reduce(
(acc, item) => acc + (item.spec.conditionalRendering?.spec?.items?.length || 0),
result.conditionalRenderRulesCount
);
return result;
case 'RowsLayout':
result.rowsLayoutCount++;
result.rowCount += layout.spec.rows.length;
const rowsNextingLevel = nestingLevel + 1;
return layout.spec.rows.reduce((acc, row) => {
acc.conditionalRenderRulesCount += row.spec.conditionalRendering?.spec?.items?.length || 0;
const children: DynamicDashboardTrackingInformationStructureNode[] = [];
structureTarget.push({ kind: 'row', children });
return !row.spec.layout
? acc
: this._parseDynamicDashboardsLayouts(acc, row.spec.layout, rowsNextingLevel, children);
}, result);
case 'TabsLayout':
result.tabsLayoutCount++;
result.tabCount += layout.spec.tabs.length;
const tabsNextingLevel = nestingLevel + 1;
return layout.spec.tabs.reduce((acc, tab) => {
acc.conditionalRenderRulesCount += tab.spec.conditionalRendering?.spec?.items?.length || 0;
const children: DynamicDashboardTrackingInformationStructureNode[] = [];
structureTarget.push({ kind: 'tab', children });
return !tab.spec.layout
? acc
: this._parseDynamicDashboardsLayouts(acc, tab.spec.layout, tabsNextingLevel, children);
}, result);
default:
return result;
}
}
}
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<
@@ -1,16 +1,44 @@
import { config, reportInteraction } from '@grafana/runtime';
import { DashboardTrackingInfo, DynamicDashboardsTrackingInformation } from '../serialization/DashboardSceneSerializer';
let isScenesContextSet = false;
export const DashboardInteractions = {
// Dashboard interactions:
dashboardInitialized: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('init_dashboard_completed', { ...properties });
dashboardInitialized: (
properties: { theme: undefined; duration: number | undefined; isScene: boolean } & Partial<DashboardTrackingInfo> &
Partial<DynamicDashboardsTrackingInformation> &
Partial<{ version_before_migration: number | undefined }>
) => {
reportDashboardInteraction('init_dashboard_completed', properties);
},
dashboardCopied: (properties: { name: string; url: string }) => {
reportInteraction('grafana_dashboard_copied', properties);
},
dashboardCreatedOrSaved: (
isNew: boolean | undefined,
properties:
| { name: string; url: string }
| {
name: string;
url: string;
numPanels: number;
uid: string;
conditionalRenderRules: number;
autoLayoutCount: number;
customGridLayoutCount: number;
panelsByDatasourceType: Record<string, number>;
}
) => {
reportDashboardInteraction(isNew ? 'created' : 'saved', properties, 'grafana_dashboard');
},
// grafana_dashboards_edit_button_clicked
// when a user clicks the ‘edit’ or ‘make editable’ button in a dashboard view mode
editButtonClicked: (properties: { outlineExpanded: boolean }) => {
editButtonClicked: (properties: { outlineExpanded: boolean; dashboardUid?: string }) => {
reportDashboardInteraction('edit_button_clicked', properties);
},
@@ -192,14 +220,18 @@ export const DashboardInteractions = {
},
};
const reportDashboardInteraction: typeof reportInteraction = (name, properties) => {
const reportDashboardInteraction = (
name: string,
properties?: Record<string, unknown>,
interactionPrefix = 'dashboards'
) => {
const meta = isScenesContextSet ? { scenesView: true } : {};
const isDynamicDashboard = config.featureToggles?.dashboardNewLayouts ?? false;
if (properties) {
reportInteraction(`dashboards_${name}`, { ...properties, ...meta, isDynamicDashboard });
reportInteraction(`${interactionPrefix}_${name}`, { ...properties, ...meta, isDynamicDashboard });
} else {
reportInteraction(`dashboards_${name}`, { isDynamicDashboard });
reportInteraction(`${interactionPrefix}_${name}`, { isDynamicDashboard });
}
};
@@ -12,6 +12,12 @@ import {
TestVariable,
VizPanel,
} from '@grafana/scenes';
import {
defaultTimeSettingsSpec,
defaultPanelSpec,
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { DashboardDTO } from 'app/types/dashboard';
@@ -20,6 +26,8 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
@@ -217,3 +225,89 @@ export function buildPanelRepeaterScene(options: SceneOptions, source?: VizPanel
return { scene, repeater: withRepeat, row, variable: panelRepeatVariable };
}
export function getTestDashboardSceneFromSaveModel(spec?: Partial<DashboardV2Spec>) {
const dashboard = transformSaveModelSchemaV2ToScene({
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'hello',
timeSettings: {
...defaultTimeSettingsSpec(),
autoRefresh: '10s',
from: 'now-1h',
to: 'now',
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Panel 1',
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
},
],
},
},
variables: [
{
kind: 'CustomVariable',
spec: {
name: 'app',
label: 'Query Variable',
description: 'A query variable',
skipUrlSync: false,
hide: 'dontHide',
options: [],
multi: false,
current: {
text: 'app1',
value: 'app1',
},
query: 'app1',
allValue: '',
includeAll: false,
allowCustomValue: true,
},
},
],
...spec,
},
apiVersion: 'v1',
metadata: {
name: 'dashboard-test',
resourceVersion: '1',
creationTimestamp: '2023-01-01T00:00:00Z',
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
});
const initialSaveModel = transformSceneToSaveModelSchemaV2(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
return dashboard;
}
@@ -0,0 +1,92 @@
import { getPanelPlugin } from '@grafana/data/test';
import { reportInteraction, setPluginImportUtils } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import nestedDashboard from '../serialization/testfiles/nested_dashboard.json';
import { getTestDashboardSceneFromSaveModel } from './test-utils';
import { trackDashboardSceneCreatedOrSaved, trackDashboardSceneLoaded } from './tracking';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
dashboardNewLayouts: true,
},
},
}));
// mock useSaveDashboardMutation
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
useSaveDashboardMutation: () => [() => Promise.resolve({ data: { version: 2, uid: 'new-uid' } })],
}));
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
export function buildTestScene() {
const dashboard = getTestDashboardSceneFromSaveModel(nestedDashboard as Partial<DashboardV2Spec>);
return dashboard;
}
describe('dashboard tracking', () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe('save v2 dashboard tracking', () => {
it('should call report interaction with correct parameters when saving a new dashboard', async () => {
const scene = buildTestScene();
trackDashboardSceneCreatedOrSaved(true, scene, { name: 'new dashboard', url: 'new-url' });
expect(reportInteraction).toHaveBeenCalledWith('grafana_dashboard_created', {
isDynamicDashboard: true,
uid: 'dashboard-test',
name: 'new dashboard',
url: 'new-url',
numPanels: 6,
conditionalRenderRules: 3,
autoLayoutCount: 3,
customGridLayoutCount: 2,
panelsByDatasourceType: {
cloudwatch: 5,
datasource: 1,
},
});
});
});
describe('init v2 dashboard tracking', () => {
it('should call report interaction with correct parameters when a dashboard has been initialized', async () => {
const scene = buildTestScene();
trackDashboardSceneLoaded(scene, 42);
expect(reportInteraction).toHaveBeenCalledWith('dashboards_init_dashboard_completed', {
isDynamicDashboard: true,
duration: 42,
isScene: true,
tabCount: 4,
templateVariableCount: 2,
maxNestingLevel: 3,
panel_type_timeseries_count: 6,
panels_count: 6,
schemaVersion: 42,
settings_livenow: true,
settings_nowdelay: undefined,
dashStructure:
'[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]',
conditionalRenderRules: 3,
autoLayoutCount: 3,
customGridLayoutCount: 2,
theme: undefined,
title: 'Cloudwatch ec2 new layout',
uid: 'dashboard-test',
variable_type_custom_count: 1,
variable_type_query_count: 1,
});
});
});
});
@@ -1,10 +1,70 @@
import { store } from '@grafana/data';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
import { DashboardInteractions } from './interactions';
export const trackDashboardSceneEditButtonClicked = () => {
const outlineExpandedByDefault = !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', true);
export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?: number) {
const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation();
DashboardInteractions.dashboardInitialized({
theme: undefined,
duration,
isScene: true,
...(dashboard.getTrackingInformation() ?? {}),
...(dynamicDashboardsTrackingInformation
? {
tabCount: dynamicDashboardsTrackingInformation.tabCount,
templateVariableCount: dynamicDashboardsTrackingInformation.templateVariableCount,
maxNestingLevel: dynamicDashboardsTrackingInformation.maxNestingLevel,
dashStructure: dynamicDashboardsTrackingInformation.dashStructure,
conditionalRenderRules: dynamicDashboardsTrackingInformation.conditionalRenderRulesCount,
autoLayoutCount: dynamicDashboardsTrackingInformation.autoLayoutCount,
customGridLayoutCount: dynamicDashboardsTrackingInformation.customGridLayoutCount,
}
: {}),
});
}
export const trackDeleteDashboardElement = (element: EditableDashboardElementInfo) => {
switch (element?.typeName) {
case 'Row':
DashboardInteractions.trackRemoveRowClick();
break;
case 'Tab':
DashboardInteractions.trackRemoveTabClick();
break;
default:
break;
}
};
export const trackDashboardSceneEditButtonClicked = (dashboardUid?: string) => {
DashboardInteractions.editButtonClicked({
outlineExpanded: outlineExpandedByDefault,
outlineExpanded: !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', true),
dashboardUid,
});
};
export function trackDashboardSceneCreatedOrSaved(
isNew: boolean,
dashboard: DashboardScene,
initialProperties: { name: string; url: string }
) {
const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation();
DashboardInteractions.dashboardCreatedOrSaved(isNew, {
...initialProperties,
...(dynamicDashboardsTrackingInformation
? {
uid: dashboard.state.uid,
numPanels: dynamicDashboardsTrackingInformation.panelCount,
conditionalRenderRules: dynamicDashboardsTrackingInformation.conditionalRenderRulesCount,
autoLayoutCount: dynamicDashboardsTrackingInformation.autoLayoutCount,
customGridLayoutCount: dynamicDashboardsTrackingInformation.customGridLayoutCount,
panelsByDatasourceType: dynamicDashboardsTrackingInformation.panelsByDatasourceType,
}
: {}),
});
}
@@ -3,17 +3,19 @@ import { useAsyncFn } from 'react-use';
import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { useAppNotification } from 'app/core/copy/appNotification';
import { updateDashboardName } from 'app/core/reducers/navBarTree';
import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { DashboardSavedEvent } from 'app/types/events';
import { useDispatch } from 'app/types/store';
import { updateDashboardUidLastUsedDatasource } from '../../utils/dashboard';
import { trackDashboardCreatedOrSaved } from '../../utils/tracking';
import { SaveDashboardOptions } from './types';
@@ -57,19 +59,13 @@ export const useDashboardSave = (isCopy = false) => {
appEvents.publish(new DashboardSavedEvent());
notifyApp.success(t('dashboard.save-dashboard.message-dashboard-saved', 'Dashboard saved'));
//Update local storage dashboard to handle things like last used datasource
// Update local storage dashboard to handle things like last used datasource
updateDashboardUidLastUsedDatasource(result.uid);
if (isCopy) {
reportInteraction('grafana_dashboard_copied', {
name: dashboard.title,
url: result.url,
});
DashboardInteractions.dashboardCopied({ name: dashboard.title || '', url: result.url });
} else {
reportInteraction(`grafana_dashboard_${dashboard.id ? 'saved' : 'created'}`, {
name: dashboard.title,
url: result.url,
});
trackDashboardCreatedOrSaved(!!dashboard.id, { name: dashboard.title, url: result.url });
}
const currentPath = locationService.getLocation().pathname;
@@ -1,7 +1,5 @@
import { VariableModel } from '@grafana/schema/dist/esm/index';
import { VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { EditableDashboardElementInfo } from 'app/features/dashboard-scene/scene/types/EditableDashboardElement';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { DashboardModel } from '../state/DashboardModel';
@@ -28,31 +26,11 @@ export function trackDashboardLoaded(dashboard: DashboardModel, duration?: numbe
});
}
export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?: number) {
const trackingInformation = dashboard.getTrackingInformation();
DashboardInteractions.dashboardInitialized({
theme: undefined,
duration,
isScene: true,
...trackingInformation,
});
export function trackDashboardCreatedOrSaved(isNew: boolean | undefined, trackingProps: { name: string; url: string }) {
DashboardInteractions.dashboardCreatedOrSaved(isNew, trackingProps);
}
export const trackDeleteDashboardElement = (element: EditableDashboardElementInfo) => {
switch (element?.typeName) {
case 'Row':
DashboardInteractions.trackRemoveRowClick();
break;
case 'Tab':
DashboardInteractions.trackRemoveTabClick();
break;
default:
break;
}
};
export function getPanelPluginCounts(panels: string[] | string[]) {
export function getPanelPluginCounts(panels: string[]) {
return panels.reduce((r: Record<string, number>, p) => {
r[panelName(p)] = 1 + r[panelName(p)] || 1;
return r;