Logging: sourcemap support for frontend stacktraces (#30590)

This commit is contained in:
Domas
2021-02-04 08:37:28 +02:00
committed by GitHub
parent 7470265949
commit 21817055bd
8 changed files with 430 additions and 112 deletions
+95
View File
@@ -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
}
+161
View File
@@ -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"}