mirror of https://github.com/grafana/grafana
Plugins: Report plugin utilization of Grafana runtime dependencies (#75156)
* Plugins: Report plugin utilization of Grafana runtime dependencies * Change approach to determine pluginName too * Fix tests * Update tests * remove commented codefixes-oncall-link
parent
6600dd265b
commit
8e8bd2760b
|
@ -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), <anonymous>: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, |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,101 @@ |
||||
import { logInfo } from '@grafana/runtime'; |
||||
|
||||
const cachedMetricProxies = new WeakMap<object, unknown>(); |
||||
const trackedKeys: Record<string, boolean> = {}; |
||||
|
||||
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<T extends object>(obj: T, parentName: string, packageName: string): T { |
||||
const handler: ProxyHandler<T> = { |
||||
get(target, key) { |
||||
if ( |
||||
// plugins are evaluated by SystemJS and not by a browser <script> tag
|
||||
// if document.currentScript is null this is most likely called by a plugin
|
||||
document.currentScript === null && |
||||
typeof key !== 'symbol' && |
||||
// __useDefault is a implementation detail of our systemjs plugins
|
||||
// that we don't want to track
|
||||
key.toString() !== '__useDefault' |
||||
) { |
||||
const pluginName = guessPluginNameFromStack() ?? ''; |
||||
const accessPath = `${parentName}.${String(key)}`; |
||||
|
||||
// we want to report API usage per-plugin when possible
|
||||
const cacheKey = `${pluginName}:${accessPath}`; |
||||
|
||||
if (!trackedKeys[cacheKey]) { |
||||
trackedKeys[cacheKey] = true; |
||||
// note: intentionally not using shorthand property assignment
|
||||
// so any future variable name changes won't affect the metrics names
|
||||
logInfo(`Plugin using ${accessPath}`, { |
||||
key: String(key), |
||||
parent: parentName, |
||||
packageName: packageName, |
||||
guessedPluginName: pluginName, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// typescript will not trust the key is a key of target, but given this is a proxy handler
|
||||
// it is guarantee that `key` is a key of `target` so we can type assert to make types work
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const value = target[key as keyof T]; |
||||
|
||||
if (value !== null && typeof value === 'object') { |
||||
if (!cachedMetricProxies.has(value)) { |
||||
cachedMetricProxies.set(value, createMetricsProxy(value, `${parentName}.${String(key)}`, packageName)); |
||||
} |
||||
return cachedMetricProxies.get(value); |
||||
} |
||||
return value; |
||||
}, |
||||
}; |
||||
|
||||
if (typeof obj === 'object' && obj !== null) { |
||||
return new Proxy(obj, handler); |
||||
} |
||||
|
||||
return obj; |
||||
} |
||||
|
||||
export function trackPackageUsage<T extends object>(obj: T, packageName: string): T { |
||||
return createMetricsProxy(obj, packageName, packageName); |
||||
} |
Loading…
Reference in new issue