Frontend Logging: Integrate grafana javascript agent (#50801)

Add Grafana Javascript Agent integration to Grafana
This commit is contained in:
Timur Olzhabayev
2022-06-28 09:25:30 +02:00
committed by GitHub
parent 849d4a3c56
commit 7c886fb6f9
29 changed files with 1496 additions and 36 deletions

View File

@@ -53,6 +53,7 @@ import { PerformanceBackend } from './core/services/echo/backends/PerformanceBac
import { ApplicationInsightsBackend } from './core/services/echo/backends/analytics/ApplicationInsightsBackend';
import { GAEchoBackend } from './core/services/echo/backends/analytics/GABackend';
import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend';
import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend';
import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBackend';
import { initDevFeatures } from './dev';
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
@@ -203,6 +204,22 @@ function initEchoSrv() {
})
);
}
if (config.grafanaJavascriptAgent.enabled) {
registerEchoBackend(
new GrafanaJavascriptAgentBackend({
...config.grafanaJavascriptAgent,
app: {
version: config.buildInfo.version,
environment: config.buildInfo.env,
},
buildInfo: config.buildInfo,
user: {
id: String(config.bootData.user?.id),
email: config.bootData.user?.email,
},
})
);
}
if (config.googleAnalyticsId) {
registerEchoBackend(

View File

@@ -0,0 +1,13 @@
import { BaseTransport, TransportItem } from '@grafana/agent-core';
import { getEchoSrv, EchoEventType } from '@grafana/runtime';
export class EchoSrvTransport extends BaseTransport {
send(event: TransportItem) {
getEchoSrv().addEvent({
type: EchoEventType.GrafanaJavascriptAgent,
payload: event,
});
}
getIgnoreUrls() {
return [];
}
}

View File

@@ -0,0 +1,229 @@
import { BaseTransport } from '@grafana/agent-core';
import { FetchTransport, initializeAgent } from '@grafana/agent-web';
import { BuildInfo } from '@grafana/data';
import { GrafanaEdition } from '@grafana/data/src/types/config';
import { EchoEventType, EchoMeta } from '@grafana/runtime';
import { GrafanaJavascriptAgentBackend, GrafanaJavascriptAgentBackendOptions } from './GrafanaJavascriptAgentBackend';
import { GrafanaJavascriptAgentEchoEvent } from './types';
jest.mock('@grafana/agent-web', () => {
const originalModule = jest.requireActual('@grafana/agent-web');
return {
__esModule: true,
...originalModule,
initializeAgent: jest.fn(),
};
});
describe('GrafanaJavascriptAgentEchoBackend', () => {
beforeEach(() => {
jest.resetAllMocks();
window.fetch = jest.fn();
jest.resetModules();
jest.clearAllMocks();
});
const buildInfo: BuildInfo = {
version: '1.0',
commit: 'abcd123',
env: 'production',
edition: GrafanaEdition.OpenSource,
latestVersion: 'ba',
hasUpdate: false,
hideVersion: false,
};
const options: GrafanaJavascriptAgentBackendOptions = {
buildInfo,
app: {
version: '1.0',
},
errorInstrumentalizationEnabled: true,
consoleInstrumentalizationEnabled: true,
webVitalsInstrumentalizationEnabled: true,
customEndpoint: '/log-grafana-javascript-agent',
user: {
email: 'darth.vader@sith.glx',
id: '504',
orgId: 1,
},
};
it('will set up FetchTransport if customEndpoint is provided', async () => {
// arrange
const originalModule = jest.requireActual('@grafana/agent-web');
jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent);
//act
const backend = new GrafanaJavascriptAgentBackend(options);
//assert
expect(backend.transports.length).toEqual(1);
expect(backend.transports[0]).toBeInstanceOf(FetchTransport);
});
it('will initialize GrafanaJavascriptAgent and set user', async () => {
// arrange
const mockedSetUser = jest.fn();
const mockedAgent = () => {
return {
api: {
setUser: mockedSetUser,
pushLog: jest.fn(),
callOriginalConsoleMethod: jest.fn(),
pushError: jest.fn(),
pushMeasurement: jest.fn(),
pushTraces: jest.fn(),
initOTEL: jest.fn(),
getOTEL: jest.fn(),
getTraceContext: jest.fn(),
},
config: {
globalObjectKey: '',
instrumentations: [],
preventGlobalExposure: false,
transports: [],
metas: [],
parseStacktrace: jest.fn(),
app: jest.fn(),
},
metas: {
add: jest.fn(),
remove: jest.fn(),
value: {},
},
transports: {
add: jest.fn(),
execute: jest.fn(),
transports: [],
},
};
};
jest.mocked(initializeAgent).mockImplementation(mockedAgent);
//act
new GrafanaJavascriptAgentBackend(options);
//assert
expect(initializeAgent).toHaveBeenCalledTimes(1);
expect(mockedSetUser).toHaveBeenCalledTimes(1);
expect(mockedSetUser).toHaveBeenCalledWith({
id: '504',
email: 'darth.vader@sith.glx',
attributes: {
orgId: '1',
},
});
});
it('will forward events to transports', async () => {
//arrange
const mockedSetUser = jest.fn();
const mockedAgent = () => {
return {
api: {
setUser: mockedSetUser,
pushLog: jest.fn(),
callOriginalConsoleMethod: jest.fn(),
pushError: jest.fn(),
pushMeasurement: jest.fn(),
pushTraces: jest.fn(),
initOTEL: jest.fn(),
getOTEL: jest.fn(),
getTraceContext: jest.fn(),
},
config: {
globalObjectKey: '',
instrumentations: [],
preventGlobalExposure: false,
transports: [],
metas: [],
parseStacktrace: jest.fn(),
app: jest.fn(),
},
metas: {
add: jest.fn(),
remove: jest.fn(),
value: {},
},
transports: {
add: jest.fn(),
execute: jest.fn(),
transports: [],
},
};
};
jest.mocked(initializeAgent).mockImplementation(mockedAgent);
const backend = new GrafanaJavascriptAgentBackend({
...options,
preventGlobalExposure: true,
});
backend.transports = [
/* eslint-disable */
{ send: jest.fn() } as unknown as BaseTransport,
{ send: jest.fn() } as unknown as BaseTransport,
];
const event: GrafanaJavascriptAgentEchoEvent = {
type: EchoEventType.GrafanaJavascriptAgent,
payload: { foo: 'bar' } as unknown as GrafanaJavascriptAgentEchoEvent,
meta: {} as unknown as EchoMeta,
};
/* eslint-enable */
backend.addEvent(event);
backend.transports.forEach((transport) => {
expect(transport.send).toHaveBeenCalledTimes(1);
expect(transport.send).toHaveBeenCalledWith(event.payload);
});
});
//@FIXME - make integration test work
// it('integration test with EchoSrv and GrafanaJavascriptAgent', async () => {
// // sets up the whole thing between window.onerror and backend endpoint call, checks that error is reported
// // use actual GrafanaJavascriptAgent & mock window.fetch
// // arrange
// const originalModule = jest.requireActual('@grafana/agent-web');
// jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent);
// const fetchSpy = (window.fetch = jest.fn());
// fetchSpy.mockResolvedValue({ status: 200 } as Response);
// const echo = new Echo({ debug: true });
// // act
// setEchoSrv(echo);
// const grafanaJavascriptAgentBackend = new GrafanaJavascriptAgentBackend({
// ...options,
// preventGlobalExposure: true,
// consoleInstrumentalizationEnabled: false,
// webVitalsInstrumentalizationEnabled: false,
// });
// echo.addBackend(grafanaJavascriptAgentBackend);
// // lets add another echo backend for grafana javascript agent events for good measure
// const myCustomErrorBackend: EchoBackend = {
// supportedEvents: [EchoEventType.GrafanaJavascriptAgent],
// flush: () => {},
// options: {},
// addEvent: jest.fn(),
// };
// echo.addBackend(myCustomErrorBackend);
// // fire off an error using global error handler, Grafana Javascript Agent should pick it up
// const error = new Error('test error');
// window.onerror!(error.message, undefined, undefined, undefined, error);
// // assert
// // check that error was reported to backend
// await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
// const [url, reqInit]: [string, RequestInit] = fetchSpy.mock.calls[0];
// expect(url).toEqual('/log-grafana-javascript-agent');
// // expect((JSON.parse(reqInit.body as string) as EchoEvent).exception!.values![0].value).toEqual('test error');
// console.log(JSON.parse(reqInit.body as string));
// // check that our custom backend got it too
// expect(myCustomErrorBackend.addEvent).toHaveBeenCalledTimes(1);
// });
});

View File

@@ -0,0 +1,84 @@
import { BaseTransport } from '@grafana/agent-core';
import {
initializeAgent,
BrowserConfig,
ErrorsInstrumentation,
ConsoleInstrumentation,
WebVitalsInstrumentation,
FetchTransport,
} from '@grafana/agent-web';
import { BuildInfo } from '@grafana/data';
import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime';
import { EchoSrvTransport } from './EchoSrvTransport';
import { GrafanaJavascriptAgentEchoEvent, User } from './types';
export interface GrafanaJavascriptAgentBackendOptions extends BrowserConfig {
buildInfo: BuildInfo;
customEndpoint: string;
user: User;
errorInstrumentalizationEnabled: boolean;
consoleInstrumentalizationEnabled: boolean;
webVitalsInstrumentalizationEnabled: boolean;
}
export class GrafanaJavascriptAgentBackend
implements EchoBackend<GrafanaJavascriptAgentEchoEvent, GrafanaJavascriptAgentBackendOptions>
{
supportedEvents = [EchoEventType.GrafanaJavascriptAgent];
private agentInstance;
transports: BaseTransport[];
constructor(public options: GrafanaJavascriptAgentBackendOptions) {
// configure instrumentalizations
const instrumentations = [];
this.transports = [];
if (options.customEndpoint) {
this.transports.push(new FetchTransport({ url: options.customEndpoint, apiKey: options.apiKey }));
}
if (options.errorInstrumentalizationEnabled) {
instrumentations.push(new ErrorsInstrumentation());
}
if (options.consoleInstrumentalizationEnabled) {
instrumentations.push(new ConsoleInstrumentation());
}
if (options.webVitalsInstrumentalizationEnabled) {
instrumentations.push(new WebVitalsInstrumentation());
}
// initialize GrafanaJavascriptAgent so it can set up it's hooks and start collecting errors
const grafanaJavaScriptAgentOptions: BrowserConfig = {
globalObjectKey: options.globalObjectKey || 'grafanaAgent',
preventGlobalExposure: options.preventGlobalExposure || false,
app: {
version: options.buildInfo.version,
environment: options.buildInfo.env,
},
instrumentations,
transports: [new EchoSrvTransport()],
};
this.agentInstance = initializeAgent(grafanaJavaScriptAgentOptions);
if (options.user) {
this.agentInstance.api.setUser({
email: options.user.email,
id: options.user.id,
attributes: {
orgId: String(options.user.orgId) || '',
},
});
}
}
addEvent = (e: EchoEvent) => {
this.transports.forEach((t) => t.send(e.payload));
};
// backend will log events to stdout, and at least in case of hosted grafana they will be
// ingested into Loki. Due to Loki limitations logs cannot be backdated,
// so not using buffering for this backend to make sure that events are logged as close
// to their context as possible
flush = () => {};
}

View File

@@ -0,0 +1,13 @@
import { CurrentUserDTO } from '@grafana/data';
import { EchoEvent, EchoEventType } from '@grafana/runtime';
export interface BaseTransport {
sendEvent(event: EchoEvent): PromiseLike<Response>;
}
export type GrafanaJavascriptAgentEchoEvent = EchoEvent<EchoEventType.GrafanaJavascriptAgent>;
export interface User extends Pick<CurrentUserDTO, 'email'> {
id: string;
orgId?: number;
}