Plugin Extension: improves mutation logs (#107370)
* Plugin Extension: improves mutation logs * chore: removes feature toggle * chore: rename function
This commit is contained in:
@@ -976,10 +976,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
alertingBulkActionsInUI?: boolean;
|
alertingBulkActionsInUI?: boolean;
|
||||||
/**
|
/**
|
||||||
* Use proxy-based read-only objects for plugin extensions instead of deep cloning
|
|
||||||
*/
|
|
||||||
extensionsReadOnlyProxy?: boolean;
|
|
||||||
/**
|
|
||||||
* Registers AuthZ /apis endpoint
|
* Registers AuthZ /apis endpoint
|
||||||
*/
|
*/
|
||||||
kubernetesAuthzApis?: boolean;
|
kubernetesAuthzApis?: boolean;
|
||||||
|
|||||||
@@ -1675,15 +1675,6 @@ var (
|
|||||||
HideFromDocs: true,
|
HideFromDocs: true,
|
||||||
Expression: "true", // enabled by default
|
Expression: "true", // enabled by default
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "extensionsReadOnlyProxy",
|
|
||||||
Description: "Use proxy-based read-only objects for plugin extensions instead of deep cloning",
|
|
||||||
Stage: FeatureStageExperimental,
|
|
||||||
Owner: grafanaPluginsPlatformSquad,
|
|
||||||
HideFromAdminPage: true,
|
|
||||||
HideFromDocs: true,
|
|
||||||
FrontendOnly: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "kubernetesAuthzApis",
|
Name: "kubernetesAuthzApis",
|
||||||
Description: "Registers AuthZ /apis endpoint",
|
Description: "Registers AuthZ /apis endpoint",
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ multiTenantFrontend,experimental,@grafana/grafana-frontend-platform,false,false,
|
|||||||
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
||||||
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
|
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
|
||||||
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
|
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
|
||||||
extensionsReadOnlyProxy,experimental,@grafana/plugins-platform-backend,false,false,true
|
|
||||||
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
|
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
|
||||||
restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
|
restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||||
skipTokenRotationIfRecent,privatePreview,@grafana/identity-access-team,false,false,false
|
skipTokenRotationIfRecent,privatePreview,@grafana/identity-access-team,false,false,false
|
||||||
|
|||||||
|
@@ -887,10 +887,6 @@ const (
|
|||||||
// Enables the alerting bulk actions in the UI
|
// Enables the alerting bulk actions in the UI
|
||||||
FlagAlertingBulkActionsInUI = "alertingBulkActionsInUI"
|
FlagAlertingBulkActionsInUI = "alertingBulkActionsInUI"
|
||||||
|
|
||||||
// FlagExtensionsReadOnlyProxy
|
|
||||||
// Use proxy-based read-only objects for plugin extensions instead of deep cloning
|
|
||||||
FlagExtensionsReadOnlyProxy = "extensionsReadOnlyProxy"
|
|
||||||
|
|
||||||
// FlagKubernetesAuthzApis
|
// FlagKubernetesAuthzApis
|
||||||
// Registers AuthZ /apis endpoint
|
// Registers AuthZ /apis endpoint
|
||||||
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
|
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
|
||||||
|
|||||||
@@ -1110,7 +1110,8 @@
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "extensionsReadOnlyProxy",
|
"name": "extensionsReadOnlyProxy",
|
||||||
"resourceVersion": "1750434297879",
|
"resourceVersion": "1750434297879",
|
||||||
"creationTimestamp": "2025-05-06T04:55:23Z"
|
"creationTimestamp": "2025-05-06T04:55:23Z",
|
||||||
|
"deletionTimestamp": "2025-06-30T08:24:11Z"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"description": "Use proxy-based read-only objects for plugin extensions instead of deep cloning",
|
"description": "Use proxy-based read-only objects for plugin extensions instead of deep cloning",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createElement, PureComponent } from 'react';
|
import { createElement, PureComponent } from 'react';
|
||||||
|
|
||||||
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
|
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
|
||||||
import { readOnlyCopy } from 'app/features/plugins/extensions/utils';
|
import { writableProxy } from 'app/features/plugins/extensions/utils';
|
||||||
|
|
||||||
import { GenericDataSourcePlugin } from '../types';
|
import { GenericDataSourcePlugin } from '../types';
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export class DataSourcePluginSettings extends PureComponent<Props> {
|
|||||||
<div>
|
<div>
|
||||||
{plugin.components.ConfigEditor &&
|
{plugin.components.ConfigEditor &&
|
||||||
createElement(plugin.components.ConfigEditor, {
|
createElement(plugin.components.ConfigEditor, {
|
||||||
options: readOnlyCopy(dataSource),
|
options: writableProxy(dataSource),
|
||||||
onOptionsChange: this.onModelChanged,
|
onOptionsChange: this.onModelChanged,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -380,8 +380,8 @@ describe('usePluginComponent()', () => {
|
|||||||
// Should not throw an error if it mutates the props
|
// Should not throw an error if it mutates the props
|
||||||
expect(() => render(Component && <Component {...originalProps} override />)).not.toThrow();
|
expect(() => render(Component && <Component {...originalProps} override />)).not.toThrow();
|
||||||
|
|
||||||
// Should log a warning
|
// Should log an error in dev mode
|
||||||
expect(log.warning).toHaveBeenCalledWith('Attempted to mutate object property "c"', {
|
expect(log.error).toHaveBeenCalledWith('Attempted to mutate object property "c"', {
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ describe('usePluginComponents()', () => {
|
|||||||
|
|
||||||
// Should also render the component if it wants to change the props
|
// Should also render the component if it wants to change the props
|
||||||
expect(() => render(<Component foo={originalFoo} override />)).not.toThrow();
|
expect(() => render(<Component foo={originalFoo} override />)).not.toThrow();
|
||||||
expect(log.warning).toHaveBeenCalledWith(`Attempted to mutate object property "foo4"`, {
|
expect(log.error).toHaveBeenCalledWith(`Attempted to mutate object property "foo4"`, {
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
getAppPluginDependencies,
|
getAppPluginDependencies,
|
||||||
getExtensionPointPluginMeta,
|
getExtensionPointPluginMeta,
|
||||||
getMutationObserverProxy,
|
getMutationObserverProxy,
|
||||||
readOnlyCopy,
|
writableProxy,
|
||||||
isReadOnlyProxy,
|
|
||||||
isMutationObserverProxy,
|
isMutationObserverProxy,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
@@ -401,7 +400,7 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
expect(proxy.a).toBe('b');
|
expect(proxy.a).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be possible to set new values, but logs a warning', () => {
|
it('should be possible to set new values, but logs a debug message', () => {
|
||||||
const obj: { a: string; b?: string } = { a: 'a' };
|
const obj: { a: string; b?: string } = { a: 'a' };
|
||||||
const proxy = getMutationObserverProxy(obj);
|
const proxy = getMutationObserverProxy(obj);
|
||||||
|
|
||||||
@@ -412,7 +411,7 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
|
|
||||||
expect(log.warning).toHaveBeenCalledWith(`Attempted to define object property "b"`, {
|
expect(log.debug).toHaveBeenCalledWith(`Attempted to define object property "b"`, {
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,12 +439,11 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('readOnlyCopy()', () => {
|
describe('writableProxy()', () => {
|
||||||
const originalEnv = config.buildInfo.env;
|
const originalEnv = config.buildInfo.env;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation();
|
jest.spyOn(console, 'warn').mockImplementation();
|
||||||
config.featureToggles.extensionsReadOnlyProxy = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -454,34 +452,19 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the same value for primitive types', () => {
|
it('should return the same value for primitive types', () => {
|
||||||
expect(readOnlyCopy(1)).toBe(1);
|
expect(writableProxy(1)).toBe(1);
|
||||||
expect(readOnlyCopy('a')).toBe('a');
|
expect(writableProxy('a')).toBe('a');
|
||||||
expect(readOnlyCopy(true)).toBe(true);
|
expect(writableProxy(true)).toBe(true);
|
||||||
expect(readOnlyCopy(false)).toBe(false);
|
expect(writableProxy(false)).toBe(false);
|
||||||
expect(readOnlyCopy(null)).toBe(null);
|
expect(writableProxy(null)).toBe(null);
|
||||||
expect(readOnlyCopy(undefined)).toBe(undefined);
|
expect(writableProxy(undefined)).toBe(undefined);
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a read-only proxy of the original object if the feature flag is enabled', () => {
|
|
||||||
config.featureToggles.extensionsReadOnlyProxy = true;
|
|
||||||
|
|
||||||
const obj = { a: 'a' };
|
|
||||||
const copy = readOnlyCopy(obj);
|
|
||||||
|
|
||||||
expect(copy).not.toBe(obj);
|
|
||||||
expect(copy.a).toBe('a');
|
|
||||||
expect(isReadOnlyProxy(copy)).toBe(true);
|
|
||||||
expect(() => {
|
|
||||||
copy.a = 'b';
|
|
||||||
}).toThrow(TypeError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a writable deep-copy of the original object in dev mode', () => {
|
it('should return a writable deep-copy of the original object in dev mode', () => {
|
||||||
config.featureToggles.extensionsReadOnlyProxy = false;
|
|
||||||
config.buildInfo.env = 'development';
|
config.buildInfo.env = 'development';
|
||||||
|
|
||||||
const obj = { a: 'a' };
|
const obj = { a: 'a' };
|
||||||
const copy = readOnlyCopy(obj);
|
const copy = writableProxy(obj);
|
||||||
|
|
||||||
expect(copy).not.toBe(obj);
|
expect(copy).not.toBe(obj);
|
||||||
expect(copy.a).toBe('a');
|
expect(copy.a).toBe('a');
|
||||||
@@ -498,11 +481,10 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return a writable deep-copy of the original object in production mode', () => {
|
it('should return a writable deep-copy of the original object in production mode', () => {
|
||||||
config.featureToggles.extensionsReadOnlyProxy = false;
|
|
||||||
config.buildInfo.env = 'production';
|
config.buildInfo.env = 'production';
|
||||||
|
|
||||||
const obj = { a: 'a' };
|
const obj = { a: 'a' };
|
||||||
const copy = readOnlyCopy(obj);
|
const copy = writableProxy(obj);
|
||||||
|
|
||||||
expect(copy).not.toBe(obj);
|
expect(copy).not.toBe(obj);
|
||||||
expect(copy.a).toBe('a');
|
expect(copy.a).toBe('a');
|
||||||
@@ -519,11 +501,10 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow freezing the object in production mode', () => {
|
it('should allow freezing the object in production mode', () => {
|
||||||
config.featureToggles.extensionsReadOnlyProxy = false;
|
|
||||||
config.buildInfo.env = 'production';
|
config.buildInfo.env = 'production';
|
||||||
|
|
||||||
const obj = { a: 'a', b: { c: 'c' } };
|
const obj = { a: 'a', b: { c: 'c' } };
|
||||||
const copy = readOnlyCopy(obj);
|
const copy = writableProxy(obj);
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
Object.freeze(copy);
|
Object.freeze(copy);
|
||||||
@@ -534,7 +515,7 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
expect(Object.isFrozen(copy.b)).toBe(true);
|
expect(Object.isFrozen(copy.b)).toBe(true);
|
||||||
expect(copy.b).toEqual({ c: 'c' });
|
expect(copy.b).toEqual({ c: 'c' });
|
||||||
|
|
||||||
expect(log.warning).toHaveBeenCalledWith(`Attempted to define object property "a"`, {
|
expect(log.debug).toHaveBeenCalledWith(`Attempted to define object property "a"`, {
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -687,7 +668,7 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
|
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not be possible to mutate the props in development mode, but it logs a warning', async () => {
|
it('should not be possible to mutate the props in development mode, but it logs an error', async () => {
|
||||||
config.buildInfo.env = 'development';
|
config.buildInfo.env = 'development';
|
||||||
const pluginId = 'grafana-worldmap-panel';
|
const pluginId = 'grafana-worldmap-panel';
|
||||||
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
|
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
|
||||||
@@ -700,8 +681,8 @@ describe('Plugin Extensions / Utils', () => {
|
|||||||
expect(await screen.findByText('Hello Grafana!')).toBeVisible();
|
expect(await screen.findByText('Hello Grafana!')).toBeVisible();
|
||||||
|
|
||||||
// Logs a warning
|
// Logs a warning
|
||||||
expect(log.warning).toHaveBeenCalledTimes(1);
|
expect(log.error).toHaveBeenCalledTimes(1);
|
||||||
expect(log.warning).toHaveBeenCalledWith(`Attempted to mutate object property "c"`, {
|
expect(log.error).toHaveBeenCalledWith(`Attempted to mutate object property "c"`, {
|
||||||
stack: expect.any(String),
|
stack: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.Com
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginContextProvider meta={pluginMeta}>
|
<PluginContextProvider meta={pluginMeta}>
|
||||||
<Component {...readOnlyCopy(props, log)} />
|
<Component {...writableProxy(props, log)} />
|
||||||
</PluginContextProvider>
|
</PluginContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -230,24 +230,27 @@ export function getMutationObserverProxy<T extends object>(obj: T, _log: Extensi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cache = new WeakMap();
|
const cache = new WeakMap();
|
||||||
|
const logFunction = isGrafanaDevMode() ? _log.error.bind(_log) : _log.warning.bind(_log); // should show error during local development
|
||||||
|
|
||||||
return new Proxy(obj, {
|
return new Proxy(obj, {
|
||||||
deleteProperty(target, prop) {
|
deleteProperty(target, prop) {
|
||||||
_log.warning(`Attempted to delete object property "${String(prop)}"`, {
|
logFunction(`Attempted to delete object property "${String(prop)}"`, {
|
||||||
stack: new Error().stack ?? '',
|
stack: new Error().stack ?? '',
|
||||||
});
|
});
|
||||||
Reflect.deleteProperty(target, prop);
|
Reflect.deleteProperty(target, prop);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
defineProperty(target, prop, descriptor) {
|
defineProperty(target, prop, descriptor) {
|
||||||
_log.warning(`Attempted to define object property "${String(prop)}"`, {
|
// because immer (used by RTK) calls Object.isFrozen and Object.freeze we know that defineProperty will be called
|
||||||
|
// behind the scenes as well so we only log message with debug level to minimize the noise and false positives
|
||||||
|
_log.debug(`Attempted to define object property "${String(prop)}"`, {
|
||||||
stack: new Error().stack ?? '',
|
stack: new Error().stack ?? '',
|
||||||
});
|
});
|
||||||
Reflect.defineProperty(target, prop, descriptor);
|
Reflect.defineProperty(target, prop, descriptor);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
set(target, prop, newValue) {
|
set(target, prop, newValue) {
|
||||||
_log.warning(`Attempted to mutate object property "${String(prop)}"`, {
|
logFunction(`Attempted to mutate object property "${String(prop)}"`, {
|
||||||
stack: new Error().stack ?? '',
|
stack: new Error().stack ?? '',
|
||||||
});
|
});
|
||||||
Reflect.set(target, prop, newValue);
|
Reflect.set(target, prop, newValue);
|
||||||
@@ -285,16 +288,12 @@ export function getMutationObserverProxy<T extends object>(obj: T, _log: Extensi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readOnlyCopy<T>(value: T, _log: ExtensionsLog = log): T {
|
export function writableProxy<T>(value: T, _log: ExtensionsLog = log): T {
|
||||||
// Primitive types are read-only by default
|
// Primitive types are read-only by default
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.featureToggles.extensionsReadOnlyProxy) {
|
|
||||||
return getReadOnlyProxy(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: we return a proxy of a deep-cloned version of the original object, which logs warnings when mutation is attempted
|
// Default: we return a proxy of a deep-cloned version of the original object, which logs warnings when mutation is attempted
|
||||||
return getMutationObserverProxy(cloneDeep(value), _log);
|
return getMutationObserverProxy(cloneDeep(value), _log);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user