Frontend logging: Remove Sentry javascript agent support (#67493)

* remove Sentry

* fix sourcemap resolve
This commit is contained in:
Domas
2023-05-02 12:10:56 +03:00
committed by GitHub
parent 9614dc2446
commit 15d4169813
38 changed files with 47 additions and 1288 deletions
-10
View File
@@ -66,7 +66,6 @@ import { GA4EchoBackend } from './core/services/echo/backends/analytics/GA4Backe
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 { KeybindingSrv } from './core/services/keybindingSrv';
import { initDevFeatures } from './dev';
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
@@ -259,15 +258,6 @@ function initEchoSrv() {
registerEchoBackend(new PerformanceBackend({}));
}
if (config.sentry.enabled) {
registerEchoBackend(
new SentryEchoBackend({
...config.sentry,
user: config.bootData.user,
buildInfo: config.buildInfo,
})
);
}
if (config.grafanaJavascriptAgent.enabled) {
registerEchoBackend(
new GrafanaJavascriptAgentBackend({
+3 -5
View File
@@ -1,5 +1,4 @@
import { captureException } from '@sentry/browser';
import { faro } from '@grafana/faro-web-sdk';
import { getEchoSrv, EchoEventType } from '@grafana/runtime';
import { PerformanceEvent } from './backends/PerformanceBackend';
@@ -14,6 +13,5 @@ export const reportPerformance = (metric: string, value: number) => {
});
};
// Sentry will process the error, adding its own metadata, applying any sampling rules,
// then push it to EchoSrv as SentryEvent
export const reportError = (error: Error) => captureException(error);
// Farp will process the error, then push it to EchoSrv as GrafanaJavascriptAgent event
export const reportError = (error: Error) => faro?.api?.pushError(error);
@@ -1,143 +0,0 @@
import { init as initSentry, setUser as sentrySetUser, Event as SentryEvent } from '@sentry/browser';
import { FetchTransport } from '@sentry/browser/dist/transports';
import { waitFor } from '@testing-library/react';
import { BuildInfo } from '@grafana/data';
import { GrafanaEdition } from '@grafana/data/src/types/config';
import { EchoBackend, EchoEventType, EchoMeta, setEchoSrv } from '@grafana/runtime';
import { Echo } from '../../Echo';
import { SentryEchoBackend, SentryEchoBackendOptions } from './SentryBackend';
import { CustomEndpointTransport } from './transports/CustomEndpointTransport';
import { EchoSrvTransport } from './transports/EchoSrvTransport';
import { SentryEchoEvent } from './types';
jest.mock('@sentry/browser');
describe('SentryEchoBackend', () => {
beforeEach(() => {
jest.resetAllMocks();
window.fetch = jest.fn();
});
const buildInfo: BuildInfo = {
version: '1.0',
commit: 'abcd123',
env: 'production',
edition: GrafanaEdition.OpenSource,
latestVersion: 'ba',
hasUpdate: false,
hideVersion: false,
};
const options: SentryEchoBackendOptions = {
enabled: true,
buildInfo,
dsn: 'https://examplePublicKey@o0.ingest.testsentry.io/0',
sampleRate: 1,
customEndpoint: '',
user: {
email: 'darth.vader@sith.glx',
id: 504,
orgId: 1,
},
};
it('will set up sentry`s FetchTransport if DSN is provided', async () => {
const backend = new SentryEchoBackend(options);
expect(backend.transports.length).toEqual(1);
expect(backend.transports[0]).toBeInstanceOf(FetchTransport);
expect((backend.transports[0] as FetchTransport).options.dsn).toEqual(options.dsn);
});
it('will set up custom endpoint transport if custom endpoint is provided', async () => {
const backend = new SentryEchoBackend({
...options,
dsn: '',
customEndpoint: '/log',
});
expect(backend.transports.length).toEqual(1);
expect(backend.transports[0]).toBeInstanceOf(CustomEndpointTransport);
expect((backend.transports[0] as CustomEndpointTransport).options.endpoint).toEqual('/log');
});
it('will initialize sentry and set user', async () => {
new SentryEchoBackend(options);
expect(initSentry).toHaveBeenCalledTimes(1);
expect(initSentry).toHaveBeenCalledWith({
release: buildInfo.version,
environment: buildInfo.env,
dsn: options.dsn,
sampleRate: options.sampleRate,
transport: EchoSrvTransport,
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed',
'Non-Error exception captured with keys',
],
});
expect(sentrySetUser).toHaveBeenCalledWith({
email: options.user?.email,
id: String(options.user?.id),
});
});
it('will forward events to transports', async () => {
const backend = new SentryEchoBackend(options);
backend.transports = [{ sendEvent: jest.fn() }, { sendEvent: jest.fn() }];
const event: SentryEchoEvent = {
type: EchoEventType.Sentry,
payload: { foo: 'bar' } as unknown as SentryEvent,
meta: {} as unknown as EchoMeta,
};
backend.addEvent(event);
backend.transports.forEach((transport) => {
expect(transport.sendEvent).toHaveBeenCalledTimes(1);
expect(transport.sendEvent).toHaveBeenCalledWith(event.payload);
});
});
it('integration test with EchoSrv, Sentry and CustomFetchTransport', async () => {
// sets up the whole thing between window.onerror and backend endpoint call, checks that error is reported
// use actual sentry & mock window.fetch
const sentry = jest.requireActual('@sentry/browser');
(initSentry as jest.Mock).mockImplementation(sentry.init);
(sentrySetUser as jest.Mock).mockImplementation(sentry.setUser);
const fetchSpy = (window.fetch = jest.fn());
fetchSpy.mockResolvedValue({ status: 200 } as Response);
// set up echo srv & sentry backend
const echo = new Echo({ debug: true });
setEchoSrv(echo);
const sentryBackend = new SentryEchoBackend({
...options,
dsn: '',
customEndpoint: '/log',
});
echo.addBackend(sentryBackend);
// lets add another echo backend for sentry events for good measure
const myCustomErrorBackend: EchoBackend = {
supportedEvents: [EchoEventType.Sentry],
flush: () => {},
options: {},
addEvent: jest.fn(),
};
echo.addBackend(myCustomErrorBackend);
// fire off an error using global error handler, Sentry should pick it up
const error = new Error('test error');
window.onerror!(error.message, undefined, undefined, undefined, error);
// 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');
expect((JSON.parse(reqInit.body as string) as SentryEvent).exception!.values![0].value).toEqual('test error');
// check that our custom backend got it too
expect(myCustomErrorBackend.addEvent).toHaveBeenCalledTimes(1);
});
});
@@ -1,66 +0,0 @@
import { BrowserOptions, init as initSentry, setUser as sentrySetUser } from '@sentry/browser';
import { FetchTransport } from '@sentry/browser/dist/transports';
import { BuildInfo } from '@grafana/data';
import { SentryConfig } from '@grafana/data/src/types/config';
import { EchoBackend, EchoEventType } from '@grafana/runtime';
import { CustomEndpointTransport } from './transports/CustomEndpointTransport';
import { EchoSrvTransport } from './transports/EchoSrvTransport';
import { SentryEchoEvent, User, BaseTransport } from './types';
export interface SentryEchoBackendOptions extends SentryConfig {
user?: User;
buildInfo: BuildInfo;
}
export class SentryEchoBackend implements EchoBackend<SentryEchoEvent, SentryEchoBackendOptions> {
supportedEvents = [EchoEventType.Sentry];
transports: BaseTransport[];
constructor(public options: SentryEchoBackendOptions) {
// set up transports to post events to grafana backend and/or Sentry
this.transports = [];
if (options.dsn) {
this.transports.push(new FetchTransport({ dsn: options.dsn }, fetch));
}
if (options.customEndpoint) {
this.transports.push(new CustomEndpointTransport({ endpoint: options.customEndpoint }));
}
// initialize Sentry so it can set up its hooks and start collecting errors
const sentryOptions: BrowserOptions = {
release: options.buildInfo.version,
environment: options.buildInfo.env,
// seems Sentry won't attempt to send events to transport unless a valid DSN is defined :shrug:
dsn: options.dsn || 'https://examplePublicKey@o0.ingest.sentry.io/0',
sampleRate: options.sampleRate,
transport: EchoSrvTransport, // will dump errors to EchoSrv
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed',
'Non-Error exception captured with keys',
],
};
if (options.user) {
sentrySetUser({
email: options.user.email,
id: String(options.user.id),
});
}
initSentry(sentryOptions);
}
addEvent = (e: SentryEchoEvent) => {
this.transports.forEach((t) => t.sendEvent(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 = () => {};
}
@@ -1,133 +0,0 @@
import { Event, Severity } from '@sentry/browser';
import { CustomEndpointTransport } from './CustomEndpointTransport';
describe('CustomEndpointTransport', () => {
const fetchSpy = (window.fetch = jest.fn());
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
jest.resetAllMocks();
// The code logs a warning to console
// Let's stub this out so we don't pollute the test output
consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
const now = new Date();
const event: Event = {
level: Severity.Error,
breadcrumbs: [],
exception: {
values: [
{
type: 'SomeError',
value: 'foo',
},
],
},
timestamp: now.getTime() / 1000,
};
it('will send received event to backend using window.fetch', async () => {
fetchSpy.mockResolvedValue({ status: 200 });
const transport = new CustomEndpointTransport({ endpoint: '/log' });
await transport.sendEvent(event);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, reqInit]: [string, RequestInit] = fetchSpy.mock.calls[0];
expect(url).toEqual('/log');
expect(reqInit.method).toEqual('POST');
expect(reqInit.headers).toEqual({
'Content-Type': 'application/json',
});
expect(JSON.parse(reqInit.body!.toString())).toEqual({
...event,
timestamp: now.toISOString(),
});
});
it('will back off if backend returns Retry-After', async () => {
const rateLimiterResponse = {
status: 429,
ok: false,
headers: new Headers({
'Retry-After': '1', // 1 second
}),
} as Response;
fetchSpy.mockResolvedValueOnce(rateLimiterResponse).mockResolvedValueOnce({ status: 200 });
const transport = new CustomEndpointTransport({ endpoint: '/log' });
// first call - backend is called, rejected because of 429
await expect(transport.sendEvent(event)).rejects.toEqual(rateLimiterResponse);
expect(fetchSpy).toHaveBeenCalledTimes(1);
// second immediate call - shot circuited because retry-after time has not expired, backend not called
await expect(transport.sendEvent(event)).resolves.toHaveProperty('status', 'skipped');
expect(fetchSpy).toHaveBeenCalledTimes(1);
// wait out the retry-after and call again - great success
await new Promise((resolve) => setTimeout(() => resolve(null), 1001));
await expect(transport.sendEvent(event)).resolves.toBeTruthy();
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('will back off if backend returns Retry-After', async () => {
const rateLimiterResponse = {
status: 429,
ok: false,
headers: new Headers({
'Retry-After': '1', // 1 second
}),
} as Response;
fetchSpy.mockResolvedValueOnce(rateLimiterResponse).mockResolvedValueOnce({ status: 200 });
const transport = new CustomEndpointTransport({ endpoint: '/log' });
// first call - backend is called, rejected because of 429
await expect(transport.sendEvent(event)).rejects.toHaveProperty('status', 429);
expect(fetchSpy).toHaveBeenCalledTimes(1);
// second immediate call - shot circuited because retry-after time has not expired, backend not called
await expect(transport.sendEvent(event)).resolves.toHaveProperty('status', 'skipped');
expect(fetchSpy).toHaveBeenCalledTimes(1);
// wait out the retry-after and call again - great success
await new Promise((resolve) => setTimeout(() => resolve(null), 1001));
await expect(transport.sendEvent(event)).resolves.toBeTruthy();
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('will drop events and log a warning to console if max concurrency is reached', async () => {
const calls: Array<(value: unknown) => void> = [];
fetchSpy.mockImplementation(
() =>
new Promise((resolve) => {
calls.push(resolve);
})
);
const transport = new CustomEndpointTransport({ endpoint: '/log', maxConcurrentRequests: 2 });
// first two requests are accepted
transport.sendEvent(event);
const event2 = transport.sendEvent(event);
expect(calls).toHaveLength(2);
// third is skipped because too many requests in flight
await expect(transport.sendEvent(event)).resolves.toHaveProperty('status', 'skipped');
expect(calls).toHaveLength(2);
// after resolving in flight requests, next request is accepted as well
calls.forEach((call) => {
call({ status: 200 });
});
await event2;
const event3 = transport.sendEvent(event);
expect(calls).toHaveLength(3);
calls[2]({ status: 200 });
await event3;
});
});
@@ -1,151 +0,0 @@
import { Event, Severity } from '@sentry/browser';
import { Response } from '@sentry/types';
import {
logger,
makePromiseBuffer,
parseRetryAfterHeader,
PromiseBuffer,
supportsReferrerPolicy,
SyncPromise,
} from '@sentry/utils';
import { BaseTransport } from '../types';
export interface CustomEndpointTransportOptions {
endpoint: string;
fetchParameters?: Partial<RequestInit>;
maxConcurrentRequests?: number;
}
const DEFAULT_MAX_CONCURRENT_REQUESTS = 3;
const DEFAULT_RATE_LIMIT_TIMEOUT_MS = 5000;
/**
* This is a copy of sentry's FetchTransport, edited to be able to push to any custom url
* instead of using Sentry-specific endpoint logic.
* Also transforms some of the payload values to be parseable by go.
* Sends events sequentially and implements back-off in case of rate limiting.
*/
export class CustomEndpointTransport implements BaseTransport {
/** Locks transport after receiving 429 response */
private _disabledUntil: Date = new Date(Date.now());
private readonly _buffer: PromiseBuffer<Response>;
constructor(public options: CustomEndpointTransportOptions) {
this._buffer = makePromiseBuffer(options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS);
}
sendEvent(event: Event): PromiseLike<Response> {
if (new Date(Date.now()) < this._disabledUntil) {
const reason = `Dropping frontend event due to too many requests.`;
console.warn(reason);
return Promise.resolve({
event,
reason,
status: 'skipped',
});
}
const sentryReq = {
// convert all timestamps to iso string, so it's parseable by backend
body: JSON.stringify({
...event,
level: event.level ?? (event.exception ? Severity.Error : Severity.Info),
exception: event.exception
? {
values: event.exception.values?.map((value) => ({
...value,
// according to both typescript and go types, value is supposed to be string.
// but in some odd cases at runtime it turns out to be an empty object {}
// let's fix it here
value: fmtSentryErrorValue(value.value),
})),
}
: event.exception,
breadcrumbs: event.breadcrumbs?.map((breadcrumb) => ({
...breadcrumb,
timestamp: makeTimestamp(breadcrumb.timestamp),
})),
timestamp: makeTimestamp(event.timestamp),
}),
url: this.options.endpoint,
};
const options: RequestInit = {
body: sentryReq.body,
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
// Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default
// https://caniuse.com/#feat=referrer-policy
// It doesn't. And it throw exception instead of ignoring this parameter...
// REF: https://github.com/getsentry/raven-js/issues/1233
referrerPolicy: supportsReferrerPolicy() ? 'origin' : '',
};
if (this.options.fetchParameters !== undefined) {
Object.assign(options, this.options.fetchParameters);
}
return this._buffer
.add(
() =>
new SyncPromise<Response>((resolve, reject) => {
window
.fetch(sentryReq.url, options)
.then((response) => {
if (response.status === 200) {
resolve({ status: 'success' });
return;
}
if (response.status === 429) {
const now = Date.now();
const retryAfterHeader = response.headers.get('Retry-After');
if (retryAfterHeader) {
this._disabledUntil = new Date(now + parseRetryAfterHeader(retryAfterHeader, now));
} else {
this._disabledUntil = new Date(now + DEFAULT_RATE_LIMIT_TIMEOUT_MS);
}
logger.warn(`Too many requests, backing off till: ${this._disabledUntil}`);
}
reject(response);
})
.catch(reject);
})
)
.then(undefined, (reason) => {
if (reason.message === 'Not adding Promise due to buffer limit reached.') {
const msg = `Dropping frontend log event due to too many requests in flight.`;
console.warn(msg);
return {
event,
reason: msg,
status: 'skipped',
};
}
throw reason;
});
}
}
function makeTimestamp(time: number | undefined): string {
if (time) {
return new Date(time * 1000).toISOString();
}
return new Date().toISOString();
}
function fmtSentryErrorValue(value: unknown): string | undefined {
if (typeof value === 'string' || value === undefined) {
return value;
} else if (value && typeof value === 'object' && Object.keys(value).length === 0) {
return '';
}
return String(value);
}
@@ -1,26 +0,0 @@
import { Event } from '@sentry/browser';
import { BaseTransport } from '@sentry/browser/dist/transports';
import { EventStatus, Request, Session, Response } from '@sentry/types';
import { getEchoSrv, EchoEventType } from '@grafana/runtime';
export class EchoSrvTransport extends BaseTransport {
sendEvent(event: Event): Promise<{ status: EventStatus; event: Event }> {
getEchoSrv().addEvent({
type: EchoEventType.Sentry,
payload: event,
});
return Promise.resolve({
status: 'success',
event,
});
}
// not recording sessions for now
sendSession(session: Session): PromiseLike<Response> {
return Promise.resolve({ status: 'skipped' });
}
// required by BaseTransport definition but not used by this implementation
_sendRequest(sentryRequest: Request, originalPayload: Event | Session): PromiseLike<Response> {
throw new Error('should not happen');
}
}
@@ -1,16 +0,0 @@
import { Event as SentryEvent } from '@sentry/browser';
import { Response } from '@sentry/types';
import { EchoEvent, EchoEventType } from '@grafana/runtime';
export interface BaseTransport {
sendEvent(event: SentryEvent): PromiseLike<Response>;
}
export type SentryEchoEvent = EchoEvent<EchoEventType.Sentry, SentryEvent>;
export interface User {
email: string;
id: number;
orgId: number;
}
@@ -17,7 +17,7 @@ export const LogMessages = {
unknownMessageFromError: 'unknown messageFromError',
};
// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent and Sentry correctly
// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly
export function logInfo(message: string, context: Record<string, string | number> = {}) {
if (config.grafanaJavascriptAgent.enabled) {
faro.api.pushLog([message], {