diff --git a/README.md b/README.md index 4f85395..01ccd48 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,17 @@ The server supports automated health checks for backend services. You can config Authorization: "Bearer abc123" ``` +### 8. OpenTelemetry Integration +The server includes built-in support for OpenTelemetry, enabling comprehensive observability through distributed tracing, metrics, and logging. This integration helps monitor application performance, troubleshoot issues, and understand system behavior in distributed environments. + +```yaml +version: '...' + +open_telemetry: + endpoint: "localhost:4317" + sample_ratio: 0.01 # == 1% +``` + ## Configuration Example Here’s a generic example of how you can configure the reverse proxy: @@ -144,6 +155,10 @@ ssl: keyfile: /path/to/your/ssl/keyfile certfile: /path/to/your/ssl/certfile +open_telemetry: + endpoint: "localhost:4317" + sample_ratio: 0.01 # == 1% + services: - domain: your-domain.com endpoints: diff --git a/cmd/config.yaml b/cmd/config.yaml index b3cff2d..2ac33d5 100644 --- a/cmd/config.yaml +++ b/cmd/config.yaml @@ -4,6 +4,10 @@ version: '0.0.1' host: localhost port: 8004 +open_telemetry: + endpoint: "localhost:4317" + sample_ratio: 0.03 + services: - domain: localhost endpoints: diff --git a/cmd/main.go b/cmd/main.go index 9afb5ea..9d3645a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,7 +17,7 @@ func main() { log.Default().Println("Config loaded successfully") - server := gatego.New(config) + server := gatego.New(config, version) err = server.Run() if err != nil { diff --git a/config-schema.json b/config-schema.json index 7d1cdb1..14a83d3 100644 --- a/config-schema.json +++ b/config-schema.json @@ -32,6 +32,21 @@ ], "description": "SSL configuration for the server." }, + "open_telemetry": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "description": "GRPC connection string for open telemetry collection agent" + }, + "sample_ratio": { + "type":"number", + "exclusiveMinimum": 0, + "maximum": 1 + } + }, + "required": ["sample_ratio", "endpoint"] + }, "services": { "type": "array", "items": { diff --git a/config/config.go b/config/config.go index 53095be..721fdbc 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( "os" "regexp" "slices" + "strconv" "strings" "time" @@ -180,11 +181,40 @@ type TLS struct { CertFile *string `yaml:"certfile"` } +type OTEL struct { + Endpoint string `yaml:"endpoint"` + SampleRatio float64 `yaml:"sample_ratio"` +} + +func (otel OTEL) validate() error { + if len(otel.Endpoint) > 0 { + if err := isValidGRPCAddress(otel.Endpoint); err != nil { + return err + } + } + + if otel.SampleRatio < 0 { + return errors.New("OpenTelemetry sample ratio MUST be above 0") + } + + if otel.SampleRatio == 0 { + return errors.New("OpenTelemetry sample ratio is missing or equales to 0") + } + + if otel.SampleRatio > 1 { + return errors.New("OpenTelemetry sample ratio CAN NOT be above 1") + } + + return nil +} + type Config struct { Version string `yaml:"version"` Host string `yaml:"host"` // listen host Port uint16 `yaml:"port"` // listen port + OTEL *OTEL `yaml:"open_telemetry"` + // TLS options SSL TLS `yaml:"ssl"` @@ -210,6 +240,12 @@ func (c Config) Validate(currentVersion string) error { return errors.New("host is required") } + if c.OTEL != nil { + if err := (*c.OTEL).validate(); err != nil { + return err + } + } + for _, service := range c.Services { if err := service.validate(); err != nil { return err @@ -317,3 +353,55 @@ func isValidMethod(method string) bool { return slices.Contains(methods, method) } + +func isValidGRPCAddress(address string) error { + if address == "" { + return fmt.Errorf("address cannot be empty") + } + + // Split host and port + host, portStr, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid address format: %v", err) + } + + // Validate port + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port number: %v", err) + } + if port < 1 || port > 65535 { + return fmt.Errorf("port number must be between 1 and 65535") + } + + // Empty host means localhost/0.0.0.0, which is valid + if host == "" { + return nil + } + + // Check if host is IPv4 or IPv6 + if ip := net.ParseIP(host); ip != nil { + return nil + } + + // Validate hostname format + hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$`) + if !hostnameRegex.MatchString(host) { + return fmt.Errorf("invalid hostname format") + } + + // Check hostname length + if len(host) > 253 { + return fmt.Errorf("hostname too long") + } + + // Validate hostname parts + parts := strings.Split(host, ".") + for _, part := range parts { + if len(part) > 63 { + return fmt.Errorf("hostname label too long") + } + } + + return nil +} diff --git a/dev/otel/docker-compose.yaml b/dev/otel/docker-compose.yaml new file mode 100644 index 0000000..54d60d1 --- /dev/null +++ b/dev/otel/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + # Jaeger + jaeger: + image: jaegertracing/all-in-one:latest + ports: + - "16686:16686" # Jaeger UI + - "14250:14250" # Model used by collector + environment: + - COLLECTOR_OTLP_ENABLED=true + + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP http receiver + - "8888:8888" # Prometheus metrics exposed by the collector + - "8889:8889" # Prometheus exporter metrics + - "13133:13133" # Health check extension + depends_on: + - jaeger \ No newline at end of file diff --git a/dev/otel/otel-collector-config.yaml b/dev/otel/otel-collector-config.yaml new file mode 100644 index 0000000..0579f2c --- /dev/null +++ b/dev/otel/otel-collector-config.yaml @@ -0,0 +1,38 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 1s + send_batch_size: 1024 + + memory_limiter: + check_interval: 1s + limit_mib: 1000 + spike_limit_mib: 200 + +exporters: + otlp: + endpoint: "jaeger:4317" + tls: + insecure: true + + debug: + verbosity: detailed + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [otlp, debug] \ No newline at end of file diff --git a/gatego.go b/gatego.go index 26e23fb..ebfaae2 100644 --- a/gatego.go +++ b/gatego.go @@ -1,6 +1,7 @@ package gatego import ( + "context" "fmt" "log" "net/http" @@ -12,14 +13,17 @@ import ( type GateGo struct { config config.Config + ctx context.Context } -func New(config config.Config) *GateGo { - return &GateGo{config: config} +func New(config config.Config, version string) *GateGo { + ctx := context.Background() + ctx = context.WithValue(ctx, "version", version) + return &GateGo{config: config, ctx: ctx} } func (gg GateGo) Run() error { - table, err := NewHandlersTable(gg.config.Services) + table, err := NewHandlersTable(gg.ctx, gg.config.OTEL, gg.config.Services) if err != nil { return err } diff --git a/go.mod b/go.mod index fcf66da..85418a0 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,24 @@ require gopkg.in/yaml.v3 v3.0.1 require ( github.com/hashicorp/go-version v1.7.0 github.com/tdewolff/minify/v2 v2.21.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 + google.golang.org/grpc v1.67.1 ) -require golang.org/x/text v0.19.0 // indirect +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect @@ -27,6 +42,9 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.9.0 github.com/tdewolff/parse/v2 v2.7.17 // indirect + go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 + go.opentelemetry.io/otel/sdk v1.31.0 golang.org/x/net v0.30.0 golang.org/x/time v0.7.0 ) diff --git a/go.sum b/go.sum index 6599925..a516bb1 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,28 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4= github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= @@ -32,8 +43,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tdewolff/minify/v2 v2.21.0 h1:nAPP1UVx0aK1xsQh/JiG3xyEnnqWw+agPstn+V6Pkto= @@ -45,12 +56,38 @@ github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03 github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= diff --git a/handler.go b/handler.go index 621f7e3..258de76 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package gatego import ( + "context" "errors" "net/http" "os" @@ -13,16 +14,6 @@ import ( var ErrUnsupportedBaseHandler = errors.New("base handler unsupported") -// func loggingMiddleware(next http.Handler) http.Handler { -// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// rc := middlewares.NewResponseCapture(w) -// next.ServeHTTP(rc, r) - -// w.WriteHeader(rc.Status()) -// w.Write(rc.Buffer()) -// }) -// } - func GetBaseHandler(service config.Service, path config.Path) (http.Handler, error) { if path.Destination != nil && *path.Destination != "" { return handlers.NewProxy(service, path) @@ -37,7 +28,7 @@ func GetBaseHandler(service config.Service, path config.Path) (http.Handler, err } } -func NewHandler(service config.Service, path config.Path) (http.Handler, error) { +func NewHandler(ctx context.Context, otel *config.OTEL, service config.Service, path config.Path) (http.Handler, error) { handler, err := GetBaseHandler(service, path) if err != nil { return nil, err @@ -47,16 +38,37 @@ func NewHandler(service config.Service, path config.Path) (http.Handler, error) handlerWithMiddlewares.Add(middlewares.NewLoggingMiddleware(os.Stdout)) + // Open Telemetry + if otel != nil { + otelMiddleware, err := middlewares.NewOpenTelemetryMiddleware( + ctx, + middlewares.OTELConfig{ + ServiceVersion: ctx.Value("version").(string), + Endpoint: otel.Endpoint, + ServiceDomain: service.Domain, + BasePath: path.Path, + SampleRatio: otel.SampleRatio, + }, + ) + if err != nil { + return nil, err + } + handlerWithMiddlewares.Add(otelMiddleware) + } + + // Timeout if path.Timeout == 0 { path.Timeout = config.DefaultTimeout } handlerWithMiddlewares.Add(middlewares.NewTimeoutMiddleware(path.Timeout)) + // Max request size if path.MaxSize == 0 { path.MaxSize = config.DefaultMaxRequestSize } handlerWithMiddlewares.Add(middlewares.NewRequestSizeLimitMiddleware(path.MaxSize)) + // Rate limits if len(path.RateLimits) > 0 { ratelimiter, err := middlewares.NewRateLimitMiddleware(path.RateLimits) if err != nil { @@ -65,18 +77,22 @@ func NewHandler(service config.Service, path config.Path) (http.Handler, error) handlerWithMiddlewares.Add(ratelimiter) } + // Add headers if path.Headers != nil { handlerWithMiddlewares.Add(middlewares.NewAddHeadersMiddleware(*path.Headers)) } + // GZIP compression if path.Gzip != nil && *path.Gzip { handlerWithMiddlewares.Add(middlewares.GzipMiddleware) } + // Remove response headers if len(path.OmitHeaders) > 0 { handlerWithMiddlewares.Add(middlewares.NewOmitHeadersMiddleware(path.OmitHeaders)) } + // Minify files minifyConfig := middlewares.MinifyConfig{ ALL: slices.Contains(path.Minify, "all"), JS: slices.Contains(path.Minify, "js"), @@ -88,6 +104,7 @@ func NewHandler(service config.Service, path config.Path) (http.Handler, error) } handlerWithMiddlewares.Add(middlewares.NewMinifyMiddleware(minifyConfig)) + // OpenAPI validation if path.OpenAPI != nil { openapiMiddleware, err := middlewares.NewOpenAPIValidationMiddleware(*path.OpenAPI) if err != nil { @@ -96,11 +113,10 @@ func NewHandler(service config.Service, path config.Path) (http.Handler, error) handlerWithMiddlewares.Add(openapiMiddleware) } + // Response cache if path.Cache { handlerWithMiddlewares.Add(middlewares.NewCacheMiddleware()) } - // handlerWithMiddlewares.Add(loggingMiddleware) - return handlerWithMiddlewares, nil } diff --git a/handlertable.go b/handlertable.go index 08e2fbe..08c40e7 100644 --- a/handlertable.go +++ b/handlertable.go @@ -1,6 +1,7 @@ package gatego import ( + "context" "net/http" "strings" @@ -14,7 +15,7 @@ func cleanDomain(domain string) string { return removePort(strings.ToLower(domain)) } -func NewHandlersTable(servicesConfig []config.Service) (HandlerTable, error) { +func NewHandlersTable(ctx context.Context, otel *config.OTEL, servicesConfig []config.Service) (HandlerTable, error) { servers := make(map[string]*pathtree.Trie[http.Handler]) for _, service := range servicesConfig { @@ -25,7 +26,7 @@ func NewHandlersTable(servicesConfig []config.Service) (HandlerTable, error) { servers[cleanedDomain] = servicePathTree for _, path := range service.Paths { - handler, err := NewHandler(service, path) + handler, err := NewHandler(ctx, otel, service, path) if err != nil { return nil, err } diff --git a/middlewares/otel.go b/middlewares/otel.go new file mode 100644 index 0000000..97bead1 --- /dev/null +++ b/middlewares/otel.go @@ -0,0 +1,126 @@ +package middlewares + +import ( + "context" + "fmt" + "net/http" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.4.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/credentials" +) + +const ( + serviceName = "gatego" + spanPrefix = "gatego" + defaultTimeout = 5 * time.Second +) + +type OTELConfig struct { + ServiceVersion string + Endpoint string // OTLP gRPC endpoint + SampleRatio float64 + Credentials credentials.TransportCredentials + ServiceDomain string + BasePath string +} + +func NewOpenTelemetryMiddleware(ctx context.Context, config OTELConfig) (Middleware, error) { + // Connection security option + securityOpt := otlptracegrpc.WithInsecure() + if config.Credentials != nil { + securityOpt = otlptracegrpc.WithTLSCredentials(config.Credentials) // TODO: allow creds + } + + exporter, err := otlptrace.New( + context.Background(), + otlptracegrpc.NewClient( + otlptracegrpc.WithEndpoint(config.Endpoint), // OTLP gRPC endpoint + securityOpt, + otlptracegrpc.WithTimeout(defaultTimeout), + ), + ) + if err != nil { + return nil, err + } + + resource := resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String(serviceName), + semconv.TelemetrySDKLanguageGo, + attribute.String("version", config.ServiceVersion), + ) + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(resource), + sdktrace.WithSampler(sdktrace.TraceIDRatioBased(config.SampleRatio)), + ) + otel.SetTracerProvider(tp) + + tracer := tp.Tracer(serviceName) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Create span for request + spanName := fmt.Sprintf("%s_%s_%s", spanPrefix, r.Method, r.URL.Path) + ctx, span := tracer.Start( + r.Context(), + spanName, + trace.WithAttributes(semconv.NetAttributesFromHTTPRequest("", r)...), + trace.WithSpanKind(trace.SpanKindServer), + ) + defer span.End() + + // Add request-specific attributes + attrs := make([]attribute.KeyValue, 0) + attrs = append(attrs, semconv.HTTPUserAgentKey.String(r.UserAgent())) + attrs = append(attrs, semconv.HTTPServerAttributesFromHTTPRequest(config.ServiceDomain, config.BasePath, r)...) + span.SetAttributes( + attrs..., + ) + + // Handle panic recovery + defer func() { + if err := recover(); err != nil { + span.SetStatus(codes.Error, fmt.Sprintf("panic: %v", err)) + span.RecordError(fmt.Errorf("%v", err)) + panic(err) // Re-panic after recording error + } + }() + + // Add span to request context + rc := NewRecorder() + next.ServeHTTP(rc, r.WithContext(ctx)) + + // Set status and attributes based on response code + statusCode := rc.Result().StatusCode + span.SetAttributes(semconv.HTTPAttributesFromHTTPStatusCode(statusCode)...) + if statusCode >= 400 { + span.SetStatus(codes.Error, http.StatusText(statusCode)) + if statusCode >= 500 { + span.RecordError(fmt.Errorf("server error: %d", statusCode)) + } + } else { + span.SetStatus(codes.Ok, "") + } + + // Add response information + span.SetAttributes( + attribute.Int64("http.response_size", rc.Result().ContentLength), + attribute.String("http.response_content_type", rc.Result().Header.Get("Content-Type")), + ) + + // Return response + rc.WriteTo(w) + }) + }, nil +}