Repository: TecharoHQ/anubis

Issue:
Lots of "context canceled" messages in logs
Hi All,

So basically my Anubis logs look a lot like this: 
```
{"time":"2025-05-05T13:48:45.072548316Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T13:48:45.074058907Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T13:48:45.074956334Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T13:50:53.009158454Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T14:01:54.450026138Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T14:01:54.841369308Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T14:02:22.462233968Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T14:02:23.73512333Z","level":"INFO","msg":"http: proxy error: context canceled"}
{"time":"2025-05-05T14:02:23.741993231Z","level":"INFO","msg":"http: proxy error: context canceled"}
```
would you be able to add a nice handler to context cancelled throw pls? I suppose these are coming from sessions that have somehow disconnected mid-through for some reason?

My setup is on Kubernetes -> Traefik Ingress Controller -> Anubis -> H2 service

Hints from issue comments:
@Xe: Those logs come from the HTTP stack (via package [log](https://pkg.go.dev/log), which is why the source code location is not annotated). I can silence them in a small change, but they are generally unactionable and can be ignored. That context cancelled error comes from the underlying socket from the client being closed, which causes the request's context to be cancelled.@sokoow: Cool, thanks for commenting so fast - and great product by the way. @Xe: Keeping this open until I push the fix to squelch the logs :)

Solution patch:
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index ecc127e..da30ad5 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -309,7 +309,7 @@ func main() {
 	h = internal.XForwardedForToXRealIP(h)
 	h = internal.XForwardedForUpdate(h)
 
-	srv := http.Server{Handler: h}
+	srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()}
 	listener, listenerUrl := setupListener(*bindNetwork, *bind)
 	slog.Info(
 		"listening",
@@ -348,7 +348,7 @@ func metricsServer(ctx context.Context, done func()) {
 	mux := http.NewServeMux()
 	mux.Handle(anubis.BasePrefix+"/metrics", promhttp.Handler())
 
-	srv := http.Server{Handler: mux}
+	srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
 	listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
 	slog.Debug("listening for metrics", "url", metricsUrl)
 
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index 5eb41c1..71769e1 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add a warning for clients that don't store cookies
 - Disable Open Graph passthrough by default ([#435](https://github.com/TecharoHQ/anubis/issues/435))
 - Clarify the license of the mascot images ([#442](https://github.com/TecharoHQ/anubis/issues/442))
+- Started Suppressing 'Context canceled' errors from http in the logs ([#446](https://github.com/TecharoHQ/anubis/issues/446))
 
 ## v1.17.1: Asahi sas Brutus: Echo 1
 
diff --git a/internal/slog.go b/internal/log.go
similarity index 53%
rename from internal/slog.go
rename to internal/log.go
index 456a732..a711407 100644
--- a/internal/slog.go
+++ b/internal/log.go
@@ -2,9 +2,11 @@ package internal
 
 import (
 	"fmt"
+	"log"
 	"log/slog"
 	"net/http"
 	"os"
+	"strings"
 )
 
 func InitSlog(level string) {
@@ -34,3 +36,24 @@ func GetRequestLogger(r *http.Request) *slog.Logger {
 		"x-real-ip", r.Header.Get("X-Real-Ip"),
 	)
 }
+
+// ErrorLogFilter is used to suppress "context canceled" logs from the http server when a request is canceled (e.g., when a client disconnects).
+type ErrorLogFilter struct {
+	Unwrap *log.Logger
+}
+
+func (elf *ErrorLogFilter) Write(p []byte) (n int, err error) {
+	logMessage := string(p)
+	if strings.Contains(logMessage, "context canceled") {
+		return len(p), nil // Suppress the log by doing nothing
+	}
+	if elf.Unwrap != nil {
+		return elf.Unwrap.Writer().Write(p)
+	}
+	return len(p), nil
+}
+
+func GetFilteredHTTPLogger() *log.Logger {
+	stdErrLogger := log.New(os.Stderr, "", log.LstdFlags) // essentially what the default logger is.
+	return log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, "", 0)
+}



Test patch:
diff --git a/internal/log_test.go b/internal/log_test.go
new file mode 100644
index 0000000..50bd849
--- /dev/null
+++ b/internal/log_test.go
@@ -0,0 +1,46 @@
+package internal
+
+import (
+	"bytes"
+	"log"
+	"strings"
+	"testing"
+)
+
+func TestErrorLogFilter(t *testing.T) {
+	var buf bytes.Buffer
+	destLogger := log.New(&buf, "", 0)
+	errorFilterWriter := &ErrorLogFilter{Unwrap: destLogger}
+	testErrorLogger := log.New(errorFilterWriter, "", 0)
+
+	// Test Case 1: Suppressed message
+	suppressedMessage := "http: proxy error: context canceled"
+	testErrorLogger.Println(suppressedMessage)
+
+	if buf.Len() != 0 {
+		t.Errorf("Suppressed message was written to output. Output: %q", buf.String())
+	}
+	buf.Reset()
+
+	// Test Case 2: Allowed message
+	allowedMessage := "http: another error occurred"
+	testErrorLogger.Println(allowedMessage)
+
+	output := buf.String()
+	if !strings.Contains(output, allowedMessage) {
+		t.Errorf("Allowed message was not written to output. Output: %q", output)
+	}
+	if !strings.HasSuffix(output, "\n") {
+		t.Errorf("Allowed message output is missing newline. Output: %q", output)
+	}
+	buf.Reset()
+
+	// Test Case 3: Partially matching message (should be suppressed)
+	partiallyMatchingMessage := "Some other log before http: proxy error: context canceled and after"
+	testErrorLogger.Println(partiallyMatchingMessage)
+
+	if buf.Len() != 0 {
+		t.Errorf("Partially matching message was written to output. Output: %q", buf.String())
+	}
+	buf.Reset()
+}