Compare commits

..

8 Commits

30 changed files with 1027 additions and 1348 deletions
+10 -10
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum
name: Test and release
on:
workflow_dispatch:
@@ -34,16 +34,15 @@ jobs:
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
# needs: [prepare]
runs-on: ubuntu-latest
container: golang:1
# container: github/super-linter:v4
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
@@ -63,11 +62,12 @@ jobs:
- name: Run unit tests
run: |
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/graphql-monitoring-proxy/graphql-monitoring-proxy
# if go.mod or go.sum have changed then commit the changes to the repository
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git add go.mod go.sum
git commit -m "Update go.mod and go.sum"
git push
-25
View File
@@ -15,12 +15,6 @@ on:
env:
GO_VERSION: ">=1.21"
permissions:
# deployments permission to deploy GitHub pages website
deployments: write
# contents permission to update benchmark contents in gh-pages branch
contents: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
@@ -84,26 +78,7 @@ jobs:
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run unit tests
run: |
CI_RUN=${CI} make test
- name: Run benchmark
run: |
go test -bench=. -benchmem ./... -run=^# | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
summary-always: true
# auto-push only if it's on main branch
auto-push: false
gh-pages-branch: "gh-pages"
benchmark-data-dir-path: "docs"
-52
View File
@@ -10,15 +10,6 @@ on:
branches:
- "main"
env:
GO_VERSION: ">=1.21"
permissions:
# deployments permission to deploy GitHub pages website
deployments: write
# contents permission to update benchmark contents in gh-pages branch
contents: write
jobs:
shared:
uses: telegram-bot-app/ci-scripts/.github/workflows/build-test-publish-inject.yaml@main
@@ -27,46 +18,3 @@ jobs:
should-deploy: false
secrets:
ghcr-token: ${{ secrets.GHCR_TOKEN }}
test:
name: "Benchmarking the results"
needs: [shared]
runs-on: ubuntu-latest
container: golang:1
# container: github/super-linter:v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run benchmark
run: |
go test -bench=. -benchmem ./... -run=^# | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
summary-always: true
# auto-push only if it's on main branch
auto-push: true
gh-pages-branch: "gh-pages"
benchmark-data-dir-path: "docs"
+1 -2
View File
@@ -1,4 +1,3 @@
graphql-proxy
test.sh
banned.json*
dist/
banned.json*
-24
View File
@@ -1,6 +1,4 @@
CI_RUN?=false
TIMESTAMP := $(shell date +%Y%m%d-%H%M%S)
# ADDITIONAL_BUILD_FLAGS=""
# ifeq ($(CI_RUN), true)
@@ -34,25 +32,3 @@ all: test-packages test
update: ## update dependencies
@go get -u -v ./...
@go mod tidy -v
.PHONY: build-amd64
build-amd64: ## build the Linux AMD64 binary
GOOS=linux GOARCH=amd64 go build -o graphql-proxy-amd64 *.go
.PHONY: build-arm64
build-arm64: ## build the Linux ARM64 binary
GOOS=linux GOARCH=arm64 go build -o graphql-proxy-arm64 *.go
.PHONY: build-all
build-all: build-amd64 build-arm64 ## build both AMD64 and ARM64 binaries
.PHONY: docker
docker: build-all ## build multi-arch (AMD64 and ARM64) docker image
@mkdir -p dist
@mv graphql-proxy-amd64 dist/bot-linux-amd64
@mv graphql-proxy-arm64 dist/bot-linux-arm64
@docker buildx build --push \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/lukaszraczylo/graphql-monitoring-proxy:local-test-build-$(TIMESTAMP) \
.
+18 -12
View File
@@ -2,13 +2,12 @@
Creates a passthrough proxy to a graphql endpoint(s), allowing you to analyse the queries and responses, producing the Prometheus metrics at a fraction of the cost - because, as we know - $0 is a fair price.
This project is in active use by [telegram-bot.app](https://telegram-bot.app), and was tested with 30k queries per second on a single instance, consuming 10 MB of RAM and 0.1% CPU. [Benchmarks](https://lukaszraczylo.github.io/graphql-monitoring-proxy/dev/bench/) are available.
This project is in active use by [telegram-bot.app](https://telegram-bot.app), and was tested with 30k queries per second on a single instance, consuming 10 MB of RAM and 0.1% CPU.
![Example of monitoring dashboard](static/monitoring-at-glance.png?raw=true)
- [graphql monitoring proxy](#graphql-monitoring-proxy)
- [Why this project exists](#why-this-project-exists)
- [Important releases](#important-releases)
- [How to deploy](#how-to-deploy)
- [Note on websocket support](#note-on-websocket-support)
- [Endpoints](#endpoints)
@@ -29,6 +28,7 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a
- [Cache operations](#cache-operations)
- [General](#general)
- [Metrics which matter](#metrics-which-matter)
- [Tracing](#tracing)
- [Healthcheck](#healthcheck)
- [Monitoring endpoint](#monitoring-endpoint)
@@ -36,21 +36,13 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a
I wanted to monitor the queries and responses of our graphql endpoint. Still, we didn't want to pay the price of the graphql server itself ( and I will not point fingers at a particular well-known project), as monitoring and basic security features should be a standard, free functionality.
### Important releases
You should always try to stick to the latest and greatest version of the graphql-proxy to ensure that it's as much bug-free as possible. Following list will be kept to the maximum of five "most important" bugs and enhancements included in the latest versions.
* **20/08/2024 - 0.23.21+** - Fixes the bug when timeouts were not respected on proxy-graphql line. Affected versions before that were timeouting after 30 seconds which was set as default ( thanks to Jurica Železnjak for reporting ). It also provides a temporary fix for running within kubernetes deployment, when graphql server ( for example - hasura ) took more time to start than the proxy, causing avalanche of errors with "can't proxy the request".
* **19/08/2024 - 0.21.82+** - Fixed the issue when proxy failed to start if global cache was disabled, therefore not initialized and proxy tried to perform the cache operations during normal query operations.
### How to deploy
You can find the example of the Kubernetes manifest in the [example standalone deployment](static/kubernetes-deployment.yaml) or [example combined deployment](static/kubernetes-single-deployment.yaml) files. Observed advantage of multideployment is that it allows the network requests to travel via localhost, without leaving the deployment which brings quite significant network performance boost.
#### Note on websocket support
Proxy in its current version 0.23.3 does not support websockets. If you need to proxy the websocket requests - you can use following trick whilst setting up the proxy. As I'm a big fan of Traefik - there's an example which works with the mentioned above combined deployment.
Proxy in its current version 0.5.30 does not support websockets. If you need to proxy the websocket requests - you can use following trick whilst setting up the proxy. As I'm a big fan of Traefik - there's an example which works with the mentioned above combined deployment.
<details>
<summary>Click to show working Traefik Ingress Route example.</summary>
@@ -112,7 +104,7 @@ In this case, both proxy and websockets will be available under the `/v1/graphql
| security | Blocking mutations in read-only mode |
| security | Allow access only to listed URLs |
| security | Ban / unban specific user from accessing the application |
| maintenance | Hasura events cleaner |
| maintenance | Hasura event cleaner |
### Configuration
@@ -153,6 +145,8 @@ 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` | Enables tracing | `false` |
| `TRACER_ENDPOINT` | Tracing endpoint | `localhost:4317` |
### Speed
@@ -302,6 +296,18 @@ With the `PURGE_METRICS_ON_CRAWL` enabled, the `graphql_proxy_requests_failed`,
If you prefer more control over the metrics purging - you can enable `PURGE_METRICS_ON_TIMER` environment variable and set the interval in seconds. This will allow you to purge the metrics on a regular basis, for example every 90 seconds. It could be better solution if you have multiple crawlers checking the metrics endpoints and you want to avoid the situation when metrics are purged by for example healthcheck.
#### Tracing
Tracing can be enabled by setting `ENABLE_TRACE` to `true` and providing compatible with OTEL `TRACER_ENDPOINT` value ( default is `localhost:4317` ). From that moment you can include `X-Trace-Span` with content being json encoded in your requests to the proxy endpoint.
The value of X-Trace-Span should be in following format:
```json
{
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
}
```
#### Healthcheck
If you'd like the `/healthz` endpoint to perform actual check for the connectivity to the graphql endpoint - set the `HEALTHCHECK_GRAPHQL_URL` environment variable to the exact URL of the graphql endpoint. The query executed will be `query { __typename }` and if the response is not `200 OK` - the healthcheck will fail. Remember that the endpoint is a full URL which you'd like to check, so it should include the protocol, host and path - for example `http://localhost:8080/v1/graphql` and it's NOT the same as value of `HOST_GRAPHQL` environment variable which should provide only the host, without path, ending with slash.
+78 -115
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"os"
"sync"
"time"
"github.com/goccy/go-json"
@@ -14,66 +13,55 @@ import (
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
var (
bannedUsersIDs = make(map[string]string)
bannedUsersIDsMutex sync.RWMutex
)
var bannedUsersIDs map[string]string = make(map[string]string)
func enableApi() {
if !cfg.Server.EnableApi {
return
}
apiserver := fiber.New(fiber.Config{
DisableStartupMessage: true,
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
})
api := apiserver.Group("/api")
api.Post("/user-ban", apiBanUser)
api.Post("/user-unban", apiUnbanUser)
api.Post("/cache-clear", apiClearCache)
api.Get("/cache-stats", apiCacheStats)
go periodicallyReloadBannedUsers()
if err := apiserver.Listen(fmt.Sprintf(":%d", cfg.Server.ApiPort)); err != nil {
cfg.Logger.Critical(&libpack_logger.LogMessage{
Message: "Can't start the service",
Pairs: map[string]interface{}{"port": cfg.Server.ApiPort},
if cfg.Server.EnableApi {
apiserver := fiber.New(fiber.Config{
DisableStartupMessage: true,
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
})
api := apiserver.Group("/api")
api.Post("/user-ban", apiBanUser)
api.Post("/user-unban", apiUnbanUser)
api.Post("/cache-clear", apiClearCache)
api.Get("/cache-stats", apiCacheStats)
go periodicallyReloadBannedUsers()
err := apiserver.Listen(fmt.Sprintf(":%d", cfg.Server.ApiPort))
if err != nil {
cfg.Logger.Critical(&libpack_logger.LogMessage{
Message: "Can't start the service",
Pairs: map[string]interface{}{"port": cfg.Server.ApiPort},
})
}
}
}
func periodicallyReloadBannedUsers() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
for {
loadBannedUsers()
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Banned users reloaded",
Pairs: map[string]interface{}{"users": bannedUsersIDs},
})
<-time.After(10 * time.Second)
}
}
func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool {
bannedUsersIDsMutex.RLock()
_, found := bannedUsersIDs[userID]
bannedUsersIDsMutex.RUnlock()
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Checking if user is banned",
Pairs: map[string]interface{}{"user_id": userID, "banned": found},
Pairs: map[string]interface{}{"user_id": userID, "found": found},
})
if found {
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "User is banned",
Pairs: map[string]interface{}{"user_id": userID},
})
c.Status(fiber.StatusForbidden).SendString("User is banned")
c.Status(403).SendString("User is banned")
}
return found
}
@@ -81,16 +69,28 @@ func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool {
func apiClearCache(c *fiber.Ctx) error {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Clearing cache via API",
Pairs: nil,
})
libpack_cache.CacheClear()
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Cache cleared via API",
Pairs: nil,
})
return c.SendString("OK: cache cleared")
c.Status(200).SendString("OK: cache cleared")
return nil
}
func apiCacheStats(c *fiber.Ctx) error {
return c.JSON(libpack_cache.GetCacheStats())
stats := libpack_cache.GetCacheStats()
err := c.JSON(stats)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't marshal cache stats",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
}
return nil
}
type apiBanUserRequest struct {
@@ -100,92 +100,71 @@ type apiBanUserRequest struct {
func apiBanUser(c *fiber.Ctx) error {
var req apiBanUserRequest
if err := c.BodyParser(&req); err != nil {
err := c.BodyParser(&req)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't parse the ban user request",
Pairs: map[string]interface{}{"error": err.Error()},
})
return c.Status(fiber.StatusBadRequest).SendString("Invalid request payload")
return err
}
if req.UserID == "" || req.Reason == "" {
return c.Status(fiber.StatusBadRequest).SendString("user_id and reason are required")
}
bannedUsersIDsMutex.Lock()
bannedUsersIDs[req.UserID] = req.Reason
bannedUsersIDsMutex.Unlock()
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Banned user",
Pairs: map[string]interface{}{"user_id": req.UserID, "reason": req.Reason},
})
if err := storeBannedUsers(); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to store banned users")
}
return c.SendString("OK: user banned")
storeBannedUsers()
c.Status(200).SendString("OK: user banned")
return nil
}
func apiUnbanUser(c *fiber.Ctx) error {
var req apiBanUserRequest
if err := c.BodyParser(&req); err != nil {
err := c.BodyParser(&req)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't parse the unban user request",
Pairs: map[string]interface{}{"error": err.Error()},
})
return c.Status(fiber.StatusBadRequest).SendString("Invalid request payload")
return err
}
if req.UserID == "" {
return c.Status(fiber.StatusBadRequest).SendString("user_id is required")
}
bannedUsersIDsMutex.Lock()
delete(bannedUsersIDs, req.UserID)
bannedUsersIDsMutex.Unlock()
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Unbanned user",
Pairs: map[string]interface{}{"user_id": req.UserID},
})
if err := storeBannedUsers(); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to store banned users")
}
return c.SendString("OK: user unbanned")
storeBannedUsers()
c.Status(200).SendString("OK: user unbanned")
return nil
}
func storeBannedUsers() error {
func storeBannedUsers() {
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
if err := lockFile(fileLock); err != nil {
return err
err := fileLock.Lock()
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't lock the file",
Pairs: map[string]interface{}{"error": err.Error()},
})
return
}
defer fileLock.Unlock()
bannedUsersIDsMutex.RLock()
data, err := json.Marshal(bannedUsersIDs)
bannedUsersIDsMutex.RUnlock()
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't marshal banned users",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
return
}
if err := os.WriteFile(cfg.Api.BannedUsersFile, data, 0644); err != nil {
err = os.WriteFile(cfg.Api.BannedUsersFile, data, 0644)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't write banned users to file",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
return
}
return nil
}
func loadBannedUsers() {
@@ -194,9 +173,19 @@ func loadBannedUsers() {
Message: "Banned users file doesn't exist - creating it",
Pairs: map[string]interface{}{"file": cfg.Api.BannedUsersFile},
})
if err := os.WriteFile(cfg.Api.BannedUsersFile, []byte("{}"), 0644); err != nil {
_, err := os.Create(cfg.Api.BannedUsersFile)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't create and write to the file",
Message: "Can't create the file",
Pairs: map[string]interface{}{"error": err.Error()},
})
return
}
// write empty json to the file
err = os.WriteFile(cfg.Api.BannedUsersFile, []byte("{}"), 0644)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't write to the file",
Pairs: map[string]interface{}{"error": err.Error()},
})
return
@@ -204,7 +193,8 @@ func loadBannedUsers() {
}
fileLock := flock.New(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
if err := lockFileRead(fileLock); err != nil {
err := fileLock.RLock() // Use RLock for read lock
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't lock the file [load]",
Pairs: map[string]interface{}{"error": err.Error()},
@@ -221,39 +211,12 @@ func loadBannedUsers() {
})
return
}
var newBannedUsers map[string]string
if err := json.Unmarshal(data, &newBannedUsers); err != nil {
err = json.Unmarshal(data, &bannedUsersIDs)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't unmarshal banned users",
Pairs: map[string]interface{}{"error": err.Error()},
})
return
}
bannedUsersIDsMutex.Lock()
bannedUsersIDs = newBannedUsers
bannedUsersIDsMutex.Unlock()
}
func lockFile(fileLock *flock.Flock) error {
if err := fileLock.Lock(); err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't lock the file",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
}
return nil
}
func lockFileRead(fileLock *flock.Flock) error {
if err := fileLock.RLock(); err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't lock the file for reading",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
}
return nil
}
+2 -53
View File
@@ -1,9 +1,6 @@
package libpack_cache
import (
"bytes"
"compress/gzip"
"io"
"sync/atomic"
"time"
@@ -58,7 +55,7 @@ func EnableCache(cfg *CacheConfig) {
}
cacheStats = &CacheStats{}
if ShouldUseRedisCache(cfg) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Using Redis cache",
})
cfg.Client = libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
@@ -67,7 +64,7 @@ func EnableCache(cfg *CacheConfig) {
RedisPassword: cfg.Redis.Password,
})
} else {
cfg.Logger.Debug(&libpack_logger.LogMessage{
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Using in-memory cache",
})
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
@@ -76,35 +73,9 @@ func EnableCache(cfg *CacheConfig) {
}
func CacheLookup(hash string) []byte {
if !IsCacheInitialized() {
return nil
}
obj, found := config.Client.Get(hash)
if found {
atomic.AddInt64(&cacheStats.CacheHits, 1)
// If the cached data is compressed, decompress it
if len(obj) > 2 && obj[0] == 0x1f && obj[1] == 0x8b {
reader, err := gzip.NewReader(bytes.NewReader(obj))
if err != nil {
config.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to create gzip reader for cached data",
Pairs: map[string]interface{}{"error": err.Error(), "hash": hash},
})
return nil
}
defer reader.Close()
decompressed, err := io.ReadAll(reader)
if err != nil {
config.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to decompress cached data",
Pairs: map[string]interface{}{"error": err.Error(), "hash": hash},
})
return nil
}
return decompressed
}
return obj
}
atomic.AddInt64(&cacheStats.CacheMisses, 1)
@@ -112,9 +83,6 @@ func CacheLookup(hash string) []byte {
}
func CacheDelete(hash string) {
if !IsCacheInitialized() {
return
}
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Deleting data from cache",
Pairs: map[string]interface{}{"hash": hash},
@@ -124,12 +92,6 @@ func CacheDelete(hash string) {
}
func CacheStore(hash string, data []byte) {
if !IsCacheInitialized() {
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache not initialized",
})
return
}
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Storing data in cache",
Pairs: map[string]interface{}{"hash": hash},
@@ -139,9 +101,6 @@ func CacheStore(hash string, data []byte) {
}
func CacheStoreWithTTL(hash string, data []byte, ttl time.Duration) {
if !IsCacheInitialized() {
return
}
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Storing data in cache with TTL",
Pairs: map[string]interface{}{"hash": hash, "ttl": ttl},
@@ -151,9 +110,6 @@ func CacheStoreWithTTL(hash string, data []byte, ttl time.Duration) {
}
func CacheGetQueries() int64 {
if !IsCacheInitialized() {
return 0
}
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Counting cache queries",
})
@@ -166,9 +122,6 @@ func CacheClear() {
}
func GetCacheStats() *CacheStats {
if !IsCacheInitialized() {
return &CacheStats{}
}
config.Logger.Debug(&libpack_logger.LogMessage{
Message: "Getting cache stats",
})
@@ -179,7 +132,3 @@ func GetCacheStats() *CacheStats {
func ShouldUseRedisCache(cfg *CacheConfig) bool {
return cfg.Redis.Enable
}
func IsCacheInitialized() bool {
return config != nil && config.Client != nil
}
+12 -19
View File
@@ -4,8 +4,8 @@ import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
libpack_cache_memory "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory"
libpack_cache_redis "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/redis"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
@@ -20,7 +20,6 @@ func BenchmarkCacheLookupInMemory(b *testing.B) {
Client: libpack_cache_memory.New(5 * time.Minute),
TTL: 5,
}
EnableCache(config)
hash := "00000000000000000000000000000000001337"
data := []byte("it's fine.")
@@ -37,18 +36,16 @@ func BenchmarkCacheLookupInMemory(b *testing.B) {
}
func BenchmarkCacheLookupRedis(b *testing.B) {
redis_server, err := miniredis.Run()
if err != nil {
panic(err)
}
mockedCache := libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
RedisServer: redisMockServer.Addr(),
RedisDB: 0,
})
config = &CacheConfig{
Logger: libpack_logger.New(),
Client: mockedCache,
TTL: 5,
}
config.Redis.DB = 0
config.Redis.URL = redis_server.Addr()
config.Redis.Enable = true
EnableCache(config)
hash := "00000000000000000000000000000000001337"
data := []byte("it's fine.")
@@ -70,7 +67,6 @@ func BenchmarkCacheStoreInMemory(b *testing.B) {
Client: libpack_cache_memory.New(5 * time.Minute),
TTL: 5,
}
EnableCache(config)
hash := "00000000000000000000000000000000001337"
data := []byte("it's fine.")
@@ -86,19 +82,16 @@ func BenchmarkCacheStoreInMemory(b *testing.B) {
}
func BenchmarkCacheStoreRedis(b *testing.B) {
redis_server, err := miniredis.Run()
if err != nil {
panic(err)
}
mockedCache := libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
RedisServer: redisMockServer.Addr(),
RedisDB: 0,
})
config = &CacheConfig{
Logger: libpack_logger.New(),
Client: mockedCache,
TTL: 5,
}
config.Redis.DB = 0
config.Redis.URL = redis_server.Addr()
config.Redis.Enable = true
EnableCache(config)
hash := "00000000000000000000000000000000001337"
data := []byte("it's fine.")
+13 -100
View File
@@ -1,12 +1,10 @@
package libpack_cache
import (
"fmt"
"sync"
"time"
"github.com/alicebob/miniredis/v2"
libpack_cache_memory "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory"
libpack_cache_redis "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/redis"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
@@ -60,15 +58,23 @@ func (suite *Tests) Test_cacheLookupInmemory() {
}
func (suite *Tests) Test_cacheLookupRedis() {
// redis_server := envutil.Getenv("REDIS_SERVER", "localhost:6379")
// config.Client = libpack_cache_redis.NewClient(&libpack_cache_redis.RedisClientConfig{
// RedisServer: redis_server,
// RedisPassword: "",
// RedisDB: 0,
// })
mockedCache := libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
RedisServer: redisMockServer.Addr(),
RedisDB: 0,
})
config = &CacheConfig{
Logger: libpack_logger.New(),
Client: mockedCache,
TTL: 5,
}
config.Redis.DB = 0
config.Redis.URL = redisMockServer.Addr()
config.Redis.Enable = true
EnableCache(config)
type args struct {
hash string
@@ -111,96 +117,3 @@ func (suite *Tests) Test_cacheLookupRedis() {
})
}
}
func (suite *Tests) Test_cacheConcurrency() {
config = &CacheConfig{
Logger: libpack_logger.New(),
Client: libpack_cache_memory.New(5 * time.Second),
TTL: 5,
}
const numGoroutines = 10
const numOperations = 1000
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
value := []byte(fmt.Sprintf("value-%d-%d", id, j))
CacheStore(key, value)
retrieved := CacheLookup(key)
assert.Equal(string(value), string(retrieved), "Concurrent cache operation failed")
}
}(i)
}
wg.Wait()
}
// func (suite *Tests) Test_cacheEviction() {
// config = &CacheConfig{
// Logger: libpack_logger.New(),
// Client: libpack_cache_memory.New(3 * time.Second), // 3 seconds TTL
// TTL: 3,
// }
// // Fill the cache
// for i := 0; i < 20; i++ {
// key := fmt.Sprintf("key-%d", i)
// value := []byte(fmt.Sprintf("value-%d", i))
// CacheStore(key, value)
// time.Sleep(100 * time.Millisecond) // Ensure different creation times
// }
// // Wait for the TTL to expire for the first half of the items
// time.Sleep(3100 * time.Millisecond)
// // Check that the oldest items have been evicted
// for i := 0; i < 10; i++ {
// key := fmt.Sprintf("key-%d", i)
// retrieved := CacheLookup(key)
// assert.Nil(retrieved, fmt.Sprintf("Old item %s should have been evicted", key))
// }
// // Check that the newer items are still in the cache
// for i := 10; i < 20; i++ {
// key := fmt.Sprintf("key-%d", i)
// expected := []byte(fmt.Sprintf("value-%d", i))
// retrieved := CacheLookup(key)
// assert.Equal(expected, retrieved, fmt.Sprintf("Recent item %s should be in cache", key))
// }
// }
func (suite *Tests) Test_cacheRedisFailure() {
mr, err := miniredis.Run()
if err != nil {
suite.T().Fatal(err)
}
defer mr.Close()
config = &CacheConfig{
Logger: libpack_logger.New(),
TTL: 5,
}
config.Redis.DB = 0
config.Redis.URL = mr.Addr()
config.Redis.Enable = true
EnableCache(config)
// Test normal operation
CacheStore("test-key", []byte("test-value"))
retrieved := CacheLookup("test-key")
assert.Equal([]byte("test-value"), retrieved)
// Simulate Redis failure
mr.Close()
// Operations should not panic, but should return errors or nil values
CacheStore("another-key", []byte("another-value"))
retrieved = CacheLookup("another-key")
assert.Nil(retrieved, "Lookup should return nil when Redis is down")
}
+3 -9
View File
@@ -69,17 +69,11 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
func (c *Cache) Get(key string) ([]byte, bool) {
entry, ok := c.entries.Load(key)
if !ok {
if !ok || entry.(CacheEntry).ExpiresAt.Before(time.Now()) {
return nil, false
}
cacheEntry := entry.(CacheEntry)
if cacheEntry.ExpiresAt.Before(time.Now()) {
c.entries.Delete(key)
return nil, false
}
value, err := c.decompress(cacheEntry.Value)
compressedValue := entry.(CacheEntry).Value
value, err := c.decompress(compressedValue)
if err != nil {
log.Printf("Error decompressing value for key %s: %v", key, err)
return nil, false
-28
View File
@@ -1,7 +1,6 @@
package libpack_cache_memory
import (
"fmt"
"testing"
"time"
)
@@ -53,30 +52,3 @@ func BenchmarkMemCacheStats(b *testing.B) {
cache.Set(key, value, 5*time.Second) // Pre-set a value to retrieve
cache.Get(key)
}
func BenchmarkCacheSet(b *testing.B) {
cache := New(5 * time.Second)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(fmt.Sprintf("key-%d", i), []byte("value"), 5*time.Second)
}
}
func BenchmarkCacheGet(b *testing.B) {
cache := New(5 * time.Second)
cache.Set("test-key", []byte("test-value"), 5*time.Second)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get("test-key")
}
}
func BenchmarkCacheDelete(b *testing.B) {
cache := New(5 * time.Second)
b.ResetTimer()
for i := 0; i < b.N; i++ {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, []byte("value"), 5*time.Second)
cache.Delete(key)
}
}
-56
View File
@@ -1,8 +1,6 @@
package libpack_cache_memory
import (
"fmt"
"sync"
"testing"
"time"
@@ -112,57 +110,3 @@ func (suite *MemoryTestSuite) Test_CacheExpire() {
})
}
}
func (suite *MemoryTestSuite) Test_ConcurrentReadWrite() {
cache := New(5 * time.Second)
const numGoroutines = 100
const numOperations = 1000
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
value := []byte(fmt.Sprintf("value-%d-%d", id, j))
if j%2 == 0 {
cache.Set(key, value, 5*time.Second)
} else {
_, _ = cache.Get(key)
}
}
}(i)
}
wg.Wait()
}
func (suite *MemoryTestSuite) Test_LargeItems() {
cache := New(5 * time.Second)
largeValue := make([]byte, 10*1024*1024) // 10MB
cache.Set("large-key", largeValue, 5*time.Second)
retrieved, found := cache.Get("large-key")
suite.Assert().True(found)
suite.Assert().Equal(largeValue, retrieved)
}
func (suite *MemoryTestSuite) Test_ZeroTTL() {
cache := New(5 * time.Second)
cache.Set("zero-ttl", []byte("value"), 0)
_, found := cache.Get("zero-ttl")
suite.Assert().False(found, "Item with zero TTL should not be stored")
}
func (suite *MemoryTestSuite) Test_LongTTL() {
cache := New(5 * time.Second)
cache.Set("long-ttl", []byte("value"), 24*365*time.Hour) // 1 year
retrieved, found := cache.Get("long-ttl")
suite.Assert().True(found)
suite.Assert().Equal([]byte("value"), retrieved)
}
+22 -30
View File
@@ -11,14 +11,18 @@ import (
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
)
const defaultValue = "-"
func extractClaimsFromJWTHeader(authorization string) (usr string, role string) {
usr, role = "-", "-"
var emptyMetrics = map[string]string{}
handleError := func(msg string, details map[string]interface{}) {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: msg,
Pairs: details,
})
}
func extractClaimsFromJWTHeader(authorization string) (usr, role string) {
usr, role = defaultValue, defaultValue
tokenParts := strings.SplitN(authorization, ".", 3)
tokenParts := strings.Split(authorization, ".")
if len(tokenParts) != 3 {
handleError("Can't split the token", map[string]interface{}{"token": authorization})
return
@@ -36,30 +40,18 @@ func extractClaimsFromJWTHeader(authorization string) (usr, role string) {
return
}
usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id")
role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role")
extractClaim := func(claimPath string, target *string, name string) {
if len(claimPath) > 0 {
var ok bool
*target, ok = ask.For(claimMap, claimPath).String("-")
if !ok {
handleError(fmt.Sprintf("Can't find the %s", name), map[string]interface{}{"claim_map": claimMap, "path": claimPath})
}
}
}
extractClaim(cfg.Client.JWTUserClaimPath, &usr, "user id")
extractClaim(cfg.Client.JWTRoleClaimPath, &role, "role")
return
}
func extractClaim(claimMap map[string]interface{}, claimPath, name string) string {
if claimPath == "" {
return defaultValue
}
value, ok := ask.For(claimMap, claimPath).String(defaultValue)
if !ok {
handleError(fmt.Sprintf("Can't find the %s", name), map[string]interface{}{"claim_map": claimMap, "path": claimPath})
return defaultValue
}
return value
}
func handleError(msg string, details map[string]interface{}) {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, emptyMetrics)
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: msg,
Pairs: details,
})
}
+58 -65
View File
@@ -5,84 +5,77 @@ import (
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
const (
initialDelay = 60 * time.Second
cleanupInterval = 1 * time.Hour
)
var delQueries = [...]string{
"DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < NOW() - interval '%d days';",
"DELETE FROM hdb_catalog.event_log WHERE created_at < NOW() - interval '%d days';",
"DELETE FROM hdb_catalog.hdb_action_log WHERE created_at < NOW() - INTERVAL '%d days';",
"DELETE FROM hdb_catalog.hdb_cron_event_invocation_logs WHERE created_at < NOW() - INTERVAL '%d days';",
"DELETE FROM hdb_catalog.hdb_scheduled_event_invocation_logs WHERE created_at < NOW() - INTERVAL '%d days';",
}
func enableHasuraEventCleaner() {
if !cfg.HasuraEventCleaner.Enable {
return
}
if cfg.HasuraEventCleaner.EventMetadataDb == "" {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Event metadata db URL not specified, event cleaner not active",
})
return
}
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Event cleaner enabled",
Pairs: map[string]interface{}{"interval_in_days": cfg.HasuraEventCleaner.ClearOlderThan},
})
go func() {
pool, err := pgxpool.New(context.Background(), cfg.HasuraEventCleaner.EventMetadataDb)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to create connection pool",
Pairs: map[string]interface{}{"error": err.Error()},
if cfg.HasuraEventCleaner.Enable {
if cfg.HasuraEventCleaner.EventMetadataDb == "" {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Event metadata db URL not specified, event cleaner not active",
Pairs: nil,
})
return
}
defer pool.Close()
time.Sleep(initialDelay)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Event cleaner enabled",
Pairs: map[string]interface{}{"interval_in_days": cfg.HasuraEventCleaner.ClearOlderThan},
})
time.Sleep(60 * time.Second) // wait for everything to start and settle down
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Initial cleanup of old events",
Pairs: nil,
})
cleanEvents(pool)
cleanEvents()
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Cleaning up old events",
})
cleanEvents(pool)
}
}()
}
func cleanEvents(pool *pgxpool.Pool) {
ctx := context.Background()
for _, query := range delQueries {
_, err := pool.Exec(ctx, fmt.Sprintf(query, cfg.HasuraEventCleaner.ClearOlderThan))
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to execute query",
Pairs: map[string]interface{}{"query": query, "error": err.Error()},
})
} else {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Successfully executed query",
Pairs: map[string]interface{}{"query": query},
})
for {
select {
case <-ticker.C:
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Cleaning up old events",
Pairs: nil,
})
cleanEvents()
}
}
}
}
func cleanEvents() {
conn, err := pgx.Connect(context.Background(), cfg.HasuraEventCleaner.EventMetadataDb)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to connect to event metadata db",
Pairs: map[string]interface{}{"error": err},
})
return
}
defer conn.Close(context.Background())
delQueries := []string{
fmt.Sprintf("DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < now() - interval '%d days';", cfg.HasuraEventCleaner.ClearOlderThan),
fmt.Sprintf("DELETE FROM hdb_catalog.event_log WHERE created_at < now() - interval '%d days';", cfg.HasuraEventCleaner.ClearOlderThan),
fmt.Sprintf("DELETE FROM hdb_catalog.hdb_action_log WHERE created_at < NOW() - INTERVAL '%d days';", cfg.HasuraEventCleaner.ClearOlderThan),
fmt.Sprintf("DELETE FROM hdb_catalog.hdb_cron_event_invocation_logs WHERE created_at < NOW() - INTERVAL '%d days';", cfg.HasuraEventCleaner.ClearOlderThan),
fmt.Sprintf("DELETE FROM hdb_catalog.hdb_scheduled_event_invocation_logs WHERE created_at < NOW() - INTERVAL '%d days';", cfg.HasuraEventCleaner.ClearOlderThan),
}
for _, query := range delQueries {
_, err := conn.Exec(context.Background(), query)
if err != nil {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Failed to execute query",
Pairs: map[string]interface{}{"query": query, "error": err},
})
}
}
cfg.Logger.Info(&libpack_logger.LogMessage{
Message: "Old events cleaned up",
Pairs: nil,
})
}
+36 -22
View File
@@ -1,55 +1,69 @@
module github.com/lukaszraczylo/graphql-monitoring-proxy
go 1.22.4
go 1.21
require (
github.com/VictoriaMetrics/metrics v1.35.1
github.com/VictoriaMetrics/metrics v1.33.1
github.com/alicebob/miniredis/v2 v2.33.0
github.com/avast/retry-go/v4 v4.6.0
github.com/goccy/go-json v0.10.3
github.com/gofiber/fiber/v2 v2.52.5
github.com/gofrs/flock v0.12.1
github.com/gofiber/fiber/v2 v2.52.4
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/gookit/goutil v0.6.17
github.com/gookit/goutil v0.6.15
github.com/graphql-go/graphql v0.8.1
github.com/jackc/pgx/v5 v5.7.1
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-ratecounter v0.1.12
github.com/lukaszraczylo/go-simple-graphql v1.2.29
github.com/redis/go-redis/v9 v9.6.1
github.com/jackc/pgx/v5 v5.6.0
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415
github.com/lukaszraczylo/go-ratecounter v0.1.8
github.com/lukaszraczylo/go-simple-graphql v1.2.14
github.com/redis/go-redis/v9 v9.5.3
github.com/stretchr/testify v1.9.0
github.com/valyala/fasthttp v1.56.0
github.com/valyala/fasthttp v1.55.0
go.opentelemetry.io/otel v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0
go.opentelemetry.io/otel/sdk v1.27.0
go.opentelemetry.io/otel/trace v1.27.0
)
require (
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/andybalholm/brotli v1.1.0 // 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.20.0 // 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.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/klauspost/compress v1.17.9 // 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/mattn/go-runewidth v0.0.15 // 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/rs/zerolog v1.33.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
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+83 -43
View File
@@ -1,5 +1,5 @@
github.com/VictoriaMetrics/metrics v1.35.1 h1:o84wtBKQbzLdDy14XeskkCZih6anG+veZ1SwJHFGwrU=
github.com/VictoriaMetrics/metrics v1.35.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8=
github.com/VictoriaMetrics/metrics v1.33.1 h1:CNV3tfm2Kpv7Y9W3ohmvqgFWPR55tV2c7M2U6OIo+UM=
github.com/VictoriaMetrics/metrics v1.33.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
@@ -12,66 +12,81 @@ 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
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=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gookit/goutil v0.6.17 h1:SxmbDz2sn2V+O+xJjJhJT/sq1/kQh6rCJ7vLBiRPZjI=
github.com/gookit/goutil v0.6.17/go.mod h1:rSw1LchE1I3TDWITZvefoAC9tS09SFu3lHXLCV7EaEY=
github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo=
github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY=
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.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-ratecounter v0.1.12 h1:VO6hHYGw/Jy9JUizXf/bS0AI2QX1ueWWAWckMFVJ/w4=
github.com/lukaszraczylo/go-ratecounter v0.1.12/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
github.com/lukaszraczylo/go-simple-graphql v1.2.29 h1:Fo/3SN4vrST1pyX1UJ5Nd+pQCkurZNJSck4pyx5B/Fk=
github.com/lukaszraczylo/go-simple-graphql v1.2.29/go.mod h1:kCvRu01tLxj0iKash5qwL7Em+SltQmZ82bs0yu2aOrk=
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415 h1:lvI8Wlbg4PxkRcg2f10wgoaRpfN19v+YdRek3+dLtlM=
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-ratecounter v0.1.8 h1:ZYm6Wkn58ZAlFWRmC7PaD4oAYHWcu8/0MUDWGe3PnJQ=
github.com/lukaszraczylo/go-ratecounter v0.1.8/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
github.com/lukaszraczylo/go-simple-graphql v1.2.14 h1:Dth+yZ+1ialCpnslSb6UgHbXszExjDUu/I95QZbnWVU=
github.com/lukaszraczylo/go-simple-graphql v1.2.14/go.mod h1:pSKmm9OLGoS9pjmIvhBB/fo0+LganRrL29CN3fdkRPw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU=
github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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=
@@ -79,8 +94,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.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.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
@@ -91,22 +106,47 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
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/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI=
go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
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/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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=
+102 -107
View File
@@ -3,7 +3,6 @@ package main
import (
"strconv"
"strings"
"sync"
"github.com/goccy/go-json"
fiber "github.com/gofiber/fiber/v2"
@@ -13,26 +12,48 @@ import (
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
)
var (
introspectionQueries = map[string]struct{}{
"__schema": {}, "__type": {}, "__typename": {}, "__directive": {},
"__directivelocation": {}, "__field": {}, "__inputvalue": {},
"__enumvalue": {}, "__typekind": {}, "__fieldtype": {},
"__inputobjecttype": {}, "__enumtype": {}, "__uniontype": {},
"__scalars": {}, "__objects": {}, "__interfaces": {},
"__unions": {}, "__enums": {}, "__inputobjects": {}, "__directives": {},
var introspection_queries = []string{
"__schema",
"__type",
"__typename",
"__directive",
"__directivelocation",
"__field",
"__inputvalue",
"__enumvalue",
"__typekind",
"__fieldtype",
"__inputobjecttype",
"__enumtype",
"__uniontype",
"__scalars",
"__objects",
"__interfaces",
"__unions",
"__enums",
"__inputobjects",
"__directives",
}
// Saving the introspection queries as a map O(1) operation instead of O(n) for a slice.
var introspectionQuerySet = map[string]struct{}{}
var introspectionAllowedQueries = map[string]struct{}{}
var allowedUrls = map[string]struct{}{}
// Utility function to convert a slice of strings to a map for O(1) lookups.
func sliceToMap(slice []string) map[string]struct{} {
resultMap := make(map[string]struct{}, len(slice))
for _, item := range slice {
resultMap[strings.ToLower(item)] = struct{}{}
}
introspectionAllowedQueries = make(map[string]struct{})
allowedUrls = make(map[string]struct{})
)
return resultMap
}
func prepareQueriesAndExemptions() {
for _, q := range cfg.Security.IntrospectionAllowed {
introspectionAllowedQueries[strings.ToLower(q)] = struct{}{}
}
for _, u := range cfg.Server.AllowURLs {
allowedUrls[u] = struct{}{}
}
introspectionQuerySet = sliceToMap(introspection_queries)
introspectionAllowedQueries = sliceToMap(cfg.Security.IntrospectionAllowed)
allowedUrls = sliceToMap(cfg.Server.AllowURLs)
}
type parseGraphQLQueryResult struct {
@@ -46,32 +67,11 @@ type parseGraphQLQueryResult struct {
shouldIgnore bool
}
var (
queryPool = sync.Pool{
New: func() interface{} {
return make(map[string]interface{}, 48)
},
}
resultPool = sync.Pool{
New: func() interface{} {
return &parseGraphQLQueryResult{}
},
}
)
func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
res := resultPool.Get().(*parseGraphQLQueryResult)
*res = parseGraphQLQueryResult{shouldIgnore: true, activeEndpoint: cfg.Server.HostGraphQL}
m := queryPool.Get().(map[string]interface{})
defer func() {
for k := range m {
delete(m, k)
}
queryPool.Put(m)
}()
if err := json.Unmarshal(c.Body(), &m); err != nil {
func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) {
res = &parseGraphQLQueryResult{shouldIgnore: true}
m := make(map[string]interface{})
err := json.Unmarshal(c.Body(), &m)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't unmarshal the request",
Pairs: map[string]interface{}{"error": err.Error(), "body": string(c.Body())},
@@ -79,13 +79,9 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
if res.shouldBlock {
resultPool.Put(res)
return res
}
return res
return
}
// get the query
query, ok := m["query"].(string)
if !ok {
cfg.Logger.Error(&libpack_logger.LogMessage{
@@ -95,8 +91,7 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
resultPool.Put(res)
return res
return
}
p, err := parser.Parse(parser.ParseParams{Source: query})
@@ -108,66 +103,60 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
resultPool.Put(res)
return res
return
}
res.shouldIgnore = false
res.operationName = "undefined"
res.activeEndpoint = cfg.Server.HostGraphQL
for _, d := range p.Definitions {
if oper, ok := d.(*ast.OperationDefinition); ok {
if res.operationType == "" {
res.operationType = strings.ToLower(oper.Operation)
if oper.Name != nil {
res.operationName = oper.Name.Value
}
res.operationType = strings.ToLower(oper.Operation)
if oper.Name != nil {
res.operationName = oper.Name.Value
}
if cfg.Server.HostGraphQLReadOnly != "" {
if res.operationType == "" {
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
} else if res.operationType != "mutation" {
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
}
// If the query is a mutation then direct it to the RW endpoint,
// otherwise direct it to the RO endpoint if it's set.
if cfg.Server.HostGraphQLReadOnly != "" && res.operationType != "mutation" {
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
}
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Endpoint selection",
Pairs: map[string]interface{}{
"operationType": res.operationType,
"selectedEndpoint": res.activeEndpoint,
},
})
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Mutation blocked - server in read-only mode",
Message: "Mutation blocked",
Pairs: map[string]interface{}{"query": query},
})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
_ = c.Status(403).SendString("The server is in read-only mode")
c.Status(403).SendString("The server is in read-only mode")
res.shouldBlock = true
resultPool.Put(res)
return res
return
}
for _, dir := range oper.Directives {
if dir.Name.Value == "cached" {
res.cacheRequest = true
for _, arg := range dir.Arguments {
switch arg.Name.Value {
case "ttl":
if v, ok := arg.Value.GetValue().(string); ok {
res.cacheTime, _ = strconv.Atoi(v)
}
case "refresh":
if v, ok := arg.Value.GetValue().(bool); ok {
res.cacheRefresh = v
if arg.Name.Value == "ttl" {
res.cacheTime, err = strconv.Atoi(arg.Value.GetValue().(string))
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't parse the ttl, using global",
Pairs: map[string]interface{}{"bad_ttl": arg.Value.GetValue().(string)},
})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return
}
}
if arg.Name.Value == "refresh" {
res.cacheRefresh = arg.Value.GetValue().(bool)
}
}
}
}
@@ -175,53 +164,59 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
if cfg.Security.BlockIntrospection {
res.shouldBlock = checkSelections(c, oper.GetSelectionSet().Selections)
if res.shouldBlock {
resultPool.Put(res)
return res
return
}
}
}
}
return res
return
}
func checkSelections(c *fiber.Ctx, selections []ast.Selection) bool {
stack := make([]ast.Selection, len(selections))
copy(stack, selections)
for len(stack) > 0 {
var s ast.Selection
s, stack = stack[len(stack)-1], stack[:len(stack)-1]
if field, ok := s.(*ast.Field); ok {
if checkIfContainsIntrospection(c, field.Name.Value) {
for _, s := range selections {
field, ok := s.(*ast.Field)
if !ok {
continue // or handle the case where the type assertion fails
}
shouldBlock := checkIfContainsIntrospection(c, field.Name.Value)
if shouldBlock {
return true
}
if field.SelectionSet != nil {
if checkSelections(c, field.GetSelectionSet().Selections) {
return true
}
if field.SelectionSet != nil {
stack = append(stack, field.GetSelectionSet().Selections...)
}
}
}
return false
}
func checkIfContainsIntrospection(c *fiber.Ctx, whatever string) bool {
func checkIfContainsIntrospection(c *fiber.Ctx, whatever string) (shouldBlock bool) {
whateverLower := strings.ToLower(whatever)
got_exemption := false
if _, exists := introspectionQueries[whateverLower]; exists {
// If the query is an introspection query, we need to check if it's allowed.
if _, exists := introspectionQuerySet[whateverLower]; exists {
if len(cfg.Security.IntrospectionAllowed) > 0 {
if _, allowed := introspectionAllowedQueries[whateverLower]; allowed {
if _, allowed_exists := introspectionAllowedQueries[whateverLower]; allowed_exists {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Introspection query allowed, passing through",
Pairs: map[string]interface{}{"query": whatever},
})
return false
got_exemption = true
shouldBlock = false
}
}
if !got_exemption {
shouldBlock = true
}
}
if shouldBlock {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
_ = c.Status(403).SendString("Introspection queries are not allowed")
return true
c.Status(403).SendString("Introspection queries are not allowed")
}
return false
return
}
-112
View File
@@ -1,10 +1,6 @@
package main
import (
"fmt"
"strings"
fiber "github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
@@ -322,111 +318,3 @@ func (suite *Tests) Test_parseGraphQLQuery() {
})
}
}
func (suite *Tests) Test_parseGraphQLQuery_complex() {
// ... existing tests ...
// Add these new test cases
suite.Run("test complex query with multiple operations", func() {
query := `
query GetUser($id: ID!) {
user(id: $id) {
name
email
}
}
mutation UpdateUser($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
}
}
`
body := fmt.Sprintf(`{"query": %q}`, query)
ctx := createTestContext(body)
result := parseGraphQLQuery(ctx)
assert.Equal("query", result.operationType)
assert.Equal("GetUser", result.operationName)
assert.False(result.shouldBlock)
})
suite.Run("test query with custom directives", func() {
query := `
query GetUser($id: ID!) @custom(directive: "value") {
user(id: $id) {
name
email
}
}
`
body := fmt.Sprintf(`{"query": %q}`, query)
ctx := createTestContext(body)
result := parseGraphQLQuery(ctx)
assert.Equal("query", result.operationType)
assert.Equal("GetUser", result.operationName)
assert.False(result.shouldBlock)
assert.False(result.shouldBlock)
})
}
func (suite *Tests) Test_checkAllowedURLs() {
tests := []struct {
name string
path string
allowed []string
expected bool
}{
{"allowed path", "/v1/graphql", []string{"/v1/graphql"}, true},
{"disallowed path", "/v2/graphql", []string{"/v1/graphql"}, false},
{"empty allowed list", "/v1/graphql", []string{}, true},
{"multiple allowed paths", "/v2/graphql", []string{"/v1/graphql", "/v2/graphql"}, true},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
allowedUrls = make(map[string]struct{})
for _, url := range tt.allowed {
allowedUrls[url] = struct{}{}
}
app := fiber.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
ctx.Request().SetRequestURI(tt.path)
ctx.Request().URI().SetPath(tt.path)
result := checkAllowedURLs(ctx)
assert.Equal(tt.expected, result)
})
}
}
func (suite *Tests) Test_checkIfContainsIntrospection() {
tests := []struct {
name string
query string
allowed []string
expected bool
}{
{"allowed introspection", "__schema", []string{"__schema"}, false},
{"disallowed introspection", "__type", []string{"__schema"}, true},
{"non-introspection query", "normalQuery", []string{}, false},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
cfg.Security.IntrospectionAllowed = tt.allowed
introspectionAllowedQueries = make(map[string]struct{})
for _, q := range tt.allowed {
introspectionAllowedQueries[strings.ToLower(q)] = struct{}{}
}
ctx := createTestContext("")
result := checkIfContainsIntrospection(ctx, tt.query)
assert.Equal(tt.expected, result)
})
}
}
func createTestContext(body string) *fiber.Ctx {
app := fiber.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
ctx.Request().SetBody([]byte(body))
return ctx
}
+17 -7
View File
@@ -5,7 +5,6 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2/middleware/proxy"
"github.com/gookit/goutil/envutil"
@@ -13,12 +12,11 @@ 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_trace "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing"
)
var (
cfg *config
once sync.Once
)
var cfg *config
var once sync.Once
// function get value from the env where the value can be anything
func getDetailsFromEnv[T any](key string, defaultValue T) T {
@@ -58,7 +56,6 @@ func parseConfig() {
c.Cache.CacheRedisURL = getDetailsFromEnv("CACHE_REDIS_URL", "localhost:6379")
c.Cache.CacheRedisPassword = getDetailsFromEnv("CACHE_REDIS_PASSWORD", "")
c.Cache.CacheRedisDB = getDetailsFromEnv("CACHE_REDIS_DB", 0)
/* security */
c.Security.BlockIntrospection = getDetailsFromEnv("BLOCK_SCHEMA_INTROSPECTION", false)
c.Security.IntrospectionAllowed = func() []string {
urls := getDetailsFromEnv("ALLOWED_INTROSPECTION", "")
@@ -92,6 +89,8 @@ 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", "")
c.Trace.Enable = getDetailsFromEnv("ENABLE_TRACE", false)
c.Trace.TraceEndpoint = getDetailsFromEnv("TRACER_ENDPOINT", "localhost:4317")
cfg = &c
if cfg.Cache.CacheEnable || cfg.Cache.CacheRedisEnable {
@@ -110,6 +109,18 @@ func parseConfig() {
loadRatelimitConfig()
once.Do(func() {
if cfg.Trace.Enable {
var err error
cfg.Trace.Client, err = libpack_trace.NewClient(cfg.Logger, cfg.Trace.TraceEndpoint)
if err != nil {
cfg.Logger.Error(&libpack_logging.LogMessage{
Message: "Failed to start tracer",
Pairs: map[string]interface{}{
"error": err,
},
})
}
}
go enableApi()
go enableHasuraEventCleaner()
})
@@ -119,7 +130,6 @@ func parseConfig() {
func main() {
parseConfig()
StartMonitoringServer()
time.Sleep(5 * time.Second)
StartHTTPProxy()
}
-26
View File
@@ -112,29 +112,3 @@ func (suite *Tests) Test_envVariableSetting() {
})
}
}
func (suite *Tests) Test_getDetailsFromEnv() {
tests := []struct {
name string
key string
defaultValue interface{}
envValue string
expected interface{}
}{
{"string value", "TEST_STRING", "default", "envValue", "envValue"},
{"int value", "TEST_INT", 0, "123", 123},
{"bool value", "TEST_BOOL", false, "true", true},
{"default value", "NON_EXISTENT", "default", "", "default"},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
if tt.envValue != "" {
os.Setenv("GMP_"+tt.key, tt.envValue)
defer os.Unsetenv("GMP_" + tt.key)
}
result := getDetailsFromEnv(tt.key, tt.defaultValue)
assert.Equal(tt.expected, result)
})
}
}
+48 -37
View File
@@ -12,14 +12,17 @@ import (
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
)
// Cache for sorted label keys to avoid repeated sorting
var sortedLabelKeysCache = struct {
m sync.Map
}{}
m map[string][]string
sync.RWMutex
}{m: make(map[string][]string)}
func (ms *MetricsSetup) get_metrics_name(name string, labels map[string]string) string {
const unknownPodName = "unknown"
var buf bytes.Buffer
// Prepare default labels without initializing a new map
podName := getPodName()
if labels == nil {
labels = defaultLabels(podName)
@@ -27,16 +30,18 @@ func (ms *MetricsSetup) get_metrics_name(name string, labels map[string]string)
ensureDefaultLabels(&labels, podName)
}
// Prefix handling
if ms.metrics_prefix != "" {
buf.WriteString(ms.metrics_prefix)
buf.WriteByte('_')
buf.WriteString("_")
}
buf.WriteString(name)
// Append labels if any
if len(labels) > 0 {
buf.WriteByte('{')
buf.WriteString("{")
appendSortedLabels(&buf, labels)
buf.WriteByte('}')
buf.WriteString("}")
}
return buf.String()
@@ -73,64 +78,64 @@ func appendSortedLabels(buf *bytes.Buffer, labels map[string]string) {
keys := getSortedKeys(labels)
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
buf.WriteString(",")
}
buf.WriteString(k)
buf.WriteString(`="`)
buf.WriteString("=\"")
buf.WriteString(labels[k])
buf.WriteByte('"')
buf.WriteString("\"")
}
}
func getSortedKeys(labels map[string]string) []string {
labelsKey := labelsToString(labels)
// Check if the sorted keys are already cached
if keys, ok := sortedLabelKeysCache.m.Load(labelsKey); ok {
return keys.([]string)
}
sortedLabelKeysCache.RLock()
keys, exists := sortedLabelKeysCache.m[labelsKey]
sortedLabelKeysCache.RUnlock()
// Compute the sorted keys
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
if !exists {
keys = make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
// Store the sorted keys in the cache
sortedLabelKeysCache.m.Store(labelsKey, keys)
sortedLabelKeysCache.Lock()
sortedLabelKeysCache.m[labelsKey] = keys
sortedLabelKeysCache.Unlock()
}
return keys
}
func labelsToString(labels map[string]string) string {
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
for k, v := range labels {
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(labels[k])
sb.WriteByte(';')
sb.WriteString("=")
sb.WriteString(v)
sb.WriteString(";")
}
return sb.String()
}
// validate_metrics_name validates the name of the metric to adhere to the Prometheus naming conventions
// https://prometheus.io/docs/practices/naming/
func validate_metrics_name(name string) error {
cleanedName := clean_metric_name(name)
// Trim leading and trailing underscores
finalName := strings.Trim(cleanedName, "_")
// Check if the processed name matches the original input
if finalName != name {
return fmt.Errorf("invalid metric name: %s, expected %s", name, finalName)
return fmt.Errorf("Invalid metric name: %s, expected %s", name, finalName)
}
return nil
}
// clean_metric_name processes the metric name according to Prometheus naming conventions
func clean_metric_name(name string) string {
var buf bytes.Buffer
lastWasUnderscore := false
@@ -139,27 +144,31 @@ func clean_metric_name(name string) string {
if is_allowed_rune(r) {
if is_special_rune(r) {
if lastWasUnderscore {
continue
continue // Skip if the previous character was also an underscore
}
r = '_'
r = '_' // Convert spaces and special characters to underscores
lastWasUnderscore = true
} else {
lastWasUnderscore = false
}
buf.WriteRune(r)
} else if !lastWasUnderscore {
buf.WriteByte('_')
buf.WriteRune('_')
lastWasUnderscore = true
}
}
return strings.Trim(buf.String(), "_")
// Remove trailing underscore
result := buf.String()
return strings.Trim(result, "_")
}
// is_allowed_rune checks if the rune is allowed in the metric name
func is_allowed_rune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '_'
}
// is_special_rune checks if the rune is a space or an underscore
func is_special_rune(r rune) bool {
return r == ' ' || r == '_'
}
@@ -169,12 +178,14 @@ func compile_metrics_with_labels(name string, labels map[string]string) string {
buf.WriteString(name)
// Collect keys and sort them
keys := getSortedKeys(labels)
// Append sorted key-value pairs to the buffer
for _, k := range keys {
buf.WriteByte('_')
buf.WriteString("_")
buf.WriteString(k)
buf.WriteByte('_')
buf.WriteString("_")
buf.WriteString(labels[k])
}
+20 -16
View File
@@ -1,3 +1,6 @@
// Package `libpack_monitoring` provides and easy way to add prometheus metrics to your application.
// It also provides a way to add custom metrics to the already started prometheus registry.
package libpack_monitoring
import (
@@ -19,7 +22,9 @@ type MetricsSetup struct {
metrics_prefix string
}
var log = libpack_logger.New().SetMinLogLevel(libpack_logger.LEVEL_INFO)
var (
log *libpack_logger.Logger
)
type InitConfig struct {
PurgeOnCrawl bool
@@ -27,11 +32,11 @@ type InitConfig struct {
}
func NewMonitoring(ic *InitConfig) *MetricsSetup {
ms := &MetricsSetup{
ic: ic,
metrics_set: metrics.NewSet(),
metrics_set_custom: metrics.NewSet(),
}
log = libpack_logger.New().SetMinLogLevel(libpack_logger.LEVEL_INFO)
ms := &MetricsSetup{ic: ic}
ms.metrics_set = metrics.NewSet()
ms.metrics_set_custom = metrics.NewSet()
// if not testing, start the prometheus endpoint
if flag.Lookup("test.v") == nil {
go ms.startPrometheusEndpoint()
@@ -55,11 +60,9 @@ func (ms *MetricsSetup) startPrometheusEndpoint() {
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
})
app.Get("/metrics", ms.metricsEndpoint)
if err := app.Listen(fmt.Sprintf(":%d", envutil.GetInt("MONITORING_PORT", 9393))); err != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "Can't start the service",
Pairs: map[string]interface{}{"error": err},
})
err := app.Listen(fmt.Sprintf(":%d", envutil.GetInt("MONITORING_PORT", 9393)))
if err != nil {
fmt.Println("Can't start the service: ", err)
}
}
@@ -82,7 +85,7 @@ func (ms *MetricsSetup) ListActiveMetrics() []string {
}
func (ms *MetricsSetup) RegisterMetricsGauge(metric_name string, labels map[string]string, val float64) *metrics.Gauge {
if err := validate_metrics_name(metric_name); err != nil {
if validate_metrics_name(metric_name) != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "RegisterMetricsGauge() error",
Pairs: map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name},
@@ -90,12 +93,13 @@ func (ms *MetricsSetup) RegisterMetricsGauge(metric_name string, labels map[stri
return nil
}
return ms.metrics_set_custom.GetOrCreateGauge(ms.get_metrics_name(metric_name, labels), func() float64 {
// get current value of the gauge and add val to it
return val
})
}
func (ms *MetricsSetup) RegisterMetricsCounter(metric_name string, labels map[string]string) *metrics.Counter {
if err := validate_metrics_name(metric_name); err != nil {
if validate_metrics_name(metric_name) != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "RegisterMetricsCounter() error",
Pairs: map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name},
@@ -109,7 +113,7 @@ func (ms *MetricsSetup) RegisterMetricsCounter(metric_name string, labels map[st
}
func (ms *MetricsSetup) RegisterFloatCounter(metric_name string, labels map[string]string) *metrics.FloatCounter {
if err := validate_metrics_name(metric_name); err != nil {
if validate_metrics_name(metric_name) != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "RegisterFloatCounter() error",
Pairs: map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name},
@@ -120,7 +124,7 @@ func (ms *MetricsSetup) RegisterFloatCounter(metric_name string, labels map[stri
}
func (ms *MetricsSetup) RegisterMetricsSummary(metric_name string, labels map[string]string) *metrics.Summary {
if err := validate_metrics_name(metric_name); err != nil {
if validate_metrics_name(metric_name) != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "RegisterMetricsSummary() error",
Pairs: map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name},
@@ -131,7 +135,7 @@ func (ms *MetricsSetup) RegisterMetricsSummary(metric_name string, labels map[st
}
func (ms *MetricsSetup) RegisterMetricsHistogram(metric_name string, labels map[string]string) *metrics.Histogram {
if err := validate_metrics_name(metric_name); err != nil {
if validate_metrics_name(metric_name) != nil {
log.Critical(&libpack_logger.LogMessage{
Message: "RegisterMetricsHistogram() error",
Pairs: map[string]interface{}{"_error": "Invalid metric name", "_metric_name": metric_name},
+85 -88
View File
@@ -1,19 +1,18 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"fmt"
"io"
"net/url"
"time"
"github.com/avast/retry-go/v4"
"github.com/gofiber/fiber/v2"
"github.com/goccy/go-json"
fiber "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_trace "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing"
"github.com/valyala/fasthttp"
)
@@ -25,15 +24,15 @@ func createFasthttpClient(timeout int) *fasthttp.Client {
InsecureSkipVerify: true,
},
MaxConnsPerHost: 2048,
ReadTimeout: time.Duration(timeout) * time.Second,
WriteTimeout: time.Duration(timeout) * time.Second,
MaxIdleConnDuration: time.Duration(timeout) * time.Second,
MaxConnDuration: time.Duration(timeout) * time.Second,
ReadTimeout: time.Second * time.Duration(timeout),
WriteTimeout: time.Second * time.Duration(timeout),
MaxIdleConnDuration: time.Second * time.Duration(timeout),
MaxConnDuration: time.Second * time.Duration(timeout),
DisableHeaderNamesNormalizing: true,
}
}
func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error {
func proxyTheRequest(c *fiber.Ctx, currentEndpoint string, ctx context.Context) error {
if !checkAllowedURLs(c) {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Request blocked",
@@ -42,118 +41,116 @@ func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
}
return fmt.Errorf("request blocked - not allowed URL: %s", c.Path())
}
proxyURL := currentEndpoint + c.Path()
_, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
c.Status(403).SendString("Request blocked - not allowed URL")
return nil
}
c.Request().Header.DisableNormalizing()
c.Request().Header.Add("X-Real-IP", c.IP())
c.Request().Header.Add(fiber.HeaderXForwardedFor, string(c.Request().Header.Peek("X-Forwarded-For")))
c.Request().Header.Del(fiber.HeaderAcceptEncoding)
// added dummy check for the log level because it executes additional functions which could
// potentially slow down the execution.
if cfg.LogLevel == "debug" {
logDebugRequest(c)
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Proxying the request",
Pairs: map[string]interface{}{
"path": c.Path(),
"body": string(c.Request().Body()),
"headers": c.GetReqHeaders(),
"request_uuid": c.Locals("request_uuid"),
},
})
}
err = retry.Do(
err := retry.Do(
func() error {
proxyErr := proxy.DoRedirects(c, proxyURL, 3, cfg.Client.FastProxyClient)
if proxyErr != nil {
return proxyErr
}
if c.Response().StatusCode() != 200 {
return fmt.Errorf("received non-200 response from the GraphQL server: %d", c.Response().StatusCode())
errInt := proxy.DoRedirects(c, currentEndpoint+c.Path(), 3, cfg.Client.FastProxyClient)
if errInt != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't proxy the request",
Pairs: map[string]interface{}{
"error": errInt.Error(),
},
})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return errInt
}
return nil
},
retry.Attempts(5),
retry.DelayType(retry.BackOffDelay),
retry.Delay(250*time.Millisecond),
retry.MaxDelay(5*time.Second),
retry.OnRetry(func(n uint, err error) {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Retrying the request",
Pairs: map[string]interface{}{
"path": c.Path(),
"attempt": n + 1,
"error": err.Error(),
"path": c.Path(),
"error": err.Error(),
},
})
}),
retry.Attempts(uint(3)),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Duration(250*time.Millisecond)),
retry.LastErrorOnly(true),
)
if err != nil {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Can't proxy the request",
Pairs: map[string]interface{}{"error": err.Error()},
Pairs: map[string]interface{}{
"error": err.Error(),
},
})
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return fmt.Errorf("failed to proxy request: %v", err)
return err
}
if cfg.LogLevel == "debug" {
logDebugResponse(c)
}
if c.Response().Header.Peek("Content-Encoding") != nil && string(c.Response().Header.Peek("Content-Encoding")) == "gzip" {
reader, err := gzip.NewReader(bytes.NewReader(c.Response().Body()))
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to create gzip reader",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
}
defer reader.Close()
decompressed, err := io.ReadAll(reader)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Failed to decompress response",
Pairs: map[string]interface{}{"error": err.Error()},
})
return err
}
c.Response().SetBody(decompressed)
c.Response().Header.Del("Content-Encoding")
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Received proxied response",
Pairs: map[string]interface{}{
"path": c.Path(),
"response_body": string(c.Response().Body()),
"response_code": c.Response().StatusCode(),
"headers": c.GetRespHeaders(),
"request_uuid": c.Locals("request_uuid"),
},
})
}
if c.Response().StatusCode() != 200 {
if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
}
return fmt.Errorf("received non-200 response from the GraphQL server: %d", c.Response().StatusCode())
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Received non-200 response from the GraphQL server",
Pairs: map[string]interface{}{
"status_code": c.Response().StatusCode(),
},
})
return fmt.Errorf("Received non-200 response from the GraphQL server: %d", c.Response().StatusCode())
}
c.Response().Header.Del(fiber.HeaderServer)
if cfg.Trace.Enable {
tracingContext := libpack_trace.TraceContextInject(ctx)
if tracingContext == nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't inject empty tracing context",
})
return nil
}
traceJsonEncoded, err := json.Marshal(tracingContext)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't convert tracing context to JSON",
Pairs: map[string]interface{}{
"error": err.Error(),
},
})
return err
}
c.Response().Header.Set("X-Trace-Span", string(traceJsonEncoded))
}
return nil
}
func logDebugRequest(c *fiber.Ctx) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Proxying the request",
Pairs: map[string]interface{}{
"path": c.Path(),
"body": string(c.Body()),
"headers": c.GetReqHeaders(),
"request_uuid": c.Locals("request_uuid"),
},
})
}
func logDebugResponse(c *fiber.Ctx) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Received proxied response",
Pairs: map[string]interface{}{
"path": c.Path(),
"response_body": string(c.Response().Body()),
"response_code": c.Response().StatusCode(),
"headers": c.GetRespHeaders(),
"request_uuid": c.Locals("request_uuid"),
},
})
}
+33 -173
View File
@@ -1,10 +1,6 @@
package main
import (
"net/http"
"net/http/httptest"
"time"
"github.com/valyala/fasthttp"
)
@@ -16,63 +12,47 @@ func (suite *Tests) Test_proxyTheRequest() {
}
tests := []struct {
headers map[string]string
name string
body string
host string
hostRO string
path string
wantErr bool
wantEndpoint string
headers map[string]string
name string
body string
host string
hostRO string
path string
wantErr bool
}{
{
name: "test_empty",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
wantEndpoint: "https://telegram-bot.app/",
name: "test_empty",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
},
{
name: "test_wrong_url",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
path: "/v1/wrongURL",
headers: supplied_headers,
wantErr: true,
wantEndpoint: "https://google.com/",
name: "test_wrong_url",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
path: "/v1/wrongURL",
headers: supplied_headers,
wantErr: true,
},
{
name: "Test read only mode",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
hostRO: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
wantEndpoint: "https://telegram-bot.app/",
name: "Test read only mode",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
hostRO: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
},
{
name: "Test read only mode wrong host",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
hostRO: "https://google.com/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: true,
wantEndpoint: "https://google.com/",
},
{
name: "Test mutation with endpoint flip",
body: `{"query":"mutation {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
hostRO: "https://google.com/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
wantEndpoint: "https://telegram-bot.app/",
name: "Test read only mode wrong host",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
hostRO: "https://google.com/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: true,
},
}
@@ -112,126 +92,6 @@ func (suite *Tests) Test_proxyTheRequest() {
} else {
assert.Nil(err, "Error is not nil", tt.name)
}
assert.Equal(tt.wantEndpoint, res.activeEndpoint, "Unexpected endpoint", tt.name)
})
}
}
func (suite *Tests) Test_proxyTheRequestWithPayloads() {
tests := []struct {
name string
payload string
url string
wantErr bool
}{
{
name: "Test with invalid URL",
payload: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
url: "://invalid-url",
wantErr: true,
},
{
name: "Test with network error",
payload: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
url: "http://non-existent-host.invalid",
wantErr: true,
},
// {
// name: "Test with large payload",
// payload: strings.Repeat("a", 10*1024*1024), // 10MB payload
// url: "https://google.com/",
// wantErr: false,
// },
}
for _, tt := range tests {
suite.Run(tt.name, func() {
cfg.Server.HostGraphQL = tt.url
ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{})
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
if tt.wantErr {
assert.NotNil(err)
} else {
assert.Nil(err)
}
})
}
}
func (suite *Tests) Test_proxyTheRequestWithTimeouts() {
originalTimeout := cfg.Client.ClientTimeout
defer func() {
cfg.Client.ClientTimeout = originalTimeout
cfg.Client.FastProxyClient = createFasthttpClient(cfg.Client.ClientTimeout)
}()
// Create a mock server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sleepDuration, _ := time.ParseDuration(r.Header.Get("X-Sleep-Duration"))
time.Sleep(sleepDuration)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"test":"response"}}`))
}))
defer mockServer.Close()
tests := []struct {
name string
clientTimeout int
sleepDuration string
body string
wantErr bool
}{
{
name: "Short timeout, long wait for response",
clientTimeout: 1,
sleepDuration: "2s",
body: `{"query":"query { test }"}`,
wantErr: true,
},
{
name: "Short timeout, short wait for response",
clientTimeout: 2,
sleepDuration: "500ms",
body: `{"query":"query { test }"}`,
wantErr: false,
},
{
name: "Long timeout, short wait for response",
clientTimeout: 10,
sleepDuration: "1s",
body: `{"query":"query { test }"}`,
wantErr: false,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
cfg.Client.ClientTimeout = tt.clientTimeout
cfg.Client.FastProxyClient = createFasthttpClient(cfg.Client.ClientTimeout)
cfg.Server.HostGraphQL = mockServer.URL
req := &fasthttp.Request{}
req.SetBody([]byte(tt.body))
req.SetRequestURI("/v1/graphql")
req.Header.SetMethod("POST")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Sleep-Duration", tt.sleepDuration)
ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{})
ctx.Request().Header.SetMethod("POST")
ctx.Request().SetBody(req.Body())
ctx.Request().SetRequestURI(string(req.RequestURI())) // Convert []byte to string
ctx.Request().Header.SetContentType("application/json")
ctx.Request().Header.Set("X-Sleep-Duration", tt.sleepDuration)
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
if tt.wantErr {
assert.NotNil(err, "Expected an error for test: %s", tt.name)
} else {
assert.Nil(err, "Expected no error for test: %s", tt.name)
}
})
}
}
+63 -40
View File
@@ -2,80 +2,89 @@ package main
import (
"os"
"sync"
"time"
"github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
type RateLimitConfig struct {
RateCounterTicker *goratecounter.RateCounter
Interval time.Duration `json:"interval"`
Req int `json:"req"`
Interval string `json:"interval"`
Req int `json:"req"`
}
var (
rateLimits = make(map[string]RateLimitConfig)
rateLimitMu sync.RWMutex
)
var rateLimits map[string]RateLimitConfig
var ratelimit_intervals = map[string]time.Duration{
"milli": time.Millisecond,
"micro": time.Microsecond,
"nano": time.Nanosecond,
"second": time.Second,
"minute": time.Minute,
"hour": time.Hour,
"day": time.Hour * 24,
}
func loadRatelimitConfig() error {
paths := []string{"/go/src/app/ratelimit.json", "./ratelimit.json", "./static/app/default-ratelimit.json"}
for _, path := range paths {
if err := loadConfigFromPath(path); err == nil {
err := loadConfigFromPath(path)
if err == nil {
return nil
}
}
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Rate limit config not found",
Pairs: map[string]interface{}{"paths": paths},
})
return os.ErrNotExist
}
func loadConfigFromPath(path string) error {
file, err := os.ReadFile(path)
if err != nil {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Failed to load config",
Pairs: map[string]interface{}{"path": path, "error": err},
})
return err
}
var config struct {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Rate limit config not found",
Pairs: map[string]interface{}{"paths": paths},
})
return os.ErrNotExist
}
func loadConfigFromPath(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
config := struct {
RateLimit map[string]RateLimitConfig `json:"ratelimit"`
}
}{}
if err := json.Unmarshal(file, &config); err != nil {
decoder := json.NewDecoder(file)
if err := decoder.Decode(&config); err != nil {
return err
}
newRateLimits := make(map[string]RateLimitConfig, len(config.RateLimit))
for key, value := range config.RateLimit {
value.RateCounterTicker = goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: value.Interval,
Interval: time.Duration(value.Req) * ratelimit_intervals[value.Interval],
})
if cfg.LogLevel == "debug" {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Setting ratelimit config for role",
Pairs: map[string]interface{}{
"role": key,
"interval_used": value.Interval,
"ratelimit": value.Req,
"role": key,
"interval_provided": value.Interval,
"interval_used": ratelimit_intervals[value.Interval],
"ratelimit": value.Req,
},
})
}
newRateLimits[key] = value
config.RateLimit[key] = value
}
rateLimitMu.Lock()
rateLimits = newRateLimits
rateLimitMu.Unlock()
rateLimits = config.RateLimit
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit config loaded",
Pairs: map[string]interface{}{"ratelimit": rateLimits},
@@ -83,14 +92,28 @@ func loadConfigFromPath(path string) error {
return nil
}
func rateLimitedRequest(userID, userRole string) bool {
rateLimitMu.RLock()
roleConfig, ok := rateLimits[userRole]
rateLimitMu.RUnlock()
if !ok || roleConfig.RateCounterTicker == nil {
func rateLimitedRequest(userID string, userRole string) (shouldAllow bool) {
if rateLimits == nil {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit role not found or ticker not initialized",
Message: "Rate limit config not found",
Pairs: map[string]interface{}{"user_role": userRole},
})
return true
}
// Fetch role config once to avoid multiple map lookups
roleConfig, ok := rateLimits[userRole]
if !ok {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit role not found",
Pairs: map[string]interface{}{"user_role": userRole},
})
return true
}
if roleConfig.RateCounterTicker == nil {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit ticker not found",
Pairs: map[string]interface{}{"user_role": userRole},
})
return true
+111 -77
View File
@@ -1,9 +1,9 @@
package main
import (
"context"
"fmt"
"strconv"
"sync"
"time"
"github.com/goccy/go-json"
@@ -15,41 +15,30 @@ import (
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
libpack_trace "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing"
)
const (
healthCheckQueryStr = `{ __typename }`
)
var (
ctxPool = sync.Pool{
New: func() interface{} {
return new(fiber.Ctx)
},
}
)
// StartHTTPProxy starts the HTTP and points it to the GraphQL server.
func StartHTTPProxy() {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Starting the HTTP proxy",
Pairs: nil,
})
serverConfig := fiber.Config{
server := fiber.New(fiber.Config{
DisableStartupMessage: true,
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
IdleTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second,
IdleTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
ReadTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
WriteTimeout: time.Duration(cfg.Client.ClientTimeout) * time.Second * 2,
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
}
server := fiber.New(serverConfig)
})
server.Use(cors.New(cors.Config{
AllowOrigins: "*",
}))
// add middleware to check if the request is a GraphQL query
server.Use(AddRequestUUID)
server.Get("/healthz", healthCheck)
@@ -62,11 +51,11 @@ func StartHTTPProxy() {
Message: "GraphQL proxy started",
Pairs: map[string]interface{}{"port": cfg.Server.PortGraphQL},
})
if err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL)); err != nil {
err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL))
if err != nil {
cfg.Logger.Critical(&libpack_logger.LogMessage{
Message: "Can't start the service",
Pairs: map[string]interface{}{"port": cfg.Server.PortGraphQL, "error": err.Error()},
Pairs: map[string]interface{}{"port": cfg.Server.PortGraphQL},
})
}
}
@@ -84,125 +73,168 @@ func checkAllowedURLs(c *fiber.Ctx) bool {
if len(allowedUrls) == 0 {
return true
}
path := c.OriginalURL()
_, ok := allowedUrls[path]
_, ok := allowedUrls[c.Path()]
return ok
}
func extractTraceHeaders(c *fiber.Ctx) (found bool, traceHeaders map[string]string) {
if !cfg.Trace.Enable {
return
}
headers := c.Request().Header
traceHeader := headers.Peek("X-Trace-Span")
if traceHeader != nil {
traceHeaders = make(map[string]string)
if err := json.Unmarshal(traceHeader, &traceHeaders); err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Error unmarshalling tracer header",
Pairs: map[string]interface{}{"error": err},
})
return
}
found = true
}
return
}
func healthCheck(c *fiber.Ctx) error {
if len(cfg.Server.HealthcheckGraphQL) > 0 {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Health check enabled",
Pairs: map[string]interface{}{"url": cfg.Server.HealthcheckGraphQL},
})
_, err := cfg.Client.GQLClient.Query(healthCheckQueryStr, nil, nil)
query := `{ __typename }`
_, err := cfg.Client.GQLClient.Query(query, nil, nil)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't reach the GraphQL server",
Pairs: map[string]interface{}{"error": err.Error()},
})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
return c.Status(500).SendString("Can't reach the GraphQL server with {__typename} query")
c.Status(500).SendString("Can't reach the GraphQL server with {__typename} query")
return err
}
}
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Health check returning OK",
Pairs: nil,
})
return c.Status(200).SendString("Health check OK")
c.Status(200).SendString("Health check OK")
return nil
}
func processGraphQLRequest(c *fiber.Ctx) error {
startTime := time.Now()
extractedUserID := "-"
extractedRoleName := "-"
// Initialize variables with default values
extractedUserID, extractedRoleName := "-", "-"
if authorization := c.Get("Authorization"); authorization != "" && (len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0) {
extractedUserID, extractedRoleName = extractClaimsFromJWTHeader(authorization)
// Pre-fetch headers and trace header processing
headers := c.Request().Header
authorization := headers.Peek("Authorization")
ctx := context.Background()
traceHeaderFound, traceHeader := extractTraceHeaders(c)
if traceHeaderFound {
ctx = libpack_trace.TraceContextExtract(ctx, traceHeader)
_, span := libpack_trace.ContinueSpanFromContext(ctx, "GraphQLRequest")
defer span.End()
}
// JWT and role extraction with pre-check
if authorization != nil && (len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0) {
extractedUserID, extractedRoleName = extractClaimsFromJWTHeader(string(authorization))
}
// Check for banned users early
if checkIfUserIsBanned(c, extractedUserID) {
return c.Status(403).SendString("User is banned")
}
if cfg.Client.RoleFromHeader != "" {
if role := c.Get(cfg.Client.RoleFromHeader); role != "" {
extractedRoleName = role
// Role extraction from header
if len(cfg.Client.RoleFromHeader) > 0 {
extractedRoleName = string(headers.Peek(cfg.Client.RoleFromHeader))
if extractedRoleName == "" {
extractedRoleName = "-"
}
}
if cfg.Client.RoleRateLimit {
// Rate limiting check
if cfg.Client.RoleRateLimit && !rateLimitedRequest(extractedUserID, extractedRoleName) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limiting enabled",
Pairs: map[string]interface{}{"user_id": extractedUserID, "role_name": extractedRoleName},
})
if !rateLimitedRequest(extractedUserID, extractedRoleName) {
return c.Status(429).SendString("Rate limit exceeded, try again later")
}
return c.Status(429).SendString("Rate limit exceeded, try again later")
}
// Parsing GraphQL query
parsedResult := parseGraphQLQuery(c)
if parsedResult.shouldBlock {
return c.Status(403).SendString("Request blocked")
}
if parsedResult.shouldIgnore {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Request passed as-is - probably not a GraphQL",
Pairs: nil,
})
return proxyTheRequest(c, parsedResult.activeEndpoint)
return proxyTheRequest(c, parsedResult.activeEndpoint, ctx)
}
calculatedQueryHash := libpack_cache.CalculateHash(c)
// Cache handling logic
queryCacheHash := libpack_cache.CalculateHash(c)
if parsedResult.cacheTime == 0 {
if cacheQuery := c.Get("X-Cache-Graphql-Query"); cacheQuery != "" {
parsedResult.cacheTime, _ = strconv.Atoi(cacheQuery)
cacheQuery := headers.Peek("X-Cache-Graphql-Query")
if cacheQuery != nil {
parsedResult.cacheTime, _ = strconv.Atoi(string(cacheQuery))
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache time set via header",
Pairs: map[string]interface{}{"cacheTime": parsedResult.cacheTime},
})
} else {
parsedResult.cacheTime = cfg.Cache.CacheTTL
}
}
wasCached := false
if parsedResult.cacheRefresh {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache refresh requested via query",
Pairs: map[string]interface{}{"user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")},
})
libpack_cache.CacheDelete(calculatedQueryHash)
libpack_cache.CacheDelete(queryCacheHash)
}
wasCached := false
if parsedResult.cacheRequest || cfg.Cache.CacheEnable || cfg.Cache.CacheRedisEnable {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache enabled",
Pairs: map[string]interface{}{"via_query": parsedResult.cacheRequest, "via_env": cfg.Cache.CacheEnable},
})
if cachedResponse := libpack_cache.CacheLookup(calculatedQueryHash); cachedResponse != nil {
if cachedResponse := libpack_cache.CacheLookup(queryCacheHash); cachedResponse != nil {
cfg.Monitoring.Increment(libpack_monitoring.MetricsCacheHit, nil)
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache hit",
Pairs: map[string]interface{}{"hash": calculatedQueryHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")},
Pairs: map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")},
})
c.Set("X-Cache-Hit", "true")
headers.Add("X-Cache-Hit", "true")
if err := c.Send(cachedResponse); err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't send the cached response",
Pairs: map[string]interface{}{"error": err.Error()},
})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
return c.Status(500).SendString("Can't send the cached response - try again later")
}
wasCached = true
c.Set("Content-Type", "application/json")
return c.Send(cachedResponse)
}
cfg.Monitoring.Increment(libpack_monitoring.MetricsCacheMiss, nil)
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache miss",
Pairs: map[string]interface{}{"hash": calculatedQueryHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")},
})
if err := proxyAndCacheTheRequest(c, calculatedQueryHash, parsedResult.cacheTime, parsedResult.activeEndpoint); err != nil {
return err
} else {
cfg.Monitoring.Increment(libpack_monitoring.MetricsCacheMiss, nil)
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Cache miss",
Pairs: map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")},
})
proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime, parsedResult.activeEndpoint, ctx)
}
} else {
if err := proxyTheRequest(c, parsedResult.activeEndpoint); err != nil {
if err := proxyTheRequest(c, parsedResult.activeEndpoint, ctx); err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't proxy the request",
Pairs: map[string]interface{}{"error": err.Error()},
@@ -212,31 +244,33 @@ func processGraphQLRequest(c *fiber.Ctx) error {
}
}
logAndMonitorRequest(c, extractedUserID, parsedResult.operationType, parsedResult.operationName, wasCached, time.Since(startTime), startTime)
timeTaken := time.Since(startTime)
logAndMonitorRequest(c, extractedUserID, parsedResult.operationType, parsedResult.operationName, wasCached, timeTaken, startTime)
return nil
}
func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int, currentEndpoint string) error {
if err := proxyTheRequest(c, currentEndpoint); err != nil {
// Additional helper function to avoid code repetition
func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int, currentEndpoint string, ctx context.Context) {
err := proxyTheRequest(c, currentEndpoint, ctx)
if err != nil {
cfg.Logger.Error(&libpack_logger.LogMessage{
Message: "Can't proxy the request",
Pairs: map[string]interface{}{"error": err.Error()},
})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
return c.Status(500).SendString("Can't proxy the request - try again later")
c.Status(500).SendString("Can't proxy the request - try again later")
return
}
libpack_cache.CacheStoreWithTTL(queryCacheHash, c.Response().Body(), time.Duration(cacheTime)*time.Second)
cfg.Monitoring.Increment(libpack_monitoring.MetricsQueriesCached, nil)
return c.Send(c.Response().Body())
c.Send(c.Response().Body())
}
func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached bool, duration time.Duration, startTime time.Time) {
labels := map[string]string{
"op_type": opType,
"op_name": opName,
"cached": strconv.FormatBool(wasCached),
"cached": fmt.Sprintf("%t", wasCached),
"user_id": userID,
}
@@ -245,7 +279,7 @@ func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached
Message: "Request processed",
Pairs: map[string]interface{}{
"ip": c.IP(),
"fwd-ip": c.Get("X-Forwarded-For"),
"fwd-ip": string(c.Request().Header.Peek("X-Forwarded-For")),
"user_id": userID,
"op_type": opType,
"op_name": opName,
+5
View File
@@ -9,6 +9,11 @@ import (
// config is a struct that holds the configuration of the application.
type config struct {
Trace struct {
Client func()
TraceEndpoint string
Enable bool
}
Logger *libpack_logging.Logger
LogLevel string
Monitoring *libpack_monitoring.MetricsSetup
+98
View File
@@ -0,0 +1,98 @@
package libpack_trace
import (
"context"
"fmt"
"time"
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
oteltrace "go.opentelemetry.io/otel/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
)
func NewClient(log *libpack_logging.Logger, otelGRPCCollector string, attr ...attribute.KeyValue) (func(), error) {
attr = append(attr, semconv.ServiceNameKey.String(libpack_config.PKG_NAME))
fmt.Printf("Starting OpenTelemetry tracer: otlp, configured with endpoint: %s\n", otelGRPCCollector)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := otlptracegrpc.NewClient(
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithEndpoint(otelGRPCCollector),
)
exporter, err := otlptrace.New(ctx, client)
if err != nil {
log.Error(&libpack_logging.LogMessage{
Message: "Failed to create exporter",
Pairs: map[string]interface{}{"error": err},
})
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithSampler(trace.AlwaysSample()),
trace.WithBatcher(exporter, trace.WithMaxExportBatchSize(1), trace.WithBatchTimeout(30*time.Second)),
trace.WithResource(resource.NewWithAttributes(semconv.SchemaURL, attr...)),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
shutdownFunc := func() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
log.Info(&libpack_logging.LogMessage{
Message: "Shutting down tracer",
Pairs: nil,
})
if err := tp.Shutdown(shutdownCtx); err != nil {
log.Warning(&libpack_logging.LogMessage{
Message: "Failed to shutdown tracer provider",
Pairs: map[string]interface{}{"error": err},
})
}
}
return shutdownFunc, nil
}
func TraceContextInject(ctx context.Context) map[string]string {
carrier := propagation.MapCarrier{}
propagator := otel.GetTextMapPropagator()
propagator.Inject(ctx, carrier)
return map[string]string(carrier)
}
func TraceContextExtract(ctx context.Context, traceContext map[string]string) context.Context {
carrier := propagation.MapCarrier(traceContext)
propagator := otel.GetTextMapPropagator()
return propagator.Extract(ctx, carrier)
}
func StartSpanFromContext(ctx context.Context, operationName string) (context.Context, oteltrace.Span) {
tr := otel.GetTracerProvider().Tracer("")
return tr.Start(ctx, operationName, oteltrace.WithSpanKind(oteltrace.SpanKindServer))
}
func ContinueSpanFromContext(ctx context.Context, operationName string) (context.Context, oteltrace.Span) {
tr := otel.GetTracerProvider().Tracer("")
options := []oteltrace.SpanStartOption{
oteltrace.WithSpanKind(oteltrace.SpanKindInternal),
oteltrace.WithAttributes(attribute.String("cont", "true")),
}
return tr.Start(ctx, operationName, options...)
}
func AddAttributesToSpan(span oteltrace.Span, attributes ...attribute.KeyValue) {
span.SetAttributes(attributes...)
}
+109
View File
@@ -0,0 +1,109 @@
package libpack_trace
import (
"testing"
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type TraceTestSuite struct {
suite.Suite
logger *libpack_logging.Logger
}
func (suite *TraceTestSuite) SetupTest() {
suite.logger = libpack_logging.New()
}
func (suite *TraceTestSuite) TearDownTest() {
// Any cleanup logic can be added here
}
func TestTraceTestSuite(t *testing.T) {
suite.Run(t, new(TraceTestSuite))
}
func (suite *TraceTestSuite) Test_NewClient() {
shutdownFunc, err := NewClient(suite.logger, "localhost:4317")
assert.NoError(suite.T(), err)
assert.NotNil(suite.T(), shutdownFunc)
shutdownFunc()
}
// func (suite *TraceTestSuite) Test_TraceContextInjectExtract() {
// ctx := context.Background()
// traceContext := TraceContextInject(ctx)
// assert.NotEmpty(suite.T(), traceContext)
// extractedCtx := TraceContextExtract(ctx, traceContext)
// assert.NotNil(suite.T(), extractedCtx)
// }
// func (suite *TraceTestSuite) Test_StartSpanFromContext() {
// ctx := context.Background()
// ctx, span := StartSpanFromContext(ctx, "operation")
// assert.NotNil(suite.T(), ctx)
// assert.NotNil(suite.T(), span)
// span.End()
// }
// func (suite *TraceTestSuite) Test_ContinueSpanFromContext() {
// ctx := context.Background()
// ctx, span := ContinueSpanFromContext(ctx, "operation")
// assert.NotNil(suite.T(), ctx)
// assert.NotNil(suite.T(), span)
// span.End()
// }
// func (suite *TraceTestSuite) Test_AddAttributesToSpan() {
// ctx := context.Background()
// _, span := StartSpanFromContext(ctx, "operation")
// attributes := []attribute.KeyValue{
// attribute.String("key1", "value1"),
// attribute.Int("key2", 2),
// }
// AddAttributesToSpan(span, attributes...)
// span.End()
// // Create an in-memory span exporter
// exporter := tracetest.NewSpanRecorder()
// tracerProvider := trace.NewTracerProvider(trace.WithSpanProcessor(exporter))
// otel.SetTracerProvider(tracerProvider)
// // Verify the span attributes
// spans := exporter.Ended()
// assert.Len(suite.T(), spans, 1)
// exportedSpan := spans[0]
// for _, attr := range attributes {
// assert.Contains(suite.T(), exportedSpan.Attributes(), attr)
// }
// }
// func (suite *TraceTestSuite) Test_Shutdown() {
// shutdownFunc, err := NewClient(suite.logger, "localhost:4317")
// assert.NoError(suite.T(), err)
// assert.NotNil(suite.T(), shutdownFunc)
// shutdownFunc()
// logOutput := captureStdOut(func() { suite.logger.Info(&libpack_logging.LogMessage{Message: "Shutting down tracer"}) })
// assert.Contains(suite.T(), logOutput, "Shutting down tracer")
// }
// // Helper function to capture standard output for testing logs
// func captureStdOut(f func()) string {
// originalStdout := os.Stdout
// r, w, _ := os.Pipe()
// os.Stdout = w
// f()
// w.Close()
// var buf bytes.Buffer
// buf.ReadFrom(r)
// os.Stdout = originalStdout
// return buf.String()
// }