Skip to content

Commit 9a43e98

Browse files
authored
fix: uniform gin context errors, fire-and-forget context safety, zerolog GORM adapter (#639)
* refactor(storage): replace stdlib GORM logger with zerolog adapter The stdlib stderr logger introduced in the previous commit emitted unstructured plain-text lines on stderr, inconsistent with the rest of the app. The zerolog adapter implements logger.Interface directly, routing all GORM diagnostics (errors, slow queries, SQL traces) as structured JSON through the app's own zerolog instance. Stdout safety is preserved — and now explicitly regression-anchored by a unit test that pipes os.Stdout and asserts nothing is written, directly encoding the invariant that protects the MCP stdio stream. * fix: apply context.WithoutCancel to HTTP handler goroutines and fix errcheck lint - oauth_callback.go and verify_email.go fire-and-forget goroutines now use context.WithoutCancel so session writes and webhook events survive after the request handler returns and Gin cancels the request context - Capture UserAgent/IP before goroutine launch to avoid referencing the gin.Context after it may be recycled - Fix errcheck lint: check errors from os.Pipe w.Close() and bytes.Buffer.ReadFrom() in gorm_logger_test.go
1 parent 6900573 commit 9a43e98

5 files changed

Lines changed: 311 additions & 32 deletions

File tree

internal/http_handlers/oauth_callback.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package http_handlers
22

33
import (
4+
"context"
45
"encoding/json"
56
"errors"
67
"fmt"
@@ -352,18 +353,21 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc {
352353
_ = h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt)
353354
}
354355

356+
bgCtx := context.WithoutCancel(ctx)
357+
userAgent := utils.GetUserAgent(ctx.Request)
358+
ip := utils.GetIP(ctx.Request)
355359
go func() {
356360
if isSignUp {
357-
_ = h.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, provider, user)
361+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserSignUpWebhookEvent, provider, user)
358362
// User is also logged in with signup
359-
_ = h.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, provider, user)
363+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserLoginWebhookEvent, provider, user)
360364
} else {
361-
_ = h.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, provider, user)
365+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserLoginWebhookEvent, provider, user)
362366
}
363-
if err := h.StorageProvider.AddSession(ctx, &schemas.Session{
367+
if err := h.StorageProvider.AddSession(bgCtx, &schemas.Session{
364368
UserID: user.ID,
365-
UserAgent: utils.GetUserAgent(ctx.Request),
366-
IP: utils.GetIP(ctx.Request),
369+
UserAgent: userAgent,
370+
IP: ip,
367371
}); err != nil {
368372
log.Debug().Err(err).Msg("Failed to add session")
369373
}

internal/http_handlers/verify_email.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package http_handlers
22

33
import (
4+
"context"
45
"net/http"
56
"strconv"
67
"strings"
@@ -225,18 +226,21 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc {
225226
IPAddress: utils.GetIP(c.Request),
226227
UserAgent: utils.GetUserAgent(c.Request),
227228
})
229+
bgCtx := context.WithoutCancel(c)
230+
userAgent := utils.GetUserAgent(c.Request)
231+
ip := utils.GetIP(c.Request)
228232
go func() {
229233
if isSignUp {
230-
_ = h.EventsProvider.RegisterEvent(c, constants.UserSignUpWebhookEvent, loginMethod, user)
234+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserSignUpWebhookEvent, loginMethod, user)
231235
// User is also logged in with signup
232-
_ = h.EventsProvider.RegisterEvent(c, constants.UserLoginWebhookEvent, loginMethod, user)
236+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserLoginWebhookEvent, loginMethod, user)
233237
} else {
234-
_ = h.EventsProvider.RegisterEvent(c, constants.UserLoginWebhookEvent, loginMethod, user)
238+
_ = h.EventsProvider.RegisterEvent(bgCtx, constants.UserLoginWebhookEvent, loginMethod, user)
235239
}
236-
if err := h.StorageProvider.AddSession(c, &schemas.Session{
240+
if err := h.StorageProvider.AddSession(bgCtx, &schemas.Session{
237241
UserID: user.ID,
238-
UserAgent: utils.GetUserAgent(c.Request),
239-
IP: utils.GetIP(c.Request),
242+
UserAgent: userAgent,
243+
IP: ip,
240244
}); err != nil {
241245
log.Debug().Err(err).Msg("Error adding session")
242246
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package sql
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
8+
"github.com/rs/zerolog"
9+
"gorm.io/gorm/logger"
10+
)
11+
12+
// zerologGORMLogger adapts zerolog to GORM's logger.Interface so that all GORM
13+
// diagnostics (errors, slow queries, statement traces) are emitted as structured
14+
// JSON on the same logger as the rest of the application — never on os.Stdout.
15+
//
16+
// This is critical for the MCP stdio server, which uses stdout as its JSON-RPC
17+
// transport: any stray plain-text line from GORM's default os.Stdout logger
18+
// would corrupt the stream.
19+
type zerologGORMLogger struct {
20+
log zerolog.Logger
21+
level logger.LogLevel
22+
slowThreshold time.Duration
23+
ignoreRecordNotFound bool
24+
}
25+
26+
func newZerologGORMLogger(log *zerolog.Logger) logger.Interface {
27+
return &zerologGORMLogger{
28+
log: log.With().Str("component", "gorm").Logger(),
29+
level: logger.Warn,
30+
slowThreshold: 200 * time.Millisecond,
31+
ignoreRecordNotFound: true,
32+
}
33+
}
34+
35+
func (l *zerologGORMLogger) LogMode(level logger.LogLevel) logger.Interface {
36+
clone := *l
37+
clone.level = level
38+
return &clone
39+
}
40+
41+
func (l *zerologGORMLogger) Info(_ context.Context, msg string, args ...interface{}) {
42+
if l.level >= logger.Info {
43+
l.log.Info().Msgf(msg, args...)
44+
}
45+
}
46+
47+
func (l *zerologGORMLogger) Warn(_ context.Context, msg string, args ...interface{}) {
48+
if l.level >= logger.Warn {
49+
l.log.Warn().Msgf(msg, args...)
50+
}
51+
}
52+
53+
func (l *zerologGORMLogger) Error(_ context.Context, msg string, args ...interface{}) {
54+
if l.level >= logger.Error {
55+
l.log.Error().Msgf(msg, args...)
56+
}
57+
}
58+
59+
// Trace logs SQL statements. Errors surface at error level, slow queries at
60+
// warn level, and everything else at debug level (only when LogLevel >= Info).
61+
func (l *zerologGORMLogger) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) {
62+
if l.level <= logger.Silent {
63+
return
64+
}
65+
elapsed := time.Since(begin)
66+
sql, rows := fc()
67+
68+
switch {
69+
case err != nil && l.level >= logger.Error &&
70+
(!errors.Is(err, logger.ErrRecordNotFound) || !l.ignoreRecordNotFound):
71+
l.log.Error().
72+
Err(err).
73+
Str("sql", sql).
74+
Int64("rows", rows).
75+
Dur("duration", elapsed).
76+
Msg("gorm error")
77+
78+
case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.level >= logger.Warn:
79+
l.log.Warn().
80+
Str("sql", sql).
81+
Int64("rows", rows).
82+
Dur("duration", elapsed).
83+
Str("slow_threshold", l.slowThreshold.String()).
84+
Msg("slow sql")
85+
86+
case l.level >= logger.Info:
87+
l.log.Debug().
88+
Str("sql", sql).
89+
Int64("rows", rows).
90+
Dur("duration", elapsed).
91+
Msg("sql")
92+
}
93+
}
94+
95+
// compile-time assertion that zerologGORMLogger satisfies the interface.
96+
var _ logger.Interface = (*zerologGORMLogger)(nil)
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package sql
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"os"
9+
"testing"
10+
"time"
11+
12+
"github.com/rs/zerolog"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"gorm.io/gorm/logger"
16+
)
17+
18+
// newTestLogger returns a zerologGORMLogger that writes to buf and the
19+
// underlying zerolog.Logger so callers can inspect emitted JSON.
20+
func newTestLogger(buf *bytes.Buffer) logger.Interface {
21+
zl := zerolog.New(buf).With().Timestamp().Logger()
22+
return newZerologGORMLogger(&zl)
23+
}
24+
25+
func loggedFields(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
26+
t.Helper()
27+
var m map[string]interface{}
28+
require.NoError(t, json.Unmarshal(buf.Bytes(), &m), "invalid JSON from logger: %s", buf.String())
29+
return m
30+
}
31+
32+
func TestZerologGORMLogger_Trace(t *testing.T) {
33+
ctx := context.Background()
34+
35+
t.Run("normal query at Info level emits debug message", func(t *testing.T) {
36+
buf := &bytes.Buffer{}
37+
l := newTestLogger(buf).LogMode(logger.Info)
38+
39+
l.Trace(ctx, time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
40+
41+
fields := loggedFields(t, buf)
42+
assert.Equal(t, "debug", fields["level"])
43+
assert.Equal(t, "sql", fields["message"])
44+
assert.Equal(t, "SELECT 1", fields["sql"])
45+
})
46+
47+
t.Run("normal query at Warn level emits nothing", func(t *testing.T) {
48+
buf := &bytes.Buffer{}
49+
l := newTestLogger(buf) // default level = Warn
50+
51+
l.Trace(ctx, time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
52+
53+
assert.Empty(t, buf.Bytes(), "no output expected at Warn level for a fast non-error query")
54+
})
55+
56+
t.Run("slow query emits warn message", func(t *testing.T) {
57+
buf := &bytes.Buffer{}
58+
l := newTestLogger(buf) // default slowThreshold = 200ms
59+
60+
begin := time.Now().Add(-500 * time.Millisecond)
61+
l.Trace(ctx, begin, func() (string, int64) { return "SELECT slow", 10 }, nil)
62+
63+
fields := loggedFields(t, buf)
64+
assert.Equal(t, "warn", fields["level"])
65+
assert.Equal(t, "slow sql", fields["message"])
66+
assert.Equal(t, "SELECT slow", fields["sql"])
67+
assert.NotNil(t, fields["slow_threshold"])
68+
})
69+
70+
t.Run("real error emits error message", func(t *testing.T) {
71+
buf := &bytes.Buffer{}
72+
l := newTestLogger(buf)
73+
74+
l.Trace(ctx, time.Now(), func() (string, int64) { return "INSERT bad", 0 }, errors.New("constraint violation"))
75+
76+
fields := loggedFields(t, buf)
77+
assert.Equal(t, "error", fields["level"])
78+
assert.Equal(t, "gorm error", fields["message"])
79+
assert.Equal(t, "INSERT bad", fields["sql"])
80+
assert.Contains(t, fields["error"], "constraint violation")
81+
})
82+
83+
t.Run("ErrRecordNotFound is suppressed by default", func(t *testing.T) {
84+
buf := &bytes.Buffer{}
85+
l := newTestLogger(buf)
86+
87+
l.Trace(ctx, time.Now(), func() (string, int64) { return "SELECT missing", 0 }, logger.ErrRecordNotFound)
88+
89+
assert.Empty(t, buf.Bytes(), "ErrRecordNotFound should be silenced when ignoreRecordNotFound=true")
90+
})
91+
92+
t.Run("ErrRecordNotFound surfaces when ignoreRecordNotFound=false", func(t *testing.T) {
93+
buf := &bytes.Buffer{}
94+
zl := zerolog.New(buf)
95+
l := &zerologGORMLogger{
96+
log: zl.With().Str("component", "gorm").Logger(),
97+
level: logger.Error,
98+
slowThreshold: 200 * time.Millisecond,
99+
ignoreRecordNotFound: false,
100+
}
101+
102+
l.Trace(ctx, time.Now(), func() (string, int64) { return "SELECT missing", 0 }, logger.ErrRecordNotFound)
103+
104+
fields := loggedFields(t, buf)
105+
assert.Equal(t, "error", fields["level"])
106+
assert.Equal(t, "gorm error", fields["message"])
107+
})
108+
109+
t.Run("Silent level suppresses all output", func(t *testing.T) {
110+
buf := &bytes.Buffer{}
111+
l := newTestLogger(buf).LogMode(logger.Silent)
112+
113+
l.Trace(ctx, time.Now().Add(-time.Second), func() (string, int64) { return "SELECT 1", 1 }, errors.New("boom"))
114+
115+
assert.Empty(t, buf.Bytes())
116+
})
117+
}
118+
119+
func TestZerologGORMLogger_InfoWarnError(t *testing.T) {
120+
ctx := context.Background()
121+
122+
t.Run("Info logs at info level when level>=Info", func(t *testing.T) {
123+
buf := &bytes.Buffer{}
124+
l := newTestLogger(buf).LogMode(logger.Info)
125+
l.Info(ctx, "test info %s", "msg")
126+
fields := loggedFields(t, buf)
127+
assert.Equal(t, "info", fields["level"])
128+
})
129+
130+
t.Run("Info suppressed when level<Info", func(t *testing.T) {
131+
buf := &bytes.Buffer{}
132+
l := newTestLogger(buf).LogMode(logger.Warn)
133+
l.Info(ctx, "should not appear")
134+
assert.Empty(t, buf.Bytes())
135+
})
136+
137+
t.Run("Warn logs at warn level", func(t *testing.T) {
138+
buf := &bytes.Buffer{}
139+
l := newTestLogger(buf).LogMode(logger.Warn)
140+
l.Warn(ctx, "slow thing %d", 42)
141+
fields := loggedFields(t, buf)
142+
assert.Equal(t, "warn", fields["level"])
143+
})
144+
145+
t.Run("Error logs at error level", func(t *testing.T) {
146+
buf := &bytes.Buffer{}
147+
l := newTestLogger(buf).LogMode(logger.Error)
148+
l.Error(ctx, "something broke")
149+
fields := loggedFields(t, buf)
150+
assert.Equal(t, "error", fields["level"])
151+
})
152+
}
153+
154+
func TestZerologGORMLogger_LogMode(t *testing.T) {
155+
buf := &bytes.Buffer{}
156+
base := newTestLogger(buf)
157+
clone := base.LogMode(logger.Info)
158+
159+
// clone must be independent of base
160+
assert.NotSame(t, base, clone)
161+
}
162+
163+
// TestZerologGORMLogger_StdoutSafety verifies that no bytes land on os.Stdout
164+
// when the logger emits errors or slow-query warnings — the invariant that
165+
// protects the MCP stdio JSON-RPC stream from corruption.
166+
func TestZerologGORMLogger_StdoutSafety(t *testing.T) {
167+
ctx := context.Background()
168+
169+
origStdout := os.Stdout
170+
r, w, err := os.Pipe()
171+
require.NoError(t, err)
172+
os.Stdout = w
173+
174+
buf := &bytes.Buffer{}
175+
l := newTestLogger(buf).LogMode(logger.Info)
176+
177+
// error path
178+
l.Trace(ctx, time.Now(), func() (string, int64) { return "SELECT 1", 0 }, errors.New("boom"))
179+
// slow-query path
180+
l.Trace(ctx, time.Now().Add(-time.Second), func() (string, int64) { return "SELECT slow", 1 }, nil)
181+
// info path
182+
l.Info(ctx, "hello")
183+
184+
require.NoError(t, w.Close())
185+
os.Stdout = origStdout
186+
187+
captured := &bytes.Buffer{}
188+
_, err = captured.ReadFrom(r)
189+
require.NoError(t, err)
190+
assert.Empty(t, captured.Bytes(), "zerologGORMLogger must never write to os.Stdout")
191+
}

0 commit comments

Comments
 (0)