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 function
pull/76049/head^2
Victor Marin 2 years ago committed by GitHub
parent 150d4d68ad
commit ef82767dab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 7
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 18
      public/app/core/log_events.ts
  7. 49
      public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.test.tsx
  8. 50
      public/app/features/dashboard/dashgrid/PanelLoadTimeMonitor.tsx
  9. 26
      public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx
  10. 180
      public/app/features/dashboard/dashgrid/panelOptionsLogger.test.ts
  11. 122
      public/app/features/dashboard/dashgrid/panelOptionsLogger.ts

@ -143,6 +143,7 @@ Experimental features might be changed or removed without prior notice.
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
| `httpSLOLevels` | Adds SLO level to http request metrics |
| `alertingModifiedExport` | Enables using UI for provisioned rules modification and export |
| `panelMonitoring` | Enables panel monitoring through logs and measurements |
| `enableNativeHTTPHistogram` | Enables native HTTP Histograms |
| `transformationsVariableSupport` | Allows using variables in transformations |
| `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists |

@ -136,6 +136,7 @@ export interface FeatureToggles {
cloudWatchWildCardDimensionValues?: boolean;
externalServiceAccounts?: boolean;
alertingModifiedExport?: boolean;
panelMonitoring?: boolean;
enableNativeHTTPHistogram?: boolean;
transformationsVariableSupport?: boolean;
kubernetesPlaylists?: boolean;

@ -824,6 +824,13 @@ var (
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
{
Name: "panelMonitoring",
Description: "Enables panel monitoring through logs and measurements",
Stage: FeatureStageExperimental,
Owner: grafanaDatavizSquad,
FrontendOnly: true,
},
{
Name: "enableNativeHTTPHistogram",
Description: "Enables native HTTP Histograms",

@ -117,6 +117,7 @@ idForwarding,experimental,@grafana/grafana-authnz-team,true,false,false,false
cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false,false
externalServiceAccounts,experimental,@grafana/grafana-authnz-team,true,false,false,false
alertingModifiedExport,experimental,@grafana/alerting-squad,false,false,false,false
panelMonitoring,experimental,@grafana/dataviz-squad,false,false,false,true
enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false,false
transformationsVariableSupport,experimental,@grafana/grafana-bi-squad,false,false,false,true
kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
117 cloudWatchWildCardDimensionValues GA @grafana/aws-datasources false false false false
118 externalServiceAccounts experimental @grafana/grafana-authnz-team true false false false
119 alertingModifiedExport experimental @grafana/alerting-squad false false false false
120 panelMonitoring experimental @grafana/dataviz-squad false false false true
121 enableNativeHTTPHistogram experimental @grafana/hosted-grafana-team false false false false
122 transformationsVariableSupport experimental @grafana/grafana-bi-squad false false false true
123 kubernetesPlaylists experimental @grafana/grafana-app-platform-squad false false false true

@ -479,6 +479,10 @@ const (
// Enables using UI for provisioned rules modification and export
FlagAlertingModifiedExport = "alertingModifiedExport"
// FlagPanelMonitoring
// Enables panel monitoring through logs and measurements
FlagPanelMonitoring = "panelMonitoring"
// FlagEnableNativeHTTPHistogram
// Enables native HTTP Histograms
FlagEnableNativeHTTPHistogram = "enableNativeHTTPHistogram"

@ -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;
};

@ -30,6 +30,7 @@ import {
SeriesVisibilityChangeMode,
AdHocFilterItem,
} from '@grafana/ui';
import config from 'app/core/config';
import { profiler } from 'app/core/profiler';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@ -47,8 +48,10 @@ import { getPanelChromeProps } from '../utils/getPanelChromeProps';
import { loadSnapshotData } from '../utils/loadSnapshotData';
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor';
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
import { liveTimer } from './liveTimer';
import { PanelOptionsLogger } from './panelOptionsLogger';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@ -81,6 +84,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
private readonly timeSrv: TimeSrv = getTimeSrv();
private subs = new Subscription();
private eventFilter: EventFilterOptions = { onlyLocal: true };
private panelOptionsLogger: PanelOptionsLogger | undefined = undefined;
constructor(props: Props) {
super(props);
@ -112,6 +116,16 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
},
data: this.getInitialPanelDataState(),
};
if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) {
const panelInfo = {
panelId: String(props.panel.id),
panelType: props.panel.type,
panelTitle: props.panel.title,
};
this.panelOptionsLogger = new PanelOptionsLogger(props.panel.getOptions(), props.panel.fieldConfig, panelInfo);
}
}
// Due to a mutable panel model we get the sync settings via function that proactively reads from the model
@ -373,8 +387,17 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
this.props.panel.updateFieldConfig(config);
};
logPanelChangesOnError() {
this.panelOptionsLogger!.logChanges(this.props.panel.getOptions(), this.props.panel.fieldConfig);
}
onPanelError = (error: Error) => {
if (config.featureToggles.panelMonitoring && this.getPanelContextApp() === CoreApp.PanelEditor) {
this.logPanelChangesOnError();
}
const errorMessage = error.message || DEFAULT_PLUGIN_ERROR;
if (this.state.errorMessage !== errorMessage) {
this.setState({ errorMessage });
}
@ -512,6 +535,9 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
onChangeTimeRange={this.onChangeTimeRange}
eventBus={dashboard.events}
/>
{config.featureToggles.panelMonitoring && this.state.errorMessage === undefined && (
<PanelLoadTimeMonitor panelType={plugin.meta.id} panelId={panel.id} panelTitle={panel.title} />
)}
</PanelContextProvider>
</>
);

@ -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…
Cancel
Save