mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-13 02:17:35 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 122148d23e | |||
| 6e493e4100 | |||
| 92da4af001 | |||
| c68dc2f20a | |||
| 11ff751001 | |||
| 0414473f15 | |||
| bc61557015 | |||
| 12ec00f697 | |||
| da4a179d66 | |||
| d0ecefce6c | |||
| c742530d2f | |||
| 7304559801 | |||
| aa46992497 | |||
| e968a48584 | |||
| c67dfe1827 | |||
| 55d86e34cf | |||
| cd4a1f16ed | |||
| 3352050bdb | |||
| bb2509e254 | |||
| d027122446 | |||
| 3abbaf66a1 | |||
| f8871a4fb7 | |||
| 420e63f383 | |||
| 9bd9f0b9ba | |||
| 31cb5930d5 | |||
| 454e1d2425 | |||
| 98afa39943 | |||
| 6605c59efd | |||
| f87f2ae5a2 | |||
| 04f6deb0a8 | |||
| 5ea41ea268 | |||
| b8b814a9be | |||
| 5b79b49b00 | |||
| bdbf829a59 | |||
| dcff327745 | |||
| f2997c4c9f | |||
| c3fe0471df | |||
| d62c718682 | |||
| 26cebee756 | |||
| acace4fe16 | |||
| f6fc338c8c | |||
| 9b792c3c64 | |||
| d3fe02aa52 | |||
| 82000bfb4c | |||
| 3aa83d4480 | |||
| caeae62236 | |||
| 0e1deab8ed | |||
| 67b0bebbc3 | |||
| 92c2c162d8 | |||
| 8367812a48 | |||
| 86fa0551df | |||
| 4be6b0f6cf | |||
| 6bc4cfd916 | |||
| a3093fe2d1 | |||
| c0f5f0830d | |||
| 623cbbcae3 | |||
| 05a07fde42 | |||
| c926d0d0a3 | |||
| 6c96880eae | |||
| 7f78869a8a | |||
| 794ec6a752 | |||
| 9678b8f7b9 | |||
| 7bb76893f5 | |||
| 4ef42e5781 | |||
| 996d29b57b | |||
| 7c80d6adaa | |||
| 31fc3ae3d9 | |||
| da8ec5f21d | |||
| 3d80f457d3 | |||
| 09c3e4cd95 | |||
| d07ee4090c | |||
| b1045b8bc2 | |||
| cc35031db9 | |||
| 6a69694ab3 | |||
| b210627fb7 | |||
| edcabe3cf0 | |||
| c99bf2b245 | |||
| 39dc7b49cf | |||
| 28223b40da | |||
| ee5618c699 | |||
| 94c097bc6c | |||
| 4e84cd7461 | |||
| e37a8beaa7 | |||
| 9dd8c11363 | |||
| 9fbee0d9a1 | |||
| 7df651c17a | |||
| 7ada94e4fa | |||
| c510c29a8f | |||
| 370602858a | |||
| 6261be6e53 | |||
| 5ae4ea1e25 |
@@ -5,69 +5,15 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
env:
|
||||
GO_VERSION: ">=1.21"
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# This job is responsible for preparation of the build
|
||||
# environment variables.
|
||||
prepare:
|
||||
name: Preparing build context
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
id: cache
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: "**/*.sum"
|
||||
|
||||
- name: Go get dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
go get ./...
|
||||
|
||||
# This job is responsible for running tests and linting the codebase
|
||||
test:
|
||||
name: "Unit testing"
|
||||
runs-on: ubuntu-latest
|
||||
container: golang:1
|
||||
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
|
||||
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
|
||||
go get -u -v ./...
|
||||
go mod tidy -v
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
CI_RUN=${CI} make test
|
||||
git config --global --add safe.directory /__w/graphql-monitoring-proxy/graphql-monitoring-proxy
|
||||
|
||||
- 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"
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
release-workflow: "release.yaml"
|
||||
secrets: inherit
|
||||
|
||||
+7
-100
@@ -1,109 +1,16 @@
|
||||
name: Run tests on PR
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- main
|
||||
push:
|
||||
paths-ignore:
|
||||
- "**/**.md"
|
||||
- "**/**.yaml"
|
||||
- "static/**"
|
||||
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:
|
||||
# This job is responsible for preparation of the build
|
||||
# environment variables.
|
||||
prepare:
|
||||
name: Preparing build context
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
id: cache
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: "**/*.sum"
|
||||
|
||||
- name: Go get dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
go get ./...
|
||||
|
||||
# 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]
|
||||
|
||||
# services:
|
||||
# # Label used to access the service container
|
||||
# redis:
|
||||
# # Docker Hub image
|
||||
# image: redis
|
||||
# # Set health checks to wait until redis has started
|
||||
# options: >-
|
||||
# --health-cmd "redis-cli ping"
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# ports:
|
||||
# # Maps the container port to the host machine
|
||||
# - 6379:6379
|
||||
|
||||
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 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"
|
||||
pr-checks:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||
with:
|
||||
go-version: "1.24"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**/release.yaml"
|
||||
- "static/**"
|
||||
- "docs/**"
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
deployments: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
with:
|
||||
go-version: "1.24"
|
||||
docker-enabled: true
|
||||
secrets: inherit
|
||||
|
||||
benchmark:
|
||||
name: Publish Benchmarks
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: main
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
|
||||
- name: Run benchmarks
|
||||
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: false
|
||||
benchmark-data-dir-path: "docs/bench"
|
||||
|
||||
- name: Push benchmark results
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docs/bench
|
||||
git diff --staged --quiet || git commit -m "Update benchmark results"
|
||||
git push origin main
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Test and release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "**/**.md"
|
||||
- "**/**.yaml"
|
||||
- "static/**"
|
||||
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
|
||||
with:
|
||||
enable-code-scans: false
|
||||
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"
|
||||
@@ -3,3 +3,5 @@ test.sh
|
||||
banned.json*
|
||||
dist/
|
||||
coverage.out
|
||||
CLAUDE.md
|
||||
graphql-monitoring-proxy
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Project-specific golangci-lint configuration (v2)
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# Code quality
|
||||
- govet # Go vet (suspicious constructs)
|
||||
- staticcheck # Advanced static analysis
|
||||
- unused # Find unused code
|
||||
- errcheck # Check for unchecked errors
|
||||
|
||||
# Security
|
||||
- gosec # Security issues
|
||||
|
||||
settings:
|
||||
unused:
|
||||
field-writes-are-uses: true
|
||||
post-statements-are-reads: true
|
||||
exported-is-used: true
|
||||
exported-fields-are-used: true
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
# Field alignment is a micro-optimization that reduces readability
|
||||
- fieldalignment
|
||||
# Shadow warnings in this codebase are intentional and safe
|
||||
- shadow
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- "all"
|
||||
# Disable naming convention checks - existing codebase uses underscores
|
||||
# and ALL_CAPS which would require significant refactoring
|
||||
- "-ST1000" # Package comments
|
||||
- "-ST1003" # Naming conventions (underscores, ALL_CAPS)
|
||||
# Disable quickfix suggestions - these are style preferences, not errors
|
||||
- "-QF1001" # De Morgan's law
|
||||
- "-QF1012" # fmt.Fprintf suggestion
|
||||
|
||||
errcheck:
|
||||
# Don't check error returns on these functions (best-effort cleanup)
|
||||
exclude-functions:
|
||||
- (*github.com/gorilla/websocket.Conn).Close
|
||||
- (*github.com/gorilla/websocket.Conn).SetReadDeadline
|
||||
- (*github.com/gorilla/websocket.Conn).WriteMessage
|
||||
- (*github.com/redis/go-redis/v9.Client).Close
|
||||
- (*github.com/redis/go-redis/v9.Pipeline).Exec
|
||||
- (io.Closer).Close
|
||||
- (*os.File).Close
|
||||
- (*compress/gzip.Reader).Close
|
||||
- (net.Conn).Close
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
# G104: Errors unhandled - covered by errcheck with proper exclusions
|
||||
- G104
|
||||
# G115: Integer overflow conversion - safe in this codebase
|
||||
# These are uint64 counter values that will never exceed int64 max
|
||||
- G115
|
||||
# G402: TLS InsecureSkipVerify - this is a configurable option
|
||||
# Users explicitly enable this via GMP_DISABLE_TLS_VERIFY env var
|
||||
- G402
|
||||
|
||||
exclusions:
|
||||
presets:
|
||||
- common-false-positives
|
||||
rules:
|
||||
# Test files can have relaxed rules
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- unused
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
# Specific file exclusions for known patterns
|
||||
- path: api\.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G306"
|
||||
# File permissions 0644 for banned users file is intentional
|
||||
# This is a non-sensitive configuration file that may be
|
||||
# read by deployment tools
|
||||
|
||||
# Exclude enableApi naming (would be a breaking change)
|
||||
- path: api\.go
|
||||
text: "ST1003"
|
||||
|
||||
# Generated files
|
||||
- path: \.pb\.go$
|
||||
linters:
|
||||
- all
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
settings:
|
||||
gofmt:
|
||||
simplify: true
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
modules-download-mode: readonly
|
||||
build-tags:
|
||||
- ""
|
||||
go: "1.23"
|
||||
|
||||
output:
|
||||
formats:
|
||||
text:
|
||||
path: stdout
|
||||
colors: true
|
||||
sort-results: true
|
||||
@@ -0,0 +1,87 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- id: graphql-proxy
|
||||
main: .
|
||||
binary: graphql-proxy
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
||||
archives:
|
||||
- id: graphql-proxy
|
||||
formats: [tar.gz]
|
||||
name_template: "graphql-proxy-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
checksum:
|
||||
name_template: "graphql-proxy-checksums.txt"
|
||||
algorithm: sha256
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^Merge'
|
||||
- '^WIP'
|
||||
- '^Update go.mod'
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: lukaszraczylo
|
||||
name: graphql-monitoring-proxy
|
||||
name_template: "version {{.Version}}"
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
dockers_v2:
|
||||
- images:
|
||||
- "ghcr.io/lukaszraczylo/graphql-monitoring-proxy"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "latest"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
extra_files:
|
||||
- static/app
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
args:
|
||||
- sign-blob
|
||||
- "--bundle=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes"
|
||||
artifacts: checksum
|
||||
output: true
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
artifacts: manifests
|
||||
output: true
|
||||
args:
|
||||
- sign
|
||||
- "${artifact}@${digest}"
|
||||
- "--yes"
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM gcr.io/distroless/base-debian12:nonroot
|
||||
ARG TARGETPLATFORM
|
||||
WORKDIR /go/src/app
|
||||
COPY --chmod=777 --chown=nonroot:nonroot static/app /go/src/app
|
||||
COPY ${TARGETPLATFORM}/graphql-proxy /go/src/app/graphql-proxy
|
||||
ENTRYPOINT ["/go/src/app/graphql-proxy"]
|
||||
@@ -57,6 +57,25 @@ You should always try to stick to the latest and greatest version of the graphql
|
||||
|
||||
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.
|
||||
|
||||
#### Verifying Release Signatures
|
||||
|
||||
All release checksums and Docker images are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
|
||||
|
||||
```bash
|
||||
# Verify checksum signature
|
||||
cosign verify-blob \
|
||||
--certificate-identity-regexp "https://github.com/lukaszraczylo/graphql-monitoring-proxy/.*" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
--bundle "<checksums-file>.sigstore.json" \
|
||||
<checksums-file>
|
||||
|
||||
# Verify Docker image
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/lukaszraczylo/graphql-monitoring-proxy/.*" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest
|
||||
```
|
||||
|
||||
#### Note on websocket support
|
||||
|
||||
**Native WebSocket Support Available!** Starting with version 0.27.0, the proxy includes native WebSocket support for GraphQL subscriptions. Enable it by setting `WEBSOCKET_ENABLE=true`.
|
||||
@@ -155,6 +174,8 @@ You can still use the non-prefixed environment variables in the spirit of the ba
|
||||
| `CACHE_TTL` | The cache TTL | `60` |
|
||||
| `CACHE_MAX_MEMORY_SIZE` | Maximum memory size for cache in MB | `100` |
|
||||
| `CACHE_MAX_ENTRIES` | Maximum number of entries in cache | `10000` |
|
||||
| `CACHE_USE_LRU` | Use LRU eviction algorithm (see [Cache Eviction](#cache-eviction-algorithms)) | `false` |
|
||||
| `CACHE_PER_USER_DISABLED` | **⚠️ SECURITY**: Disable per-user cache isolation | `false` (**DO NOT** set to `true` in multi-user apps) |
|
||||
| `ENABLE_REDIS_CACHE` | Enable distributed Redis cache | `false` |
|
||||
| `CACHE_REDIS_URL` | URL to redis server / cluster endpoint | `localhost:6379` |
|
||||
| `CACHE_REDIS_PASSWORD` | Redis connection password | `` |
|
||||
@@ -347,19 +368,38 @@ The admin dashboard (`/admin`) provides:
|
||||
The cache engine is enabled in the background by default, using no additional resources.
|
||||
You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` or `ENABLE_REDIS_CACHE` environment variable to `true` - which will enable the cache for all queries without introspection. You can leave the global cache disabled and enable the cache for specific queries by adding the `@cached` directive to the query.
|
||||
|
||||
**Important**: The cache key is calculated from the **entire request body**, which includes both the GraphQL query and variables. This means:
|
||||
**Important**: The cache key is calculated from the **request body + user context (user ID and role)**. This means:
|
||||
- Identical queries with different variables are cached separately
|
||||
- Identical queries with different variable values get their own cache entries
|
||||
- This ensures correct caching behavior for parameterized queries
|
||||
- **Identical queries from different users are cached separately** (security isolation)
|
||||
- **Identical queries with different roles are cached separately** (prevents privilege escalation)
|
||||
- This ensures correct caching behavior and prevents data leakage between users
|
||||
|
||||
**🔒 Security Update (v0.27.0+)**: Cache keys now include user context by default to prevent security vulnerabilities where users could see each other's cached data. This is enabled by default and should NOT be disabled in multi-user applications.
|
||||
|
||||
Example:
|
||||
```graphql
|
||||
# These two requests will have DIFFERENT cache keys:
|
||||
# These requests will have DIFFERENT cache keys:
|
||||
|
||||
# Different variables
|
||||
query GetUser($id: ID!) { user(id: $id) { name } }
|
||||
variables: { "id": "123" }
|
||||
variables: { "id": "123" } // Cache key: MD5(body + user:alice + role:user)
|
||||
|
||||
query GetUser($id: ID!) { user(id: $id) { name } }
|
||||
variables: { "id": "456" }
|
||||
variables: { "id": "456" } // Cache key: MD5(body + user:alice + role:user)
|
||||
|
||||
# Different users (SECURITY: prevents data leakage)
|
||||
query GetMyProfile { me { email } }
|
||||
Authorization: Bearer token_for_alice // Cache key: MD5(body + user:alice + role:user)
|
||||
|
||||
query GetMyProfile { me { email } }
|
||||
Authorization: Bearer token_for_bob // Cache key: MD5(body + user:bob + role:user)
|
||||
|
||||
# Different roles (SECURITY: prevents privilege escalation)
|
||||
query GetData { data { value } }
|
||||
Authorization: Bearer token_admin // Cache key: MD5(body + user:alice + role:admin)
|
||||
|
||||
query GetData { data { value } }
|
||||
Authorization: Bearer token_user // Cache key: MD5(body + user:alice + role:user)
|
||||
```
|
||||
|
||||
In the case of the `@cached` you can add additional parameters to the directive which will set the cache for specific queries to the provided time.
|
||||
@@ -419,12 +459,40 @@ These features ensure the cache runs efficiently even under high load and with l
|
||||
Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly.
|
||||
Since version `0.15.48` the you can also use the distributed Redis cache.
|
||||
|
||||
#### Cache Eviction Algorithms
|
||||
|
||||
The proxy supports two cache eviction strategies:
|
||||
|
||||
**Standard (default):** Uses Go's `sync.Map` with approximate eviction. When memory limits are reached, entries are evicted based on iteration order (pseudo-random). This is memory-efficient and has excellent concurrent read performance.
|
||||
|
||||
**LRU (Least Recently Used):** Uses a proper LRU algorithm with a linked list to track access order. When limits are reached, the least recently accessed entries are evicted first. Enable with `CACHE_USE_LRU=true`.
|
||||
|
||||
| Feature | Standard | LRU |
|
||||
|---------|----------|-----|
|
||||
| Eviction order | Pseudo-random | Least recently used |
|
||||
| Read performance | Excellent | Good |
|
||||
| Memory tracking | Approximate | Precise |
|
||||
| Best for | High read throughput | Cache hit optimization |
|
||||
|
||||
*LRU cache configuration:*
|
||||
```bash
|
||||
GMP_ENABLE_GLOBAL_CACHE=true
|
||||
GMP_CACHE_TTL=300
|
||||
GMP_CACHE_USE_LRU=true
|
||||
GMP_CACHE_MAX_MEMORY_SIZE=200
|
||||
GMP_CACHE_MAX_ENTRIES=5000
|
||||
```
|
||||
|
||||
Use LRU when cache hit rate is critical and you want to ensure frequently accessed data stays cached. Use Standard (default) for maximum read throughput with less memory overhead.
|
||||
|
||||
#### Read-only endpoint
|
||||
|
||||
You can now specify the read-only GraphQL endpoint by setting the `HOST_GRAPHQL_READONLY` environment variable. The default value is empty, preventing the proxy from using the read-only endpoint for the queries and directing all the requests to the main endpoint specified as `HOST_GRAPHQL`. If the `HOST_GRAPHQL_READONLY` is set, the proxy will use the read-only endpoint for the queries with the `query` type and the main endpoint for the `mutation` type queries. Format of the read-only endpoint is the same as `HOST_GRAPHQL` endpoint, for example `http://localhost:8080/`.
|
||||
|
||||
You can check out the [example of combined deployment with RW and read-only hasura](static/kubernetes-single-deployment-with-ro.yaml).
|
||||
|
||||
**Important:** When using a read-only Hasura instance connected to a PostgreSQL read replica, you **must** disable event trigger processing on that instance by setting `HASURA_GRAPHQL_EVENTS_FETCH_INTERVAL=0` in the read-only Hasura container environment variables. This prevents the read-only instance from attempting to process event triggers (which require write access to event log tables), avoiding "cannot set transaction read-write mode during recovery" errors.
|
||||
|
||||
### Resilience
|
||||
|
||||
#### Circuit Breaker Pattern
|
||||
@@ -723,6 +791,8 @@ Following tables are being cleaned:
|
||||
- `hdb_catalog.hdb_cron_event_invocation_logs`
|
||||
- `hdb_catalog.hdb_scheduled_event_invocation_logs`
|
||||
|
||||
**Important for RO/RW setups:** The `HASURA_EVENT_METADATA_DB` connection string must point to the **read-write primary database** where the `hdb_catalog` schema resides. The cleaner executes DELETE operations which require write permissions. Do not point this to a read-only replica.
|
||||
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
+149
-20
@@ -563,6 +563,26 @@
|
||||
<span class="metric-label">Enabled</span>
|
||||
<span class="metric-value" id="cb-enabled">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Total Requests</span>
|
||||
<span class="metric-value" id="cb-total-requests">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Total Successes</span>
|
||||
<span class="metric-value" id="cb-total-successes">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Total Failures</span>
|
||||
<span class="metric-value" id="cb-total-failures">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Consecutive Successes</span>
|
||||
<span class="metric-value" id="cb-consecutive-successes">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Consecutive Failures</span>
|
||||
<span class="metric-value" id="cb-consecutive-failures">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Max Failures</span>
|
||||
<span class="metric-value" id="cb-max-failures">--</span>
|
||||
@@ -1055,6 +1075,63 @@
|
||||
|
||||
// Update all statistics
|
||||
function updateAllStats(data) {
|
||||
// Check if this is cluster mode data
|
||||
if (data.cluster_mode && data.stats) {
|
||||
// Cluster mode: data is structured differently
|
||||
// Stats contains aggregated data with nested objects
|
||||
const stats = data.stats;
|
||||
|
||||
// Update cluster status section
|
||||
document.getElementById('cluster-status-section').style.display = 'block';
|
||||
document.getElementById('cluster-total-instances').textContent = data.total_instances || 0;
|
||||
document.getElementById('cluster-healthy-instances').textContent = data.healthy_instances || 0;
|
||||
document.getElementById('overview-title').textContent = 'Cluster Overview';
|
||||
|
||||
// Update cluster info in toggle
|
||||
const totalInstances = data.total_instances || 0;
|
||||
document.getElementById('cluster-info').textContent =
|
||||
`(${totalInstances} instance${totalInstances !== 1 ? 's' : ''} available)`;
|
||||
|
||||
// Build stats object with uptime from cluster_uptime
|
||||
const statsWithUptime = {
|
||||
...stats,
|
||||
uptime_seconds: stats.cluster_uptime || 0,
|
||||
uptime_human: formatUptime(stats.cluster_uptime || 0)
|
||||
};
|
||||
updateStats(statsWithUptime);
|
||||
|
||||
// Extract nested objects from stats for cluster mode
|
||||
if (stats.circuit_breaker) updateCircuitBreaker(stats.circuit_breaker);
|
||||
if (stats.coalescing) updateCoalescing(stats.coalescing);
|
||||
if (stats.retry_budget) updateRetryBudget(stats.retry_budget);
|
||||
if (stats.websocket) updateWebSocket(stats.websocket);
|
||||
if (stats.connections) updateConnections(stats.connections);
|
||||
|
||||
// Handle memory for cluster mode (Redis doesn't track memory per instance)
|
||||
if (stats.memory) {
|
||||
const totalMemMB = stats.memory.total_usage_mb;
|
||||
if (totalMemMB < 0) {
|
||||
// All instances are using Redis cache
|
||||
document.getElementById('cache-memory').textContent = 'N/A';
|
||||
document.getElementById('cache-memory').title = 'Memory tracking not available for Redis cache';
|
||||
document.getElementById('cache-memory-pct').textContent = 'Redis cache';
|
||||
document.getElementById('memory-progress').style.width = '0%';
|
||||
} else {
|
||||
document.getElementById('cache-memory').textContent = totalMemMB.toFixed(2) + ' MB';
|
||||
document.getElementById('cache-memory-pct').textContent = 'Cluster total';
|
||||
}
|
||||
}
|
||||
|
||||
// Update instance list if available
|
||||
if (data.instances && data.instances.length > 0) {
|
||||
document.getElementById('instance-details-section').style.display = 'block';
|
||||
updateInstanceList(data.instances, null);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-cluster mode: original behavior
|
||||
if (data.stats) updateStats(data.stats);
|
||||
if (data.health) updateHealth(data.health);
|
||||
if (data.circuit_breaker) updateCircuitBreaker(data.circuit_breaker);
|
||||
@@ -1200,14 +1277,54 @@
|
||||
stateEl.classList.remove('loading');
|
||||
|
||||
let badgeClass = 'badge-info';
|
||||
if (data.state === 'closed') badgeClass = 'badge-success';
|
||||
else if (data.state === 'open') badgeClass = 'badge-danger';
|
||||
else if (data.state === 'half-open') badgeClass = 'badge-warning';
|
||||
let stateText = data.state || 'unknown';
|
||||
|
||||
stateEl.innerHTML = `<span class="badge ${badgeClass}">${data.state || 'Unknown'}</span>`;
|
||||
// For cluster mode, determine state from instance counts
|
||||
if (data.instances_open !== undefined) {
|
||||
// Cluster mode data
|
||||
if (data.instances_open > 0) {
|
||||
stateText = `${data.instances_open} open`;
|
||||
badgeClass = 'badge-danger';
|
||||
} else if (data.instances_halfopen > 0) {
|
||||
stateText = `${data.instances_halfopen} half-open`;
|
||||
badgeClass = 'badge-warning';
|
||||
} else if (data.instances_closed > 0) {
|
||||
stateText = `${data.instances_closed} closed`;
|
||||
badgeClass = 'badge-success';
|
||||
}
|
||||
} else {
|
||||
// Single instance mode
|
||||
if (data.state === 'closed') badgeClass = 'badge-success';
|
||||
else if (data.state === 'open') badgeClass = 'badge-danger';
|
||||
else if (data.state === 'half-open') badgeClass = 'badge-warning';
|
||||
}
|
||||
|
||||
stateEl.innerHTML = `<span class="badge ${badgeClass}">${stateText}</span>`;
|
||||
|
||||
document.getElementById('cb-enabled').textContent = data.enabled ? 'Yes' : 'No';
|
||||
|
||||
if (data.counts) {
|
||||
// Single instance mode with detailed counts
|
||||
document.getElementById('cb-total-requests').textContent =
|
||||
(data.counts.requests || 0).toLocaleString();
|
||||
document.getElementById('cb-total-successes').textContent =
|
||||
(data.counts.total_successes || 0).toLocaleString();
|
||||
document.getElementById('cb-total-failures').textContent =
|
||||
(data.counts.total_failures || 0).toLocaleString();
|
||||
document.getElementById('cb-consecutive-successes').textContent =
|
||||
(data.counts.consecutive_successes || 0).toLocaleString();
|
||||
document.getElementById('cb-consecutive-failures').textContent =
|
||||
(data.counts.consecutive_failures || 0).toLocaleString();
|
||||
} else if (data.instances_open !== undefined) {
|
||||
// Cluster mode - show instance distribution instead
|
||||
const total = (data.instances_open || 0) + (data.instances_closed || 0) + (data.instances_halfopen || 0);
|
||||
document.getElementById('cb-total-requests').textContent = total + ' instances';
|
||||
document.getElementById('cb-total-successes').textContent = (data.instances_closed || 0).toLocaleString();
|
||||
document.getElementById('cb-total-failures').textContent = (data.instances_open || 0).toLocaleString();
|
||||
document.getElementById('cb-consecutive-successes').textContent = '--';
|
||||
document.getElementById('cb-consecutive-failures').textContent = '--';
|
||||
}
|
||||
|
||||
if (data.config) {
|
||||
document.getElementById('cb-max-failures').textContent = data.config.max_failures || '--';
|
||||
document.getElementById('cb-timeout').textContent = (data.config.timeout || '--') + 's';
|
||||
@@ -1217,23 +1334,31 @@
|
||||
function updateCoalescing(data) {
|
||||
document.getElementById('coalescing-rate').textContent =
|
||||
(data.backend_savings_pct || 0).toFixed(1) + '%';
|
||||
document.getElementById('coalescing-total').textContent =
|
||||
(data.total_requests || 0).toLocaleString();
|
||||
document.getElementById('coalescing-primary').textContent =
|
||||
(data.primary_requests || 0).toLocaleString();
|
||||
document.getElementById('coalescing-coalesced').textContent =
|
||||
(data.coalesced_requests || 0).toLocaleString();
|
||||
|
||||
// Handle both single instance (total_requests) and cluster mode (total_coalesced + total_primary)
|
||||
const totalRequests = data.total_requests ||
|
||||
((data.total_coalesced_requests || 0) + (data.total_primary_requests || 0));
|
||||
document.getElementById('coalescing-total').textContent = totalRequests.toLocaleString();
|
||||
|
||||
// Handle both single instance and cluster mode field names
|
||||
const primaryRequests = data.primary_requests || data.total_primary_requests || 0;
|
||||
document.getElementById('coalescing-primary').textContent = primaryRequests.toLocaleString();
|
||||
|
||||
const coalescedRequests = data.coalesced_requests || data.total_coalesced_requests || 0;
|
||||
document.getElementById('coalescing-coalesced').textContent = coalescedRequests.toLocaleString();
|
||||
|
||||
document.getElementById('coalescing-savings').textContent =
|
||||
(data.backend_savings_pct || 0).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function updateRetryBudget(data) {
|
||||
document.getElementById('retry-tokens').textContent =
|
||||
data.current_tokens || '--';
|
||||
document.getElementById('retry-current-tokens').textContent =
|
||||
data.current_tokens || '--';
|
||||
document.getElementById('retry-max-tokens').textContent =
|
||||
data.max_tokens || '--';
|
||||
// Use explicit undefined check to handle 0 values correctly
|
||||
const currentTokens = data.current_tokens !== undefined ? data.current_tokens : '--';
|
||||
const maxTokens = data.max_tokens !== undefined ? data.max_tokens : '--';
|
||||
|
||||
document.getElementById('retry-tokens').textContent = currentTokens;
|
||||
document.getElementById('retry-current-tokens').textContent = currentTokens;
|
||||
document.getElementById('retry-max-tokens').textContent = maxTokens;
|
||||
document.getElementById('retry-total').textContent =
|
||||
(data.total_attempts || 0).toLocaleString();
|
||||
document.getElementById('retry-denied').textContent =
|
||||
@@ -1243,13 +1368,17 @@
|
||||
}
|
||||
|
||||
function updateWebSocket(data) {
|
||||
document.getElementById('ws-connections').textContent =
|
||||
data.active_connections || 0;
|
||||
// Handle both single instance (active_connections) and cluster mode (total_connections)
|
||||
const connections = data.active_connections !== undefined ? data.active_connections :
|
||||
(data.total_connections !== undefined ? data.total_connections : 0);
|
||||
document.getElementById('ws-connections').textContent = connections;
|
||||
}
|
||||
|
||||
function updateConnections(data) {
|
||||
document.getElementById('pool-connections').textContent =
|
||||
data.active_connections || 0;
|
||||
// Handle both single instance (active_connections) and cluster mode (total_active)
|
||||
const connections = data.active_connections !== undefined ? data.active_connections :
|
||||
(data.total_active !== undefined ? data.total_active : 0);
|
||||
document.getElementById('pool-connections').textContent = connections;
|
||||
}
|
||||
|
||||
async function resetCoalescing() {
|
||||
|
||||
+241
-72
@@ -9,9 +9,18 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
// Admin dashboard constants
|
||||
const (
|
||||
// WebSocketReadDeadline is the read deadline for WebSocket connections
|
||||
WebSocketReadDeadline = 60 * time.Second
|
||||
// StatsStreamInterval is the interval for streaming stats updates
|
||||
StatsStreamInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
//go:embed admin/dashboard.html
|
||||
var dashboardHTML embed.FS
|
||||
|
||||
@@ -60,7 +69,7 @@ func (ad *AdminDashboard) RegisterRoutes(app *fiber.App) {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Admin dashboard routes registered",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": "/admin",
|
||||
},
|
||||
})
|
||||
@@ -79,13 +88,48 @@ func (ad *AdminDashboard) serveDashboard(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// getStats returns overall proxy statistics
|
||||
// In cluster mode (when metrics aggregator is available), returns aggregated stats from all instances
|
||||
func (ad *AdminDashboard) getStats(c *fiber.Ctx) error {
|
||||
// Check if cluster mode is enabled - if so, return aggregated stats
|
||||
if aggregator := GetMetricsAggregator(); aggregator != nil {
|
||||
metrics, err := aggregator.GetAggregatedMetrics()
|
||||
if err != nil {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to get aggregated metrics, falling back to local stats",
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
// Fall through to local stats on error
|
||||
} else {
|
||||
// Return aggregated cluster stats
|
||||
response := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
"timestamp": metrics.LastUpdate.Format(time.RFC3339),
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
// Add combined stats from aggregation
|
||||
if metrics.CombinedStats != nil {
|
||||
for k, v := range metrics.CombinedStats {
|
||||
response[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Local instance stats (fallback or non-cluster mode)
|
||||
uptimeSeconds := time.Since(startTime).Seconds()
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"cluster_mode": false,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"uptime_seconds": uptimeSeconds,
|
||||
"uptime_human": formatDuration(time.Since(startTime)),
|
||||
"version": "0.27.0", // TODO: Get from build info
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
if cfg != nil && cfg.Monitoring != nil {
|
||||
@@ -95,7 +139,7 @@ func (ad *AdminDashboard) getStats(c *fiber.Ctx) error {
|
||||
total := succeeded + failed + skipped
|
||||
|
||||
// Request statistics
|
||||
requestStats := map[string]interface{}{
|
||||
requestStats := map[string]any{
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
@@ -137,7 +181,7 @@ func (ad *AdminDashboard) getStats(c *fiber.Ctx) error {
|
||||
if totalCacheRequests > 0 {
|
||||
hitRate = float64(cacheStats.CacheHits) / float64(totalCacheRequests) * 100
|
||||
}
|
||||
stats["cache_summary"] = map[string]interface{}{
|
||||
stats["cache_summary"] = map[string]any{
|
||||
"hits": cacheStats.CacheHits,
|
||||
"misses": cacheStats.CacheMisses,
|
||||
"hit_rate_pct": hitRate,
|
||||
@@ -170,16 +214,16 @@ func formatDuration(d time.Duration) string {
|
||||
func (ad *AdminDashboard) getHealth(c *fiber.Ctx) error {
|
||||
healthMgr := GetBackendHealthManager()
|
||||
|
||||
health := map[string]interface{}{
|
||||
health := map[string]any{
|
||||
"status": "unknown",
|
||||
"backend": map[string]interface{}{
|
||||
"backend": map[string]any{
|
||||
"healthy": false,
|
||||
},
|
||||
}
|
||||
|
||||
if healthMgr != nil {
|
||||
isHealthy := healthMgr.IsHealthy()
|
||||
health["backend"] = map[string]interface{}{
|
||||
health["backend"] = map[string]any{
|
||||
"healthy": isHealthy,
|
||||
"consecutive_failures": healthMgr.GetConsecutiveFailures(),
|
||||
"last_check": healthMgr.GetLastHealthCheck().Format(time.RFC3339),
|
||||
@@ -197,7 +241,7 @@ func (ad *AdminDashboard) getHealth(c *fiber.Ctx) error {
|
||||
|
||||
// getCircuitBreakerStatus returns circuit breaker status
|
||||
func (ad *AdminDashboard) getCircuitBreakerStatus(c *fiber.Ctx) error {
|
||||
status := map[string]interface{}{
|
||||
status := map[string]any{
|
||||
"enabled": false,
|
||||
"state": "unknown",
|
||||
}
|
||||
@@ -208,10 +252,18 @@ func (ad *AdminDashboard) getCircuitBreakerStatus(c *fiber.Ctx) error {
|
||||
if cb != nil {
|
||||
cbMutex.RLock()
|
||||
state := cb.State()
|
||||
counts := cb.Counts()
|
||||
cbMutex.RUnlock()
|
||||
|
||||
status["state"] = state.String()
|
||||
status["config"] = map[string]interface{}{
|
||||
status["counts"] = map[string]any{
|
||||
"requests": counts.Requests,
|
||||
"total_successes": counts.TotalSuccesses,
|
||||
"total_failures": counts.TotalFailures,
|
||||
"consecutive_successes": counts.ConsecutiveSuccesses,
|
||||
"consecutive_failures": counts.ConsecutiveFailures,
|
||||
}
|
||||
status["config"] = map[string]any{
|
||||
"max_failures": cfg.CircuitBreaker.MaxFailures,
|
||||
"failure_ratio": cfg.CircuitBreaker.FailureRatio,
|
||||
"timeout": cfg.CircuitBreaker.Timeout,
|
||||
@@ -225,9 +277,62 @@ func (ad *AdminDashboard) getCircuitBreakerStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// getCacheStats returns cache statistics
|
||||
// In cluster mode, returns aggregated cache stats from all instances
|
||||
func (ad *AdminDashboard) getCacheStats(c *fiber.Ctx) error {
|
||||
stats := map[string]interface{}{
|
||||
"enabled": false,
|
||||
// Check if cluster mode is enabled - if so, return aggregated cache stats
|
||||
if aggregator := GetMetricsAggregator(); aggregator != nil {
|
||||
metrics, err := aggregator.GetAggregatedMetrics()
|
||||
if err != nil {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to get aggregated cache metrics, falling back to local stats",
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
// Fall through to local stats on error
|
||||
} else {
|
||||
// Build aggregated cache stats from combined stats
|
||||
response := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
}
|
||||
|
||||
// Add cache config from local config
|
||||
if cfg != nil {
|
||||
response["enabled"] = cfg.Cache.CacheEnable
|
||||
response["redis_enabled"] = cfg.Cache.CacheRedisEnable
|
||||
response["ttl_seconds"] = cfg.Cache.CacheTTL
|
||||
response["max_memory_mb"] = cfg.Cache.CacheMaxMemorySize
|
||||
response["max_entries"] = cfg.Cache.CacheMaxEntries
|
||||
}
|
||||
|
||||
// Extract aggregated cache stats from combined stats
|
||||
if metrics.CombinedStats != nil {
|
||||
if cacheHits, ok := metrics.CombinedStats["cache_hits"]; ok {
|
||||
response["cache_hits"] = cacheHits
|
||||
}
|
||||
if cacheMisses, ok := metrics.CombinedStats["cache_misses"]; ok {
|
||||
response["cache_misses"] = cacheMisses
|
||||
}
|
||||
if cachedQueries, ok := metrics.CombinedStats["cached_queries"]; ok {
|
||||
response["cached_queries"] = cachedQueries
|
||||
}
|
||||
if hitRate, ok := metrics.CombinedStats["cache_hit_rate_pct"]; ok {
|
||||
response["hit_rate_pct"] = hitRate
|
||||
}
|
||||
if memoryMB, ok := metrics.CombinedStats["memory_usage_mb"]; ok {
|
||||
response["memory_usage_mb"] = memoryMB
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Local instance stats (fallback or non-cluster mode)
|
||||
stats := map[string]any{
|
||||
"cluster_mode": false,
|
||||
"enabled": false,
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
@@ -280,7 +385,7 @@ func (ad *AdminDashboard) getCacheStats(c *fiber.Ctx) error {
|
||||
func (ad *AdminDashboard) getConnectionStats(c *fiber.Ctx) error {
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"available": false,
|
||||
}
|
||||
|
||||
@@ -297,7 +402,7 @@ func (ad *AdminDashboard) getRetryBudgetStats(c *fiber.Ctx) error {
|
||||
rb := GetRetryBudget()
|
||||
|
||||
if rb == nil {
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"enabled": false,
|
||||
})
|
||||
}
|
||||
@@ -310,7 +415,7 @@ func (ad *AdminDashboard) getCoalescingStats(c *fiber.Ctx) error {
|
||||
rc := GetRequestCoalescer()
|
||||
|
||||
if rc == nil {
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"enabled": false,
|
||||
})
|
||||
}
|
||||
@@ -323,7 +428,7 @@ func (ad *AdminDashboard) getWebSocketStats(c *fiber.Ctx) error {
|
||||
wsp := GetWebSocketProxy()
|
||||
|
||||
if wsp == nil {
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"enabled": false,
|
||||
})
|
||||
}
|
||||
@@ -333,8 +438,8 @@ func (ad *AdminDashboard) getWebSocketStats(c *fiber.Ctx) error {
|
||||
|
||||
// clearCache clears the cache
|
||||
func (ad *AdminDashboard) clearCache(c *fiber.Ctx) error {
|
||||
// TODO: Implement cache clearing
|
||||
return c.JSON(map[string]interface{}{
|
||||
libpack_cache.CacheClear()
|
||||
return c.JSON(map[string]any{
|
||||
"success": true,
|
||||
"message": "Cache cleared successfully",
|
||||
})
|
||||
@@ -347,7 +452,7 @@ func (ad *AdminDashboard) resetRetryBudget(c *fiber.Ctx) error {
|
||||
rb.Reset()
|
||||
}
|
||||
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"success": true,
|
||||
"message": "Retry budget statistics reset",
|
||||
})
|
||||
@@ -360,7 +465,7 @@ func (ad *AdminDashboard) resetCoalescing(c *fiber.Ctx) error {
|
||||
rc.Reset()
|
||||
}
|
||||
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"success": true,
|
||||
"message": "Coalescing statistics reset",
|
||||
})
|
||||
@@ -370,7 +475,7 @@ func (ad *AdminDashboard) resetCoalescing(c *fiber.Ctx) error {
|
||||
func (ad *AdminDashboard) getClusterStats(c *fiber.Ctx) error {
|
||||
aggregator := GetMetricsAggregator()
|
||||
if aggregator == nil {
|
||||
return c.Status(503).JSON(map[string]interface{}{
|
||||
return c.Status(503).JSON(map[string]any{
|
||||
"error": "Cluster mode not available",
|
||||
"message": "Redis-based metrics aggregation is not enabled",
|
||||
"cluster_mode": false,
|
||||
@@ -382,17 +487,17 @@ func (ad *AdminDashboard) getClusterStats(c *fiber.Ctx) error {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to get aggregated metrics",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
return c.Status(500).JSON(map[string]interface{}{
|
||||
return c.Status(500).JSON(map[string]any{
|
||||
"error": "Failed to retrieve cluster metrics",
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Format response similar to regular stats endpoint
|
||||
response := map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
@@ -407,7 +512,7 @@ func (ad *AdminDashboard) getClusterStats(c *fiber.Ctx) error {
|
||||
func (ad *AdminDashboard) getClusterInstances(c *fiber.Ctx) error {
|
||||
aggregator := GetMetricsAggregator()
|
||||
if aggregator == nil {
|
||||
return c.Status(503).JSON(map[string]interface{}{
|
||||
return c.Status(503).JSON(map[string]any{
|
||||
"error": "Cluster mode not available",
|
||||
"message": "Redis-based metrics aggregation is not enabled",
|
||||
"cluster_mode": false,
|
||||
@@ -419,16 +524,16 @@ func (ad *AdminDashboard) getClusterInstances(c *fiber.Ctx) error {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to get instance metrics",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
return c.Status(500).JSON(map[string]interface{}{
|
||||
return c.Status(500).JSON(map[string]any{
|
||||
"error": "Failed to retrieve instance metrics",
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
@@ -441,7 +546,7 @@ func (ad *AdminDashboard) getClusterInstances(c *fiber.Ctx) error {
|
||||
func (ad *AdminDashboard) getClusterDebug(c *fiber.Ctx) error {
|
||||
aggregator := GetMetricsAggregator()
|
||||
|
||||
debug := map[string]interface{}{
|
||||
debug := map[string]any{
|
||||
"aggregator_initialized": aggregator != nil,
|
||||
"redis_cache_enabled": false,
|
||||
}
|
||||
@@ -466,7 +571,7 @@ func (ad *AdminDashboard) getClusterDebug(c *fiber.Ctx) error {
|
||||
// Show first instance structure as example
|
||||
if len(metrics.Instances) > 0 {
|
||||
first := metrics.Instances[0]
|
||||
debug["sample_instance"] = map[string]interface{}{
|
||||
debug["sample_instance"] = map[string]any{
|
||||
"instance_id": first.InstanceID,
|
||||
"hostname": first.Hostname,
|
||||
"uptime_seconds": first.UptimeSeconds,
|
||||
@@ -477,7 +582,7 @@ func (ad *AdminDashboard) getClusterDebug(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Show requests structure if it exists
|
||||
if requests, ok := first.Stats["requests"].(map[string]interface{}); ok {
|
||||
if requests, ok := first.Stats["requests"].(map[string]any); ok {
|
||||
debug["sample_requests"] = requests
|
||||
}
|
||||
}
|
||||
@@ -488,7 +593,7 @@ func (ad *AdminDashboard) getClusterDebug(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Helper to get map keys
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
func getMapKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
@@ -500,7 +605,7 @@ func getMapKeys(m map[string]interface{}) []string {
|
||||
func (ad *AdminDashboard) forcePublish(c *fiber.Ctx) error {
|
||||
aggregator := GetMetricsAggregator()
|
||||
if aggregator == nil {
|
||||
return c.Status(503).JSON(map[string]interface{}{
|
||||
return c.Status(503).JSON(map[string]any{
|
||||
"error": "Aggregator not initialized",
|
||||
"success": false,
|
||||
})
|
||||
@@ -509,7 +614,7 @@ func (ad *AdminDashboard) forcePublish(c *fiber.Ctx) error {
|
||||
// Trigger publish in goroutine to avoid blocking
|
||||
go aggregator.publishMetrics()
|
||||
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"success": true,
|
||||
"triggered": true,
|
||||
"message": "Publish triggered in background",
|
||||
@@ -538,7 +643,7 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket client connected to stats stream",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"remote_addr": c.RemoteAddr().String(),
|
||||
},
|
||||
})
|
||||
@@ -549,18 +654,18 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket client disconnected from stats stream",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"remote_addr": c.RemoteAddr().String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
c.Close()
|
||||
_ = c.Close() // Best-effort cleanup
|
||||
}()
|
||||
|
||||
// Set up ping/pong handlers
|
||||
c.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
_ = c.SetReadDeadline(time.Now().Add(WebSocketReadDeadline))
|
||||
c.SetPongHandler(func(string) error {
|
||||
c.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
_ = c.SetReadDeadline(time.Now().Add(WebSocketReadDeadline))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -578,14 +683,14 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Stream statistics every 2 seconds
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
// Stream statistics at configured interval
|
||||
ticker := time.NewTicker(StatsStreamInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send initial stats immediately
|
||||
if stats := ad.gatherAllStats(); stats != nil {
|
||||
// Send initial stats immediately (cluster-aware for dashboard)
|
||||
if stats := ad.gatherAllStatsClusterAware(); stats != nil {
|
||||
if data, err := json.Marshal(stats); err == nil {
|
||||
c.WriteMessage(websocket.TextMessage, data)
|
||||
_ = c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,8 +698,8 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Gather all stats
|
||||
stats := ad.gatherAllStats()
|
||||
// Gather all stats (cluster-aware for dashboard)
|
||||
stats := ad.gatherAllStatsClusterAware()
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.Marshal(stats)
|
||||
@@ -602,7 +707,7 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to marshal stats for WebSocket",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -613,7 +718,7 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Failed to write to WebSocket (client likely disconnected)",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -627,16 +732,64 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
}
|
||||
|
||||
// gatherAllStats collects all statistics into a single structure
|
||||
func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
// This always returns LOCAL stats for this instance (used by metrics aggregator)
|
||||
func (ad *AdminDashboard) gatherAllStats() map[string]any {
|
||||
return ad.gatherAllStatsWithMode(false)
|
||||
}
|
||||
|
||||
// gatherAllStatsClusterAware collects statistics with cluster awareness
|
||||
// If cluster mode is available, returns aggregated stats from all instances
|
||||
func (ad *AdminDashboard) gatherAllStatsClusterAware() map[string]any {
|
||||
return ad.gatherAllStatsWithMode(true)
|
||||
}
|
||||
|
||||
// gatherAllStatsWithMode collects statistics with optional cluster mode
|
||||
func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string]any {
|
||||
// Check if cluster mode is requested and available
|
||||
if useClusterMode {
|
||||
if aggregator := GetMetricsAggregator(); aggregator != nil {
|
||||
metrics, err := aggregator.GetAggregatedMetrics()
|
||||
if err == nil && metrics != nil {
|
||||
// Return aggregated cluster stats
|
||||
result := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
}
|
||||
|
||||
// Build stats section from combined stats
|
||||
stats := map[string]any{
|
||||
"timestamp": metrics.LastUpdate.Format(time.RFC3339),
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
// Copy all combined stats
|
||||
if metrics.CombinedStats != nil {
|
||||
for k, v := range metrics.CombinedStats {
|
||||
stats[k] = v
|
||||
}
|
||||
}
|
||||
result["stats"] = stats
|
||||
|
||||
// Add per-instance details
|
||||
result["instances"] = metrics.Instances
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local stats
|
||||
result := make(map[string]any)
|
||||
result["cluster_mode"] = false
|
||||
|
||||
// Main stats
|
||||
uptimeSeconds := time.Since(startTime).Seconds()
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"uptime_seconds": uptimeSeconds,
|
||||
"uptime_human": formatDuration(time.Since(startTime)),
|
||||
"version": "0.27.0",
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
if cfg != nil && cfg.Monitoring != nil {
|
||||
@@ -645,7 +798,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
skipped := getAdminMetricValue("requests_skipped")
|
||||
total := succeeded + failed + skipped
|
||||
|
||||
requestStats := map[string]interface{}{
|
||||
requestStats := map[string]any{
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
@@ -684,7 +837,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
if totalCacheRequests > 0 {
|
||||
hitRate = float64(cacheStats.CacheHits) / float64(totalCacheRequests) * 100
|
||||
}
|
||||
stats["cache_summary"] = map[string]interface{}{
|
||||
stats["cache_summary"] = map[string]any{
|
||||
"hits": cacheStats.CacheHits,
|
||||
"misses": cacheStats.CacheMisses,
|
||||
"hit_rate_pct": hitRate,
|
||||
@@ -697,16 +850,16 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
|
||||
// Health
|
||||
healthMgr := GetBackendHealthManager()
|
||||
health := map[string]interface{}{
|
||||
health := map[string]any{
|
||||
"status": "unknown",
|
||||
"backend": map[string]interface{}{
|
||||
"backend": map[string]any{
|
||||
"healthy": false,
|
||||
},
|
||||
}
|
||||
|
||||
if healthMgr != nil {
|
||||
isHealthy := healthMgr.IsHealthy()
|
||||
health["backend"] = map[string]interface{}{
|
||||
health["backend"] = map[string]any{
|
||||
"healthy": isHealthy,
|
||||
"consecutive_failures": healthMgr.GetConsecutiveFailures(),
|
||||
"last_check": healthMgr.GetLastHealthCheck().Format(time.RFC3339),
|
||||
@@ -721,7 +874,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
result["health"] = health
|
||||
|
||||
// Circuit breaker
|
||||
cbStatus := map[string]interface{}{
|
||||
cbStatus := map[string]any{
|
||||
"enabled": false,
|
||||
"state": "unknown",
|
||||
}
|
||||
@@ -732,10 +885,18 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
if cb != nil {
|
||||
cbMutex.RLock()
|
||||
state := cb.State()
|
||||
counts := cb.Counts()
|
||||
cbMutex.RUnlock()
|
||||
|
||||
cbStatus["state"] = state.String()
|
||||
cbStatus["config"] = map[string]interface{}{
|
||||
cbStatus["counts"] = map[string]any{
|
||||
"requests": counts.Requests,
|
||||
"total_successes": counts.TotalSuccesses,
|
||||
"total_failures": counts.TotalFailures,
|
||||
"consecutive_successes": counts.ConsecutiveSuccesses,
|
||||
"consecutive_failures": counts.ConsecutiveFailures,
|
||||
}
|
||||
cbStatus["config"] = map[string]any{
|
||||
"max_failures": cfg.CircuitBreaker.MaxFailures,
|
||||
"failure_ratio": cfg.CircuitBreaker.FailureRatio,
|
||||
"timeout": cfg.CircuitBreaker.Timeout,
|
||||
@@ -747,7 +908,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
result["circuit_breaker"] = cbStatus
|
||||
|
||||
// Cache stats
|
||||
cacheStats := map[string]interface{}{
|
||||
cacheStats := map[string]any{
|
||||
"enabled": false,
|
||||
}
|
||||
|
||||
@@ -771,23 +932,31 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
}
|
||||
cacheStats["hit_rate_pct"] = hitRate
|
||||
|
||||
memoryUsage := libpack_cache.GetCacheMemoryUsage()
|
||||
maxMemory := libpack_cache.GetCacheMaxMemorySize()
|
||||
cacheStats["memory_usage_bytes"] = memoryUsage
|
||||
cacheStats["memory_usage_mb"] = float64(memoryUsage) / (1024 * 1024)
|
||||
// Only get memory usage for in-memory cache (not Redis)
|
||||
if cfg.Cache.CacheEnable && !cfg.Cache.CacheRedisEnable {
|
||||
memoryUsage := libpack_cache.GetCacheMemoryUsage()
|
||||
maxMemory := libpack_cache.GetCacheMaxMemorySize()
|
||||
cacheStats["memory_usage_bytes"] = memoryUsage
|
||||
cacheStats["memory_usage_mb"] = float64(memoryUsage) / (1024 * 1024)
|
||||
|
||||
memoryUsagePct := 0.0
|
||||
if maxMemory > 0 {
|
||||
memoryUsagePct = float64(memoryUsage) / float64(maxMemory) * 100
|
||||
memoryUsagePct := 0.0
|
||||
if maxMemory > 0 {
|
||||
memoryUsagePct = float64(memoryUsage) / float64(maxMemory) * 100
|
||||
}
|
||||
cacheStats["memory_usage_pct"] = memoryUsagePct
|
||||
} else {
|
||||
// For Redis cache, memory tracking is not available per instance
|
||||
cacheStats["memory_usage_bytes"] = int64(-1)
|
||||
cacheStats["memory_usage_mb"] = float64(-1)
|
||||
cacheStats["memory_usage_pct"] = float64(-1)
|
||||
}
|
||||
cacheStats["memory_usage_pct"] = memoryUsagePct
|
||||
}
|
||||
}
|
||||
result["cache"] = cacheStats
|
||||
|
||||
// Connection stats
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
connStats := map[string]interface{}{
|
||||
connStats := map[string]any{
|
||||
"available": false,
|
||||
}
|
||||
|
||||
@@ -800,7 +969,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
// Retry budget
|
||||
rb := GetRetryBudget()
|
||||
if rb == nil {
|
||||
result["retry_budget"] = map[string]interface{}{"enabled": false}
|
||||
result["retry_budget"] = map[string]any{"enabled": false}
|
||||
} else {
|
||||
result["retry_budget"] = rb.GetStats()
|
||||
}
|
||||
@@ -808,7 +977,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
// Coalescing
|
||||
rc := GetRequestCoalescer()
|
||||
if rc == nil {
|
||||
result["coalescing"] = map[string]interface{}{"enabled": false}
|
||||
result["coalescing"] = map[string]any{"enabled": false}
|
||||
} else {
|
||||
result["coalescing"] = rc.GetStats()
|
||||
}
|
||||
@@ -816,7 +985,7 @@ func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
// WebSocket
|
||||
wsp := GetWebSocketProxy()
|
||||
if wsp == nil {
|
||||
result["websocket"] = map[string]interface{}{"enabled": false}
|
||||
result["websocket"] = map[string]any{"enabled": false}
|
||||
} else {
|
||||
result["websocket"] = wsp.GetStats()
|
||||
}
|
||||
|
||||
+23
-19
@@ -103,7 +103,7 @@ func TestAdminDashboard_GetStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -116,7 +116,7 @@ func TestAdminDashboard_GetStats(t *testing.T) {
|
||||
assert.NotNil(t, stats["requests"])
|
||||
|
||||
// Verify request stats structure
|
||||
requests := stats["requests"].(map[string]interface{})
|
||||
requests := stats["requests"].(map[string]any)
|
||||
assert.NotNil(t, requests["total"])
|
||||
assert.NotNil(t, requests["succeeded"])
|
||||
assert.NotNil(t, requests["failed"])
|
||||
@@ -139,7 +139,7 @@ func TestAdminDashboard_GetHealth(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var health map[string]interface{}
|
||||
var health map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &health)
|
||||
assert.NoError(t, err)
|
||||
@@ -188,7 +188,7 @@ func TestAdminDashboard_GetCircuitBreakerStatus(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var status map[string]interface{}
|
||||
var status map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &status)
|
||||
assert.NoError(t, err)
|
||||
@@ -214,12 +214,16 @@ func TestAdminDashboard_GetCacheStats(t *testing.T) {
|
||||
CacheRedisEnable bool
|
||||
CacheMaxMemorySize int
|
||||
CacheMaxEntries int
|
||||
CacheUseLRU bool
|
||||
GraphQLQueryCacheSize int
|
||||
PerUserCacheDisabled bool
|
||||
}{
|
||||
CacheEnable: true,
|
||||
CacheTTL: 60,
|
||||
CacheMaxMemorySize: 100,
|
||||
CacheMaxEntries: 10000,
|
||||
CacheEnable: true,
|
||||
CacheTTL: 60,
|
||||
CacheMaxMemorySize: 100,
|
||||
CacheMaxEntries: 10000,
|
||||
CacheUseLRU: false,
|
||||
PerUserCacheDisabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -232,7 +236,7 @@ func TestAdminDashboard_GetCacheStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -256,7 +260,7 @@ func TestAdminDashboard_GetConnectionStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -279,7 +283,7 @@ func TestAdminDashboard_GetRetryBudgetStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -302,7 +306,7 @@ func TestAdminDashboard_GetCoalescingStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -325,7 +329,7 @@ func TestAdminDashboard_GetWebSocketStats(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var stats map[string]interface{}
|
||||
var stats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &stats)
|
||||
assert.NoError(t, err)
|
||||
@@ -348,7 +352,7 @@ func TestAdminDashboard_ClearCache(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
assert.NoError(t, err)
|
||||
@@ -379,7 +383,7 @@ func TestAdminDashboard_ResetRetryBudget(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
assert.NoError(t, err)
|
||||
@@ -406,7 +410,7 @@ func TestAdminDashboard_ResetCoalescing(t *testing.T) {
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// Parse response
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
assert.NoError(t, err)
|
||||
@@ -471,7 +475,7 @@ func TestAdminDashboard_IntegrationWithFeatures(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var rbStats map[string]interface{}
|
||||
var rbStats map[string]any
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &rbStats)
|
||||
assert.Equal(t, true, rbStats["enabled"])
|
||||
@@ -482,7 +486,7 @@ func TestAdminDashboard_IntegrationWithFeatures(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var coalStats map[string]interface{}
|
||||
var coalStats map[string]any
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &coalStats)
|
||||
assert.Equal(t, true, coalStats["enabled"])
|
||||
@@ -493,7 +497,7 @@ func TestAdminDashboard_IntegrationWithFeatures(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var wsStats map[string]interface{}
|
||||
var wsStats map[string]any
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
json.Unmarshal(body, &wsStats)
|
||||
assert.Equal(t, true, wsStats["enabled"])
|
||||
|
||||
@@ -37,7 +37,7 @@ func authMiddleware(c *fiber.Ctx) error {
|
||||
if expectedKey == "" {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Admin API authentication disabled - endpoints protected by network segmentation",
|
||||
Pairs: map[string]interface{}{"endpoint": c.Path()},
|
||||
Pairs: map[string]any{"endpoint": c.Path()},
|
||||
})
|
||||
return c.Next()
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func authMiddleware(c *fiber.Ctx) error {
|
||||
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(expectedKey)) != 1 {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Unauthorized API access attempt",
|
||||
Pairs: map[string]interface{}{"endpoint": c.Path(), "ip": c.IP()},
|
||||
Pairs: map[string]any{"endpoint": c.Path(), "ip": c.IP()},
|
||||
})
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Unauthorized",
|
||||
@@ -61,6 +61,23 @@ func enableApi(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SECURITY WARNING: Check if API authentication is configured
|
||||
adminAPIKey := os.Getenv("GMP_ADMIN_API_KEY")
|
||||
if adminAPIKey == "" {
|
||||
adminAPIKey = os.Getenv("ADMIN_API_KEY")
|
||||
}
|
||||
if adminAPIKey == "" {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "⚠️ Admin API enabled WITHOUT authentication - all endpoints are publicly accessible!",
|
||||
Pairs: map[string]any{
|
||||
"security_risk": "HIGH - Admin API endpoints can be accessed without credentials",
|
||||
"affected_ops": "user-ban, user-unban, cache-clear, circuit-breaker controls",
|
||||
"recommendation": "Set GMP_ADMIN_API_KEY environment variable or use network segmentation",
|
||||
"api_port": cfg.Server.ApiPort,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
apiserver := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
AppName: fmt.Sprintf("GraphQL Monitoring Proxy - %s v%s", libpack_config.PKG_NAME, libpack_config.PKG_VERSION),
|
||||
@@ -115,7 +132,7 @@ func periodicallyReloadBannedUsers(ctx context.Context) {
|
||||
loadBannedUsers()
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Banned users reloaded",
|
||||
Pairs: map[string]interface{}{"users": bannedUsersIDs},
|
||||
Pairs: map[string]any{"users": bannedUsersIDs},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -128,18 +145,18 @@ func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Checking if user is banned",
|
||||
Pairs: map[string]interface{}{"user_id": userID, "banned": found},
|
||||
Pairs: map[string]any{"user_id": userID, "banned": found},
|
||||
})
|
||||
|
||||
if found {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "User is banned",
|
||||
Pairs: map[string]interface{}{"user_id": userID},
|
||||
Pairs: map[string]any{"user_id": userID},
|
||||
})
|
||||
if err := c.Status(fiber.StatusForbidden).SendString("User is banned"); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to send banned user response",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,9 +187,11 @@ func apiCircuitBreakerHealth(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Get circuit breaker state
|
||||
// Get circuit breaker state with proper mutex protection
|
||||
cbMutex.RLock()
|
||||
state := cb.State()
|
||||
counts := cb.Counts()
|
||||
cbMutex.RUnlock()
|
||||
|
||||
// Determine health status
|
||||
var status string
|
||||
@@ -223,7 +242,7 @@ func apiBanUser(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't parse the ban user request",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid request payload")
|
||||
}
|
||||
@@ -238,7 +257,7 @@ func apiBanUser(c *fiber.Ctx) error {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Banned user",
|
||||
Pairs: map[string]interface{}{"user_id": req.UserID, "reason": req.Reason},
|
||||
Pairs: map[string]any{"user_id": req.UserID, "reason": req.Reason},
|
||||
})
|
||||
|
||||
if err := storeBannedUsers(); err != nil {
|
||||
@@ -253,7 +272,7 @@ func apiUnbanUser(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't parse the unban user request",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid request payload")
|
||||
}
|
||||
@@ -268,7 +287,7 @@ func apiUnbanUser(c *fiber.Ctx) error {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Unbanned user",
|
||||
Pairs: map[string]interface{}{"user_id": req.UserID},
|
||||
Pairs: map[string]any{"user_id": req.UserID},
|
||||
})
|
||||
|
||||
if err := storeBannedUsers(); err != nil {
|
||||
@@ -287,7 +306,7 @@ func storeBannedUsers() error {
|
||||
if err := fileLock.Unlock(); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to unlock file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -299,7 +318,7 @@ func storeBannedUsers() error {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't marshal banned users",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -307,7 +326,7 @@ func storeBannedUsers() error {
|
||||
if err := os.WriteFile(cfg.Api.BannedUsersFile, data, 0o644); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't write banned users to file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -319,12 +338,12 @@ func loadBannedUsers() {
|
||||
if _, err := os.Stat(cfg.Api.BannedUsersFile); os.IsNotExist(err) {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Banned users file doesn't exist - creating it",
|
||||
Pairs: map[string]interface{}{"file": cfg.Api.BannedUsersFile},
|
||||
Pairs: map[string]any{"file": cfg.Api.BannedUsersFile},
|
||||
})
|
||||
if err := os.WriteFile(cfg.Api.BannedUsersFile, []byte("{}"), 0o644); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't create and write to the file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -334,7 +353,7 @@ func loadBannedUsers() {
|
||||
if err := lockFileRead(fileLock); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't lock the file [load]",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -342,7 +361,7 @@ func loadBannedUsers() {
|
||||
if err := fileLock.Unlock(); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to unlock file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -351,7 +370,7 @@ func loadBannedUsers() {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't read banned users from file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -360,7 +379,7 @@ func loadBannedUsers() {
|
||||
if err := json.Unmarshal(data, &newBannedUsers); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't unmarshal banned users",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -386,7 +405,7 @@ func lockFile(fileLock *flock.Flock) error {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't lock the file",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -394,7 +413,7 @@ func lockFile(fileLock *flock.Flock) error {
|
||||
case <-ctx.Done():
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "File lock timeout",
|
||||
Pairs: map[string]interface{}{"timeout": "30s"},
|
||||
Pairs: map[string]any{"timeout": "30s"},
|
||||
})
|
||||
return fmt.Errorf("file lock timeout after 30 seconds")
|
||||
}
|
||||
@@ -416,7 +435,7 @@ func lockFileRead(fileLock *flock.Flock) error {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't lock the file for reading",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -424,7 +443,7 @@ func lockFileRead(fileLock *flock.Flock) error {
|
||||
case <-ctx.Done():
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "File read lock timeout",
|
||||
Pairs: map[string]interface{}{"timeout": "30s"},
|
||||
Pairs: map[string]any{"timeout": "30s"},
|
||||
})
|
||||
return fmt.Errorf("file read lock timeout after 30 seconds")
|
||||
}
|
||||
|
||||
+15
-15
@@ -87,7 +87,7 @@ func (suite *APIAuthSecurityTestSuite) TestOptionalAuthentication() {
|
||||
os.Unsetenv("ADMIN_API_KEY")
|
||||
|
||||
tests := []struct {
|
||||
body map[string]interface{}
|
||||
body map[string]any
|
||||
name string
|
||||
endpoint string
|
||||
method string
|
||||
@@ -131,7 +131,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
os.Setenv("GMP_ADMIN_API_KEY", suite.validAPIKey)
|
||||
defer os.Unsetenv("GMP_ADMIN_API_KEY")
|
||||
tests := []struct {
|
||||
body map[string]interface{}
|
||||
body map[string]any
|
||||
name string
|
||||
apiKey string
|
||||
endpoint string
|
||||
@@ -144,7 +144,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: "",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject requests without API key",
|
||||
},
|
||||
@@ -153,7 +153,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: "wrong-key",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject requests with invalid API key",
|
||||
},
|
||||
@@ -162,7 +162,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: "' OR '1'='1",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject SQL injection attempts in API key",
|
||||
},
|
||||
@@ -171,7 +171,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: "<script>alert('xss')</script>",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject XSS attempts in API key",
|
||||
},
|
||||
@@ -180,7 +180,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: "key; rm -rf /",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject command injection attempts in API key",
|
||||
},
|
||||
@@ -189,7 +189,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: suite.validAPIKey,
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 200,
|
||||
description: "Should accept valid API key for user-ban endpoint",
|
||||
},
|
||||
@@ -198,7 +198,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: suite.validAPIKey,
|
||||
endpoint: "/api/user-unban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test unban"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test unban"},
|
||||
expectedStatus: 200,
|
||||
description: "Should accept valid API key for user-unban endpoint",
|
||||
},
|
||||
@@ -225,7 +225,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: strings.ToUpper(suite.validAPIKey),
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject case-modified API key (case sensitive)",
|
||||
},
|
||||
@@ -234,7 +234,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: suite.validAPIKey + "extra",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject API key with extra characters",
|
||||
},
|
||||
@@ -243,7 +243,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: suite.validAPIKey[5:],
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject partial API key",
|
||||
},
|
||||
@@ -262,7 +262,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
apiKey: suite.validAPIKey + "тест",
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test reason"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test reason"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject API key with unicode characters",
|
||||
},
|
||||
@@ -298,7 +298,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthentication() {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
var response map[string]interface{}
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(body, &response)
|
||||
suite.NoError(err)
|
||||
|
||||
@@ -559,7 +559,7 @@ func (suite *APIAuthSecurityTestSuite) TestAPIAuthenticationErrorMessages() {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
var response map[string]interface{}
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(body, &response)
|
||||
suite.NoError(err)
|
||||
|
||||
|
||||
+8
-11
@@ -56,7 +56,7 @@ func (bhm *BackendHealthManager) WaitForBackendReady(timeout time.Duration) erro
|
||||
|
||||
bhm.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Waiting for GraphQL backend to become ready",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"backend_url": bhm.backendURL,
|
||||
"timeout": timeout.String(),
|
||||
},
|
||||
@@ -70,7 +70,7 @@ func (bhm *BackendHealthManager) WaitForBackendReady(timeout time.Duration) erro
|
||||
bhm.mu.Unlock()
|
||||
bhm.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "GraphQL backend is ready",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"retry_count": retryCount,
|
||||
"time_taken": time.Since(deadline.Add(-timeout)).String(),
|
||||
},
|
||||
@@ -83,7 +83,7 @@ func (bhm *BackendHealthManager) WaitForBackendReady(timeout time.Duration) erro
|
||||
if retryCount%5 == 0 {
|
||||
bhm.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Still waiting for GraphQL backend",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"retry_count": retryCount,
|
||||
"time_remaining": time.Until(deadline).String(),
|
||||
},
|
||||
@@ -185,7 +185,7 @@ func (bhm *BackendHealthManager) checkBackendHealth() bool {
|
||||
if err != nil {
|
||||
bhm.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Backend health check failed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"check_url": healthCheckURL,
|
||||
},
|
||||
@@ -199,7 +199,7 @@ func (bhm *BackendHealthManager) checkBackendHealth() bool {
|
||||
if !isHealthy {
|
||||
bhm.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Backend returned unhealthy status",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"status_code": statusCode,
|
||||
"check_url": healthCheckURL,
|
||||
},
|
||||
@@ -226,14 +226,11 @@ func (bhm *BackendHealthManager) updateHealthStatus(isHealthy bool) {
|
||||
if !previouslyHealthy {
|
||||
bhm.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "GraphQL backend recovered",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"consecutive_failures": bhm.consecutiveFails.Load(),
|
||||
},
|
||||
})
|
||||
// Trigger circuit breaker reset if needed
|
||||
if cfg != nil && cfg.CircuitBreaker.Enable && cb != nil {
|
||||
// The circuit breaker will automatically reset based on its timeout
|
||||
}
|
||||
// Note: Circuit breaker resets automatically based on its configured timeout
|
||||
}
|
||||
bhm.consecutiveFails.Store(0)
|
||||
} else {
|
||||
@@ -241,7 +238,7 @@ func (bhm *BackendHealthManager) updateHealthStatus(isHealthy bool) {
|
||||
if previouslyHealthy {
|
||||
bhm.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "GraphQL backend became unhealthy",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"consecutive_failures": fails,
|
||||
},
|
||||
})
|
||||
|
||||
Vendored
+72
-30
@@ -1,8 +1,12 @@
|
||||
// Package libpack_cache provides a unified caching interface that supports
|
||||
// both in-memory and Redis backends. It handles response caching for GraphQL
|
||||
// queries with automatic compression and TTL management.
|
||||
package libpack_cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -26,8 +30,11 @@ type CacheConfig struct {
|
||||
Memory struct {
|
||||
MaxMemorySize int64 `json:"max_memory_size"` // Maximum memory size in bytes
|
||||
MaxEntries int64 `json:"max_entries"` // Maximum number of entries
|
||||
UseLRU bool `json:"use_lru"` // Use LRU eviction algorithm instead of random eviction
|
||||
}
|
||||
TTL int `json:"ttl"`
|
||||
TTL int `json:"ttl"`
|
||||
IncludeUserContext bool `json:"include_user_context"` // Include user ID and role in cache key
|
||||
PerUserCacheDisabled bool `json:"per_user_cache_disabled"` // Disable per-user caching (backward compatibility)
|
||||
}
|
||||
|
||||
type CacheStats struct {
|
||||
@@ -52,10 +59,14 @@ var (
|
||||
config *CacheConfig
|
||||
)
|
||||
|
||||
// CalculateHash generates an MD5 hash from the request body.
|
||||
// CalculateHash generates an MD5 hash from the request body and optionally user context.
|
||||
// For GraphQL requests, this includes both the query and variables,
|
||||
// ensuring that identical queries with different variables are cached separately.
|
||||
//
|
||||
// SECURITY FIX: This function now includes user ID and role in the cache key by default
|
||||
// to prevent data leakage between authenticated users. Set CACHE_PER_USER_DISABLED=true
|
||||
// to revert to the old behavior (NOT RECOMMENDED for multi-user applications).
|
||||
//
|
||||
// Example GraphQL request body:
|
||||
//
|
||||
// {
|
||||
@@ -63,9 +74,30 @@ var (
|
||||
// "variables": { "id": "123" }
|
||||
// }
|
||||
//
|
||||
// Different variable values will produce different cache keys.
|
||||
func CalculateHash(c *fiber.Ctx) string {
|
||||
return strutil.Md5(c.Body())
|
||||
// With user context enabled (default):
|
||||
// - Same query, same variables, same user → same cache key
|
||||
// - Same query, same variables, different user → different cache key
|
||||
//
|
||||
// Different variable values will always produce different cache keys.
|
||||
func CalculateHash(c *fiber.Ctx, userID string, userRole string) string {
|
||||
cacheKeyData := string(c.Body())
|
||||
|
||||
// Include user context in cache key (default behavior for security)
|
||||
// Only skip if explicitly disabled via configuration (backward compatibility)
|
||||
if config != nil && !config.PerUserCacheDisabled {
|
||||
// Normalize empty user values to prevent cache key collisions
|
||||
if userID == "" {
|
||||
userID = "-"
|
||||
}
|
||||
if userRole == "" {
|
||||
userRole = "-"
|
||||
}
|
||||
|
||||
// Append user context to ensure cache isolation between users
|
||||
cacheKeyData = fmt.Sprintf("%s|uid:%s|role:%s", cacheKeyData, userID, userRole)
|
||||
}
|
||||
|
||||
return strutil.Md5(cacheKeyData)
|
||||
}
|
||||
|
||||
func EnableCache(cfg *CacheConfig) {
|
||||
@@ -88,7 +120,7 @@ func EnableCache(cfg *CacheConfig) {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to create Redis client",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
// Fall back to memory cache
|
||||
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
|
||||
@@ -96,34 +128,41 @@ func EnableCache(cfg *CacheConfig) {
|
||||
cfg.Client = libpack_cache_redis.NewCacheWrapper(redisClient, cfg.Logger)
|
||||
}
|
||||
} else {
|
||||
// Calculate memory and entry limits
|
||||
maxMemory := cfg.Memory.MaxMemorySize
|
||||
if maxMemory <= 0 {
|
||||
maxMemory = libpack_cache_memory.DefaultMaxMemorySize
|
||||
}
|
||||
|
||||
maxEntries := cfg.Memory.MaxEntries
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = libpack_cache_memory.DefaultMaxCacheSize
|
||||
}
|
||||
|
||||
cacheType := "standard"
|
||||
if cfg.Memory.UseLRU {
|
||||
cacheType = "LRU"
|
||||
}
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Using in-memory cache",
|
||||
Pairs: map[string]interface{}{
|
||||
"max_memory_size_bytes": cfg.Memory.MaxMemorySize,
|
||||
"max_entries": cfg.Memory.MaxEntries,
|
||||
Pairs: map[string]any{
|
||||
"type": cacheType,
|
||||
"max_memory_size_bytes": maxMemory,
|
||||
"max_entries": maxEntries,
|
||||
},
|
||||
})
|
||||
|
||||
// Use memory size and entry limits if configured, otherwise use defaults
|
||||
if cfg.Memory.MaxMemorySize > 0 || cfg.Memory.MaxEntries > 0 {
|
||||
maxMemory := cfg.Memory.MaxMemorySize
|
||||
if maxMemory <= 0 {
|
||||
maxMemory = libpack_cache_memory.DefaultMaxMemorySize
|
||||
}
|
||||
|
||||
maxEntries := cfg.Memory.MaxEntries
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = libpack_cache_memory.DefaultMaxCacheSize
|
||||
}
|
||||
|
||||
if cfg.Memory.UseLRU {
|
||||
// Use LRU cache with proper eviction algorithm
|
||||
cfg.Client = libpack_cache_memory.NewLRUMemoryCache(maxMemory, maxEntries)
|
||||
} else {
|
||||
// Use standard sync.Map-based cache
|
||||
cfg.Client = libpack_cache_memory.NewWithSize(
|
||||
time.Duration(cfg.TTL)*time.Second,
|
||||
maxMemory,
|
||||
maxEntries,
|
||||
)
|
||||
} else {
|
||||
// Backward compatibility
|
||||
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
|
||||
}
|
||||
}
|
||||
config = cfg
|
||||
@@ -143,7 +182,7 @@ func CacheLookup(hash string) []byte {
|
||||
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},
|
||||
Pairs: map[string]any{"error": err.Error(), "hash": hash},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -152,7 +191,7 @@ func CacheLookup(hash string) []byte {
|
||||
if closeErr := reader.Close(); closeErr != nil {
|
||||
config.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to close gzip reader",
|
||||
Pairs: map[string]interface{}{"error": closeErr.Error(), "hash": hash},
|
||||
Pairs: map[string]any{"error": closeErr.Error(), "hash": hash},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -161,7 +200,7 @@ func CacheLookup(hash string) []byte {
|
||||
if err != nil {
|
||||
config.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to decompress cached data",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "hash": hash},
|
||||
Pairs: map[string]any{"error": err.Error(), "hash": hash},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -179,7 +218,7 @@ func CacheDelete(hash string) {
|
||||
}
|
||||
config.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Deleting data from cache",
|
||||
Pairs: map[string]interface{}{"hash": hash},
|
||||
Pairs: map[string]any{"hash": hash},
|
||||
})
|
||||
// Use atomic operations with validation to prevent inconsistent statistics
|
||||
for {
|
||||
@@ -204,7 +243,7 @@ func CacheStore(hash string, data []byte) {
|
||||
}
|
||||
config.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Storing data in cache",
|
||||
Pairs: map[string]interface{}{"hash": hash},
|
||||
Pairs: map[string]any{"hash": hash},
|
||||
})
|
||||
atomic.AddInt64(&cacheStats.CachedQueries, 1)
|
||||
config.Client.Set(hash, data, time.Duration(config.TTL)*time.Second)
|
||||
@@ -216,7 +255,7 @@ func CacheStoreWithTTL(hash string, data []byte, ttl time.Duration) {
|
||||
}
|
||||
config.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Storing data in cache with TTL",
|
||||
Pairs: map[string]interface{}{"hash": hash, "ttl": ttl},
|
||||
Pairs: map[string]any{"hash": hash, "ttl": ttl},
|
||||
})
|
||||
atomic.AddInt64(&cacheStats.CachedQueries, 1)
|
||||
config.Client.Set(hash, data, ttl)
|
||||
@@ -233,6 +272,9 @@ func CacheGetQueries() int64 {
|
||||
}
|
||||
|
||||
func CacheClear() {
|
||||
if !IsCacheInitialized() {
|
||||
return
|
||||
}
|
||||
config.Client.Clear()
|
||||
cacheStats = &CacheStats{}
|
||||
}
|
||||
|
||||
Vendored
+78
-16
@@ -20,7 +20,7 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
// Test with empty body
|
||||
suite.Run("empty body", func() {
|
||||
ctx.Request().SetBody([]byte(""))
|
||||
hash := CalculateHash(ctx)
|
||||
hash := CalculateHash(ctx, "user1", "admin")
|
||||
assert.NotEmpty(hash)
|
||||
assert.Equal(32, len(hash)) // MD5 hash is 32 characters
|
||||
})
|
||||
@@ -28,7 +28,7 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
// Test with non-empty body
|
||||
suite.Run("non-empty body", func() {
|
||||
ctx.Request().SetBody([]byte("test body"))
|
||||
hash := CalculateHash(ctx)
|
||||
hash := CalculateHash(ctx, "user1", "admin")
|
||||
assert.NotEmpty(hash)
|
||||
assert.Equal(32, len(hash))
|
||||
})
|
||||
@@ -36,10 +36,10 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
// Test with different bodies produce different hashes
|
||||
suite.Run("different bodies", func() {
|
||||
ctx.Request().SetBody([]byte("body1"))
|
||||
hash1 := CalculateHash(ctx)
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
ctx.Request().SetBody([]byte("body2"))
|
||||
hash2 := CalculateHash(ctx)
|
||||
hash2 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
assert.NotEqual(hash1, hash2)
|
||||
})
|
||||
@@ -51,10 +51,10 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
query2 := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { name } }","variables":{"id":"456"}}`)
|
||||
|
||||
ctx.Request().SetBody(query1)
|
||||
hash1 := CalculateHash(ctx)
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
ctx.Request().SetBody(query2)
|
||||
hash2 := CalculateHash(ctx)
|
||||
hash2 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Different variables should produce different cache keys")
|
||||
})
|
||||
@@ -66,13 +66,83 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
query2 := []byte(`{"query":"query GetUsers { users { name } }","variables":{}}`)
|
||||
|
||||
ctx.Request().SetBody(query1)
|
||||
hash1 := CalculateHash(ctx)
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
ctx.Request().SetBody(query2)
|
||||
hash2 := CalculateHash(ctx)
|
||||
hash2 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Query with and without variables object should produce different cache keys")
|
||||
})
|
||||
|
||||
// SECURITY TEST: Different users should get different cache keys
|
||||
suite.Run("different users produce different cache keys", func() {
|
||||
// Same query, same variables, but different users - CRITICAL SECURITY TEST
|
||||
query := []byte(`{"query":"query GetMyProfile { me { id email } }"}`)
|
||||
ctx.Request().SetBody(query)
|
||||
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
hash2 := CalculateHash(ctx, "user2", "user")
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Different users MUST produce different cache keys to prevent data leakage")
|
||||
})
|
||||
|
||||
// SECURITY TEST: Same user should get same cache key
|
||||
suite.Run("same user produces same cache key", func() {
|
||||
// Same query, same user
|
||||
query := []byte(`{"query":"query GetMyProfile { me { id email } }"}`)
|
||||
ctx.Request().SetBody(query)
|
||||
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
hash2 := CalculateHash(ctx, "user1", "admin")
|
||||
|
||||
assert.Equal(hash1, hash2, "Same user should get same cache key for cache effectiveness")
|
||||
})
|
||||
|
||||
// SECURITY TEST: Different roles should get different cache keys
|
||||
suite.Run("different roles produce different cache keys", func() {
|
||||
// Same query, same user ID, but different roles
|
||||
query := []byte(`{"query":"query GetData { data { value } }"}`)
|
||||
ctx.Request().SetBody(query)
|
||||
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
hash2 := CalculateHash(ctx, "user1", "user")
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Different roles MUST produce different cache keys to prevent privilege escalation")
|
||||
})
|
||||
|
||||
// SECURITY TEST: Empty user context should be normalized
|
||||
suite.Run("empty user context is normalized", func() {
|
||||
query := []byte(`{"query":"query GetPublic { public { data } }"}`)
|
||||
ctx.Request().SetBody(query)
|
||||
|
||||
// Empty strings should be normalized to "-"
|
||||
hash1 := CalculateHash(ctx, "", "")
|
||||
hash2 := CalculateHash(ctx, "-", "-")
|
||||
|
||||
assert.Equal(hash1, hash2, "Empty user context should be normalized to prevent cache key collisions")
|
||||
})
|
||||
|
||||
// BACKWARD COMPATIBILITY TEST: Legacy mode without user context
|
||||
suite.Run("legacy mode without user context", func() {
|
||||
// Setup config with per-user cache disabled
|
||||
oldConfig := config
|
||||
config = &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
Client: libpack_cache_memory.New(5 * time.Minute),
|
||||
TTL: 60,
|
||||
PerUserCacheDisabled: true, // Disable per-user caching
|
||||
}
|
||||
defer func() { config = oldConfig }()
|
||||
|
||||
query := []byte(`{"query":"query GetData { data { value } }"}`)
|
||||
ctx.Request().SetBody(query)
|
||||
|
||||
// In legacy mode, different users should get the SAME cache key (backward compatibility)
|
||||
hash1 := CalculateHash(ctx, "user1", "admin")
|
||||
hash2 := CalculateHash(ctx, "user2", "user")
|
||||
|
||||
assert.Equal(hash1, hash2, "With per-user cache disabled, all users get same cache key (backward compatibility)")
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *Tests) Test_CacheDelete() {
|
||||
@@ -112,8 +182,6 @@ func (suite *Tests) Test_CacheDelete() {
|
||||
suite.Run("uninitialized cache", func() {
|
||||
// Save current config
|
||||
oldConfig := config
|
||||
|
||||
// Set config to nil
|
||||
config = nil
|
||||
|
||||
// This should not cause any errors
|
||||
@@ -156,8 +224,6 @@ func (suite *Tests) Test_CacheStoreWithTTL() {
|
||||
suite.Run("uninitialized cache", func() {
|
||||
// Save current config
|
||||
oldConfig := config
|
||||
|
||||
// Set config to nil
|
||||
config = nil
|
||||
|
||||
// This should not cause any errors
|
||||
@@ -194,8 +260,6 @@ func (suite *Tests) Test_CacheGetQueries() {
|
||||
suite.Run("uninitialized cache", func() {
|
||||
// Save current config
|
||||
oldConfig := config
|
||||
|
||||
// Set config to nil
|
||||
config = nil
|
||||
|
||||
// This should return 0
|
||||
@@ -280,8 +344,6 @@ func (suite *Tests) Test_GetCacheStats() {
|
||||
suite.Run("uninitialized cache", func() {
|
||||
// Save current config
|
||||
oldConfig := config
|
||||
|
||||
// Set config to nil
|
||||
config = nil
|
||||
|
||||
// This should return empty stats
|
||||
|
||||
Vendored
+9
-4
@@ -38,12 +38,12 @@ func NewLRUMemoryCache(maxMemorySize, maxEntries int64) *LRUMemoryCache {
|
||||
entries: make(map[string]*lruEntry),
|
||||
evictList: list.New(),
|
||||
gzipWriterPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
},
|
||||
gzipReaderPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return &gzip.Reader{}
|
||||
},
|
||||
},
|
||||
@@ -257,11 +257,11 @@ func (c *LRUMemoryCache) decompress(data []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
// GetStats returns cache statistics
|
||||
func (c *LRUMemoryCache) GetStats() map[string]interface{} {
|
||||
func (c *LRUMemoryCache) GetStats() map[string]any {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"entries": atomic.LoadInt64(&c.currentCount),
|
||||
"memory_bytes": atomic.LoadInt64(&c.currentMemory),
|
||||
"max_entries": c.maxEntries,
|
||||
@@ -279,3 +279,8 @@ func (c *LRUMemoryCache) GetMemoryUsage() int64 {
|
||||
func (c *LRUMemoryCache) GetMaxMemorySize() int64 {
|
||||
return c.maxMemorySize
|
||||
}
|
||||
|
||||
// CountQueries returns the number of entries in the cache
|
||||
func (c *LRUMemoryCache) CountQueries() int64 {
|
||||
return atomic.LoadInt64(&c.currentCount)
|
||||
}
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type LRUMemoryCacheTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestLRUMemoryCacheTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(LRUMemoryCacheTestSuite))
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestNewLRUMemoryCache() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100) // 1MB, 100 entries
|
||||
suite.NotNil(cache)
|
||||
suite.Equal(int64(0), cache.CountQueries())
|
||||
suite.Equal(int64(0), cache.GetMemoryUsage())
|
||||
suite.Equal(int64(1024*1024), cache.GetMaxMemorySize())
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestSetAndGet() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
// Set a value
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
|
||||
// Get the value
|
||||
val, found := cache.Get("key1")
|
||||
suite.True(found)
|
||||
suite.Equal([]byte("value1"), val)
|
||||
|
||||
// Get non-existent key
|
||||
val, found = cache.Get("nonexistent")
|
||||
suite.False(found)
|
||||
suite.Nil(val)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestUpdateExisting() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key1", []byte("value2"), 5*time.Second)
|
||||
|
||||
val, found := cache.Get("key1")
|
||||
suite.True(found)
|
||||
suite.Equal([]byte("value2"), val)
|
||||
suite.Equal(int64(1), cache.CountQueries())
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestDelete() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
suite.Equal(int64(1), cache.CountQueries())
|
||||
|
||||
cache.Delete("key1")
|
||||
suite.Equal(int64(0), cache.CountQueries())
|
||||
|
||||
val, found := cache.Get("key1")
|
||||
suite.False(found)
|
||||
suite.Nil(val)
|
||||
|
||||
// Delete non-existent key should not panic
|
||||
cache.Delete("nonexistent")
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestClear() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
cache.Set("key3", []byte("value3"), 5*time.Second)
|
||||
suite.Equal(int64(3), cache.CountQueries())
|
||||
|
||||
cache.Clear()
|
||||
suite.Equal(int64(0), cache.CountQueries())
|
||||
suite.Equal(int64(0), cache.GetMemoryUsage())
|
||||
|
||||
_, found := cache.Get("key1")
|
||||
suite.False(found)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestExpiration() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("key1", []byte("value1"), 100*time.Millisecond)
|
||||
|
||||
// Should exist immediately
|
||||
val, found := cache.Get("key1")
|
||||
suite.True(found)
|
||||
suite.Equal([]byte("value1"), val)
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Should be expired
|
||||
val, found = cache.Get("key1")
|
||||
suite.False(found)
|
||||
suite.Nil(val)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestEvictionByCount() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
cache.Set("key3", []byte("value3"), 5*time.Second)
|
||||
|
||||
// All 3 should exist
|
||||
_, found := cache.Get("key1")
|
||||
suite.True(found)
|
||||
_, found = cache.Get("key2")
|
||||
suite.True(found)
|
||||
_, found = cache.Get("key3")
|
||||
suite.True(found)
|
||||
|
||||
// Add 4th entry - should evict oldest (key1)
|
||||
cache.Set("key4", []byte("value4"), 5*time.Second)
|
||||
|
||||
suite.Equal(int64(3), cache.CountQueries())
|
||||
|
||||
// key1 should be evicted (it was least recently used)
|
||||
_, found = cache.Get("key1")
|
||||
suite.False(found)
|
||||
|
||||
// Others should still exist
|
||||
_, found = cache.Get("key2")
|
||||
suite.True(found)
|
||||
_, found = cache.Get("key3")
|
||||
suite.True(found)
|
||||
_, found = cache.Get("key4")
|
||||
suite.True(found)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestLRUOrder() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
cache.Set("key3", []byte("value3"), 5*time.Second)
|
||||
|
||||
// Access key1 to make it recently used
|
||||
cache.Get("key1")
|
||||
|
||||
// Add key4 - should evict key2 (now least recently used)
|
||||
cache.Set("key4", []byte("value4"), 5*time.Second)
|
||||
|
||||
// key2 should be evicted
|
||||
_, found := cache.Get("key2")
|
||||
suite.False(found)
|
||||
|
||||
// key1 should still exist (was accessed recently)
|
||||
_, found = cache.Get("key1")
|
||||
suite.True(found)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestEvictionByMemory() {
|
||||
// Small memory limit - 500 bytes
|
||||
cache := NewLRUMemoryCache(500, 100)
|
||||
|
||||
// Each entry has ~64 bytes overhead + key + value
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
cache.Set("key3", []byte("value3"), 5*time.Second)
|
||||
|
||||
// Add large entry that should trigger eviction
|
||||
largeValue := make([]byte, 200)
|
||||
cache.Set("large", largeValue, 5*time.Second)
|
||||
|
||||
// Memory should be under limit
|
||||
suite.LessOrEqual(cache.GetMemoryUsage(), int64(500))
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestCompression() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
// Create a compressible value (> 1KB to trigger compression)
|
||||
largeValue := make([]byte, 2048)
|
||||
for i := range largeValue {
|
||||
largeValue[i] = 'A' // Highly compressible
|
||||
}
|
||||
|
||||
cache.Set("compressed", largeValue, 5*time.Second)
|
||||
|
||||
// Should be able to retrieve it correctly
|
||||
val, found := cache.Get("compressed")
|
||||
suite.True(found)
|
||||
suite.Equal(largeValue, val)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestGetStats() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
|
||||
stats := cache.GetStats()
|
||||
suite.Equal(int64(2), stats["entries"])
|
||||
suite.Equal(int64(1024*1024), stats["max_memory"])
|
||||
suite.Equal(int64(100), stats["max_entries"])
|
||||
suite.NotNil(stats["memory_bytes"])
|
||||
suite.NotNil(stats["fill_percent"])
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestConcurrentAccess() {
|
||||
cache := NewLRUMemoryCache(10*1024*1024, 1000)
|
||||
const numGoroutines = 50
|
||||
const numOperations = 500
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines * 3) // readers, writers, deleters
|
||||
|
||||
// Writers
|
||||
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))
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers
|
||||
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)
|
||||
cache.Get(key)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Deleters
|
||||
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%100)
|
||||
cache.Delete(key)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestCleanExpiredEntries() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
cache.Set("expire1", []byte("value1"), 50*time.Millisecond)
|
||||
cache.Set("expire2", []byte("value2"), 50*time.Millisecond)
|
||||
cache.Set("keep", []byte("value3"), 5*time.Second)
|
||||
|
||||
suite.Equal(int64(3), cache.CountQueries())
|
||||
|
||||
// Wait for some to expire
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Clean expired entries
|
||||
cache.CleanExpiredEntries()
|
||||
|
||||
// Only "keep" should remain
|
||||
suite.Equal(int64(1), cache.CountQueries())
|
||||
|
||||
_, found := cache.Get("keep")
|
||||
suite.True(found)
|
||||
}
|
||||
|
||||
func (suite *LRUMemoryCacheTestSuite) TestCountQueries() {
|
||||
cache := NewLRUMemoryCache(1024*1024, 100)
|
||||
|
||||
suite.Equal(int64(0), cache.CountQueries())
|
||||
|
||||
cache.Set("key1", []byte("value1"), 5*time.Second)
|
||||
suite.Equal(int64(1), cache.CountQueries())
|
||||
|
||||
cache.Set("key2", []byte("value2"), 5*time.Second)
|
||||
suite.Equal(int64(2), cache.CountQueries())
|
||||
|
||||
cache.Delete("key1")
|
||||
suite.Equal(int64(1), cache.CountQueries())
|
||||
|
||||
cache.Clear()
|
||||
suite.Equal(int64(0), cache.CountQueries())
|
||||
}
|
||||
|
||||
// Benchmarks
|
||||
|
||||
func BenchmarkLRUMemoryCacheSet(b *testing.B) {
|
||||
cache := NewLRUMemoryCache(100*1024*1024, 100000)
|
||||
value := []byte("benchmark-value")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUMemoryCacheGet(b *testing.B) {
|
||||
cache := NewLRUMemoryCache(100*1024*1024, 100000)
|
||||
value := []byte("benchmark-value")
|
||||
|
||||
// Pre-populate
|
||||
for i := 0; i < 10000; i++ {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
cache.Set(key, value, 5*time.Minute)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
key := fmt.Sprintf("key-%d", i%10000)
|
||||
cache.Get(key)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUMemoryCacheConcurrent(b *testing.B) {
|
||||
cache := NewLRUMemoryCache(100*1024*1024, 100000)
|
||||
value := []byte("benchmark-value")
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
if i%2 == 0 {
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
} else {
|
||||
cache.Get(key)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+9
-6
@@ -1,3 +1,6 @@
|
||||
// Package libpack_cache_memory provides an in-memory LRU cache implementation
|
||||
// with automatic compression for large values, memory limits, and background
|
||||
// eviction of expired entries.
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
@@ -61,12 +64,12 @@ func NewWithSize(globalTTL time.Duration, maxMemorySize int64, maxCacheSize int6
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
compressPool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
},
|
||||
decompressPool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
r, _ := gzip.NewReader(bytes.NewReader([]byte{}))
|
||||
return r
|
||||
},
|
||||
@@ -204,7 +207,7 @@ func (c *Cache) Delete(key string) {
|
||||
}
|
||||
|
||||
func (c *Cache) Clear() {
|
||||
c.entries.Range(func(key, value interface{}) bool {
|
||||
c.entries.Range(func(key, value any) bool {
|
||||
c.entries.Delete(key)
|
||||
return true
|
||||
})
|
||||
@@ -255,7 +258,7 @@ func (c *Cache) decompress(data []byte) ([]byte, error) {
|
||||
|
||||
func (c *Cache) CleanExpiredEntries() {
|
||||
now := time.Now()
|
||||
c.entries.Range(func(key, value interface{}) bool {
|
||||
c.entries.Range(func(key, value any) bool {
|
||||
entry := value.(CacheEntry)
|
||||
if entry.ExpiresAt.Before(now) {
|
||||
if _, exists := c.entries.LoadAndDelete(key); exists {
|
||||
@@ -276,7 +279,7 @@ func (c *Cache) evictOldest(n int) {
|
||||
|
||||
// Collect all entries with their expiry times
|
||||
entries := make([]keyExpiry, 0, n*2)
|
||||
c.entries.Range(func(k, v interface{}) bool {
|
||||
c.entries.Range(func(k, v any) bool {
|
||||
key := k.(string)
|
||||
entry := v.(CacheEntry)
|
||||
entries = append(entries, keyExpiry{entry.ExpiresAt, key})
|
||||
@@ -316,7 +319,7 @@ func (c *Cache) evictToFreeMemory(bytesToFree int64) {
|
||||
|
||||
// Collect entries to consider for eviction
|
||||
entries := make([]keyMemorySize, 0, int(c.maxCacheSize/5))
|
||||
c.entries.Range(func(k, v interface{}) bool {
|
||||
c.entries.Range(func(k, v any) bool {
|
||||
key := k.(string)
|
||||
entry := v.(CacheEntry)
|
||||
entries = append(entries, keyMemorySize{entry.ExpiresAt, key, entry.MemorySize})
|
||||
|
||||
Vendored
+4
-1
@@ -1,3 +1,6 @@
|
||||
// Package libpack_cache_redis provides a Redis-backed cache implementation
|
||||
// for distributed caching across multiple proxy instances. Supports key
|
||||
// prefixing for multi-tenant isolation.
|
||||
package libpack_cache_redis
|
||||
|
||||
import (
|
||||
@@ -42,7 +45,7 @@ func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
||||
ctx: context.Background(),
|
||||
prefix: redisClientConfig.Prefix,
|
||||
builderPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return &strings.Builder{}
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+5
-5
@@ -29,7 +29,7 @@ func (w *CacheWrapper) Set(key string, value []byte, ttl time.Duration) {
|
||||
if err := w.redis.Set(key, value, ttl); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis set error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
@@ -43,7 +43,7 @@ func (w *CacheWrapper) Get(key string) ([]byte, bool) {
|
||||
if err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis get error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
@@ -58,7 +58,7 @@ func (w *CacheWrapper) Delete(key string) {
|
||||
if err := w.redis.Delete(key); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis delete error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
@@ -71,7 +71,7 @@ func (w *CacheWrapper) Clear() {
|
||||
if err := w.redis.Clear(); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis clear error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
@@ -84,7 +84,7 @@ func (w *CacheWrapper) CountQueries() int64 {
|
||||
if err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis count queries error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,8 +33,9 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerCacheFallback() {
|
||||
ctx := app.AcquireCtx(requestCtx)
|
||||
defer app.ReleaseCtx(ctx)
|
||||
|
||||
// Calculate the cache key that would be used
|
||||
cacheKey := libpack_cache.CalculateHash(ctx)
|
||||
// Calculate the cache key that would be used (with default user context since no auth headers)
|
||||
// extractUserInfo() returns ("-", "-") when no auth is present
|
||||
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
||||
|
||||
// Add a test response to the cache
|
||||
cachedResponse := []byte(`{"data":{"test":"cached-response"}}`)
|
||||
@@ -43,7 +44,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerCacheFallback() {
|
||||
// Trip the circuit by generating failures
|
||||
testErr := errors.New("test error")
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
assert.Error(suite.T(), err, "Execute should return error")
|
||||
@@ -107,7 +108,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerNoCacheFallback() {
|
||||
// Trip the circuit by generating failures
|
||||
testErr := errors.New("test error")
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
assert.Error(suite.T(), err, "Execute should return error")
|
||||
@@ -158,15 +159,16 @@ func (suite *CircuitBreakerTestSuite) TestCacheDisabledFallback() {
|
||||
ctx := app.AcquireCtx(requestCtx)
|
||||
defer app.ReleaseCtx(ctx)
|
||||
|
||||
// Calculate cache key and store a response
|
||||
cacheKey := libpack_cache.CalculateHash(ctx)
|
||||
// Calculate cache key and store a response (with default user context since no auth headers)
|
||||
// extractUserInfo() returns ("-", "-") when no auth is present
|
||||
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
||||
cachedResponse := []byte(`{"data":{"test":"cached-response"}}`)
|
||||
libpack_cache.CacheStore(cacheKey, cachedResponse)
|
||||
|
||||
// Trip the circuit by generating failures
|
||||
testErr := errors.New("test error")
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
assert.Error(suite.T(), err, "Execute should return error")
|
||||
|
||||
@@ -23,18 +23,14 @@ func NewCircuitBreakerMetrics(monitoring *libpack_monitoring.MetricsSetup) *Circ
|
||||
// Initialize state value
|
||||
cbm.stateValue.Store(float64(0))
|
||||
|
||||
// Create gauge with callback that reads the atomic value
|
||||
cbm.stateGauge = monitoring.RegisterMetricsGauge(
|
||||
// Create gauge with callback that reads the atomic value on every scrape
|
||||
// This ensures the metric always reflects the current circuit breaker state
|
||||
cbm.stateGauge = monitoring.RegisterMetricsGaugeFunc(
|
||||
libpack_monitoring.MetricsCircuitState,
|
||||
nil,
|
||||
0, // Initial value doesn't matter as callback will be used
|
||||
)
|
||||
|
||||
// Override the gauge callback to read from atomic value
|
||||
cbm.stateGauge = monitoring.RegisterMetricsGauge(
|
||||
libpack_monitoring.MetricsCircuitState,
|
||||
nil,
|
||||
cbm.GetState(),
|
||||
func() float64 {
|
||||
return cbm.GetState()
|
||||
},
|
||||
)
|
||||
|
||||
return cbm
|
||||
|
||||
@@ -25,7 +25,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerStateTransitions() {
|
||||
// 2. Generate failures to trip the circuit
|
||||
testErr := errors.New("test error")
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
assert.Error(suite.T(), err, "Execute should return error")
|
||||
@@ -35,7 +35,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerStateTransitions() {
|
||||
assert.Equal(suite.T(), gobreaker.StateOpen.String(), cb.State().String(), "Circuit should transition to open state after failures")
|
||||
|
||||
// Verify that requests are rejected during open state
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return "success", nil
|
||||
})
|
||||
assert.Equal(suite.T(), gobreaker.ErrOpenState.Error(), err.Error(), "Should return ErrOpenState when circuit is open")
|
||||
@@ -55,7 +55,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerStateTransitions() {
|
||||
// (Sony's gobreaker transitions to half-open on the next request after timeout)
|
||||
tmpState := cb.State()
|
||||
// Execute a successful request to check state
|
||||
_, _ = cb.Execute(func() (interface{}, error) {
|
||||
_, _ = cb.Execute(func() (any, error) {
|
||||
return "success", nil
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerStateTransitions() {
|
||||
|
||||
// 6. Execute successful requests in half-open state to transition back to closed
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxRequestsInHalfOpen; i++ {
|
||||
_, err = cb.Execute(func() (interface{}, error) {
|
||||
_, err = cb.Execute(func() (any, error) {
|
||||
return "success", nil
|
||||
})
|
||||
assert.NoError(suite.T(), err, "Execute should not return error")
|
||||
@@ -104,7 +104,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerHalfOpenToOpen() {
|
||||
// 1. Generate failures to trip the circuit
|
||||
testErr := errors.New("test error")
|
||||
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
assert.Error(suite.T(), err, "Execute should return error")
|
||||
@@ -119,7 +119,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerHalfOpenToOpen() {
|
||||
// The next request should transition the circuit to half-open
|
||||
tmpState := cb.State()
|
||||
// Try a request that will fail
|
||||
_, _ = cb.Execute(func() (interface{}, error) {
|
||||
_, _ = cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ func (suite *CircuitBreakerTestSuite) TestExecuteFunctionBehavior() {
|
||||
|
||||
// Test with success
|
||||
result := "success"
|
||||
execResult, err := cb.Execute(func() (interface{}, error) {
|
||||
execResult, err := cb.Execute(func() (any, error) {
|
||||
return result, nil
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ func (suite *CircuitBreakerTestSuite) TestExecuteFunctionBehavior() {
|
||||
|
||||
// Test with error
|
||||
testErr := errors.New("test error")
|
||||
_, err = cb.Execute(func() (interface{}, error) {
|
||||
_, err = cb.Execute(func() (any, error) {
|
||||
return nil, testErr
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package libpack_config provides build-time configuration variables
|
||||
// for package name and version, which are set during the build process
|
||||
// using ldflags.
|
||||
package libpack_config
|
||||
|
||||
var (
|
||||
|
||||
+7
-7
@@ -118,7 +118,7 @@ func (cpm *ConnectionPoolManager) cleanIdleConnections() {
|
||||
if cpm.logger != nil {
|
||||
cpm.logger.Debug(&libpack_logging.LogMessage{
|
||||
Message: "Cleaned idle HTTP connections",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"active_connections": cpm.activeConnections.Load(),
|
||||
"total_connections": cpm.totalConnections.Load(),
|
||||
},
|
||||
@@ -172,7 +172,7 @@ func (cpm *ConnectionPoolManager) performKeepAlive() {
|
||||
if cpm.logger != nil {
|
||||
cpm.logger.Debug(&libpack_logging.LogMessage{
|
||||
Message: "Keep-alive request failed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
@@ -202,7 +202,7 @@ func (cpm *ConnectionPoolManager) checkAndRecover() {
|
||||
if cpm.logger != nil {
|
||||
cpm.logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "Connection pool health degraded, attempting recovery",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"consecutive_failures": failures,
|
||||
},
|
||||
})
|
||||
@@ -246,8 +246,8 @@ func (cpm *ConnectionPoolManager) RecordConnectionFailure() {
|
||||
}
|
||||
|
||||
// GetConnectionStats returns current connection statistics
|
||||
func (cpm *ConnectionPoolManager) GetConnectionStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (cpm *ConnectionPoolManager) GetConnectionStats() map[string]any {
|
||||
return map[string]any{
|
||||
"active_connections": cpm.activeConnections.Load(),
|
||||
"total_connections": cpm.totalConnections.Load(),
|
||||
"connection_failures": cpm.connectionFailures.Load(),
|
||||
@@ -296,7 +296,7 @@ func InitializeConnectionPool(client *fasthttp.Client) {
|
||||
connectionPoolMutex.Lock()
|
||||
defer connectionPoolMutex.Unlock()
|
||||
if connectionPoolManager != nil {
|
||||
connectionPoolManager.Shutdown()
|
||||
_ = connectionPoolManager.Shutdown() // Best-effort cleanup
|
||||
}
|
||||
connectionPoolManager = NewConnectionPoolManager(client)
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func ShutdownConnectionPool() {
|
||||
connectionPoolMutex.Lock()
|
||||
defer connectionPoolMutex.Unlock()
|
||||
if connectionPoolManager != nil {
|
||||
connectionPoolManager.Shutdown()
|
||||
_ = connectionPoolManager.Shutdown() // Best-effort cleanup
|
||||
connectionPoolManager = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/graphql-go/graphql/language/ast"
|
||||
"github.com/graphql-go/graphql/language/parser"
|
||||
"github.com/graphql-go/graphql/language/source"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
// debugParseGraphQLQuery provides detailed logging for mutation routing analysis
|
||||
// This is automatically called when LOG_LEVEL=DEBUG to help identify routing issues
|
||||
//
|
||||
// It logs:
|
||||
// - GraphQL query structure (operations, selections, directives)
|
||||
// - Final routing decision (which endpoint was chosen)
|
||||
// - Automatic detection of mutations routed to wrong endpoints
|
||||
//
|
||||
// To enable: Set LOG_LEVEL=DEBUG and restart the proxy
|
||||
func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
if cfg == nil || cfg.Logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "=== DEBUG: Parsing GraphQL Query ===",
|
||||
Pairs: map[string]any{
|
||||
"query_length": len(query),
|
||||
"query_preview": truncateString(query, 100),
|
||||
},
|
||||
})
|
||||
|
||||
// Parse the query
|
||||
src := source.NewSource(&source.Source{
|
||||
Body: []byte(query),
|
||||
Name: "Debug GraphQL request",
|
||||
})
|
||||
|
||||
p, err := parser.Parse(parser.ParseParams{Source: src})
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Failed to parse query",
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Query parsed successfully",
|
||||
Pairs: map[string]any{
|
||||
"definitions_count": len(p.Definitions),
|
||||
},
|
||||
})
|
||||
|
||||
// Analyze each definition
|
||||
for i, d := range p.Definitions {
|
||||
if oper, ok := d.(*ast.OperationDefinition); ok {
|
||||
operationType := strings.ToLower(oper.Operation)
|
||||
operationName := "unnamed"
|
||||
if oper.Name != nil {
|
||||
operationName = oper.Name.Value
|
||||
}
|
||||
|
||||
// Count selections
|
||||
selectionCount := 0
|
||||
if oper.SelectionSet != nil {
|
||||
selectionCount = len(oper.GetSelectionSet().Selections)
|
||||
}
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: fmt.Sprintf("DEBUG: Definition #%d (OperationDefinition)", i),
|
||||
Pairs: map[string]any{
|
||||
"operation_type": operationType,
|
||||
"operation_name": operationName,
|
||||
"selection_count": selectionCount,
|
||||
"is_mutation": operationType == "mutation",
|
||||
"directive_count": len(oper.Directives),
|
||||
},
|
||||
})
|
||||
|
||||
// Log selections for mutations
|
||||
if operationType == "mutation" && oper.SelectionSet != nil {
|
||||
for j, sel := range oper.GetSelectionSet().Selections {
|
||||
if field, ok := sel.(*ast.Field); ok {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: fmt.Sprintf("DEBUG: Mutation field #%d", j),
|
||||
Pairs: map[string]any{
|
||||
"field_name": field.Name.Value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if frag, ok := d.(*ast.FragmentDefinition); ok {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: fmt.Sprintf("DEBUG: Definition #%d (FragmentDefinition)", i),
|
||||
Pairs: map[string]any{
|
||||
"fragment_name": frag.Name.Value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Now run the actual parsing to see the result
|
||||
result := parseGraphQLQuery(c)
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Final routing decision",
|
||||
Pairs: map[string]any{
|
||||
"operation_type": result.operationType,
|
||||
"operation_name": result.operationName,
|
||||
"active_endpoint": result.activeEndpoint,
|
||||
"should_block": result.shouldBlock,
|
||||
"should_ignore": result.shouldIgnore,
|
||||
"write_endpoint": cfg.Server.HostGraphQL,
|
||||
"read_endpoint": cfg.Server.HostGraphQLReadOnly,
|
||||
"is_using_write": result.activeEndpoint == cfg.Server.HostGraphQL,
|
||||
},
|
||||
})
|
||||
|
||||
// Check for potential issues
|
||||
if result.operationType == "mutation" && result.activeEndpoint != cfg.Server.HostGraphQL {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: ⚠️ BUG DETECTED: Mutation routed to wrong endpoint!",
|
||||
Pairs: map[string]any{
|
||||
"expected_endpoint": cfg.Server.HostGraphQL,
|
||||
"actual_endpoint": result.activeEndpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if result.operationType == "mutation" && strings.Contains(strings.ToLower(result.activeEndpoint), "read") {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: ⚠️ CRITICAL: Mutation endpoint contains 'read' in URL!",
|
||||
Pairs: map[string]any{
|
||||
"endpoint": result.activeEndpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -20,19 +20,19 @@ func extractClaimsFromJWTHeader(authorization string) (usr, role string) {
|
||||
|
||||
tokenParts := strings.SplitN(authorization, ".", 3)
|
||||
if len(tokenParts) != 3 {
|
||||
handleError("Can't split the token", map[string]interface{}{"token": maskToken(authorization)})
|
||||
handleError("Can't split the token", map[string]any{"token": maskToken(authorization)})
|
||||
return
|
||||
}
|
||||
|
||||
claim, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
|
||||
if err != nil {
|
||||
handleError("Can't decode the token", map[string]interface{}{"token": maskToken(authorization)})
|
||||
handleError("Can't decode the token", map[string]any{"token": maskToken(authorization)})
|
||||
return
|
||||
}
|
||||
|
||||
var claimMap map[string]interface{}
|
||||
var claimMap map[string]any
|
||||
if err = json.Unmarshal(claim, &claimMap); err != nil {
|
||||
handleError("Can't unmarshal the claim", map[string]interface{}{"token": maskToken(authorization)})
|
||||
handleError("Can't unmarshal the claim", map[string]any{"token": maskToken(authorization)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,20 +42,20 @@ func extractClaimsFromJWTHeader(authorization string) (usr, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
func extractClaim(claimMap map[string]interface{}, claimPath, name string) string {
|
||||
func extractClaim(claimMap map[string]any, claimPath, name string) string {
|
||||
if claimPath == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Validate claim path to prevent injection attacks
|
||||
if !isValidClaimPath(claimPath) {
|
||||
handleError(fmt.Sprintf("Invalid claim path for %s", name), map[string]interface{}{"path": claimPath})
|
||||
handleError(fmt.Sprintf("Invalid claim path for %s", name), map[string]any{"path": 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": sanitizeClaimMap(claimMap), "path": claimPath})
|
||||
handleError(fmt.Sprintf("Can't find the %s", name), map[string]any{"claim_map": sanitizeClaimMap(claimMap), "path": claimPath})
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ func isValidClaimPath(path string) bool {
|
||||
}
|
||||
|
||||
// sanitizeClaimMap removes sensitive data from claim map for logging
|
||||
func sanitizeClaimMap(claimMap map[string]interface{}) map[string]interface{} {
|
||||
sanitized := make(map[string]interface{})
|
||||
func sanitizeClaimMap(claimMap map[string]any) map[string]any {
|
||||
sanitized := make(map[string]any)
|
||||
sensitiveKeys := map[string]bool{
|
||||
"password": true, "secret": true, "token": true, "key": true,
|
||||
"auth": true, "credential": true, "private": true,
|
||||
@@ -110,7 +110,7 @@ func sanitizeClaimMap(claimMap map[string]interface{}) map[string]interface{} {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func handleError(msg string, details map[string]interface{}) {
|
||||
func handleError(msg string, details map[string]any) {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, emptyMetrics)
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: msg,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
graphql-monitoring-proxy.raczylo.com
|
||||
+713
@@ -0,0 +1,713 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GraphQL Monitoring Proxy - High-Performance GraphQL Gateway</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="High-performance GraphQL proxy with monitoring, caching, circuit breaker, rate limiting, and security features. Zero cost monitoring at 100k+ req/s."
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class'
|
||||
}
|
||||
</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
body { font-family: "Inter", sans-serif; }
|
||||
code, pre { font-family: "JetBrains Mono", monospace; }
|
||||
.theme-transition {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
|
||||
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.dark .glass {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #e879f9 0%, #818cf8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.dark .gradient-text {
|
||||
background: linear-gradient(135deg, #f0abfc 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
|
||||
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
|
||||
html { scroll-behavior: smooth; }
|
||||
</style>
|
||||
<script>
|
||||
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
|
||||
<!-- Navigation -->
|
||||
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="flex justify-between h-16 items-center">
|
||||
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
|
||||
<i class="fas fa-diagram-project text-2xl gradient-text"></i>
|
||||
<span class="text-xl font-bold gradient-text">graphql-monitoring-proxy</span>
|
||||
</a>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
|
||||
<a href="#monitoring" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Monitoring</a>
|
||||
<a href="#speed" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Speed</a>
|
||||
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
|
||||
<a href="#resilience" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Resilience</a>
|
||||
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Install</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
|
||||
<i class="fas fa-moon dark:hidden text-xl"></i>
|
||||
<i class="fas fa-sun hidden dark:inline text-xl"></i>
|
||||
</button>
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
|
||||
<i class="fab fa-github text-xl"></i>
|
||||
</a>
|
||||
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
|
||||
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
|
||||
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
|
||||
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
|
||||
<a href="#monitoring" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Monitoring</a>
|
||||
<a href="#speed" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Speed</a>
|
||||
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
|
||||
<a href="#resilience" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Resilience</a>
|
||||
<a href="#installation" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Install</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-fuchsia-50 via-violet-50 to-indigo-50 dark:from-gray-900 dark:via-fuchsia-900/20 dark:to-indigo-900/20 theme-transition"></div>
|
||||
<div class="absolute top-0 -left-4 w-72 h-72 bg-fuchsia-300 dark:bg-fuchsia-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
|
||||
<div class="absolute top-0 -right-4 w-72 h-72 bg-violet-300 dark:bg-violet-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
|
||||
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-indigo-300 dark:bg-indigo-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
|
||||
|
||||
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center">
|
||||
<div class="mb-8 sm:mb-10 flex justify-center animate-fade-in-up">
|
||||
<div class="text-8xl sm:text-9xl animate-float">
|
||||
<i class="fas fa-diagram-project gradient-text"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
|
||||
GraphQL Monitoring<br /><span class="gradient-text">Proxy</span>
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-3xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
|
||||
Enterprise-grade GraphQL gateway with Prometheus metrics, smart caching, circuit breaker, rate limiting, request coalescing, WebSocket subscriptions, and comprehensive security - all at zero cost.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
|
||||
<a href="#installation" class="group relative bg-gradient-to-r from-fuchsia-500 to-indigo-600 hover:from-fuchsia-600 hover:to-indigo-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
|
||||
<span class="relative z-10">Get Started</span>
|
||||
</a>
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
|
||||
<i class="fab fa-github mr-2"></i>View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4">
|
||||
<img src="https://img.shields.io/github/v/release/lukaszraczylo/graphql-monitoring-proxy" alt="Version" class="h-5" />
|
||||
<img src="https://img.shields.io/github/license/lukaszraczylo/graphql-monitoring-proxy" alt="License" class="h-5" />
|
||||
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/graphql-monitoring-proxy" alt="Go Report" class="h-5" />
|
||||
</div>
|
||||
<div class="mt-12 sm:mt-16 max-w-3xl mx-auto px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-fuchsia-500 to-indigo-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-500"></div>
|
||||
<div class="relative bg-gray-900 rounded-xl p-6 text-left">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span class="ml-2 text-gray-400 text-sm">terminal</span>
|
||||
</div>
|
||||
<pre class="text-gray-100 text-sm sm:text-base overflow-x-auto"><code><span class="text-gray-400"># Run with Docker</span>
|
||||
<span class="text-fuchsia-400">$</span> docker run -p 8080:8080 -p 9393:9393 \
|
||||
-e GMP_HOST_GRAPHQL=http://your-graphql:4000/ \
|
||||
-e GMP_ENABLE_GLOBAL_CACHE=true \
|
||||
-e GMP_ENABLE_CIRCUIT_BREAKER=true \
|
||||
ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Performance Stats -->
|
||||
<section class="py-12 sm:py-16 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="grid sm:grid-cols-4 gap-4 text-center">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<div class="text-4xl font-bold gradient-text mb-2">100k+</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Requests/second</p>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<div class="text-4xl font-bold gradient-text mb-2">10MB</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">RAM usage</p>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<div class="text-4xl font-bold gradient-text mb-2">0.1%</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">CPU usage</p>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<div class="text-4xl font-bold gradient-text mb-2">$0</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Cost</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<a href="bench/" class="inline-flex items-center text-fuchsia-600 dark:text-fuchsia-400 hover:underline font-medium">
|
||||
View benchmarks
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Overview -->
|
||||
<section id="features" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Feature Overview</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Everything you need for production GraphQL</p>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-fuchsia-500 to-fuchsia-600 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<i class="fas fa-chart-line text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Monitoring</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Prometheus metrics, OpenTelemetry tracing, admin dashboard</p>
|
||||
</div>
|
||||
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<i class="fas fa-bolt text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Speed</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Smart caching, request coalescing, read-only replicas</p>
|
||||
</div>
|
||||
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<i class="fas fa-shield-halved text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Security</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Rate limiting, introspection blocking, user banning</p>
|
||||
</div>
|
||||
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<i class="fas fa-heart-pulse text-white"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Resilience</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Circuit breaker, retry budget, connection recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Monitoring Section -->
|
||||
<section id="monitoring" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
|
||||
<i class="fas fa-chart-line gradient-text mr-3"></i>Monitoring
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Complete observability for your GraphQL API</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-fire mr-2 text-orange-500"></i>
|
||||
Prometheus Metrics
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Query execution timing with histograms</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>User ID extraction from JWT tokens</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Operation name and type tracking</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Cache hit/miss ratios</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Success/failure/skipped counters</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Configurable metrics purging</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-satellite-dish mr-2 text-blue-500"></i>
|
||||
OpenTelemetry Tracing
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Distributed tracing support</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Configurable OTLP collector endpoint</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Trace context propagation via headers</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Child span creation for each request</li>
|
||||
</ul>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg mt-4 text-xs overflow-x-auto"><code>GMP_ENABLE_TRACE=true
|
||||
GMP_TRACE_ENDPOINT=localhost:4317</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl md:col-span-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-desktop mr-2 text-pink-500"></i>
|
||||
Real-Time Admin Dashboard
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Web-based UI at <code class="text-fuchsia-600 dark:text-fuchsia-400">/admin</code> with auto-refresh every 5 seconds:</p>
|
||||
<div class="grid sm:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">System Health</h4>
|
||||
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<li>Backend GraphQL status</li>
|
||||
<li>Redis connectivity</li>
|
||||
<li>Response times</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Live Statistics</h4>
|
||||
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<li>Request coalescing rate</li>
|
||||
<li>Retry budget tokens</li>
|
||||
<li>Active WebSocket connections</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Controls</h4>
|
||||
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<li>Circuit breaker state</li>
|
||||
<li>Cache statistics</li>
|
||||
<li>Reset/clear actions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Speed Section -->
|
||||
<section id="speed" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
|
||||
<i class="fas fa-bolt gradient-text mr-3"></i>Speed
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Maximize throughput, minimize latency</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-layer-group mr-2 text-amber-500"></i>
|
||||
Request Coalescing
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Deduplicate concurrent identical queries - only one request hits the backend, response is shared with all waiting clients.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Reduces backend load 50-80%</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Prevents thundering herd on cache expiry</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Zero latency for primary request</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Enabled by default</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-database mr-2 text-violet-500"></i>
|
||||
Smart Caching
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Memory-aware caching with per-user isolation, compression, and flexible TTL control.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>In-memory with LRU eviction</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Distributed Redis cache support</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Per-query TTL via <code>@cached(ttl: 90)</code></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Force refresh via <code>@cached(refresh: true)</code></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Automatic gzip compression</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Per-user cache isolation (security)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-plug mr-2 text-emerald-500"></i>
|
||||
WebSocket Subscriptions
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Native GraphQL subscription support with bidirectional proxying.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Automatic ping/pong keep-alive</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Configurable message size limits</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Connection statistics in dashboard</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Graceful connection handling</li>
|
||||
</ul>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg mt-4 text-xs overflow-x-auto"><code>GMP_WEBSOCKET_ENABLE=true
|
||||
GMP_WEBSOCKET_PING_INTERVAL=30</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-code-branch mr-2 text-cyan-500"></i>
|
||||
Read-Only Replica Support
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Route queries to read replicas, mutations to primary for maximum throughput.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Automatic query/mutation routing</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Scales read capacity horizontally</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Works with Hasura read replicas</li>
|
||||
</ul>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg mt-4 text-xs overflow-x-auto"><code>GMP_HOST_GRAPHQL=http://primary:8080/
|
||||
GMP_HOST_GRAPHQL_READONLY=http://replica:8080/</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security Section -->
|
||||
<section id="security" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
|
||||
<i class="fas fa-shield-halved gradient-text mr-3"></i>Security
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Protect your GraphQL API from abuse</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-gauge-high mr-2 text-rose-500"></i>
|
||||
Role-Based Rate Limiting
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Different rate limits per user role with burst control and dynamic config reload.</p>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg text-xs overflow-x-auto"><code>{
|
||||
"ratelimit": {
|
||||
"admin": { "req": 1000, "interval": "second", "burst": 2000 },
|
||||
"premium": { "req": 500, "interval": "second" },
|
||||
"guest": { "req": 10, "interval": "second" },
|
||||
"-": { "req": 5, "interval": "second" }
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-eye-slash mr-2 text-indigo-500"></i>
|
||||
Introspection Blocking
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Block schema introspection to prevent API discovery attacks, with configurable allowlists.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Blocks __schema, __type, etc.</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Deep nested query inspection</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Allowlist specific introspections</li>
|
||||
</ul>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg mt-4 text-xs overflow-x-auto"><code>GMP_BLOCK_SCHEMA_INTROSPECTION=true
|
||||
GMP_ALLOWED_INTROSPECTION="__typename"</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-ban mr-2 text-red-500"></i>
|
||||
User Ban/Unban API
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Block misbehaving users detected by your monitoring system.</p>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg text-xs overflow-x-auto"><code>curl -X POST http://localhost:9090/api/user-ban \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"user_id": "1337", "reason": "Scraping"}'</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-lock mr-2 text-amber-500"></i>
|
||||
Additional Security
|
||||
</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>Read-only mode:</strong> Block all mutations</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>URL allowlist:</strong> Restrict accessible endpoints</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>JWT claim extraction:</strong> User ID and role from tokens</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>API authentication:</strong> Optional X-API-Key for admin endpoints</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>Log sanitization:</strong> Automatic redaction of sensitive data</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i><strong>SQL injection prevention:</strong> Parameterized queries</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Resilience Section -->
|
||||
<section id="resilience" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
|
||||
<i class="fas fa-heart-pulse gradient-text mr-3"></i>Resilience
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Handle failures gracefully</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-toggle-off mr-2 text-rose-500"></i>
|
||||
Circuit Breaker
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Prevent cascading failures with automatic detection and recovery.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Trip on consecutive failures or ratio</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Automatic recovery after timeout</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Serve cached responses when open</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Configurable for timeouts, 5XX, 4XX</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Exponential backoff support</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Health endpoint: <code>/api/circuit-breaker/health</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-coins mr-2 text-amber-500"></i>
|
||||
Retry Budget
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Prevent retry storms with token bucket rate limiting.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Token bucket algorithm</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Configurable refill rate</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Prevents overwhelming recovering backends</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Enabled by default</li>
|
||||
</ul>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg mt-4 text-xs overflow-x-auto"><code>GMP_RETRY_BUDGET_ENABLE=true
|
||||
GMP_RETRY_BUDGET_TOKENS_PER_SEC=10
|
||||
GMP_RETRY_BUDGET_MAX_TOKENS=100</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-rotate mr-2 text-cyan-500"></i>
|
||||
Connection Recovery
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Automatic connection pool management and backend health monitoring.</p>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Backend startup readiness probe</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Keep-alive with health checks</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Automatic pool reset on failures</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-500 mt-1"></i>Intelligent retry with backoff</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-triangle-exclamation mr-2 text-orange-500"></i>
|
||||
Graceful Degradation
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Informative error responses with retry recommendations.</p>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg text-xs overflow-x-auto"><code>{
|
||||
"errors": [{
|
||||
"message": "Backend temporarily unavailable",
|
||||
"extensions": {
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"retryable": true,
|
||||
"retry_after": 60
|
||||
}
|
||||
}]
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Maintenance Section -->
|
||||
<section class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
|
||||
<i class="fas fa-wrench gradient-text mr-3"></i>Maintenance
|
||||
</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Built-in tools for Hasura users</p>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-broom mr-2 text-emerald-500"></i>
|
||||
Hasura Event Cleaner
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Automatically clean up old event logs to prevent database bloat. Runs hourly.</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2 text-sm">Tables Cleaned</h4>
|
||||
<ul class="space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<li><code>hdb_catalog.event_invocation_logs</code></li>
|
||||
<li><code>hdb_catalog.event_log</code></li>
|
||||
<li><code>hdb_catalog.hdb_action_log</code></li>
|
||||
<li><code>hdb_catalog.hdb_cron_event_invocation_logs</code></li>
|
||||
<li><code>hdb_catalog.hdb_scheduled_event_invocation_logs</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2 text-sm">Configuration</h4>
|
||||
<pre class="bg-gray-900 text-gray-100 p-3 rounded-lg text-xs overflow-x-auto"><code>GMP_HASURA_EVENT_CLEANER=true
|
||||
GMP_HASURA_EVENT_CLEANER_OLDER_THAN=14
|
||||
GMP_HASURA_EVENT_METADATA_DB=postgres://...</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Installation Section -->
|
||||
<section id="installation" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Installation</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Deploy in seconds</p>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
||||
<i class="fab fa-docker mr-2 text-blue-500"></i>
|
||||
Docker
|
||||
</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>docker pull ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
||||
<i class="fas fa-download mr-2 text-fuchsia-500"></i>
|
||||
Binary Download
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3">Download from the <a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/releases/latest" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">releases page</a>.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Supported: Darwin ARM64/AMD64, Linux ARM64/AMD64, Windows AMD64</p>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
|
||||
<i class="fas fa-dharmachakra mr-2 text-indigo-500"></i>
|
||||
Kubernetes
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3">Example manifests available:</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-deployment.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Standalone deployment</a></li>
|
||||
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-single-deployment.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Combined deployment (proxy + Hasura)</a></li>
|
||||
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-single-deployment-with-ro.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Combined with read-only replica</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Endpoints Section -->
|
||||
<section class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Endpoints</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Available HTTP endpoints</p>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/*</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">GraphQL passthrough endpoint</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/admin</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Admin dashboard UI</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:9393/metrics</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Prometheus metrics</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/healthz</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Health check (with optional backend verification)</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/livez</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Liveness probe</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:9090/api/*</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Management API (user-ban, cache-clear, circuit-breaker)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-8 bg-gray-100 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-diagram-project text-xl gradient-text"></i>
|
||||
<span class="font-semibold gradient-text">graphql-monitoring-proxy</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<i class="fab fa-github text-xl"></i>
|
||||
</a>
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
|
||||
Issues
|
||||
</a>
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
|
||||
Releases
|
||||
</a>
|
||||
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy#configuration" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
|
||||
Full Docs
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">MIT License</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Theme toggle
|
||||
document.getElementById('theme-toggle').addEventListener('click', function() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
const openIcon = document.getElementById('menu-open-icon');
|
||||
const closeIcon = document.getElementById('menu-close-icon');
|
||||
|
||||
menu.classList.toggle('hidden');
|
||||
openIcon.classList.toggle('hidden');
|
||||
closeIcon.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking a link
|
||||
document.querySelectorAll('#mobile-menu a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
document.getElementById('mobile-menu').classList.add('hidden');
|
||||
document.getElementById('menu-open-icon').classList.remove('hidden');
|
||||
document.getElementById('menu-close-icon').classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,15 +29,15 @@ const (
|
||||
|
||||
// ProxyError represents a structured error response
|
||||
type ProxyError struct {
|
||||
Code string `json:"code"` // Machine-readable error code
|
||||
Message string `json:"message"` // Human-readable error message
|
||||
Details string `json:"details,omitempty"` // Additional error details
|
||||
Retryable bool `json:"retryable"` // Whether the request can be retried
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Timestamp time.Time `json:"timestamp"` // When the error occurred
|
||||
TraceID string `json:"trace_id,omitempty"` // Trace ID for correlation
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional context
|
||||
Cause error `json:"-"` // Original error (not serialized)
|
||||
Code string `json:"code"` // Machine-readable error code
|
||||
Message string `json:"message"` // Human-readable error message
|
||||
Details string `json:"details,omitempty"` // Additional error details
|
||||
Retryable bool `json:"retryable"` // Whether the request can be retried
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Timestamp time.Time `json:"timestamp"` // When the error occurred
|
||||
TraceID string `json:"trace_id,omitempty"` // Trace ID for correlation
|
||||
Metadata map[string]any `json:"metadata,omitempty"` // Additional context
|
||||
Cause error `json:"-"` // Original error (not serialized)
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
@@ -78,7 +78,7 @@ func NewProxyError(code, message string, statusCode int, retryable bool) *ProxyE
|
||||
StatusCode: statusCode,
|
||||
Retryable: retryable,
|
||||
Timestamp: time.Now(),
|
||||
Metadata: make(map[string]interface{}),
|
||||
Metadata: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,122 +101,13 @@ func (e *ProxyError) WithTraceID(traceID string) *ProxyError {
|
||||
}
|
||||
|
||||
// WithMetadata adds metadata
|
||||
func (e *ProxyError) WithMetadata(key string, value interface{}) *ProxyError {
|
||||
func (e *ProxyError) WithMetadata(key string, value any) *ProxyError {
|
||||
e.Metadata[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
|
||||
// NewConnectionError creates a connection-related error
|
||||
func NewConnectionError(err error) *ProxyError {
|
||||
code := ErrCodeConnectionRefused
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if contains(errStr, "reset") {
|
||||
code = ErrCodeConnectionReset
|
||||
}
|
||||
}
|
||||
|
||||
return NewProxyError(code, "Failed to connect to backend", 502, true).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// NewTimeoutError creates a timeout error
|
||||
func NewTimeoutError(err error) *ProxyError {
|
||||
return NewProxyError(ErrCodeTimeout, "Request timed out", 504, false).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// NewCircuitOpenError creates a circuit breaker open error
|
||||
func NewCircuitOpenError() *ProxyError {
|
||||
return NewProxyError(ErrCodeCircuitOpen, "Service temporarily unavailable due to circuit breaker", 503, false).
|
||||
WithDetails("The backend service is currently experiencing issues. Please try again later.")
|
||||
}
|
||||
|
||||
// NewRateLimitError creates a rate limit error
|
||||
func NewRateLimitError(userID, role string) *ProxyError {
|
||||
return NewProxyError(ErrCodeRateLimited, "Rate limit exceeded", 429, false).
|
||||
WithDetails("You have exceeded the rate limit for your role").
|
||||
WithMetadata("user_id", userID).
|
||||
WithMetadata("role", role)
|
||||
}
|
||||
|
||||
// NewBackendError creates a backend error from status code
|
||||
func NewBackendError(statusCode int, body string) *ProxyError {
|
||||
code := ErrCodeBackendError
|
||||
message := "Backend returned an error"
|
||||
retryable := false
|
||||
|
||||
switch {
|
||||
case statusCode == 429:
|
||||
code = ErrCodeRateLimited
|
||||
message = "Backend rate limit exceeded"
|
||||
retryable = true
|
||||
case statusCode == 503:
|
||||
code = ErrCodeServiceUnavailable
|
||||
message = "Backend service unavailable"
|
||||
retryable = true
|
||||
case statusCode == 502 || statusCode == 504:
|
||||
code = ErrCodeBadGateway
|
||||
message = "Bad gateway"
|
||||
retryable = true
|
||||
case statusCode >= 500:
|
||||
code = ErrCodeBackendError
|
||||
message = "Backend server error"
|
||||
retryable = true
|
||||
case statusCode == 404:
|
||||
code = ErrCodeNotFound
|
||||
message = "Resource not found"
|
||||
case statusCode == 403:
|
||||
code = ErrCodeForbidden
|
||||
message = "Access forbidden"
|
||||
case statusCode == 401:
|
||||
code = ErrCodeUnauthorized
|
||||
message = "Unauthorized"
|
||||
case statusCode >= 400:
|
||||
code = ErrCodeInvalidRequest
|
||||
message = "Invalid request"
|
||||
}
|
||||
|
||||
return NewProxyError(code, message, statusCode, retryable).
|
||||
WithMetadata("backend_status", statusCode).
|
||||
WithMetadata("backend_body", truncateString(body, 500))
|
||||
}
|
||||
|
||||
// NewInvalidResponseError creates an invalid response error
|
||||
func NewInvalidResponseError(details string) *ProxyError {
|
||||
return NewProxyError(ErrCodeInvalidResponse, "Backend returned invalid response", 502, false).
|
||||
WithDetails(details)
|
||||
}
|
||||
|
||||
// NewInternalError creates an internal error
|
||||
func NewInternalError(err error) *ProxyError {
|
||||
return NewProxyError(ErrCodeInternalError, "Internal proxy error", 500, false).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// NewContextCanceledError creates a context canceled error
|
||||
func NewContextCanceledError() *ProxyError {
|
||||
return NewProxyError(ErrCodeContextCanceled, "Request canceled", 499, false).
|
||||
WithDetails("The request was canceled by the client")
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
|
||||
@@ -15,12 +15,13 @@ const (
|
||||
)
|
||||
|
||||
// Use parameterized queries to prevent SQL injection
|
||||
// Cast $1 to interval type to allow proper parameterized interval values
|
||||
var delQueries = [...]string{
|
||||
"DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < NOW() - INTERVAL $1",
|
||||
"DELETE FROM hdb_catalog.event_log WHERE created_at < NOW() - INTERVAL $1",
|
||||
"DELETE FROM hdb_catalog.hdb_action_log WHERE created_at < NOW() - INTERVAL $1",
|
||||
"DELETE FROM hdb_catalog.hdb_cron_event_invocation_logs WHERE created_at < NOW() - INTERVAL $1",
|
||||
"DELETE FROM hdb_catalog.hdb_scheduled_event_invocation_logs WHERE created_at < NOW() - INTERVAL $1",
|
||||
"DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < NOW() - $1::INTERVAL",
|
||||
"DELETE FROM hdb_catalog.event_log WHERE created_at < NOW() - $1::INTERVAL",
|
||||
"DELETE FROM hdb_catalog.hdb_action_log WHERE created_at < NOW() - $1::INTERVAL",
|
||||
"DELETE FROM hdb_catalog.hdb_cron_event_invocation_logs WHERE created_at < NOW() - $1::INTERVAL",
|
||||
"DELETE FROM hdb_catalog.hdb_scheduled_event_invocation_logs WHERE created_at < NOW() - $1::INTERVAL",
|
||||
}
|
||||
|
||||
func enableHasuraEventCleaner(ctx context.Context) error {
|
||||
@@ -47,7 +48,7 @@ func enableHasuraEventCleaner(ctx context.Context) error {
|
||||
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Event cleaner enabled",
|
||||
Pairs: map[string]interface{}{"interval_in_days": clearOlderThan},
|
||||
Pairs: map[string]any{"interval_in_days": clearOlderThan},
|
||||
})
|
||||
|
||||
// Parse pool configuration
|
||||
@@ -66,7 +67,7 @@ func enableHasuraEventCleaner(ctx context.Context) error {
|
||||
if err != nil {
|
||||
logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to create connection pool",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func cleanEvents(ctx context.Context, pool *pgxpool.Pool, clearOlderThan int, lo
|
||||
} else {
|
||||
logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Successfully executed query",
|
||||
Pairs: map[string]interface{}{"query": query, "interval": interval},
|
||||
Pairs: map[string]any{"query": query, "interval": interval},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func cleanEvents(ctx context.Context, pool *pgxpool.Pool, clearOlderThan int, lo
|
||||
}
|
||||
logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to execute some queries",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"failed_queries": failedQueries,
|
||||
"errors": errMsgs,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestEventsSecurityTestSuite(t *testing.T) {
|
||||
// TestEventCleanerSQLInjection tests various SQL injection attempts in the event cleaner
|
||||
func (suite *EventsSecurityTestSuite) TestEventCleanerSQLInjection() {
|
||||
tests := []struct {
|
||||
clearDays interface{}
|
||||
clearDays any
|
||||
name string
|
||||
description string
|
||||
expectError bool
|
||||
@@ -175,7 +175,7 @@ func (suite *EventsSecurityTestSuite) TestEventCleanerParameterizedQueries() {
|
||||
|
||||
// TestEventCleanerConcurrentSQLInjection tests SQL injection under concurrent conditions
|
||||
func (suite *EventsSecurityTestSuite) TestEventCleanerConcurrentSQLInjection() {
|
||||
maliciousInputs := []interface{}{
|
||||
maliciousInputs := []any{
|
||||
"1'; DROP TABLE events; --",
|
||||
"1 OR 1=1",
|
||||
"'; TRUNCATE events; --",
|
||||
@@ -185,7 +185,7 @@ func (suite *EventsSecurityTestSuite) TestEventCleanerConcurrentSQLInjection() {
|
||||
done := make(chan error, len(maliciousInputs))
|
||||
|
||||
for _, input := range maliciousInputs {
|
||||
go func(val interface{}) {
|
||||
go func(val any) {
|
||||
err := validateClearDaysInput(val)
|
||||
done <- err
|
||||
}(input)
|
||||
@@ -202,7 +202,7 @@ func (suite *EventsSecurityTestSuite) TestEventCleanerConcurrentSQLInjection() {
|
||||
// TestEventCleanerInputSanitization tests input sanitization effectiveness
|
||||
func (suite *EventsSecurityTestSuite) TestEventCleanerInputSanitization() {
|
||||
tests := []struct {
|
||||
input interface{}
|
||||
input any
|
||||
name string
|
||||
expected int
|
||||
hasError bool
|
||||
@@ -279,7 +279,7 @@ func (suite *EventsSecurityTestSuite) TestEventCleanerDatabaseInteraction() {
|
||||
// Helper functions that should be implemented in the main codebase
|
||||
|
||||
// validateClearDaysInput validates and sanitizes the clearDays input
|
||||
func validateClearDaysInput(input interface{}) error {
|
||||
func validateClearDaysInput(input any) error {
|
||||
// This function should be implemented in the main codebase
|
||||
// to validate clearDays input before using it in SQL queries
|
||||
|
||||
@@ -319,7 +319,7 @@ func validateClearDaysInput(input interface{}) error {
|
||||
}
|
||||
|
||||
// sanitizeAndValidateClearDays sanitizes and validates the input, returning the clean integer
|
||||
func sanitizeAndValidateClearDays(input interface{}) (int, error) {
|
||||
func sanitizeAndValidateClearDays(input any) (int, error) {
|
||||
err := validateClearDaysInput(input)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -340,8 +340,8 @@ func getDelQueries() []string {
|
||||
// This should return the actual delQueries from the main package
|
||||
// For testing purposes, we return expected parameterized queries
|
||||
return []string{
|
||||
"DELETE FROM hdb_catalog.event_log WHERE created_at < NOW() - INTERVAL '$1 days'",
|
||||
"DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < NOW() - INTERVAL '$1 days'",
|
||||
"DELETE FROM hdb_catalog.event_log WHERE created_at < NOW() - $1::INTERVAL",
|
||||
"DELETE FROM hdb_catalog.event_invocation_logs WHERE created_at < NOW() - $1::INTERVAL",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,53 @@
|
||||
module github.com/lukaszraczylo/graphql-monitoring-proxy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.6
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/VictoriaMetrics/metrics v1.40.2
|
||||
github.com/VictoriaMetrics/metrics v1.43.0
|
||||
github.com/alicebob/miniredis/v2 v2.33.0
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/gofiber/fiber/v2 v2.52.12
|
||||
github.com/gofiber/websocket/v2 v2.2.1
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gookit/goutil v0.7.1
|
||||
github.com/gookit/goutil v0.7.4
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/graphql-go/graphql v0.8.1
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/jackc/pgx/v5 v5.9.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.84
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.89
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/sony/gobreaker v1.0.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||
go.opentelemetry.io/otel/sdk v1.38.0
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fasthttp/websocket v1.5.12 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.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.18.1 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
@@ -58,17 +55,17 @@ require (
|
||||
github.com/valyala/histogram v1.2.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
|
||||
github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
|
||||
github.com/VictoriaMetrics/metrics v1.43.0 h1:mtzzl30hG28FtrsqfffKgtf5X3I8CdYCuToSr/bHw6M=
|
||||
github.com/VictoriaMetrics/metrics v1.43.0/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
|
||||
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=
|
||||
github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
|
||||
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -16,10 +16,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
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/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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=
|
||||
@@ -32,12 +30,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/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.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
|
||||
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
@@ -48,24 +46,26 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/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/goutil v0.7.1 h1:AaFJPN9mrdeYBv8HOybri26EHGCC34WJVT7jUStGJsI=
|
||||
github.com/gookit/goutil v0.7.1/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gookit/goutil v0.7.4 h1:OWgUngToNz+bPlX5aP+EMG31DraEU63uvKMwwT3vseM=
|
||||
github.com/gookit/goutil v0.7.4/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
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.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
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.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
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=
|
||||
@@ -74,18 +74,18 @@ github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUf
|
||||
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.84 h1:yP00k8XSYKFYo6PmZFOsDblexLOG6WZzVWhzdstrxiw=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.84/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
|
||||
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
|
||||
@@ -99,8 +99,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
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=
|
||||
@@ -109,49 +109,51 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 h1:vk5TfqZHNn0obhPIYeS+cxIFKFQgser/M2jnI+9c6MM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101/go.mod h1:E17fc4PDhkr22dE3RgnH2hEubUaky6ZwW4VhANxyspg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
+57
-18
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
@@ -37,6 +38,40 @@ var (
|
||||
currentCacheSize int64 // Use atomic operations for this
|
||||
)
|
||||
|
||||
// sanitizeOperationName removes null bytes and other invalid characters from operation names
|
||||
// This prevents panics when creating metrics with invalid label values
|
||||
func sanitizeOperationName(name string) string {
|
||||
if name == "" || name == "undefined" {
|
||||
return name
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(name))
|
||||
|
||||
for _, r := range name {
|
||||
// Skip null bytes entirely
|
||||
if r == '\x00' {
|
||||
continue
|
||||
}
|
||||
// Replace control characters with underscores
|
||||
if r < 32 || r == 127 {
|
||||
buf.WriteByte('_')
|
||||
continue
|
||||
}
|
||||
// Only allow printable characters
|
||||
if unicode.IsPrint(r) {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
result := buf.String()
|
||||
// Return "undefined" if we ended up with an empty string after sanitization
|
||||
if result == "" {
|
||||
return "undefined"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func prepareQueriesAndExemptions() {
|
||||
introspectionAllowedQueries = make(map[string]struct{})
|
||||
allowedUrls = make(map[string]struct{})
|
||||
@@ -68,14 +103,14 @@ type parseGraphQLQueryResult struct {
|
||||
var (
|
||||
// Pool for request/response maps during unmarshaling
|
||||
queryPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make(map[string]interface{}, 48)
|
||||
New: func() any {
|
||||
return make(map[string]any, 48)
|
||||
},
|
||||
}
|
||||
|
||||
// Pool for parse result objects
|
||||
resultPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return &parseGraphQLQueryResult{}
|
||||
},
|
||||
}
|
||||
@@ -111,7 +146,7 @@ func initGraphQLParsing() {
|
||||
if cfg != nil && cfg.Logger != nil {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "GraphQL query cache initialized",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"max_entries": maxQueryCacheSize,
|
||||
"max_size_mb": 50,
|
||||
},
|
||||
@@ -209,7 +244,7 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
res.activeEndpoint = cfg.Server.HostGraphQL
|
||||
|
||||
// Get a map from the pool for JSON unmarshaling
|
||||
m := queryPool.Get().(map[string]interface{})
|
||||
m := queryPool.Get().(map[string]any)
|
||||
defer func() {
|
||||
// Clear and return the map to the pool
|
||||
for k := range m {
|
||||
@@ -298,8 +333,8 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
res.operationType = "mutation"
|
||||
if oper.Name != nil {
|
||||
mutationName = oper.Name.Value
|
||||
// Use mutation name immediately
|
||||
res.operationName = mutationName
|
||||
// Use mutation name immediately, sanitized to prevent metric panics
|
||||
res.operationName = sanitizeOperationName(mutationName)
|
||||
}
|
||||
break // Found a mutation, no need to continue first pass
|
||||
}
|
||||
@@ -316,7 +351,7 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
// We already set operation type to mutation in first pass
|
||||
// Only set name if we didn't find a mutation name earlier
|
||||
if res.operationName == "undefined" && oper.Name != nil {
|
||||
res.operationName = oper.Name.Value
|
||||
res.operationName = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
} else {
|
||||
// No mutation found, use the normal logic
|
||||
@@ -325,18 +360,10 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
}
|
||||
|
||||
if res.operationName == "undefined" && oper.Name != nil {
|
||||
res.operationName = oper.Name.Value
|
||||
res.operationName = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle endpoint routing - always use write endpoint for mutations
|
||||
if res.operationType == "mutation" {
|
||||
res.activeEndpoint = cfg.Server.HostGraphQL
|
||||
} else if cfg.Server.HostGraphQLReadOnly != "" {
|
||||
// Use read-only endpoint for non-mutation operations
|
||||
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
|
||||
}
|
||||
|
||||
// Block mutations in read-only mode
|
||||
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
|
||||
if ifNotInTest() {
|
||||
@@ -359,13 +386,25 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle endpoint routing AFTER processing all definitions
|
||||
// This ensures mutations are always routed to the write endpoint
|
||||
if res.operationType == "mutation" {
|
||||
res.activeEndpoint = cfg.Server.HostGraphQL
|
||||
} else if cfg.Server.HostGraphQLReadOnly != "" {
|
||||
// Use read-only endpoint for non-mutation operations
|
||||
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
|
||||
}
|
||||
|
||||
// Track parsing time
|
||||
if ifNotInTest() && cfg.Monitoring != nil {
|
||||
parseTime := float64(time.Since(startTime).Milliseconds())
|
||||
cfg.Monitoring.IncrementFloat(libpack_monitoring.MetricsGraphQLParsingTime, nil, parseTime)
|
||||
}
|
||||
|
||||
return res
|
||||
// Create a copy to return, since the original will be returned to the pool
|
||||
// This prevents race conditions where concurrent requests could modify the same result
|
||||
result := *res
|
||||
return &result
|
||||
}
|
||||
|
||||
// processDirectives extracts caching directives from the operation
|
||||
|
||||
+1
-1
@@ -486,7 +486,7 @@ func (suite *Tests) Test_DeepIntrospectionQueries() {
|
||||
for _, q := range tt.allowed {
|
||||
introspectionAllowedQueries[strings.ToLower(q)] = struct{}{}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"query": tt.query,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
@@ -116,9 +116,9 @@ func (suite *IntegrationSecurityTestSuite) setupTestApps() {
|
||||
}
|
||||
|
||||
// Mock GraphQL response
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"data": map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": "12345",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
@@ -156,7 +156,7 @@ func (suite *IntegrationSecurityTestSuite) TestEndToEndSecurity() {
|
||||
defer func() { cfg.LogLevel = originalLogLevel }()
|
||||
|
||||
// Create GraphQL request with sensitive data
|
||||
graphqlQuery := map[string]interface{}{
|
||||
graphqlQuery := map[string]any{
|
||||
"query": `
|
||||
mutation LoginUser($input: LoginInput!) {
|
||||
login(input: $input) {
|
||||
@@ -165,8 +165,8 @@ func (suite *IntegrationSecurityTestSuite) TestEndToEndSecurity() {
|
||||
}
|
||||
}
|
||||
`,
|
||||
"variables": map[string]interface{}{
|
||||
"input": map[string]interface{}{
|
||||
"variables": map[string]any{
|
||||
"input": map[string]any{
|
||||
"email": "user@example.com",
|
||||
"password": "secret123password",
|
||||
"api_key": "sk-sensitive-key-123",
|
||||
@@ -194,7 +194,7 @@ func (suite *IntegrationSecurityTestSuite) TestEndToEndSecurity() {
|
||||
// TestAPISecurityFlow tests complete API security workflow
|
||||
func (suite *IntegrationSecurityTestSuite) TestAPISecurityFlow() {
|
||||
tests := []struct {
|
||||
body map[string]interface{}
|
||||
body map[string]any
|
||||
name string
|
||||
endpoint string
|
||||
method string
|
||||
@@ -207,7 +207,7 @@ func (suite *IntegrationSecurityTestSuite) TestAPISecurityFlow() {
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
apiKey: "",
|
||||
body: map[string]interface{}{"user_id": "malicious-user", "reason": "test ban"},
|
||||
body: map[string]any{"user_id": "malicious-user", "reason": "test ban"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject unauthorized ban attempts",
|
||||
},
|
||||
@@ -216,7 +216,7 @@ func (suite *IntegrationSecurityTestSuite) TestAPISecurityFlow() {
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
apiKey: "' OR '1'='1 --",
|
||||
body: map[string]interface{}{"user_id": "test-user", "reason": "test ban"},
|
||||
body: map[string]any{"user_id": "test-user", "reason": "test ban"},
|
||||
expectedStatus: 401,
|
||||
description: "Should reject SQL injection in API key",
|
||||
},
|
||||
@@ -225,7 +225,7 @@ func (suite *IntegrationSecurityTestSuite) TestAPISecurityFlow() {
|
||||
endpoint: "/api/user-ban",
|
||||
method: "POST",
|
||||
apiKey: suite.validAPIKey,
|
||||
body: map[string]interface{}{"user_id": "test-user-ban", "reason": "test ban reason"},
|
||||
body: map[string]any{"user_id": "test-user-ban", "reason": "test ban reason"},
|
||||
expectedStatus: 200,
|
||||
description: "Should accept valid ban request",
|
||||
},
|
||||
@@ -488,9 +488,9 @@ func (suite *IntegrationSecurityTestSuite) TestDataSanitizationIntegration() {
|
||||
defer func() { cfg.LogLevel = originalLogLevel }()
|
||||
|
||||
// Create request with sensitive data
|
||||
sensitiveData := map[string]interface{}{
|
||||
sensitiveData := map[string]any{
|
||||
"query": "{ user { id name } }",
|
||||
"variables": map[string]interface{}{
|
||||
"variables": map[string]any{
|
||||
"password": "secret123",
|
||||
"api_key": "sk-sensitive-123",
|
||||
"credit_card": "4111111111111111",
|
||||
@@ -513,7 +513,7 @@ func (suite *IntegrationSecurityTestSuite) TestDataSanitizationIntegration() {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
var response map[string]interface{}
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(body, &response)
|
||||
suite.NoError(err)
|
||||
|
||||
@@ -587,7 +587,7 @@ func (suite *IntegrationSecurityTestSuite) TestErrorHandlingSecurityIntegration(
|
||||
func (suite *IntegrationSecurityTestSuite) TestComprehensiveSecurityScenario() {
|
||||
suite.Run("Complete security workflow", func() {
|
||||
// 1. Attempt SQL injection via GraphQL
|
||||
maliciousGraphQL := map[string]interface{}{
|
||||
maliciousGraphQL := map[string]any{
|
||||
"query": "{ user(id: \"'; DROP TABLE users; --\") { id } }",
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ func BenchmarkSecurityOperations(b *testing.B) {
|
||||
})
|
||||
|
||||
b.Run("Log Sanitization", func(b *testing.B) {
|
||||
testData := map[string]interface{}{
|
||||
testData := map[string]any{
|
||||
"password": "secret123",
|
||||
"api_key": "sk-123456",
|
||||
"data": "normal data",
|
||||
|
||||
+539
-2
@@ -1,14 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gookit/goutil/strutil"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/sony/gobreaker"
|
||||
@@ -115,7 +117,8 @@ func (suite *Tests) TestCachingAndCircuitBreakerInteraction() {
|
||||
suite.Equal(responseBody, firstResponseBody, "Response body should match server response")
|
||||
|
||||
// Calculate hash the same way the system does, before releasing context
|
||||
cacheKey := strutil.Md5(ctx.Body())
|
||||
// Use default user context ("-", "-") since no auth headers are set in this test
|
||||
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
||||
|
||||
// Store in cache directly for test
|
||||
libpack_cache.CacheStore(cacheKey, []byte(responseBody))
|
||||
@@ -495,3 +498,537 @@ func getMetricValue(metricName string) int {
|
||||
}
|
||||
return int(counter.Get())
|
||||
}
|
||||
|
||||
// TestRequestCoalescingIntegration tests that request coalescing works end-to-end
|
||||
// through the proxy layer, ensuring concurrent identical requests result in only
|
||||
// one backend call while all clients receive the correct response.
|
||||
func (suite *Tests) TestRequestCoalescingIntegration() {
|
||||
// Save original config
|
||||
originalCoalescing := cfg.RequestCoalescing
|
||||
originalClient := cfg.Client.FastProxyClient
|
||||
originalHostGraphQL := cfg.Server.HostGraphQL
|
||||
|
||||
// Restore after test
|
||||
defer func() {
|
||||
cfg.RequestCoalescing = originalCoalescing
|
||||
cfg.Client.FastProxyClient = originalClient
|
||||
cfg.Server.HostGraphQL = originalHostGraphQL
|
||||
}()
|
||||
|
||||
// Track backend calls
|
||||
var backendCallCount atomic.Int32
|
||||
var requestDelay = 100 * time.Millisecond
|
||||
|
||||
// Create test server that counts requests and introduces delay
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backendCallCount.Add(1)
|
||||
time.Sleep(requestDelay) // Delay to allow concurrent requests to coalesce
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"data":{"users":[{"id":"1","name":"Test User"}]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Configure for test
|
||||
cfg.Server.HostGraphQL = server.URL
|
||||
cfg.Client.ClientTimeout = 5
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
cfg.RequestCoalescing.Enable = true
|
||||
|
||||
// Initialize request coalescer for this test
|
||||
// Reset the global coalescer by creating a new one
|
||||
testCoalescer := NewRequestCoalescer(true, cfg.Logger, cfg.Monitoring)
|
||||
|
||||
// Temporarily replace global coalescer
|
||||
originalCoalescer := requestCoalescer
|
||||
requestCoalescer = testCoalescer
|
||||
defer func() {
|
||||
requestCoalescer = originalCoalescer
|
||||
}()
|
||||
|
||||
// Test Case 1: Concurrent identical requests should coalesce
|
||||
suite.Run("concurrent_identical_requests_coalesce", func() {
|
||||
backendCallCount.Store(0)
|
||||
testCoalescer.Reset()
|
||||
|
||||
concurrentRequests := 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrentRequests)
|
||||
|
||||
responses := make([]string, concurrentRequests)
|
||||
errors := make([]error, concurrentRequests)
|
||||
|
||||
// Launch concurrent requests with identical query
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { users { id name } }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
errors[index] = err
|
||||
responses[index] = string(ctx.Response().Body())
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify only 1 backend call was made
|
||||
suite.Equal(int32(1), backendCallCount.Load(),
|
||||
"Should make only 1 backend call for %d concurrent identical requests", concurrentRequests)
|
||||
|
||||
// Verify all requests succeeded with same response
|
||||
expectedResponse := `{"data":{"users":[{"id":"1","name":"Test User"}]}}`
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
suite.Nil(errors[i], "Request %d should succeed", i)
|
||||
suite.Equal(expectedResponse, responses[i],
|
||||
"Request %d should have correct response", i)
|
||||
}
|
||||
|
||||
// Verify coalescing stats
|
||||
stats := testCoalescer.GetStats()
|
||||
suite.Equal(int64(concurrentRequests), stats["total_requests"],
|
||||
"Total requests should match")
|
||||
suite.Equal(int64(1), stats["primary_requests"],
|
||||
"Should have 1 primary request")
|
||||
suite.Equal(int64(concurrentRequests-1), stats["coalesced_requests"],
|
||||
"Should have %d coalesced requests", concurrentRequests-1)
|
||||
})
|
||||
|
||||
// Test Case 2: Different queries should NOT coalesce
|
||||
suite.Run("different_queries_not_coalesced", func() {
|
||||
backendCallCount.Store(0)
|
||||
testCoalescer.Reset()
|
||||
|
||||
// Create server that returns query-specific responses
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backendCallCount.Add(1)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
body := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(body)
|
||||
|
||||
var response string
|
||||
if strings.Contains(string(body), "query1") {
|
||||
response = `{"data":{"result":"query1"}}`
|
||||
} else if strings.Contains(string(body), "query2") {
|
||||
response = `{"data":{"result":"query2"}}`
|
||||
} else {
|
||||
response = `{"data":{"result":"unknown"}}`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server2.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = server2.URL
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
var response1, response2 string
|
||||
var err1, err2 error
|
||||
|
||||
// Launch two requests with different queries concurrently
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { query1 }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
err1 = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
response1 = string(ctx.Response().Body())
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { query2 }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
err2 = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
response2 = string(ctx.Response().Body())
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Both requests should succeed
|
||||
suite.Nil(err1, "Query1 should succeed")
|
||||
suite.Nil(err2, "Query2 should succeed")
|
||||
|
||||
// Should have made 2 backend calls (no coalescing for different queries)
|
||||
suite.Equal(int32(2), backendCallCount.Load(),
|
||||
"Should make 2 backend calls for 2 different queries")
|
||||
|
||||
// Responses should be different
|
||||
suite.Contains(response1, "query1", "Response1 should be for query1")
|
||||
suite.Contains(response2, "query2", "Response2 should be for query2")
|
||||
})
|
||||
|
||||
// Test Case 3: Coalescing disabled should make separate calls
|
||||
suite.Run("coalescing_disabled", func() {
|
||||
// Create a fresh server for this test
|
||||
var disabledCallCount atomic.Int32
|
||||
serverDisabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
disabledCallCount.Add(1)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"data":{"users":[{"id":"1"}]}}`))
|
||||
}))
|
||||
defer serverDisabled.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = serverDisabled.URL
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
// Disable coalescing
|
||||
cfg.RequestCoalescing.Enable = false
|
||||
|
||||
concurrentRequests := 5
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrentRequests)
|
||||
|
||||
// Launch concurrent identical requests
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { users { id } }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should make separate backend calls when coalescing is disabled
|
||||
suite.Equal(int32(concurrentRequests), disabledCallCount.Load(),
|
||||
"Should make %d backend calls when coalescing is disabled", concurrentRequests)
|
||||
|
||||
// Re-enable for subsequent tests
|
||||
cfg.RequestCoalescing.Enable = true
|
||||
})
|
||||
|
||||
// Test Case 4: Error responses should be shared correctly
|
||||
suite.Run("error_responses_coalesced", func() {
|
||||
backendCallCount.Store(0)
|
||||
testCoalescer.Reset()
|
||||
|
||||
// Create server that returns errors
|
||||
serverError := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backendCallCount.Add(1)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"message":"Internal server error"}]}`))
|
||||
}))
|
||||
defer serverError.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = serverError.URL
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
concurrentRequests := 5
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrentRequests)
|
||||
|
||||
errors := make([]error, concurrentRequests)
|
||||
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { fail }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
errors[index] = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should still only make 1 backend call
|
||||
suite.Equal(int32(1), backendCallCount.Load(),
|
||||
"Should make only 1 backend call even for error responses")
|
||||
|
||||
// All requests should receive the same error
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
suite.NotNil(errors[i], "Request %d should have error", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryBudgetIntegration tests that retry budget correctly limits retry attempts
|
||||
func (suite *Tests) TestRetryBudgetIntegration() {
|
||||
// Initialize a retry budget with limited tokens for testing
|
||||
budgetCtx := context.Background()
|
||||
testBudget := NewRetryBudgetWithContext(budgetCtx, RetryBudgetConfig{
|
||||
MaxTokens: 3, // Only allow 3 retries total
|
||||
TokensPerSecond: 0, // Don't refill during test
|
||||
Enabled: true,
|
||||
}, cfg.Logger)
|
||||
|
||||
// Replace global retry budget
|
||||
originalBudget := retryBudget
|
||||
retryBudget = testBudget
|
||||
defer func() {
|
||||
testBudget.Shutdown()
|
||||
retryBudget = originalBudget
|
||||
}()
|
||||
|
||||
suite.Run("retry_budget_limits_retries", func() {
|
||||
testBudget.Reset()
|
||||
|
||||
// Verify retry budget is set and works correctly
|
||||
rb := GetRetryBudget()
|
||||
suite.NotNil(rb, "Retry budget should be set")
|
||||
suite.True(rb.enabled, "Retry budget should be enabled")
|
||||
suite.T().Logf("Retry budget: enabled=%v, tokens=%d", rb.enabled, rb.currentTokens.Load())
|
||||
|
||||
// Test that AllowRetry consumes tokens correctly
|
||||
initialTokens := rb.currentTokens.Load()
|
||||
suite.Equal(int64(3), initialTokens, "Should start with 3 tokens")
|
||||
|
||||
// First 3 retries should be allowed
|
||||
suite.True(rb.AllowRetry(), "First retry should be allowed")
|
||||
suite.True(rb.AllowRetry(), "Second retry should be allowed")
|
||||
suite.True(rb.AllowRetry(), "Third retry should be allowed")
|
||||
|
||||
// Fourth retry should be denied (tokens exhausted)
|
||||
suite.False(rb.AllowRetry(), "Fourth retry should be denied - budget exhausted")
|
||||
|
||||
// Verify stats
|
||||
stats := rb.GetStats()
|
||||
suite.Equal(int64(4), stats["total_attempts"].(int64), "Should have 4 total attempts")
|
||||
suite.Equal(int64(3), stats["allowed_retries"].(int64), "Should have 3 allowed retries")
|
||||
suite.Equal(int64(1), stats["denied_retries"].(int64), "Should have 1 denied retry")
|
||||
|
||||
suite.T().Logf("Retry budget stats: total=%d, allowed=%d, denied=%d",
|
||||
stats["total_attempts"], stats["allowed_retries"], stats["denied_retries"])
|
||||
})
|
||||
|
||||
suite.Run("retry_budget_exhaustion", func() {
|
||||
// Create a new budget with only 1 token
|
||||
testBudget.Shutdown()
|
||||
budgetCtx2 := context.Background()
|
||||
testBudget2 := NewRetryBudgetWithContext(budgetCtx2, RetryBudgetConfig{
|
||||
MaxTokens: 1, // Only allow 1 retry
|
||||
TokensPerSecond: 0, // Don't refill
|
||||
Enabled: true,
|
||||
}, cfg.Logger)
|
||||
retryBudget = testBudget2
|
||||
defer func() {
|
||||
testBudget2.Shutdown()
|
||||
}()
|
||||
|
||||
// Test budget exhaustion with 1 token
|
||||
rb := GetRetryBudget()
|
||||
suite.NotNil(rb, "Retry budget should be set")
|
||||
suite.Equal(int64(1), rb.currentTokens.Load(), "Should start with 1 token")
|
||||
|
||||
// First retry should be allowed
|
||||
suite.True(rb.AllowRetry(), "First retry should be allowed")
|
||||
|
||||
// Second retry should be denied (only 1 token available)
|
||||
suite.False(rb.AllowRetry(), "Second retry should be denied - budget exhausted")
|
||||
|
||||
// Verify stats
|
||||
stats := rb.GetStats()
|
||||
suite.Equal(int64(2), stats["total_attempts"].(int64), "Should have 2 total attempts")
|
||||
suite.Equal(int64(1), stats["allowed_retries"].(int64), "Should have 1 allowed retry")
|
||||
suite.Equal(int64(1), stats["denied_retries"].(int64), "Should have 1 denied retry")
|
||||
|
||||
suite.T().Logf("Retry budget stats: total=%d, allowed=%d, denied=%d",
|
||||
stats["total_attempts"], stats["allowed_retries"], stats["denied_retries"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestConnectionPoolStatsIntegration tests that connection pool stats are tracked
|
||||
func (suite *Tests) TestConnectionPoolStatsIntegration() {
|
||||
// Save original config
|
||||
originalClient := cfg.Client.FastProxyClient
|
||||
originalHostGraphQL := cfg.Server.HostGraphQL
|
||||
originalCoalescing := cfg.RequestCoalescing.Enable
|
||||
|
||||
// Restore after test
|
||||
defer func() {
|
||||
cfg.Client.FastProxyClient = originalClient
|
||||
cfg.Server.HostGraphQL = originalHostGraphQL
|
||||
cfg.RequestCoalescing.Enable = originalCoalescing
|
||||
}()
|
||||
|
||||
// Disable request coalescing for accurate tracking
|
||||
cfg.RequestCoalescing.Enable = false
|
||||
|
||||
suite.Run("connection_success_tracked", func() {
|
||||
// Create test server that succeeds
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"data":{"test":"success"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = server.URL
|
||||
cfg.Client.ClientTimeout = 5
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
// Initialize connection pool
|
||||
InitializeConnectionPool(cfg.Client.FastProxyClient)
|
||||
defer ShutdownConnectionPool()
|
||||
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
|
||||
|
||||
// Get stats before
|
||||
statsBefore := poolMgr.GetConnectionStats()
|
||||
successBefore := statsBefore["total_connections"].(int64)
|
||||
|
||||
// Make a successful request
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { test }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
|
||||
suite.Nil(err, "Request should succeed")
|
||||
|
||||
// Get stats after
|
||||
statsAfter := poolMgr.GetConnectionStats()
|
||||
successAfter := statsAfter["total_connections"].(int64)
|
||||
|
||||
suite.Greater(successAfter, successBefore,
|
||||
"Total connections should increase after successful request")
|
||||
})
|
||||
|
||||
suite.Run("connection_failure_tracked_on_5xx", func() {
|
||||
// Create test server that returns 503
|
||||
// Note: 503 triggers retry which records failures
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"message":"Service unavailable"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = server.URL
|
||||
cfg.Client.ClientTimeout = 2
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
// Initialize connection pool
|
||||
InitializeConnectionPool(cfg.Client.FastProxyClient)
|
||||
defer ShutdownConnectionPool()
|
||||
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
|
||||
|
||||
// Get stats before
|
||||
statsBefore := poolMgr.GetConnectionStats()
|
||||
failuresBefore := statsBefore["connection_failures"].(int64)
|
||||
|
||||
// Make a failing request (503 is retryable, so it will retry and track failures)
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { fail }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
|
||||
// Get stats after - should have failures from retry attempts
|
||||
statsAfter := poolMgr.GetConnectionStats()
|
||||
failuresAfter := statsAfter["connection_failures"].(int64)
|
||||
|
||||
suite.Greater(failuresAfter, failuresBefore,
|
||||
"Connection failures should increase after 5xx responses that trigger retries")
|
||||
|
||||
suite.T().Logf("Connection failures: before=%d, after=%d",
|
||||
failuresBefore, failuresAfter)
|
||||
})
|
||||
|
||||
suite.Run("stats_reflect_request_outcomes", func() {
|
||||
// This test verifies that connection stats properly reflect the
|
||||
// combination of successes and failures over multiple requests
|
||||
|
||||
// Start with a fresh server
|
||||
var requestCount atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := requestCount.Add(1)
|
||||
// First 2 requests succeed, rest fail
|
||||
if count <= 2 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"data":{"test":"success"}}`))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"message":"Error"}]}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg.Server.HostGraphQL = server.URL
|
||||
cfg.Client.ClientTimeout = 2
|
||||
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
|
||||
|
||||
// Initialize connection pool
|
||||
InitializeConnectionPool(cfg.Client.FastProxyClient)
|
||||
defer ShutdownConnectionPool()
|
||||
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
|
||||
|
||||
// Make 2 successful requests
|
||||
for i := 0; i < 2; i++ {
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query": "query { test }"}`))
|
||||
|
||||
ctx := suite.app.AcquireCtx(reqCtx)
|
||||
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
|
||||
suite.app.ReleaseCtx(ctx)
|
||||
}
|
||||
|
||||
// Get stats after successes
|
||||
statsAfterSuccess := poolMgr.GetConnectionStats()
|
||||
totalConnections := statsAfterSuccess["total_connections"].(int64)
|
||||
|
||||
suite.GreaterOrEqual(totalConnections, int64(2),
|
||||
"Should have at least 2 successful connections tracked")
|
||||
|
||||
suite.T().Logf("Total connections after 2 successful requests: %d", totalConnections)
|
||||
})
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
// Package libpack_logger provides structured JSON logging with configurable
|
||||
// log levels, caller information, and automatic sensitive data redaction.
|
||||
// Supports debug, info, warning, and error log levels.
|
||||
package libpack_logger
|
||||
|
||||
import (
|
||||
@@ -47,13 +50,13 @@ type Logger struct {
|
||||
|
||||
// LogMessage represents a log message with optional pairs.
|
||||
type LogMessage struct {
|
||||
Pairs map[string]interface{}
|
||||
Pairs map[string]any
|
||||
Message string
|
||||
}
|
||||
|
||||
// bufferPool is used to reuse bytes.Buffer for efficiency.
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
@@ -132,7 +135,7 @@ func (l *Logger) shouldLog(level int) bool {
|
||||
// log writes the log message with the given level.
|
||||
func (l *Logger) log(level int, m *LogMessage) {
|
||||
if m.Pairs == nil {
|
||||
m.Pairs = make(map[string]interface{})
|
||||
m.Pairs = make(map[string]any)
|
||||
}
|
||||
|
||||
m.Pairs[fieldNames["timestamp"]] = time.Now().Format(l.timeFormat)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestLogConcurrentAccess(t *testing.T) {
|
||||
defer wg.Done()
|
||||
msg := &LogMessage{
|
||||
Message: "concurrent log test",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"goroutine_id": id,
|
||||
},
|
||||
}
|
||||
|
||||
+5
-5
@@ -9,7 +9,7 @@ import (
|
||||
// LRUCacheEntry represents a cache entry with metadata
|
||||
type LRUCacheEntry struct {
|
||||
timestamp time.Time
|
||||
value interface{}
|
||||
value any
|
||||
element *list.Element
|
||||
key string
|
||||
size int64
|
||||
@@ -44,7 +44,7 @@ func NewLRUCache(maxEntries int, maxSize int64) *LRUCache {
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *LRUCache) Get(key string) (interface{}, bool) {
|
||||
func (c *LRUCache) Get(key string) (any, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -61,7 +61,7 @@ func (c *LRUCache) Get(key string) (interface{}, bool) {
|
||||
}
|
||||
|
||||
// Set adds or updates a value in the cache
|
||||
func (c *LRUCache) Set(key string, value interface{}, size int64) {
|
||||
func (c *LRUCache) Set(key string, value any, size int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -211,11 +211,11 @@ func (c *LRUCache) CleanupExpired(maxAge time.Duration) int {
|
||||
}
|
||||
|
||||
// GetStats returns cache statistics
|
||||
func (c *LRUCache) GetStats() map[string]interface{} {
|
||||
func (c *LRUCache) GetStats() map[string]any {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"entries": c.evictList.Len(),
|
||||
"size_bytes": c.currentSize,
|
||||
"max_entries": c.maxEntries,
|
||||
|
||||
@@ -131,8 +131,30 @@ func parseConfig() {
|
||||
c.Cache.CacheTTL = getDetailsFromEnv("CACHE_TTL", 60)
|
||||
c.Cache.CacheMaxMemorySize = getDetailsFromEnv("CACHE_MAX_MEMORY_SIZE", 100) // Default 100MB
|
||||
c.Cache.CacheMaxEntries = getDetailsFromEnv("CACHE_MAX_ENTRIES", 10000) // Default 10000 entries
|
||||
c.Cache.CacheUseLRU = getDetailsFromEnv("CACHE_USE_LRU", false) // Use LRU eviction algorithm
|
||||
// GraphQL query parsing cache - auto-calculate based on CPU cores if not set
|
||||
c.Cache.GraphQLQueryCacheSize = getDetailsFromEnv("GRAPHQL_QUERY_CACHE_SIZE", runtime.GOMAXPROCS(0)*250)
|
||||
|
||||
// SECURITY: Per-user cache isolation (enabled by default for security)
|
||||
// Set CACHE_PER_USER_DISABLED=true ONLY if you have a single-user application
|
||||
// or understand the security implications of shared cache across users
|
||||
c.Cache.PerUserCacheDisabled = getDetailsFromEnv("CACHE_PER_USER_DISABLED", false)
|
||||
|
||||
// Log warning if per-user caching is disabled
|
||||
if c.Cache.PerUserCacheDisabled {
|
||||
defer func() {
|
||||
if c.Logger != nil {
|
||||
c.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "⚠️ Per-user cache isolation is DISABLED - Users may see each other's cached data!",
|
||||
Pairs: map[string]any{
|
||||
"security_risk": "CRITICAL - Do not use in multi-user applications",
|
||||
"recommendation": "Remove CACHE_PER_USER_DISABLED or set it to false",
|
||||
},
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Redis cache
|
||||
c.Cache.CacheRedisEnable = getDetailsFromEnv("ENABLE_REDIS_CACHE", false)
|
||||
c.Cache.CacheRedisURL = getDetailsFromEnv("CACHE_REDIS_URL", "localhost:6379")
|
||||
@@ -171,7 +193,7 @@ func parseConfig() {
|
||||
if clientTimeout < 1 || clientTimeout > 3600 { // 1 second to 1 hour max
|
||||
c.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "Invalid client timeout, using default",
|
||||
Pairs: map[string]interface{}{"requested": clientTimeout, "default": 120},
|
||||
Pairs: map[string]any{"requested": clientTimeout, "default": 120},
|
||||
})
|
||||
clientTimeout = 120
|
||||
}
|
||||
@@ -183,7 +205,7 @@ func parseConfig() {
|
||||
if maxConns < 1 || maxConns > 10000 { // Reasonable bounds
|
||||
c.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "Invalid max connections per host, using default",
|
||||
Pairs: map[string]interface{}{"requested": maxConns, "default": 1024},
|
||||
Pairs: map[string]any{"requested": maxConns, "default": 1024},
|
||||
})
|
||||
maxConns = 1024
|
||||
}
|
||||
@@ -219,7 +241,7 @@ func parseConfig() {
|
||||
if c.Logger != nil {
|
||||
c.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "⚠️ TLS certificate verification is DISABLED - This is a security risk in production!",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"recommendation": "Enable TLS verification by removing CLIENT_DISABLE_TLS_VERIFY or setting it to false",
|
||||
},
|
||||
})
|
||||
@@ -239,14 +261,14 @@ func parseConfig() {
|
||||
if validatedPath, err := validateFilePath(bannedUsersFile); err != nil {
|
||||
c.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Invalid banned users file path, using default",
|
||||
Pairs: map[string]interface{}{"requested": bannedUsersFile, "error": err.Error()},
|
||||
Pairs: map[string]any{"requested": bannedUsersFile, "error": err.Error()},
|
||||
})
|
||||
c.Api.BannedUsersFile = "/go/src/app/banned_users.json"
|
||||
} else {
|
||||
c.Api.BannedUsersFile = validatedPath
|
||||
}
|
||||
c.Server.PurgeOnCrawl = getDetailsFromEnv("PURGE_METRICS_ON_CRAWL", false)
|
||||
c.Server.PurgeEvery = getDetailsFromEnv("PURGE_METRICS_ON_TIMER", 0)
|
||||
c.Server.PurgeEvery = getDetailsFromEnv("PURGE_METRICS_ON_TIMER", 1800) // Default: purge metrics every 30 minutes
|
||||
// Hasura event cleaner
|
||||
c.HasuraEventCleaner.Enable = getDetailsFromEnv("HASURA_EVENT_CLEANER", false)
|
||||
c.HasuraEventCleaner.ClearOlderThan = getDetailsFromEnv("HASURA_EVENT_CLEANER_OLDER_THAN", 1)
|
||||
@@ -309,12 +331,12 @@ func parseConfig() {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Failed to initialize tracing",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
} else {
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Tracing initialized",
|
||||
Pairs: map[string]interface{}{"endpoint": cfg.Tracing.Endpoint},
|
||||
Pairs: map[string]any{"endpoint": cfg.Tracing.Endpoint},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -324,7 +346,7 @@ func parseConfig() {
|
||||
if cfg.Cache.CacheRedisEnable {
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Initializing metrics aggregator for cluster mode",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"redis_url": cfg.Cache.CacheRedisURL,
|
||||
"redis_db": cfg.Cache.CacheRedisDB,
|
||||
},
|
||||
@@ -338,14 +360,14 @@ func parseConfig() {
|
||||
); err != nil {
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "FAILED to initialize metrics aggregator - cluster mode will not work",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "✓ Metrics aggregator successfully initialized",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": GetMetricsAggregator().GetInstanceID(),
|
||||
},
|
||||
})
|
||||
@@ -355,8 +377,9 @@ func parseConfig() {
|
||||
// Initialize cache if enabled
|
||||
if cfg.Cache.CacheEnable || cfg.Cache.CacheRedisEnable {
|
||||
cacheConfig := &libpack_cache.CacheConfig{
|
||||
Logger: cfg.Logger,
|
||||
TTL: cfg.Cache.CacheTTL,
|
||||
Logger: cfg.Logger,
|
||||
TTL: cfg.Cache.CacheTTL,
|
||||
PerUserCacheDisabled: cfg.Cache.PerUserCacheDisabled,
|
||||
}
|
||||
// Redis cache configurations
|
||||
if cfg.Cache.CacheRedisEnable {
|
||||
@@ -368,9 +391,16 @@ func parseConfig() {
|
||||
// Memory cache configurations
|
||||
cacheConfig.Memory.MaxMemorySize = int64(cfg.Cache.CacheMaxMemorySize) * 1024 * 1024 // Convert MB to bytes
|
||||
cacheConfig.Memory.MaxEntries = int64(cfg.Cache.CacheMaxEntries)
|
||||
cacheConfig.Memory.UseLRU = cfg.Cache.CacheUseLRU
|
||||
|
||||
cacheType := "standard"
|
||||
if cfg.Cache.CacheUseLRU {
|
||||
cacheType = "LRU"
|
||||
}
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Configuring memory cache with limits",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"type": cacheType,
|
||||
"max_memory_mb": cfg.Cache.CacheMaxMemorySize,
|
||||
"max_entries": cfg.Cache.CacheMaxEntries,
|
||||
},
|
||||
@@ -387,15 +417,7 @@ func parseConfig() {
|
||||
initCircuitBreaker(cfg)
|
||||
}
|
||||
|
||||
// Initialize retry budget
|
||||
if cfg.RetryBudget.Enable {
|
||||
retryBudgetConfig := RetryBudgetConfig{
|
||||
TokensPerSecond: cfg.RetryBudget.TokensPerSecond,
|
||||
MaxTokens: cfg.RetryBudget.MaxTokens,
|
||||
Enabled: cfg.RetryBudget.Enable,
|
||||
}
|
||||
InitializeRetryBudget(retryBudgetConfig, cfg.Logger)
|
||||
}
|
||||
// Note: Retry budget is initialized in main() with context for graceful shutdown
|
||||
|
||||
// Initialize request coalescer
|
||||
if cfg.RequestCoalescing.Enable {
|
||||
@@ -420,11 +442,7 @@ func parseConfig() {
|
||||
healthMgr.StartHealthChecking()
|
||||
}
|
||||
|
||||
// Initialize RPS tracker for real-time requests per second monitoring
|
||||
InitializeRPSTracker()
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "RPS tracker initialized",
|
||||
})
|
||||
// Note: RPS tracker is initialized in main() with context for graceful shutdown
|
||||
|
||||
// Load rate limit configuration with improved error handling
|
||||
if err := loadRatelimitConfig(); err != nil {
|
||||
@@ -432,7 +450,7 @@ func parseConfig() {
|
||||
detailedError := err.Error()
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Failed to start service due to rate limit configuration error",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": detailedError,
|
||||
},
|
||||
})
|
||||
@@ -462,6 +480,22 @@ func main() {
|
||||
// Initialize shutdown manager
|
||||
shutdownManager = NewShutdownManager(ctx)
|
||||
|
||||
// Initialize RPS tracker with context for graceful shutdown
|
||||
InitializeRPSTracker(ctx)
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "RPS tracker initialized",
|
||||
})
|
||||
|
||||
// Initialize retry budget with context for graceful shutdown
|
||||
if cfg.RetryBudget.Enable {
|
||||
retryBudgetConfig := RetryBudgetConfig{
|
||||
TokensPerSecond: cfg.RetryBudget.TokensPerSecond,
|
||||
MaxTokens: cfg.RetryBudget.MaxTokens,
|
||||
Enabled: cfg.RetryBudget.Enable,
|
||||
}
|
||||
InitializeRetryBudgetWithContext(ctx, retryBudgetConfig, cfg.Logger)
|
||||
}
|
||||
|
||||
// Create a wait group to manage goroutines
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -483,7 +517,7 @@ func main() {
|
||||
if err := enableApi(ctx); err != nil {
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "API server error",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -493,7 +527,7 @@ func main() {
|
||||
if err := enableHasuraEventCleaner(ctx); err != nil {
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Event cleaner error",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -533,7 +567,7 @@ func main() {
|
||||
// Start monitoring server
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Starting monitoring server...",
|
||||
Pairs: map[string]interface{}{"port": cfg.Server.PortMonitoring},
|
||||
Pairs: map[string]any{"port": cfg.Server.PortMonitoring},
|
||||
})
|
||||
|
||||
// Start monitoring server in a goroutine
|
||||
@@ -551,7 +585,7 @@ func main() {
|
||||
case err := <-monitoringErrCh:
|
||||
cfg.Logger.Critical(&libpack_logging.LogMessage{
|
||||
Message: "Failed to start monitoring server",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"port": cfg.Server.PortMonitoring,
|
||||
},
|
||||
@@ -566,7 +600,7 @@ func main() {
|
||||
startupTimeout := time.Duration(getDetailsFromEnv("BACKEND_STARTUP_TIMEOUT", 300)) * time.Second
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Waiting for GraphQL backend to be ready",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"timeout_seconds": int(startupTimeout.Seconds()),
|
||||
},
|
||||
})
|
||||
@@ -574,7 +608,7 @@ func main() {
|
||||
if err := healthMgr.WaitForBackendReady(startupTimeout); err != nil {
|
||||
cfg.Logger.Critical(&libpack_logging.LogMessage{
|
||||
Message: "GraphQL backend did not become ready in time",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"timeout": startupTimeout.String(),
|
||||
},
|
||||
@@ -589,7 +623,7 @@ func main() {
|
||||
// Start HTTP proxy
|
||||
cfg.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Starting HTTP proxy server...",
|
||||
Pairs: map[string]interface{}{"port": cfg.Server.PortGraphQL},
|
||||
Pairs: map[string]any{"port": cfg.Server.PortGraphQL},
|
||||
})
|
||||
|
||||
// Start HTTP proxy in a goroutine
|
||||
@@ -607,7 +641,7 @@ func main() {
|
||||
case err := <-proxyErrCh:
|
||||
cfg.Logger.Critical(&libpack_logging.LogMessage{
|
||||
Message: "Failed to start HTTP proxy server",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"port": cfg.Server.PortGraphQL,
|
||||
},
|
||||
@@ -636,7 +670,7 @@ func main() {
|
||||
if err := shutdownManager.Shutdown(30 * time.Second); err != nil {
|
||||
cfg.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Error during shutdown",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -717,7 +751,7 @@ func startCacheMemoryMonitoring(ctx context.Context) {
|
||||
if percentUsed > 80.0 {
|
||||
cfg.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "Memory cache usage is high",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"memory_usage_bytes": memoryUsage,
|
||||
"memory_limit_bytes": memoryLimit,
|
||||
"percent_used": percentUsed,
|
||||
|
||||
+2
-2
@@ -146,8 +146,8 @@ func (suite *Tests) Test_envVariableSetting() {
|
||||
|
||||
func (suite *Tests) Test_getDetailsFromEnv() {
|
||||
tests := []struct {
|
||||
defaultValue interface{}
|
||||
expected interface{}
|
||||
defaultValue any
|
||||
expected any
|
||||
name string
|
||||
key string
|
||||
envValue string
|
||||
|
||||
+83
-57
@@ -29,19 +29,19 @@ type MetricsAggregator struct {
|
||||
|
||||
// InstanceMetrics represents metrics for a single proxy instance
|
||||
type InstanceMetrics struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
UptimeSeconds float64 `json:"uptime_seconds"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Cache map[string]interface{} `json:"cache,omitempty"` // Full cache details including memory
|
||||
CacheSummary map[string]interface{} `json:"cache_summary,omitempty"` // Deprecated: kept for compatibility
|
||||
Health map[string]interface{} `json:"health"`
|
||||
CircuitBreaker map[string]interface{} `json:"circuit_breaker,omitempty"`
|
||||
RetryBudget map[string]interface{} `json:"retry_budget,omitempty"`
|
||||
Coalescing map[string]interface{} `json:"coalescing,omitempty"`
|
||||
WebSocketStats map[string]interface{} `json:"websocket,omitempty"`
|
||||
Connections map[string]interface{} `json:"connections,omitempty"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
UptimeSeconds float64 `json:"uptime_seconds"`
|
||||
Stats map[string]any `json:"stats"`
|
||||
Cache map[string]any `json:"cache,omitempty"` // Full cache details including memory
|
||||
CacheSummary map[string]any `json:"cache_summary,omitempty"` // Deprecated: kept for compatibility
|
||||
Health map[string]any `json:"health"`
|
||||
CircuitBreaker map[string]any `json:"circuit_breaker,omitempty"`
|
||||
RetryBudget map[string]any `json:"retry_budget,omitempty"`
|
||||
Coalescing map[string]any `json:"coalescing,omitempty"`
|
||||
WebSocketStats map[string]any `json:"websocket,omitempty"`
|
||||
Connections map[string]any `json:"connections,omitempty"`
|
||||
}
|
||||
|
||||
// AggregatedMetrics represents combined metrics from all instances
|
||||
@@ -49,7 +49,7 @@ type AggregatedMetrics struct {
|
||||
TotalInstances int `json:"total_instances"`
|
||||
HealthyInstances int `json:"healthy_instances"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
CombinedStats map[string]interface{} `json:"combined_stats"`
|
||||
CombinedStats map[string]any `json:"combined_stats"`
|
||||
Instances []InstanceMetrics `json:"instances"`
|
||||
PerInstanceStats map[string]InstanceMetrics `json:"per_instance_stats"`
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func InitializeMetricsAggregator(redisURL, redisPassword string, redisDB int, lo
|
||||
if logger != nil {
|
||||
logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "❌ CRITICAL: Redis connection test FAILED during initialization",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"redis_url": redisURL,
|
||||
"redis_db": redisDB,
|
||||
@@ -111,7 +111,7 @@ func InitializeMetricsAggregator(redisURL, redisPassword string, redisDB int, lo
|
||||
if logger != nil {
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "✓ Redis connection test PASSED",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"redis_url": redisURL,
|
||||
"redis_db": redisDB,
|
||||
},
|
||||
@@ -146,7 +146,7 @@ func InitializeMetricsAggregator(redisURL, redisPassword string, redisDB int, lo
|
||||
if logger != nil {
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Metrics aggregator initialized",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": instanceID,
|
||||
"redis_url": redisURL,
|
||||
"publish_key": aggregator.publishKey,
|
||||
@@ -199,7 +199,7 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Cannot publish metrics - global config not initialized yet",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": ma.instanceID,
|
||||
},
|
||||
})
|
||||
@@ -215,7 +215,7 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "gatherAllStats returned empty/nil result",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": ma.instanceID,
|
||||
},
|
||||
})
|
||||
@@ -238,11 +238,11 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
|
||||
// Extract specific sections - CRITICAL: we must set the correct structure
|
||||
// Stats should contain the inner stats object with requests, cache_summary, etc.
|
||||
if stats, ok := allStats["stats"].(map[string]interface{}); ok {
|
||||
if stats, ok := allStats["stats"].(map[string]any); ok {
|
||||
metrics.Stats = stats
|
||||
|
||||
// Also extract cache summary separately for easier access (deprecated but kept for compatibility)
|
||||
if cacheSummary, ok := stats["cache_summary"].(map[string]interface{}); ok {
|
||||
if cacheSummary, ok := stats["cache_summary"].(map[string]any); ok {
|
||||
metrics.CacheSummary = cacheSummary
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to extract stats from allStats - using empty stats",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": ma.instanceID,
|
||||
"allStats_keys": func() []string {
|
||||
keys := make([]string, 0, len(allStats))
|
||||
@@ -263,32 +263,32 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
},
|
||||
})
|
||||
}
|
||||
metrics.Stats = make(map[string]interface{})
|
||||
metrics.Stats = make(map[string]any)
|
||||
}
|
||||
|
||||
// Extract full cache details (includes memory usage)
|
||||
if cache, ok := allStats["cache"].(map[string]interface{}); ok {
|
||||
if cache, ok := allStats["cache"].(map[string]any); ok {
|
||||
metrics.Cache = cache
|
||||
}
|
||||
|
||||
if health, ok := allStats["health"].(map[string]interface{}); ok {
|
||||
if health, ok := allStats["health"].(map[string]any); ok {
|
||||
metrics.Health = health
|
||||
} else {
|
||||
metrics.Health = make(map[string]interface{})
|
||||
metrics.Health = make(map[string]any)
|
||||
}
|
||||
if cb, ok := allStats["circuit_breaker"].(map[string]interface{}); ok {
|
||||
if cb, ok := allStats["circuit_breaker"].(map[string]any); ok {
|
||||
metrics.CircuitBreaker = cb
|
||||
}
|
||||
if rb, ok := allStats["retry_budget"].(map[string]interface{}); ok {
|
||||
if rb, ok := allStats["retry_budget"].(map[string]any); ok {
|
||||
metrics.RetryBudget = rb
|
||||
}
|
||||
if coal, ok := allStats["coalescing"].(map[string]interface{}); ok {
|
||||
if coal, ok := allStats["coalescing"].(map[string]any); ok {
|
||||
metrics.Coalescing = coal
|
||||
}
|
||||
if ws, ok := allStats["websocket"].(map[string]interface{}); ok {
|
||||
if ws, ok := allStats["websocket"].(map[string]any); ok {
|
||||
metrics.WebSocketStats = ws
|
||||
}
|
||||
if conn, ok := allStats["connections"].(map[string]interface{}); ok {
|
||||
if conn, ok := allStats["connections"].(map[string]any); ok {
|
||||
metrics.Connections = conn
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to marshal metrics for Redis",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -321,7 +321,7 @@ func (ma *MetricsAggregator) publishMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "❌ CRITICAL: Failed to publish metrics to Redis - cluster mode will not work!",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"instance_id": ma.instanceID,
|
||||
"key": key,
|
||||
@@ -348,7 +348,7 @@ func (ma *MetricsAggregator) removeInstanceMetrics() {
|
||||
if err != nil && ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Failed to remove instance metrics from Redis during shutdown",
|
||||
Pairs: map[string]interface{}{"instance_id": ma.instanceID, "error": err.Error()},
|
||||
Pairs: map[string]any{"instance_id": ma.instanceID, "error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (ma *MetricsAggregator) removeInstanceMetrics() {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Removed instance metrics from Redis",
|
||||
Pairs: map[string]interface{}{"instance_id": ma.instanceID},
|
||||
Pairs: map[string]any{"instance_id": ma.instanceID},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
TotalInstances: 0,
|
||||
HealthyInstances: 0,
|
||||
LastUpdate: time.Now(),
|
||||
CombinedStats: make(map[string]interface{}),
|
||||
CombinedStats: make(map[string]any),
|
||||
Instances: []InstanceMetrics{},
|
||||
PerInstanceStats: make(map[string]InstanceMetrics),
|
||||
}, nil
|
||||
@@ -391,7 +391,7 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, instanceID)
|
||||
cmds[i] = pipe.Get(ctx, key)
|
||||
}
|
||||
pipe.Exec(ctx)
|
||||
_, _ = pipe.Exec(ctx) // Errors handled per-command below
|
||||
|
||||
// Parse metrics
|
||||
instances := make([]InstanceMetrics, 0, len(instanceIDs))
|
||||
@@ -422,7 +422,7 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
if ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Failed to unmarshal instance metrics",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
continue
|
||||
@@ -440,7 +440,7 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
if ma.logger != nil {
|
||||
ma.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Removed inactive instance",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": instID,
|
||||
"inactive_seconds": age.Seconds(),
|
||||
},
|
||||
@@ -463,7 +463,7 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
if ma.logger != nil && (staleCount > 0 || errorCount > 0) {
|
||||
ma.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Cleaned up stale instance IDs from Redis",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"total_in_set": len(instanceIDs),
|
||||
"valid_instances": len(instances),
|
||||
"stale_cleaned": staleCount,
|
||||
@@ -486,14 +486,14 @@ func (ma *MetricsAggregator) GetAggregatedMetrics() (*AggregatedMetrics, error)
|
||||
}
|
||||
|
||||
// aggregateStats combines statistics from multiple instances
|
||||
func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[string]interface{} {
|
||||
func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[string]any {
|
||||
if len(instances) == 0 {
|
||||
if ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "No instances to aggregate",
|
||||
})
|
||||
}
|
||||
return make(map[string]interface{})
|
||||
return make(map[string]any)
|
||||
}
|
||||
|
||||
// Initialize aggregated values
|
||||
@@ -506,6 +506,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
totalCacheMisses int64
|
||||
totalCachedQueries int64
|
||||
totalMemoryUsageMB float64
|
||||
hasValidMemoryStats bool // Track if any instance has valid memory stats
|
||||
totalCurrentRPS float64
|
||||
totalAvgRPS float64
|
||||
totalActiveConnections int64
|
||||
@@ -518,7 +519,10 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
totalRetryAllowed int64
|
||||
totalRetryDenied int64
|
||||
totalRetryAttempts int64
|
||||
totalCurrentTokens int64
|
||||
totalMaxTokens int64
|
||||
retryBudgetEnabled = false
|
||||
retryTokensPerSec float64 // Use max tokens_per_sec from any instance
|
||||
|
||||
// Circuit breaker stats
|
||||
cbOpenCount int
|
||||
@@ -538,7 +542,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
if ma.logger != nil {
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Instance has nil Stats",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": instance.InstanceID,
|
||||
"index": idx,
|
||||
},
|
||||
@@ -547,7 +551,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
continue
|
||||
}
|
||||
|
||||
if stats, ok := instance.Stats["requests"].(map[string]interface{}); ok {
|
||||
if stats, ok := instance.Stats["requests"].(map[string]any); ok {
|
||||
if total, ok := stats["total"].(float64); ok {
|
||||
totalRequests += int64(total)
|
||||
}
|
||||
@@ -575,7 +579,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
}
|
||||
ma.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Instance Stats missing 'requests' key",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"instance_id": instance.InstanceID,
|
||||
"stats_keys": keys,
|
||||
"index": idx,
|
||||
@@ -598,9 +602,11 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
}
|
||||
|
||||
// Aggregate memory usage from full cache details
|
||||
// Skip -1 values which indicate Redis cache (memory tracking not available)
|
||||
if len(instance.Cache) > 0 {
|
||||
if memMB, ok := instance.Cache["memory_usage_mb"].(float64); ok {
|
||||
if memMB, ok := instance.Cache["memory_usage_mb"].(float64); ok && memMB >= 0 {
|
||||
totalMemoryUsageMB += memMB
|
||||
hasValidMemoryStats = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +648,17 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
if attempts, ok := instance.RetryBudget["total_attempts"].(float64); ok {
|
||||
totalRetryAttempts += int64(attempts)
|
||||
}
|
||||
if currentTokens, ok := instance.RetryBudget["current_tokens"].(float64); ok {
|
||||
totalCurrentTokens += int64(currentTokens)
|
||||
}
|
||||
if maxTokens, ok := instance.RetryBudget["max_tokens"].(float64); ok {
|
||||
totalMaxTokens += int64(maxTokens)
|
||||
}
|
||||
if tokensPerSec, ok := instance.RetryBudget["tokens_per_sec"].(float64); ok {
|
||||
if tokensPerSec > retryTokensPerSec {
|
||||
retryTokensPerSec = tokensPerSec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate circuit breaker stats
|
||||
@@ -698,11 +715,11 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
result := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": len(instances),
|
||||
"cluster_uptime": oldestUptime,
|
||||
"requests": map[string]interface{}{
|
||||
"requests": map[string]any{
|
||||
"total": totalRequests,
|
||||
"succeeded": totalSucceeded,
|
||||
"failed": totalFailed,
|
||||
@@ -711,36 +728,45 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
"current_requests_per_second": totalCurrentRPS,
|
||||
"avg_requests_per_second": totalAvgRPS,
|
||||
},
|
||||
"cache_summary": map[string]interface{}{
|
||||
"cache_summary": map[string]any{
|
||||
"hits": totalCacheHits,
|
||||
"misses": totalCacheMisses,
|
||||
"hit_rate_pct": cacheHitRate,
|
||||
"total_cached": totalCachedQueries,
|
||||
},
|
||||
"memory": map[string]interface{}{
|
||||
"total_usage_mb": totalMemoryUsageMB,
|
||||
"memory": map[string]any{
|
||||
"total_usage_mb": func() float64 {
|
||||
if hasValidMemoryStats {
|
||||
return totalMemoryUsageMB
|
||||
}
|
||||
return -1
|
||||
}(),
|
||||
"available": hasValidMemoryStats,
|
||||
},
|
||||
"connections": map[string]interface{}{
|
||||
"connections": map[string]any{
|
||||
"total_active": totalActiveConnections,
|
||||
},
|
||||
"websocket": map[string]interface{}{
|
||||
"websocket": map[string]any{
|
||||
"total_connections": totalWSConnections,
|
||||
},
|
||||
"coalescing": map[string]interface{}{
|
||||
"coalescing": map[string]any{
|
||||
"enabled": len(instances) > 0, // enabled if we have instances with data
|
||||
"total_coalesced_requests": totalCoalescedRequests,
|
||||
"total_primary_requests": totalPrimaryRequests,
|
||||
"backend_savings_pct": backendSavings,
|
||||
"coalescing_rate_pct": backendSavings,
|
||||
},
|
||||
"retry_budget": map[string]interface{}{
|
||||
"retry_budget": map[string]any{
|
||||
"enabled": retryBudgetEnabled,
|
||||
"allowed_retries": totalRetryAllowed,
|
||||
"denied_retries": totalRetryDenied,
|
||||
"total_attempts": totalRetryAttempts,
|
||||
"denial_rate_pct": retryDenialRate,
|
||||
"current_tokens": totalCurrentTokens,
|
||||
"max_tokens": totalMaxTokens,
|
||||
"tokens_per_sec": retryTokensPerSec,
|
||||
},
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"circuit_breaker": map[string]any{
|
||||
"enabled": circuitBreakerEnabled,
|
||||
"state": cbState,
|
||||
"instances_open": cbOpenCount,
|
||||
@@ -762,7 +788,7 @@ func (ma *MetricsAggregator) Shutdown() {
|
||||
}
|
||||
|
||||
if ma.redisClient != nil {
|
||||
ma.redisClient.Close()
|
||||
_ = ma.redisClient.Close() // Best-effort cleanup
|
||||
}
|
||||
|
||||
if ma.logger != nil {
|
||||
|
||||
@@ -9,7 +9,3 @@ func (ms *MetricsSetup) RegisterDefaultMetrics() {
|
||||
ms.RegisterMetricsCounter(MetricsCacheMiss, nil)
|
||||
ms.RegisterMetricsCounter(MetricsQueriesCached, nil)
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RegisterGoMetrics() {
|
||||
// TODO: metrics.WriteProcessMetrics(ms.metrics_set)
|
||||
}
|
||||
|
||||
+107
-17
@@ -68,26 +68,74 @@ func ensureDefaultLabels(labels *map[string]string, podName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeLabelValue removes or replaces characters that are invalid in metric labels
|
||||
// This includes null bytes, newlines, carriage returns, quotes, and backslashes
|
||||
func sanitizeLabelValue(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(value))
|
||||
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '\x00': // null byte
|
||||
continue // Skip null bytes entirely
|
||||
case '\n', '\r', '\t': // newlines, carriage returns, tabs
|
||||
buf.WriteByte(' ') // Replace with space
|
||||
case '"', '\\': // quotes and backslashes need escaping
|
||||
buf.WriteByte('\\')
|
||||
buf.WriteRune(r)
|
||||
default:
|
||||
// Only allow printable ASCII and common unicode characters
|
||||
if unicode.IsPrint(r) {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func appendSortedLabels(buf *bytes.Buffer, labels map[string]string) {
|
||||
if len(labels) == 0 {
|
||||
// Add defer/recover to prevent panics from crashing the application
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log the panic but don't crash
|
||||
fmt.Fprintf(os.Stderr, "Recovered from panic in appendSortedLabels: %v\n", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if len(labels) == 0 || buf == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a snapshot to avoid concurrent access issues
|
||||
labelsCopy := make(map[string]string, len(labels))
|
||||
for k, v := range labels {
|
||||
labelsCopy[k] = v
|
||||
if k == "" {
|
||||
continue // Skip empty keys
|
||||
}
|
||||
// Sanitize the label value to remove null bytes and other invalid characters
|
||||
labelsCopy[k] = sanitizeLabelValue(v)
|
||||
}
|
||||
|
||||
if len(labelsCopy) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
keys := getSortedKeys(labelsCopy)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
if v, ok := labelsCopy[k]; ok {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
buf.WriteString(k)
|
||||
buf.WriteString(`="`)
|
||||
buf.WriteString(v)
|
||||
buf.WriteByte('"')
|
||||
}
|
||||
buf.WriteString(k)
|
||||
buf.WriteString(`="`)
|
||||
buf.WriteString(labelsCopy[k])
|
||||
buf.WriteByte('"')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +165,15 @@ func getSortedKeys(labels map[string]string) []string {
|
||||
}
|
||||
|
||||
func labelsToString(labels map[string]string) string {
|
||||
if labels == nil {
|
||||
// Add defer/recover to prevent panics from crashing the application
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log the panic but don't crash
|
||||
fmt.Fprintf(os.Stderr, "Recovered from panic in labelsToString: %v\n", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -126,17 +182,34 @@ func labelsToString(labels map[string]string) string {
|
||||
values := make(map[string]string, len(labels))
|
||||
|
||||
for k, v := range labels {
|
||||
if k == "" {
|
||||
continue // Skip empty keys
|
||||
}
|
||||
keys = append(keys, k)
|
||||
values[k] = v
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
// Pre-allocate the builder with estimated capacity to avoid reallocation
|
||||
var sb strings.Builder
|
||||
estimatedSize := 0
|
||||
for _, k := range keys {
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(values[k])
|
||||
sb.WriteByte(';')
|
||||
estimatedSize += len(k) + len(values[k]) + 2 // key + value + '=' + ';'
|
||||
}
|
||||
sb.Grow(estimatedSize)
|
||||
|
||||
for _, k := range keys {
|
||||
if v, ok := values[k]; ok {
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
sb.WriteByte(';')
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -186,6 +259,14 @@ func is_special_rune(r rune) bool {
|
||||
}
|
||||
|
||||
func compile_metrics_with_labels(name string, labels map[string]string) string {
|
||||
// Add defer/recover to prevent panics from crashing the application
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log the panic but don't crash
|
||||
fmt.Fprintf(os.Stderr, "Recovered from panic in compile_metrics_with_labels: %v\n", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
buf.WriteString(name)
|
||||
@@ -197,16 +278,25 @@ func compile_metrics_with_labels(name string, labels map[string]string) string {
|
||||
// Create a snapshot to avoid concurrent access issues
|
||||
labelsCopy := make(map[string]string, len(labels))
|
||||
for k, v := range labels {
|
||||
if k == "" {
|
||||
continue // Skip empty keys
|
||||
}
|
||||
labelsCopy[k] = v
|
||||
}
|
||||
|
||||
if len(labelsCopy) == 0 {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
keys := getSortedKeys(labelsCopy)
|
||||
|
||||
for _, k := range keys {
|
||||
buf.WriteByte('_')
|
||||
buf.WriteString(k)
|
||||
buf.WriteByte('_')
|
||||
buf.WriteString(labelsCopy[k])
|
||||
if v, ok := labelsCopy[k]; ok {
|
||||
buf.WriteByte('_')
|
||||
buf.WriteString(k)
|
||||
buf.WriteByte('_')
|
||||
buf.WriteString(v)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Package libpack_monitoring provides Prometheus-compatible metrics collection
|
||||
// and exposure using VictoriaMetrics. Supports counters, gauges, histograms,
|
||||
// and custom metrics with labels.
|
||||
package libpack_monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -17,6 +21,8 @@ type MetricsSetup struct {
|
||||
metrics_set_custom *metrics.Set
|
||||
ic *InitConfig
|
||||
metrics_prefix string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var log = libpack_logger.New().SetMinLogLevel(libpack_logger.LEVEL_INFO)
|
||||
@@ -27,10 +33,18 @@ type InitConfig struct {
|
||||
}
|
||||
|
||||
func NewMonitoring(ic *InitConfig) *MetricsSetup {
|
||||
return NewMonitoringWithContext(context.Background(), ic)
|
||||
}
|
||||
|
||||
// NewMonitoringWithContext creates a new monitoring instance with context for graceful shutdown
|
||||
func NewMonitoringWithContext(ctx context.Context, ic *InitConfig) *MetricsSetup {
|
||||
monCtx, cancel := context.WithCancel(ctx)
|
||||
ms := &MetricsSetup{
|
||||
ic: ic,
|
||||
metrics_set: metrics.NewSet(),
|
||||
metrics_set_custom: metrics.NewSet(),
|
||||
ctx: monCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
if flag.Lookup("test.v") == nil {
|
||||
@@ -39,8 +53,14 @@ func NewMonitoring(ic *InitConfig) *MetricsSetup {
|
||||
if ic.PurgeEvery > 0 {
|
||||
ticker := time.NewTicker(time.Duration(ic.PurgeEvery) * time.Second)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
ms.PurgeMetrics()
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ms.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ms.PurgeMetrics()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -49,6 +69,13 @@ func NewMonitoring(ic *InitConfig) *MetricsSetup {
|
||||
return ms
|
||||
}
|
||||
|
||||
// Shutdown stops the monitoring goroutines
|
||||
func (ms *MetricsSetup) Shutdown() {
|
||||
if ms.cancel != nil {
|
||||
ms.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) startPrometheusEndpoint() {
|
||||
app := fiber.New(fiber.Config{
|
||||
DisableStartupMessage: true,
|
||||
@@ -58,7 +85,7 @@ func (ms *MetricsSetup) startPrometheusEndpoint() {
|
||||
if err := app.Listen(fmt.Sprintf(":%d", envutil.GetInt("MONITORING_PORT", 9393))); err != nil {
|
||||
log.Critical(&libpack_logger.LogMessage{
|
||||
Message: "Can't start the MONITORING service",
|
||||
Pairs: map[string]interface{}{"error": err},
|
||||
Pairs: map[string]any{"error": err},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,7 +112,7 @@ func (ms *MetricsSetup) RegisterMetricsGauge(metric_name string, labels map[stri
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsGauge() error - invalid metric name",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "metric_name": metric_name},
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy gauge instead of nil to prevent panics
|
||||
return &metrics.Gauge{}
|
||||
@@ -95,11 +122,25 @@ func (ms *MetricsSetup) RegisterMetricsGauge(metric_name string, labels map[stri
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterMetricsGaugeFunc registers a gauge with a callback function that is called on every scrape
|
||||
// This is useful for metrics that need to return a dynamic value
|
||||
func (ms *MetricsSetup) RegisterMetricsGaugeFunc(metric_name string, labels map[string]string, fn func() float64) *metrics.Gauge {
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsGaugeFunc() error - invalid metric name",
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy gauge instead of nil to prevent panics
|
||||
return &metrics.Gauge{}
|
||||
}
|
||||
return ms.metrics_set_custom.GetOrCreateGauge(ms.get_metrics_name(metric_name, labels), fn)
|
||||
}
|
||||
|
||||
func (ms *MetricsSetup) RegisterMetricsCounter(metric_name string, labels map[string]string) *metrics.Counter {
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsCounter() error - invalid metric name",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "metric_name": metric_name},
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy counter instead of nil to prevent panics
|
||||
return &metrics.Counter{}
|
||||
@@ -114,7 +155,7 @@ func (ms *MetricsSetup) RegisterFloatCounter(metric_name string, labels map[stri
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterFloatCounter() error - invalid metric name",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "metric_name": metric_name},
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy float counter instead of nil to prevent panics
|
||||
return &metrics.FloatCounter{}
|
||||
@@ -126,7 +167,7 @@ func (ms *MetricsSetup) RegisterMetricsSummary(metric_name string, labels map[st
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsSummary() error - invalid metric name",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "metric_name": metric_name},
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy summary instead of nil to prevent panics
|
||||
return &metrics.Summary{}
|
||||
@@ -138,7 +179,7 @@ func (ms *MetricsSetup) RegisterMetricsHistogram(metric_name string, labels map[
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsHistogram() error - invalid metric name",
|
||||
Pairs: map[string]interface{}{"error": err.Error(), "metric_name": metric_name},
|
||||
Pairs: map[string]any{"error": err.Error(), "metric_name": metric_name},
|
||||
})
|
||||
// Return a dummy histogram instead of nil to prevent panics
|
||||
return &metrics.Histogram{}
|
||||
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
// Package pools provides memory-efficient buffer and gzip reader pools
|
||||
// for reducing allocations in high-throughput request processing.
|
||||
// Buffers are automatically sized and recycled to minimize GC pressure.
|
||||
package pools
|
||||
|
||||
import (
|
||||
@@ -16,21 +19,21 @@ const (
|
||||
|
||||
// bufferPool is the global pool for reusable buffers
|
||||
var bufferPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return bytes.NewBuffer(make([]byte, 0, InitialBufferSize))
|
||||
},
|
||||
}
|
||||
|
||||
// gzipWriterPool is the global pool for reusable gzip writers
|
||||
var gzipWriterPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
}
|
||||
|
||||
// gzipReaderPool is the global pool for reusable gzip readers
|
||||
var gzipReaderPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
New: func() any {
|
||||
return new(gzip.Reader)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
@@ -90,7 +88,7 @@ func initCircuitBreaker(config *config) {
|
||||
|
||||
config.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Circuit breaker initialized",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"max_failures": config.CircuitBreaker.MaxFailures,
|
||||
"timeout_seconds": config.CircuitBreaker.Timeout,
|
||||
"max_half_open_reqs": config.CircuitBreaker.MaxRequestsInHalfOpen,
|
||||
@@ -105,7 +103,7 @@ func createTripFunc(config *config) func(counts gobreaker.Counts) bool {
|
||||
if counts.ConsecutiveFailures >= safeUint32(config.CircuitBreaker.MaxFailures) {
|
||||
config.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Circuit breaker tripped due to consecutive failures",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"consecutive_failures": counts.ConsecutiveFailures,
|
||||
"max_failures": config.CircuitBreaker.MaxFailures,
|
||||
"total_requests": counts.Requests,
|
||||
@@ -122,7 +120,7 @@ func createTripFunc(config *config) func(counts gobreaker.Counts) bool {
|
||||
if failureRatio >= config.CircuitBreaker.FailureRatio {
|
||||
config.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Circuit breaker tripped due to failure ratio",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"failure_ratio": failureRatio,
|
||||
"threshold": config.CircuitBreaker.FailureRatio,
|
||||
"total_failures": counts.TotalFailures,
|
||||
@@ -162,7 +160,7 @@ func createStateChangeFunc(config *config) func(name string, from gobreaker.Stat
|
||||
// Log state change
|
||||
config.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Circuit breaker state changed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"from": from.String(),
|
||||
"to": to.String(),
|
||||
"name": name,
|
||||
@@ -315,7 +313,7 @@ func setupTracing(c *fiber.Ctx) context.Context {
|
||||
if err != nil {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Failed to parse trace header",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
} else if spanCtx, err := tracer.ExtractSpanContext(spanInfo); err == nil {
|
||||
ctx = trace.ContextWithSpanContext(ctx, spanCtx)
|
||||
@@ -325,18 +323,74 @@ func setupTracing(c *fiber.Ctx) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// performProxyRequest executes the proxy request with retries and circuit breaker
|
||||
// performProxyRequest executes the proxy request with retries, circuit breaker, and request coalescing
|
||||
func performProxyRequest(c *fiber.Ctx, proxyURL string) error {
|
||||
// Extract user context for cache key (needed for coalescing and circuit breaker fallback)
|
||||
userID, userRole := extractUserInfo(c)
|
||||
|
||||
// Calculate cache key - includes user context for security
|
||||
// This key is used for both request coalescing and cache fallback
|
||||
cacheKey := libpack_cache.CalculateHash(c, userID, userRole)
|
||||
|
||||
// Check if request coalescing is enabled
|
||||
rc := GetRequestCoalescer()
|
||||
if rc != nil && cfg.RequestCoalescing.Enable {
|
||||
// Use request coalescing to deduplicate identical concurrent requests
|
||||
response, err := rc.Do(cacheKey, func() (*CoalescedResponse, error) {
|
||||
// Execute the actual proxy request
|
||||
proxyErr := performProxyRequestCore(c, proxyURL, cacheKey)
|
||||
|
||||
// Capture the response for coalescing
|
||||
if proxyErr != nil {
|
||||
return &CoalescedResponse{
|
||||
Err: proxyErr,
|
||||
StatusCode: c.Response().StatusCode(),
|
||||
}, proxyErr
|
||||
}
|
||||
|
||||
return &CoalescedResponse{
|
||||
Body: c.Response().Body(),
|
||||
StatusCode: c.Response().StatusCode(),
|
||||
Headers: make(map[string]string),
|
||||
}, nil
|
||||
})
|
||||
|
||||
// Check for error from rc.Do (though it typically returns nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for error stored in the response (for coalesced requests)
|
||||
if response != nil && response.Err != nil {
|
||||
return response.Err
|
||||
}
|
||||
|
||||
// For coalesced requests (not the primary), we need to copy the response
|
||||
if response != nil && response.Body != nil && len(response.Body) > 0 {
|
||||
// Only set response if this is a coalesced request (body would be empty otherwise)
|
||||
if len(c.Response().Body()) == 0 {
|
||||
c.Response().SetStatusCode(response.StatusCode)
|
||||
c.Response().SetBody(response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// No coalescing - execute directly
|
||||
return performProxyRequestCore(c, proxyURL, cacheKey)
|
||||
}
|
||||
|
||||
// performProxyRequestCore executes the proxy request with retries and circuit breaker
|
||||
// This is the core implementation used by both direct calls and coalesced requests
|
||||
func performProxyRequestCore(c *fiber.Ctx, proxyURL string, cacheKey string) error {
|
||||
// If circuit breaker is not enabled, use the original method
|
||||
if !cfg.CircuitBreaker.Enable || cb == nil {
|
||||
return performProxyRequestWithRetries(c, proxyURL)
|
||||
}
|
||||
|
||||
// Calculate cache key for potential fallback
|
||||
cacheKey := libpack_cache.CalculateHash(c)
|
||||
|
||||
// Execute request through circuit breaker
|
||||
_, err := cb.Execute(func() (interface{}, error) {
|
||||
_, err := cb.Execute(func() (any, error) {
|
||||
// Execute the request with retries
|
||||
err := performProxyRequestWithRetries(c, proxyURL)
|
||||
// Check if the error or status code should trip the circuit breaker
|
||||
@@ -344,7 +398,7 @@ func performProxyRequest(c *fiber.Ctx, proxyURL string) error {
|
||||
// Log error that could potentially trip the circuit
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Error in circuit-protected request",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -398,24 +452,41 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
|
||||
return retry.Unrecoverable(fmt.Errorf("fiber context became nil during retry"))
|
||||
}
|
||||
|
||||
// Get connection pool manager for stats tracking
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
|
||||
// Execute the proxy request
|
||||
if err := doProxyRequestWithTimeout(c, proxyURL, cfg.Client.FastProxyClient); err != nil {
|
||||
proxyErr := doProxyRequestWithTimeout(c, proxyURL, cfg.Client.FastProxyClient)
|
||||
if proxyErr != nil {
|
||||
// Check if this is a connection error
|
||||
if isConnectionError(err) {
|
||||
if isConnectionError(proxyErr) {
|
||||
notifyHealthManager(false)
|
||||
return err // Connection errors are retryable
|
||||
// Track connection failure
|
||||
if poolMgr != nil {
|
||||
poolMgr.RecordConnectionFailure()
|
||||
}
|
||||
return proxyErr // Connection errors are retryable
|
||||
}
|
||||
|
||||
// Check if this is a timeout error - don't retry timeouts
|
||||
if isTimeoutError(err) {
|
||||
return retry.Unrecoverable(err)
|
||||
if isTimeoutError(proxyErr) {
|
||||
return retry.Unrecoverable(proxyErr)
|
||||
}
|
||||
return err
|
||||
|
||||
// Check if this is a retryable HTTP error (e.g., 503)
|
||||
// These indicate the server responded but with an error status
|
||||
if strings.Contains(proxyErr.Error(), "non-200 response") {
|
||||
// Track as a failure for retryable HTTP errors
|
||||
if poolMgr != nil {
|
||||
poolMgr.RecordConnectionFailure()
|
||||
}
|
||||
}
|
||||
return proxyErr
|
||||
}
|
||||
|
||||
// Safety check before accessing response
|
||||
if c == nil || c.Response() == nil {
|
||||
return retry.Unrecoverable(fmt.Errorf("fiber context or response became nil"))
|
||||
// Safety check before accessing response (c is already validated at function entry)
|
||||
if c.Response() == nil {
|
||||
return retry.Unrecoverable(fmt.Errorf("fiber response became nil"))
|
||||
}
|
||||
|
||||
// Check status code and determine retry strategy
|
||||
@@ -425,10 +496,18 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
|
||||
if err == nil {
|
||||
// Success case
|
||||
notifyHealthManager(true)
|
||||
// Track successful connection
|
||||
if poolMgr != nil {
|
||||
poolMgr.RecordConnectionSuccess()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldRetry {
|
||||
// Track connection failure for retryable errors (5xx, etc)
|
||||
if poolMgr != nil {
|
||||
poolMgr.RecordConnectionFailure()
|
||||
}
|
||||
return err // Retryable error
|
||||
}
|
||||
|
||||
@@ -470,7 +549,7 @@ func performProxyRequestWithEnhancedRetries(c *fiber.Ctx, proxyURL string, backe
|
||||
retry.OnRetry(func(n uint, err error) {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Retrying the request",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"attempt": n + 1,
|
||||
"max_attempts": attempts,
|
||||
@@ -485,31 +564,51 @@ func performProxyRequestWithEnhancedRetries(c *fiber.Ctx, proxyURL string, backe
|
||||
retry.LastErrorOnly(true),
|
||||
retry.RetryIf(func(err error) bool {
|
||||
// Don't retry if context is cancelled or context is nil
|
||||
defer func() {
|
||||
// Recover from any panic when accessing context
|
||||
if r := recover(); r != nil {
|
||||
// If we panic, don't retry
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to safely access the context
|
||||
ctx := c.Context()
|
||||
if ctx == nil {
|
||||
// Safely check if context is done/cancelled
|
||||
// Note: fasthttp.RequestCtx.Done() can panic if not properly initialized
|
||||
// If we panic, don't retry (maintains backward compatibility with test behavior)
|
||||
shouldRetry := true
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// If we panic accessing context, don't retry
|
||||
// This typically happens in test scenarios with mock contexts
|
||||
shouldRetry = false
|
||||
}
|
||||
}()
|
||||
ctx := c.Context()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shouldRetry = false
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
if !shouldRetry {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if context is done/cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
// Check retry budget before allowing retry
|
||||
if rb := GetRetryBudget(); rb != nil {
|
||||
if !rb.AllowRetry() {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Retry denied by budget",
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -593,7 +692,7 @@ func handleCircuitOpenGracefulDegradation(c *fiber.Ctx, cacheKey string) error {
|
||||
if cachedResponse := libpack_cache.CacheLookup(cacheKey); cachedResponse != nil {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Circuit open - serving from cache",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
},
|
||||
})
|
||||
@@ -613,7 +712,7 @@ func handleCircuitOpenGracefulDegradation(c *fiber.Ctx, cacheKey string) error {
|
||||
// No cached response available - provide helpful error response
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Circuit open - no cached response available",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
},
|
||||
})
|
||||
@@ -669,7 +768,7 @@ func handleGzippedResponse(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to create gzip reader",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -687,7 +786,7 @@ func handleGzippedResponse(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to decompress response",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -701,157 +800,6 @@ func handleGzippedResponse(c *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeForLogging removes sensitive data from request/response bodies before logging
|
||||
func sanitizeForLogging(body []byte, contentType string) string {
|
||||
// List of sensitive field patterns to redact
|
||||
sensitiveFields := []string{
|
||||
"password", "passwd", "pwd",
|
||||
"token", "api_key", "apikey", "api-key",
|
||||
"secret", "private_key", "privatekey", "private-key",
|
||||
"authorization", "auth", "bearer",
|
||||
"session", "sessionid", "session_id", "cookie",
|
||||
"ssn", "social_security",
|
||||
"credit_card", "card_number", "cardnumber", "cvv", "cvc",
|
||||
"email", "phone", "address",
|
||||
}
|
||||
|
||||
// Try to parse as JSON if content type suggests it
|
||||
if strings.Contains(strings.ToLower(contentType), "json") {
|
||||
var data map[string]interface{}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber() // Preserve number precision and type
|
||||
if err := decoder.Decode(&data); err == nil {
|
||||
redactSensitiveFields(data, sensitiveFields)
|
||||
sanitized, _ := json.Marshal(data)
|
||||
return string(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-JSON or failed parsing, truncate to prevent logging large bodies
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 1000 {
|
||||
return bodyStr[:1000] + "... [truncated]"
|
||||
}
|
||||
|
||||
// For small non-JSON bodies, do basic string replacement
|
||||
for _, field := range sensitiveFields {
|
||||
// Simple pattern matching for key-value pairs
|
||||
bodyStr = redactPatternInString(bodyStr, field)
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
}
|
||||
|
||||
// redactSensitiveFields recursively redacts sensitive fields in a map
|
||||
func redactSensitiveFields(data map[string]interface{}, fields []string) {
|
||||
for key, value := range data {
|
||||
keyLower := strings.ToLower(key)
|
||||
// Check if the key matches any sensitive field
|
||||
for _, field := range fields {
|
||||
if strings.Contains(keyLower, field) {
|
||||
data[key] = "[REDACTED]"
|
||||
break
|
||||
}
|
||||
}
|
||||
// Recurse for nested objects
|
||||
if nested, ok := value.(map[string]interface{}); ok {
|
||||
redactSensitiveFields(nested, fields)
|
||||
}
|
||||
// Handle arrays of objects
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if nestedItem, ok := item.(map[string]interface{}); ok {
|
||||
redactSensitiveFields(nestedItem, fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redactPatternInString performs basic pattern redaction in strings
|
||||
func redactPatternInString(text string, pattern string) string {
|
||||
// Use proper regex to capture and redact complete sensitive values
|
||||
// Order matters: process most specific patterns first
|
||||
|
||||
// 1. JSON pattern: "field":"value" → "field":"[REDACTED]"
|
||||
jsonPattern := regexp.MustCompile(`(?i)"` + regexp.QuoteMeta(pattern) + `"\s*:\s*"[^"]*"`)
|
||||
text = jsonPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return regexp.MustCompile(`:\s*"[^"]*"`).ReplaceAllString(match, `:"[REDACTED]"`)
|
||||
})
|
||||
|
||||
// 2. XML pattern: <field>value</field> → <field>[REDACTED]</field>
|
||||
xmlPattern := regexp.MustCompile(`(?i)<` + regexp.QuoteMeta(pattern) + `>[^<]*</` + regexp.QuoteMeta(pattern) + `>`)
|
||||
xmlMatched := xmlPattern.MatchString(text)
|
||||
text = xmlPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return regexp.MustCompile(`>[^<]*<`).ReplaceAllString(match, ">[REDACTED]<")
|
||||
})
|
||||
|
||||
// If XML pattern was matched, also add a standardized redaction marker for test compatibility
|
||||
if xmlMatched {
|
||||
// Append a form-style marker to indicate redaction occurred
|
||||
if !strings.Contains(text, pattern+"=[REDACTED]") {
|
||||
text = text + " " + pattern + "=[REDACTED]"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Double quoted pattern: field="value" → field="[REDACTED]"
|
||||
quotedPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `="[^"]*"`)
|
||||
text = quotedPattern.ReplaceAllString(text, pattern+`="[REDACTED]"`)
|
||||
|
||||
// 4. Single quoted pattern: field='value' → field='[REDACTED]'
|
||||
singleQuotedPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `='[^']*'`)
|
||||
text = singleQuotedPattern.ReplaceAllString(text, pattern+`='[REDACTED]'`)
|
||||
|
||||
// 5. Form/URL pattern: field=value& or field=value$ → field=[REDACTED]& or field=[REDACTED]$
|
||||
// This must be last and should only match unquoted values
|
||||
formPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `=([^&\s"']+)(?:[&\s]|$)`)
|
||||
text = formPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// Only replace if the value is not already [REDACTED]
|
||||
if strings.Contains(match, "[REDACTED]") {
|
||||
return match
|
||||
}
|
||||
return regexp.MustCompile(`=([^&\s"']+)`).ReplaceAllString(match, "=[REDACTED]")
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// convertHeaders converts map[string][]string to map[string]string by taking first value
|
||||
func convertHeaders(headers map[string][]string) map[string]string {
|
||||
converted := make(map[string]string)
|
||||
for key, values := range headers {
|
||||
if len(values) > 0 {
|
||||
converted[key] = values[0]
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
// sanitizeHeaders removes sensitive headers from logging
|
||||
func sanitizeHeaders(headers map[string]string) map[string]string {
|
||||
sanitized := make(map[string]string)
|
||||
sensitiveHeaders := []string{
|
||||
"authorization", "x-api-key", "x-auth-token", "cookie", "set-cookie",
|
||||
"x-api-secret", "x-access-token", "x-csrf-token",
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
keyLower := strings.ToLower(key)
|
||||
isRedacted := false
|
||||
for _, sensitive := range sensitiveHeaders {
|
||||
if strings.Contains(keyLower, sensitive) {
|
||||
sanitized[key] = "[REDACTED]"
|
||||
isRedacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRedacted {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// logDebugRequest logs the request details when in debug mode with sanitization.
|
||||
func logDebugRequest(c *fiber.Ctx) {
|
||||
contentType := string(c.Request().Header.ContentType())
|
||||
@@ -860,7 +808,7 @@ func logDebugRequest(c *fiber.Ctx) {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Proxying the request",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"body": sanitizedBody,
|
||||
"headers": sanitizedHeaders,
|
||||
@@ -877,7 +825,7 @@ func logDebugResponse(c *fiber.Ctx) {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Received proxied response",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"response_body": sanitizedBody,
|
||||
"response_code": c.Response().StatusCode(),
|
||||
@@ -895,7 +843,7 @@ func safeMaxRequests(maxRequestsInHalfOpen int) uint32 {
|
||||
if cfg != nil && cfg.Logger != nil {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Invalid MaxRequestsInHalfOpen value, using default",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"requested_value": maxRequestsInHalfOpen,
|
||||
"default_value": defaultMaxRequestsInHalfOpen,
|
||||
},
|
||||
|
||||
@@ -21,19 +21,19 @@ func TestProxyLoggingSecurityTestSuite(t *testing.T) {
|
||||
func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
input map[string]any
|
||||
expected map[string]any
|
||||
contentType string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Password field redaction",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"username": "user123",
|
||||
"password": "secret123",
|
||||
"email": "user@example.com",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"username": "user123",
|
||||
"password": "[REDACTED]",
|
||||
"email": "[REDACTED]",
|
||||
@@ -43,13 +43,13 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "API key and token redaction",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"data": "normal data",
|
||||
"api_key": "sk-123456789",
|
||||
"token": "bearer-token-123",
|
||||
"auth": "auth-value",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"data": "normal data",
|
||||
"api_key": "[REDACTED]",
|
||||
"token": "[REDACTED]",
|
||||
@@ -60,22 +60,22 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Nested sensitive fields",
|
||||
input: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"user": map[string]any{
|
||||
"name": "John Doe",
|
||||
"password": "secret123",
|
||||
"profile": map[string]interface{}{
|
||||
"profile": map[string]any{
|
||||
"api_key": "sk-nested-key",
|
||||
"bio": "User bio",
|
||||
},
|
||||
},
|
||||
"public_data": "visible",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"user": map[string]any{
|
||||
"name": "John Doe",
|
||||
"password": "[REDACTED]",
|
||||
"profile": map[string]interface{}{
|
||||
"profile": map[string]any{
|
||||
"api_key": "[REDACTED]",
|
||||
"bio": "User bio",
|
||||
},
|
||||
@@ -87,25 +87,25 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Array with sensitive data",
|
||||
input: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"users": []any{
|
||||
map[string]any{
|
||||
"name": "User1",
|
||||
"password": "pass1",
|
||||
},
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"name": "User2",
|
||||
"token": "token2",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"users": []any{
|
||||
map[string]any{
|
||||
"name": "User1",
|
||||
"password": "[REDACTED]",
|
||||
},
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"name": "User2",
|
||||
"token": "[REDACTED]",
|
||||
},
|
||||
@@ -116,13 +116,13 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Credit card and financial data",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"order_id": "12345",
|
||||
"credit_card": "4111111111111111",
|
||||
"cvv": "123",
|
||||
"amount": 100.50,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"order_id": "12345",
|
||||
"credit_card": "[REDACTED]",
|
||||
"cvv": "[REDACTED]",
|
||||
@@ -133,14 +133,14 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Personal identifiable information",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"name": "John Doe",
|
||||
"ssn": "123-45-6789",
|
||||
"phone": "+1-555-123-4567",
|
||||
"address": "123 Main St",
|
||||
"age": 30,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"name": "John Doe",
|
||||
"ssn": "[REDACTED]",
|
||||
"phone": "[REDACTED]",
|
||||
@@ -152,13 +152,13 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Mixed case field names",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"UserName": "john",
|
||||
"PASSWORD": "secret",
|
||||
"Api_Key": "key123",
|
||||
"Bearer": "token",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"UserName": "john",
|
||||
"PASSWORD": "[REDACTED]",
|
||||
"Api_Key": "[REDACTED]",
|
||||
@@ -169,24 +169,24 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
},
|
||||
{
|
||||
name: "Various password patterns",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"pwd": "secret1",
|
||||
"passwd": "secret2",
|
||||
"password": "secret3",
|
||||
"pass": "not-redacted", // Should NOT be redacted (not in list)
|
||||
"pass": "secret4", // Now redacted for better security coverage
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"pwd": "[REDACTED]",
|
||||
"passwd": "[REDACTED]",
|
||||
"password": "[REDACTED]",
|
||||
"pass": "not-redacted",
|
||||
"pass": "[REDACTED]",
|
||||
},
|
||||
contentType: "application/json",
|
||||
description: "Should handle various password field patterns",
|
||||
},
|
||||
{
|
||||
name: "Various auth patterns",
|
||||
input: map[string]interface{}{
|
||||
input: map[string]any{
|
||||
"authorization": "Bearer token123",
|
||||
"auth": "basic auth",
|
||||
"bearer": "token456",
|
||||
@@ -195,7 +195,7 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
"session_id": "session789",
|
||||
"cookie": "cookie_value",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
expected: map[string]any{
|
||||
"authorization": "[REDACTED]",
|
||||
"auth": "[REDACTED]",
|
||||
"bearer": "[REDACTED]",
|
||||
@@ -219,7 +219,7 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||||
result := sanitizeForLogging(inputBytes, tt.contentType)
|
||||
|
||||
// Parse the result back to compare
|
||||
var sanitized map[string]interface{}
|
||||
var sanitized map[string]any
|
||||
decoder := json.NewDecoder(strings.NewReader(result))
|
||||
decoder.UseNumber() // Preserve number precision and type
|
||||
err = decoder.Decode(&sanitized)
|
||||
@@ -398,10 +398,10 @@ func (suite *ProxyLoggingSecurityTestSuite) TestRedactSensitiveFields() {
|
||||
sensitiveFields := []string{"password", "token", "secret"}
|
||||
|
||||
suite.Run("Deep nested structure", func() {
|
||||
data := map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": map[string]interface{}{
|
||||
"level3": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"level1": map[string]any{
|
||||
"level2": map[string]any{
|
||||
"level3": map[string]any{
|
||||
"password": "testdeepsecret",
|
||||
"public": "data",
|
||||
},
|
||||
@@ -415,28 +415,28 @@ func (suite *ProxyLoggingSecurityTestSuite) TestRedactSensitiveFields() {
|
||||
redactSensitiveFields(data, sensitiveFields)
|
||||
|
||||
// Verify deep nesting is handled
|
||||
level3 := data["level1"].(map[string]interface{})["level2"].(map[string]interface{})["level3"].(map[string]interface{})
|
||||
level3 := data["level1"].(map[string]any)["level2"].(map[string]any)["level3"].(map[string]any)
|
||||
suite.Equal("[REDACTED]", level3["password"])
|
||||
suite.Equal("data", level3["public"])
|
||||
|
||||
// Verify intermediate levels
|
||||
level2 := data["level1"].(map[string]interface{})["level2"].(map[string]interface{})
|
||||
level2 := data["level1"].(map[string]any)["level2"].(map[string]any)
|
||||
suite.Equal("[REDACTED]", level2["token"])
|
||||
|
||||
// Verify top level
|
||||
suite.Equal("[REDACTED]", data["secret"])
|
||||
level1 := data["level1"].(map[string]interface{})
|
||||
level1 := data["level1"].(map[string]any)
|
||||
suite.Equal("value", level1["normal"])
|
||||
})
|
||||
|
||||
suite.Run("Array of objects", func() {
|
||||
data := map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"users": []any{
|
||||
map[string]any{
|
||||
"name": "User1",
|
||||
"password": "testpass1",
|
||||
},
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"name": "User2",
|
||||
"token": "testtoken2",
|
||||
},
|
||||
@@ -446,9 +446,9 @@ func (suite *ProxyLoggingSecurityTestSuite) TestRedactSensitiveFields() {
|
||||
|
||||
redactSensitiveFields(data, sensitiveFields)
|
||||
|
||||
users := data["users"].([]interface{})
|
||||
user1 := users[0].(map[string]interface{})
|
||||
user2 := users[1].(map[string]interface{})
|
||||
users := data["users"].([]any)
|
||||
user1 := users[0].(map[string]any)
|
||||
user2 := users[1].(map[string]any)
|
||||
|
||||
suite.Equal("[REDACTED]", user1["password"])
|
||||
suite.Equal("User1", user1["name"])
|
||||
@@ -509,9 +509,9 @@ func (suite *ProxyLoggingSecurityTestSuite) TestRedactPatternInString() {
|
||||
// TestSanitizationPerformance tests performance of sanitization functions
|
||||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizationPerformance() {
|
||||
// Create a large JSON structure with sensitive data
|
||||
largeData := make(map[string]interface{})
|
||||
largeData := make(map[string]any)
|
||||
for i := 0; i < 1000; i++ {
|
||||
largeData[fmt.Sprintf("user_%d", i)] = map[string]interface{}{
|
||||
largeData[fmt.Sprintf("user_%d", i)] = map[string]any{
|
||||
"name": fmt.Sprintf("User%d", i),
|
||||
"password": fmt.Sprintf("secret%d", i),
|
||||
"email": fmt.Sprintf("user%d@example.com", i),
|
||||
@@ -526,12 +526,12 @@ func (suite *ProxyLoggingSecurityTestSuite) TestSanitizationPerformance() {
|
||||
result := sanitizeForLogging(largeJSON, "application/json")
|
||||
|
||||
// Verify the result is valid JSON
|
||||
var sanitized map[string]interface{}
|
||||
var sanitized map[string]any
|
||||
err = json.Unmarshal([]byte(result), &sanitized)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify sensitive data was redacted (spot check)
|
||||
user0 := sanitized["user_0"].(map[string]interface{})
|
||||
user0 := sanitized["user_0"].(map[string]any)
|
||||
suite.Equal("[REDACTED]", user0["password"])
|
||||
suite.Equal("[REDACTED]", user0["email"])
|
||||
suite.Equal("User0", user0["name"])
|
||||
@@ -557,7 +557,7 @@ func (suite *ProxyLoggingSecurityTestSuite) TestEdgeCases() {
|
||||
|
||||
// This should not panic
|
||||
suite.NotPanics(func() {
|
||||
data := make(map[string]interface{})
|
||||
data := make(map[string]any)
|
||||
data["test"] = nil
|
||||
redactSensitiveFields(data, sensitiveFields)
|
||||
})
|
||||
@@ -577,12 +577,12 @@ func (suite *ProxyLoggingSecurityTestSuite) TestEdgeCases() {
|
||||
|
||||
// BenchmarkSanitizeForLogging benchmarks the sanitization function
|
||||
func BenchmarkSanitizeForLogging(b *testing.B) {
|
||||
testData := map[string]interface{}{
|
||||
testData := map[string]any{
|
||||
"username": "testuser",
|
||||
"password": "secret123",
|
||||
"api_key": "sk-123456789",
|
||||
"data": "normal data",
|
||||
"nested": map[string]interface{}{
|
||||
"nested": map[string]any{
|
||||
"token": "nested-token",
|
||||
"value": "nested-value",
|
||||
},
|
||||
|
||||
@@ -82,6 +82,36 @@ func (suite *Tests) Test_proxyTheRequest() {
|
||||
wantErr: false,
|
||||
wantEndpoint: "https://telegram-bot.app/",
|
||||
},
|
||||
{
|
||||
name: "Test mutation with multiple operations (bug fix regression test)",
|
||||
body: `{"query":"mutation getOrCreateUser { insert_tg_users_one(object: {id: 123}) { id } } query otherQuery { users { id } }"}`,
|
||||
host: "https://telegram-bot.app/",
|
||||
hostRO: "https://google.com/",
|
||||
path: "/v1/graphql",
|
||||
headers: supplied_headers,
|
||||
wantErr: false,
|
||||
wantEndpoint: "https://telegram-bot.app/",
|
||||
},
|
||||
{
|
||||
name: "Test mutation followed by fragment (bug fix regression test)",
|
||||
body: `{"query":"mutation insertUser { insert_users_one(object: {name: \"test\"}) { ...userFields } } fragment userFields on users { id name }"}`,
|
||||
host: "https://telegram-bot.app/",
|
||||
hostRO: "https://google.com/",
|
||||
path: "/v1/graphql",
|
||||
headers: supplied_headers,
|
||||
wantErr: false,
|
||||
wantEndpoint: "https://telegram-bot.app/",
|
||||
},
|
||||
{
|
||||
name: "Test complex mutation document (main-bot style)",
|
||||
body: `{"query":"mutation getOrCreateUser($user_id: bigint!, $group_id: bigint!) { insert_tg_users_one(object: {id: $user_id}, on_conflict: {constraint: tg_users_pkey, update_columns: last_seen}) { id } insert_tg_groups_one(object: {id: $group_id}, on_conflict: {constraint: tg_groups_pkey, update_columns: last_seen}) { id } }"}`,
|
||||
host: "https://telegram-bot.app/",
|
||||
hostRO: "https://google.com/",
|
||||
path: "/v1/graphql",
|
||||
headers: supplied_headers,
|
||||
wantErr: false,
|
||||
wantEndpoint: "https://telegram-bot.app/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+13
-13
@@ -25,8 +25,8 @@ type RateLimitConfig struct {
|
||||
func (r *RateLimitConfig) UnmarshalJSON(data []byte) error {
|
||||
// Use a temporary struct to unmarshal the JSON data
|
||||
type RateLimitConfigTemp struct {
|
||||
Interval interface{} `json:"interval"`
|
||||
Req int `json:"req"`
|
||||
Interval any `json:"interval"`
|
||||
Req int `json:"req"`
|
||||
}
|
||||
|
||||
var temp RateLimitConfigTemp
|
||||
@@ -96,7 +96,7 @@ func loadRatelimitConfig() error {
|
||||
// Log detailed error information
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to load rate limit configuration",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"paths": paths,
|
||||
"path_errors": configError.PathErrors,
|
||||
},
|
||||
@@ -120,7 +120,7 @@ func loadConfigFromPath(path string) error {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Failed to load rate limit config",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": path,
|
||||
"error": errMsg,
|
||||
"error_details": err.Error(),
|
||||
@@ -137,7 +137,7 @@ func loadConfigFromPath(path string) error {
|
||||
errMsg := fmt.Sprintf("Invalid JSON format: %s", err.Error())
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Failed to parse rate limit config",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": path,
|
||||
"error": errMsg,
|
||||
},
|
||||
@@ -150,7 +150,7 @@ func loadConfigFromPath(path string) error {
|
||||
errMsg := "Empty rate limit configuration"
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Invalid rate limit config",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": path,
|
||||
"error": errMsg,
|
||||
},
|
||||
@@ -167,7 +167,7 @@ func loadConfigFromPath(path string) error {
|
||||
if cfg.LogLevel == "DEBUG" {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Setting ratelimit config for role",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"role": key,
|
||||
"interval_used": value.Interval,
|
||||
"ratelimit": value.Req,
|
||||
@@ -186,7 +186,7 @@ func loadConfigFromPath(path string) error {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit config loaded",
|
||||
Pairs: map[string]interface{}{"ratelimit": rateLimits},
|
||||
Pairs: map[string]any{"ratelimit": rateLimits},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func rateLimitedRequest(userID, userRole string) bool {
|
||||
if !ok || roleConfig.RateCounterTicker == nil {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit role not found or ticker not initialized - defaulting to deny",
|
||||
Pairs: map[string]interface{}{"user_role": userRole},
|
||||
Pairs: map[string]any{"user_role": userRole},
|
||||
})
|
||||
// Default to deny when config not found (security fix)
|
||||
return false
|
||||
@@ -224,7 +224,7 @@ func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoin
|
||||
roleConfig.RateCounterTicker.Incr(1)
|
||||
tickerRate := roleConfig.RateCounterTicker.GetRate()
|
||||
|
||||
logDetails := map[string]interface{}{
|
||||
logDetails := map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"rate": tickerRate,
|
||||
@@ -235,14 +235,14 @@ func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoin
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit ticker",
|
||||
Pairs: map[string]interface{}{"log_details": logDetails},
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
|
||||
// Check burst limit if configured
|
||||
if roleConfig.Burst > 0 && tickerRate > float64(roleConfig.Burst) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Burst limit exceeded",
|
||||
Pairs: map[string]interface{}{"log_details": logDetails},
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoin
|
||||
if tickerRate > float64(roleConfig.Req) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit exceeded",
|
||||
Pairs: map[string]interface{}{"log_details": logDetails},
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error)
|
||||
if rc.logger != nil {
|
||||
rc.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Request coalesced with in-flight request",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"key": key[:min(len(key), 32)] + "...",
|
||||
"waiters": waiters,
|
||||
},
|
||||
@@ -115,7 +115,7 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error)
|
||||
if rc.logger != nil {
|
||||
rc.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Request coalesced (race condition)",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"key": key[:min(len(key), 32)] + "...",
|
||||
"waiters": waiters,
|
||||
},
|
||||
@@ -163,7 +163,7 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error)
|
||||
if rc.logger != nil && waiters > 1 {
|
||||
rc.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Request completed, served coalesced waiters",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"key": key[:min(len(key), 32)] + "...",
|
||||
"waiters": waiters,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
@@ -183,7 +183,7 @@ func (rc *RequestCoalescer) Do(key string, fn func() (*CoalescedResponse, error)
|
||||
}
|
||||
|
||||
// GetStats returns coalescing statistics
|
||||
func (rc *RequestCoalescer) GetStats() map[string]interface{} {
|
||||
func (rc *RequestCoalescer) GetStats() map[string]any {
|
||||
totalRequests := rc.totalRequests.Load()
|
||||
coalescedRequests := rc.coalescedRequests.Load()
|
||||
|
||||
@@ -199,7 +199,7 @@ func (rc *RequestCoalescer) GetStats() map[string]interface{} {
|
||||
savings = float64(coalescedRequests) / float64(primaryRequests) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"enabled": rc.enabled,
|
||||
"total_requests": totalRequests,
|
||||
"primary_requests": primaryRequests,
|
||||
|
||||
+38
-10
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -18,6 +19,8 @@ type RetryBudget struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
logger *libpack_logger.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// Statistics
|
||||
totalAttempts atomic.Int64
|
||||
@@ -32,13 +35,21 @@ type RetryBudgetConfig struct {
|
||||
Enabled bool // Whether retry budget is enabled
|
||||
}
|
||||
|
||||
// NewRetryBudget creates a new retry budget
|
||||
// NewRetryBudget creates a new retry budget (deprecated, use NewRetryBudgetWithContext)
|
||||
func NewRetryBudget(config RetryBudgetConfig, logger *libpack_logger.Logger) *RetryBudget {
|
||||
return NewRetryBudgetWithContext(context.Background(), config, logger)
|
||||
}
|
||||
|
||||
// NewRetryBudgetWithContext creates a new retry budget with context for graceful shutdown
|
||||
func NewRetryBudgetWithContext(ctx context.Context, config RetryBudgetConfig, logger *libpack_logger.Logger) *RetryBudget {
|
||||
budgetCtx, cancel := context.WithCancel(ctx)
|
||||
rb := &RetryBudget{
|
||||
tokensPerSecond: config.TokensPerSecond,
|
||||
maxTokens: int64(config.MaxTokens),
|
||||
enabled: config.Enabled,
|
||||
logger: logger,
|
||||
ctx: budgetCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Initialize with full bucket
|
||||
@@ -70,7 +81,7 @@ func (rb *RetryBudget) AllowRetry() bool {
|
||||
if rb.logger != nil {
|
||||
rb.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Retry denied: budget exhausted",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"current_tokens": current,
|
||||
"denied_count": rb.deniedRetries.Load(),
|
||||
},
|
||||
@@ -91,8 +102,20 @@ func (rb *RetryBudget) refillLoop() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond) // Refill every 100ms
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
rb.refill()
|
||||
for {
|
||||
select {
|
||||
case <-rb.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rb.refill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the retry budget goroutine
|
||||
func (rb *RetryBudget) Shutdown() {
|
||||
if rb.cancel != nil {
|
||||
rb.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +150,7 @@ func (rb *RetryBudget) refill() {
|
||||
}
|
||||
|
||||
// GetStats returns current statistics
|
||||
func (rb *RetryBudget) GetStats() map[string]interface{} {
|
||||
func (rb *RetryBudget) GetStats() map[string]any {
|
||||
totalAttempts := rb.totalAttempts.Load()
|
||||
allowedRetries := rb.allowedRetries.Load()
|
||||
deniedRetries := rb.deniedRetries.Load()
|
||||
@@ -137,7 +160,7 @@ func (rb *RetryBudget) GetStats() map[string]interface{} {
|
||||
denialRate = float64(deniedRetries) / float64(totalAttempts) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"enabled": rb.enabled,
|
||||
"current_tokens": rb.currentTokens.Load(),
|
||||
"max_tokens": rb.maxTokens,
|
||||
@@ -172,7 +195,7 @@ func (rb *RetryBudget) UpdateConfig(config RetryBudgetConfig) {
|
||||
if rb.logger != nil {
|
||||
rb.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Retry budget configuration updated",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"tokens_per_sec": config.TokensPerSecond,
|
||||
"max_tokens": config.MaxTokens,
|
||||
"enabled": config.Enabled,
|
||||
@@ -187,14 +210,19 @@ var (
|
||||
retryBudgetOnce sync.Once
|
||||
)
|
||||
|
||||
// InitializeRetryBudget initializes the global retry budget
|
||||
// InitializeRetryBudget initializes the global retry budget (deprecated, use InitializeRetryBudgetWithContext)
|
||||
func InitializeRetryBudget(config RetryBudgetConfig, logger *libpack_logger.Logger) *RetryBudget {
|
||||
return InitializeRetryBudgetWithContext(context.Background(), config, logger)
|
||||
}
|
||||
|
||||
// InitializeRetryBudgetWithContext initializes the global retry budget with context for graceful shutdown
|
||||
func InitializeRetryBudgetWithContext(ctx context.Context, config RetryBudgetConfig, logger *libpack_logger.Logger) *RetryBudget {
|
||||
retryBudgetOnce.Do(func() {
|
||||
retryBudget = NewRetryBudget(config, logger)
|
||||
retryBudget = NewRetryBudgetWithContext(ctx, config, logger)
|
||||
if logger != nil && config.Enabled {
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Retry budget initialized",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"tokens_per_sec": config.TokensPerSecond,
|
||||
"max_tokens": config.MaxTokens,
|
||||
},
|
||||
|
||||
+27
-8
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -12,11 +13,17 @@ type RPSTracker struct {
|
||||
lastSampleTime atomic.Int64 // Unix nano
|
||||
currentRPS uint64 // stored as uint64, accessed with atomic operations
|
||||
mu sync.RWMutex // for currentRPS updates
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewRPSTracker creates a new RPS tracker
|
||||
func NewRPSTracker() *RPSTracker {
|
||||
tracker := &RPSTracker{}
|
||||
// NewRPSTracker creates a new RPS tracker with context for graceful shutdown
|
||||
func NewRPSTracker(ctx context.Context) *RPSTracker {
|
||||
trackerCtx, cancel := context.WithCancel(ctx)
|
||||
tracker := &RPSTracker{
|
||||
ctx: trackerCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
tracker.lastSampleTime.Store(time.Now().UnixNano())
|
||||
go tracker.updateLoop()
|
||||
return tracker
|
||||
@@ -33,8 +40,20 @@ func (r *RPSTracker) updateLoop() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
r.sample()
|
||||
for {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.sample()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops the RPS tracker
|
||||
func (r *RPSTracker) Shutdown() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +94,10 @@ func (r *RPSTracker) GetCurrentRPS() float64 {
|
||||
|
||||
var globalRPSTracker *RPSTracker
|
||||
|
||||
// InitializeRPSTracker initializes the global RPS tracker
|
||||
func InitializeRPSTracker() *RPSTracker {
|
||||
// InitializeRPSTracker initializes the global RPS tracker with context for graceful shutdown
|
||||
func InitializeRPSTracker(ctx context.Context) *RPSTracker {
|
||||
if globalRPSTracker == nil {
|
||||
globalRPSTracker = NewRPSTracker()
|
||||
globalRPSTracker = NewRPSTracker(ctx)
|
||||
}
|
||||
return globalRPSTracker
|
||||
}
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Sanitization constants
|
||||
const (
|
||||
// MaxLogBodySize is the maximum size of body content to include in logs
|
||||
MaxLogBodySize = 1000
|
||||
// RedactedPlaceholder is the string used to replace sensitive values
|
||||
RedactedPlaceholder = "[REDACTED]"
|
||||
// TruncatedSuffix is appended to truncated log content
|
||||
TruncatedSuffix = "... [truncated]"
|
||||
)
|
||||
|
||||
// sensitiveFieldPatterns contains common sensitive field names for redaction
|
||||
var sensitiveFieldPatterns = []string{
|
||||
// Passwords
|
||||
"password", "passwd", "pwd", "pass",
|
||||
// Tokens (expanded coverage)
|
||||
"token", "accesstoken", "access_token", "refreshtoken", "refresh_token",
|
||||
"api_key", "apikey", "api-key", "api_token",
|
||||
"jwt", "jwttoken", "jwt_token", "idtoken", "id_token",
|
||||
// Secrets & Keys
|
||||
"secret", "client_secret", "clientsecret",
|
||||
"private_key", "privatekey", "private-key",
|
||||
// Auth
|
||||
"authorization", "auth", "bearer", "basic",
|
||||
// Sessions
|
||||
"session", "sessionid", "session_id", "cookie", "csrf", "xsrf",
|
||||
// PII - Personal Identifiable Information
|
||||
"ssn", "social_security", "personal_id", "national_id",
|
||||
"credit_card", "card_number", "cardnumber", "cvv", "cvc", "cvv2",
|
||||
"track1", "track2", "pan",
|
||||
"email", "phone", "address", "postal", "zip",
|
||||
// MFA/2FA
|
||||
"otp", "2fa", "mfa", "pin", "totp",
|
||||
}
|
||||
|
||||
// sensitiveHeaderPatterns contains header names that should be redacted
|
||||
var sensitiveHeaderPatterns = []string{
|
||||
"authorization", "x-api-key", "x-auth-token", "cookie", "set-cookie",
|
||||
"x-api-secret", "x-access-token", "x-csrf-token",
|
||||
}
|
||||
|
||||
// sanitizeForLogging removes sensitive data from request/response bodies before logging
|
||||
func sanitizeForLogging(body []byte, contentType string) string {
|
||||
// Try to parse as JSON if content type suggests it
|
||||
if strings.Contains(strings.ToLower(contentType), "json") {
|
||||
var data map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber() // Preserve number precision and type
|
||||
if err := decoder.Decode(&data); err == nil {
|
||||
redactSensitiveFields(data, sensitiveFieldPatterns)
|
||||
sanitized, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
// Fall through to string-based sanitization on marshal error
|
||||
} else {
|
||||
return string(sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-JSON or failed parsing, truncate to prevent logging large bodies
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > MaxLogBodySize {
|
||||
return bodyStr[:MaxLogBodySize] + TruncatedSuffix
|
||||
}
|
||||
|
||||
// For small non-JSON bodies, do basic string replacement
|
||||
for _, field := range sensitiveFieldPatterns {
|
||||
bodyStr = redactPatternInString(bodyStr, field)
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
}
|
||||
|
||||
// redactSensitiveFields recursively redacts sensitive fields in a map
|
||||
func redactSensitiveFields(data map[string]any, fields []string) {
|
||||
for key, value := range data {
|
||||
keyLower := strings.ToLower(key)
|
||||
// Check if the key matches any sensitive field
|
||||
for _, field := range fields {
|
||||
if strings.Contains(keyLower, field) {
|
||||
data[key] = RedactedPlaceholder
|
||||
break
|
||||
}
|
||||
}
|
||||
// Recurse for nested objects
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
redactSensitiveFields(nested, fields)
|
||||
}
|
||||
// Handle arrays of objects
|
||||
if arr, ok := value.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if nestedItem, ok := item.(map[string]any); ok {
|
||||
redactSensitiveFields(nestedItem, fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redactPatternInString performs basic pattern redaction in strings
|
||||
func redactPatternInString(text string, pattern string) string {
|
||||
// Use proper regex to capture and redact complete sensitive values
|
||||
// Order matters: process most specific patterns first
|
||||
|
||||
// 1. JSON pattern: "field":"value" → "field":"[REDACTED]"
|
||||
jsonPattern := regexp.MustCompile(`(?i)"` + regexp.QuoteMeta(pattern) + `"\s*:\s*"[^"]*"`)
|
||||
text = jsonPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return regexp.MustCompile(`:\s*"[^"]*"`).ReplaceAllString(match, `:"[REDACTED]"`)
|
||||
})
|
||||
|
||||
// 2. XML pattern: <field>value</field> → <field>[REDACTED]</field>
|
||||
xmlPattern := regexp.MustCompile(`(?i)<` + regexp.QuoteMeta(pattern) + `>[^<]*</` + regexp.QuoteMeta(pattern) + `>`)
|
||||
xmlMatched := xmlPattern.MatchString(text)
|
||||
text = xmlPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return regexp.MustCompile(`>[^<]*<`).ReplaceAllString(match, ">[REDACTED]<")
|
||||
})
|
||||
|
||||
// If XML pattern was matched, also add a standardized redaction marker for test compatibility
|
||||
if xmlMatched {
|
||||
// Append a form-style marker to indicate redaction occurred
|
||||
if !strings.Contains(text, pattern+"=[REDACTED]") {
|
||||
text = text + " " + pattern + "=[REDACTED]"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Double quoted pattern: field="value" → field="[REDACTED]"
|
||||
quotedPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `="[^"]*"`)
|
||||
text = quotedPattern.ReplaceAllString(text, pattern+`="[REDACTED]"`)
|
||||
|
||||
// 4. Single quoted pattern: field='value' → field='[REDACTED]'
|
||||
singleQuotedPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `='[^']*'`)
|
||||
text = singleQuotedPattern.ReplaceAllString(text, pattern+`='[REDACTED]'`)
|
||||
|
||||
// 5. Form/URL pattern: field=value& or field=value$ → field=[REDACTED]& or field=[REDACTED]$
|
||||
// This must be last and should only match unquoted values
|
||||
formPattern := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(pattern) + `=([^&\s"']+)(?:[&\s]|$)`)
|
||||
text = formPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// Only replace if the value is not already [REDACTED]
|
||||
if strings.Contains(match, "[REDACTED]") {
|
||||
return match
|
||||
}
|
||||
return regexp.MustCompile(`=([^&\s"']+)`).ReplaceAllString(match, "=[REDACTED]")
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// convertHeaders converts map[string][]string to map[string]string by taking first value
|
||||
func convertHeaders(headers map[string][]string) map[string]string {
|
||||
converted := make(map[string]string)
|
||||
for key, values := range headers {
|
||||
if len(values) > 0 {
|
||||
converted[key] = values[0]
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
// sanitizeHeaders removes sensitive headers from logging
|
||||
func sanitizeHeaders(headers map[string]string) map[string]string {
|
||||
sanitized := make(map[string]string)
|
||||
|
||||
for key, value := range headers {
|
||||
keyLower := strings.ToLower(key)
|
||||
isRedacted := false
|
||||
for _, sensitive := range sensitiveHeaderPatterns {
|
||||
if strings.Contains(keyLower, sensitive) {
|
||||
sanitized[key] = RedactedPlaceholder
|
||||
isRedacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRedacted {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func StartHTTPProxy() error {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "GraphQL proxy starting",
|
||||
Pairs: map[string]interface{}{"port": cfg.Server.PortGraphQL},
|
||||
Pairs: map[string]any{"port": cfg.Server.PortGraphQL},
|
||||
})
|
||||
|
||||
if err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL)); err != nil {
|
||||
@@ -168,7 +168,7 @@ func healthCheck(c *fiber.Ctx) error {
|
||||
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Health check: Can't reach the GraphQL server",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"error": errorMsg,
|
||||
"response_time_ms": graphqlStatus.ResponseTime,
|
||||
@@ -224,7 +224,7 @@ func healthCheck(c *fiber.Ctx) error {
|
||||
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Health check: Can't connect to Redis",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"server": cfg.Cache.CacheRedisURL,
|
||||
"error": errorMsg,
|
||||
"response_time_ms": redisStatus.ResponseTime,
|
||||
@@ -243,7 +243,7 @@ func healthCheck(c *fiber.Ctx) error {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Health check completed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"status": response.Status,
|
||||
"dependencies": response.Dependencies,
|
||||
},
|
||||
@@ -272,6 +272,17 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
|
||||
// Parse the GraphQL query
|
||||
parsedResult := parseGraphQLQuery(c)
|
||||
|
||||
// Debug logging for mutation routing analysis (enabled when LOG_LEVEL=DEBUG)
|
||||
if cfg.LogLevel == "DEBUG" {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(c.Body(), &m); err == nil {
|
||||
if query, ok := m["query"].(string); ok {
|
||||
debugParseGraphQLQuery(c, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parsedResult.shouldBlock {
|
||||
return c.Status(fiber.StatusForbidden).SendString("Request blocked")
|
||||
}
|
||||
@@ -316,8 +327,11 @@ func extractUserInfo(c *fiber.Ctx) (string, string) {
|
||||
|
||||
// handleCaching manages the caching logic for GraphQL requests
|
||||
func handleCaching(c *fiber.Ctx, parsedResult *parseGraphQLQueryResult, userID string) (bool, error) {
|
||||
// Calculate query hash for cache key
|
||||
calculatedQueryHash := libpack_cache.CalculateHash(c)
|
||||
// Extract user role for cache key (in addition to userID already passed)
|
||||
_, userRole := extractUserInfo(c)
|
||||
|
||||
// Calculate query hash for cache key - now includes user context for security
|
||||
calculatedQueryHash := libpack_cache.CalculateHash(c, userID, userRole)
|
||||
|
||||
// Set cache time from header or default
|
||||
if parsedResult.cacheTime == 0 {
|
||||
@@ -366,7 +380,7 @@ func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int,
|
||||
if err := proxyTheRequest(c, currentEndpoint); err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Can't proxy the request",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("Can't proxy the request - try again later")
|
||||
@@ -389,7 +403,7 @@ func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached
|
||||
if cfg.Server.AccessLog {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Request processed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"ip": c.IP(),
|
||||
"fwd-ip": c.Get("X-Forwarded-For"),
|
||||
"user_id": userID,
|
||||
|
||||
+4
-4
@@ -54,7 +54,7 @@ func (sm *ShutdownManager) RunGoroutine(name string, fn func(context.Context)) {
|
||||
if logger != nil {
|
||||
logger.Debug(&libpack_logging.LogMessage{
|
||||
Message: "Starting managed goroutine",
|
||||
Pairs: map[string]interface{}{"name": name},
|
||||
Pairs: map[string]any{"name": name},
|
||||
})
|
||||
}
|
||||
fn(sm.ctx)
|
||||
@@ -64,7 +64,7 @@ func (sm *ShutdownManager) RunGoroutine(name string, fn func(context.Context)) {
|
||||
if logger != nil {
|
||||
logger.Debug(&libpack_logging.LogMessage{
|
||||
Message: "Managed goroutine finished",
|
||||
Pairs: map[string]interface{}{"name": name},
|
||||
Pairs: map[string]any{"name": name},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -114,7 +114,7 @@ func (sm *ShutdownManager) doShutdown(timeout time.Duration) error {
|
||||
if logger != nil {
|
||||
logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "Shutting down component",
|
||||
Pairs: map[string]interface{}{"component": c.Name},
|
||||
Pairs: map[string]any{"component": c.Name},
|
||||
})
|
||||
}
|
||||
if err := c.Shutdown(shutdownCtx); err != nil {
|
||||
@@ -124,7 +124,7 @@ func (sm *ShutdownManager) doShutdown(timeout time.Duration) error {
|
||||
if logger != nil {
|
||||
logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "Error shutting down component",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"component": c.Name,
|
||||
"error": err.Error(),
|
||||
},
|
||||
|
||||
@@ -97,6 +97,9 @@ spec:
|
||||
value: "error"
|
||||
- name: HASURA_GRAPHQL_SERVER_PORT
|
||||
value: "8088"
|
||||
# Disable event trigger processing on read-only instance
|
||||
- name: HASURA_GRAPHQL_EVENTS_FETCH_INTERVAL
|
||||
value: "0"
|
||||
|
||||
- name: graphql-proxy
|
||||
image: ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ type config struct {
|
||||
CacheRedisEnable bool
|
||||
CacheMaxMemorySize int
|
||||
CacheMaxEntries int
|
||||
GraphQLQueryCacheSize int // Max number of parsed GraphQL queries to cache
|
||||
CacheUseLRU bool // Use LRU eviction algorithm instead of random eviction
|
||||
GraphQLQueryCacheSize int // Max number of parsed GraphQL queries to cache
|
||||
PerUserCacheDisabled bool // Disable per-user cache isolation (SECURITY RISK - not recommended)
|
||||
}
|
||||
Client struct {
|
||||
GQLClient *graphql.BaseClient
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package tracing provides OpenTelemetry distributed tracing integration
|
||||
// for the GraphQL proxy. Supports OTLP export to collectors like Jaeger,
|
||||
// Zipkin, or any OTLP-compatible backend.
|
||||
package tracing
|
||||
|
||||
import (
|
||||
|
||||
+109
-20
@@ -8,6 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
@@ -66,7 +67,7 @@ func NewWebSocketProxy(backendURL string, config WebSocketConfig, logger *libpac
|
||||
if logger != nil && config.Enabled {
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket proxy enabled",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"backend_url": backendURL,
|
||||
"ping_interval": config.PingInterval,
|
||||
"max_message_size": config.MaxMessageSize,
|
||||
@@ -131,7 +132,7 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket connection established",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"active_connections": wsp.activeConnections.Load(),
|
||||
},
|
||||
@@ -141,28 +142,64 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
// Set message size limit
|
||||
clientConn.SetReadLimit(wsp.maxMessageSize)
|
||||
|
||||
// Connect to backend WebSocket with forwarded headers
|
||||
backendConn, err := wsp.dialBackend(ctx, headers)
|
||||
// Read first message to extract authentication from connection_init payload
|
||||
// This bridges the gap between clients that send auth in payload vs Hasura expecting it in HTTP headers
|
||||
messageType, message, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
wsp.errors.Add(1)
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to connect to backend WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Message: "Failed to read first message from client",
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ = clientConn.Close() // Best-effort cleanup
|
||||
return
|
||||
}
|
||||
|
||||
// Try to extract headers from connection_init payload (for GraphQL WebSocket protocols)
|
||||
enrichedHeaders := wsp.extractAuthFromPayload(message, headers)
|
||||
|
||||
// Connect to backend WebSocket with enriched headers
|
||||
backendConn, err := wsp.dialBackend(ctx, enrichedHeaders)
|
||||
if err != nil {
|
||||
wsp.errors.Add(1)
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to connect to backend WebSocket",
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ = clientConn.Close() // Best-effort cleanup
|
||||
return
|
||||
}
|
||||
defer func() { _ = backendConn.Close() }() // Best-effort cleanup
|
||||
|
||||
// Forward the first message (connection_init) to backend
|
||||
if err := backendConn.WriteMessage(messageType, message); err != nil {
|
||||
wsp.errors.Add(1)
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to forward connection_init to backend",
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
clientConn.Close()
|
||||
return
|
||||
}
|
||||
defer backendConn.Close()
|
||||
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Backend WebSocket connection established",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"subprotocol": backendConn.Subprotocol(),
|
||||
"has_authorization": headers.Get("Authorization") != "",
|
||||
@@ -194,7 +231,7 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket connection closed",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"duration_seconds": duration.Seconds(),
|
||||
"messages_sent": wsp.messagesSent.Load(),
|
||||
@@ -221,7 +258,7 @@ func (wsp *WebSocketProxy) proxyClientToBackend(ctx context.Context, client *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Client WebSocket closed normally",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
},
|
||||
})
|
||||
@@ -231,7 +268,7 @@ func (wsp *WebSocketProxy) proxyClientToBackend(ctx context.Context, client *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Error reading from client WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -249,7 +286,7 @@ func (wsp *WebSocketProxy) proxyClientToBackend(ctx context.Context, client *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Error writing to backend WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -261,7 +298,7 @@ func (wsp *WebSocketProxy) proxyClientToBackend(ctx context.Context, client *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Message proxied to backend",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"message_type": messageType,
|
||||
"message_size": len(message),
|
||||
@@ -285,7 +322,7 @@ func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *go
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Backend WebSocket closed normally",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
},
|
||||
})
|
||||
@@ -295,7 +332,7 @@ func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *go
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Error reading from backend WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -313,7 +350,7 @@ func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *go
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Error writing to client WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -325,7 +362,7 @@ func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *go
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Message proxied to client",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"message_type": messageType,
|
||||
"message_size": len(message),
|
||||
@@ -336,6 +373,58 @@ func (wsp *WebSocketProxy) proxyBackendToClient(ctx context.Context, backend *go
|
||||
}
|
||||
}
|
||||
|
||||
// extractAuthFromPayload extracts authentication headers from GraphQL WebSocket connection_init payload
|
||||
// This bridges the gap between clients sending auth in payload and Hasura expecting it in HTTP headers
|
||||
func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeaders http.Header) http.Header {
|
||||
// Create a copy of original headers
|
||||
enrichedHeaders := make(http.Header)
|
||||
for k, v := range originalHeaders {
|
||||
enrichedHeaders[k] = v
|
||||
}
|
||||
|
||||
// Try to parse as JSON to extract headers from payload
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
// Not JSON or parse error, return original headers
|
||||
return enrichedHeaders
|
||||
}
|
||||
|
||||
// Check if this is a connection_init message
|
||||
msgType, ok := msg["type"].(string)
|
||||
if !ok || (msgType != "connection_init" && msgType != "start") {
|
||||
// Not a connection_init, return original headers
|
||||
return enrichedHeaders
|
||||
}
|
||||
|
||||
// Extract payload
|
||||
payload, ok := msg["payload"].(map[string]any)
|
||||
if !ok {
|
||||
return enrichedHeaders
|
||||
}
|
||||
|
||||
// Try to extract headers from payload.headers (graphql-ws format)
|
||||
if payloadHeaders, ok := payload["headers"].(map[string]any); ok {
|
||||
for key, value := range payloadHeaders {
|
||||
if strValue, ok := value.(string); ok {
|
||||
enrichedHeaders.Set(key, strValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check top-level payload keys that look like headers (Apollo format)
|
||||
for key, value := range payload {
|
||||
if strValue, ok := value.(string); ok {
|
||||
// Common auth headers
|
||||
if key == "Authorization" || key == "authorization" ||
|
||||
key == "x-hasura-role" || key == "x-hasura-admin-secret" {
|
||||
enrichedHeaders.Set(key, strValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedHeaders
|
||||
}
|
||||
|
||||
// dialBackend establishes a WebSocket connection to the backend
|
||||
func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header) (*gorillaws.Conn, error) {
|
||||
// Convert http:// to ws:// or https:// to wss://
|
||||
@@ -373,8 +462,8 @@ func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header)
|
||||
}
|
||||
|
||||
// GetStats returns WebSocket statistics
|
||||
func (wsp *WebSocketProxy) GetStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (wsp *WebSocketProxy) GetStats() map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": wsp.enabled,
|
||||
"active_connections": wsp.activeConnections.Load(),
|
||||
"total_connections": wsp.totalConnections.Load(),
|
||||
|
||||
Reference in New Issue
Block a user