Skip to content

Commit c00c82a

Browse files
authored
Merge pull request #69 from cloudblue/fix/lazy-logger-contrib
fix(contrib): resolve slog.Default() lazily to respect LOG_LEVEL=debug
2 parents 58e4015 + 7e5e15e commit c00c82a

12 files changed

Lines changed: 247 additions & 30 deletions

File tree

plugins/contrib/internal/oauthutil/token.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,6 @@ const defaultExpiryMargin = 1 * time.Minute
9191

9292
// NewTokenFetcher creates a TokenFetcher with defaults applied for nil fields.
9393
func NewTokenFetcher(cfg TokenFetcher) *TokenFetcher {
94-
if cfg.Logger == nil {
95-
cfg.Logger = slog.Default()
96-
}
9794
if cfg.Client == nil {
9895
cfg.Client = DefaultHTTPClient()
9996
}
@@ -103,6 +100,16 @@ func NewTokenFetcher(cfg TokenFetcher) *TokenFetcher {
103100
return &cfg
104101
}
105102

103+
// log returns the configured logger, or slog.Default() if none was set.
104+
// Called at log-emit time so the current global default is always used
105+
// when no explicit logger is provided.
106+
func (tf *TokenFetcher) log() *slog.Logger {
107+
if tf.Logger != nil {
108+
return tf.Logger
109+
}
110+
return slog.Default()
111+
}
112+
106113
// Exchange sends a token request with the given form parameters and returns
107114
// the parsed result. The caller is responsible for setting grant-type-specific
108115
// form parameters (e.g., refresh_token) before calling Exchange.
@@ -169,7 +176,7 @@ func (tf *TokenFetcher) doRequest(ctx context.Context, req *http.Request) (statu
169176
if ctx.Err() != nil {
170177
return 0, nil, nil, fmt.Errorf("token request for %s: %w", tf.TokenURL, ctx.Err())
171178
}
172-
tf.Logger.LogAttrs(ctx, slog.LevelWarn, "token endpoint request failed",
179+
tf.log().LogAttrs(ctx, slog.LevelWarn, "token endpoint request failed",
173180
slog.String("token_url", tf.TokenURL),
174181
slog.String("error", err.Error()))
175182
return 0, nil, nil, fmt.Errorf("token endpoint request for %s: %w",
@@ -193,7 +200,7 @@ func (tf *TokenFetcher) doRequest(ctx context.Context, req *http.Request) (statu
193200
func (tf *TokenFetcher) handleErrorResponse(ctx context.Context, statusCode int, header http.Header, body []byte) error {
194201
contentType := header.Get("Content-Type")
195202

196-
if tf.Logger.Enabled(ctx, slog.LevelDebug) {
203+
if tf.log().Enabled(ctx, slog.LevelDebug) {
197204
attrs := []slog.Attr{
198205
slog.Int("status", statusCode),
199206
slog.String("content_type", contentType),
@@ -209,7 +216,7 @@ func (tf *TokenFetcher) handleErrorResponse(ctx context.Context, statusCode int,
209216
}
210217
}
211218

212-
tf.Logger.LogAttrs(ctx, slog.LevelDebug, "token endpoint error response", attrs...)
219+
tf.log().LogAttrs(ctx, slog.LevelDebug, "token endpoint error response", attrs...)
213220
}
214221

215222
if statusCode == http.StatusUnauthorized {
@@ -218,7 +225,7 @@ func (tf *TokenFetcher) handleErrorResponse(ctx context.Context, statusCode int,
218225
}
219226

220227
if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError {
221-
tf.Logger.LogAttrs(ctx, slog.LevelWarn, "token endpoint unavailable",
228+
tf.log().LogAttrs(ctx, slog.LevelWarn, "token endpoint unavailable",
222229
slog.String("token_url", tf.TokenURL),
223230
slog.Int("status", statusCode))
224231
return fmt.Errorf("token endpoint returned %d (content-type: %s): %w",

plugins/contrib/internal/oauthutil/token_test.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package oauthutil
55

66
import (
77
"encoding/json"
8+
"io"
9+
"log/slog"
810
"testing"
911
"time"
1012
)
@@ -30,8 +32,10 @@ func TestNewTokenFetcher_Defaults(t *testing.T) {
3032
TokenURL: "https://example.com/token",
3133
})
3234

33-
if f.Logger == nil {
34-
t.Error("Logger should default to non-nil")
35+
// Logger field must be nil — no eager slog.Default() capture at construction.
36+
// Lazy resolution happens via log() at call time.
37+
if f.Logger != nil {
38+
t.Error("Logger field should be nil when not provided; lazy resolution via log()")
3539
}
3640
if f.Client == nil {
3741
t.Error("Client should default to non-nil")
@@ -41,6 +45,26 @@ func TestNewTokenFetcher_Defaults(t *testing.T) {
4145
}
4246
}
4347

48+
func TestNewTokenFetcher_NilLogger_LazyResolution(t *testing.T) {
49+
f := NewTokenFetcher(TokenFetcher{TokenURL: "https://example.com/token"})
50+
51+
if f.log() != slog.Default() {
52+
t.Error("log() should return slog.Default() when Logger is nil")
53+
}
54+
}
55+
56+
func TestNewTokenFetcher_ExplicitLogger_UsesExplicitLogger(t *testing.T) {
57+
custom := slog.New(slog.NewTextHandler(io.Discard, nil))
58+
f := NewTokenFetcher(TokenFetcher{
59+
TokenURL: "https://example.com/token",
60+
Logger: custom,
61+
})
62+
63+
if f.log() != custom {
64+
t.Error("log() should return the explicitly provided logger")
65+
}
66+
}
67+
4468
func TestNewTokenFetcher_PreservesExplicitValues(t *testing.T) {
4569
margin := 5 * time.Minute
4670
f := NewTokenFetcher(TokenFetcher{

plugins/contrib/microsoft/token.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,6 @@ func NewRefreshTokenSource(cfg Config) *RefreshTokenSource {
163163
margin = defaultExpiryMargin
164164
}
165165

166-
logger := cfg.Logger
167-
if logger == nil {
168-
logger = slog.Default()
169-
}
170-
171166
client := cfg.HTTPClient
172167
if client == nil {
173168
client = oauthutil.DefaultHTTPClient()
@@ -182,13 +177,23 @@ func NewRefreshTokenSource(cfg Config) *RefreshTokenSource {
182177
maxPoolSize: maxPool,
183178
expiryMargin: margin,
184179
httpClient: client,
185-
logger: logger,
180+
logger: cfg.Logger,
186181
onSaveError: cfg.OnSaveError,
187182
pool: make(map[string]*list.Element),
188183
lru: list.New(),
189184
}
190185
}
191186

187+
// log returns the configured logger, or slog.Default() if none was set.
188+
// Called at log-emit time so the current global default is always used
189+
// when no explicit logger is provided.
190+
func (s *RefreshTokenSource) log() *slog.Logger {
191+
if s.logger != nil {
192+
return s.logger
193+
}
194+
return slog.Default()
195+
}
196+
192197
// GetCredentials extracts TenantID and Resource from the transaction context,
193198
// resolves a per-tenant entry from the pool, checks the per-resource access
194199
// token cache, and if needed exchanges the tenant's MRRT for a new access
@@ -244,14 +249,14 @@ func (s *RefreshTokenSource) getAccessToken(
244249
entry.mu.RLock()
245250
if ct, ok := entry.tokens[resource]; ok && time.Now().Before(ct.expiresAt) {
246251
entry.mu.RUnlock()
247-
s.logger.LogAttrs(ctx, slog.LevelDebug, "access token cache hit",
252+
s.log().LogAttrs(ctx, slog.LevelDebug, "access token cache hit",
248253
slog.String("tenant_id", entry.tenantID),
249254
slog.String("resource", resource))
250255
return ct.accessToken, ct.expiresAt, nil
251256
}
252257
entry.mu.RUnlock()
253258

254-
s.logger.LogAttrs(ctx, slog.LevelDebug, "access token cache miss",
259+
s.log().LogAttrs(ctx, slog.LevelDebug, "access token cache miss",
255260
slog.String("tenant_id", entry.tenantID),
256261
slog.String("resource", resource))
257262

@@ -321,7 +326,7 @@ func (s *RefreshTokenSource) exchange(
321326

322327
if result.RefreshToken != "" {
323328
if saveErr := s.store.Save(ctx, tenantID, result.RefreshToken); saveErr != nil {
324-
s.logger.LogAttrs(ctx, slog.LevelError, "failed to save rotated refresh token",
329+
s.log().LogAttrs(ctx, slog.LevelError, "failed to save rotated refresh token",
325330
slog.String("tenant_id", tenantID),
326331
slog.String("resource", resource),
327332
slog.String("error", saveErr.Error()))
@@ -375,7 +380,7 @@ func (s *RefreshTokenSource) evictLRU(ctx context.Context) {
375380
}
376381

377382
entry, _ := back.Value.(*tenantEntry)
378-
s.logger.LogAttrs(ctx, slog.LevelDebug, "evicting LRU pool entry",
383+
s.log().LogAttrs(ctx, slog.LevelDebug, "evicting LRU pool entry",
379384
slog.String("tenant_id", entry.tenantID))
380385

381386
delete(s.pool, entry.tenantID)

plugins/contrib/microsoft/token_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,3 +1446,33 @@ func TestGetCredentials_KeyResolver_ValidTenantIDCheck_RejectsBadResolverValue(t
14461446
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
14471447
}
14481448
}
1449+
1450+
func TestNewRefreshTokenSource_NilLogger_LazyResolution(t *testing.T) {
1451+
src := NewRefreshTokenSource(Config{
1452+
ClientID: "id",
1453+
ClientSecret: "secret",
1454+
Store: newMemoryTokenStore(),
1455+
})
1456+
1457+
// logger field must be nil — no eager slog.Default() at construction.
1458+
if src.logger != nil {
1459+
t.Error("logger field should be nil when not provided; lazy resolution via log()")
1460+
}
1461+
if src.log() != slog.Default() {
1462+
t.Error("log() should return slog.Default() when logger is nil")
1463+
}
1464+
}
1465+
1466+
func TestNewRefreshTokenSource_ExplicitLogger_UsesExplicitLogger(t *testing.T) {
1467+
custom := slog.New(slog.NewTextHandler(io.Discard, nil))
1468+
src := NewRefreshTokenSource(Config{
1469+
ClientID: "id",
1470+
ClientSecret: "secret",
1471+
Store: newMemoryTokenStore(),
1472+
Logger: custom,
1473+
})
1474+
1475+
if src.log() != custom {
1476+
t.Error("log() should return the explicitly provided logger")
1477+
}
1478+
}

plugins/contrib/mux.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,23 @@ func WithLogger(l *slog.Logger) MuxOption {
5757
// NewMux creates a new request multiplexer. Use [Mux.Handle] and
5858
// [Mux.Default] to register routes before serving traffic.
5959
func NewMux(opts ...MuxOption) *Mux {
60-
m := &Mux{
61-
logger: slog.Default(),
62-
}
60+
m := &Mux{}
6361
for _, opt := range opts {
6462
opt(m)
6563
}
6664
return m
6765
}
6866

67+
// log returns the configured logger, or slog.Default() if none was set.
68+
// Called at log-emit time so the current global default is always used
69+
// when no explicit logger is provided.
70+
func (m *Mux) log() *slog.Logger {
71+
if m.logger != nil {
72+
return m.logger
73+
}
74+
return slog.Default()
75+
}
76+
6977
// Handle registers a route that dispatches matching requests to the
7078
// given provider. Routes are evaluated by specificity at dispatch time,
7179
// not registration order — but registration order breaks ties.
@@ -81,7 +89,7 @@ func (m *Mux) Handle(route Route, provider sdk.CredentialProvider) {
8189
newSpec := route.Specificity()
8290
for _, e := range m.entries {
8391
if e.route.Specificity() == newSpec && routesMayOverlap(e.route, route) {
84-
m.logger.Warn("routes registered with equal specificity may overlap, first registered wins on tie",
92+
m.log().Warn("routes registered with equal specificity may overlap, first registered wins on tie",
8593
"existing_route", routeString(e.route),
8694
"new_route", routeString(route),
8795
)

plugins/contrib/mux_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package contrib
66
import (
77
"context"
88
"errors"
9+
"io"
910
"log/slog"
1011
"net/http"
1112
"sync"
@@ -602,3 +603,24 @@ func TestMux_Compliance(t *testing.T) {
602603
mux.Default(&namedProvider{name: "compliance"})
603604
compliance.VerifyContract(t, mux)
604605
}
606+
607+
func TestNewMux_NilLogger_LazyResolution(t *testing.T) {
608+
m := NewMux()
609+
610+
// logger field must be nil — no eager slog.Default() at construction.
611+
if m.logger != nil {
612+
t.Error("logger field should be nil when not provided; lazy resolution via log()")
613+
}
614+
if m.log() != slog.Default() {
615+
t.Error("log() should return slog.Default() when logger is nil")
616+
}
617+
}
618+
619+
func TestNewMux_WithLogger_UsesExplicitLogger(t *testing.T) {
620+
custom := slog.New(slog.NewTextHandler(io.Discard, nil))
621+
m := NewMux(WithLogger(custom))
622+
623+
if m.log() != custom {
624+
t.Error("log() should return the explicitly provided logger")
625+
}
626+
}

plugins/contrib/oauth/refreshtoken.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ func NewRefreshToken(cfg RefreshTokenConfig) *RefreshToken {
116116
return rt
117117
}
118118

119+
// log returns the configured logger, or slog.Default() if none was set.
120+
// Called at log-emit time so the current global default is always used
121+
// when no explicit logger is provided.
122+
func (rt *RefreshToken) log() *slog.Logger {
123+
if rt.logger != nil {
124+
return rt.logger
125+
}
126+
return slog.Default()
127+
}
128+
119129
// GetCredentials fetches an OAuth2 bearer token using the refresh token grant
120130
// and returns it as a cacheable credential (Fast Path).
121131
//
@@ -151,7 +161,7 @@ func (rt *RefreshToken) fetch(ctx context.Context) (*cachedToken, error) {
151161

152162
if result.RefreshToken != "" {
153163
if saveErr := rt.store.Save(ctx, result.RefreshToken); saveErr != nil {
154-
rt.logger.LogAttrs(ctx, slog.LevelError, "failed to save rotated refresh token",
164+
rt.log().LogAttrs(ctx, slog.LevelError, "failed to save rotated refresh token",
155165
slog.String("token_url", rt.fetcher.TokenURL),
156166
slog.String("error", saveErr.Error()))
157167

plugins/contrib/oauth/refreshtoken_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,3 +975,37 @@ func TestRefreshToken_Compliance(t *testing.T) {
975975
plugin := contrib.AsPlugin(rt)
976976
compliance.VerifyContract(t, plugin)
977977
}
978+
979+
func TestNewRefreshToken_NilLogger_LazyResolution(t *testing.T) {
980+
store := newMemoryStore("tok")
981+
rt := NewRefreshToken(RefreshTokenConfig{
982+
TokenURL: "https://example.com/token",
983+
ClientID: "id",
984+
ClientSecret: "secret",
985+
Store: store,
986+
})
987+
988+
// logger field must be nil — no eager slog.Default() at construction.
989+
if rt.logger != nil {
990+
t.Error("logger field should be nil when not provided; lazy resolution via log()")
991+
}
992+
if rt.log() != slog.Default() {
993+
t.Error("log() should return slog.Default() when logger is nil")
994+
}
995+
}
996+
997+
func TestNewRefreshToken_ExplicitLogger_UsesExplicitLogger(t *testing.T) {
998+
custom := slog.New(slog.NewTextHandler(io.Discard, nil))
999+
store := newMemoryStore("tok")
1000+
rt := NewRefreshToken(RefreshTokenConfig{
1001+
TokenURL: "https://example.com/token",
1002+
ClientID: "id",
1003+
ClientSecret: "secret",
1004+
Store: store,
1005+
Logger: custom,
1006+
})
1007+
1008+
if rt.log() != custom {
1009+
t.Error("log() should return the explicitly provided logger")
1010+
}
1011+
}

plugins/contrib/oauth/token.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,30 @@ func newTokenManager(
4444
}
4545
}
4646

47+
// log returns the configured logger, or slog.Default() if none was set.
48+
// Called at log-emit time so the current global default is always used
49+
// when no explicit logger is provided.
50+
func (tm *tokenManager) log() *slog.Logger {
51+
if tm.logger != nil {
52+
return tm.logger
53+
}
54+
return slog.Default()
55+
}
56+
4757
// getToken returns a valid access token, fetching a new one if needed.
4858
// Concurrent callers are deduplicated via singleflight.
4959
func (tm *tokenManager) getToken(ctx context.Context) (string, time.Time, error) {
5060
tm.mu.RLock()
5161
if tm.token != nil && time.Now().Before(tm.token.expiresAt) {
5262
t := tm.token
5363
tm.mu.RUnlock()
54-
tm.logger.LogAttrs(ctx, slog.LevelDebug, "token cache hit",
64+
tm.log().LogAttrs(ctx, slog.LevelDebug, "token cache hit",
5565
slog.String("token_url", tm.tokenURL))
5666
return t.accessToken, t.expiresAt, nil
5767
}
5868
tm.mu.RUnlock()
5969

60-
tm.logger.LogAttrs(ctx, slog.LevelDebug, "token cache miss",
70+
tm.log().LogAttrs(ctx, slog.LevelDebug, "token cache miss",
6171
slog.String("token_url", tm.tokenURL))
6272

6373
// Use context.WithoutCancel so that a single caller's cancellation
@@ -72,7 +82,7 @@ func (tm *tokenManager) getToken(ctx context.Context) (string, time.Time, error)
7282
tm.token = cached
7383
tm.mu.Unlock()
7484

75-
tm.logger.LogAttrs(ctx, slog.LevelDebug, "token fetched",
85+
tm.log().LogAttrs(ctx, slog.LevelDebug, "token fetched",
7686
slog.String("token_url", tm.tokenURL),
7787
slog.Time("expires_at", cached.expiresAt))
7888

0 commit comments

Comments
 (0)