mirror of https://github.com/grafana/grafana
Frontend logging: Remove Sentry javascript agent support (#67493)
* remove Sentry * fix sourcemap resolvepull/67646/head
parent
9614dc2446
commit
15d4169813
@ -1,108 +0,0 @@ |
|||||||
package frontendlogging |
|
||||||
|
|
||||||
import ( |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"strings" |
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go" |
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log" |
|
||||||
) |
|
||||||
|
|
||||||
type CtxVector []interface{} |
|
||||||
|
|
||||||
var logger = log.New("frontendlogging") |
|
||||||
|
|
||||||
type FrontendSentryExceptionValue struct { |
|
||||||
Value string `json:"value,omitempty"` |
|
||||||
Type string `json:"type,omitempty"` |
|
||||||
Stacktrace sentry.Stacktrace `json:"stacktrace,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
type FrontendSentryException struct { |
|
||||||
Values []FrontendSentryExceptionValue `json:"values,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
type FrontendSentryEvent struct { |
|
||||||
*sentry.Event |
|
||||||
Exception *FrontendSentryException `json:"exception,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
func (value *FrontendSentryExceptionValue) FmtMessage() string { |
|
||||||
return fmt.Sprintf("%s: %s", value.Type, value.Value) |
|
||||||
} |
|
||||||
|
|
||||||
func fmtLine(frame sentry.Frame) string { |
|
||||||
module := "" |
|
||||||
if len(frame.Module) > 0 { |
|
||||||
module = frame.Module + "|" |
|
||||||
} |
|
||||||
return fmt.Sprintf("\n at %s (%s%s:%v:%v)", frame.Function, module, frame.Filename, frame.Lineno, frame.Colno) |
|
||||||
} |
|
||||||
|
|
||||||
func (value *FrontendSentryExceptionValue) FmtStacktrace(store *SourceMapStore) string { |
|
||||||
var stacktrace = value.FmtMessage() |
|
||||||
for _, frame := range value.Stacktrace.Frames { |
|
||||||
mappedFrame, err := store.resolveSourceLocation(frame) |
|
||||||
if err != nil { |
|
||||||
logger.Error("Error resolving stack trace frame source location", "err", err) |
|
||||||
stacktrace += fmtLine(frame) // even if reading source map fails for unexpected reason, still better to log compiled location than nothing at all
|
|
||||||
} else { |
|
||||||
if mappedFrame != nil { |
|
||||||
stacktrace += fmtLine(*mappedFrame) |
|
||||||
} else { |
|
||||||
stacktrace += fmtLine(frame) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return stacktrace |
|
||||||
} |
|
||||||
|
|
||||||
func (exception *FrontendSentryException) FmtStacktraces(store *SourceMapStore) string { |
|
||||||
stacktraces := make([]string, 0, len(exception.Values)) |
|
||||||
for _, value := range exception.Values { |
|
||||||
stacktraces = append(stacktraces, value.FmtStacktrace(store)) |
|
||||||
} |
|
||||||
return strings.Join(stacktraces, "\n\n") |
|
||||||
} |
|
||||||
|
|
||||||
func addEventContextToLogContext(rootPrefix string, logCtx *CtxVector, eventCtx map[string]interface{}) { |
|
||||||
for key, element := range eventCtx { |
|
||||||
prefix := fmt.Sprintf("%s_%s", rootPrefix, key) |
|
||||||
switch v := element.(type) { |
|
||||||
case map[string]interface{}: |
|
||||||
addEventContextToLogContext(prefix, logCtx, v) |
|
||||||
default: |
|
||||||
*logCtx = append(*logCtx, prefix, fmt.Sprintf("%v", v)) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (event *FrontendSentryEvent) ToLogContext(store *SourceMapStore) []interface{} { |
|
||||||
var ctx = CtxVector{"url", event.Request.URL, "user_agent", event.Request.Headers["User-Agent"], |
|
||||||
"event_id", event.EventID, "original_timestamp", event.Timestamp} |
|
||||||
|
|
||||||
if event.Exception != nil { |
|
||||||
ctx = append(ctx, "stacktrace", event.Exception.FmtStacktraces(store)) |
|
||||||
} |
|
||||||
addEventContextToLogContext("context", &ctx, event.Contexts) |
|
||||||
if len(event.User.Email) > 0 { |
|
||||||
ctx = append(ctx, "user_email", event.User.Email, "user_id", event.User.ID) |
|
||||||
} |
|
||||||
|
|
||||||
return ctx |
|
||||||
} |
|
||||||
|
|
||||||
func (event *FrontendSentryEvent) MarshalJSON() ([]byte, error) { |
|
||||||
eventJSON, err := json.Marshal(event.Event) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
exceptionJSON, err := json.Marshal(map[string]interface{}{"exception": event.Exception}) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
exceptionJSON[0] = ',' |
|
||||||
return append(eventJSON[:len(eventJSON)-1], exceptionJSON...), nil |
|
||||||
} |
|
@ -1,28 +0,0 @@ |
|||||||
package setting |
|
||||||
|
|
||||||
import "github.com/go-kit/log/level" |
|
||||||
|
|
||||||
type Sentry struct { |
|
||||||
Enabled bool `json:"enabled"` |
|
||||||
DSN string `json:"dsn"` |
|
||||||
CustomEndpoint string `json:"customEndpoint"` |
|
||||||
SampleRate float64 `json:"sampleRate"` |
|
||||||
EndpointRPS int `json:"-"` |
|
||||||
EndpointBurst int `json:"-"` |
|
||||||
} |
|
||||||
|
|
||||||
func (cfg *Cfg) readSentryConfig() { |
|
||||||
raw := cfg.Raw.Section("log.frontend") |
|
||||||
provider := raw.Key("provider").MustString("sentry") |
|
||||||
if provider == "sentry" || provider != "grafana" { |
|
||||||
_ = level.Warn(cfg.Logger).Log("msg", "\"sentry\" frontend logging provider is deprecated and will be removed in the next major version. Use \"grafana\" provider instead.") |
|
||||||
cfg.Sentry = Sentry{ |
|
||||||
Enabled: raw.Key("enabled").MustBool(true), |
|
||||||
DSN: raw.Key("sentry_dsn").String(), |
|
||||||
CustomEndpoint: raw.Key("custom_endpoint").MustString("/log"), |
|
||||||
SampleRate: raw.Key("sample_rate").MustFloat64(), |
|
||||||
EndpointRPS: raw.Key("log_endpoint_requests_per_second_limit").MustInt(3), |
|
||||||
EndpointBurst: raw.Key("log_endpoint_burst_limit").MustInt(15), |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -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; |
|
||||||
} |
|
Loading…
Reference in new issue