Skip to content

Commit ade32aa

Browse files
committed
api: log error responses
Errors returned by API handlers were only shown in the UI banner and left no trace in logs — the VPN gateway failure on Windows was undiagnosable after the banner was dismissed. Handlers write errors with c.JSON(4xx/5xx, ...), which echo treats as successful responses, so no framework hook sees them. Add an outermost middleware that captures error response bodies (lazily, only once the status is known to be >= 400, capped at 4 KiB) and logs them, plus errors traveling to echo's HTTPErrorHandler: basic auth 401s, route-not-found and recovered panics. 5xx are logged everywhere as errors; 4xx as warnings and only under /api/ to keep SPA static misses and browsers' initial basic-auth 401s out of the logs.
1 parent 4b90ea5 commit ade32aa

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

api/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ func (h *Handler) setupRouter(address string) (*echo.Echo, error) {
104104
e.Validator = &customValidator{validator: val}
105105

106106
// Middleware
107+
// First/outermost, so it also sees errors from the middleware below
108+
// (basic auth 401s, panics recovered to 500s).
109+
e.Use(errorLogMiddleware(h.logger))
107110
if !h.conf.DevMode() {
108111
e.Use(middleware.Recover())
109112
}

api/middleware.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/ipfs/go-log/v2"
10+
"github.com/labstack/echo/v4"
11+
)
12+
13+
// errorBodyLogLimit caps how much of an error response body is captured for
14+
// logging. Error bodies are small JSON objects; the cap only guards against a
15+
// pathological handler.
16+
const errorBodyLogLimit = 4 * 1024
17+
18+
// errorLogMiddleware logs every error response, which would otherwise exist
19+
// only in the UI banner that showed it: handlers write errors directly with
20+
// c.JSON(4xx/5xx, ...), so from echo's point of view they are successful
21+
// responses and no framework hook sees them. Must be registered first (as the
22+
// outermost middleware) — errors from inner middleware (basic auth 401s,
23+
// panics recovered to 500s) are only visible to middleware outside of them.
24+
//
25+
// 5xx are logged everywhere as errors; 4xx as warnings and only under the API
26+
// prefix — outside it they are browser noise (static file misses, the basic
27+
// auth 401 every browser session starts with).
28+
func errorLogMiddleware(logger *log.ZapEventLogger) echo.MiddlewareFunc {
29+
return errorLogMiddlewareLogf(func(status int, format string, args ...any) {
30+
if status >= http.StatusInternalServerError {
31+
logger.Errorf(format, args...)
32+
} else {
33+
logger.Warnf(format, args...)
34+
}
35+
})
36+
}
37+
38+
// errorLogMiddlewareLogf is errorLogMiddleware with the log destination
39+
// injected, so tests can assert on emitted records.
40+
func errorLogMiddlewareLogf(logf func(status int, format string, args ...any)) echo.MiddlewareFunc {
41+
return func(next echo.HandlerFunc) echo.HandlerFunc {
42+
return func(c echo.Context) error {
43+
w := &errorCaptureWriter{ResponseWriter: c.Response().Writer}
44+
c.Response().Writer = w
45+
46+
err := next(c)
47+
req := c.Request()
48+
49+
// An error heading to echo's HTTPErrorHandler: it is rendered
50+
// after this middleware returns, so the capture below never sees
51+
// its body — log the error value itself. If the response is
52+
// already committed, the write went through the capture writer
53+
// and the branch below reports it instead.
54+
if err != nil && !c.Response().Committed {
55+
status := http.StatusInternalServerError
56+
if httpErr, ok := errors.AsType[*echo.HTTPError](err); ok {
57+
status = httpErr.Code
58+
}
59+
if shouldLogErrorResponse(status, req.URL.Path) {
60+
logf(status, "%s %s: %d: %v", req.Method, req.URL.Path, status, err)
61+
}
62+
return err
63+
}
64+
65+
if w.status >= 400 && shouldLogErrorResponse(w.status, req.URL.Path) {
66+
body := bytes.TrimSpace(w.body.Bytes())
67+
truncated := ""
68+
if w.truncated {
69+
truncated = " (body truncated)"
70+
}
71+
logf(w.status, "%s %s: %d: %s%s", req.Method, req.URL.Path, w.status, body, truncated)
72+
}
73+
return err
74+
}
75+
}
76+
}
77+
78+
func shouldLogErrorResponse(status int, path string) bool {
79+
return status >= http.StatusInternalServerError || strings.HasPrefix(path, V0Prefix)
80+
}
81+
82+
// errorCaptureWriter passes everything through to the underlying
83+
// ResponseWriter and additionally keeps a copy of the body — but only once
84+
// the status is known to be an error, so success responses (log dumps,
85+
// metrics scrapes, pprof profiles) are never buffered.
86+
type errorCaptureWriter struct {
87+
http.ResponseWriter
88+
status int
89+
body bytes.Buffer
90+
truncated bool
91+
}
92+
93+
func (w *errorCaptureWriter) WriteHeader(status int) {
94+
w.status = status
95+
w.ResponseWriter.WriteHeader(status)
96+
}
97+
98+
func (w *errorCaptureWriter) Write(b []byte) (int, error) {
99+
if w.status >= 400 {
100+
free := errorBodyLogLimit - w.body.Len()
101+
if len(b) <= free {
102+
w.body.Write(b)
103+
} else {
104+
w.body.Write(b[:free])
105+
w.truncated = true
106+
}
107+
}
108+
return w.ResponseWriter.Write(b)
109+
}
110+
111+
// Flush keeps streaming endpoints (pprof profiles) working: echo's
112+
// Response.Flush type-asserts the underlying writer to http.Flusher and would
113+
// panic on a wrapper without it.
114+
func (w *errorCaptureWriter) Flush() {
115+
if f, ok := w.ResponseWriter.(http.Flusher); ok {
116+
f.Flush()
117+
}
118+
}
119+
120+
// Unwrap lets http.ResponseController reach the underlying writer's optional
121+
// interfaces.
122+
func (w *errorCaptureWriter) Unwrap() http.ResponseWriter {
123+
return w.ResponseWriter
124+
}

api/middleware_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/labstack/echo/v4"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// logRecord is one captured logf call.
15+
type logRecord struct {
16+
status int
17+
message string
18+
}
19+
20+
func newMiddlewareTestEcho() (*echo.Echo, *[]logRecord) {
21+
records := &[]logRecord{}
22+
e := echo.New()
23+
e.Use(errorLogMiddlewareLogf(func(status int, format string, args ...any) {
24+
*records = append(*records, logRecord{status: status, message: fmt.Sprintf(format, args...)})
25+
}))
26+
return e, records
27+
}
28+
29+
func doRequest(e *echo.Echo, method, path string) *httptest.ResponseRecorder {
30+
req := httptest.NewRequest(method, path, nil)
31+
rec := httptest.NewRecorder()
32+
e.ServeHTTP(rec, req)
33+
return rec
34+
}
35+
36+
func TestErrorLogMiddleware(t *testing.T) {
37+
apiPath := V0Prefix + "test"
38+
39+
t.Run("success response is passed through and not logged", func(t *testing.T) {
40+
e, records := newMiddlewareTestEcho()
41+
bigBody := strings.Repeat("x", 3*errorBodyLogLimit)
42+
e.GET(apiPath, func(c echo.Context) error {
43+
return c.String(http.StatusOK, bigBody)
44+
})
45+
46+
rec := doRequest(e, http.MethodGet, apiPath)
47+
require.Equal(t, http.StatusOK, rec.Code)
48+
require.Equal(t, bigBody, rec.Body.String())
49+
require.Empty(t, *records)
50+
})
51+
52+
t.Run("handler-written 4xx on API path is logged with body", func(t *testing.T) {
53+
e, records := newMiddlewareTestEcho()
54+
e.POST(apiPath, func(c echo.Context) error {
55+
return c.JSON(http.StatusBadRequest, ErrorMessage("invalid peer id"))
56+
})
57+
58+
rec := doRequest(e, http.MethodPost, apiPath)
59+
require.Equal(t, http.StatusBadRequest, rec.Code)
60+
require.Len(t, *records, 1)
61+
require.Equal(t, http.StatusBadRequest, (*records)[0].status)
62+
require.Contains(t, (*records)[0].message, "POST "+apiPath+": 400:")
63+
require.Contains(t, (*records)[0].message, "invalid peer id")
64+
})
65+
66+
t.Run("4xx outside API prefix is not logged", func(t *testing.T) {
67+
e, records := newMiddlewareTestEcho()
68+
e.GET("/static.js", func(c echo.Context) error {
69+
return c.String(http.StatusNotFound, "404 page not found")
70+
})
71+
72+
rec := doRequest(e, http.MethodGet, "/static.js")
73+
require.Equal(t, http.StatusNotFound, rec.Code)
74+
require.Empty(t, *records)
75+
})
76+
77+
t.Run("5xx outside API prefix is still logged", func(t *testing.T) {
78+
e, records := newMiddlewareTestEcho()
79+
e.GET("/broken", func(c echo.Context) error {
80+
return c.JSON(http.StatusInternalServerError, ErrorMessage("boom"))
81+
})
82+
83+
doRequest(e, http.MethodGet, "/broken")
84+
require.Len(t, *records, 1)
85+
require.Equal(t, http.StatusInternalServerError, (*records)[0].status)
86+
})
87+
88+
t.Run("echo HTTPError is logged with its status and rendered by echo", func(t *testing.T) {
89+
e, records := newMiddlewareTestEcho()
90+
e.GET(apiPath, func(c echo.Context) error {
91+
return echo.NewHTTPError(http.StatusUnauthorized, "bad credentials")
92+
})
93+
94+
rec := doRequest(e, http.MethodGet, apiPath)
95+
require.Equal(t, http.StatusUnauthorized, rec.Code)
96+
require.Contains(t, rec.Body.String(), "bad credentials")
97+
require.Len(t, *records, 1)
98+
require.Equal(t, http.StatusUnauthorized, (*records)[0].status)
99+
require.Contains(t, (*records)[0].message, "bad credentials")
100+
})
101+
102+
t.Run("plain error from handler is logged as 500", func(t *testing.T) {
103+
e, records := newMiddlewareTestEcho()
104+
e.GET(apiPath, func(c echo.Context) error {
105+
return fmt.Errorf("database exploded")
106+
})
107+
108+
rec := doRequest(e, http.MethodGet, apiPath)
109+
require.Equal(t, http.StatusInternalServerError, rec.Code)
110+
require.Len(t, *records, 1)
111+
require.Equal(t, http.StatusInternalServerError, (*records)[0].status)
112+
require.Contains(t, (*records)[0].message, "database exploded")
113+
})
114+
115+
t.Run("route not found under API prefix is logged", func(t *testing.T) {
116+
e, records := newMiddlewareTestEcho()
117+
118+
rec := doRequest(e, http.MethodGet, V0Prefix+"no/such/route")
119+
require.Equal(t, http.StatusNotFound, rec.Code)
120+
require.Len(t, *records, 1)
121+
require.Equal(t, http.StatusNotFound, (*records)[0].status)
122+
})
123+
124+
t.Run("oversized error body is truncated in the log, intact on the wire", func(t *testing.T) {
125+
e, records := newMiddlewareTestEcho()
126+
bigError := strings.Repeat("e", 2*errorBodyLogLimit)
127+
e.GET(apiPath, func(c echo.Context) error {
128+
return c.String(http.StatusBadRequest, bigError)
129+
})
130+
131+
rec := doRequest(e, http.MethodGet, apiPath)
132+
require.Equal(t, bigError, rec.Body.String())
133+
require.Len(t, *records, 1)
134+
message := (*records)[0].message
135+
require.Contains(t, message, "(body truncated)")
136+
// message = "GET <path>: 400: <capped body> (body truncated)" — the
137+
// captured part must be capped at errorBodyLogLimit, not the full 2x.
138+
require.Less(t, len(message), errorBodyLogLimit+200)
139+
})
140+
141+
t.Run("committed response with error returned is logged once", func(t *testing.T) {
142+
e, records := newMiddlewareTestEcho()
143+
e.GET(apiPath, func(c echo.Context) error {
144+
_ = c.JSON(http.StatusBadRequest, ErrorMessage("written and returned"))
145+
return fmt.Errorf("handler also returned an error")
146+
})
147+
148+
rec := doRequest(e, http.MethodGet, apiPath)
149+
require.Equal(t, http.StatusBadRequest, rec.Code)
150+
require.Len(t, *records, 1)
151+
require.Contains(t, (*records)[0].message, "written and returned")
152+
})
153+
154+
t.Run("Flush is passed through to the underlying writer", func(t *testing.T) {
155+
e, _ := newMiddlewareTestEcho()
156+
e.GET(apiPath, func(c echo.Context) error {
157+
_ = c.String(http.StatusOK, "chunk")
158+
c.Response().Flush()
159+
return nil
160+
})
161+
162+
rec := doRequest(e, http.MethodGet, apiPath)
163+
require.Equal(t, http.StatusOK, rec.Code)
164+
require.True(t, rec.Flushed)
165+
})
166+
}

0 commit comments

Comments
 (0)