Plugin Extension: improves mutation logs (#107370)

* Plugin Extension: improves  mutation logs

* chore: removes feature toggle

* chore: rename function
This commit is contained in:
Hugo Häggmark
2025-07-01 06:10:57 +02:00
committed by GitHub
parent 38e1f900e7
commit 24138bde70
10 changed files with 32 additions and 69 deletions
@@ -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;
-9
View File
@@ -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",
-1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
219 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true
220 alertRuleUseFiredAtForStartsAt experimental @grafana/alerting-squad false false false
221 alertingBulkActionsInUI GA @grafana/alerting-squad false false true
extensionsReadOnlyProxy experimental @grafana/plugins-platform-backend false false true
222 kubernetesAuthzApis experimental @grafana/identity-access-team false false false
223 restoreDashboards experimental @grafana/grafana-frontend-platform false false false
224 skipTokenRotationIfRecent privatePreview @grafana/identity-access-team false false false
-4
View File
@@ -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"
+2 -1
View File
@@ -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);
} }