Files
httplog/httplog.go
2025-09-01 14:58:18 -04:00

395 lines
8.2 KiB
Go

// httplog provides several utilities for logging http requests and responses.
//
// This package is needs testing, and is missing features. You should probably not be using it.
package httplog
import (
"bytes"
"cmp"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"sync/atomic"
)
var (
logMutex sync.Mutex
logOutput io.Writer = os.Stderr
)
func SetLogOutput(w io.Writer) {
logMutex.Lock()
defer logMutex.Unlock()
logOutput = w
}
func emit(prefix string, text []byte) {
if len(text) == 0 {
return
}
logMutex.Lock()
defer logMutex.Unlock()
for i := 0; i < len(text); {
var line []byte
if j := bytes.IndexByte(text[i:], '\n'); j != -1 {
line = text[i : i+j]
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
i = i + j + 1
} else {
line = text[i:]
i = len(text)
}
if len(line) > 0 {
fmt.Fprintf(logOutput, "%s%s\n", prefix, line)
}
}
if f, ok := logOutput.(interface{ Flush() error }); ok {
f.Flush()
}
}
type logWriter struct {
prefix string
buf []byte
}
func (w *logWriter) Write(p []byte) (int, error) {
i := bytes.LastIndexByte(p, '\n')
if i == -1 {
w.buf = append(w.buf, p...)
return len(p), nil
}
w.buf = append(w.buf, p[:i+1]...)
emit(w.prefix, w.buf)
w.buf = append(w.buf[:0], p[i+1:]...)
return len(p), nil
}
func (w *logWriter) Close() error {
if len(w.buf) > 0 {
emit(w.prefix, w.buf)
w.buf = w.buf[:0]
}
return nil
}
type logRequestWriter struct {
logWriter
headersWritten, closed bool
getStatus func() string
getHeader func() http.Header
getTrailer func() http.Header
}
func (w *logRequestWriter) Write(p []byte) (int, error) {
if w.headersWritten {
return w.logWriter.Write(p)
}
w.headersWritten = true
if w.getStatus != nil {
if status := w.getStatus(); status != "" {
fmt.Fprintf(&w.logWriter, "%s\n", status)
}
}
if w.getHeader != nil {
if h := w.getHeader(); len(h) > 0 {
h.Write(&w.logWriter)
}
}
return w.logWriter.Write(p)
}
func (w *logRequestWriter) Close() error {
if w.closed {
return nil
}
w.closed = true
w.Write(nil) // force headers be written, if it hasn't already.
if w.getTrailer != nil {
if h := w.getTrailer(); len(h) > 0 {
io.WriteString(&w.logWriter, "\n")
h.Write(&w.logWriter)
}
}
return nil
}
// this is more complicated than it arguably ought to be, because
// [http.Request] notes that closing can happen concurrently with reading,
// and more importantly, closing needs to unblock an in-progress read.
//
// the unblocking detail is the for the underlying reader to figure out,
// but we need to at least handle concurrently being closed safely.
type teeReadCloser struct {
m sync.Mutex
closed bool
err error
r io.ReadCloser
log logRequestWriter
}
func (r *teeReadCloser) Read(p []byte) (n int, err error) {
r.m.Lock()
closed, err := r.closed, r.err
r.m.Unlock()
if err != nil {
return 0, err
}
if closed {
return 0, os.ErrClosed
}
n, err = r.r.Read(p)
r.m.Lock()
defer r.m.Unlock()
if err != nil && err != io.EOF {
r.err = err
}
if n > 0 && !r.log.closed {
if _, err := r.log.Write(p[:n]); err != nil {
r.log.Close()
log.Printf("error teeing read to writer: %v", err)
}
}
if err != nil && !r.log.closed {
if err := r.log.Close(); err != nil {
log.Printf("error closing tee writer: %v", err)
}
}
return n, err
}
func (r *teeReadCloser) Close() error {
r.m.Lock()
closed, err := r.closed, r.err
r.m.Unlock()
if closed {
return err
}
err = r.r.Close()
r.m.Lock()
defer r.m.Unlock()
r.closed = true
r.err = cmp.Or(err, os.ErrClosed)
if !r.log.closed {
if err := r.log.Close(); err != nil {
log.Printf("error closing tee writer: %v", err)
}
}
return err
}
type teeResponseWriter struct {
http.ResponseWriter
log logRequestWriter
statusCode int
headerWritten bool
}
func (w *teeResponseWriter) WriteHeader(code int) {
if w.headerWritten {
return
}
w.statusCode = code
w.headerWritten = true
w.log.Write(nil) // force headers to be written
w.ResponseWriter.WriteHeader(code)
}
func (w *teeResponseWriter) Write(p []byte) (int, error) {
if !w.headerWritten {
w.WriteHeader(http.StatusOK)
}
n, err := w.ResponseWriter.Write(p)
w.log.Write(p[:n])
return n, err
}
func (w *teeResponseWriter) Flush() {
if !w.headerWritten {
w.WriteHeader(http.StatusOK)
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
var requestID atomic.Uint32
func makeSendRecvPrefixPair() (string, string) {
id := requestID.Add(1)
return fmt.Sprintf("%d ➡ ⁞ ", id), fmt.Sprintf("⬅ %d ⁞ ", id)
}
func logResponseWriter(prefix string, w http.ResponseWriter, r *http.Request) *teeResponseWriter {
trw := &teeResponseWriter{
ResponseWriter: w,
log: logRequestWriter{
logWriter: logWriter{prefix: prefix},
getHeader: func() http.Header { return w.Header() },
getTrailer: func() http.Header { return w.Header() },
},
}
trw.log.getStatus = func() string {
return fmt.Sprintf("%s %d %s", r.Proto, trw.statusCode, http.StatusText(trw.statusCode))
}
return trw
}
// Error logs an error message.
//
// Currently, the only reason you'd use this would be if [http.Client] or [http.Transport] returned an error instead of a response.
func Error(prefix string, err error) {
emit(prefix, fmt.Appendf(nil, "⚡ error: %v", err))
}
// Handler invokes the next http.Handler, logging both the request and the generated response.
func Handler(w http.ResponseWriter, req *http.Request, next http.Handler) {
send, recv := makeSendRecvPrefixPair()
body := &teeReadCloser{
r: req.Body,
log: logRequestWriter{
logWriter: logWriter{
prefix: recv,
},
getStatus: func() string {
return fmt.Sprintf("%s %s\n", req.Method, req.RequestURI)
},
getHeader: func() http.Header {
return req.Header
},
getTrailer: func() http.Header {
return req.Trailer
},
},
}
body.log.Write(nil) // we can write status and headers immediately
defer body.log.Close()
req2 := req.Clone(req.Context())
req2.Body = body
lrw := logResponseWriter(send, w, req2)
defer lrw.log.Close()
next.ServeHTTP(lrw, req2)
}
// Middleware wraps the Handler function, turning it into http middleware.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Handler(w, r, next)
})
}
// NewRequest is semantically equivalent to [http.NewRequest]. The request will be logged immediately, and if
// http.NewRequestWithContext returns an error, that will be logged as well.
//
// If this function does not return an error, it will also return a prefix that can be used with the [Response] or [Error] functions.
func NewRequest(ctx context.Context, method, url string, r io.Reader) (req *http.Request, recvPrefix string, err error) {
send, recv := makeSendRecvPrefixPair()
if r == nil {
r = http.NoBody
}
rc, ok := r.(io.ReadCloser)
if !ok {
rc = io.NopCloser(r)
}
body := &teeReadCloser{
r: rc,
log: logRequestWriter{
logWriter: logWriter{
prefix: send,
},
getHeader: func() http.Header {
return req.Header
},
getTrailer: func() http.Header {
return req.Trailer
},
},
}
// We can print the status line now, but the headers won't be available until the request has been passed to an http client or transport.
fmt.Fprintf(&body.log.logWriter, "%s %s\n", method, url)
req, err = http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
body.r.Close()
Error(send, err)
return nil, "", err
}
return req, recv, err
}
// Response immediately logs the status line and headers for the response,
// and then replaces its Body field with a proxy object that will log the
// body as it's read, and then print any trailers once EOF is reached.
func Response(prefix string, resp *http.Response) *http.Response {
body := &teeReadCloser{
r: resp.Body,
log: logRequestWriter{
logWriter: logWriter{prefix: prefix},
getStatus: func() string {
return fmt.Sprintf("%s %s", resp.Proto, resp.Status)
},
getHeader: func() http.Header {
return resp.Header
},
getTrailer: func() http.Header {
return resp.Trailer
},
},
}
body.log.Write(nil) // the headers and status line are can be printed immediately.
resp.Body = body
return resp
}