feat(observability): configurable logging, request ids and error logs

- internal/logging: LOG_LEVEL/LOG_FORMAT parsing and slog handler
- request_id middleware (X-Request-ID or generated, echoed) + debug access log
- httpx registers errors; middleware logs internal causes with request_id
- health excluded, objects/sse debug-only, no query/body/secrets in logs
- new observability capability spec; README/.env.example docs
This commit is contained in:
iu
2026-10-11 13:40:11 +03:00
parent a5d0cee0eb
commit 6361e48b20
18 changed files with 998 additions and 5 deletions
+2
View File
@@ -53,6 +53,7 @@ func Internal(message string) *AppError {
}
func Abort(c *gin.Context, err error) {
_ = c.Error(err)
var appErr *AppError
if errors.As(err, &appErr) {
c.AbortWithStatusJSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
@@ -62,6 +63,7 @@ func Abort(c *gin.Context, err error) {
}
func RespondError(c *gin.Context, err error) {
_ = c.Error(err)
var appErr *AppError
if errors.As(err, &appErr) {
c.JSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
+68
View File
@@ -0,0 +1,68 @@
package httpx
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func runHandler(t *testing.T, handler gin.HandlerFunc) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
handler(c)
return c, w
}
func TestRespondErrorRegistersInternalError(t *testing.T) {
c, w := runHandler(t, func(c *gin.Context) {
RespondError(c, errors.New("boom"))
})
if len(c.Errors) != 1 {
t.Fatalf("c.Errors = %d, want 1", len(c.Errors))
}
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
if body := w.Body.String(); body != `{"error":{"code":"internal_error","message":"internal server error"}}` {
t.Fatalf("body = %s", body)
}
}
func TestRespondErrorRegistersAppError(t *testing.T) {
c, w := runHandler(t, func(c *gin.Context) {
RespondError(c, NotFound("photo_not_found", "photo not found"))
})
if len(c.Errors) != 1 {
t.Fatalf("c.Errors = %d, want 1", len(c.Errors))
}
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestAbortRegistersInternalError(t *testing.T) {
c, w := runHandler(t, func(c *gin.Context) {
Abort(c, errors.New("kaboom"))
})
if len(c.Errors) != 1 {
t.Fatalf("c.Errors = %d, want 1", len(c.Errors))
}
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
}
// Ensure the package-level recovery of AppError still works for logging levels.
func TestInternalIsAppError500(t *testing.T) {
err := Internal("oops")
var appErr *AppError
if !errors.As(err, &appErr) || appErr.Status != http.StatusInternalServerError {
t.Fatalf("Internal() = %#v", err)
}
}