mirror of https://github.com/grafana/grafana
Frontend Logging: Integrate grafana javascript agent (#50801)
Add Grafana Javascript Agent integration to Grafanapull/51274/head^2
parent
849d4a3c56
commit
7c886fb6f9
@ -0,0 +1,28 @@ |
||||
package frontendlogging |
||||
|
||||
import ( |
||||
"fmt" |
||||
) |
||||
|
||||
type FrontendGrafanaJavascriptAgentEvent struct { |
||||
Exceptions []Exception `json:"exceptions,omitempty"` |
||||
Logs []Log `json:"logs,omitempty"` |
||||
Measurements []Measurement `json:"measurements,omitempty"` |
||||
Meta Meta `json:"meta,omitempty"` |
||||
Traces *Traces `json:"traces,omitempty"` |
||||
} |
||||
|
||||
// KeyValToInterfaceMap converts KeyVal to map[string]interface
|
||||
func KeyValToInterfaceMap(kv *KeyVal) map[string]interface{} { |
||||
retv := make(map[string]interface{}) |
||||
for el := kv.Oldest(); el != nil; el = el.Next() { |
||||
retv[fmt.Sprint(el.Key)] = el.Value |
||||
} |
||||
return retv |
||||
} |
||||
func (event *FrontendGrafanaJavascriptAgentEvent) AddMetaToContext(ctx CtxVector) []interface{} { |
||||
for k, v := range KeyValToInterfaceMap(event.Meta.KeyVal()) { |
||||
ctx = append(ctx, k, v) |
||||
} |
||||
return ctx |
||||
} |
@ -0,0 +1,419 @@ |
||||
/* This file is mostly copied over from https://github.com/grafana/agent/tree/main/pkg/integrations/v2/app_agent_receiver,
|
||||
as soon as we can use agent as a dependency this can be refactored |
||||
*/ |
||||
|
||||
package frontendlogging |
||||
|
||||
import ( |
||||
"fmt" |
||||
"sort" |
||||
"strings" |
||||
"time" |
||||
|
||||
om "github.com/wk8/go-ordered-map" |
||||
otlp "go.opentelemetry.io/collector/model/otlp" |
||||
otelpdata "go.opentelemetry.io/collector/model/pdata" |
||||
) |
||||
|
||||
// KeyVal is an ordered map of string to interface
|
||||
type KeyVal = om.OrderedMap |
||||
|
||||
// NewKeyVal creates new empty KeyVal
|
||||
func NewKeyVal() *KeyVal { |
||||
return om.New() |
||||
} |
||||
|
||||
func KeyValAdd(kv *KeyVal, key string, value string) { |
||||
if len(value) > 0 { |
||||
kv.Set(key, value) |
||||
} |
||||
} |
||||
|
||||
// MergeKeyVal will merge source in target
|
||||
func MergeKeyVal(target *KeyVal, source *KeyVal) { |
||||
for el := source.Oldest(); el != nil; el = el.Next() { |
||||
target.Set(el.Key, el.Value) |
||||
} |
||||
} |
||||
|
||||
// KeyValFromMap will instantiate KeyVal from a map[string]string
|
||||
func KeyValFromMap(m map[string]string) *KeyVal { |
||||
kv := NewKeyVal() |
||||
keys := make([]string, 0, len(m)) |
||||
for k := range m { |
||||
keys = append(keys, k) |
||||
} |
||||
sort.Strings(keys) |
||||
for _, k := range keys { |
||||
KeyValAdd(kv, k, m[k]) |
||||
} |
||||
return kv |
||||
} |
||||
|
||||
// Payload is the body of the receiver request
|
||||
type Payload struct { |
||||
Exceptions []Exception `json:"exceptions,omitempty"` |
||||
Logs []Log `json:"logs,omitempty"` |
||||
Measurements []Measurement `json:"measurements,omitempty"` |
||||
Meta Meta `json:"meta,omitempty"` |
||||
Traces *Traces `json:"traces,omitempty"` |
||||
} |
||||
|
||||
// Frame struct represents a single stacktrace frame
|
||||
type Frame struct { |
||||
Function string `json:"function,omitempty"` |
||||
Module string `json:"module,omitempty"` |
||||
Filename string `json:"filename,omitempty"` |
||||
Lineno int `json:"lineno,omitempty"` |
||||
Colno int `json:"colno,omitempty"` |
||||
} |
||||
|
||||
// String function converts a Frame into a human readable string
|
||||
func (frame Frame) String() 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) |
||||
} |
||||
|
||||
// MergeKeyValWithPrefix will merge source in target, adding a prefix to each key being merged in
|
||||
func MergeKeyValWithPrefix(target *KeyVal, source *KeyVal, prefix string) { |
||||
for el := source.Oldest(); el != nil; el = el.Next() { |
||||
target.Set(fmt.Sprintf("%s%s", prefix, el.Key), el.Value) |
||||
} |
||||
} |
||||
|
||||
// Stacktrace is a collection of Frames
|
||||
type Stacktrace struct { |
||||
Frames []Frame `json:"frames,omitempty"` |
||||
} |
||||
|
||||
// Exception struct controls all the data regarding an exception
|
||||
type Exception struct { |
||||
Type string `json:"type,omitempty"` |
||||
Value string `json:"value,omitempty"` |
||||
Stacktrace *Stacktrace `json:"stacktrace,omitempty"` |
||||
Timestamp time.Time `json:"timestamp"` |
||||
Trace TraceContext `json:"trace,omitempty"` |
||||
} |
||||
|
||||
// Message string is concatenating of the Exception.Type and Exception.Value
|
||||
func (e Exception) Message() string { |
||||
return fmt.Sprintf("%s: %s", e.Type, e.Value) |
||||
} |
||||
|
||||
// String is the string representation of an Exception
|
||||
func (e Exception) String() string { |
||||
var stacktrace = e.Message() |
||||
if e.Stacktrace != nil { |
||||
for _, frame := range e.Stacktrace.Frames { |
||||
stacktrace += frame.String() |
||||
} |
||||
} |
||||
return stacktrace |
||||
} |
||||
|
||||
// KeyVal representation of the exception object
|
||||
func (e Exception) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "timestamp", e.Timestamp.String()) |
||||
KeyValAdd(kv, "kind", "exception") |
||||
KeyValAdd(kv, "type", e.Type) |
||||
KeyValAdd(kv, "value", e.Value) |
||||
KeyValAdd(kv, "stacktrace", e.String()) |
||||
MergeKeyVal(kv, e.Trace.KeyVal()) |
||||
return kv |
||||
} |
||||
|
||||
// TraceContext holds trace id and span id associated to an entity (log, exception, measurement...).
|
||||
type TraceContext struct { |
||||
TraceID string `json:"trace_id"` |
||||
SpanID string `json:"span_id"` |
||||
} |
||||
|
||||
// KeyVal representation of the trace context object.
|
||||
func (tc TraceContext) KeyVal() *KeyVal { |
||||
retv := NewKeyVal() |
||||
KeyValAdd(retv, "traceID", tc.TraceID) |
||||
KeyValAdd(retv, "spanID", tc.SpanID) |
||||
return retv |
||||
} |
||||
|
||||
// Traces wraps the otel traces model.
|
||||
type Traces struct { |
||||
otelpdata.Traces |
||||
} |
||||
|
||||
// UnmarshalJSON unmarshals Traces model.
|
||||
func (t *Traces) UnmarshalJSON(b []byte) error { |
||||
unmarshaler := otlp.NewJSONTracesUnmarshaler() |
||||
td, err := unmarshaler.UnmarshalTraces(b) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
*t = Traces{td} |
||||
return nil |
||||
} |
||||
|
||||
// MarshalJSON marshals Traces model to json.
|
||||
func (t Traces) MarshalJSON() ([]byte, error) { |
||||
marshaler := otlp.NewJSONTracesMarshaler() |
||||
return marshaler.MarshalTraces(t.Traces) |
||||
} |
||||
|
||||
// SpanSlice unpacks Traces entity into a slice of Spans.
|
||||
func (t Traces) SpanSlice() []otelpdata.Span { |
||||
spans := make([]otelpdata.Span, 0) |
||||
rss := t.ResourceSpans() |
||||
for i := 0; i < rss.Len(); i++ { |
||||
rs := rss.At(i) |
||||
ilss := rs.InstrumentationLibrarySpans() |
||||
for j := 0; j < ilss.Len(); j++ { |
||||
s := ilss.At(j).Spans() |
||||
for si := 0; si < s.Len(); si++ { |
||||
spans = append(spans, s.At(si)) |
||||
} |
||||
} |
||||
} |
||||
return spans |
||||
} |
||||
|
||||
// SpanToKeyVal returns KeyVal representation of a Span.
|
||||
func SpanToKeyVal(s otelpdata.Span) *KeyVal { |
||||
kv := NewKeyVal() |
||||
if s.StartTimestamp() > 0 { |
||||
KeyValAdd(kv, "timestamp", s.StartTimestamp().AsTime().String()) |
||||
} |
||||
if s.EndTimestamp() > 0 { |
||||
KeyValAdd(kv, "end_timestamp", s.StartTimestamp().AsTime().String()) |
||||
} |
||||
KeyValAdd(kv, "kind", "span") |
||||
KeyValAdd(kv, "traceID", s.TraceID().HexString()) |
||||
KeyValAdd(kv, "spanID", s.SpanID().HexString()) |
||||
KeyValAdd(kv, "span_kind", s.Kind().String()) |
||||
KeyValAdd(kv, "name", s.Name()) |
||||
KeyValAdd(kv, "parent_spanID", s.ParentSpanID().HexString()) |
||||
s.Attributes().Range(func(k string, v otelpdata.AttributeValue) bool { |
||||
KeyValAdd(kv, "attr_"+k, fmt.Sprintf("%v", v)) |
||||
return true |
||||
}) |
||||
|
||||
return kv |
||||
} |
||||
|
||||
// LogLevel is log level enum for incoming app logs
|
||||
type LogLevel string |
||||
|
||||
const ( |
||||
// LogLevelTrace is "trace"
|
||||
LogLevelTrace LogLevel = "trace" |
||||
// LogLevelDebug is "debug"
|
||||
LogLevelDebug LogLevel = "debug" |
||||
// LogLevelInfo is "info"
|
||||
LogLevelInfo LogLevel = "info" |
||||
// LogLevelWarning is "warning"
|
||||
LogLevelWarning LogLevel = "warn" |
||||
// LogLevelError is "error"
|
||||
LogLevelError LogLevel = "error" |
||||
) |
||||
|
||||
// LogContext is a string to string map structure that
|
||||
// represents the context of a log message
|
||||
type LogContext map[string]string |
||||
|
||||
// Log struct controls the data that come into a Log message
|
||||
type Log struct { |
||||
Message string `json:"message,omitempty"` |
||||
LogLevel LogLevel `json:"level,omitempty"` |
||||
Context LogContext `json:"context,omitempty"` |
||||
Timestamp time.Time `json:"timestamp"` |
||||
Trace TraceContext `json:"trace,omitempty"` |
||||
} |
||||
|
||||
// KeyVal representation of a Log object
|
||||
func (l Log) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "timestamp", l.Timestamp.String()) |
||||
KeyValAdd(kv, "kind", "log") |
||||
KeyValAdd(kv, "message", l.Message) |
||||
KeyValAdd(kv, "level", string(l.LogLevel)) |
||||
MergeKeyValWithPrefix(kv, KeyValFromMap(l.Context), "context_") |
||||
MergeKeyVal(kv, l.Trace.KeyVal()) |
||||
return kv |
||||
} |
||||
|
||||
func (l Log) KeyValContext() *KeyVal { |
||||
kv := NewKeyVal() |
||||
MergeKeyValWithPrefix(kv, KeyValFromMap(l.Context), "context_") |
||||
return kv |
||||
} |
||||
|
||||
// Measurement holds the data for user provided measurements
|
||||
type Measurement struct { |
||||
Values map[string]float64 `json:"values,omitempty"` |
||||
Timestamp time.Time `json:"timestamp,omitempty"` |
||||
Trace TraceContext `json:"trace,omitempty"` |
||||
Type string `json:"type,omitempty"` |
||||
} |
||||
|
||||
// KeyVal representation of the Measurement object
|
||||
func (m Measurement) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
|
||||
KeyValAdd(kv, "timestamp", m.Timestamp.String()) |
||||
KeyValAdd(kv, "kind", "measurement") |
||||
|
||||
keys := make([]string, 0, len(m.Values)) |
||||
for k := range m.Values { |
||||
keys = append(keys, k) |
||||
} |
||||
sort.Strings(keys) |
||||
for _, k := range keys { |
||||
KeyValAdd(kv, k, fmt.Sprintf("%f", m.Values[k])) |
||||
} |
||||
MergeKeyVal(kv, m.Trace.KeyVal()) |
||||
return kv |
||||
} |
||||
|
||||
// SDK holds metadata about the app agent that produced the event
|
||||
type SDK struct { |
||||
Name string `json:"name,omitempty"` |
||||
Version string `json:"version,omitempty"` |
||||
Integrations []SDKIntegration `json:"integrations,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key->value representation of Sdk metadata
|
||||
func (sdk SDK) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "name", sdk.Name) |
||||
KeyValAdd(kv, "version", sdk.Version) |
||||
|
||||
if len(sdk.Integrations) > 0 { |
||||
integrations := make([]string, len(sdk.Integrations)) |
||||
|
||||
for i, integration := range sdk.Integrations { |
||||
integrations[i] = integration.String() |
||||
} |
||||
|
||||
KeyValAdd(kv, "integrations", strings.Join(integrations, ",")) |
||||
} |
||||
|
||||
return kv |
||||
} |
||||
|
||||
// SDKIntegration holds metadata about a plugin/integration on the app agent that collected and sent the event
|
||||
type SDKIntegration struct { |
||||
Name string `json:"name,omitempty"` |
||||
Version string `json:"version,omitempty"` |
||||
} |
||||
|
||||
func (i SDKIntegration) String() string { |
||||
return fmt.Sprintf("%s:%s", i.Name, i.Version) |
||||
} |
||||
|
||||
// User holds metadata about the user related to an app event
|
||||
type User struct { |
||||
Email string `json:"email,omitempty"` |
||||
ID string `json:"id,omitempty"` |
||||
Username string `json:"username,omitempty"` |
||||
Attributes map[string]string `json:"attributes,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces a key->value representation User metadata
|
||||
func (u User) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "email", u.Email) |
||||
KeyValAdd(kv, "id", u.ID) |
||||
KeyValAdd(kv, "username", u.Username) |
||||
MergeKeyValWithPrefix(kv, KeyValFromMap(u.Attributes), "attr_") |
||||
return kv |
||||
} |
||||
|
||||
// Meta holds metadata about an app event
|
||||
type Meta struct { |
||||
SDK SDK `json:"sdk,omitempty"` |
||||
App App `json:"app,omitempty"` |
||||
User User `json:"user,omitempty"` |
||||
Session Session `json:"session,omitempty"` |
||||
Page Page `json:"page,omitempty"` |
||||
Browser Browser `json:"browser,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key->value representation of the app event metadatga
|
||||
func (m Meta) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
MergeKeyValWithPrefix(kv, m.SDK.KeyVal(), "sdk_") |
||||
MergeKeyValWithPrefix(kv, m.App.KeyVal(), "app_") |
||||
MergeKeyValWithPrefix(kv, m.User.KeyVal(), "user_") |
||||
MergeKeyValWithPrefix(kv, m.Session.KeyVal(), "session_") |
||||
MergeKeyValWithPrefix(kv, m.Page.KeyVal(), "page_") |
||||
MergeKeyValWithPrefix(kv, m.Browser.KeyVal(), "browser_") |
||||
return kv |
||||
} |
||||
|
||||
// Session holds metadata about the browser session the event originates from
|
||||
type Session struct { |
||||
ID string `json:"id,omitempty"` |
||||
Attributes map[string]string `json:"attributes,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key->value representation of the Session metadata
|
||||
func (s Session) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "id", s.ID) |
||||
MergeKeyValWithPrefix(kv, KeyValFromMap(s.Attributes), "attr_") |
||||
return kv |
||||
} |
||||
|
||||
// Page holds metadata about the web page event originates from
|
||||
type Page struct { |
||||
ID string `json:"id,omitempty"` |
||||
URL string `json:"url,omitempty"` |
||||
Attributes map[string]string `json:"attributes,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key->val representation of Page metadata
|
||||
func (p Page) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "id", p.ID) |
||||
KeyValAdd(kv, "url", p.URL) |
||||
MergeKeyValWithPrefix(kv, KeyValFromMap(p.Attributes), "attr_") |
||||
return kv |
||||
} |
||||
|
||||
// App holds metadata about the application event originates from
|
||||
type App struct { |
||||
Name string `json:"name,omitempty"` |
||||
Release string `json:"release,omitempty"` |
||||
Version string `json:"version,omitempty"` |
||||
Environment string `json:"environment,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key-> value representation of App metadata
|
||||
func (a App) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "name", a.Name) |
||||
KeyValAdd(kv, "release", a.Release) |
||||
KeyValAdd(kv, "version", a.Version) |
||||
KeyValAdd(kv, "environment", a.Environment) |
||||
return kv |
||||
} |
||||
|
||||
// Browser holds metadata about a client's browser
|
||||
type Browser struct { |
||||
Name string `json:"name,omitempty"` |
||||
Version string `json:"version,omitempty"` |
||||
OS string `json:"os,omitempty"` |
||||
Mobile bool `json:"mobile,omitempty"` |
||||
} |
||||
|
||||
// KeyVal produces key->value representation of the Browser metadata
|
||||
func (b Browser) KeyVal() *KeyVal { |
||||
kv := NewKeyVal() |
||||
KeyValAdd(kv, "name", b.Name) |
||||
KeyValAdd(kv, "version", b.Version) |
||||
KeyValAdd(kv, "os", b.OS) |
||||
KeyValAdd(kv, "mobile", fmt.Sprintf("%v", b.Mobile)) |
||||
return kv |
||||
} |
@ -0,0 +1,56 @@ |
||||
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 { |
||||
return ex |
||||
} |
||||
frames := []Frame{} |
||||
|
||||
for _, frame := range ex.Stacktrace.Frames { |
||||
frame := frame |
||||
mappedFrame, err := ResolveSourceLocation(store, &frame) |
||||
if err != nil { |
||||
frames = append(frames, frame) |
||||
} else if mappedFrame != nil { |
||||
frames = append(frames, *mappedFrame) |
||||
} else { |
||||
frames = append(frames, frame) |
||||
} |
||||
} |
||||
|
||||
return &Exception{ |
||||
Type: ex.Type, |
||||
Value: ex.Value, |
||||
Stacktrace: &Stacktrace{Frames: frames}, |
||||
Timestamp: ex.Timestamp, |
||||
} |
||||
} |
@ -0,0 +1,29 @@ |
||||
package setting |
||||
|
||||
type GrafanaJavascriptAgent struct { |
||||
Enabled bool `json:"enabled"` |
||||
CustomEndpoint string `json:"customEndpoint"` |
||||
EndpointRPS int `json:"-"` |
||||
EndpointBurst int `json:"-"` |
||||
ErrorInstrumentalizationEnabled bool `json:"errorInstrumentalizationEnabled"` |
||||
ConsoleInstrumentalizationEnabled bool `json:"consoleInstrumentalizationEnabled"` |
||||
WebVitalsInstrumentalizationEnabled bool `json:"webVitalsInstrumentalizationEnabled"` |
||||
ApiKey string `json:"apiKey"` |
||||
} |
||||
|
||||
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(), |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,13 @@ |
||||
import { BaseTransport, TransportItem } from '@grafana/agent-core'; |
||||
import { getEchoSrv, EchoEventType } from '@grafana/runtime'; |
||||
export class EchoSrvTransport extends BaseTransport { |
||||
send(event: TransportItem) { |
||||
getEchoSrv().addEvent({ |
||||
type: EchoEventType.GrafanaJavascriptAgent, |
||||
payload: event, |
||||
}); |
||||
} |
||||
getIgnoreUrls() { |
||||
return []; |
||||
} |
||||
} |
@ -0,0 +1,229 @@ |
||||
import { BaseTransport } from '@grafana/agent-core'; |
||||
import { FetchTransport, initializeAgent } from '@grafana/agent-web'; |
||||
import { BuildInfo } from '@grafana/data'; |
||||
import { GrafanaEdition } from '@grafana/data/src/types/config'; |
||||
import { EchoEventType, EchoMeta } from '@grafana/runtime'; |
||||
|
||||
import { GrafanaJavascriptAgentBackend, GrafanaJavascriptAgentBackendOptions } from './GrafanaJavascriptAgentBackend'; |
||||
import { GrafanaJavascriptAgentEchoEvent } from './types'; |
||||
|
||||
jest.mock('@grafana/agent-web', () => { |
||||
const originalModule = jest.requireActual('@grafana/agent-web'); |
||||
return { |
||||
__esModule: true, |
||||
...originalModule, |
||||
initializeAgent: jest.fn(), |
||||
}; |
||||
}); |
||||
|
||||
describe('GrafanaJavascriptAgentEchoBackend', () => { |
||||
beforeEach(() => { |
||||
jest.resetAllMocks(); |
||||
window.fetch = jest.fn(); |
||||
jest.resetModules(); |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
const buildInfo: BuildInfo = { |
||||
version: '1.0', |
||||
commit: 'abcd123', |
||||
env: 'production', |
||||
edition: GrafanaEdition.OpenSource, |
||||
latestVersion: 'ba', |
||||
hasUpdate: false, |
||||
hideVersion: false, |
||||
}; |
||||
|
||||
const options: GrafanaJavascriptAgentBackendOptions = { |
||||
buildInfo, |
||||
app: { |
||||
version: '1.0', |
||||
}, |
||||
errorInstrumentalizationEnabled: true, |
||||
consoleInstrumentalizationEnabled: true, |
||||
webVitalsInstrumentalizationEnabled: true, |
||||
customEndpoint: '/log-grafana-javascript-agent', |
||||
user: { |
||||
email: 'darth.vader@sith.glx', |
||||
id: '504', |
||||
orgId: 1, |
||||
}, |
||||
}; |
||||
|
||||
it('will set up FetchTransport if customEndpoint is provided', async () => { |
||||
// arrange
|
||||
const originalModule = jest.requireActual('@grafana/agent-web'); |
||||
jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent); |
||||
|
||||
//act
|
||||
const backend = new GrafanaJavascriptAgentBackend(options); |
||||
|
||||
//assert
|
||||
expect(backend.transports.length).toEqual(1); |
||||
expect(backend.transports[0]).toBeInstanceOf(FetchTransport); |
||||
}); |
||||
|
||||
it('will initialize GrafanaJavascriptAgent and set user', async () => { |
||||
// arrange
|
||||
const mockedSetUser = jest.fn(); |
||||
const mockedAgent = () => { |
||||
return { |
||||
api: { |
||||
setUser: mockedSetUser, |
||||
pushLog: jest.fn(), |
||||
callOriginalConsoleMethod: jest.fn(), |
||||
pushError: jest.fn(), |
||||
pushMeasurement: jest.fn(), |
||||
pushTraces: jest.fn(), |
||||
initOTEL: jest.fn(), |
||||
getOTEL: jest.fn(), |
||||
getTraceContext: jest.fn(), |
||||
}, |
||||
config: { |
||||
globalObjectKey: '', |
||||
instrumentations: [], |
||||
preventGlobalExposure: false, |
||||
transports: [], |
||||
metas: [], |
||||
parseStacktrace: jest.fn(), |
||||
app: jest.fn(), |
||||
}, |
||||
metas: { |
||||
add: jest.fn(), |
||||
remove: jest.fn(), |
||||
value: {}, |
||||
}, |
||||
transports: { |
||||
add: jest.fn(), |
||||
execute: jest.fn(), |
||||
transports: [], |
||||
}, |
||||
}; |
||||
}; |
||||
jest.mocked(initializeAgent).mockImplementation(mockedAgent); |
||||
|
||||
//act
|
||||
new GrafanaJavascriptAgentBackend(options); |
||||
|
||||
//assert
|
||||
expect(initializeAgent).toHaveBeenCalledTimes(1); |
||||
expect(mockedSetUser).toHaveBeenCalledTimes(1); |
||||
expect(mockedSetUser).toHaveBeenCalledWith({ |
||||
id: '504', |
||||
email: 'darth.vader@sith.glx', |
||||
attributes: { |
||||
orgId: '1', |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('will forward events to transports', async () => { |
||||
//arrange
|
||||
const mockedSetUser = jest.fn(); |
||||
const mockedAgent = () => { |
||||
return { |
||||
api: { |
||||
setUser: mockedSetUser, |
||||
pushLog: jest.fn(), |
||||
callOriginalConsoleMethod: jest.fn(), |
||||
pushError: jest.fn(), |
||||
pushMeasurement: jest.fn(), |
||||
pushTraces: jest.fn(), |
||||
initOTEL: jest.fn(), |
||||
getOTEL: jest.fn(), |
||||
getTraceContext: jest.fn(), |
||||
}, |
||||
config: { |
||||
globalObjectKey: '', |
||||
instrumentations: [], |
||||
preventGlobalExposure: false, |
||||
transports: [], |
||||
metas: [], |
||||
parseStacktrace: jest.fn(), |
||||
app: jest.fn(), |
||||
}, |
||||
metas: { |
||||
add: jest.fn(), |
||||
remove: jest.fn(), |
||||
value: {}, |
||||
}, |
||||
transports: { |
||||
add: jest.fn(), |
||||
execute: jest.fn(), |
||||
transports: [], |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
jest.mocked(initializeAgent).mockImplementation(mockedAgent); |
||||
const backend = new GrafanaJavascriptAgentBackend({ |
||||
...options, |
||||
preventGlobalExposure: true, |
||||
}); |
||||
|
||||
backend.transports = [ |
||||
/* eslint-disable */ |
||||
{ send: jest.fn() } as unknown as BaseTransport, |
||||
{ send: jest.fn() } as unknown as BaseTransport, |
||||
]; |
||||
const event: GrafanaJavascriptAgentEchoEvent = { |
||||
type: EchoEventType.GrafanaJavascriptAgent, |
||||
payload: { foo: 'bar' } as unknown as GrafanaJavascriptAgentEchoEvent, |
||||
meta: {} as unknown as EchoMeta, |
||||
}; |
||||
/* eslint-enable */ |
||||
backend.addEvent(event); |
||||
backend.transports.forEach((transport) => { |
||||
expect(transport.send).toHaveBeenCalledTimes(1); |
||||
expect(transport.send).toHaveBeenCalledWith(event.payload); |
||||
}); |
||||
}); |
||||
|
||||
//@FIXME - make integration test work
|
||||
|
||||
// it('integration test with EchoSrv and GrafanaJavascriptAgent', async () => {
|
||||
// // sets up the whole thing between window.onerror and backend endpoint call, checks that error is reported
|
||||
// // use actual GrafanaJavascriptAgent & mock window.fetch
|
||||
|
||||
// // arrange
|
||||
// const originalModule = jest.requireActual('@grafana/agent-web');
|
||||
// jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent);
|
||||
// const fetchSpy = (window.fetch = jest.fn());
|
||||
// fetchSpy.mockResolvedValue({ status: 200 } as Response);
|
||||
// const echo = new Echo({ debug: true });
|
||||
|
||||
// // act
|
||||
// setEchoSrv(echo);
|
||||
// const grafanaJavascriptAgentBackend = new GrafanaJavascriptAgentBackend({
|
||||
// ...options,
|
||||
// preventGlobalExposure: true,
|
||||
// consoleInstrumentalizationEnabled: false,
|
||||
// webVitalsInstrumentalizationEnabled: false,
|
||||
// });
|
||||
// echo.addBackend(grafanaJavascriptAgentBackend);
|
||||
|
||||
// // lets add another echo backend for grafana javascript agent events for good measure
|
||||
// const myCustomErrorBackend: EchoBackend = {
|
||||
// supportedEvents: [EchoEventType.GrafanaJavascriptAgent],
|
||||
// flush: () => {},
|
||||
// options: {},
|
||||
// addEvent: jest.fn(),
|
||||
// };
|
||||
// echo.addBackend(myCustomErrorBackend);
|
||||
|
||||
// // fire off an error using global error handler, Grafana Javascript Agent should pick it up
|
||||
// const error = new Error('test error');
|
||||
// window.onerror!(error.message, undefined, undefined, undefined, error);
|
||||
|
||||
// // assert
|
||||
// // check that error was reported to backend
|
||||
// await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
||||
// const [url, reqInit]: [string, RequestInit] = fetchSpy.mock.calls[0];
|
||||
// expect(url).toEqual('/log-grafana-javascript-agent');
|
||||
// // expect((JSON.parse(reqInit.body as string) as EchoEvent).exception!.values![0].value).toEqual('test error');
|
||||
// console.log(JSON.parse(reqInit.body as string));
|
||||
|
||||
// // check that our custom backend got it too
|
||||
// expect(myCustomErrorBackend.addEvent).toHaveBeenCalledTimes(1);
|
||||
// });
|
||||
}); |
@ -0,0 +1,84 @@ |
||||
import { BaseTransport } from '@grafana/agent-core'; |
||||
import { |
||||
initializeAgent, |
||||
BrowserConfig, |
||||
ErrorsInstrumentation, |
||||
ConsoleInstrumentation, |
||||
WebVitalsInstrumentation, |
||||
FetchTransport, |
||||
} from '@grafana/agent-web'; |
||||
import { BuildInfo } from '@grafana/data'; |
||||
import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime'; |
||||
|
||||
import { EchoSrvTransport } from './EchoSrvTransport'; |
||||
import { GrafanaJavascriptAgentEchoEvent, User } from './types'; |
||||
|
||||
export interface GrafanaJavascriptAgentBackendOptions extends BrowserConfig { |
||||
buildInfo: BuildInfo; |
||||
customEndpoint: string; |
||||
user: User; |
||||
errorInstrumentalizationEnabled: boolean; |
||||
consoleInstrumentalizationEnabled: boolean; |
||||
webVitalsInstrumentalizationEnabled: boolean; |
||||
} |
||||
|
||||
export class GrafanaJavascriptAgentBackend |
||||
implements EchoBackend<GrafanaJavascriptAgentEchoEvent, GrafanaJavascriptAgentBackendOptions> |
||||
{ |
||||
supportedEvents = [EchoEventType.GrafanaJavascriptAgent]; |
||||
private agentInstance; |
||||
transports: BaseTransport[]; |
||||
|
||||
constructor(public options: GrafanaJavascriptAgentBackendOptions) { |
||||
// configure instrumentalizations
|
||||
const instrumentations = []; |
||||
this.transports = []; |
||||
|
||||
if (options.customEndpoint) { |
||||
this.transports.push(new FetchTransport({ url: options.customEndpoint, apiKey: options.apiKey })); |
||||
} |
||||
|
||||
if (options.errorInstrumentalizationEnabled) { |
||||
instrumentations.push(new ErrorsInstrumentation()); |
||||
} |
||||
if (options.consoleInstrumentalizationEnabled) { |
||||
instrumentations.push(new ConsoleInstrumentation()); |
||||
} |
||||
if (options.webVitalsInstrumentalizationEnabled) { |
||||
instrumentations.push(new WebVitalsInstrumentation()); |
||||
} |
||||
|
||||
// initialize GrafanaJavascriptAgent so it can set up it's hooks and start collecting errors
|
||||
const grafanaJavaScriptAgentOptions: BrowserConfig = { |
||||
globalObjectKey: options.globalObjectKey || 'grafanaAgent', |
||||
preventGlobalExposure: options.preventGlobalExposure || false, |
||||
app: { |
||||
version: options.buildInfo.version, |
||||
environment: options.buildInfo.env, |
||||
}, |
||||
instrumentations, |
||||
transports: [new EchoSrvTransport()], |
||||
}; |
||||
this.agentInstance = initializeAgent(grafanaJavaScriptAgentOptions); |
||||
|
||||
if (options.user) { |
||||
this.agentInstance.api.setUser({ |
||||
email: options.user.email, |
||||
id: options.user.id, |
||||
attributes: { |
||||
orgId: String(options.user.orgId) || '', |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
addEvent = (e: EchoEvent) => { |
||||
this.transports.forEach((t) => t.send(e.payload)); |
||||
}; |
||||
|
||||
// backend will log events to stdout, and at least in case of hosted grafana they will be
|
||||
// ingested into Loki. Due to Loki limitations logs cannot be backdated,
|
||||
// so not using buffering for this backend to make sure that events are logged as close
|
||||
// to their context as possible
|
||||
flush = () => {}; |
||||
} |
@ -0,0 +1,13 @@ |
||||
import { CurrentUserDTO } from '@grafana/data'; |
||||
import { EchoEvent, EchoEventType } from '@grafana/runtime'; |
||||
|
||||
export interface BaseTransport { |
||||
sendEvent(event: EchoEvent): PromiseLike<Response>; |
||||
} |
||||
|
||||
export type GrafanaJavascriptAgentEchoEvent = EchoEvent<EchoEventType.GrafanaJavascriptAgent>; |
||||
|
||||
export interface User extends Pick<CurrentUserDTO, 'email'> { |
||||
id: string; |
||||
orgId?: number; |
||||
} |
Loading…
Reference in new issue