middleware/logging: duplicate error string recorded in `error` & `stack`, and all non-nil errors hardcoded to LevelError
What happened:
In github.com/go-kratos/kratos/v3/middleware/logging, there are two significant issues regarding error handling and structured logging:
Duplicate error string recorded in both
errorandstackattributes: Inlogging.go,extractError(err)attempts to extract stack traces viafmt.Sprintf("%+v", err):func extractError(err error) (slog.Level, string) { if err != nil { return slog.LevelError, fmt.Sprintf("%+v", err) } return slog.LevelInfo, "" }However, Kratos's own
*errors.Error(as well as Go standard library errors created viaerrors.Neworfmt.Errorf) does not implementfmt.Formatterwith stack frame capture. As a result,fmt.Sprintf("%+v", err)evaluates to the exact same string aserr.Error(). Consequently, the middleware logs the exact same error message twice:attrs = append(attrs, slog.Any("error", err)) if stack != "" { attrs = append(attrs, slog.String("stack", stack)) // Identical to err.Error()! }All non-nil errors are hardcoded to
slog.LevelError: Any non-nil error—including client/business expected errors (e.g.400 Bad Request,404 Not Found,401 Unauthorized) and transient rate-limiting signals (e.g.503 Service Unavailable,429 Too Many Requests)—is unconditionally logged atslog.LevelError. In production SRE environments, this causes severe Alert Fatigue (alert storms) on monitoring platforms (e.g., Prometheus / Grafana / Datadog) that trigger on error log rates.
What you expected to happen:
- No duplicate error logging:
If an error does not contain a real stack trace (or when
fmt.Sprintf("%+v", err) == err.Error()), thestackattribute should either be omitted or deduplicated. Furthermore, runtime panic stacks are already captured bymiddleware/recoveryviaruntime.Stack(), somiddleware/loggingshould not duplicate pseudo-stacks. - Dynamic Log Level Mapping:
Status codes should map to appropriate log levels:
2xx / 3xx->slog.LevelInfo4xx(and optionally503/429) ->slog.LevelWarn(not paging SRE alerts)500/ internal server errors ->slog.LevelErrorSupport an optionalWithLevelFunc(fn func(code int32, err error) slog.Level)option for custom mapping.
How to reproduce it (as minimally and precisely as possible):
Run a handler with middleware/logging that returns a business 400 error:
package main
import (
"context"
"log/slog"
"os"
"github.com/go-kratos/kratos/v3/errors"
"github.com/go-kratos/kratos/v3/middleware/logging"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mw := logging.Server(logger)
handler := mw(func(ctx context.Context, req any) (any, error) {
return nil, errors.BadRequest("USER_NOT_FOUND", "user does not exist")
})
_, _ = handler(context.Background(), "test-request")
}Actual Log Output:
{
"time": "2026-09-12T01:30:00Z",
"level": "ERROR",
"msg": "server request",
"kind": "server",
"component": "",
"operation": "",
"code": 400,
"reason": "USER_NOT_FOUND",
"error": "error: code = 400 reason = USER_NOT_FOUND message = user does not exist metadata = map[] cause = <nil>",
"stack": "error: code = 400 reason = USER_NOT_FOUND message = user does not exist metadata = map[] cause = <nil>"
}Notice that:
"level"isERRORfor a simple 400 Bad Request;"stack"and"error"contain identical text.
Anything else we need to know?:
- In Kratos v2,
loggingonly had a"stack"key (which formatted with%+v). In v3, when migrating tolog/slog,"error"was added without removing or cleaning up"stack". - We have tested a clean fix:
- Omit the
stackattribute when no stack frames are present or removestackin favor ofmiddleware/recoveryhandling. - Provide a default level mapper where
code >= 400 && code < 500outputsslog.LevelWarn.
- Omit the
- Happy to submit a Pull Request if the maintainers agree with this direction!
Environment:
- Kratos version (use
kratos -v): v3.0.0 - Go version (use
go version): go version go1.24.0 (or go1.27) - OS (e.g:
cat /etc/os-release): Windows 11 / Linux (Ubuntu 24.04) - Others: log/slog with JSONHandler
Source: go-kratos/kratos