From 04c64abcb1ba73e55da8282530a0ab9e4166cd18 Mon Sep 17 00:00:00 2001 From: Rashmi Tondare Date: Fri, 20 Mar 2026 18:29:38 +0530 Subject: [PATCH 1/2] simplify Level type and align with slog.Level (#1) --- interceptors/logging/logging.go | 4 ++-- interceptors/logging/slog.go | 39 +++++++-------------------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/interceptors/logging/logging.go b/interceptors/logging/logging.go index 7e62ae6ca..4b2b1cb4b 100644 --- a/interceptors/logging/logging.go +++ b/interceptors/logging/logging.go @@ -7,6 +7,7 @@ import ( "context" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors" + _ "log/slog" ) var ( @@ -206,8 +207,7 @@ func AddFields(ctx context.Context, f Fields) { // Logger requires Log method, similar to experimental slog, allowing logging interceptor to be interoperable. Official // adapters for popular loggers are in `provider/` directory (separate modules). It's totally ok to copy simple function // implementation over. -// TODO(bwplotka): Once slog is official, we could use slog method directly. Currently level is copied over, so we don't -// depend on experimental module. +// [Level] is an alias for [slog.Level], so you can use it in your own logger implementation. // interface used for all our interceptors. type Logger interface { Log(ctx context.Context, level Level, msg string, fields ...any) diff --git a/interceptors/logging/slog.go b/interceptors/logging/slog.go index 8861e46ba..1a6f9ba4f 100644 --- a/interceptors/logging/slog.go +++ b/interceptors/logging/slog.go @@ -9,38 +9,15 @@ package logging -// A Level is the importance or severity of a log event. -// The higher the level, the more important or severe the event. -type Level int +import "log/slog" -// Level numbers are inherently arbitrary, -// but we picked them to satisfy three constraints. -// Any system can map them to another numbering scheme if it wishes. -// -// First, we wanted the default level to be Info, Since Levels are ints, Info is -// the default value for int, zero. -// +// Level is an alias for slog.Level. +type Level slog.Level -// Second, we wanted to make it easy to use levels to specify logger verbosity. -// Since a larger level means a more severe event, a logger that accepts events -// with smaller (or more negative) level means a more verbose logger. Logger -// verbosity is thus the negation of event severity, and the default verbosity -// of 0 accepts all events at least as severe as INFO. -// -// Third, we wanted some room between levels to accommodate schemes with named -// levels between ours. For example, Google Cloud Logging defines a Notice level -// between Info and Warn. Since there are only a few of these intermediate -// levels, the gap between the numbers need not be large. Our gap of 4 matches -// OpenTelemetry's mapping. Subtracting 9 from an OpenTelemetry level in the -// DEBUG, INFO, WARN and ERROR ranges converts it to the corresponding slog -// Level range. OpenTelemetry also has the names TRACE and FATAL, which slog -// does not. But those OpenTelemetry levels can still be represented as slog -// Levels by using the appropriate integers. -// -// Names for common levels. +// Common level values. const ( - LevelDebug Level = -4 - LevelInfo Level = 0 - LevelWarn Level = 4 - LevelError Level = 8 + LevelDebug Level = Level(slog.LevelDebug) + LevelInfo Level = Level(slog.LevelInfo) + LevelWarn Level = Level(slog.LevelWarn) + LevelError Level = Level(slog.LevelError) ) From 8c24ed2f730f70854ddcb53a38fe260a0865d354 Mon Sep 17 00:00:00 2001 From: Rashmi Tondare Date: Tue, 24 Mar 2026 13:32:25 +0530 Subject: [PATCH 2/2] add tests for Level type compatibility; update go version in logging/examples --- interceptors/logging/examples/go.mod | 12 +- interceptors/logging/examples/go.sum | 5 + interceptors/logging/slog_test.go | 212 +++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 interceptors/logging/slog_test.go diff --git a/interceptors/logging/examples/go.mod b/interceptors/logging/examples/go.mod index 8a32d8865..27667eec2 100644 --- a/interceptors/logging/examples/go.mod +++ b/interceptors/logging/examples/go.mod @@ -1,6 +1,6 @@ module github.com/grpc-ecosystem/go-grpc-middleware/interceptors/logging/examples -go 1.23.0 +go 1.24.0 require ( github.com/go-kit/log v0.2.1 @@ -19,11 +19,11 @@ require ( github.com/mattn/go-isatty v0.0.14 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) replace github.com/grpc-ecosystem/go-grpc-middleware/v2 => ../../../ diff --git a/interceptors/logging/examples/go.sum b/interceptors/logging/examples/go.sum index 610da478a..f3ca560b2 100644 --- a/interceptors/logging/examples/go.sum +++ b/interceptors/logging/examples/go.sum @@ -60,19 +60,24 @@ go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/interceptors/logging/slog_test.go b/interceptors/logging/slog_test.go new file mode 100644 index 000000000..ac1da07dc --- /dev/null +++ b/interceptors/logging/slog_test.go @@ -0,0 +1,212 @@ +// Copyright (c) The go-grpc-middleware Authors. +// Licensed under the Apache License 2.0. + +package logging + +import ( + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +// TestLevelValues verifies that Level constants retain their expected numeric +// values after the type was changed from a bare int to an alias of slog.Level. +// Any change here would be a breaking change for users who rely on the numeric +// values (e.g. serialization, comparison, or custom code-to-level mappers). +func TestLevelValues(t *testing.T) { + tests := []struct { + name string + level Level + wantInt int + slogLvl slog.Level + }{ + {name: "LevelDebug", level: LevelDebug, wantInt: -4, slogLvl: slog.LevelDebug}, + {name: "LevelInfo", level: LevelInfo, wantInt: 0, slogLvl: slog.LevelInfo}, + {name: "LevelWarn", level: LevelWarn, wantInt: 4, slogLvl: slog.LevelWarn}, + {name: "LevelError", level: LevelError, wantInt: 8, slogLvl: slog.LevelError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Numeric value must remain unchanged (backward compatibility). + assert.Equal(t, tt.wantInt, int(tt.level), "numeric value of %s must be %d", tt.name, tt.wantInt) + // Must equal the corresponding slog.Level constant. + assert.Equal(t, tt.slogLvl, slog.Level(tt.level), "%s must equal slog.%s", tt.name, tt.slogLvl) + }) + } +} + +// TestLevelSlogRoundTrip ensures Level to slog.Level conversions are lossless. +func TestLevelSlogRoundTrip(t *testing.T) { + tests := []struct { + name string + level Level + }{ + {name: "LevelDebug", level: LevelDebug}, + {name: "LevelInfo", level: LevelInfo}, + {name: "LevelWarn", level: LevelWarn}, + {name: "LevelError", level: LevelError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slogLevel := slog.Level(tt.level) + backToLevel := Level(slogLevel) + assert.Equal(t, tt.level, backToLevel, "round-trip conversion must be lossless for %v", tt.level) + }) + } +} + +// TestLevelComparison verifies the ordering contract: Debug < Info < Warn < Error. +func TestLevelComparison(t *testing.T) { + tests := []struct { + name string + lower Level + higher Level + }{ + {name: "Debug < Info", lower: LevelDebug, higher: LevelInfo}, + {name: "Info < Warn", lower: LevelInfo, higher: LevelWarn}, + {name: "Warn < Error", lower: LevelWarn, higher: LevelError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Less(t, tt.lower, tt.higher) + }) + } +} + +// TestLoggerFuncReceivesCorrectLevel asserts that when the logging interceptor +// invokes Logger.Log, the level argument is still the expected Level type and +// value. This catches accidental type mismatches that could arise after the +// Level alias change. +func TestLoggerFuncReceivesCorrectLevel(t *testing.T) { + tests := []struct { + name string + level Level + }{ + {name: "LevelDebug", level: LevelDebug}, + {name: "LevelInfo", level: LevelInfo}, + {name: "LevelWarn", level: LevelWarn}, + {name: "LevelError", level: LevelError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var captured Level + fn := LoggerFunc(func(_ context.Context, lvl Level, _ string, _ ...any) { + captured = lvl + }) + fn.Log(context.Background(), tt.level, "test message") + assert.Equal(t, tt.level, captured) + }) + } +} + +// TestLoggerInterfaceAcceptsSlogLevel ensures that a slog.Level value can be +// passed through the Logger interface after a simple cast, which is the primary +// use-case unlocked by the alias change. +func TestLoggerInterfaceAcceptsSlogLevel(t *testing.T) { + tests := []struct { + name string + slogLevel slog.Level + wantLevel Level + }{ + {name: "slog.LevelDebug", slogLevel: slog.LevelDebug, wantLevel: LevelDebug}, + {name: "slog.LevelInfo", slogLevel: slog.LevelInfo, wantLevel: LevelInfo}, + {name: "slog.LevelWarn", slogLevel: slog.LevelWarn, wantLevel: LevelWarn}, + {name: "slog.LevelError", slogLevel: slog.LevelError, wantLevel: LevelError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var captured Level + fn := LoggerFunc(func(_ context.Context, lvl Level, _ string, _ ...any) { + captured = lvl + }) + fn.Log(context.Background(), Level(tt.slogLevel), "msg") + assert.Equal(t, tt.wantLevel, captured, + "Level(%v) passed to Logger should equal %v", tt.slogLevel, tt.wantLevel) + }) + } +} + +// TestDefaultServerCodeToLevel_LevelTypes confirms that the server code-to-level +// mapper returns values that are valid slog.Level equivalents. +func TestDefaultServerCodeToLevel_LevelTypes(t *testing.T) { + tests := []struct { + code codes.Code + wantLvl Level + }{ + {codes.OK, LevelInfo}, + {codes.NotFound, LevelInfo}, + {codes.InvalidArgument, LevelInfo}, + {codes.Internal, LevelError}, + {codes.Unknown, LevelError}, + {codes.DataLoss, LevelError}, + {codes.Unauthenticated, LevelInfo}, + } + for _, tt := range tests { + t.Run(tt.code.String(), func(t *testing.T) { + got := DefaultServerCodeToLevel(tt.code) + assert.Equal(t, tt.wantLvl, got) + // Verify the returned Level is a valid slog.Level. + assert.Equal(t, slog.Level(tt.wantLvl), slog.Level(got)) + }) + } +} + +// TestDefaultClientCodeToLevel_LevelTypes confirms that the client code-to-level +// mapper returns values that are valid slog.Level equivalents. +func TestDefaultClientCodeToLevel_LevelTypes(t *testing.T) { + tests := []struct { + code codes.Code + wantLvl Level + }{ + {codes.OK, LevelDebug}, + {codes.Canceled, LevelDebug}, + {codes.NotFound, LevelDebug}, + {codes.Unknown, LevelInfo}, + {codes.DeadlineExceeded, LevelInfo}, + {codes.Unauthenticated, LevelInfo}, + {codes.Internal, LevelWarn}, + {codes.Unavailable, LevelWarn}, + } + for _, tt := range tests { + t.Run(tt.code.String(), func(t *testing.T) { + got := DefaultClientCodeToLevel(tt.code) + assert.Equal(t, tt.wantLvl, got) + // Verify the returned Level is a valid slog.Level. + assert.Equal(t, slog.Level(tt.wantLvl), slog.Level(got)) + }) + } +} + +// TestCustomCodeToLevelWithSlogLevel simulates a user-defined CodeToLevel +// function that returns slog.Level values cast to Level. This is the main +// integration pattern enabled by the alias change. +func TestCustomCodeToLevelWithSlogLevel(t *testing.T) { + // A user-defined mapper using slog.Level values directly. + customMapper := func(code codes.Code) Level { + switch code { + case codes.OK: + return Level(slog.LevelDebug) + case codes.Internal: + return Level(slog.LevelError) + default: + return Level(slog.LevelInfo) + } + } + + require.Equal(t, LevelDebug, customMapper(codes.OK)) + require.Equal(t, LevelError, customMapper(codes.Internal)) + require.Equal(t, LevelInfo, customMapper(codes.NotFound)) +} + +// TestLevelZeroValue ensures the zero value of Level equals LevelInfo (which is +// slog.LevelInfo = 0). This is important because Go zero-initializes variables +// and this was always the implicit contract. +func TestLevelZeroValue(t *testing.T) { + var zeroLevel Level + assert.Equal(t, LevelInfo, zeroLevel, "zero value of Level must be LevelInfo") + assert.Equal(t, slog.LevelInfo, slog.Level(zeroLevel), "zero value must map to slog.LevelInfo") +}