diff --git a/cmd/mcp/cmd/root.go b/cmd/mcp/cmd/root.go index a55d79d..2fc2205 100644 --- a/cmd/mcp/cmd/root.go +++ b/cmd/mcp/cmd/root.go @@ -5,6 +5,9 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + "github.com/ethpandaops/mcp/pkg/config" + "github.com/ethpandaops/mcp/pkg/observability" ) var ( @@ -20,15 +23,48 @@ var rootCmd = &cobra.Command{ Ethereum network analytics capabilities including ClickHouse blockchain data, Prometheus metrics, Loki logs, and sandboxed Python execution.`, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - level, err := logrus.ParseLevel(logLevel) + // Load config to get logging settings if available. + cfg, err := config.Load(cfgFile) + if err != nil { + // Fall back to CLI flag if config fails to load. + level, err := logrus.ParseLevel(logLevel) + if err != nil { + return err + } + log.SetLevel(level) + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + return nil + } + + // Configure logging based on config file. + loggerCfg := observability.LoggerConfig{ + Level: observability.LogLevel(cfg.Observability.Logging.Level), + Format: observability.LogFormat(cfg.Observability.Logging.Format), + OutputPath: cfg.Observability.Logging.OutputPath, + } + + // CLI flag overrides config file. + if logLevel != "" && logLevel != "info" { + loggerCfg.Level = observability.LogLevel(logLevel) + } + + configuredLog, err := observability.ConfigureLogger(loggerCfg) if err != nil { - return err + // Fall back to default logging. + level, _ := logrus.ParseLevel(logLevel) + log.SetLevel(level) + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + return nil } - log.SetLevel(level) - log.SetFormatter(&logrus.TextFormatter{ - FullTimestamp: true, - }) + // Copy settings to the global log. + log.SetLevel(configuredLog.Level) + log.SetFormatter(configuredLog.Formatter) + log.SetOutput(configuredLog.Out) return nil }, diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index dc03bcf..5411d48 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/ethpandaops/mcp/internal/version" + "github.com/ethpandaops/mcp/pkg/observability" "github.com/ethpandaops/mcp/pkg/proxy" ) @@ -34,13 +35,48 @@ var rootCmd = &cobra.Command{ Prometheus, and Loki backends. This is designed for Kubernetes deployment where the proxy runs centrally and MCP clients connect using JWTs for authentication.`, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - level, err := logrus.ParseLevel(logLevel) + // Load config to get logging settings if available. + cfg, err := proxy.LoadServerConfig(cfgFile) if err != nil { - return err + // Fall back to CLI flag if config fails to load. + level, err := logrus.ParseLevel(logLevel) + if err != nil { + return err + } + log.SetLevel(level) + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + return nil } - log.SetLevel(level) - log.SetFormatter(&logrus.JSONFormatter{}) + // Configure logging based on config file. + loggerCfg := observability.LoggerConfig{ + Level: observability.LogLevel(cfg.Logging.Level), + Format: observability.LogFormat(cfg.Logging.Format), + OutputPath: cfg.Logging.OutputPath, + } + + // CLI flag overrides config file. + if logLevel != "" && logLevel != "info" { + loggerCfg.Level = observability.LogLevel(logLevel) + } + + configuredLog, err := observability.ConfigureLogger(loggerCfg) + if err != nil { + // Fall back to default logging. + level, _ := logrus.ParseLevel(logLevel) + log.SetLevel(level) + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + return nil + } + + // Copy settings to the global log. + log.SetLevel(configuredLog.Level) + log.SetFormatter(configuredLog.Formatter) + log.SetOutput(configuredLog.Out) return nil }, diff --git a/config.example.yaml b/config.example.yaml index 8078666..b998889 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -106,6 +106,11 @@ proxy: observability: metrics_enabled: true metrics_port: 31490 + # Logging configuration + logging: + level: info # debug, info, warn, error + format: text # text or json + output_path: "" # Optional: file path for log output (default: stdout) # Semantic search configuration (required). # Ensure the model file exists at the configured path. diff --git a/go.mod b/go.mod index c4519cc..67b4a52 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/ethpandaops/mcp go 1.24.1 require ( - github.com/ClickHouse/clickhouse-go/v2 v2.42.0 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 github.com/containerd/errdefs v1.0.0 @@ -24,10 +23,8 @@ require ( ) require ( - github.com/ClickHouse/ch-go v0.69.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.2.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -40,8 +37,6 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-faster/city v1.0.1 // indirect - github.com/go-faster/errors v0.7.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-github/v53 v53.2.0 // indirect @@ -49,7 +44,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/kelindar/iostream v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -59,15 +53,11 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/paulmach/orb v0.12.0 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/segmentio/asm v1.2.1 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.8.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect @@ -79,8 +69,8 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 1391a25..93d1ec0 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,9 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM= -github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg= -github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk= -github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= @@ -56,22 +50,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= @@ -91,18 +77,12 @@ github.com/kelindar/iostream v1.4.0 h1:ELKlinnM/K3GbRp9pYhWuZOyBxMMlYAfsOP+gauvZ github.com/kelindar/iostream v1.4.0/go.mod h1:MkjMuVb6zGdPQVdwLnFRO0xOTOdDvBWTztFmjRDQkXk= github.com/kelindar/search v0.4.0 h1:mj3U26qB6BQJr9/6Q1vHS/I40CQ6Mhb5iJfmfI2e/mY= github.com/kelindar/search v0.4.0/go.mod h1:7goLXnzQ6b0vMJr9SKWmABblTrJgPG3rVwp3yz2Lo5Q= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -119,7 +99,6 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -128,11 +107,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= -github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= -github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -148,10 +122,6 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk= @@ -161,24 +131,13 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -203,68 +162,30 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/config.go b/pkg/config/config.go index bc48f5a..514411a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -121,8 +121,16 @@ type StorageConfig struct { // ObservabilityConfig holds observability configuration. type ObservabilityConfig struct { - MetricsEnabled bool `yaml:"metrics_enabled"` - MetricsPort int `yaml:"metrics_port"` + MetricsEnabled bool `yaml:"metrics_enabled"` + MetricsPort int `yaml:"metrics_port"` + Logging LoggingConfig `yaml:"logging"` +} + +// LoggingConfig holds logging configuration. +type LoggingConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + OutputPath string `yaml:"output_path,omitempty"` } // ProxyConfig holds proxy connection configuration. @@ -281,6 +289,14 @@ func applyDefaults(cfg *Config) { cfg.Observability.MetricsPort = 2490 } + // Logging defaults. + if cfg.Observability.Logging.Level == "" { + cfg.Observability.Logging.Level = "info" + } + if cfg.Observability.Logging.Format == "" { + cfg.Observability.Logging.Format = "text" + } + // Proxy defaults. if cfg.Proxy.URL == "" { cfg.Proxy.URL = "http://localhost:18081" diff --git a/pkg/observability/logging.go b/pkg/observability/logging.go new file mode 100644 index 0000000..20945a0 --- /dev/null +++ b/pkg/observability/logging.go @@ -0,0 +1,403 @@ +// Package observability provides logging capabilities for ethpandaops-mcp. +package observability + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +// LogLevel represents the logging level. +type LogLevel string + +const ( + LogLevelDebug LogLevel = "debug" + LogLevelInfo LogLevel = "info" + LogLevelWarn LogLevel = "warn" + LogLevelError LogLevel = "error" +) + +// LogFormat represents the logging format. +type LogFormat string + +const ( + LogFormatText LogFormat = "text" + LogFormatJSON LogFormat = "json" +) + +// contextKey is a type for context keys to avoid collisions. +type contextKey int + +const ( + // RequestIDKey is the context key for request ID. + RequestIDKey contextKey = iota + // CorrelationIDKey is the context key for correlation ID. + CorrelationIDKey + // LoggerKey is the context key for the request-scoped logger. + LoggerKey +) + +// RequestIDHeader is the HTTP header for request ID propagation. +const RequestIDHeader = "X-Request-ID" + +// CorrelationIDHeader is the HTTP header for correlation ID propagation. +const CorrelationIDHeader = "X-Correlation-ID" + +// LoggerConfig holds configuration for the logger. +type LoggerConfig struct { + Level LogLevel `yaml:"level"` + Format LogFormat `yaml:"format"` + OutputPath string `yaml:"output_path,omitempty"` +} + +// Logger is the structured logger interface. +type Logger interface { + logrus.FieldLogger +} + +// DefaultLogger returns a new default logger. +func DefaultLogger() *logrus.Logger { + logger := logrus.New() + logger.SetOutput(os.Stdout) + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339, + }) + + return logger +} + +// ConfigureLogger configures the logger based on the provided config. +func ConfigureLogger(cfg LoggerConfig) (*logrus.Logger, error) { + logger := logrus.New() + + // Set level. + switch cfg.Level { + case LogLevelDebug: + logger.SetLevel(logrus.DebugLevel) + case LogLevelInfo: + logger.SetLevel(logrus.InfoLevel) + case LogLevelWarn: + logger.SetLevel(logrus.WarnLevel) + case LogLevelError: + logger.SetLevel(logrus.ErrorLevel) + default: + logger.SetLevel(logrus.InfoLevel) + } + + // Set format. + switch cfg.Format { + case LogFormatJSON: + logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: time.RFC3339, + }) + case LogFormatText, "": + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339, + }) + default: + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339, + }) + } + + // Set output. + if cfg.OutputPath != "" { + file, err := os.OpenFile(cfg.OutputPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + logger.SetOutput(file) + } else { + logger.SetOutput(os.Stdout) + } + + return logger, nil +} + +// GenerateRequestID generates a new request ID. +func GenerateRequestID() string { + return uuid.New().String() +} + +// GetRequestID retrieves the request ID from context. +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(RequestIDKey).(string); ok { + return id + } + return "" +} + +// GetCorrelationID retrieves the correlation ID from context. +func GetCorrelationID(ctx context.Context) string { + if id, ok := ctx.Value(CorrelationIDKey).(string); ok { + return id + } + return "" +} + +// WithRequestID adds a request ID to the context. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, RequestIDKey, requestID) +} + +// WithCorrelationID adds a correlation ID to the context. +func WithCorrelationID(ctx context.Context, correlationID string) context.Context { + return context.WithValue(ctx, CorrelationIDKey, correlationID) +} + +// WithLogger adds a logger to the context. +func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context { + return context.WithValue(ctx, LoggerKey, logger) +} + +// GetLogger retrieves the logger from context, or returns a default logger. +func GetLogger(ctx context.Context) logrus.FieldLogger { + if logger, ok := ctx.Value(LoggerKey).(logrus.FieldLogger); ok { + return logger + } + return DefaultLogger() +} + +// LoggingMiddleware creates an HTTP middleware that logs requests. +type LoggingMiddleware struct { + logger *logrus.Logger +} + +// NewLoggingMiddleware creates a new logging middleware. +func NewLoggingMiddleware(logger *logrus.Logger) *LoggingMiddleware { + return &LoggingMiddleware{logger: logger} +} + +// responseWriter is a wrapper around http.ResponseWriter to capture status code and response size. +type responseWriter struct { + http.ResponseWriter + statusCode int + size int +} + +func newResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + size, err := rw.ResponseWriter.Write(b) + rw.size += size + return size, err +} + +// Middleware returns the HTTP middleware function. +func (m *LoggingMiddleware) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Extract or generate request ID. + requestID := r.Header.Get(RequestIDHeader) + if requestID == "" { + requestID = GenerateRequestID() + } + + // Extract correlation ID (can be empty). + correlationID := r.Header.Get(CorrelationIDHeader) + + // Create request-scoped logger with fields. + requestLogger := m.logger.WithFields(logrus.Fields{ + "request_id": requestID, + "correlation_id": correlationID, + }) + + // Add request ID and correlation ID to response headers. + w.Header().Set(RequestIDHeader, requestID) + if correlationID != "" { + w.Header().Set(CorrelationIDHeader, correlationID) + } + + // Create context with request info. + ctx := r.Context() + ctx = WithRequestID(ctx, requestID) + ctx = WithCorrelationID(ctx, correlationID) + ctx = WithLogger(ctx, requestLogger) + + // Wrap response writer to capture status code. + rw := newResponseWriter(w) + + // Call next handler. + next.ServeHTTP(rw, r.WithContext(ctx)) + + // Calculate duration. + duration := time.Since(start) + + // Build log entry. + fields := logrus.Fields{ + "method": r.Method, + "path": r.URL.Path, + "status": rw.statusCode, + "duration_ms": duration.Milliseconds(), + "duration": duration.String(), + "request_id": requestID, + "correlation_id": correlationID, + "user_agent": r.UserAgent(), + "remote_addr": r.RemoteAddr, + "size": rw.size, + } + + // Add query parameters if present (sanitized). + if r.URL.RawQuery != "" { + fields["query"] = sanitizeQuery(r.URL.Query()) + } + + // Log based on status code. + switch { + case rw.statusCode >= 500: + requestLogger.WithFields(fields).Error("HTTP request error") + case rw.statusCode >= 400: + requestLogger.WithFields(fields).Warn("HTTP request warning") + default: + requestLogger.WithFields(fields).Info("HTTP request completed") + } + }) + } +} + +// sanitizeQuery sanitizes query parameters by truncating long values. +func sanitizeQuery(values map[string][]string) map[string]string { + result := make(map[string]string, len(values)) + for key, vals := range values { + if len(vals) == 0 { + continue + } + val := vals[0] + if len(val) > 100 { + val = val[:100] + "..." + } + result[key] = val + } + return result +} + +// LoggingConfig holds logging configuration for the observability config. +type LoggingConfig struct { + Level LogLevel `yaml:"level"` + Format LogFormat `yaml:"format"` + OutputPath string `yaml:"output_path,omitempty"` +} + +// ApplyDefaults applies default values to logging config. +func (c *LoggingConfig) ApplyDefaults() { + if c.Level == "" { + c.Level = LogLevelInfo + } + if c.Format == "" { + c.Format = LogFormatText + } +} + +// LoggerFromContext retrieves a logger from context or returns the default. +// This is a convenience function that matches the common pattern. +func LoggerFromContext(ctx context.Context, defaultLogger logrus.FieldLogger) logrus.FieldLogger { + if ctx == nil { + return defaultLogger + } + if logger, ok := ctx.Value(LoggerKey).(logrus.FieldLogger); ok { + return logger + } + return defaultLogger +} + +// RequestScopedLogger creates a logger with request context fields. +func RequestScopedLogger(base logrus.FieldLogger, ctx context.Context) logrus.FieldLogger { + fields := logrus.Fields{} + + if requestID := GetRequestID(ctx); requestID != "" { + fields["request_id"] = requestID + } + if correlationID := GetCorrelationID(ctx); correlationID != "" { + fields["correlation_id"] = correlationID + } + + if len(fields) > 0 { + return base.WithFields(fields) + } + return base +} + +// HTTPRequestInfo holds information about an HTTP request for logging. +type HTTPRequestInfo struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Status int `json:"status"` + Duration time.Duration `json:"duration"` + DurationMs int64 `json:"duration_ms"` + RequestID string `json:"request_id"` + Correlation string `json:"correlation_id,omitempty"` + UserAgent string `json:"user_agent"` + RemoteAddr string `json:"remote_addr"` + ResponseSize int `json:"size"` +} + +// LogHTTPRequest logs an HTTP request with structured fields. +func LogHTTPRequest(logger logrus.FieldLogger, info HTTPRequestInfo) { + fields := logrus.Fields{ + "method": info.Method, + "path": info.Path, + "status": info.Status, + "duration_ms": info.DurationMs, + "duration": info.Duration.String(), + "request_id": info.RequestID, + "user_agent": info.UserAgent, + "remote_addr": info.RemoteAddr, + "size": info.ResponseSize, + } + + if info.Query != "" { + fields["query"] = info.Query + } + if info.Correlation != "" { + fields["correlation_id"] = info.Correlation + } + + switch { + case info.Status >= 500: + logger.WithFields(fields).Error("HTTP request error") + case info.Status >= 400: + logger.WithFields(fields).Warn("HTTP request warning") + default: + logger.WithFields(fields).Info("HTTP request completed") + } +} + +// IsValidLogLevel checks if a log level is valid. +func IsValidLogLevel(level string) bool { + switch LogLevel(strings.ToLower(level)) { + case LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError: + return true + default: + return false + } +} + +// IsValidLogFormat checks if a log format is valid. +func IsValidLogFormat(format string) bool { + switch LogFormat(strings.ToLower(format)) { + case LogFormatText, LogFormatJSON: + return true + default: + return false + } +} diff --git a/pkg/observability/logging_test.go b/pkg/observability/logging_test.go new file mode 100644 index 0000000..991d3b3 --- /dev/null +++ b/pkg/observability/logging_test.go @@ -0,0 +1,325 @@ +package observability + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateRequestID(t *testing.T) { + id1 := GenerateRequestID() + id2 := GenerateRequestID() + + assert.NotEmpty(t, id1) + assert.NotEmpty(t, id2) + assert.NotEqual(t, id1, id2, "Request IDs should be unique") + assert.Len(t, id1, 36, "Request ID should be a UUID (36 characters)") +} + +func TestConfigureLogger(t *testing.T) { + tests := []struct { + name string + config LoggerConfig + wantLevel logrus.Level + }{ + { + name: "debug level", + config: LoggerConfig{ + Level: LogLevelDebug, + Format: LogFormatText, + }, + wantLevel: logrus.DebugLevel, + }, + { + name: "info level", + config: LoggerConfig{ + Level: LogLevelInfo, + Format: LogFormatText, + }, + wantLevel: logrus.InfoLevel, + }, + { + name: "warn level", + config: LoggerConfig{ + Level: LogLevelWarn, + Format: LogFormatJSON, + }, + wantLevel: logrus.WarnLevel, + }, + { + name: "error level", + config: LoggerConfig{ + Level: LogLevelError, + Format: LogFormatJSON, + }, + wantLevel: logrus.ErrorLevel, + }, + { + name: "default level for invalid", + config: LoggerConfig{ + Level: "invalid", + Format: LogFormatText, + }, + wantLevel: logrus.InfoLevel, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger, err := ConfigureLogger(tt.config) + require.NoError(t, err) + assert.Equal(t, tt.wantLevel, logger.Level) + }) + } +} + +func TestConfigureLoggerWithFileOutput(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-log-*.log") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + config := LoggerConfig{ + Level: LogLevelInfo, + Format: LogFormatText, + OutputPath: tmpFile.Name(), + } + + logger, err := ConfigureLogger(config) + require.NoError(t, err) + + logger.Info("test message") + + content, err := os.ReadFile(tmpFile.Name()) + require.NoError(t, err) + assert.Contains(t, string(content), "test message") +} + +func TestContextWithRequestID(t *testing.T) { + ctx := context.Background() + + // Test with request ID + ctx = WithRequestID(ctx, "test-request-id") + assert.Equal(t, "test-request-id", GetRequestID(ctx)) + + // Test with correlation ID + ctx = WithCorrelationID(ctx, "test-correlation-id") + assert.Equal(t, "test-correlation-id", GetCorrelationID(ctx)) +} + +func TestContextWithLogger(t *testing.T) { + logger := logrus.New() + ctx := context.Background() + + // Test setting and getting logger + ctx = WithLogger(ctx, logger.WithField("test", "value")) + retrievedLogger := GetLogger(ctx) + assert.NotNil(t, retrievedLogger) +} + +func TestLoggingMiddleware(t *testing.T) { + logger := logrus.New() + logger.SetOutput(os.NewFile(0, os.DevNull)) // Suppress output + + middleware := NewLoggingMiddleware(logger) + + handler := middleware.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + + // Verify context has request ID + requestID := GetRequestID(r.Context()) + assert.NotEmpty(t, requestID) + + // Verify context has logger + ctxLogger := GetLogger(r.Context()) + assert.NotNil(t, ctxLogger) + })) + + req := httptest.NewRequest(http.MethodGet, "/test-path", nil) + req.Header.Set("User-Agent", "test-agent") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.NotEmpty(t, rr.Header().Get(RequestIDHeader)) +} + +func TestLoggingMiddlewareWithExistingRequestID(t *testing.T) { + logger := logrus.New() + logger.SetOutput(os.NewFile(0, os.DevNull)) + + middleware := NewLoggingMiddleware(logger) + + handler := middleware.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(RequestIDHeader, "existing-request-id") + req.Header.Set(CorrelationIDHeader, "existing-correlation-id") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + // Verify existing IDs are preserved + assert.Equal(t, "existing-request-id", rr.Header().Get(RequestIDHeader)) + assert.Equal(t, "existing-correlation-id", rr.Header().Get(CorrelationIDHeader)) +} + +func TestLoggingMiddlewareErrorStatus(t *testing.T) { + logger := logrus.New() + logger.SetOutput(os.NewFile(0, os.DevNull)) + + middleware := NewLoggingMiddleware(logger) + + handler := middleware.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + + req := httptest.NewRequest(http.MethodGet, "/error", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) +} + +func TestResponseWriter(t *testing.T) { + recorder := httptest.NewRecorder() + rw := newResponseWriter(recorder) + + assert.Equal(t, http.StatusOK, rw.statusCode) + + rw.WriteHeader(http.StatusCreated) + assert.Equal(t, http.StatusCreated, rw.statusCode) + + n, err := rw.Write([]byte("hello")) + assert.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, 5, rw.size) +} + +func TestSanitizeQuery(t *testing.T) { + input := map[string][]string{ + "short": {"value"}, + "long": {strings.Repeat("a", 200)}, + "empty": {}, + "special": {"hello world"}, + } + + result := sanitizeQuery(input) + + assert.Equal(t, "value", result["short"]) + assert.Equal(t, 103, len(result["long"])) // 100 + "..." + assert.NotContains(t, result, "empty") + assert.Equal(t, "hello world", result["special"]) +} + +func TestIsValidLogLevel(t *testing.T) { + assert.True(t, IsValidLogLevel("debug")) + assert.True(t, IsValidLogLevel("info")) + assert.True(t, IsValidLogLevel("warn")) + assert.True(t, IsValidLogLevel("error")) + assert.True(t, IsValidLogLevel("DEBUG")) + assert.False(t, IsValidLogLevel("invalid")) + assert.False(t, IsValidLogLevel("")) +} + +func TestIsValidLogFormat(t *testing.T) { + assert.True(t, IsValidLogFormat("text")) + assert.True(t, IsValidLogFormat("json")) + assert.True(t, IsValidLogFormat("TEXT")) + assert.False(t, IsValidLogFormat("invalid")) + assert.False(t, IsValidLogFormat("")) +} + +func TestRequestScopedLogger(t *testing.T) { + baseLogger := logrus.New() + ctx := context.Background() + + // Without context values + logger := RequestScopedLogger(baseLogger, ctx) + assert.NotNil(t, logger) + + // With request ID + ctx = WithRequestID(ctx, "req-123") + logger = RequestScopedLogger(baseLogger, ctx) + assert.NotNil(t, logger) + + // With correlation ID + ctx = WithCorrelationID(ctx, "corr-456") + logger = RequestScopedLogger(baseLogger, ctx) + assert.NotNil(t, logger) +} + +func TestLogHTTPRequest(t *testing.T) { + logger := logrus.New() + logger.SetOutput(os.NewFile(0, os.DevNull)) + + info := HTTPRequestInfo{ + Method: http.MethodGet, + Path: "/test", + Status: http.StatusOK, + Duration: 100 * time.Millisecond, + DurationMs: 100, + RequestID: "req-123", + Correlation: "corr-456", + UserAgent: "test-agent", + RemoteAddr: "127.0.0.1", + ResponseSize: 1024, + } + + // Should not panic + LogHTTPRequest(logger, info) + + // Test with error status + info.Status = http.StatusInternalServerError + LogHTTPRequest(logger, info) + + // Test with warning status + info.Status = http.StatusBadRequest + LogHTTPRequest(logger, info) +} + +func TestLoggerFromContext(t *testing.T) { + defaultLogger := logrus.New() + + // With nil context + logger := LoggerFromContext(nil, defaultLogger) + assert.Equal(t, defaultLogger, logger) + + // With empty context + ctx := context.Background() + logger = LoggerFromContext(ctx, defaultLogger) + assert.Equal(t, defaultLogger, logger) + + // With logger in context + ctxLogger := logrus.New() + ctx = WithLogger(ctx, ctxLogger) + logger = LoggerFromContext(ctx, defaultLogger) + assert.Equal(t, ctxLogger, logger) +} + +func TestDefaultLogger(t *testing.T) { + logger := DefaultLogger() + assert.NotNil(t, logger) + assert.Equal(t, logrus.InfoLevel, logger.Level) +} + +func TestLoggingConfigApplyDefaults(t *testing.T) { + config := LoggingConfig{} + config.ApplyDefaults() + + assert.Equal(t, LogLevelInfo, config.Level) + assert.Equal(t, LogFormatText, config.Format) +} diff --git a/pkg/observability/observability.go b/pkg/observability/observability.go index 4e64d89..72f86dd 100644 --- a/pkg/observability/observability.go +++ b/pkg/observability/observability.go @@ -21,6 +21,8 @@ type Service interface { Start(ctx context.Context) error // Stop gracefully shuts down the metrics server. Stop() error + // ConfigureLogging configures the global logger based on config. + ConfigureLogging() (*logrus.Logger, error) } // service implements the Service interface. @@ -127,5 +129,16 @@ func (s *service) readyHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"status":"ready"}`)) } +// ConfigureLogging configures the global logger based on config. +func (s *service) ConfigureLogging() (*logrus.Logger, error) { + loggerCfg := LoggerConfig{ + Level: LogLevel(s.cfg.Logging.Level), + Format: LogFormat(s.cfg.Logging.Format), + OutputPath: s.cfg.Logging.OutputPath, + } + + return ConfigureLogger(loggerCfg) +} + // Compile-time interface compliance check. var _ Service = (*service)(nil) diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index ebbf5f7..44d1353 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" + "github.com/ethpandaops/mcp/pkg/observability" "github.com/ethpandaops/mcp/pkg/proxy/handlers" "github.com/ethpandaops/mcp/pkg/types" ) @@ -192,9 +193,13 @@ func (s *server) buildMiddlewareChain() func(http.Handler) http.Handler { h = s.rateLimiter.Middleware()(h) } - // Authentication (outermost). + // Authentication. h = s.authenticator.Middleware()(h) + // Request logging (outermost). + loggingMiddleware := observability.NewLoggingMiddleware(s.log.(*logrus.Logger)) + h = loggingMiddleware.Middleware()(h) + return h } } diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go index 715d1b8..8291e67 100644 --- a/pkg/proxy/server_config.go +++ b/pkg/proxy/server_config.go @@ -37,6 +37,9 @@ type ServerConfig struct { // Audit holds audit logging configuration. Audit AuditConfig `yaml:"audit"` + + // Logging holds logging configuration. + Logging LoggingConfig `yaml:"logging"` } // HTTPServerConfig holds HTTP server configuration. @@ -132,6 +135,18 @@ type AuditConfig struct { MaxQueryLength int `yaml:"max_query_length,omitempty"` } +// LoggingConfig holds logging configuration for the proxy. +type LoggingConfig struct { + // Level is the log level (debug, info, warn, error). + Level string `yaml:"level"` + + // Format is the log format (text or json). + Format string `yaml:"format"` + + // OutputPath is the path to log file (empty = stdout). + OutputPath string `yaml:"output_path,omitempty"` +} + // ApplyDefaults sets default values for the server config. func (c *ServerConfig) ApplyDefaults() { // Server defaults. @@ -179,6 +194,14 @@ func (c *ServerConfig) ApplyDefaults() { c.Audit.MaxQueryLength = 500 } + // Logging defaults. + if c.Logging.Level == "" { + c.Logging.Level = "info" + } + if c.Logging.Format == "" { + c.Logging.Format = "text" + } + // ClickHouse defaults. for i := range c.ClickHouse { if c.ClickHouse[i].Port == 0 { diff --git a/pkg/server/server.go b/pkg/server/server.go index 271c3dc..db633a9 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -390,6 +390,10 @@ func (s *service) runStreamableHTTP(ctx context.Context) error { func (s *service) buildHTTPHandler(mcpHandler http.Handler) http.Handler { r := chi.NewRouter() + // Apply logging middleware first to capture all requests. + loggingMiddleware := observability.NewLoggingMiddleware(s.log.(*logrus.Logger)) + r.Use(loggingMiddleware.Middleware()) + // Apply auth middleware if enabled. if s.auth != nil && s.auth.Enabled() { r.Use(s.auth.Middleware()) @@ -423,6 +427,10 @@ func (s *service) buildHTTPHandler(mcpHandler http.Handler) http.Handler { func (s *service) buildStreamableHTTPHandler(mcpHandler http.Handler) http.Handler { r := chi.NewRouter() + // Apply logging middleware first to capture all requests. + loggingMiddleware := observability.NewLoggingMiddleware(s.log.(*logrus.Logger)) + r.Use(loggingMiddleware.Middleware()) + // Apply auth middleware if enabled. if s.auth != nil && s.auth.Enabled() { r.Use(s.auth.Middleware()) diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml index 00a1387..d12c50b 100644 --- a/proxy-config.example.yaml +++ b/proxy-config.example.yaml @@ -95,3 +95,9 @@ audit: enabled: true log_queries: true max_query_length: 500 + +# Logging configuration +logging: + level: info # debug, info, warn, error + format: text # text or json + output_path: "" # Optional: file path for log output (default: stdout)