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);
+ }
+ }
+ };
+}