diff --git a/README.md b/README.md index 11cca88..9c49c7c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a - [Endpoints](#endpoints) - [Features](#features) - [Configuration](#configuration) + - [Tracing](#tracing) - [Speed](#speed) - [Caching](#caching) - [Read-only endpoint](#read-only-endpoint) @@ -107,6 +108,7 @@ In this case, both proxy and websockets will be available under the `/v1/graphql | monitor | Extracting user id from JWT token and adding it as a label to metrics | | monitor | Extracting the query name and type and adding it as a label to metrics| | monitor | Calculating the query duration and adding it to the metrics | +| monitor | OpenTelemetry tracing support with configurable endpoint | | speed | Caching the queries, together with per-query cache and TTL | | speed | Support for READ ONLY graphql endpoint | | security | Blocking schema introspection | @@ -155,6 +157,26 @@ You can still use the non-prefixed environment variables in the spirit of the ba | `HASURA_EVENT_CLEANER` | Enable the hasura event cleaner | `false` | | `HASURA_EVENT_CLEANER_OLDER_THAN` | The interval for the hasura event cleaner (in days) | `1` | | `HASURA_EVENT_METADATA_DB` | URL to the hasura metadata database | `postgresql://localhost:5432/hasura` | +| `ENABLE_TRACE` | Enable OpenTelemetry tracing | `false` | +| `TRACE_ENDPOINT` | OpenTelemetry collector endpoint | `localhost:4317` | + +### Tracing + +The proxy supports OpenTelemetry tracing to help monitor and debug requests. When enabled, it will create spans for each proxied request and send them to the configured OpenTelemetry collector. + +To use tracing: + +1. Enable tracing by setting `ENABLE_TRACE=true` +2. Configure the OpenTelemetry collector endpoint using `TRACE_ENDPOINT` (defaults to `localhost:4317`) +3. Include trace context in your requests using the `X-Trace-Span` header with the following format: + +```json +{ + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" +} +``` + +The proxy will extract the trace context from the header and create child spans for each request, allowing you to trace requests through your system. ### Speed diff --git a/go.mod b/go.mod index 2378bb9..bf806ee 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy -go 1.22.4 +go 1.22.7 + +toolchain go1.23.4 require ( github.com/VictoriaMetrics/metrics v1.35.1 @@ -17,39 +19,53 @@ require ( github.com/lukaszraczylo/go-ratecounter v0.1.12 github.com/lukaszraczylo/go-simple-graphql v1.2.37 github.com/redis/go-redis/v9 v9.7.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/valyala/fasthttp v1.58.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + google.golang.org/grpc v1.69.2 ) require ( github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/andybalholm/brotli v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gookit/color v1.5.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/protobuf v1.36.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 40e9b6c..d392fb7 100644 --- a/go.sum +++ b/go.sum @@ -12,14 +12,20 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms= @@ -28,6 +34,10 @@ github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27X github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= @@ -36,6 +46,8 @@ github.com/gookit/goutil v0.6.18 h1:MUVj0G16flubWT8zYVicIuisUiHdgirPAkmnfD2kKgw= github.com/gookit/goutil v0.6.18/go.mod h1:AY/5sAwKe7Xck+mEbuxj0n/bc3qwrGNe3Oeulln7zBA= github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -70,13 +82,13 @@ github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93Ge github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= @@ -93,6 +105,26 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +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/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= @@ -109,6 +141,14 @@ golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/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/graphql.go b/graphql.go index a3d8dc7..43d55e2 100644 --- a/graphql.go +++ b/graphql.go @@ -38,7 +38,7 @@ func prepareQueriesAndExemptions() { // Process allowed URLs for _, u := range cfg.Server.AllowURLs { - allowedUrls[u] = struct{}{} + allowedUrls[u] = struct{}{} } } @@ -178,33 +178,33 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult { func checkSelections(c *fiber.Ctx, selections []ast.Selection) bool { for _, s := range selections { - switch sel := s.(type) { - case *ast.Field: - fieldName := strings.ToLower(sel.Name.Value) - if _, exists := introspectionQueries[fieldName]; exists { - if len(cfg.Security.IntrospectionAllowed) > 0 { - _, allowed := introspectionAllowedQueries[fieldName] - if !allowed { - return true // Block if this field isn't allowed - } - // Even if this field is allowed, we need to check its nested selections - } else { - return true // Block if no allowlist exists - } - } - // Always check nested selections - if sel.SelectionSet != nil { - if checkSelections(c, sel.GetSelectionSet().Selections) { - return true - } - } - case *ast.InlineFragment: - if sel.SelectionSet != nil { - if checkSelections(c, sel.GetSelectionSet().Selections) { - return true - } + switch sel := s.(type) { + case *ast.Field: + fieldName := strings.ToLower(sel.Name.Value) + if _, exists := introspectionQueries[fieldName]; exists { + if len(cfg.Security.IntrospectionAllowed) > 0 { + _, allowed := introspectionAllowedQueries[fieldName] + if !allowed { + return true // Block if this field isn't allowed } + // Even if this field is allowed, we need to check its nested selections + } else { + return true // Block if no allowlist exists + } } + // Always check nested selections + if sel.SelectionSet != nil { + if checkSelections(c, sel.GetSelectionSet().Selections) { + return true + } + } + case *ast.InlineFragment: + if sel.SelectionSet != nil { + if checkSelections(c, sel.GetSelectionSet().Selections) { + return true + } + } + } } return false } diff --git a/main.go b/main.go index f9a97b5..1e6ca6c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "os" "strings" @@ -13,11 +14,13 @@ import ( libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config" libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" + libpack_tracing "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing" ) var ( - cfg *config - once sync.Once + cfg *config + once sync.Once + tracer *libpack_tracing.TracingSetup ) // getDetailsFromEnv retrieves the value from the environment or returns the default. @@ -102,8 +105,38 @@ func parseConfig() { c.HasuraEventCleaner.Enable = getDetailsFromEnv("HASURA_EVENT_CLEANER", false) c.HasuraEventCleaner.ClearOlderThan = getDetailsFromEnv("HASURA_EVENT_CLEANER_OLDER_THAN", 1) c.HasuraEventCleaner.EventMetadataDb = getDetailsFromEnv("HASURA_EVENT_METADATA_DB", "") + // Tracing configuration + c.Tracing.Enable = getDetailsFromEnv("ENABLE_TRACE", false) + c.Tracing.Endpoint = getDetailsFromEnv("TRACE_ENDPOINT", "localhost:4317") cfg = &c + // Initialize tracing if enabled + if cfg.Tracing.Enable { + if cfg.Tracing.Endpoint == "" { + cfg.Logger.Warning(&libpack_logging.LogMessage{ + Message: "Tracing endpoint not configured, using default localhost:4317", + }) + cfg.Tracing.Endpoint = "localhost:4317" + } + + var err error + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tracer, err = libpack_tracing.NewTracing(ctx, cfg.Tracing.Endpoint) + if err != nil { + cfg.Logger.Error(&libpack_logging.LogMessage{ + Message: "Failed to initialize tracing", + Pairs: map[string]interface{}{"error": err.Error()}, + }) + } else { + cfg.Logger.Info(&libpack_logging.LogMessage{ + Message: "Tracing initialized", + Pairs: map[string]interface{}{"endpoint": cfg.Tracing.Endpoint}, + }) + } + } + // Initialize cache if enabled if cfg.Cache.CacheEnable || cfg.Cache.CacheRedisEnable { cacheConfig := &libpack_cache.CacheConfig{ @@ -133,6 +166,16 @@ func main() { StartMonitoringServer() time.Sleep(5 * time.Second) StartHTTPProxy() + + // Cleanup tracing on exit + if tracer != nil { + if err := tracer.Shutdown(context.Background()); err != nil { + cfg.Logger.Error(&libpack_logging.LogMessage{ + Message: "Error shutting down tracer", + Pairs: map[string]interface{}{"error": err.Error()}, + }) + } + } } // ifNotInTest checks if the program is not running in a test environment. diff --git a/proxy.go b/proxy.go index c0902d6..f06135d 100644 --- a/proxy.go +++ b/proxy.go @@ -3,17 +3,21 @@ package main import ( "bytes" "compress/gzip" + "context" "crypto/tls" "fmt" "io" "net/url" "time" + "go.opentelemetry.io/otel/trace" + "github.com/avast/retry-go/v4" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/proxy" libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring" + libpack_tracing "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing" "github.com/valyala/fasthttp" ) @@ -36,6 +40,30 @@ func createFasthttpClient(timeout int) *fasthttp.Client { // proxyTheRequest handles the request proxying logic. func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error { + var span trace.Span + ctx := context.Background() + + if cfg.Tracing.Enable && tracer != nil { + // Extract trace information from header + if traceHeader := c.Get("X-Trace-Span"); traceHeader != "" { + spanInfo, err := libpack_tracing.ParseTraceHeader(traceHeader) + if err != nil { + cfg.Logger.Warning(&libpack_logger.LogMessage{ + Message: "Failed to parse trace header", + Pairs: map[string]interface{}{"error": err.Error()}, + }) + } else { + if spanCtx, err := tracer.ExtractSpanContext(spanInfo); err == nil { + ctx = trace.ContextWithSpanContext(ctx, spanCtx) + } + } + } + + // Start a new span + span, ctx = tracer.StartSpan(ctx, "proxy_request") + defer span.End() + } + if !checkAllowedURLs(c) { cfg.Logger.Error(&libpack_logger.LogMessage{ Message: "Request blocked", diff --git a/server.go b/server.go index 99fca0c..05be1ef 100644 --- a/server.go +++ b/server.go @@ -162,7 +162,7 @@ func processGraphQLRequest(c *fiber.Ctx) error { } } - wasCached := false + wasCached := false //nolint:ineffassign if parsedResult.cacheRefresh { cfg.Logger.Debug(&libpack_logger.LogMessage{ diff --git a/struct_config.go b/struct_config.go index 0b0d65e..97104d3 100644 --- a/struct_config.go +++ b/struct_config.go @@ -12,8 +12,12 @@ type config struct { Logger *libpack_logging.Logger LogLevel string Monitoring *libpack_monitoring.MetricsSetup - Api struct{ BannedUsersFile string } - Client struct { + Tracing struct { + Enable bool + Endpoint string + } + Api struct{ BannedUsersFile string } + Client struct { GQLClient *graphql.BaseClient FastProxyClient *fasthttp.Client JWTUserClaimPath string diff --git a/tracing/tracing.go b/tracing/tracing.go new file mode 100644 index 0000000..55854e3 --- /dev/null +++ b/tracing/tracing.go @@ -0,0 +1,113 @@ +package tracing + +import ( + "context" + "encoding/json" + "fmt" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type TracingSetup struct { + tracerProvider *sdktrace.TracerProvider + tracer trace.Tracer +} + +type TraceSpanInfo struct { + TraceParent string `json:"traceparent"` +} + +// NewTracing creates a new tracing setup with OTLP exporter +func NewTracing(ctx context.Context, endpoint string) (*TracingSetup, error) { + if ctx == nil { + return nil, fmt.Errorf("context cannot be nil") + } + if endpoint == "" { + return nil, fmt.Errorf("endpoint cannot be empty") + } + + conn, err := grpc.DialContext(ctx, endpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + grpc.WithReturnConnectionError(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create gRPC connection to collector: %w", err) + } + + exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("failed to create trace exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName("graphql-monitoring-proxy"), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tracerProvider) + otel.SetTextMapPropagator(propagation.TraceContext{}) + + tracer := tracerProvider.Tracer("graphql-monitoring-proxy") + + return &TracingSetup{ + tracerProvider: tracerProvider, + tracer: tracer, + }, nil +} + +// ExtractSpanContext extracts span context from TraceSpanInfo +func (ts *TracingSetup) ExtractSpanContext(spanInfo *TraceSpanInfo) (trace.SpanContext, error) { + carrier := propagation.MapCarrier{ + "traceparent": spanInfo.TraceParent, + } + ctx := context.Background() + ctx = otel.GetTextMapPropagator().Extract(ctx, carrier) + spanCtx := trace.SpanContextFromContext(ctx) + if !spanCtx.IsValid() { + return trace.SpanContext{}, fmt.Errorf("invalid span context") + } + return spanCtx, nil +} + +// ParseTraceHeader parses X-Trace-Span header content +func ParseTraceHeader(headerContent string) (*TraceSpanInfo, error) { + var spanInfo TraceSpanInfo + if err := json.Unmarshal([]byte(headerContent), &spanInfo); err != nil { + return nil, fmt.Errorf("failed to parse trace header: %w", err) + } + return &spanInfo, nil +} + +// Shutdown cleanly shuts down the tracer provider +func (ts *TracingSetup) Shutdown(ctx context.Context) error { + if ts.tracerProvider == nil { + return nil + } + return ts.tracerProvider.Shutdown(ctx) +} + +// StartSpan starts a new span with the given name and parent context +func (ts *TracingSetup) StartSpan(ctx context.Context, name string) (trace.Span, context.Context) { + if ts.tracer == nil { + return trace.SpanFromContext(ctx), ctx + } + ctx, span := ts.tracer.Start(ctx, name) + return span, ctx +} diff --git a/tracing/tracing_test.go b/tracing/tracing_test.go new file mode 100644 index 0000000..dff062e --- /dev/null +++ b/tracing/tracing_test.go @@ -0,0 +1,102 @@ +package tracing + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" +) + +func TestParseTraceHeader(t *testing.T) { + tests := []struct { + name string + header string + want *TraceSpanInfo + wantErr bool + }{ + { + name: "valid trace header", + header: `{"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}`, + want: &TraceSpanInfo{ + TraceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + }, + wantErr: false, + }, + { + name: "invalid json", + header: `{"traceparent": invalid}`, + want: nil, + wantErr: true, + }, + { + name: "empty header", + header: "", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTraceHeader(tt.header) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTraceHeader() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(tt.want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("ParseTraceHeader() = %v, want %v", string(gotJSON), string(wantJSON)) + } + } + }) + } +} + +func TestNewTracing(t *testing.T) { + // Skip actual connection tests since they require a running collector + t.Run("empty endpoint", func(t *testing.T) { + ctx := context.Background() + _, err := NewTracing(ctx, "") + assert.Error(t, err, "Expected error for empty endpoint") + assert.Contains(t, err.Error(), "endpoint cannot be empty") + }) + + t.Run("nil context", func(t *testing.T) { + _, err := NewTracing(nil, "localhost:4317") + assert.Error(t, err, "Expected error for nil context") + assert.Contains(t, err.Error(), "context cannot be nil") + }) +} + +func TestTracingSetup_ExtractSpanContext(t *testing.T) { + ts := &TracingSetup{} + spanInfo := &TraceSpanInfo{ + TraceParent: "invalid-traceparent", + } + + _, err := ts.ExtractSpanContext(spanInfo) + assert.Error(t, err, "Expected error for invalid traceparent") + assert.Contains(t, err.Error(), "invalid span context") +} + +func TestTracingSetup_StartSpan(t *testing.T) { + ts := &TracingSetup{} + ctx := context.Background() + + span, newCtx := ts.StartSpan(ctx, "test-span") + assert.NotNil(t, span, "Expected non-nil span even when tracer is nil") + assert.NotNil(t, newCtx, "Expected non-nil context") + assert.Equal(t, trace.SpanFromContext(ctx), span, "Expected span from context when tracer is nil") +} + +func TestTracingSetup_Shutdown(t *testing.T) { + ts := &TracingSetup{} + ctx := context.Background() + + err := ts.Shutdown(ctx) + assert.NoError(t, err, "Expected no error when shutting down nil tracer provider") +}