mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Frontend logging: Remove Sentry javascript agent support (#67493)
* remove Sentry * fix sourcemap resolve
This commit is contained in:
parent
9614dc2446
commit
15d4169813
6
.github/renovate.json5
vendored
6
.github/renovate.json5
vendored
@ -16,12 +16,6 @@
|
||||
"systemjs",
|
||||
"ts-loader", // we should remove ts-loader and use babel-loader instead
|
||||
"ora", // we should bump this once we move to esm modules
|
||||
|
||||
// Sentry deprecated in favor of Grafana Faro for frontend logging.
|
||||
// Major effort required to upgrade to latest Sentry, not worthwhile
|
||||
"@sentry/browser",
|
||||
"@sentry/types",
|
||||
"@sentry/utils",
|
||||
],
|
||||
"includePaths": ["package.json", "packages/**"],
|
||||
"ignorePaths": ["packages/grafana-toolkit/package.json", "emails/**", "plugins-bundled/**", "**/mocks/**"],
|
||||
|
@ -872,21 +872,12 @@ facility =
|
||||
tag =
|
||||
|
||||
[log.frontend]
|
||||
# Should Sentry javascript agent be initialized
|
||||
# Should Faro javascript agent be initialized
|
||||
enabled = false
|
||||
|
||||
# Defines which provider to use sentry or grafana
|
||||
provider = sentry
|
||||
|
||||
# Sentry DSN if you want to send events to Sentry.
|
||||
sentry_dsn =
|
||||
|
||||
# Custom HTTP endpoint to send events to. Default will log the events to stdout.
|
||||
custom_endpoint =
|
||||
|
||||
# Rate of events to be reported to Sentry between 0 (none) and 1 (all), float
|
||||
sample_rate = 1.0
|
||||
|
||||
# Requests per second limit enforced per an extended period, for Grafana backend log ingestion endpoint (/log).
|
||||
log_endpoint_requests_per_second_limit = 3
|
||||
|
||||
|
@ -844,20 +844,11 @@
|
||||
;tag =
|
||||
|
||||
[log.frontend]
|
||||
# Should Sentry javascript agent be initialized
|
||||
# Should Faro javascript agent be initialized
|
||||
;enabled = false
|
||||
|
||||
# Defines which provider to use, default is Sentry
|
||||
;provider = sentry
|
||||
|
||||
# Sentry DSN if you want to send events to Sentry.
|
||||
;sentry_dsn =
|
||||
|
||||
# Custom HTTP endpoint to send events captured by the Sentry agent to. Default will log the events to stdout.
|
||||
;custom_endpoint = /log
|
||||
|
||||
# Rate of events to be reported between 0 (none) and 1 (all), float
|
||||
;sample_rate = 1.0
|
||||
# Custom HTTP endpoint to send events to. Default will log the events to stdout.
|
||||
;custom_endpoint = /log-grafana-javascript-agent
|
||||
|
||||
# Requests per second limit enforced an extended period, for Grafana backend log ingestion endpoint (/log).
|
||||
;log_endpoint_requests_per_second_limit = 3
|
||||
|
@ -1284,31 +1284,19 @@ Syslog tag. By default, the process's `argv[0]` is used.
|
||||
|
||||
### enabled
|
||||
|
||||
Sentry javascript agent is initialized. Default is `false`.
|
||||
|
||||
### provider
|
||||
|
||||
Defines which provider to use `sentry` or `grafana`. Default is `sentry`
|
||||
|
||||
### sentry_dsn
|
||||
|
||||
Sentry DSN if you want to send events to Sentry
|
||||
Faro javascript agent is initialized. Default is `false`.
|
||||
|
||||
### custom_endpoint
|
||||
|
||||
Custom HTTP endpoint to send events captured by the Sentry agent to. Default, `/log`, will log the events to stdout.
|
||||
|
||||
### sample_rate
|
||||
|
||||
Rate of events to be reported between `0` (none) and `1` (all, default), float.
|
||||
Custom HTTP endpoint to send events captured by the Faro agent to. Default, `/log-grafana-javascript-agent`, will log the events to stdout.
|
||||
|
||||
### log_endpoint_requests_per_second_limit
|
||||
|
||||
Requests per second limit enforced per an extended period, for Grafana backend log ingestion endpoint, `/log`. Default is `3`.
|
||||
Requests per second limit enforced per an extended period, for Grafana backend log ingestion endpoint, `/log-grafana-javascript-agent`. Default is `3`.
|
||||
|
||||
### log_endpoint_burst_limit
|
||||
|
||||
Maximum requests accepted per short interval of time for Grafana backend log ingestion endpoint, `/log`. Default is `15`.
|
||||
Maximum requests accepted per short interval of time for Grafana backend log ingestion endpoint, `/log-grafana-javascript-agent`. Default is `15`.
|
||||
|
||||
### instrumentations_errors_enabled
|
||||
|
||||
|
1
go.mod
1
go.mod
@ -40,7 +40,6 @@ require (
|
||||
github.com/crewjam/saml v0.4.12
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/gchaincl/sqlhooks v1.3.0
|
||||
github.com/getsentry/sentry-go v0.13.0
|
||||
github.com/go-git/go-git/v5 v5.4.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.4
|
||||
github.com/go-openapi/strfmt v0.21.7
|
||||
|
4
go.sum
4
go.sum
@ -801,8 +801,6 @@ github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebK
|
||||
github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
|
||||
github.com/getkin/kin-openapi v0.115.0 h1:c8WHRLVY3G8m9jQTy0/DnIuljgRwTCB5twZytQS4JyU=
|
||||
github.com/getkin/kin-openapi v0.115.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc=
|
||||
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew=
|
||||
@ -822,7 +820,6 @@ github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
|
||||
github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
|
||||
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
|
||||
@ -1962,7 +1959,6 @@ github.com/pierrec/lz4 v2.6.0+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
|
||||
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
|
@ -287,9 +287,6 @@
|
||||
"@react-stately/tree": "3.3.1",
|
||||
"@reduxjs/toolkit": "1.9.3",
|
||||
"@remix-run/router": "^1.5.0",
|
||||
"@sentry/browser": "6.19.7",
|
||||
"@sentry/types": "6.19.7",
|
||||
"@sentry/utils": "6.19.7",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/react-resizable": "3.0.3",
|
||||
"@types/trusted-types": "2.0.3",
|
||||
|
@ -46,18 +46,6 @@ export interface LicenseInfo {
|
||||
trialExpiry?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes Sentry integration config
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SentryConfig {
|
||||
enabled: boolean;
|
||||
dsn: string;
|
||||
customEndpoint: string;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes GrafanaJavascriptAgentConfig integration config
|
||||
*
|
||||
@ -209,7 +197,6 @@ export interface GrafanaConfig {
|
||||
licenseInfo: LicenseInfo;
|
||||
http2Enabled: boolean;
|
||||
dateFormats?: SystemDateFormatSettings;
|
||||
sentry: SentryConfig;
|
||||
grafanaJavascriptAgent: GrafanaJavascriptAgentConfig;
|
||||
customTheme?: any;
|
||||
geomapDefaultBaseLayer?: MapLayerOptions;
|
||||
|
@ -41,7 +41,6 @@
|
||||
"@grafana/e2e-selectors": "10.1.0-pre",
|
||||
"@grafana/faro-web-sdk": "1.0.2",
|
||||
"@grafana/ui": "10.1.0-pre",
|
||||
"@sentry/browser": "6.19.7",
|
||||
"history": "4.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"rxjs": "7.8.0",
|
||||
|
@ -103,12 +103,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
supportBundlesEnabled = false;
|
||||
http2Enabled = false;
|
||||
dateFormats?: SystemDateFormatSettings;
|
||||
sentry = {
|
||||
enabled: false,
|
||||
dsn: '',
|
||||
customEndpoint: '',
|
||||
sampleRate: 1,
|
||||
};
|
||||
grafanaJavascriptAgent = {
|
||||
enabled: false,
|
||||
customEndpoint: '',
|
||||
|
@ -78,7 +78,6 @@ export interface EchoEvent<T extends EchoEventType = any, P = any> {
|
||||
export enum EchoEventType {
|
||||
Performance = 'performance',
|
||||
MetaAnalytics = 'meta-analytics',
|
||||
Sentry = 'sentry',
|
||||
Pageview = 'pageview',
|
||||
Interaction = 'interaction',
|
||||
ExperimentView = 'experimentview',
|
||||
|
@ -1,76 +1,54 @@
|
||||
import { captureMessage, captureException, Severity as LogLevel } from '@sentry/browser';
|
||||
|
||||
import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk';
|
||||
import { faro, LogLevel } from '@grafana/faro-web-sdk';
|
||||
|
||||
import { config } from '../config';
|
||||
|
||||
export { LogLevel };
|
||||
|
||||
// a bit stricter than what Sentry allows
|
||||
type Contexts = Record<string, Record<string, number | string | Record<string, string | number>>>;
|
||||
|
||||
/**
|
||||
* Log a message at INFO level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry
|
||||
*
|
||||
* Log a message at INFO level
|
||||
* @public
|
||||
*/
|
||||
export function logInfo(message: string, contexts?: Contexts) {
|
||||
if (config.grafanaJavascriptAgent.enabled) {
|
||||
faro.api.pushLog([message], {
|
||||
level: GrafanaLogLevel.INFO,
|
||||
level: LogLevel.INFO,
|
||||
context: contexts,
|
||||
});
|
||||
}
|
||||
if (config.sentry.enabled) {
|
||||
captureMessage(message, {
|
||||
level: LogLevel.Info,
|
||||
contexts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message at WARNING level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry
|
||||
* Log a message at WARNING level
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function logWarning(message: string, contexts?: Contexts) {
|
||||
if (config.grafanaJavascriptAgent.enabled) {
|
||||
faro.api.pushLog([message], {
|
||||
level: GrafanaLogLevel.WARN,
|
||||
level: LogLevel.WARN,
|
||||
context: contexts,
|
||||
});
|
||||
}
|
||||
if (config.sentry.enabled) {
|
||||
captureMessage(message, {
|
||||
level: LogLevel.Warning,
|
||||
contexts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message at DEBUG level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry
|
||||
* Log a message at DEBUG level
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function logDebug(message: string, contexts?: Contexts) {
|
||||
if (config.grafanaJavascriptAgent.enabled) {
|
||||
faro.api.pushLog([message], {
|
||||
level: GrafanaLogLevel.DEBUG,
|
||||
level: LogLevel.DEBUG,
|
||||
context: contexts,
|
||||
});
|
||||
}
|
||||
if (config.sentry.enabled) {
|
||||
captureMessage(message, {
|
||||
level: LogLevel.Debug,
|
||||
contexts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry
|
||||
* Log an error
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@ -78,7 +56,4 @@ export function logError(err: Error, contexts?: Contexts) {
|
||||
if (config.grafanaJavascriptAgent.enabled) {
|
||||
faro.api.pushError(err);
|
||||
}
|
||||
if (config.sentry.enabled) {
|
||||
captureException(err, { contexts });
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,6 @@
|
||||
"@react-aria/overlays": "3.10.1",
|
||||
"@react-aria/utils": "3.13.1",
|
||||
"@react-stately/menu": "3.4.1",
|
||||
"@sentry/browser": "6.19.7",
|
||||
"ansicolor": "1.1.100",
|
||||
"calculate-size": "1.1.1",
|
||||
"classnames": "2.3.2",
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { captureException } from '@sentry/browser';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
@ -6,7 +5,6 @@ import { faro } from '@grafana/faro-web-sdk';
|
||||
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
jest.mock('@sentry/browser');
|
||||
jest.mock('@grafana/faro-web-sdk', () => ({
|
||||
faro: {
|
||||
api: {
|
||||
@ -31,7 +29,7 @@ describe('ErrorBoundary', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should catch error and report it to sentry, including react component stack in context', async () => {
|
||||
it('should catch error and report it to Faro', async () => {
|
||||
const problem = new Error('things went terribly wrong');
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
@ -46,13 +44,6 @@ describe('ErrorBoundary', () => {
|
||||
);
|
||||
|
||||
await screen.findByText(problem.message);
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
const [error, context] = (captureException as jest.Mock).mock.calls[0];
|
||||
expect(error).toBe(problem);
|
||||
expect(context).toHaveProperty('contexts');
|
||||
expect(context.contexts).toHaveProperty('react');
|
||||
expect(context.contexts.react).toHaveProperty('componentStack');
|
||||
expect(context.contexts.react.componentStack).toMatch(/^\s+at ErrorThrower (.*)\s+at ErrorBoundary (.*)\s*$/);
|
||||
expect(faro.api.pushError).toHaveBeenCalledTimes(1);
|
||||
expect((faro.api.pushError as jest.Mock).mock.calls[0][0]).toBe(problem);
|
||||
});
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { captureException } from '@sentry/browser';
|
||||
import React, { PureComponent, ReactNode, ComponentType } from 'react';
|
||||
|
||||
import { faro } from '@grafana/faro-web-sdk';
|
||||
@ -38,7 +37,6 @@ export class ErrorBoundary extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
captureException(error, { contexts: { react: { componentStack: errorInfo.componentStack } } });
|
||||
faro?.api?.pushError(error);
|
||||
this.setState({ error, errorInfo });
|
||||
|
||||
|
@ -185,7 +185,6 @@ type FrontendSettingsDTO struct {
|
||||
RendererVersion string `json:"rendererVersion"`
|
||||
SecretsManagerPluginEnabled bool `json:"secretsManagerPluginEnabled"`
|
||||
Http2Enabled bool `json:"http2Enabled"`
|
||||
Sentry setting.Sentry `json:"sentry"`
|
||||
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
|
||||
PluginCatalogURL string `json:"pluginCatalogURL"`
|
||||
PluginAdminEnabled bool `json:"pluginAdminEnabled"`
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"html/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/navtree"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type IndexViewData struct {
|
||||
@ -27,7 +26,6 @@ type IndexViewData struct {
|
||||
FavIcon template.URL
|
||||
AppleTouchIcon template.URL
|
||||
AppTitle string
|
||||
Sentry *setting.Sentry
|
||||
ContentDeliveryURL string
|
||||
LoadingLogo template.URL
|
||||
CSPContent string
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/frontendlogging"
|
||||
@ -16,51 +15,8 @@ var frontendLogger = log.New("frontend")
|
||||
|
||||
type frontendLogMessageHandler func(hs *HTTPServer, c *web.Context)
|
||||
|
||||
const sentryLogEndpointPath = "/log"
|
||||
const grafanaJavascriptAgentEndpointPath = "/log-grafana-javascript-agent"
|
||||
|
||||
/** @deprecated will be removed in the next major version */
|
||||
func NewFrontendLogMessageHandler(store *frontendlogging.SourceMapStore) frontendLogMessageHandler {
|
||||
return func(hs *HTTPServer, c *web.Context) {
|
||||
event := frontendlogging.FrontendSentryEvent{}
|
||||
if err := web.Bind(c.Req, &event); err != nil {
|
||||
c.Resp.WriteHeader(http.StatusBadRequest)
|
||||
_, err = c.Resp.Write([]byte("bad request data"))
|
||||
if err != nil {
|
||||
hs.log.Error("could not write to response", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var msg = "unknown"
|
||||
|
||||
if len(event.Message) > 0 {
|
||||
msg = event.Message
|
||||
} else if event.Exception != nil && len(event.Exception.Values) > 0 {
|
||||
msg = event.Exception.Values[0].FmtMessage()
|
||||
}
|
||||
|
||||
var ctx = event.ToLogContext(store)
|
||||
|
||||
switch event.Level {
|
||||
case sentry.LevelError:
|
||||
frontendLogger.Error(msg, ctx...)
|
||||
case sentry.LevelWarning:
|
||||
frontendLogger.Warn(msg, ctx...)
|
||||
case sentry.LevelDebug:
|
||||
frontendLogger.Debug(msg, ctx...)
|
||||
default:
|
||||
frontendLogger.Info(msg, ctx...)
|
||||
}
|
||||
|
||||
c.Resp.WriteHeader(http.StatusAccepted)
|
||||
_, err := c.Resp.Write([]byte("OK"))
|
||||
if err != nil {
|
||||
hs.log.Error("could not write to response", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GrafanaJavascriptAgentLogMessageHandler(store *frontendlogging.SourceMapStore) frontendLogMessageHandler {
|
||||
return func(hs *HTTPServer, c *web.Context) {
|
||||
event := frontendlogging.FrontendGrafanaJavascriptAgentEvent{}
|
||||
@ -143,9 +99,9 @@ func GrafanaJavascriptAgentLogMessageHandler(store *frontendlogging.SourceMapSto
|
||||
// this is to avoid reporting errors in case config was changes but there are browser
|
||||
// sessions still open with older config
|
||||
func (hs *HTTPServer) frontendLogEndpoints() web.Handler {
|
||||
if !(hs.Cfg.GrafanaJavascriptAgent.Enabled || hs.Cfg.Sentry.Enabled) {
|
||||
if !(hs.Cfg.GrafanaJavascriptAgent.Enabled) {
|
||||
return func(ctx *web.Context) {
|
||||
if ctx.Req.Method == http.MethodPost && (ctx.Req.URL.Path == sentryLogEndpointPath || ctx.Req.URL.Path == grafanaJavascriptAgentEndpointPath) {
|
||||
if ctx.Req.Method == http.MethodPost && ctx.Req.URL.Path == grafanaJavascriptAgentEndpointPath {
|
||||
ctx.Resp.WriteHeader(http.StatusAccepted)
|
||||
_, err := ctx.Resp.Write([]byte("OK"))
|
||||
if err != nil {
|
||||
@ -156,33 +112,11 @@ func (hs *HTTPServer) frontendLogEndpoints() web.Handler {
|
||||
}
|
||||
|
||||
sourceMapStore := frontendlogging.NewSourceMapStore(hs.Cfg, hs.pluginStaticRouteResolver, frontendlogging.ReadSourceMapFromFS)
|
||||
|
||||
var rateLimiter *rate.Limiter
|
||||
var handler frontendLogMessageHandler
|
||||
handlerEndpoint := ""
|
||||
dummyEndpoint := ""
|
||||
|
||||
if hs.Cfg.GrafanaJavascriptAgent.Enabled {
|
||||
rateLimiter = rate.NewLimiter(rate.Limit(hs.Cfg.GrafanaJavascriptAgent.EndpointRPS), hs.Cfg.GrafanaJavascriptAgent.EndpointBurst)
|
||||
handler = GrafanaJavascriptAgentLogMessageHandler(sourceMapStore)
|
||||
handlerEndpoint = grafanaJavascriptAgentEndpointPath
|
||||
dummyEndpoint = sentryLogEndpointPath
|
||||
} else {
|
||||
rateLimiter = rate.NewLimiter(rate.Limit(hs.Cfg.Sentry.EndpointRPS), hs.Cfg.Sentry.EndpointBurst)
|
||||
handler = NewFrontendLogMessageHandler(sourceMapStore)
|
||||
handlerEndpoint = sentryLogEndpointPath
|
||||
dummyEndpoint = grafanaJavascriptAgentEndpointPath
|
||||
}
|
||||
rateLimiter := rate.NewLimiter(rate.Limit(hs.Cfg.GrafanaJavascriptAgent.EndpointRPS), hs.Cfg.GrafanaJavascriptAgent.EndpointBurst)
|
||||
handler := GrafanaJavascriptAgentLogMessageHandler(sourceMapStore)
|
||||
|
||||
return func(ctx *web.Context) {
|
||||
if ctx.Req.Method == http.MethodPost && ctx.Req.URL.Path == dummyEndpoint {
|
||||
ctx.Resp.WriteHeader(http.StatusAccepted)
|
||||
_, err := ctx.Resp.Write([]byte("OK"))
|
||||
if err != nil {
|
||||
hs.log.Error("could not write to response", "err", err)
|
||||
}
|
||||
}
|
||||
if ctx.Req.Method == http.MethodPost && ctx.Req.URL.Path == handlerEndpoint {
|
||||
if ctx.Req.Method == http.MethodPost && ctx.Req.URL.Path == grafanaJavascriptAgentEndpointPath {
|
||||
if !rateLimiter.AllowN(time.Now(), 1) {
|
||||
ctx.Resp.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
|
@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -30,79 +29,6 @@ type SourceMapReadRecord struct {
|
||||
|
||||
type logScenarioFunc func(c *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord)
|
||||
|
||||
func logSentryEventScenario(t *testing.T, desc string, event frontendlogging.FrontendSentryEvent, fn logScenarioFunc) {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
var logcontent = make(map[string]interface{})
|
||||
logcontent["logger"] = "frontend"
|
||||
newfrontendLogger := log.Logger(log.LoggerFunc(func(keyvals ...interface{}) error {
|
||||
for i := 0; i < len(keyvals); i += 2 {
|
||||
logcontent[keyvals[i].(string)] = keyvals[i+1]
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
origHandler := frontendLogger.GetLogger()
|
||||
frontendLogger.Swap(level.NewFilter(newfrontendLogger, level.AllowInfo()))
|
||||
sourceMapReads := []SourceMapReadRecord{}
|
||||
|
||||
t.Cleanup(func() {
|
||||
frontendLogger.Swap(origHandler)
|
||||
})
|
||||
|
||||
sc := setupScenarioContext(t, "/log")
|
||||
|
||||
cdnRootURL, e := url.Parse("https://storage.googleapis.com/grafana-static-assets")
|
||||
require.NoError(t, e)
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
StaticRootPath: "/staticroot",
|
||||
CDNRootURL: cdnRootURL,
|
||||
}
|
||||
|
||||
readSourceMap := func(dir string, path string) ([]byte, error) {
|
||||
sourceMapReads = append(sourceMapReads, SourceMapReadRecord{
|
||||
dir: dir,
|
||||
path: path,
|
||||
})
|
||||
if strings.Contains(path, "error") {
|
||||
return nil, errors.New("epic hard drive failure")
|
||||
}
|
||||
if strings.HasSuffix(path, "foo.js.map") {
|
||||
f, err := os.ReadFile("./frontendlogging/test-data/foo.js.map")
|
||||
require.NoError(t, err)
|
||||
return f, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// fake plugin route so we will try to find a source map there
|
||||
pm := fakePluginStaticRouteResolver{
|
||||
routes: []*plugins.StaticRoute{
|
||||
{
|
||||
Directory: "/usr/local/telepathic-panel",
|
||||
PluginID: "telepathic",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sourceMapStore := frontendlogging.NewSourceMapStore(cfg, &pm, readSourceMap)
|
||||
|
||||
loggingHandler := NewFrontendLogMessageHandler(sourceMapStore)
|
||||
|
||||
handler := routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
|
||||
sc.context = c
|
||||
c.Req.Body = mockRequestBody(event)
|
||||
c.Req.Header.Add("Content-Type", "application/json")
|
||||
loggingHandler(nil, c.Context)
|
||||
return response.Success("ok")
|
||||
})
|
||||
|
||||
sc.m.Post(sc.url, handler)
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
fn(sc, logcontent, sourceMapReads)
|
||||
})
|
||||
}
|
||||
|
||||
func logGrafanaJavascriptAgentEventScenario(t *testing.T, desc string, event frontendlogging.FrontendGrafanaJavascriptAgentEvent, fn logScenarioFunc) {
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
var logcontent = make(map[string]interface{})
|
||||
@ -176,216 +102,6 @@ func logGrafanaJavascriptAgentEventScenario(t *testing.T, desc string, event fro
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendLoggingEndpointSentry(t *testing.T) {
|
||||
ts, err := time.Parse("2006-01-02T15:04:05.000Z", "2020-10-22T06:29:29.078Z")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("FrontendLoggingEndpoint", func(t *testing.T) {
|
||||
request := sentry.Request{
|
||||
URL: "http://localhost:3000/",
|
||||
Headers: map[string]string{
|
||||
"User-Agent": "Chrome",
|
||||
},
|
||||
}
|
||||
|
||||
user := sentry.User{
|
||||
Email: "geralt@kaermorhen.com",
|
||||
ID: "45",
|
||||
}
|
||||
|
||||
event := sentry.Event{
|
||||
EventID: "123",
|
||||
Level: sentry.LevelError,
|
||||
Request: &request,
|
||||
Timestamp: ts,
|
||||
}
|
||||
|
||||
errorEvent := frontendlogging.FrontendSentryEvent{
|
||||
Event: &event,
|
||||
Exception: &frontendlogging.FrontendSentryException{
|
||||
Values: []frontendlogging.FrontendSentryExceptionValue{
|
||||
{
|
||||
Type: "UserError",
|
||||
Value: "Please replace user and try again",
|
||||
Stacktrace: sentry.Stacktrace{
|
||||
Frames: []sentry.Frame{
|
||||
{
|
||||
Function: "foofn",
|
||||
Filename: "foo.js",
|
||||
Lineno: 123,
|
||||
Colno: 23,
|
||||
},
|
||||
{
|
||||
Function: "barfn",
|
||||
Filename: "bar.js",
|
||||
Lineno: 113,
|
||||
Colno: 231,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logSentryEventScenario(t, "Should log received error event", errorEvent,
|
||||
func(sc *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord) {
|
||||
assert.Equal(t, http.StatusAccepted, sc.resp.Code)
|
||||
assertContextContains(t, logs, "logger", "frontend")
|
||||
assertContextContains(t, logs, "url", errorEvent.Request.URL)
|
||||
assertContextContains(t, logs, "user_agent", errorEvent.Request.Headers["User-Agent"])
|
||||
assertContextContains(t, logs, "event_id", errorEvent.EventID)
|
||||
assertContextContains(t, logs, "original_timestamp", errorEvent.Timestamp)
|
||||
assertContextContains(t, logs, "stacktrace", `UserError: Please replace user and try again
|
||||
at foofn (foo.js:123:23)
|
||||
at barfn (bar.js:113:231)`)
|
||||
assert.NotContains(t, logs, "context")
|
||||
})
|
||||
|
||||
messageEvent := frontendlogging.FrontendSentryEvent{
|
||||
Event: &sentry.Event{
|
||||
EventID: "123",
|
||||
Level: sentry.LevelInfo,
|
||||
Request: &request,
|
||||
Timestamp: ts,
|
||||
Message: "hello world",
|
||||
User: user,
|
||||
},
|
||||
Exception: nil,
|
||||
}
|
||||
|
||||
logSentryEventScenario(t, "Should log received message event", messageEvent,
|
||||
func(sc *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord) {
|
||||
assert.Equal(t, http.StatusAccepted, sc.resp.Code)
|
||||
assert.Len(t, logs, 10)
|
||||
assertContextContains(t, logs, "logger", "frontend")
|
||||
assertContextContains(t, logs, "msg", "hello world")
|
||||
assertContextContains(t, logs, level.Key().(string), level.InfoValue())
|
||||
assertContextContains(t, logs, "logger", "frontend")
|
||||
assertContextContains(t, logs, "url", messageEvent.Request.URL)
|
||||
assertContextContains(t, logs, "user_agent", messageEvent.Request.Headers["User-Agent"])
|
||||
assertContextContains(t, logs, "event_id", messageEvent.EventID)
|
||||
assertContextContains(t, logs, "original_timestamp", messageEvent.Timestamp)
|
||||
assert.NotContains(t, logs, "stacktrace")
|
||||
assert.NotContains(t, logs, "context")
|
||||
assertContextContains(t, logs, "user_email", user.Email)
|
||||
assertContextContains(t, logs, "user_id", user.ID)
|
||||
})
|
||||
|
||||
eventWithContext := frontendlogging.FrontendSentryEvent{
|
||||
Event: &sentry.Event{
|
||||
EventID: "123",
|
||||
Level: sentry.LevelInfo,
|
||||
Request: &request,
|
||||
Timestamp: ts,
|
||||
Message: "hello world",
|
||||
User: user,
|
||||
Contexts: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"one": "two",
|
||||
"three": 4,
|
||||
},
|
||||
"bar": "baz",
|
||||
},
|
||||
},
|
||||
Exception: nil,
|
||||
}
|
||||
|
||||
logSentryEventScenario(t, "Should log event context", eventWithContext,
|
||||
func(sc *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord) {
|
||||
assert.Equal(t, http.StatusAccepted, sc.resp.Code)
|
||||
assertContextContains(t, logs, "context_foo_one", "two")
|
||||
assertContextContains(t, logs, "context_foo_three", "4")
|
||||
assertContextContains(t, logs, "context_bar", "baz")
|
||||
})
|
||||
|
||||
errorEventForSourceMapping := frontendlogging.FrontendSentryEvent{
|
||||
Event: &event,
|
||||
Exception: &frontendlogging.FrontendSentryException{
|
||||
Values: []frontendlogging.FrontendSentryExceptionValue{
|
||||
{
|
||||
Type: "UserError",
|
||||
Value: "Please replace user and try again",
|
||||
Stacktrace: sentry.Stacktrace{
|
||||
Frames: []sentry.Frame{
|
||||
{
|
||||
Function: "foofn",
|
||||
Filename: "http://localhost:3000/public/build/moo/foo.js", // source map found and mapped, core
|
||||
Lineno: 2,
|
||||
Colno: 5,
|
||||
},
|
||||
{
|
||||
Function: "foofn",
|
||||
Filename: "http://localhost:3000/public/plugins/telepathic/foo.js", // plugin, source map found and mapped
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
{
|
||||
Function: "explode",
|
||||
Filename: "http://localhost:3000/public/build/error.js", // reading source map throws error
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
{
|
||||
Function: "wat",
|
||||
Filename: "http://localhost:3000/public/build/bar.js", // core, but source map not found on fs
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
{
|
||||
Function: "nope",
|
||||
Filename: "http://localhost:3000/baz.js", // not core or plugin, wont even attempt to get source map
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
{
|
||||
Function: "fake",
|
||||
Filename: "http://localhost:3000/public/build/../../secrets.txt", // path will be sanitized
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
{
|
||||
Function: "cdn",
|
||||
Filename: "https://storage.googleapis.com/grafana-static-assets/grafana-oss/pre-releases/7.5.0-11925pre/public/build/foo.js", // source map found and mapped
|
||||
Lineno: 3,
|
||||
Colno: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logSentryEventScenario(t, "Should load sourcemap and transform stacktrace line when possible",
|
||||
errorEventForSourceMapping, func(sc *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord) {
|
||||
assert.Equal(t, http.StatusAccepted, sc.resp.Code)
|
||||
assert.Len(t, logs, 9)
|
||||
assertContextContains(t, logs, "stacktrace", `UserError: Please replace user and try again
|
||||
at ? (core|webpack:///./some_source.ts:2:2)
|
||||
at ? (telepathic|webpack:///./some_source.ts:3:2)
|
||||
at explode (http://localhost:3000/public/build/error.js:3:10)
|
||||
at wat (http://localhost:3000/public/build/bar.js:3:10)
|
||||
at nope (http://localhost:3000/baz.js:3:10)
|
||||
at fake (http://localhost:3000/public/build/../../secrets.txt:3:10)
|
||||
at ? (core|webpack:///./some_source.ts:3:2)`)
|
||||
assert.Len(t, sourceMapReads, 6)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[0].dir)
|
||||
assert.Equal(t, "build/moo/foo.js.map", sourceMapReads[0].path)
|
||||
assert.Equal(t, "/usr/local/telepathic-panel", sourceMapReads[1].dir)
|
||||
assert.Equal(t, "/foo.js.map", sourceMapReads[1].path)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[2].dir)
|
||||
assert.Equal(t, "build/error.js.map", sourceMapReads[2].path)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[3].dir)
|
||||
assert.Equal(t, "build/bar.js.map", sourceMapReads[3].path)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[4].dir)
|
||||
assert.Equal(t, "secrets.txt.map", sourceMapReads[4].path)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[5].dir)
|
||||
assert.Equal(t, "build/foo.js.map", sourceMapReads[5].path)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendLoggingEndpointGrafanaJavascriptAgent(t *testing.T) {
|
||||
ts, err := time.Parse("2006-01-02T15:04:05.000Z", "2020-10-22T06:29:29.078Z")
|
||||
require.NoError(t, err)
|
||||
@ -536,13 +252,13 @@ func TestFrontendLoggingEndpointGrafanaJavascriptAgent(t *testing.T) {
|
||||
func(sc *scenarioContext, logs map[string]interface{}, sourceMapReads []SourceMapReadRecord) {
|
||||
assert.Equal(t, http.StatusAccepted, sc.resp.Code)
|
||||
assertContextContains(t, logs, "stacktrace", `UserError: Please replace user and try again
|
||||
at ? (webpack:///./some_source.ts:2:2)
|
||||
at ? (webpack:///./some_source.ts:3:2)
|
||||
at ? (core|webpack:///./some_source.ts:2:2)
|
||||
at ? (telepathic|webpack:///./some_source.ts:3:2)
|
||||
at explode (http://localhost:3000/public/build/error.js:3:10)
|
||||
at wat (http://localhost:3000/public/build/bar.js:3:10)
|
||||
at nope (http://localhost:3000/baz.js:3:10)
|
||||
at fake (http://localhost:3000/public/build/../../secrets.txt:3:10)
|
||||
at ? (webpack:///./some_source.ts:3:2)`)
|
||||
at ? (core|webpack:///./some_source.ts:3:2)`)
|
||||
assert.Len(t, sourceMapReads, 6)
|
||||
assert.Equal(t, "/staticroot", sourceMapReads[0].dir)
|
||||
assert.Equal(t, "build/moo/foo.js.map", sourceMapReads[0].path)
|
||||
|
@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type CtxVector []interface{}
|
||||
|
||||
type FrontendGrafanaJavascriptAgentEvent struct {
|
||||
Exceptions []Exception `json:"exceptions,omitempty"`
|
||||
Logs []Log `json:"logs,omitempty"`
|
||||
|
@ -1,33 +1,5 @@
|
||||
package frontendlogging
|
||||
|
||||
// ResolveSourceLocation resolves minified source location to original source location
|
||||
func ResolveSourceLocation(store *SourceMapStore, frame *Frame) (*Frame, error) {
|
||||
smap, err := store.getSourceMap(frame.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if smap == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
file, function, line, col, ok := smap.consumer.Source(frame.Lineno, frame.Colno)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// unfortunately in many cases go-sourcemap fails to determine the original function name.
|
||||
// not a big issue as long as file, line and column are correct
|
||||
if len(function) == 0 {
|
||||
function = "?"
|
||||
}
|
||||
return &Frame{
|
||||
Filename: file,
|
||||
Lineno: line,
|
||||
Colno: col,
|
||||
Function: function,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TransformException will attempt to resolved all monified source locations in the stacktrace with original source locations
|
||||
func TransformException(ex *Exception, store *SourceMapStore) *Exception {
|
||||
if ex.Stacktrace == nil {
|
||||
@ -37,7 +9,7 @@ func TransformException(ex *Exception, store *SourceMapStore) *Exception {
|
||||
|
||||
for _, frame := range ex.Stacktrace.Frames {
|
||||
frame := frame
|
||||
mappedFrame, err := ResolveSourceLocation(store, &frame)
|
||||
mappedFrame, err := store.resolveSourceLocation(frame)
|
||||
if err != nil {
|
||||
frames = append(frames, frame)
|
||||
} else if mappedFrame != nil {
|
||||
|
@ -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
|
||||
}
|
@ -9,13 +9,14 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
sourcemap "github.com/go-sourcemap/sourcemap"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var logger = log.New("frontendlogging")
|
||||
|
||||
type sourceMapLocation struct {
|
||||
dir string
|
||||
path string
|
||||
@ -136,7 +137,7 @@ func (store *SourceMapStore) getSourceMap(sourceURL string) (*sourceMap, error)
|
||||
return smap, nil
|
||||
}
|
||||
|
||||
func (store *SourceMapStore) resolveSourceLocation(frame sentry.Frame) (*sentry.Frame, error) {
|
||||
func (store *SourceMapStore) resolveSourceLocation(frame Frame) (*Frame, error) {
|
||||
smap, err := store.getSourceMap(frame.Filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -157,7 +158,7 @@ func (store *SourceMapStore) resolveSourceLocation(frame sentry.Frame) (*sentry.
|
||||
if len(smap.pluginID) > 0 {
|
||||
module = smap.pluginID
|
||||
}
|
||||
return &sentry.Frame{
|
||||
return &Frame{
|
||||
Filename: file,
|
||||
Lineno: line,
|
||||
Colno: col,
|
||||
|
@ -184,7 +184,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
RendererVersion: hs.RenderService.Version(),
|
||||
SecretsManagerPluginEnabled: secretsManagerPluginEnabled,
|
||||
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
|
||||
Sentry: hs.Cfg.Sentry,
|
||||
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
|
||||
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
|
||||
PluginAdminEnabled: hs.Cfg.PluginAdminEnabled,
|
||||
|
@ -136,7 +136,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
||||
AppleTouchIcon: "public/img/apple-touch-icon.png",
|
||||
AppTitle: "Grafana",
|
||||
NavTree: navTree,
|
||||
Sentry: &hs.Cfg.Sentry,
|
||||
Nonce: c.RequestNonce,
|
||||
ContentDeliveryURL: hs.Cfg.GetContentDeliveryURL(hs.License.ContentDeliveryPrefix()),
|
||||
LoadingLogo: "public/img/grafana_icon.svg",
|
||||
|
@ -363,9 +363,6 @@ type Cfg struct {
|
||||
DashboardAnnotationCleanupSettings AnnotationCleanupSettings
|
||||
APIAnnotationCleanupSettings AnnotationCleanupSettings
|
||||
|
||||
// Sentry config
|
||||
Sentry Sentry
|
||||
|
||||
// GrafanaJavascriptAgent config
|
||||
GrafanaJavascriptAgent GrafanaJavascriptAgent
|
||||
|
||||
@ -1187,7 +1184,6 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
|
||||
cfg.GeomapEnableCustomBaseLayers = geomapSection.Key("enable_custom_baselayers").MustBool(true)
|
||||
|
||||
cfg.readDateFormats()
|
||||
cfg.readSentryConfig()
|
||||
cfg.readGrafanaJavascriptAgentConfig()
|
||||
|
||||
if err := cfg.readLiveSettings(iniFile); err != nil {
|
||||
|
@ -13,17 +13,14 @@ type GrafanaJavascriptAgent struct {
|
||||
|
||||
func (cfg *Cfg) readGrafanaJavascriptAgentConfig() {
|
||||
raw := cfg.Raw.Section("log.frontend")
|
||||
provider := raw.Key("provider").MustString("sentry")
|
||||
if provider == "grafana" {
|
||||
cfg.GrafanaJavascriptAgent = GrafanaJavascriptAgent{
|
||||
Enabled: raw.Key("enabled").MustBool(true),
|
||||
CustomEndpoint: raw.Key("custom_endpoint").MustString("/log-grafana-javascript-agent"),
|
||||
EndpointRPS: raw.Key("log_endpoint_requests_per_second_limit").MustInt(3),
|
||||
EndpointBurst: raw.Key("log_endpoint_burst_limit").MustInt(15),
|
||||
ErrorInstrumentalizationEnabled: raw.Key("instrumentations_errors_enabled").MustBool(true),
|
||||
ConsoleInstrumentalizationEnabled: raw.Key("instrumentations_console_enabled").MustBool(true),
|
||||
WebVitalsInstrumentalizationEnabled: raw.Key("instrumentations_webvitals_enabled").MustBool(true),
|
||||
ApiKey: raw.Key("api_key").String(),
|
||||
}
|
||||
cfg.GrafanaJavascriptAgent = GrafanaJavascriptAgent{
|
||||
Enabled: raw.Key("enabled").MustBool(true),
|
||||
CustomEndpoint: raw.Key("custom_endpoint").MustString("/log-grafana-javascript-agent"),
|
||||
EndpointRPS: raw.Key("log_endpoint_requests_per_second_limit").MustInt(3),
|
||||
EndpointBurst: raw.Key("log_endpoint_burst_limit").MustInt(15),
|
||||
ErrorInstrumentalizationEnabled: raw.Key("instrumentations_errors_enabled").MustBool(true),
|
||||
ConsoleInstrumentalizationEnabled: raw.Key("instrumentations_console_enabled").MustBool(true),
|
||||
WebVitalsInstrumentalizationEnabled: raw.Key("instrumentations_webvitals_enabled").MustBool(true),
|
||||
ApiKey: raw.Key("api_key").String(),
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
@ -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({
|
||||
|
@ -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], {
|
||||
|
71
yarn.lock
71
yarn.lock
@ -3028,7 +3028,6 @@ __metadata:
|
||||
"@grafana/ui": 10.1.0-pre
|
||||
"@rollup/plugin-commonjs": 23.0.2
|
||||
"@rollup/plugin-node-resolve": 15.0.1
|
||||
"@sentry/browser": 6.19.7
|
||||
"@testing-library/dom": 9.0.1
|
||||
"@testing-library/react": 14.0.0
|
||||
"@testing-library/user-event": 14.4.3
|
||||
@ -3141,7 +3140,6 @@ __metadata:
|
||||
"@react-aria/utils": 3.13.1
|
||||
"@react-stately/menu": 3.4.1
|
||||
"@rollup/plugin-node-resolve": 15.0.1
|
||||
"@sentry/browser": 6.19.7
|
||||
"@storybook/addon-a11y": 6.5.16
|
||||
"@storybook/addon-actions": 6.5.16
|
||||
"@storybook/addon-docs": 6.5.16
|
||||
@ -6451,70 +6449,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/browser@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/browser@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/core": 6.19.7
|
||||
"@sentry/types": 6.19.7
|
||||
"@sentry/utils": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 071d00c76c2d0384580474c634c58c6196bbd1a3cf510da1309bd1565c57df7422fca8ceb717db189fa557f2c711a21664ee1ab935dfd9869faf416d388e6f78
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/core@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/core@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/hub": 6.19.7
|
||||
"@sentry/minimal": 6.19.7
|
||||
"@sentry/types": 6.19.7
|
||||
"@sentry/utils": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: d212e8ef07114549de4a93b81f8bfa217ca1550ca7a5eeaa611e5629faef78ff72663ce561ffa2cff48f3dc556745ef65177044f9965cdd3cbccf617cf3bf675
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/hub@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/hub@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/types": 6.19.7
|
||||
"@sentry/utils": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 10bb1c5cba1b0f1e27a3dd0a186c22f94aeaf11c4662890ab07b2774f46f46af78d61e3ba71d76edc750a7b45af86edd032f35efecdb4efa2eaf551080ccdcb1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/minimal@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/minimal@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/hub": 6.19.7
|
||||
"@sentry/types": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: 9153ac426ee056fc34c5be898f83d74ec08f559d69f544c5944ec05e584b62ed356b92d1a9b08993a7022ad42b5661c3d72881221adc19bee5fc1af3ad3864a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/types@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/types@npm:6.19.7"
|
||||
checksum: f46ef74a33376ad6ea9b128115515c58eb9369d89293c60aa67abca26b5d5d519aa4d0a736db56ae0d75ffd816643d62187018298523cbc2e6c2fb3a6b2a9035
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sentry/utils@npm:6.19.7":
|
||||
version: 6.19.7
|
||||
resolution: "@sentry/utils@npm:6.19.7"
|
||||
dependencies:
|
||||
"@sentry/types": 6.19.7
|
||||
tslib: ^1.9.3
|
||||
checksum: a000223b9c646c64e3565e79cace1eeb75114342b768367c4dddd646476c215eb1bddfb70c63f05e2352d3bce2d7d415344e4757a001605d0e01ac74da5dd306
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sideway/address@npm:^4.1.3":
|
||||
version: 4.1.4
|
||||
resolution: "@sideway/address@npm:4.1.4"
|
||||
@ -18770,9 +18704,6 @@ __metadata:
|
||||
"@reduxjs/toolkit": 1.9.3
|
||||
"@remix-run/router": ^1.5.0
|
||||
"@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1
|
||||
"@sentry/browser": 6.19.7
|
||||
"@sentry/types": 6.19.7
|
||||
"@sentry/utils": 6.19.7
|
||||
"@swc/core": 1.3.38
|
||||
"@swc/helpers": 0.4.14
|
||||
"@testing-library/dom": 9.0.1
|
||||
@ -32939,7 +32870,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^1.10.0, tslib@npm:^1.14.1, tslib@npm:^1.8.1, tslib@npm:^1.9.3":
|
||||
"tslib@npm:^1.10.0, tslib@npm:^1.14.1, tslib@npm:^1.8.1":
|
||||
version: 1.14.1
|
||||
resolution: "tslib@npm:1.14.1"
|
||||
checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd
|
||||
|
Loading…
Reference in New Issue
Block a user