Plugins: Added hook to make it easier to track interactions in plugins (#56126)

* first stab at context away plugin tracking.

* adding a plugin context and a hook to get hold of a tracker that always appends the plugin context information.

* wip

* improved the code a bit.

* wip

* Fixed type errors.

* added datasource_uid to data sources.

* fixed error message when trying to use hook outside of context.

* small refactoring according to feedback.

* using the correct provider for data source context.

* check not needed.

* enforcing the interaction name to start with grafana_plugin_

* exposing guards for the other context type.

* added structure for writing reporter hook tests.

* added some more tests.

* added tests.

* reverted back to inheritance between context types.

* adding mock for getDataSourceSrv
This commit is contained in:
Marcus Andersson
2022-11-02 16:57:57 +01:00
committed by GitHub
parent 932429a545
commit b804b2f073
27 changed files with 591 additions and 104 deletions

View File

@@ -52,6 +52,7 @@
"@rollup/plugin-node-resolve": "13.3.0",
"@testing-library/dom": "8.13.0",
"@testing-library/react": "12.1.4",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/user-event": "14.4.3",
"@types/angular": "1.8.4",
"@types/history": "4.7.11",

View File

@@ -0,0 +1,34 @@
import { DataSourceInstanceSettings, PluginMeta } from '@grafana/data';
import { config } from '../../config';
export type PluginEventProperties = {
grafana_version: string;
plugin_type: string;
plugin_version: string;
plugin_id: string;
plugin_name: string;
};
export function createPluginEventProperties(meta: PluginMeta): PluginEventProperties {
return {
grafana_version: config.buildInfo.version,
plugin_type: String(meta.type),
plugin_version: meta.info.version,
plugin_id: meta.id,
plugin_name: meta.name,
};
}
export type DataSourcePluginEventProperties = PluginEventProperties & {
datasource_uid: string;
};
export function createDataSourcePluginEventProperties(
instanceSettings: DataSourceInstanceSettings
): DataSourcePluginEventProperties {
return {
...createPluginEventProperties(instanceSettings.meta),
datasource_uid: instanceSettings.uid,
};
}

View File

@@ -0,0 +1,278 @@
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import {
DataSourceInstanceSettings,
DataSourcePluginContextProvider,
PluginContextProvider,
PluginMeta,
PluginMetaInfo,
PluginSignatureStatus,
PluginType,
} from '@grafana/data';
import { reportInteraction } from '../utils';
import { usePluginInteractionReporter } from './usePluginInteractionReporter';
jest.mock('../utils', () => ({ reportInteraction: jest.fn() }));
const reportInteractionMock = jest.mocked(reportInteraction);
describe('usePluginInteractionReporter', () => {
beforeEach(() => jest.resetAllMocks());
describe('within a panel plugin', () => {
it('should report interaction with plugin context info for internal panel', () => {
const report = renderPluginReporterHook({
id: 'gauge',
name: 'Gauge',
type: PluginType.panel,
info: createPluginMetaInfo({
version: '',
}),
});
report('grafana_plugin_gradient_mode_changed');
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_gradient_mode_changed');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'panel',
plugin_version: '',
plugin_id: 'gauge',
plugin_name: 'Gauge',
});
});
it('should report interaction with plugin context info for external panel', () => {
const report = renderPluginReporterHook({
id: 'grafana-clock-panel',
name: 'Clock',
type: PluginType.panel,
info: createPluginMetaInfo({
version: '2.1.0',
}),
});
report('grafana_plugin_time_zone_changed');
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_time_zone_changed');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'panel',
plugin_version: '2.1.0',
plugin_id: 'grafana-clock-panel',
plugin_name: 'Clock',
});
});
it('should report interaction with plugin context info and extra info provided when reporting', () => {
const report = renderPluginReporterHook({
id: 'grafana-clock-panel',
name: 'Clock',
type: PluginType.panel,
info: createPluginMetaInfo({
version: '2.1.0',
}),
});
report('grafana_plugin_time_zone_changed', {
time_zone: 'Europe/Stockholm',
});
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_time_zone_changed');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'panel',
plugin_version: '2.1.0',
plugin_id: 'grafana-clock-panel',
plugin_name: 'Clock',
time_zone: 'Europe/Stockholm',
});
});
});
describe('within a data source plugin', () => {
it('should report interaction with plugin context info for internal data source', () => {
const report = renderDataSourcePluginReporterHook({
uid: 'qeSI8VV7z',
meta: createPluginMeta({
id: 'prometheus',
name: 'Prometheus',
type: PluginType.datasource,
info: createPluginMetaInfo({
version: '',
}),
}),
});
report('grafana_plugin_query_mode_changed');
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_query_mode_changed');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'datasource',
plugin_version: '',
plugin_id: 'prometheus',
plugin_name: 'Prometheus',
datasource_uid: 'qeSI8VV7z',
});
});
it('should report interaction with plugin context info for external data source', () => {
const report = renderDataSourcePluginReporterHook({
uid: 'PD8C576611E62080A',
meta: createPluginMeta({
id: 'grafana-github-datasource',
name: 'GitHub',
type: PluginType.datasource,
info: createPluginMetaInfo({
version: '1.2.0',
}),
}),
});
report('grafana_plugin_repository_selected');
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_repository_selected');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'datasource',
plugin_version: '1.2.0',
plugin_id: 'grafana-github-datasource',
plugin_name: 'GitHub',
datasource_uid: 'PD8C576611E62080A',
});
});
it('should report interaction with plugin context info and extra info provided when reporting', () => {
const report = renderDataSourcePluginReporterHook({
uid: 'PD8C576611E62080A',
meta: createPluginMeta({
id: 'grafana-github-datasource',
name: 'GitHub',
type: PluginType.datasource,
info: createPluginMetaInfo({
version: '1.2.0',
}),
}),
});
report('grafana_plugin_repository_selected', {
repository: 'grafana/grafana',
});
const [args] = reportInteractionMock.mock.calls;
const [interactionName, properties] = args;
expect(reportInteractionMock.mock.calls.length).toBe(1);
expect(interactionName).toBe('grafana_plugin_repository_selected');
expect(properties).toEqual({
grafana_version: '1.0',
plugin_type: 'datasource',
plugin_version: '1.2.0',
plugin_id: 'grafana-github-datasource',
plugin_name: 'GitHub',
datasource_uid: 'PD8C576611E62080A',
repository: 'grafana/grafana',
});
});
});
describe('ensure interaction name follows convention', () => {
it('should throw name does not start with "grafana_plugin_"', () => {
const report = renderDataSourcePluginReporterHook();
expect(() => report('select_query_type')).toThrow();
});
it('should throw if name is exactly "grafana_plugin_"', () => {
const report = renderPluginReporterHook();
expect(() => report('grafana_plugin_')).toThrow();
});
});
});
function renderPluginReporterHook(meta?: Partial<PluginMeta>): typeof reportInteraction {
const wrapper = ({ children }: React.PropsWithChildren<{}>) => (
<PluginContextProvider meta={createPluginMeta(meta)}>{children}</PluginContextProvider>
);
const { result } = renderHook(() => usePluginInteractionReporter(), { wrapper });
return result.current;
}
function renderDataSourcePluginReporterHook(settings?: Partial<DataSourceInstanceSettings>): typeof reportInteraction {
const wrapper = ({ children }: React.PropsWithChildren<{}>) => (
<DataSourcePluginContextProvider instanceSettings={createDataSourceInstanceSettings(settings)}>
{children}
</DataSourcePluginContextProvider>
);
const { result } = renderHook(() => usePluginInteractionReporter(), { wrapper });
return result.current;
}
function createPluginMeta(meta: Partial<PluginMeta> = {}): PluginMeta {
return {
id: 'gauge',
name: 'Gauge',
type: PluginType.panel,
info: createPluginMetaInfo(),
module: 'app/plugins/panel/gauge/module',
baseUrl: '',
signature: PluginSignatureStatus.internal,
...meta,
};
}
function createPluginMetaInfo(info: Partial<PluginMetaInfo> = {}): PluginMetaInfo {
return {
author: { name: 'Grafana Labs' },
description: 'Standard gauge visualization',
links: [],
logos: {
large: 'public/app/plugins/panel/gauge/img/icon_gauge.svg',
small: 'public/app/plugins/panel/gauge/img/icon_gauge.svg',
},
screenshots: [],
updated: '',
version: '',
...info,
};
}
function createDataSourceInstanceSettings(
settings: Partial<DataSourceInstanceSettings> = {}
): DataSourceInstanceSettings {
const { meta, ...rest } = settings;
return {
id: 1,
uid: '',
name: '',
meta: createPluginMeta(meta),
type: PluginType.datasource,
readOnly: false,
jsonData: {},
access: 'proxy',
...rest,
};
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { isDataSourcePluginContext, usePluginContext } from '@grafana/data';
import { reportInteraction } from '../utils';
import { createDataSourcePluginEventProperties, createPluginEventProperties } from './eventProperties';
const namePrefix = 'grafana_plugin_';
export function usePluginInteractionReporter(): typeof reportInteraction {
const context = usePluginContext();
return useMemo(() => {
const info = isDataSourcePluginContext(context)
? createDataSourcePluginEventProperties(context.instanceSettings)
: createPluginEventProperties(context.meta);
return (interactionName: string, properties?: Record<string, unknown>) => {
if (!validInteractionName(interactionName)) {
throw new Error(`Interactions reported in plugins should start with: "${namePrefix}".`);
}
return reportInteraction(interactionName, { ...properties, ...info });
};
}, [context]);
}
function validInteractionName(interactionName: string): boolean {
return interactionName.startsWith(namePrefix) && interactionName.length > namePrefix.length;
}

View File

@@ -1,13 +1,14 @@
import { config } from '../config';
import { locationService } from '../services';
import { getEchoSrv, EchoEventType } from '../services/EchoSrv';
import {
ExperimentViewEchoEvent,
InteractionEchoEvent,
MetaAnalyticsEvent,
MetaAnalyticsEventPayload,
PageviewEchoEvent,
} from '../types/analytics';
} from './types';
/**
* Helper function to report meta analytics to the {@link EchoSrv}.
@@ -42,7 +43,7 @@ export const reportPageview = () => {
*
* @public
*/
export const reportInteraction = (interactionName: string, properties?: Record<string, any>) => {
export const reportInteraction = (interactionName: string, properties?: Record<string, unknown>) => {
getEchoSrv().addEvent<InteractionEchoEvent>({
type: EchoEventType.Interaction,
payload: {

View File

@@ -5,9 +5,9 @@
*/
export * from './services';
export * from './config';
export * from './types';
export * from './analytics/types';
export { loadPluginCss, SystemJS, type PluginCssOptions } from './utils/plugin';
export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './utils/analytics';
export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './analytics/utils';
export { featureEnabled } from './utils/licensing';
export { logInfo, logDebug, logWarning, logError } from './utils/logging';
export {
@@ -35,3 +35,10 @@ export {
type DataSourcePickerProps,
type DataSourcePickerState,
} from './components/DataSourcePicker';
export {
type PluginEventProperties,
createPluginEventProperties,
type DataSourcePluginEventProperties,
createDataSourcePluginEventProperties,
} from './analytics/plugins/eventProperties';
export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter';

View File

@@ -1 +0,0 @@
export * from './analytics';