forked from Ivasoft/opds-proxy
feat: structured logging improvements
This change adds a child logger with request information to the http request context. Also improved the log format for clarity. - Unescaped the query string - Use JSON log format when not in dev mode. - Added request ip to the request child logger. - The child logger is passed to the converter so we can associate any errors with a specific request / file. - Add book file name as a logger attribute - Add user agent header as a logger attribute - Log the "X-Forwarded-For" header, falling back to RemoteAddr
This commit is contained in:
@@ -3,7 +3,7 @@ testdata_dir = "testdata"
|
|||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
args_bin = ["--dev"]
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ."
|
cmd = "go build -o ./tmp/main ."
|
||||||
delay = 1000
|
delay = 1000
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package convert
|
package convert
|
||||||
|
|
||||||
|
import "log/slog"
|
||||||
|
|
||||||
type Converter interface {
|
type Converter interface {
|
||||||
// Whether or not the converter is available
|
// Whether or not the converter is available
|
||||||
// Usually based on the availability of the underlying tool
|
// Usually based on the availability of the underlying tool
|
||||||
Available() bool
|
Available() bool
|
||||||
// Convert the input file to the output file
|
// Convert the input file to the output file
|
||||||
Convert(input string) (string, error)
|
Convert(log *slog.Logger, input string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package convert
|
package convert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -21,7 +22,7 @@ func (kc *KepubConverter) Available() bool {
|
|||||||
return kc.available
|
return kc.available
|
||||||
}
|
}
|
||||||
|
|
||||||
func (kc *KepubConverter) Convert(input string) (string, error) {
|
func (kc *KepubConverter) Convert(_ *slog.Logger, input string) (string, error) {
|
||||||
kc.mutex.Lock()
|
kc.mutex.Lock()
|
||||||
defer kc.mutex.Unlock()
|
defer kc.mutex.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (mc *MobiConverter) Available() bool {
|
|||||||
return mc.available
|
return mc.available
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MobiConverter) Convert(input string) (string, error) {
|
func (mc *MobiConverter) Convert(log *slog.Logger, input string) (string, error) {
|
||||||
mc.mutex.Lock()
|
mc.mutex.Lock()
|
||||||
defer mc.mutex.Unlock()
|
defer mc.mutex.Unlock()
|
||||||
|
|
||||||
@@ -49,14 +49,14 @@ func (mc *MobiConverter) Convert(input string) (string, error) {
|
|||||||
isError := true
|
isError := true
|
||||||
if exiterr, ok := err.(*exec.ExitError); ok {
|
if exiterr, ok := err.(*exec.ExitError); ok {
|
||||||
// Sometimes warnings cause a 1 exit-code, but the file is still created
|
// Sometimes warnings cause a 1 exit-code, but the file is still created
|
||||||
slog.Info("Exit code", slog.Any("code", exiterr.ExitCode()))
|
log.Info("Exit code", slog.Any("code", exiterr.ExitCode()))
|
||||||
if exiterr.ExitCode() == 1 {
|
if exiterr.ExitCode() == 1 {
|
||||||
isError = false
|
isError = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isError {
|
if isError {
|
||||||
slog.Error("Error converting file",
|
log.Error("Error converting file",
|
||||||
slog.Any("error", err),
|
slog.Any("error", err),
|
||||||
slog.String("stdout", out.String()),
|
slog.String("stdout", out.String()),
|
||||||
slog.String("stderr", stderr.String()),
|
slog.String("stderr", stderr.String()),
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0
|
github.com/go-task/slim-sprig/v3 v3.0.0
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/knadh/koanf/maps v0.1.1 // indirect
|
github.com/knadh/koanf/maps v0.1.1 // indirect
|
||||||
github.com/knadh/koanf/providers/confmap v0.1.0
|
github.com/knadh/koanf/providers/confmap v0.1.0
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -8,6 +8,8 @@ github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAp
|
|||||||
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
|
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
|
||||||
|
|||||||
11
main.go
11
main.go
@@ -16,9 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ProxyConfig struct {
|
type ProxyConfig struct {
|
||||||
Port string `koanf:"port"`
|
Port string `koanf:"port"`
|
||||||
Auth AuthConfig `koanf:"auth"`
|
Auth AuthConfig `koanf:"auth"`
|
||||||
Feeds []FeedConfig `koanf:"feeds" `
|
Feeds []FeedConfig `koanf:"feeds" `
|
||||||
|
isDevMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -38,6 +39,7 @@ func main() {
|
|||||||
// These aren't mapped to the config file.
|
// These aren't mapped to the config file.
|
||||||
configPath := fs.String("config", "config.yml", "config file to load")
|
configPath := fs.String("config", "config.yml", "config file to load")
|
||||||
generateKeys := fs.Bool("generate-keys", false, "generate cookie signing keys and exit")
|
generateKeys := fs.Bool("generate-keys", false, "generate cookie signing keys and exit")
|
||||||
|
isDevMode := fs.Bool("dev", false, "enable development mode")
|
||||||
|
|
||||||
port := fs.String("port", "8080", "port to listen on")
|
port := fs.String("port", "8080", "port to listen on")
|
||||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||||
@@ -79,6 +81,9 @@ func main() {
|
|||||||
config.Auth.BlockKey = blockKey
|
config.Auth.BlockKey = blockKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This should only be set by the command line flag,
|
||||||
|
// so we don't use koanf to set this.
|
||||||
|
config.isDevMode = *isDevMode
|
||||||
server, err := NewServer(&config)
|
server, err := NewServer(&config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
54
server.go
54
server.go
@@ -1,16 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/evan-buss/opds-proxy/convert"
|
"github.com/evan-buss/opds-proxy/convert"
|
||||||
"github.com/evan-buss/opds-proxy/html"
|
"github.com/evan-buss/opds-proxy/html"
|
||||||
"github.com/evan-buss/opds-proxy/opds"
|
"github.com/evan-buss/opds-proxy/opds"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/gorilla/securecookie"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +46,12 @@ type Credentials struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
requestLogger = contextKey("log")
|
||||||
|
)
|
||||||
|
|
||||||
func NewServer(config *ProxyConfig) (*Server, error) {
|
func NewServer(config *ProxyConfig) (*Server, error) {
|
||||||
hashKey, err := hex.DecodeString(config.Auth.HashKey)
|
hashKey, err := hex.DecodeString(config.Auth.HashKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,6 +62,10 @@ func NewServer(config *ProxyConfig) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.isDevMode {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||||
|
}
|
||||||
|
|
||||||
s := securecookie.New(hashKey, blockKey)
|
s := securecookie.New(hashKey, blockKey)
|
||||||
|
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
@@ -72,9 +84,28 @@ func NewServer(config *ProxyConfig) (*Server, error) {
|
|||||||
func logger(next http.Handler) http.Handler {
|
func logger(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
id := rand.Intn(1000)
|
id := uuid.New()
|
||||||
|
requestIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if requestIP == "" {
|
||||||
|
requestIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
query, _ := url.QueryUnescape(r.URL.RawQuery)
|
||||||
|
|
||||||
|
log := slog.With(
|
||||||
|
slog.Group("request",
|
||||||
|
slog.String("id", id.String()),
|
||||||
|
slog.String("ip", requestIP),
|
||||||
|
slog.String("method", r.Method),
|
||||||
|
slog.String("path", r.URL.Path),
|
||||||
|
slog.String("query", query),
|
||||||
|
slog.String("user-agent", r.UserAgent()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), requestLogger, log))
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
slog.Info("Request", slog.Int("id", id), slog.String("path", r.URL.Path), slog.String("query", r.URL.RawQuery), slog.Duration("duration", time.Since(start)))
|
log.Info("Request Completed", slog.String("duration", time.Since(start).String()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,12 +199,20 @@ func handleFeed(outputDir string, feeds []FeedConfig, s *securecookie.SecureCook
|
|||||||
converter = mobiConverter
|
converter = mobiConverter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log := r.Context().Value(requestLogger).(*slog.Logger)
|
||||||
|
filename, err := parseFileName(resp)
|
||||||
|
if err == nil {
|
||||||
|
log = log.With(slog.String("file", filename))
|
||||||
|
}
|
||||||
|
|
||||||
if mimeType != EPUB_MIME || converter == nil {
|
if mimeType != EPUB_MIME || converter == nil {
|
||||||
forwardResponse(w, resp)
|
forwardResponse(w, resp)
|
||||||
|
if filename != "" {
|
||||||
|
log.Info("Sent File")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, err := parseFileName(resp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(r, w, "Failed to parse file name", err)
|
handleError(r, w, "Failed to parse file name", err)
|
||||||
return
|
return
|
||||||
@@ -183,7 +222,7 @@ func handleFeed(outputDir string, feeds []FeedConfig, s *securecookie.SecureCook
|
|||||||
downloadFile(epubFile, resp)
|
downloadFile(epubFile, resp)
|
||||||
defer os.Remove(epubFile)
|
defer os.Remove(epubFile)
|
||||||
|
|
||||||
outputFile, err := converter.Convert(epubFile)
|
outputFile, err := converter.Convert(log, epubFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(r, w, "Failed to convert epub", err)
|
handleError(r, w, "Failed to convert epub", err)
|
||||||
return
|
return
|
||||||
@@ -193,6 +232,8 @@ func handleFeed(outputDir string, feeds []FeedConfig, s *securecookie.SecureCook
|
|||||||
handleError(r, w, "Failed to send converted file", err)
|
handleError(r, w, "Failed to send converted file", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Info("Sent Converted File", slog.String("converter", reflect.TypeOf(converter).String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +348,8 @@ func fetchFromUrl(url string, credentials *Credentials) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleError(r *http.Request, w http.ResponseWriter, message string, err error) {
|
func handleError(r *http.Request, w http.ResponseWriter, message string, err error) {
|
||||||
slog.Error(message, slog.String("path", r.URL.Path), slog.String("query", r.URL.RawQuery), slog.Any("error", err))
|
log := r.Context().Value(requestLogger).(*slog.Logger)
|
||||||
|
log.Error(message, slog.Any("error", err))
|
||||||
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user