diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1bece..a8c1bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `List` to `server.HealthGRPCHandler` and `server/connectrpc.HealthGRPCHandler`, implementing the `grpc.health.v1.Health/List` RPC added to the `HealthServer` interface in gRPC-Go v1.82.0. Both return a single entry keyed by the empty service name, matching the server-wide health check these handlers expose. +- Add `server.WithCodeLevelFunc` to let callers of `dgrpc/server/standard` override the gRPC-code-to-zap-level mapping used for the request-completion log line, instead of only the global `standard.Verbosity` knob. Useful for services (e.g. ones doing admission control) where a code like `codes.ResourceExhausted` is routine and shouldn't default to `Info`. ### Changed diff --git a/server/options.go b/server/options.go index 2d219c3..63cdf41 100644 --- a/server/options.go +++ b/server/options.go @@ -10,7 +10,9 @@ import ( "connectrpc.com/connect" "github.com/rs/cors" "go.uber.org/zap" + "go.uber.org/zap/zapcore" "google.golang.org/grpc" + "google.golang.org/grpc/codes" ) type Options struct { @@ -29,6 +31,11 @@ type Options struct { PostStreamInterceptors []grpc.StreamServerInterceptor ConnectExtraInterceptors []connect.Interceptor + // CodeLevelFunc, when set, overrides the default gRPC-code-to-zap-level mapping + // used by the `dgrpc/server/standard` server for its request-completion log line. + // See WithCodeLevelFunc. + CodeLevelFunc func(codes.Code) zapcore.Level + Registrator func(gs *grpc.Server) SecureTLSConfig *tls.Config @@ -308,6 +315,25 @@ func WithGRPCServerOptions(opts ...grpc.ServerOption) Option { } } +// WithCodeLevelFunc lets you override how gRPC status codes are mapped to zap log +// levels for the standard server's request-completion log line ("finished +// unary/streaming call with code X"). +// +// By default that mapping is controlled by the package-level `standard.Verbosity` +// variable, which applies the same code-to-level rules to every service using +// `dgrpc/server/standard`. A caller that gives specific meaning to a code — for +// example using `codes.ResourceExhausted` for routine admission-control +// backpressure — can use this option to log it at a different level without +// affecting other `dgrpc` users. +// +// **Important** Only taken into consideration by the `dgrpc/server/standard` +// server, ignored by all other server implementations. +func WithCodeLevelFunc(f func(codes.Code) zapcore.Level) Option { + return func(options *Options) { + options.CodeLevelFunc = f + } +} + // OverrideTraceID option can be used to force the generation of a new fresh trace ID // for every gRPC request entering the middleware func OverrideTraceID() Option { diff --git a/server/standard/server.go b/server/standard/server.go index 8cf3465..66398af 100644 --- a/server/standard/server.go +++ b/server/standard/server.go @@ -59,6 +59,9 @@ var notReadyResponse = map[string]interface{}{"is_ready": false} // For now, this controls server logging of gRCP code to zap level which // reduce some of the case into `INFO` level and some more like `OK` on `DEBUG` // level. +// +// A single server can instead override the whole gRPC-code-to-zap-level mapping, +// ignoring Verbosity entirely, via server.WithCodeLevelFunc. var Verbosity = 3 // StandardServer is actually a thin wrapper struct around an `*http.StandardServer` and a @@ -353,7 +356,7 @@ func newGRPCServer(options *server.Options) *grpc.Server { zopts := []grpc_zap.Option{ grpc_zap.WithDurationField(grpc_zap.DurationToTimeMillisField), grpc_zap.WithDecider(defaultLoggingDecider), - grpc_zap.WithLevels(defaultServerCodeLevel), + grpc_zap.WithLevels(codeLevelFunc(options)), } // Zap base server interceptor @@ -444,6 +447,17 @@ func defaultLoggingDecider(fullMethodName string, err error) bool { return true } +// codeLevelFunc returns the gRPC-code-to-zap-level mapping to use, preferring +// a caller-supplied override (see server.WithCodeLevelFunc) over the built-in +// Verbosity-based default. +func codeLevelFunc(options *server.Options) func(codes.Code) zapcore.Level { + if options.CodeLevelFunc != nil { + return options.CodeLevelFunc + } + + return defaultServerCodeLevel +} + func defaultServerCodeLevel(code codes.Code) zapcore.Level { // Our default is Verbosity 3 (which essentially means Info) and higher means more verbose // while lower means less verbose. diff --git a/server/standard/server_test.go b/server/standard/server_test.go index 9fc5f6b..65d7915 100644 --- a/server/standard/server_test.go +++ b/server/standard/server_test.go @@ -6,6 +6,9 @@ import ( "github.com/streamingfast/dgrpc/server" "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "google.golang.org/grpc/codes" ) func TestHealthOver_isActive(t *testing.T) { @@ -33,3 +36,24 @@ func TestHealthOver_isActive(t *testing.T) { }) } } + +func TestCodeLevelFunc(t *testing.T) { + t.Run("defaults to defaultServerCodeLevel when unset", func(t *testing.T) { + options := &server.Options{} + assert.Equal(t, defaultServerCodeLevel(codes.ResourceExhausted), codeLevelFunc(options)(codes.ResourceExhausted)) + }) + + t.Run("uses caller-supplied override when set", func(t *testing.T) { + options := &server.Options{ + CodeLevelFunc: func(code codes.Code) zapcore.Level { + if code == codes.ResourceExhausted { + return zap.DebugLevel + } + return zap.InfoLevel + }, + } + + assert.Equal(t, zap.DebugLevel, codeLevelFunc(options)(codes.ResourceExhausted)) + assert.Equal(t, zap.InfoLevel, codeLevelFunc(options)(codes.NotFound)) + }) +}