mirror of
https://github.com/grafana/grafana.git
synced 2026-08-11 13:44:59 -05:00
Logging: sourcemap support for frontend stacktraces (#30590)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package frontendlogging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/inconshreveable/log15"
|
||||
)
|
||||
|
||||
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 {
|
||||
var stacktraces []string
|
||||
for _, value := range exception.Values {
|
||||
stacktraces = append(stacktraces, value.FmtStacktrace(store))
|
||||
}
|
||||
return strings.Join(stacktraces, "\n\n")
|
||||
}
|
||||
|
||||
func addEventContextToLogContext(rootPrefix string, logCtx log15.Ctx, 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[prefix] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (event *FrontendSentryEvent) ToLogContext(store *SourceMapStore) log15.Ctx {
|
||||
var ctx = make(log15.Ctx)
|
||||
ctx["url"] = event.Request.URL
|
||||
ctx["user_agent"] = event.Request.Headers["User-Agent"]
|
||||
ctx["event_id"] = event.EventID
|
||||
ctx["original_timestamp"] = event.Timestamp
|
||||
if event.Exception != nil {
|
||||
ctx["stacktrace"] = event.Exception.FmtStacktraces(store)
|
||||
}
|
||||
addEventContextToLogContext("context", ctx, event.Contexts)
|
||||
if len(event.User.Email) > 0 {
|
||||
ctx["user_email"] = event.User.Email
|
||||
ctx["user_id"] = event.User.ID
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package frontendlogging
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
sourcemap "github.com/go-sourcemap/sourcemap"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type sourceMapLocation struct {
|
||||
dir string
|
||||
path string
|
||||
pluginID string
|
||||
}
|
||||
|
||||
type sourceMap struct {
|
||||
consumer *sourcemap.Consumer
|
||||
pluginID string
|
||||
}
|
||||
|
||||
type ReadSourceMapFn func(dir string, path string) ([]byte, error)
|
||||
|
||||
func ReadSourceMapFromFS(dir string, path string) ([]byte, error) {
|
||||
file, err := http.Dir(dir).Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error("Failed to close source map file", "err", err)
|
||||
}
|
||||
}()
|
||||
return ioutil.ReadAll(file)
|
||||
}
|
||||
|
||||
type SourceMapStore struct {
|
||||
cache map[string]*sourceMap
|
||||
cfg *setting.Cfg
|
||||
readSourceMap ReadSourceMapFn
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func NewSourceMapStore(cfg *setting.Cfg, readSourceMap ReadSourceMapFn) *SourceMapStore {
|
||||
return &SourceMapStore{
|
||||
cache: make(map[string]*sourceMap),
|
||||
cfg: cfg,
|
||||
readSourceMap: readSourceMap,
|
||||
}
|
||||
}
|
||||
|
||||
/* guessSourceMapLocation will attempt to guess location of a source map on fs.
|
||||
* it does not read the source file or make any web requests,
|
||||
* just assumes that a [source filename].map file might exist in the same dir as the source file
|
||||
* and only considers sources coming from grafana core or plugins`
|
||||
*/
|
||||
func (store *SourceMapStore) guessSourceMapLocation(sourceURL string) (*sourceMapLocation, error) {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// determine if source comes from grafana core, look in public build dir
|
||||
if strings.HasPrefix(u.Path, "/public/build/") {
|
||||
return &sourceMapLocation{
|
||||
dir: store.cfg.StaticRootPath,
|
||||
path: filepath.Join("build", u.Path[len("/public/build/"):]) + ".map",
|
||||
pluginID: "",
|
||||
}, nil
|
||||
// if source comes from a plugin, look in plugin dir
|
||||
} else if strings.HasPrefix(u.Path, "/public/plugins/") {
|
||||
for _, route := range plugins.StaticRoutes {
|
||||
pluginPrefix := filepath.Join("/public/plugins/", route.PluginId)
|
||||
if strings.HasPrefix(u.Path, pluginPrefix) {
|
||||
return &sourceMapLocation{
|
||||
dir: route.Directory,
|
||||
path: u.Path[len(pluginPrefix):] + ".map",
|
||||
pluginID: route.PluginId,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (store *SourceMapStore) getSourceMap(sourceURL string) (*sourceMap, error) {
|
||||
store.Lock()
|
||||
defer store.Unlock()
|
||||
|
||||
if smap, ok := store.cache[sourceURL]; ok {
|
||||
return smap, nil
|
||||
}
|
||||
sourceMapLocation, err := store.guessSourceMapLocation(sourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceMapLocation == nil {
|
||||
// Cache nil value for sourceURL, since we want to flag that we couldn't guess the map location and not try again
|
||||
store.cache[sourceURL] = nil
|
||||
return nil, nil
|
||||
}
|
||||
path := strings.ReplaceAll(sourceMapLocation.path, "../", "") // just in case
|
||||
b, err := store.readSourceMap(sourceMapLocation.dir, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Cache nil value for sourceURL, since we want to flag that it wasn't found in the filesystem and not try again
|
||||
store.cache[sourceURL] = nil
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
consumer, err := sourcemap.Parse(sourceURL+".map", b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
smap := &sourceMap{
|
||||
consumer: consumer,
|
||||
pluginID: sourceMapLocation.pluginID,
|
||||
}
|
||||
store.cache[sourceURL] = smap
|
||||
return smap, nil
|
||||
}
|
||||
|
||||
func (store *SourceMapStore) resolveSourceLocation(frame sentry.Frame) (*sentry.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 = "?"
|
||||
}
|
||||
module := "core"
|
||||
if len(smap.pluginID) > 0 {
|
||||
module = smap.pluginID
|
||||
}
|
||||
return &sentry.Frame{
|
||||
Filename: file,
|
||||
Lineno: line,
|
||||
Colno: col,
|
||||
Function: function,
|
||||
Module: module,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"some_source.js","sourceRoot":"","sources":["webpack:///./some_source.ts"],"names":[],"mappings":"AAAA,SAAS,GAAG;IACV,IAAM,GAAG,GAAG,GAAG,CAAC;IAChB,OAAO,GAAG,CAAC;AACb,CAAC"}
|
||||
Reference in New Issue
Block a user