Hey everyone,
I'm back. This was my last post: https://dev.to/silver_oof_/starting-a-cs-project-week-0-5cfh. As promised, we're now going to finish the extra work I left in Go.
So, remember the server we built? It works. But if you've been staring at your terminal, you've noticed it says absolutely nothing when a request comes in. Except the “local host is running” line. We don’t want that, we need it to log its state to us.
Why we need logs
The net/http library we're using doesn't print request logs automatically. You know, those boring lines like:
2026/10/12 18:42:10 GET / 200
Yes, they look stupid. But they're actually important. They tell us the state of the latest request hitting our server AND give us a timed history of every request, which is how we notice bugs. (A random 500 at 3 AM is a lot easier to find when something wrote it down.)
Since the library doesn't give us this, we have to build the logging system ourselves.
Making the logs
As we know http.ResponseWriter has no method to read back the status code. We can set it, but we can't ask what it was.
So the plan is to wrap the original ResponseWriter in our own struct, and remember the status code on the way through. The idea I have for this is the following:
Handler
↓
our wrapper (writes down the status code)
↓
real ResponseWriter
↓
Client
Step 1: The wrapper struct
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
http.ResponseWriter is embedded without a field name. That means our struct automatically gets all its methods, so it still is a ResponseWriter. We only override the parts we care about. statusCode is where we store what we catch.
Step 2: WriteHeader()
func (w *loggingResponseWriter) WriteHeader(code int) {
if w.statusCode != 0 {
return
}
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
WriteHeader() is what a handler calls to send a status code. Let's break it down:
- First, it checks if a status code was already set. If so, it returns, because only the first status code counts in an HTTP response.
- Otherwise, it stores the code in
w.statusCode. - Then it calls the original
ResponseWriter.WriteHeader(code)so the client still receives the status code.
So we record it AND pass it along.
Step 3: Write()
func (w *loggingResponseWriter) Write(b []byte) (int, error) {
if w.statusCode == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
Write() sends the response body to the client. Remember our "Hello from Go!" handler? It never called WriteHeader() anywhere. Go quietly assumes 200 OK in that case.
Since we can't see Go's quiet assumption, we copy it ourselves: if no status code has been set when Write() is called, we call WriteHeader(http.StatusOK) first. Then we hand the body to the original Write(b) and return the bytes written and any error.
Without this step, our logger would print 0 for every successful request. Not ideal.
Step 4: The Logger middleware
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lrw := &loggingResponseWriter{ResponseWriter: w}
next.ServeHTTP(lrw, r)
log.Printf("%s %s %d", r.Method, r.URL.Path, lrw.statusCode)
})
}
This is middleware: a function that sits in front of our handler, does something, and passes the request along. It takes the next handler and returns a new one.
Inside it:
- We wrap the original
ResponseWriterin ourloggingResponseWriter. - We call
next.ServeHTTP(lrw, r), which runs the real handler, but with our wrapper instead of the original writer. - After the handler finishes,
log.Printf()prints the method, URL path, and status code.
The flow now looks like:
Browser
↓
Logger (wraps the writer)
↓
ServeMux
↓
Our handler runs
↓
Logger prints: GET / 200
↓
Browser
Plug it in
A middleware does nothing until we use it. In main, wrap the mux when starting the server:
log.Fatal(http.ListenAndServe(":8080", Logger(mux)))
Now every request goes through Logger before reaching the ServeMux. Run it, open localhost, and your terminal should finally print something like GET / 200. And because we used log instead of print, we also got the timestamp for free.
Yes, that was a lot of code to print one line. But the best part is that this works for every route we add from now on. We write it once and forget it.
So… that's all for this one. Next, we start building the actual features of ZeroApi. If I got anything wrong (especially around middleware), please correct me in the comments. I'm learning as I go too.
Top comments (1)
Author: I forgot to add /health endpoint. The function of this endpoint is to check whether the API is running and able to respond to requests.
To implement it, we put the following code into our api/main.go
As you see, this is same as the /{$} endpoint we added previously, however, the work of this endpoint gonna be nothing important related to our project. All it will add is status:ok, for now.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.