http.Server.ErrorLog is nil in a reverse proxy and its TLS handshake errors never reach the structured sink. Why, and how do you fix it?
answer
- the field is optional, and that is the problem
- no request means no access log line
- nil falls back to the older package
- bridge it with a fixed-level logger
- the proxy type has its own field too
basics
~20 sWith a nil ErrorLog, net/http writes its own diagnostics through the log package's standard logger, as plain text on standard error. A JSON-only ingest discards them. Set ErrorLog to a logger from slog.NewLogLogger so those lines become structured records.
solid answer
~40 s`http.Server.ErrorLog` is documented as optional: when it is nil the server logs through the `log` package's standard logger. So messages like `http: TLS handshake error from 10.0.3.4:52310: remote error: tls: bad certificate` go out as unstructured text on standard error, unaffected by anything you configured on your `slog` logger. If your collector only accepts structured records, those lines are dropped or land as unparsed blobs nobody alerts on - and handshake failures are exactly the class of error that never surfaces any other way, because the request never reaches a handler. The fix is to give the field a bridged logger: `srv.ErrorLog = slog.NewLogLogger(slog.Default().Handler(), slog.LevelError)`. Do the same for `httputil.ReverseProxy.ErrorLog`, which has its own field and the same nil default.
code
go · 10 linesproxy := &httputil.ReverseProxy{Rewrite: rewrite}
errLog := slog.NewLogLogger(slog.Default().Handler(), slog.LevelError)
proxy.ErrorLog = errLog
srv := &http.Server{
Addr: ":8443",
Handler: proxy,
ErrorLog: errLog,
}go deeper
Know that http.Server has an ErrorLog field taking a *log.Logger, and that leaving it nil sends the server's own messages to the log package rather than turning them off.
Explain the mechanics: which failures are reported there rather than through a handler, what slog.NewLogLogger returns, and why the bridged records all share the level you passed.
Demonstrate the diagnosis. Describe how a class of failure stays invisible because it never reaches a handler, how you would confirm the lines are being dropped at ingest, and how you pick a level that does not page on scanner noise.
Own the standard. Decide that every long-lived listener in the fleet ships with its diagnostics wired to the structured sink, and treat a nil ErrorLog as a review defect rather than something each service rediscovers during an incident.
## Where a server's own diagnostics go A Go HTTP server produces two very different streams. One is whatever your handlers log, which you control. The other is the server's own commentary about connections that never became a successful request: TLS handshake failures, malformed request lines, oversized headers, superfluous `WriteHeader` calls, errors accepting connections. That second stream goes to `http.Server.ErrorLog`, a `*log.Logger` field. The documentation is explicit that when it is nil, logging is done via the log package's standard logger. That nil default is a trap in a service whose logging has moved to `log/slog`, because it is silent. Nothing warns you. The server keeps working, the lines keep being written, and they simply go somewhere your pipeline does not look - plain text on standard error while everything else in the process emits records with a level and a timestamp of the handler's choosing. ## Why handshake errors are the painful case A TLS handshake failure never produces a request, so there is no access-log line, no handler, no middleware, no status code and no latency metric. The only trace of it in the entire process is the string `http: TLS handshake error from <addr>: <cause>` on the error log. A proxy in front of an internal fleet can be rejecting a whole client's traffic for an expired client certificate or an unsupported cipher, and every dashboard will look healthy: the request count from that client is simply zero. The same silence covers a client sending a request line the parser rejects, or headers past `MaxHeaderBytes` - both are refused before your code sees them. ## The fix, and the level choice Give the field a logger that writes into your handler: ```go srv.ErrorLog = slog.NewLogLogger(slog.Default().Handler(), slog.LevelError) ``` `slog.NewLogLogger(h, level)` returns a `*log.Logger` whose every write becomes a record on `h` at that fixed level. Note what you are choosing here: **one level for the whole stream**, exactly as with the process-wide bridge, and the message stays a single formatted string. Error is the usual pick, but be honest about what you are promoting - a scanner probing your port will produce handshake errors continuously, so an alert wired directly to "any Error from this logger" will page on internet noise. Many teams pick Warn for the proxy edge and alert on rate rather than on presence. Also take the handler from your existing logger rather than building a second one, so the bridged records go to the same destination and carry the same handler configuration as everything else. ## The other fields with the same shape `http.Server.ErrorLog` is not the only one. `httputil.ReverseProxy` has its own `ErrorLog` field with the same nil-means-log-package default, and it is where proxy-side failures surface - a dial error to the backend, a copy that failed mid-body. If you wire only the server and not the proxy, half the edge stays invisible. `httputil.ReverseProxy` also has an `ErrorHandler` field, which is a better place to translate a backend failure into a structured record with attributes, since there you still have the `*http.Request` in hand and can attach the target, the method and the path as fields rather than as text. That contrast is worth stating in an interview: the `ErrorLog` bridge is a *capture* mechanism for diagnostics you cannot restructure, while an explicit hook like `ErrorHandler` is where you get real structure back. Bridge what you cannot reach; instrument properly what you can. ## Diagnosing it after the fact The symptom that leads here is usually indirect. Somebody reports a client failing while your metrics show nothing, or the ingest side reports a count of lines it could not parse - and sampling those lines back to their source finds `http: ...` text arriving on the container's error stream. The confirmation is cheap: `srv.ErrorLog == nil` in the code, or a deliberate handshake failure against the port to see where the message lands. A related check is that the server is your own `http.Server` value at all. Code that reaches for the package-level convenience helpers gets a server value it never named, and therefore never gets an `ErrorLog` set on it - constructing the `http.Server` explicitly is a precondition for configuring any of this. ## Summary of the answer Nil `ErrorLog` means the log package's standard logger; that is plain text on standard error outside your structured pipeline; handshake and parse failures exist nowhere else; fix it with `slog.NewLogLogger` on the same handler, choose the level knowing it applies to the whole stream, and remember `httputil.ReverseProxy.ErrorLog` separately.
- What level would you assign to the bridged server error log, and why is Error not automatic?Error is defensible but blunt: every internet scanner hitting the port produces handshake failures, so a page wired to any Error record from this logger fires on background noise. Warn plus an alert on rate is often better at an internet-facing edge, with Error kept for a stream where each record genuinely warrants attention.
- Why can a bridged ErrorLog line never carry the request path as an attribute?Because for most of these messages there is no request. A handshake failure or a rejected request line happens before parsing completes, so no `*http.Request` exists to take fields from. What you get is the connection's remote address inside the formatted text, which is why these lines stay strings rather than becoming structured records.
- Where would you get properly structured errors out of httputil.ReverseProxy instead of bridged text?Its `ErrorHandler` field. It is called with the `http.ResponseWriter`, the `*http.Request` and the error, so you can emit a real record with the target, method, path and error as attributes and choose the response status yourself. Leave `ErrorLog` bridged as a safety net for anything the handler does not cover.
saying these in an interview costs you the question
- Thinks a nil ErrorLog means the server logs nothing
- Expects handshake failures to appear in an access log
- Sets ErrorLog to a brand-new handler with a different destination
- Wires the server but forgets the reverse proxy's own field
- Assumes bridged lines can carry request attributes