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 670665c39ed..96fb0128294 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -136,6 +136,7 @@ Experimental features might be changed or removed without prior notice. | `requestInstrumentationStatusSource` | Include a status source label for request metrics and logs | | `wargamesTesting` | Placeholder feature flag for internal testing | | `alertingInsights` | Show the new alerting insights landing page | +| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1f4bb6c141f..ea13fc0f721 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -127,4 +127,5 @@ export interface FeatureToggles { lokiRunQueriesInParallel?: boolean; wargamesTesting?: boolean; alertingInsights?: boolean; + pluginsAPIMetrics?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cc1d98fc6b8..caa4724d8b3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -759,5 +759,12 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, }, + { + Name: "pluginsAPIMetrics", + Description: "Sends metrics of public grafana packages usage by plugins", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f6d76445f0d..b50154601ac 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -108,3 +108,4 @@ requestInstrumentationStatusSource,experimental,@grafana/plugins-platform-backen lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false,false wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false,false alertingInsights,experimental,@grafana/alerting-squad,false,false,false,true +pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ff9ae1b5993..e04185c4814 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -442,4 +442,8 @@ const ( // FlagAlertingInsights // Show the new alerting insights landing page FlagAlertingInsights = "alertingInsights" + + // FlagPluginsAPIMetrics + // Sends metrics of public grafana packages usage by plugins + FlagPluginsAPIMetrics = "pluginsAPIMetrics" ) diff --git a/public/app/features/plugins/loader/packageMetrics.test.ts b/public/app/features/plugins/loader/packageMetrics.test.ts new file mode 100644 index 00000000000..f687515d8f3 --- /dev/null +++ b/public/app/features/plugins/loader/packageMetrics.test.ts @@ -0,0 +1,226 @@ +import { logInfo } from '@grafana/runtime'; + +import { trackPackageUsage } from './packageMetrics'; + +jest.mock('@grafana/runtime', () => ({ + logInfo: jest.fn().mockImplementation(), +})); + +// notice each test object has a different key to prevent hitting the cache +const logInfoMock = logInfo as jest.Mock; +const mockUsage = jest.fn(); + +describe('trackPackageUsage', () => { + beforeEach(() => { + logInfoMock.mockClear(); + }); + + describe('With document.currentScript null', () => { + const originalCurrentScript = document.currentScript; + + // set currentScript to null + beforeAll(() => { + Object.defineProperty(document, 'currentScript', { + value: null, + writable: true, + }); + }); + + // restore original currentScript + afterAll(() => { + Object.defineProperty(document, 'currentScript', { + value: originalCurrentScript, + writable: true, + }); + }); + + it('should log API usage and return a proxy object', () => { + const obj = { + foo: 'bar', + }; + const packageName = 'your-package'; + + const result = trackPackageUsage(obj, packageName); + + mockUsage(result.foo); + + expect(logInfoMock).toHaveBeenCalledTimes(1); + expect(logInfoMock).toHaveBeenLastCalledWith(`Plugin using your-package.foo`, { + key: 'foo', + parent: 'your-package', + packageName: 'your-package', + guessedPluginName: '', + }); + expect(result).toEqual(obj); + }); + + it('should return a proxy object for nested properties', () => { + const obj = { + foo2: { + bar: 'baz', + }, + }; + const packageName = 'your-package'; + + const result = trackPackageUsage(obj, packageName); + mockUsage(result.foo2.bar); + + // 2 calls, one for each attribute + expect(logInfoMock).toHaveBeenCalledTimes(2); + + expect(logInfoMock).toHaveBeenCalledWith(`Plugin using your-package.foo2`, { + key: 'foo2', + parent: 'your-package', + packageName: 'your-package', + guessedPluginName: '', + }); + expect(logInfoMock).toHaveBeenCalledWith(`Plugin using your-package.foo2.bar`, { + key: 'bar', + parent: 'your-package.foo2', + packageName: 'your-package', + guessedPluginName: '', + }); + + expect(result.foo2).toEqual(obj.foo2); + }); + + it('should not log API usage for symbols or __useDefault key', () => { + const obj = { + [Symbol('key')]: 'value', + __useDefault: 'default', + }; + const packageName = 'your-package'; + + const result = trackPackageUsage(obj, packageName); + + expect(logInfoMock).not.toHaveBeenCalled(); + expect(result).toEqual(obj); + }); + + it('should return the same proxy object for the same nested property', () => { + const obj = { + foo3: { + bar: 'baz', + }, + }; + const packageName = 'your-package'; + + const result1 = trackPackageUsage(obj, packageName); + const result2 = trackPackageUsage(obj, packageName); + + mockUsage(result1.foo3); + + expect(logInfoMock).toHaveBeenCalledTimes(1); + expect(logInfoMock).toHaveBeenCalledWith(`Plugin using your-package.foo3`, { + key: 'foo3', + parent: 'your-package', + packageName: 'your-package', + guessedPluginName: '', + }); + mockUsage(result2.foo3.bar); + expect(logInfoMock).toHaveBeenCalledWith(`Plugin using your-package.foo3.bar`, { + key: 'bar', + parent: 'your-package.foo3', + packageName: 'your-package', + guessedPluginName: '', + }); + + expect(result1.foo3).toEqual(obj.foo3); + expect(result2.foo3).toEqual(obj.foo3); + expect(result1.foo3).toBe(result2.foo3); + }); + + it('should not report twice the same key usage', () => { + const obj = { + cacheMe: 'please', + zap: { + cacheMeInner: 'please', + }, + }; + + const result = trackPackageUsage(obj, 'your-package'); + + mockUsage(result.cacheMe); + expect(logInfoMock).toHaveBeenCalledTimes(1); + mockUsage(result.cacheMe); + expect(logInfoMock).toHaveBeenCalledTimes(1); + + mockUsage(result.zap); + expect(logInfoMock).toHaveBeenCalledTimes(2); + mockUsage(result.zap); + expect(logInfoMock).toHaveBeenCalledTimes(2); + + mockUsage(result.zap.cacheMeInner); + expect(logInfoMock).toHaveBeenCalledTimes(3); + mockUsage(result.zap.cacheMeInner); + expect(logInfoMock).toHaveBeenCalledTimes(3); + + expect(result).toEqual(obj); + }); + }); + + it('should guess the plugin name from the stacktrace and urls', () => { + const mockErrorConstructor = jest.fn().mockImplementation(() => { + return { + stack: `Error + at eval (eval at get (http://localhost:3000/public/build/app.38735bee027ded74d65e.js:167859:11), :1:1) + at Object.get (http://localhost:3000/public/build/app.38735bee027ded74d65e.js:167859:11) + at eval (http://localhost:3000/public/plugins/alexanderzobnin-zabbix-app/panel-triggers/module.js?_cache=1695283550582:3:2582) + at eval (http://localhost:3000/public/plugins/alexanderzobnin-zabbix-app/panel-triggers/module.js?_cache=1695283550582:159:22081) + at Object.eval (http://localhost:3000/public/plugins/alexanderzobnin-zabbix-app/panel-triggers/module.js?_cache=1695283550582:159:22087) + at Object.execute (http://localhost:3000/public/build/app.38735bee027ded74d65e.js:529405:37) + at doExec (http://localhost:3000/public/build/app.38735bee027ded74d65e.js:529955:32) + at postOrderExec (http://localhost:3000/public/build/app.38735bee027ded74d65e.js:529951:12) + at http://localhost:3000/public/build/app.38735bee027ded74d65e.js:529899:14 + at async http://localhost:3000/public/build/app.38735bee027ded74d65e.js:166261:16`, + }; + }); + + const errorSpy = jest.spyOn(global, 'Error').mockImplementation(mockErrorConstructor); + + const obj = { + lord: 'me', + }; + + const result = trackPackageUsage(obj, 'your-package'); + mockUsage(result.lord); + + expect(logInfoMock).toHaveBeenCalledTimes(1); + expect(logInfoMock).toHaveBeenLastCalledWith(`Plugin using your-package.lord`, { + key: 'lord', + parent: 'your-package', + packageName: 'your-package', + guessedPluginName: 'alexanderzobnin-zabbix-app', + }); + + errorSpy.mockRestore(); + }); + + it('Should skip tracking if document.currentScript is not null', () => { + // Save the original value of the attribute + const originalCurrentScript = document.currentScript; + + // Define a new property on the document object with the mock currentScript + Object.defineProperty(document, 'currentScript', { + value: { + src: 'mocked-script.js', + }, + writable: true, + }); + + const obj = { + lor: 'me', + }; + + const result = trackPackageUsage(obj, 'your-package'); + + mockUsage(result.lor); + expect(logInfoMock).not.toHaveBeenCalled(); + + // Restore the original value of the currentScript attribute + Object.defineProperty(document, 'currentScript', { + value: originalCurrentScript, + writable: true, + }); + }); +}); diff --git a/public/app/features/plugins/loader/packageMetrics.ts b/public/app/features/plugins/loader/packageMetrics.ts new file mode 100644 index 00000000000..cbf3d495ee8 --- /dev/null +++ b/public/app/features/plugins/loader/packageMetrics.ts @@ -0,0 +1,101 @@ +import { logInfo } from '@grafana/runtime'; + +const cachedMetricProxies = new WeakMap(); +const trackedKeys: Record = {}; + +let pluginNameFromUrlRegex = /plugins\/([^/]*)\/.*?module\.js/i; + +/** + * This function attempts to determine the plugin name by + * analyzing the stack trace. It achieves this by generating + * an error object and accessing its stack property, + * which typically includes the script URL. + * + * Note that when inside an async function of any kind, the + * stack trace is somewhat lost and the plugin name cannot + * be determined most of the times. + * + * It assumes that the plugin ID is part of the URL, + * although this cannot be guaranteed. + * + * Please note that this function is specifically designed + * for plugins loaded with systemjs. + * + * It is important to treat the information provided by + * this function as a "best guess" and not rely on it + * for any business logic. + */ +function guessPluginNameFromStack(): string | undefined { + try { + const errorStack = new Error().stack; + if (errorStack?.includes('systemJSPrototype')) { + return undefined; + } + if (errorStack && errorStack.includes('module.js')) { + let match = errorStack.match(pluginNameFromUrlRegex); + if (match && match[1]) { + return match[1]; + } + } + } catch (e) { + return undefined; + } + return undefined; +} + +function createMetricsProxy(obj: T, parentName: string, packageName: string): T { + const handler: ProxyHandler = { + get(target, key) { + if ( + // plugins are evaluated by SystemJS and not by a browser