package logging import ( "fmt" "log" "net" "net/http" "os" "path/filepath" "strconv" "strings" "time" ) // ---------- Access logging ---------- // statusRecorder captures the status code and body size for the access log. type statusRecorder struct { http.ResponseWriter status int bytes int } func (s *statusRecorder) WriteHeader(code int) { if s.status == 0 { s.status = code } s.ResponseWriter.WriteHeader(code) } // Unwrap returns the wrapped ResponseWriter, so that http.ResponseController // can reach its Hijack and Flush: a WebSocket upgrade hijacks the connection. func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } func (s *statusRecorder) Write(data []byte) (int, error) { if s.status == 0 { s.status = http.StatusOK } written, err := s.ResponseWriter.Write(data) s.bytes += written return written, err } // logField escapes a value for a quoted log field the way nginx does // (quotes, backslashes and control characters become \xHH) and uses "-" // for empty values. func logField(value string) string { if value == "" { return "-" } var builder strings.Builder for i := 0; i < len(value); i++ { char := value[i] if char == '"' || char == '\\' || char < 0x20 || char == 0x7f { fmt.Fprintf(&builder, "\\x%02X", char) } else { builder.WriteByte(char) } } return builder.String() } // clientKey attempts to produce a client host name as a key identifier to split // requests in the log between sources. Could be extended to also check // Forwarded-For or other proxy headers func clientKey(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // AccessLog wraps next, writing a line to logger for each request in the // Apache/nginx Common Log Format, or the Combined Log Format (which adds the // referer and user agent) if combined is true. func AccessLog( logger *log.Logger, combined bool, next http.Handler, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() recorder := &statusRecorder{ ResponseWriter: w, } next.ServeHTTP(recorder, r) if recorder.status == 0 { recorder.status = http.StatusOK } user := "-" if username, _, ok := r.BasicAuth(); ok && username != "" { user = logField(username) } size := "-" // CLF uses "-" for an empty body if recorder.bytes > 0 { size = strconv.Itoa(recorder.bytes) } line := fmt.Sprintf( `%s - %s [%s] "%s" %d %s`, clientKey(r), user, start.Format("02/Jan/2006:15:04:05 -0700"), logField(r.Method+" "+r.RequestURI+" "+r.Proto), recorder.status, size, ) if combined { line += fmt.Sprintf( ` "%s" "%s"`, logField(r.Referer()), logField(r.UserAgent()), ) } logger.Println(line) }) } // OpenAccessLog opens the access log at path for appending, creating it if // needed, or stdout for "-", for AccessLog. It returns a logger writing to it // and a function that closes it (doing nothing for stdout). func OpenAccessLog(path string) (*log.Logger, func() error, error) { if path == "-" { return log.New(os.Stdout, "", 0), func() error { return nil }, nil } file, err := os.OpenFile( filepath.Clean(path), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600, ) if err != nil { return nil, nil, fmt.Errorf("opening access log: %w", err) } return log.New(file, "", 0), file.Close, nil }