Skip to content

Commit f7ef49a

Browse files
authored
Merge pull request #124 from fishbrain/add-sentry-support
Add support for Sentry error reporting
2 parents a7bd216 + 8746989 commit f7ef49a

5 files changed

Lines changed: 327 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# AGENTS.md — logging-go
2+
3+
## Overview
4+
5+
Shared logging library for Fishbrain's Go services. Single-package Go module (`package logging`) that wraps [logrus](https://github.com/sirupsen/logrus) with Bugsnag error reporting, Sentry error reporting, Datadog trace correlation, and NSQ log-level bridging.
6+
7+
**Module path**: `github.com/fishbrain/logging-go`
8+
9+
## Commands
10+
11+
| Task | Command |
12+
|-------|------------------|
13+
| Build | `go build ./...` |
14+
| Test | `go test ./...` |
15+
16+
There is no linter, formatter, or Makefile configured. CI (`go.yml`) runs `go build -v .` only — no test step in CI.
17+
18+
## Project Structure
19+
20+
```
21+
logging.go # All library code — types, logger init, entry helpers, Bugsnag/Sentry hooks
22+
logging_test.go # All tests
23+
go.mod / go.sum # Module definition (Go 1.24+, toolchain 1.26)
24+
.tool-versions # asdf version pinning (go 1.26.0)
25+
```
26+
27+
This is a **single-file library** — everything lives in `logging.go` and `logging_test.go`. No subdirectories, no `cmd/`, no `internal/`.
28+
29+
## Architecture & Key Types
30+
31+
### Global singleton
32+
33+
`Init(LoggingConfig)` initializes the package-level `Log *Logger` variable. It is guarded by a nil check (not a `sync.Once`), so it only runs once. `TestMain` calls `Init(LoggingConfig{})` to set up the singleton before tests run.
34+
35+
### Type hierarchy
36+
37+
- **`Logger`** — wraps `*logrus.Logger`. Provides `WithField`, `WithError`, `WithDDTrace`, `NewEntry`, and `NSQLogger`.
38+
- **`Entry`** — wraps `*logrus.Entry`. Provides domain-specific field helpers (`WithUser`, `WithEvent`, `WithChannel`, `WithDuration`, etc.) that return `*Entry` for chaining.
39+
- **`NSQLogger`** — adaptor that implements `Output(int, string) error` so it can be passed to `nsq.SetLogger`.
40+
- **`bugsnagHook`** — logrus hook that fires on Error/Fatal/Panic levels, forwarding to Bugsnag with metadata.
41+
- **`sentryHook`** — logrus hook that fires on Error/Fatal/Panic levels, forwarding to Sentry with metadata and extra fields.
42+
43+
### Initialization flow
44+
45+
```
46+
Init(config) →
47+
1. bugsnag.Configure(...) — sets up Bugsnag client
48+
2. bugsnag.OnBeforeNotify(...) — unwraps *fmt.wrapError to get real error class
49+
3. sentry.Init(...) — sets up Sentry client (if SentryDSN is set and environment matches ErrorNotifyReleaseStages)
50+
4. Log = new(true, withSentry, config) — creates Logger with Bugsnag and optionally Sentry hooks attached
51+
```
52+
53+
## Key Dependencies
54+
55+
| Dependency | Purpose |
56+
|---|---|
57+
| `github.com/sirupsen/logrus` | Structured logging (JSON formatter) |
58+
| `github.com/bugsnag/bugsnag-go/v2` | Error reporting to Bugsnag |
59+
| `github.com/DataDog/dd-trace-go/v2` | Datadog APM trace/span ID injection |
60+
| `github.com/getsentry/sentry-go` | Error reporting to Sentry |
61+
| `github.com/nsqio/go-nsq` | NSQ message queue log-level bridging |
62+
| `github.com/stretchr/testify` | Test assertions |
63+
64+
## Code Patterns & Conventions
65+
66+
### Fluent entry builder
67+
68+
All `With*` methods return `*Entry` to support chaining:
69+
70+
```go
71+
Log.WithDDTrace(ctx).WithUser(userID).WithDuration(d).Info("processed request")
72+
```
73+
74+
When adding new field helpers, follow this pattern: method on `*Entry`, return `*Entry`, delegate to `e.WithField(...)`.
75+
76+
### Error wrapping
77+
78+
Errors passed to `WithError` are wrapped with `bugsnag_errors.New(err, 1)` to capture stack traces. The `1` parameter controls stack frame skipping. The standalone `Errorf` and `ErrorWithStacktrace` functions also use this pattern.
79+
80+
### JSON log output
81+
82+
Logrus is configured with `JSONFormatter` and custom field mapping:
83+
- `msg``message`
84+
- `func``logger.method_name`
85+
- `file``logger.name`
86+
- `error` key → `error.message`
87+
- Timestamp format: `RFC3339Nano`
88+
89+
### Log levels
90+
91+
The `LogLevel` config string must be uppercase: `"ERROR"`, `"WARNING"`, `"INFO"`, `"DEBUG"`. Unknown values default to `InfoLevel`.
92+
93+
### NSQ log bridging
94+
95+
`Logger.NSQLogger()` returns an `(NSQLogger, nsq.LogLevel)` tuple for plugging into `nsq.SetLogger`. The `NSQLogger.Output` method parses the 3-character prefix from NSQ log messages to route them to the correct logrus level.
96+
97+
## Testing
98+
99+
- **Framework**: stdlib `testing` + `testify/assert`
100+
- **Setup**: `TestMain` initializes the global `Log` singleton via `Init(LoggingConfig{})`
101+
- **Log capture**: Tests use `os.Pipe()` to capture log output by swapping `Log.Out`, then assert on the captured string content
102+
- **Concurrency test**: `TestConcurrentUseOfEntry` verifies entries are safe for concurrent use across goroutines
103+
- **Table-driven tests**: `TestGetLogrusLogLevel` uses a table-driven approach with a package-level test data slice
104+
- **Sentry hook tests**: `TestSentryHookFire`, `TestSentryHookLevels`, `TestNewWithSentry`, and `TestNewWithoutSentry` cover the Sentry hook and its integration into the logger
105+
- **Release-stage gating tests**: `TestShouldNotify` verifies the `shouldNotify` helper used for conditional Sentry/Bugsnag activation
106+
107+
## Gotchas
108+
109+
1. **No CI test step**: The GitHub Actions workflow builds but does not run tests. Running `go test ./...` locally is essential before pushing.
110+
2. **Singleton guard is not sync.Once**: `Init` uses `if nil == Log` — safe for single-goroutine init, but not for concurrent callers. In practice this is fine since `Init` is called once at service startup.
111+
3. **`ioutil.ReadAll` in tests**: Tests use the deprecated `io/ioutil` package. New code should use `io.ReadAll` instead.
112+
4. **Bugsnag error unwrapping limit**: The `OnBeforeNotify` handler unwraps `*fmt.wrapError` chains up to 11 levels deep, then logs and stops.
113+
5. **`logrus.ErrorKey` is mutated globally**: `new()` sets `logrus.ErrorKey = "error.message"` as a side effect — this affects all logrus loggers in the process, not just this one.
114+
6. **Reversed nil check style**: The codebase uses Yoda conditions (`nil == Log`) in the `Init` function.
115+
7. **`BugsnagNotifyReleaseStages` renamed**: The config field was renamed to `ErrorNotifyReleaseStages` and is now shared between Bugsnag and Sentry for release-stage gating.
116+
8. **Sentry is conditional**: Sentry is only initialized when `SentryDSN` is non-empty and the current `Environment` is in `ErrorNotifyReleaseStages`. If `sentry.Init` fails, it logs to stderr and proceeds without the Sentry hook.
117+
118+
## Releasing
119+
120+
Create a GitHub Release. The module is imported by other Fishbrain Go services via its module path. Versioning follows Go module semantics (semver tags).
121+
122+
## Ownership
123+
124+
Owned by `@fishbrain/platform-team` (see `CODEOWNERS`). Dependency updates managed by Renovate (see `renovate.json`).

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
3737
github.com/dustin/go-humanize v1.0.1 // indirect
3838
github.com/ebitengine/purego v0.8.4 // indirect
39+
github.com/getsentry/sentry-go v0.43.0 // indirect
3940
github.com/go-logr/logr v1.4.3 // indirect
4041
github.com/go-logr/stdr v1.2.2 // indirect
4142
github.com/go-ole/go-ole v1.3.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
6060
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
6161
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
6262
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
63+
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
64+
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
6365
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
6466
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
6567
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=

logging.go

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
1414
"github.com/bugsnag/bugsnag-go/v2"
1515
bugsnag_errors "github.com/bugsnag/bugsnag-go/v2/errors"
16+
"github.com/getsentry/sentry-go"
1617
nsq "github.com/nsqio/go-nsq"
1718
"github.com/sirupsen/logrus"
1819
)
@@ -26,12 +27,13 @@ var (
2627
)
2728

2829
type LoggingConfig struct {
29-
LogLevel string
30-
Environment string
31-
AppVersion string
32-
BugsnagAPIKey string
33-
BugsnagNotifyReleaseStages []string
34-
BugsnagProjectPackages []string
30+
LogLevel string
31+
Environment string
32+
AppVersion string
33+
BugsnagAPIKey string
34+
ErrorNotifyReleaseStages []string
35+
BugsnagProjectPackages []string
36+
SentryDSN string
3537
}
3638

3739
type Logger struct {
@@ -40,6 +42,8 @@ type Logger struct {
4042

4143
type bugsnagHook struct{}
4244

45+
type sentryHook struct{}
46+
4347
func (l Logger) getNSQLogLevel() nsq.LogLevel {
4448
switch l.Level {
4549
case logrus.DebugLevel:
@@ -279,7 +283,53 @@ func (b *bugsnagHook) Levels() []logrus.Level {
279283
}
280284
}
281285

282-
func new(withBugsnag bool, config LoggingConfig) *Logger {
286+
func (s *sentryHook) Fire(entry *logrus.Entry) error {
287+
var notifyErr error
288+
switch err := entry.Data[logrus.ErrorKey].(type) {
289+
case *bugsnag_errors.Error:
290+
notifyErr = err
291+
case error:
292+
if entry.Message != "" {
293+
notifyErr = fmt.Errorf("%s: %w", entry.Message, err)
294+
} else {
295+
notifyErr = err
296+
}
297+
default:
298+
notifyErr = fmt.Errorf("%s", entry.Message)
299+
}
300+
301+
event := sentry.NewEvent()
302+
event.Level = sentry.LevelError
303+
if entry.Level == logrus.FatalLevel {
304+
event.Level = sentry.LevelFatal
305+
}
306+
event.Message = notifyErr.Error()
307+
event.Exception = []sentry.Exception{{
308+
Type: reflect.TypeOf(notifyErr).String(),
309+
Value: notifyErr.Error(),
310+
}}
311+
312+
extra := make(map[string]interface{})
313+
for key, val := range entry.Data {
314+
if key != logrus.ErrorKey {
315+
extra[key] = val
316+
}
317+
}
318+
event.Extra = extra
319+
320+
sentry.CaptureEvent(event)
321+
return nil
322+
}
323+
324+
func (s *sentryHook) Levels() []logrus.Level {
325+
return []logrus.Level{
326+
logrus.ErrorLevel,
327+
logrus.FatalLevel,
328+
logrus.PanicLevel,
329+
}
330+
}
331+
332+
func new(withBugsnag bool, withSentry bool, config LoggingConfig) *Logger {
283333
log := logrus.New()
284334
logrus.ErrorKey = "error.message"
285335
log.Formatter = &logrus.JSONFormatter{
@@ -296,18 +346,31 @@ func new(withBugsnag bool, config LoggingConfig) *Logger {
296346
log.Hooks.Add(&bugsnagHook{})
297347
}
298348

349+
if withSentry {
350+
log.Hooks.Add(&sentryHook{})
351+
}
352+
299353
return &Logger{log}
300354
}
301355

356+
func shouldNotify(releaseStages []string, environment string) bool {
357+
for _, stage := range releaseStages {
358+
if stage == environment {
359+
return true
360+
}
361+
}
362+
return false
363+
}
364+
302365
func Init(config LoggingConfig) {
303366
if nil == Log {
304367
bugsnag.Configure(bugsnag.Configuration{
305368
APIKey: config.BugsnagAPIKey,
306369
ReleaseStage: config.Environment,
307370
AppVersion: config.AppVersion,
308-
NotifyReleaseStages: config.BugsnagNotifyReleaseStages,
371+
NotifyReleaseStages: config.ErrorNotifyReleaseStages,
309372
ProjectPackages: config.BugsnagProjectPackages,
310-
Logger: stdlog.New(new(false, config).Writer(), "bugsnag: ", 0),
373+
Logger: stdlog.New(new(false, false, config).Writer(), "bugsnag: ", 0),
311374
})
312375
bugsnag.OnBeforeNotify(
313376
func(event *bugsnag.Event, config *bugsnag.Configuration) error {
@@ -334,6 +397,21 @@ func Init(config LoggingConfig) {
334397
event.ErrorClass = errClass
335398
return nil
336399
})
337-
Log = new(true, config)
400+
401+
withSentry := false
402+
if config.SentryDSN != "" && shouldNotify(config.ErrorNotifyReleaseStages, config.Environment) {
403+
err := sentry.Init(sentry.ClientOptions{
404+
Dsn: config.SentryDSN,
405+
Environment: config.Environment,
406+
Release: config.AppVersion,
407+
})
408+
if err != nil {
409+
stdlog.Printf("sentry.Init: %s", err)
410+
} else {
411+
withSentry = true
412+
}
413+
}
414+
415+
Log = new(true, withSentry, config)
338416
}
339417
}

0 commit comments

Comments
 (0)