diff --git a/.betterer.results b/.betterer.results index 5b3c6e7e05b..b61bae1357e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -977,6 +977,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "6"], [0, 0, 0, "Do not use any type assertions.", "7"] ], + "packages/grafana-runtime/src/analytics/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "packages/grafana-runtime/src/components/PanelRenderer.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1032,9 +1035,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "packages/grafana-runtime/src/types/analytics.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1050,9 +1050,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"] ], - "packages/grafana-runtime/src/utils/analytics.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-runtime/src/utils/plugin.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx new file mode 100644 index 00000000000..ec61e62713e --- /dev/null +++ b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx @@ -0,0 +1,20 @@ +import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; + +import { DataSourceInstanceSettings } from '../../types'; + +import { Context, DataSourcePluginContextType } from './PluginContext'; + +export type DataSourcePluginContextProviderProps = { + instanceSettings: DataSourceInstanceSettings; +}; + +export function DataSourcePluginContextProvider( + props: PropsWithChildren +): ReactElement { + const { children, instanceSettings } = props; + const value: DataSourcePluginContextType = useMemo(() => { + return { instanceSettings, meta: instanceSettings.meta }; + }, [instanceSettings]); + + return {children}; +} diff --git a/packages/grafana-data/src/context/plugins/PluginContext.tsx b/packages/grafana-data/src/context/plugins/PluginContext.tsx new file mode 100644 index 00000000000..f8748617541 --- /dev/null +++ b/packages/grafana-data/src/context/plugins/PluginContext.tsx @@ -0,0 +1,14 @@ +import { createContext } from 'react'; + +import { DataSourceInstanceSettings } from '../../types/datasource'; +import { PluginMeta } from '../../types/plugin'; + +export interface PluginContextType { + meta: PluginMeta; +} + +export interface DataSourcePluginContextType extends PluginContextType { + instanceSettings: DataSourceInstanceSettings; +} + +export const Context = createContext(undefined); diff --git a/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx new file mode 100644 index 00000000000..282db933825 --- /dev/null +++ b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx @@ -0,0 +1,14 @@ +import React, { PropsWithChildren, ReactElement } from 'react'; + +import { PluginMeta } from '../../types/plugin'; + +import { Context } from './PluginContext'; + +export type PluginContextProviderProps = { + meta: PluginMeta; +}; + +export function PluginContextProvider(props: PropsWithChildren): ReactElement { + const { children, ...rest } = props; + return {children}; +} diff --git a/packages/grafana-data/src/context/plugins/guards.ts b/packages/grafana-data/src/context/plugins/guards.ts new file mode 100644 index 00000000000..3a93c862f09 --- /dev/null +++ b/packages/grafana-data/src/context/plugins/guards.ts @@ -0,0 +1,5 @@ +import { type DataSourcePluginContextType, type PluginContextType } from './PluginContext'; + +export function isDataSourcePluginContext(context: PluginContextType): context is DataSourcePluginContextType { + return 'instanceSettings' in context && 'meta' in context; +} diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx new file mode 100644 index 00000000000..51723446b81 --- /dev/null +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { Context, PluginContextType } from './PluginContext'; + +export function usePluginContext(): PluginContextType { + const context = useContext(Context); + if (!context) { + throw new Error('usePluginContext must be used within a PluginContextProvider'); + } + return context; +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index c9d92f7bff1..e46409f4d65 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -26,4 +26,12 @@ export { PanelPlugin, type SetFieldConfigOptionsArgs, type StandardOptionConfig export { createFieldConfigRegistry } from './panel/registryFactories'; export { type QueryRunner, type QueryRunnerOptions } from './types/queryRunner'; export { type GroupingToMatrixTransformerOptions } from './transformations/transformers/groupingToMatrix'; +export { type PluginContextType, type DataSourcePluginContextType } from './context/plugins/PluginContext'; +export { type PluginContextProviderProps, PluginContextProvider } from './context/plugins/PluginContextProvider'; +export { + type DataSourcePluginContextProviderProps, + DataSourcePluginContextProvider, +} from './context/plugins/DataSourcePluginContextProvider'; +export { usePluginContext } from './context/plugins/usePluginContext'; +export { isDataSourcePluginContext } from './context/plugins/guards'; export { getLinksSupplier } from './field/fieldOverrides'; diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 3abb81535e6..b785ab6d91a 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -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", diff --git a/packages/grafana-runtime/src/analytics/plugins/eventProperties.ts b/packages/grafana-runtime/src/analytics/plugins/eventProperties.ts new file mode 100644 index 00000000000..214a3698033 --- /dev/null +++ b/packages/grafana-runtime/src/analytics/plugins/eventProperties.ts @@ -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, + }; +} diff --git a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx new file mode 100644 index 00000000000..75b52e2cf59 --- /dev/null +++ b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.test.tsx @@ -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): typeof reportInteraction { + const wrapper = ({ children }: React.PropsWithChildren<{}>) => ( + {children} + ); + const { result } = renderHook(() => usePluginInteractionReporter(), { wrapper }); + return result.current; +} + +function renderDataSourcePluginReporterHook(settings?: Partial): typeof reportInteraction { + const wrapper = ({ children }: React.PropsWithChildren<{}>) => ( + + {children} + + ); + const { result } = renderHook(() => usePluginInteractionReporter(), { wrapper }); + return result.current; +} + +function createPluginMeta(meta: Partial = {}): 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 { + 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 { + const { meta, ...rest } = settings; + + return { + id: 1, + uid: '', + name: '', + meta: createPluginMeta(meta), + type: PluginType.datasource, + readOnly: false, + jsonData: {}, + access: 'proxy', + ...rest, + }; +} diff --git a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts new file mode 100644 index 00000000000..d9d89b76770 --- /dev/null +++ b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts @@ -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) => { + 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; +} diff --git a/packages/grafana-runtime/src/types/analytics.ts b/packages/grafana-runtime/src/analytics/types.ts similarity index 100% rename from packages/grafana-runtime/src/types/analytics.ts rename to packages/grafana-runtime/src/analytics/types.ts diff --git a/packages/grafana-runtime/src/utils/analytics.ts b/packages/grafana-runtime/src/analytics/utils.ts similarity index 96% rename from packages/grafana-runtime/src/utils/analytics.ts rename to packages/grafana-runtime/src/analytics/utils.ts index 9ec3ce4b97d..ac901f0879c 100644 --- a/packages/grafana-runtime/src/utils/analytics.ts +++ b/packages/grafana-runtime/src/analytics/utils.ts @@ -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) => { +export const reportInteraction = (interactionName: string, properties?: Record) => { getEchoSrv().addEvent({ type: EchoEventType.Interaction, payload: { diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 7709758b93b..2652ebfe41b 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -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'; diff --git a/packages/grafana-runtime/src/types/index.ts b/packages/grafana-runtime/src/types/index.ts deleted file mode 100644 index 77dc4f52a6e..00000000000 --- a/packages/grafana-runtime/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './analytics'; diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx index ae064653ded..4c08764ce30 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx @@ -3,7 +3,7 @@ import { noop } from 'lodash'; import React, { FC, useCallback, useMemo } from 'react'; import { useAsync } from 'react-use'; -import { CoreApp, DataQuery, GrafanaTheme2, LoadingState } from '@grafana/data'; +import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, LoadingState } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { Alert, Button, useStyles2 } from '@grafana/ui'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; @@ -49,7 +49,9 @@ export const ExpressionEditor: FC = ({ value, onChange, d return null; } - if (error || !dataSource || !dataSource?.components?.QueryEditor) { + const dsi = getDataSourceSrv().getInstanceSettings(dataSourceName); + + if (error || !dataSource || !dataSource?.components?.QueryEditor || !dsi) { const errorMessage = error?.message || 'Data source plugin does not export any Query Editor component'; return
Could not load query editor due to: {errorMessage}
; } @@ -65,15 +67,16 @@ export const ExpressionEditor: FC = ({ value, onChange, d return ( <> - - + + +