package logger import ( "io" "time" "octopus/internal/config" // "github.com/buger/jsonparser" "github.com/getsentry/sentry-go" "github.com/rs/zerolog" "github.com/tidwall/gjson" ) var levelsMapping = map[zerolog.Level]sentry.Level{ zerolog.DebugLevel: sentry.LevelDebug, zerolog.InfoLevel: sentry.LevelInfo, zerolog.WarnLevel: sentry.LevelWarning, zerolog.ErrorLevel: sentry.LevelError, zerolog.FatalLevel: sentry.LevelFatal, zerolog.PanicLevel: sentry.LevelFatal, } var _ = io.WriteCloser(new(SentryWriter)) var now = time.Now // SentryWriter is a sentry events writer with std io.SentryWriter iface. type SentryWriter struct { client *sentry.Client levels map[zerolog.Level]struct{} flushTimeout time.Duration } // Write handles zerolog's json and sends events to sentry. func (w *SentryWriter) Write(data []byte) (int, error) { event, ok := w.parseLogEvent(data) if ok { w.client.CaptureEvent(event, nil, nil) // should flush before os.Exit if event.Level == sentry.LevelFatal { w.client.Flush(w.flushTimeout) } } return len(data), nil } // Close forces client to flush all pending events. // Can be useful before application exits. func (w *SentryWriter) Close() error { w.client.Flush(w.flushTimeout) return nil } func (w *SentryWriter) parseLogEvent(data []byte) (*sentry.Event, bool) { const logger = "zerolog" lvlStr := gjson.GetBytes(data, zerolog.LevelFieldName) lvl, err := zerolog.ParseLevel(lvlStr.String()) if err != nil { return nil, false } _, enabled := w.levels[lvl] if !enabled { return nil, false } sentryLvl, ok := levelsMapping[lvl] if !ok { return nil, false } event := sentry.Event{ Timestamp: now(), Level: sentryLvl, Logger: logger, Tags: make(map[string]string, 6), Request: &sentry.Request{}, } gjson.ParseBytes(data).ForEach(func(key, value gjson.Result) bool { switch key.String() { // case zerolog.LevelFieldName, zerolog.TimestampFieldName: case zerolog.MessageFieldName: event.Message = value.String() case zerolog.ErrorFieldName: event.Exception = append(event.Exception, sentry.Exception{ Value: value.String(), Stacktrace: newStacktrace(), }) case config.LogTagURL: event.Request.URL = value.String() case config.LogTagMethod: event.Request.Method = value.String() case config.LogTagHeaders: headers := make(map[string]string) value.ForEach(func(key, value gjson.Result) bool { headers[key.String()] = value.String() return true }) event.Request.Headers = headers case config.LogTagData: event.Request.Data = value.String() case config.LogTagAuthorization: event.Tags["Authorization"] = value.String() case config.LogTagBID: event.Tags["bid"] = value.String() case config.LogTagStaffID: event.Tags["staff_id"] = value.String() case config.LogTagTraceID: event.Tags["trace_id"] = value.String() default: event.Tags[key.String()] = value.String() } return true }) return &event, true } func newStacktrace() *sentry.Stacktrace { const ( module = "github.com/archdx/zerolog-sentry" loggerModule = "github.com/rs/zerolog" ) st := sentry.NewStacktrace() threshold := len(st.Frames) - 1 // drop current module frames for ; threshold > 0 && st.Frames[threshold].Module == module; threshold-- { } outer: // try to drop zerolog module frames after logger call point for i := threshold; i > 0; i-- { if st.Frames[i].Module == loggerModule { for j := i - 1; j >= 0; j-- { if st.Frames[j].Module != loggerModule { threshold = j break outer } } break } } st.Frames = st.Frames[:threshold+1] return st } // WriterOption configures sentry events writer. type WriterOption interface { apply(*options) } type optionFunc func(*options) func (fn optionFunc) apply(c *options) { fn(c) } type options struct { release string environment string serverName string levels []zerolog.Level ignoreErrors []string sampleRate float64 flushTimeout time.Duration debug bool } // WithLevels configures zerolog levels that have to be sent to Sentry. // Default levels are: error, fatal, panic. func WithLevels(levels ...zerolog.Level) WriterOption { return optionFunc(func(cfg *options) { cfg.levels = levels }) } // WithSampleRate configures the sample rate as a percentage of events to be sent in the range of 0.0 to 1.0. func WithSampleRate(rate float64) WriterOption { return optionFunc(func(cfg *options) { cfg.sampleRate = rate }) } // WithRelease configures the release to be sent with events. func WithRelease(release string) WriterOption { return optionFunc(func(cfg *options) { cfg.release = release }) } // WithEnvironment configures the environment to be sent with events. func WithEnvironment(environment string) WriterOption { return optionFunc(func(cfg *options) { cfg.environment = environment }) } // WithServerName configures the server name field for events. Default value is OS hostname. func WithServerName(serverName string) WriterOption { return optionFunc(func(cfg *options) { cfg.serverName = serverName }) } // WithIgnoreErrors configures the list of regexp strings that will be used to match against event's message // and if applicable, caught errors type and value. If the match is found, then a whole event will be dropped. func WithIgnoreErrors(reList []string) WriterOption { return optionFunc(func(cfg *options) { cfg.ignoreErrors = reList }) } // WithDebug enables sentry client debug logs. func WithDebug(debug bool) WriterOption { return optionFunc(func(cfg *options) { cfg.debug = debug }) } // NewSentryWriter creates writer with provided DSN and options. func NewSentryWriter(dsn string, opts ...WriterOption) (*SentryWriter, error) { cfg := newDefaultConfig() for _, opt := range opts { opt.apply(&cfg) } client, err := sentry.NewClient(sentry.ClientOptions{ Dsn: dsn, SampleRate: cfg.sampleRate, Release: cfg.release, Environment: cfg.environment, ServerName: cfg.serverName, IgnoreErrors: cfg.ignoreErrors, Debug: cfg.debug, }) if err != nil { return nil, err } levels := make(map[zerolog.Level]struct{}, len(cfg.levels)) for _, lvl := range cfg.levels { levels[lvl] = struct{}{} } return &SentryWriter{ client: client, levels: levels, flushTimeout: cfg.flushTimeout, }, nil } func newDefaultConfig() options { return options{ levels: []zerolog.Level{ zerolog.ErrorLevel, zerolog.FatalLevel, zerolog.PanicLevel, }, sampleRate: 1.0, flushTimeout: 3 * time.Second, } }