From ef82767dabea7d19cd8efac0c36ed590d4d767f4 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon, 9 Oct 2023 08:19:08 +0300 Subject: [PATCH] Panel monitoring (#75456) * WIP * remove bug * XY Chart logs * wip * wip * wip * wip * wip * Revert experimental logs * wip dataviz options monitor * add logging functionality on panel save * remove unused file * readd start load time * remove afterFrame lib. remove assertions where possible * add tests * PR modifications * fix betterer * rename logEvent to logPanelEvent * add feature flag * split monitor into measurement and logging parts * rename component * log panel options on error capture also. Log overrides only then * refactor logs * log panel option changes only on error in panel edit mode * refactor function --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + public/app/core/log_events.ts | 18 ++ .../dashgrid/PanelLoadTimeMonitor.test.tsx | 49 +++++ .../dashgrid/PanelLoadTimeMonitor.tsx | 50 +++++ .../dashboard/dashgrid/PanelStateWrapper.tsx | 26 +++ .../dashgrid/panelOptionsLogger.test.ts | 180 ++++++++++++++++++ .../dashboard/dashgrid/panelOptionsLogger.ts | 122 ++++++++++++ 11 files changed, 459 insertions(+) create mode 100644 public/app/core/log_events.ts create mode 100644 public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx create mode 100644 public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts create mode 100644 public/app/features/dashboard/dashgrid/panelOptionsLogger.ts diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 21fd4dffe8e..4db65160b46 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -143,6 +143,7 @@ Experimental features might be changed or removed without prior notice. | `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins | | `httpSLOLevels` | Adds SLO level to http request metrics | | `alertingModifiedExport` | Enables using UI for provisioned rules modification and export | +| `panelMonitoring` | Enables panel monitoring through logs and measurements | | `enableNativeHTTPHistogram` | Enables native HTTP Histograms | | `transformationsVariableSupport` | Allows using variables in transformations | | `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a879ded6895..ee8b2d1496e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -136,6 +136,7 @@ export interface FeatureToggles { cloudWatchWildCardDimensionValues?: boolean; externalServiceAccounts?: boolean; alertingModifiedExport?: boolean; + panelMonitoring?: boolean; enableNativeHTTPHistogram?: boolean; transformationsVariableSupport?: boolean; kubernetesPlaylists?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f390304cf37..8cc929a830a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -824,6 +824,13 @@ var ( FrontendOnly: false, Owner: grafanaAlertingSquad, }, + { + Name: "panelMonitoring", + Description: "Enables panel monitoring through logs and measurements", + Stage: FeatureStageExperimental, + Owner: grafanaDatavizSquad, + FrontendOnly: true, + }, { Name: "enableNativeHTTPHistogram", Description: "Enables native HTTP Histograms", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 11418b803b4..482c1ec2dc9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -117,6 +117,7 @@ idForwarding,experimental,@grafana/grafana-authnz-team,true,false,false,false cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false,false externalServiceAccounts,experimental,@grafana/grafana-authnz-team,true,false,false,false alertingModifiedExport,experimental,@grafana/alerting-squad,false,false,false,false +panelMonitoring,experimental,@grafana/dataviz-squad,false,false,false,true enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false,false transformationsVariableSupport,experimental,@grafana/grafana-bi-squad,false,false,false,true kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index be4be3adb42..62ba51e5739 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -479,6 +479,10 @@ const ( // Enables using UI for provisioned rules modification and export FlagAlertingModifiedExport = "alertingModifiedExport" + // FlagPanelMonitoring + // Enables panel monitoring through logs and measurements + FlagPanelMonitoring = "panelMonitoring" + // FlagEnableNativeHTTPHistogram // Enables native HTTP Histograms FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram" diff --git a/public/app/core/log_events.ts b/public/app/core/log_events.ts new file mode 100644 index 00000000000..ba6232fc231 --- /dev/null +++ b/public/app/core/log_events.ts @@ -0,0 +1,18 @@ +export enum PanelLogEvents { + FIELD_CONFIG_OVERRIDES_CHANGED_EVENT = 'field config overrides changed', + NEW_PANEL_OPTION_EVENT = 'new panel option', + PANEL_OPTION_CHANGED_EVENT = 'panel option changed', + NEW_DEFAULT_FIELD_CONFIG_EVENT = 'new default field config', + DEFAULT_FIELD_CONFIG_CHANGED_EVENT = 'default field config changed', + NEW_CUSTOM_FIELD_CONFIG_EVENT = 'new custom field config', + CUSTOM_FIELD_CONFIG_CHANGED_EVENT = 'custom field config changed', + MEASURE_PANEL_LOAD_TIME_EVENT = 'measure panel load time', + THRESHOLDS_COUNT_CHANGED_EVENT = 'thresholds count changed', + THRESHOLDS_MODE_CHANGED_EVENT = 'thresholds mode changed', + MAPPINGS_COUNT_CHANGED_EVENT = 'mappings count changed', + LINKS_COUNT_CHANGED_EVENT = 'links count changed', + PANEL_ERROR = 'panel error', +} + +export const FIELD_CONFIG_OVERRIDES_KEY = 'overrides'; +export const FIELD_CONFIG_CUSTOM_KEY = 'custom'; diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx new file mode 100644 index 00000000000..cc960b006f1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +const mockPushMeasurement = jest.fn(); + +import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor'; + +jest.mock('app/core/config', () => ({ + config: { + grafanaJavascriptAgent: { + enabled: true, + }, + }, +})); + +jest.mock('@grafana/faro-web-sdk', () => ({ + faro: { + api: { + pushMeasurement: mockPushMeasurement, + }, + }, +})); + +describe('PanelLoadTimeMonitor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('logs load time measurement on render', () => { + jest.useFakeTimers(); + + const props = { + isInPanelEdit: true, + panelType: 'timeseries', + panelId: 1, + panelTitle: 'Panel Title', + panelOptions: {}, + panelFieldConfig: { + defaults: {}, + overrides: [], + }, + }; + + render(); + + jest.runAllTimers(); + + expect(mockPushMeasurement).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx new file mode 100644 index 00000000000..230add25c6a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; + +import { faro } from '@grafana/faro-web-sdk'; +import { config } from 'app/core/config'; +import { PanelLogEvents } from 'app/core/log_events'; + +interface Props { + panelType: string; + panelId: number; + panelTitle: string; +} + +export const PanelLoadTimeMonitor = (props: Props) => { + const startLoadTime = performance.now(); + + useEffect(() => { + if (!config.grafanaJavascriptAgent.enabled) { + return; + } + + // This code will be run ASAP after Style and Layout information have + // been calculated and the paint has occurred. + // https://firefox-source-docs.mozilla.org/performance/bestpractices.html + requestAnimationFrame(() => { + setTimeout(() => { + faro.api.pushMeasurement( + { + type: PanelLogEvents.MEASURE_PANEL_LOAD_TIME_EVENT, + values: { + start_loading_time_ms: startLoadTime, + load_time_ms: performance.now() - startLoadTime, + }, + }, + { + context: { + panel_type: props.panelType, + panel_id: String(props.panelId), + panel_title: props.panelTitle, + }, + } + ); + }, 0); + }); + + return; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 6bf76c3d075..c8bc81ea66c 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -30,6 +30,7 @@ import { SeriesVisibilityChangeMode, AdHocFilterItem, } from '@grafana/ui'; +import config from 'app/core/config'; import { profiler } from 'app/core/profiler'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -47,8 +48,10 @@ import { getPanelChromeProps } from '../utils/getPanelChromeProps'; import { loadSnapshotData } from '../utils/loadSnapshotData'; import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper'; +import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { liveTimer } from './liveTimer'; +import { PanelOptionsLogger } from './panelOptionsLogger'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -81,6 +84,7 @@ export class PanelStateWrapper extends PureComponent { private readonly timeSrv: TimeSrv = getTimeSrv(); private subs = new Subscription(); private eventFilter: EventFilterOptions = { onlyLocal: true }; + private panelOptionsLogger: PanelOptionsLogger | undefined = undefined; constructor(props: Props) { super(props); @@ -112,6 +116,16 @@ export class PanelStateWrapper extends PureComponent { }, data: this.getInitialPanelDataState(), }; + + if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) { + const panelInfo = { + panelId: String(props.panel.id), + panelType: props.panel.type, + panelTitle: props.panel.title, + }; + + this.panelOptionsLogger = new PanelOptionsLogger(props.panel.getOptions(), props.panel.fieldConfig, panelInfo); + } } // Due to a mutable panel model we get the sync settings via function that proactively reads from the model @@ -373,8 +387,17 @@ export class PanelStateWrapper extends PureComponent { this.props.panel.updateFieldConfig(config); }; + logPanelChangesOnError() { + this.panelOptionsLogger!.logChanges(this.props.panel.getOptions(), this.props.panel.fieldConfig); + } + onPanelError = (error: Error) => { + if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) { + this.logPanelChangesOnError(); + } + const errorMessage = error.message || DEFAULT_PLUGIN_ERROR; + if (this.state.errorMessage !== errorMessage) { this.setState({ errorMessage }); } @@ -512,6 +535,9 @@ export class PanelStateWrapper extends PureComponent { onChangeTimeRange={this.onChangeTimeRange} eventBus={dashboard.events} /> + {config.featureToggles.panelMonitoring && this.state.errorMessage === undefined && ( + + )} ); diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts new file mode 100644 index 00000000000..95a43c75cad --- /dev/null +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts @@ -0,0 +1,180 @@ +import { GraphDrawStyle, GraphGradientMode } from '@grafana/schema'; + +const mockPushEvent = jest.fn(); + +import { PanelOptionsLogger } from './panelOptionsLogger'; + +jest.mock('@grafana/faro-web-sdk', () => ({ + faro: { + api: { + pushEvent: mockPushEvent, + }, + }, +})); + +jest.mock('app/core/config', () => ({ + config: { + grafanaJavascriptAgent: { + enabled: true, + }, + }, +})); + +describe('OptionsPane', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('logs panel options', () => { + const oldPanelOptions = { + showHeader: true, + footer: { + show: true, + }, + }; + + const newPanelOptions = { + showHeader: false, + footer: { + show: false, + }, + showTypeIcons: true, + }; + + const panelInfo = { + panelType: 'table', + panelId: '1', + panelTitle: 'Panel Title', + }; + + const expectedLogResults = [ + { + key: 'showHeader', + newValue: 'false', + oldValue: 'true', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'table', + }, + { + key: 'footer', + newValue: '{"show":false}', + oldValue: '{"show":true}', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'table', + }, + { + key: 'showTypeIcons', + newValue: 'true', + oldValue: '', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'table', + }, + ]; + + const panelOptionsLogger = new PanelOptionsLogger(oldPanelOptions, { defaults: {}, overrides: [] }, panelInfo); + + panelOptionsLogger.logChanges(newPanelOptions, { defaults: {}, overrides: [] }); + + expect(mockPushEvent).toHaveBeenCalledTimes(3); + expect(mockPushEvent.mock.calls).toEqual([ + ['panel option changed', expectedLogResults[0]], + ['panel option changed', expectedLogResults[1]], + ['new panel option', expectedLogResults[2]], + ]); + }); + it('logs field config changes', () => { + const oldFieldConfig = { + defaults: { + unit: 'bytes', + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + overrides: [ + { + matcher: { + id: 'byName', + options: '', + }, + properties: [], + }, + ], + }; + + const newFieldConfig = { + defaults: { + unit: 'metres', + newField: 'newValue', + custom: { + drawStyle: GraphDrawStyle.Line, + gradientMode: GraphGradientMode.Hue, + }, + }, + overrides: [], + }; + + const panelInfo = { + panelType: 'timeseries', + panelId: '1', + panelTitle: 'Panel Title', + }; + + const expectedLogResults = [ + { + key: 'overrides', + newValue: '[]', + oldValue: '[{"matcher":{"id":"byName","options":""},"properties":[]}]', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'timeseries', + }, + { + key: 'unit', + newValue: 'metres', + oldValue: 'bytes', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'timeseries', + }, + { + key: 'newField', + newValue: 'newValue', + oldValue: '', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'timeseries', + }, + { + key: 'drawStyle', + newValue: 'line', + oldValue: 'bars', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'timeseries', + }, + { + key: 'gradientMode', + newValue: 'hue', + oldValue: '', + panelTitle: 'Panel Title', + panelId: '1', + panelType: 'timeseries', + }, + ]; + + const panelOptionsLogger = new PanelOptionsLogger({}, oldFieldConfig, panelInfo); + + panelOptionsLogger.logChanges({}, newFieldConfig); + + expect(mockPushEvent).toHaveBeenCalledTimes(5); + expect(mockPushEvent.mock.calls).toEqual([ + ['field config overrides changed', expectedLogResults[0]], + ['default field config changed', expectedLogResults[1]], + ['new default field config', expectedLogResults[2]], + ['custom field config changed', expectedLogResults[3]], + ['new custom field config', expectedLogResults[4]], + ]); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts new file mode 100644 index 00000000000..21748075306 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts @@ -0,0 +1,122 @@ +import { FieldConfigSource } from '@grafana/data'; +import { faro } from '@grafana/faro-web-sdk'; +import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events'; + +interface PanelLogInfo { + panelId: string; + panelType: string; + panelTitle: string; +} + +export class PanelOptionsLogger { + private initialPanelOptions: unknown; + private initialFieldConfig: FieldConfigSource; + private panelLogInfo: PanelLogInfo; + + constructor(initialPanelOptions: unknown, initialFieldConfig: FieldConfigSource, panelLogInfo: PanelLogInfo) { + this.initialPanelOptions = initialPanelOptions; + this.initialFieldConfig = initialFieldConfig; + this.panelLogInfo = panelLogInfo; + } + + logChanges = (latestPanelOptions: unknown, latestFieldConfig: FieldConfigSource) => { + this.logPanelOptionChanges(latestPanelOptions, this.initialPanelOptions); + this.logFieldConfigChanges(latestFieldConfig, this.initialFieldConfig); + + //set the old values to the current values for next log diff + this.initialPanelOptions = latestPanelOptions; + this.initialFieldConfig = latestFieldConfig; + }; + + logPanelEvent = (eventName: string, newKey: string, newVal: string, oldVal?: string) => { + const logObj = { + key: newKey, + newValue: newVal, + oldValue: oldVal ?? '', + panelTitle: this.panelLogInfo.panelTitle, + panelId: this.panelLogInfo.panelId, + panelType: this.panelLogInfo.panelType, + }; + + faro.api.pushEvent(eventName, logObj); + }; + + logPanelOptionChanges = (panelOptions: unknown, oldPanelOptions: unknown) => { + if (typeof panelOptions !== 'object' || panelOptions === null) { + return; + } + + if (typeof oldPanelOptions !== 'object' || oldPanelOptions === null) { + return; + } + + const oldPanelOptionsUnknown: { [key: string]: unknown } = { ...oldPanelOptions }; + + for (const [key, value] of Object.entries(panelOptions)) { + const newValue: string = typeof value !== 'string' ? JSON.stringify(value) : value; + const oldValue: string = + typeof value !== 'string' ? JSON.stringify(oldPanelOptionsUnknown[key]) : String(oldPanelOptionsUnknown[key]); + + if (oldPanelOptionsUnknown[key] === undefined) { + this.logPanelEvent(PanelLogEvents.NEW_PANEL_OPTION_EVENT, key, newValue); + } else if (oldValue !== newValue) { + this.logPanelEvent(PanelLogEvents.PANEL_OPTION_CHANGED_EVENT, key, newValue, oldValue); + } + } + }; + + logFieldConfigChanges = (fieldConfig: FieldConfigSource, oldFieldConfig: FieldConfigSource) => { + // overrides are an array of objects, so stringify it all and log changes + // in lack of an index, we can't tell which override changed + const oldOverridesStr = JSON.stringify(oldFieldConfig.overrides); + const newOverridesStr = JSON.stringify(fieldConfig.overrides); + if (oldOverridesStr !== newOverridesStr) { + this.logPanelEvent( + PanelLogEvents.FIELD_CONFIG_OVERRIDES_CHANGED_EVENT, + FIELD_CONFIG_OVERRIDES_KEY, + newOverridesStr, + oldOverridesStr + ); + } + + const oldDefaults: { [key: string]: unknown } = { ...oldFieldConfig.defaults }; + + // go through field config keys except custom, we treat that below + for (const [key, value] of Object.entries(fieldConfig.defaults)) { + if (key === FIELD_CONFIG_CUSTOM_KEY) { + continue; + } + + const newValue: string = typeof value !== 'string' ? JSON.stringify(value) : value; + const oldValue: string = typeof value !== 'string' ? JSON.stringify(oldDefaults[key]) : String(oldDefaults[key]); + + if (oldDefaults[key] === undefined) { + this.logPanelEvent(PanelLogEvents.NEW_DEFAULT_FIELD_CONFIG_EVENT, key, newValue); + } else if (oldValue !== newValue) { + this.logPanelEvent(PanelLogEvents.DEFAULT_FIELD_CONFIG_CHANGED_EVENT, key, newValue, oldValue); + } + } + + if (!fieldConfig.defaults.custom || oldDefaults.custom === undefined) { + return; + } + + const oldCustom: { [key: string]: unknown } = { ...oldDefaults.custom }; + + // go through custom field config keys + for (const [key, value] of Object.entries(fieldConfig.defaults.custom)) { + if (oldDefaults.custom === null || oldCustom[key] === null) { + continue; + } + + const newValue: string = typeof value !== 'string' ? JSON.stringify(value) : value; + const oldValue: string = typeof value !== 'string' ? JSON.stringify(oldCustom[key]) : String(oldCustom[key]); + + if (oldCustom[key] === undefined) { + this.logPanelEvent(PanelLogEvents.NEW_CUSTOM_FIELD_CONFIG_EVENT, key, newValue); + } else if (oldValue !== newValue) { + this.logPanelEvent(PanelLogEvents.CUSTOM_FIELD_CONFIG_CHANGED_EVENT, key, newValue, oldValue); + } + } + }; +}