mirror of https://github.com/grafana/grafana
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 functionpull/76049/head^2
parent
150d4d68ad
commit
ef82767dab
|
@ -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'; |
@ -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(<PanelLoadTimeMonitor {...props} />); |
||||
|
||||
jest.runAllTimers(); |
||||
|
||||
expect(mockPushMeasurement).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
@ -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; |
||||
}; |
@ -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]], |
||||
]); |
||||
}); |
||||
}); |
@ -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<unknown>, oldFieldConfig: FieldConfigSource<unknown>) => { |
||||
// 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); |
||||
} |
||||
} |
||||
}; |
||||
} |
Loading…
Reference in new issue