mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-05 23:03:48 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bff79e4f4 | |||
| b6e83f2837 | |||
| 287289cd80 | |||
| 21b429c98a | |||
| d96d2f429f | |||
| c2c75d69c0 | |||
| 65fa936b60 | |||
| 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 |
@@ -21,7 +21,7 @@ jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
docker-enabled: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem ./... -run=^# | tee output.txt
|
||||
|
||||
+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
|
||||
@@ -19,6 +19,7 @@ builds:
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.appVersion={{.Version}}
|
||||
|
||||
archives:
|
||||
- id: graphql-proxy
|
||||
|
||||
@@ -5,4 +5,9 @@ ARG TARGETOS
|
||||
# silly workaround for distroless image as no chmod is available
|
||||
COPY --chmod=777 --chown=nonroot:nonroot static/app /go/src/app
|
||||
ADD dist/bot-$TARGETOS-$TARGETARCH /go/src/app/graphql-proxy
|
||||
# Runtime tuning: operators should override GOMEMLIMIT per deployment
|
||||
# to match container memory limits (e.g. set to ~80% of cgroup limit).
|
||||
ENV GOMEMLIMIT=512MiB
|
||||
# NOTE: no HEALTHCHECK — distroless:nonroot lacks /bin/sh and curl/wget.
|
||||
# Use orchestrator-level probes (Kubernetes liveness/readiness) hitting /live on monitoring port.
|
||||
ENTRYPOINT ["/go/src/app/graphql-proxy"]
|
||||
|
||||
@@ -3,4 +3,9 @@ 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
|
||||
# Runtime tuning: operators should override GOMEMLIMIT per deployment
|
||||
# to match container memory limits (e.g. set to ~80% of cgroup limit).
|
||||
ENV GOMEMLIMIT=512MiB
|
||||
# NOTE: no HEALTHCHECK — distroless:nonroot lacks /bin/sh and curl/wget.
|
||||
# Use orchestrator-level probes (Kubernetes liveness/readiness) hitting /live on monitoring port.
|
||||
ENTRYPOINT ["/go/src/app/graphql-proxy"]
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
CI_RUN?=false
|
||||
TIMESTAMP := $(shell date +%Y%m%d-%H%M%S)
|
||||
|
||||
# Build hardening flags
|
||||
# -s: omit symbol table, -w: omit DWARF debug info (smaller binaries)
|
||||
LDFLAGS ?= -s -w
|
||||
# -trimpath: remove local filesystem paths from binary (reproducible builds)
|
||||
GOFLAGS ?= -trimpath
|
||||
# CGO_ENABLED=0: static binary, no libc dependency (distroless-friendly)
|
||||
export CGO_ENABLED = 0
|
||||
|
||||
# ADDITIONAL_BUILD_FLAGS=""
|
||||
|
||||
# ifeq ($(CI_RUN), true)
|
||||
@@ -17,15 +25,15 @@ run: build ## run application
|
||||
|
||||
.PHONY: build
|
||||
build: ## build the binary
|
||||
go build -o graphql-proxy *.go
|
||||
go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o graphql-proxy *.go
|
||||
|
||||
.PHONY: test
|
||||
test: ## run tests on library
|
||||
@LOG_LEVEL=info go test -v -cover -race ./...
|
||||
@CGO_ENABLED=1 LOG_LEVEL=info go test -v -cover -race ./...
|
||||
|
||||
.PHONY: test-packages
|
||||
test-packages: ## run tests on packages
|
||||
@go test -v -cover ./pkg/...
|
||||
@CGO_ENABLED=1 go test -v -cover -race ./pkg/...
|
||||
|
||||
.PHONY: all
|
||||
all: test-packages test
|
||||
@@ -37,11 +45,11 @@ update: ## update dependencies
|
||||
|
||||
.PHONY: build-amd64
|
||||
build-amd64: ## build the Linux AMD64 binary
|
||||
GOOS=linux GOARCH=amd64 go build -o graphql-proxy-amd64 *.go
|
||||
GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o graphql-proxy-amd64 *.go
|
||||
|
||||
.PHONY: build-arm64
|
||||
build-arm64: ## build the Linux ARM64 binary
|
||||
GOOS=linux GOARCH=arm64 go build -o graphql-proxy-arm64 *.go
|
||||
GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o graphql-proxy-arm64 *.go
|
||||
|
||||
.PHONY: build-all
|
||||
build-all: build-amd64 build-arm64 ## build both AMD64 and ARM64 binaries
|
||||
|
||||
@@ -198,6 +198,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba
|
||||
| `MAX_CONNS_PER_HOST` | Maximum connections per host | `1024` |
|
||||
| `CLIENT_DISABLE_TLS_VERIFY` | Disable TLS verification | `false` |
|
||||
| `LOG_LEVEL` | The log level | `info` |
|
||||
| `ENABLE_ALLOCATION_TRACKING` | Enable per-request memory allocation tracking | `false` |
|
||||
| `BLOCK_SCHEMA_INTROSPECTION`| Blocks the schema introspection | `false` |
|
||||
| `ALLOWED_INTROSPECTION` | Allow only certain queries in introspection | `` |
|
||||
| `ENABLE_ACCESS_LOG` | Enable the access log | `false` |
|
||||
@@ -224,6 +225,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba
|
||||
| `WEBSOCKET_PONG_TIMEOUT` | WebSocket pong timeout in seconds | `60` |
|
||||
| `WEBSOCKET_MAX_MESSAGE_SIZE` | Max WebSocket message size in bytes | `524288` (512KB) |
|
||||
| `ADMIN_DASHBOARD_ENABLE` | Enable admin dashboard UI | `true` |
|
||||
| `PPROF_PORT` | Localhost-only debug pprof endpoint port (default: disabled). Never expose publicly. | `` |
|
||||
|
||||
### Tracing
|
||||
|
||||
@@ -1098,16 +1100,18 @@ If you'd like the `/healthz` endpoint to perform actual check for the connectivi
|
||||
|
||||
Example metrics produced by the proxy:
|
||||
|
||||
The `executed_query` and `timed_query` metrics carry only the `{op_type, cached}` label set. The previous `user_id` and `op_name` labels were removed to bound Prometheus cardinality (per-user and per-operation-name labels caused unbounded series growth).
|
||||
|
||||
```
|
||||
graphql_proxy_timed_query_bucket{cached="false",user_id="-",op_type="mutation",op_name="updateUserDetails",vmrange="1.000e-02...1.136e-02"} 6
|
||||
graphql_proxy_timed_query_count{op_name="",cached="false",user_id="-",op_type=""} 78
|
||||
graphql_proxy_timed_query_bucket{op_name="MyQuery",cached="false",user_id="-",op_type="query",vmrange="5.995e+00...6.813e+00"} 1
|
||||
graphql_proxy_timed_query_sum{op_name="MyQuery",cached="false",user_id="-",op_type="query"} 6
|
||||
graphql_proxy_timed_query_count{op_name="MyQuery",cached="false",user_id="-",op_type="query"} 1
|
||||
graphql_proxy_executed_query{user_id="-",op_type="mutation",op_name="updateKnownSpammer",cached="false"} 1486
|
||||
graphql_proxy_executed_query{user_id="-",op_type="query",op_name="checkIfAdminsNeedRefreshing",cached="false"} 13167
|
||||
graphql_proxy_executed_query{user_id="1337",op_type="query",op_name="checkIfKnownMedia",cached="false"} 429
|
||||
graphql_proxy_executed_query{user_id="-",op_type="query",op_name="checkIfSpamAIRequiresUpdate",cached="false"} 8891
|
||||
graphql_proxy_timed_query_bucket{op_type="mutation",cached="false",vmrange="1.000e-02...1.136e-02"} 6
|
||||
graphql_proxy_timed_query_count{op_type="",cached="false"} 78
|
||||
graphql_proxy_timed_query_bucket{op_type="query",cached="false",vmrange="5.995e+00...6.813e+00"} 1
|
||||
graphql_proxy_timed_query_sum{op_type="query",cached="false"} 6
|
||||
graphql_proxy_timed_query_count{op_type="query",cached="false"} 1
|
||||
graphql_proxy_executed_query{op_type="mutation",cached="false"} 1486
|
||||
graphql_proxy_executed_query{op_type="query",cached="false"} 13167
|
||||
graphql_proxy_executed_query{op_type="query",cached="false"} 429
|
||||
graphql_proxy_executed_query{op_type="query",cached="true"} 8891
|
||||
graphql_proxy_requests_failed 324
|
||||
graphql_proxy_requests_skipped 0
|
||||
graphql_proxy_requests_succesful 454823
|
||||
@@ -1115,3 +1119,16 @@ graphql_proxy_cache_hit{microservice="graphql_proxy",pod="hasura-w-proxy-interna
|
||||
graphql_proxy_cache_hit{pod="hasura-w-proxy-internal-6b5f4b4bbb-9xwfc",microservice="graphql_proxy"} 1
|
||||
graphql_proxy_cache_miss{microservice="graphql_proxy",pod="hasura-w-proxy-internal-6b5f4b4bbb-9xwfc"} 23
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
On startup this binary sends a single anonymous adoption ping — project name,
|
||||
version, timestamp; no identifiers, no GraphQL operations, no query/response
|
||||
content. Fire-and-forget with a 2-second timeout; cannot block startup or
|
||||
panic.
|
||||
|
||||
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
|
||||
for the exact wire format, source, and full opt-out documentation.
|
||||
|
||||
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
|
||||
or `GRAPHQL_MONITORING_PROXY_DISABLE_TELEMETRY=1`.
|
||||
|
||||
+95
-76
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -9,9 +10,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 +70,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",
|
||||
},
|
||||
})
|
||||
@@ -88,18 +98,18 @@ func (ad *AdminDashboard) getStats(c *fiber.Ctx) error {
|
||||
if ad.logger != nil {
|
||||
ad.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to get aggregated metrics, falling back to local stats",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
// Fall through to local stats on error
|
||||
} else {
|
||||
// Return aggregated cluster stats
|
||||
response := map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
"timestamp": metrics.LastUpdate.Format(time.RFC3339),
|
||||
"version": "0.27.0",
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
// Add combined stats from aggregation
|
||||
@@ -115,12 +125,12 @@ func (ad *AdminDashboard) getStats(c *fiber.Ctx) error {
|
||||
|
||||
// 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 {
|
||||
@@ -130,7 +140,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,
|
||||
@@ -172,7 +182,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,
|
||||
@@ -205,16 +215,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),
|
||||
@@ -232,7 +242,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",
|
||||
}
|
||||
@@ -247,14 +257,14 @@ func (ad *AdminDashboard) getCircuitBreakerStatus(c *fiber.Ctx) error {
|
||||
cbMutex.RUnlock()
|
||||
|
||||
status["state"] = state.String()
|
||||
status["counts"] = 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]interface{}{
|
||||
status["config"] = map[string]any{
|
||||
"max_failures": cfg.CircuitBreaker.MaxFailures,
|
||||
"failure_ratio": cfg.CircuitBreaker.FailureRatio,
|
||||
"timeout": cfg.CircuitBreaker.Timeout,
|
||||
@@ -277,13 +287,13 @@ func (ad *AdminDashboard) getCacheStats(c *fiber.Ctx) error {
|
||||
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]interface{}{"error": err.Error()},
|
||||
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]interface{}{
|
||||
response := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
}
|
||||
@@ -321,7 +331,7 @@ func (ad *AdminDashboard) getCacheStats(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Local instance stats (fallback or non-cluster mode)
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"cluster_mode": false,
|
||||
"enabled": false,
|
||||
}
|
||||
@@ -376,7 +386,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,
|
||||
}
|
||||
|
||||
@@ -393,7 +403,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,
|
||||
})
|
||||
}
|
||||
@@ -406,7 +416,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,
|
||||
})
|
||||
}
|
||||
@@ -419,7 +429,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,
|
||||
})
|
||||
}
|
||||
@@ -430,7 +440,7 @@ func (ad *AdminDashboard) getWebSocketStats(c *fiber.Ctx) error {
|
||||
// clearCache clears the cache
|
||||
func (ad *AdminDashboard) clearCache(c *fiber.Ctx) error {
|
||||
libpack_cache.CacheClear()
|
||||
return c.JSON(map[string]interface{}{
|
||||
return c.JSON(map[string]any{
|
||||
"success": true,
|
||||
"message": "Cache cleared successfully",
|
||||
})
|
||||
@@ -443,7 +453,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",
|
||||
})
|
||||
@@ -456,7 +466,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",
|
||||
})
|
||||
@@ -466,7 +476,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,
|
||||
@@ -478,17 +488,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,
|
||||
@@ -503,7 +513,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,
|
||||
@@ -515,16 +525,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,
|
||||
@@ -537,7 +547,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,
|
||||
}
|
||||
@@ -562,7 +572,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,
|
||||
@@ -573,7 +583,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
|
||||
}
|
||||
}
|
||||
@@ -584,7 +594,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)
|
||||
@@ -596,7 +606,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,
|
||||
})
|
||||
@@ -605,7 +615,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",
|
||||
@@ -634,7 +644,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(),
|
||||
},
|
||||
})
|
||||
@@ -645,18 +655,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
|
||||
})
|
||||
|
||||
@@ -674,14 +684,23 @@ 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()
|
||||
|
||||
// Per-connection encoder + buffer reused across ticks to avoid
|
||||
// a fresh json.Marshal allocation every 2s per connection.
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
// 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)
|
||||
buf.Reset()
|
||||
if err := enc.Encode(stats); err == nil {
|
||||
// json.Encoder.Encode appends a trailing newline; strip it
|
||||
// so the wire format matches the previous json.Marshal output.
|
||||
_ = c.WriteMessage(websocket.TextMessage, bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,24 +711,24 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
// Gather all stats (cluster-aware for dashboard)
|
||||
stats := ad.gatherAllStatsClusterAware()
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
// Encode into reused buffer (no per-tick allocation churn)
|
||||
buf.Reset()
|
||||
if err := enc.Encode(stats); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Send to client
|
||||
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
// Send to client (strip trailing newline from Encoder to match prior format)
|
||||
if err := c.WriteMessage(websocket.TextMessage, bytes.TrimRight(buf.Bytes(), "\n")); err != nil {
|
||||
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
|
||||
@@ -724,34 +743,34 @@ func (ad *AdminDashboard) handleStatsWebSocket(c *websocket.Conn) {
|
||||
|
||||
// gatherAllStats collects all statistics into a single structure
|
||||
// This always returns LOCAL stats for this instance (used by metrics aggregator)
|
||||
func (ad *AdminDashboard) gatherAllStats() map[string]interface{} {
|
||||
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]interface{} {
|
||||
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]interface{} {
|
||||
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]interface{}{
|
||||
result := map[string]any{
|
||||
"cluster_mode": true,
|
||||
"total_instances": metrics.TotalInstances,
|
||||
"healthy_instances": metrics.HealthyInstances,
|
||||
}
|
||||
|
||||
// Build stats section from combined stats
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"timestamp": metrics.LastUpdate.Format(time.RFC3339),
|
||||
"version": "0.27.0",
|
||||
"version": libpack_config.PKG_VERSION,
|
||||
}
|
||||
|
||||
// Copy all combined stats
|
||||
@@ -771,16 +790,16 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
}
|
||||
|
||||
// Fall back to local stats
|
||||
result := make(map[string]interface{})
|
||||
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 {
|
||||
@@ -789,7 +808,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
skipped := getAdminMetricValue("requests_skipped")
|
||||
total := succeeded + failed + skipped
|
||||
|
||||
requestStats := map[string]interface{}{
|
||||
requestStats := map[string]any{
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
@@ -828,7 +847,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
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,
|
||||
@@ -841,16 +860,16 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
|
||||
// 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),
|
||||
@@ -865,7 +884,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
result["health"] = health
|
||||
|
||||
// Circuit breaker
|
||||
cbStatus := map[string]interface{}{
|
||||
cbStatus := map[string]any{
|
||||
"enabled": false,
|
||||
"state": "unknown",
|
||||
}
|
||||
@@ -880,14 +899,14 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
cbMutex.RUnlock()
|
||||
|
||||
cbStatus["state"] = state.String()
|
||||
cbStatus["counts"] = 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]interface{}{
|
||||
cbStatus["config"] = map[string]any{
|
||||
"max_failures": cfg.CircuitBreaker.MaxFailures,
|
||||
"failure_ratio": cfg.CircuitBreaker.FailureRatio,
|
||||
"timeout": cfg.CircuitBreaker.Timeout,
|
||||
@@ -899,7 +918,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
result["circuit_breaker"] = cbStatus
|
||||
|
||||
// Cache stats
|
||||
cacheStats := map[string]interface{}{
|
||||
cacheStats := map[string]any{
|
||||
"enabled": false,
|
||||
}
|
||||
|
||||
@@ -947,7 +966,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
|
||||
// Connection stats
|
||||
poolMgr := GetConnectionPoolManager()
|
||||
connStats := map[string]interface{}{
|
||||
connStats := map[string]any{
|
||||
"available": false,
|
||||
}
|
||||
|
||||
@@ -960,7 +979,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
// 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()
|
||||
}
|
||||
@@ -968,7 +987,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
// 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()
|
||||
}
|
||||
@@ -976,7 +995,7 @@ func (ad *AdminDashboard) gatherAllStatsWithMode(useClusterMode bool) map[string
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newClusterApp registers all cluster + control routes on a fresh Fiber app.
|
||||
func newClusterApp(t *testing.T) (*fiber.App, *AdminDashboard) {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
logger := libpack_logger.New()
|
||||
dashboard := NewAdminDashboard(logger)
|
||||
dashboard.RegisterRoutes(app)
|
||||
return app, dashboard
|
||||
}
|
||||
|
||||
// ensureNilAggregator guarantees no metrics aggregator is active for the test
|
||||
// and restores the original value after.
|
||||
func ensureNilAggregator(t *testing.T) {
|
||||
t.Helper()
|
||||
aggregatorMutex.Lock()
|
||||
orig := metricsAggregator
|
||||
metricsAggregator = nil
|
||||
aggregatorMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
aggregatorMutex.Lock()
|
||||
metricsAggregator = orig
|
||||
aggregatorMutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// ---- getClusterStats -------------------------------------------------------
|
||||
|
||||
func TestGetClusterStats_NoAggregator_Returns503(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
app, _ := newClusterApp(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin/api/cluster/stats", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, false, body["cluster_mode"])
|
||||
assert.NotEmpty(t, body["error"])
|
||||
}
|
||||
|
||||
// ---- getClusterInstances ---------------------------------------------------
|
||||
|
||||
func TestGetClusterInstances_NoAggregator_Returns503(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
app, _ := newClusterApp(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin/api/cluster/instances", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, false, body["cluster_mode"])
|
||||
assert.NotEmpty(t, body["error"])
|
||||
}
|
||||
|
||||
// ---- getClusterDebug -------------------------------------------------------
|
||||
|
||||
func TestGetClusterDebug_NoAggregator_Returns200WithFalseFlag(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
// also set cfg so the redis_cache_enabled branch is exercised
|
||||
cfg = &config{
|
||||
Logger: libpack_logger.New(),
|
||||
}
|
||||
cfg.Cache.CacheEnable = true
|
||||
cfg.Cache.CacheRedisEnable = false
|
||||
|
||||
app, _ := newClusterApp(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin/api/cluster/debug", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, false, body["aggregator_initialized"])
|
||||
assert.Equal(t, false, body["redis_cache_enabled"])
|
||||
assert.Equal(t, true, body["cache_enabled"])
|
||||
}
|
||||
|
||||
func TestGetClusterDebug_NilCfg_Returns200WithDefaults(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
orig := cfg
|
||||
cfg = nil
|
||||
defer func() { cfg = orig }()
|
||||
|
||||
app, _ := newClusterApp(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin/api/cluster/debug", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, false, body["aggregator_initialized"])
|
||||
assert.Equal(t, false, body["redis_cache_enabled"])
|
||||
}
|
||||
|
||||
// ---- forcePublish ----------------------------------------------------------
|
||||
|
||||
func TestForcePublish_NoAggregator_Returns503(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
app, _ := newClusterApp(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/admin/api/cluster/force-publish", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, false, body["success"])
|
||||
assert.NotEmpty(t, body["error"])
|
||||
}
|
||||
|
||||
// ---- gatherAllStats / gatherAllStatsWithMode / gatherAllStatsClusterAware --
|
||||
|
||||
func newDashboardForGather(t *testing.T) *AdminDashboard {
|
||||
t.Helper()
|
||||
logger := libpack_logger.New()
|
||||
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
||||
cfg = &config{
|
||||
Logger: logger,
|
||||
Monitoring: monitoring,
|
||||
}
|
||||
return NewAdminDashboard(logger)
|
||||
}
|
||||
|
||||
func TestGatherAllStats_ReturnsExpectedTopLevelKeys(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
ad := newDashboardForGather(t)
|
||||
|
||||
result := ad.gatherAllStats()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
// cluster_mode must be false when no aggregator
|
||||
assert.Equal(t, false, result["cluster_mode"])
|
||||
|
||||
// stats sub-map must exist
|
||||
statsRaw, ok := result["stats"]
|
||||
assert.True(t, ok, "stats key must be present")
|
||||
stats, ok := statsRaw.(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, stats["timestamp"])
|
||||
assert.NotNil(t, stats["uptime_seconds"])
|
||||
assert.NotNil(t, stats["uptime_human"])
|
||||
assert.NotEmpty(t, stats["version"])
|
||||
assert.NotNil(t, stats["requests"])
|
||||
|
||||
// health sub-map must exist
|
||||
healthRaw, ok := result["health"]
|
||||
assert.True(t, ok, "health key must be present")
|
||||
health, ok := healthRaw.(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.NotNil(t, health["status"])
|
||||
assert.NotNil(t, health["backend"])
|
||||
}
|
||||
|
||||
func TestGatherAllStatsWithMode_FalseMode_ReturnsLocalStats(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
ad := newDashboardForGather(t)
|
||||
|
||||
result := ad.gatherAllStatsWithMode(false)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, false, result["cluster_mode"])
|
||||
assert.NotNil(t, result["stats"])
|
||||
assert.NotNil(t, result["health"])
|
||||
}
|
||||
|
||||
func TestGatherAllStatsWithMode_TrueModeNoAggregator_FallsBackToLocal(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
ad := newDashboardForGather(t)
|
||||
|
||||
// With no aggregator, cluster mode request must fall back to local stats.
|
||||
result := ad.gatherAllStatsWithMode(true)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, false, result["cluster_mode"])
|
||||
}
|
||||
|
||||
func TestGatherAllStatsClusterAware_NoAggregator_FallsBackToLocal(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
ad := newDashboardForGather(t)
|
||||
|
||||
result := ad.gatherAllStatsClusterAware()
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, false, result["cluster_mode"])
|
||||
}
|
||||
|
||||
func TestGatherAllStats_NilCfg_ReturnsStatsWithoutRequests(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
origCfg := cfg
|
||||
cfg = nil
|
||||
defer func() { cfg = origCfg }()
|
||||
|
||||
ad := NewAdminDashboard(nil)
|
||||
|
||||
result := ad.gatherAllStats()
|
||||
assert.NotNil(t, result)
|
||||
stats, ok := result["stats"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
// when cfg is nil, "requests" key must NOT be present
|
||||
_, hasRequests := stats["requests"]
|
||||
assert.False(t, hasRequests)
|
||||
}
|
||||
|
||||
func TestGatherAllStats_RequestStatsShape(t *testing.T) {
|
||||
ensureNilAggregator(t)
|
||||
ad := newDashboardForGather(t)
|
||||
|
||||
result := ad.gatherAllStats()
|
||||
stats := result["stats"].(map[string]any)
|
||||
requests, ok := stats["requests"].(map[string]any)
|
||||
assert.True(t, ok, "requests must be a map")
|
||||
assert.NotNil(t, requests["total"])
|
||||
assert.NotNil(t, requests["succeeded"])
|
||||
assert.NotNil(t, requests["failed"])
|
||||
assert.NotNil(t, requests["skipped"])
|
||||
assert.NotNil(t, requests["success_rate_pct"])
|
||||
assert.NotNil(t, requests["failure_rate_pct"])
|
||||
assert.NotNil(t, requests["skip_rate_pct"])
|
||||
assert.NotNil(t, requests["avg_requests_per_second"])
|
||||
assert.NotNil(t, requests["current_requests_per_second"])
|
||||
}
|
||||
+15
-15
@@ -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)
|
||||
@@ -236,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)
|
||||
@@ -260,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)
|
||||
@@ -283,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)
|
||||
@@ -306,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)
|
||||
@@ -329,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)
|
||||
@@ -352,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)
|
||||
@@ -383,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)
|
||||
@@ -410,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)
|
||||
@@ -475,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"])
|
||||
@@ -486,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"])
|
||||
@@ -497,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"])
|
||||
|
||||
@@ -17,10 +17,7 @@ import (
|
||||
"github.com/sony/gobreaker"
|
||||
)
|
||||
|
||||
var (
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex sync.RWMutex
|
||||
)
|
||||
var bannedUsersIDs sync.Map // key: userID string, value: reason string
|
||||
|
||||
// authMiddleware provides API key authentication for admin endpoints
|
||||
func authMiddleware(c *fiber.Ctx) error {
|
||||
@@ -37,7 +34,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 +43,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 +58,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,31 +129,29 @@ 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": snapshotBannedUsers()},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkIfUserIsBanned(c *fiber.Ctx, userID string) bool {
|
||||
bannedUsersIDsMutex.RLock()
|
||||
_, found := bannedUsersIDs[userID]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
_, found := bannedUsersIDs.Load(userID)
|
||||
|
||||
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()},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -225,7 +237,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")
|
||||
}
|
||||
@@ -234,13 +246,11 @@ func apiBanUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("user_id and reason are required")
|
||||
}
|
||||
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs[req.UserID] = req.Reason
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
bannedUsersIDs.Store(req.UserID, req.Reason)
|
||||
|
||||
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 {
|
||||
@@ -255,7 +265,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")
|
||||
}
|
||||
@@ -264,13 +274,11 @@ func apiUnbanUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("user_id is required")
|
||||
}
|
||||
|
||||
bannedUsersIDsMutex.Lock()
|
||||
delete(bannedUsersIDs, req.UserID)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
bannedUsersIDs.Delete(req.UserID)
|
||||
|
||||
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 {
|
||||
@@ -289,19 +297,17 @@ 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()},
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
bannedUsersIDsMutex.RLock()
|
||||
data, err := json.Marshal(bannedUsersIDs)
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
data, err := json.Marshal(snapshotBannedUsers())
|
||||
|
||||
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
|
||||
}
|
||||
@@ -309,7 +315,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
|
||||
}
|
||||
@@ -321,12 +327,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
|
||||
}
|
||||
@@ -336,7 +342,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
|
||||
}
|
||||
@@ -344,7 +350,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()},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -353,7 +359,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
|
||||
}
|
||||
@@ -362,14 +368,38 @@ 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
|
||||
}
|
||||
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = newBannedUsers
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(newBannedUsers)
|
||||
}
|
||||
|
||||
// snapshotBannedUsers returns a plain map copy of the current banned users.
|
||||
func snapshotBannedUsers() map[string]string {
|
||||
out := make(map[string]string)
|
||||
bannedUsersIDs.Range(func(k, v any) bool {
|
||||
ks, kok := k.(string)
|
||||
vs, vok := v.(string)
|
||||
if kok && vok {
|
||||
out[ks] = vs
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// replaceBannedUsers swaps the banned users set with the provided map.
|
||||
// Existing entries are removed before inserting the new ones.
|
||||
func replaceBannedUsers(newUsers map[string]string) {
|
||||
bannedUsersIDs.Range(func(k, _ any) bool {
|
||||
bannedUsersIDs.Delete(k)
|
||||
return true
|
||||
})
|
||||
for k, v := range newUsers {
|
||||
bannedUsersIDs.Store(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func lockFile(fileLock *flock.Flock) error {
|
||||
@@ -388,7 +418,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
|
||||
}
|
||||
@@ -396,7 +426,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")
|
||||
}
|
||||
@@ -418,7 +448,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
|
||||
}
|
||||
@@ -426,7 +456,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")
|
||||
}
|
||||
|
||||
+30
-51
@@ -18,9 +18,7 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
cfg.Api.BannedUsersFile = filepath.Join(os.TempDir(), "banned_users_reload_test.json")
|
||||
|
||||
// Initial empty banned users
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Create a test version of periodicallyReloadBannedUsers that executes once and signals completion
|
||||
done := make(chan bool)
|
||||
@@ -37,9 +35,7 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
_ = os.Remove(fmt.Sprintf("%s.lock", cfg.Api.BannedUsersFile))
|
||||
|
||||
// Ensure banned users map is empty
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Execute reloader once
|
||||
go testPeriodicallyReloadBannedUsers()
|
||||
@@ -50,9 +46,7 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Safely check the map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
mapSize := len(bannedUsersIDs)
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
mapSize := len(snapshotBannedUsers())
|
||||
|
||||
// Verify map is still empty
|
||||
assert.Equal(suite.T(), 0, mapSize)
|
||||
@@ -70,20 +64,17 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Clear the banned users map
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Execute reloader once
|
||||
go testPeriodicallyReloadBannedUsers()
|
||||
<-done
|
||||
|
||||
// Safely check the map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
mapSize := len(bannedUsersIDs)
|
||||
value1 := bannedUsersIDs["test-user-reload-1"]
|
||||
value2 := bannedUsersIDs["test-user-reload-2"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
snap := snapshotBannedUsers()
|
||||
mapSize := len(snap)
|
||||
value1 := snap["test-user-reload-1"]
|
||||
value2 := snap["test-user-reload-2"]
|
||||
|
||||
// Verify banned users map was loaded
|
||||
assert.Equal(suite.T(), 2, mapSize)
|
||||
@@ -102,19 +93,16 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Clear the banned users map
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Execute reloader once to load initial data
|
||||
go testPeriodicallyReloadBannedUsers()
|
||||
<-done
|
||||
|
||||
// Safely check the map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
mapSize := len(bannedUsersIDs)
|
||||
initialValue := bannedUsersIDs["test-user-initial"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
snap := snapshotBannedUsers()
|
||||
mapSize := len(snap)
|
||||
initialValue := snap["test-user-initial"]
|
||||
|
||||
// Verify initial data was loaded
|
||||
assert.Equal(suite.T(), 1, mapSize)
|
||||
@@ -134,12 +122,11 @@ func (suite *Tests) Test_PeriodicallyReloadBannedUsers() {
|
||||
<-done
|
||||
|
||||
// Safely check the map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
mapSize = len(bannedUsersIDs)
|
||||
value1 := bannedUsersIDs["test-user-updated-1"]
|
||||
value2 := bannedUsersIDs["test-user-updated-2"]
|
||||
_, exists := bannedUsersIDs["test-user-initial"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
snap = snapshotBannedUsers()
|
||||
mapSize = len(snap)
|
||||
value1 := snap["test-user-updated-1"]
|
||||
value2 := snap["test-user-updated-2"]
|
||||
_, exists := snap["test-user-initial"]
|
||||
|
||||
// Verify updated data was loaded
|
||||
assert.Equal(suite.T(), 2, mapSize)
|
||||
@@ -175,19 +162,16 @@ func (suite *Tests) Test_LoadUnloadBannedUsers() {
|
||||
// Test loading banned users
|
||||
suite.Run("load banned users", func() {
|
||||
// Clear the banned users map
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Load banned users
|
||||
loadBannedUsers()
|
||||
|
||||
// Check the banned users map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
count := len(bannedUsersIDs)
|
||||
reason1 := bannedUsersIDs["user1"]
|
||||
reason2 := bannedUsersIDs["user2"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
snap := snapshotBannedUsers()
|
||||
count := len(snap)
|
||||
reason1 := snap["user1"]
|
||||
reason2 := snap["user2"]
|
||||
|
||||
assert.Equal(suite.T(), 2, count)
|
||||
assert.Equal(suite.T(), "reason1", reason1)
|
||||
@@ -197,32 +181,27 @@ func (suite *Tests) Test_LoadUnloadBannedUsers() {
|
||||
// Test updating banned users
|
||||
suite.Run("update banned users", func() {
|
||||
// Update the banned users map
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = map[string]string{
|
||||
replaceBannedUsers(map[string]string{
|
||||
"user3": "reason3",
|
||||
"user4": "reason4",
|
||||
}
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
})
|
||||
|
||||
// Store the updated banned users
|
||||
err := storeBannedUsers()
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Clear the banned users map
|
||||
bannedUsersIDsMutex.Lock()
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDsMutex.Unlock()
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Load banned users again
|
||||
loadBannedUsers()
|
||||
|
||||
// Check the banned users map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
count := len(bannedUsersIDs)
|
||||
reason3 := bannedUsersIDs["user3"]
|
||||
reason4 := bannedUsersIDs["user4"]
|
||||
_, user1Exists := bannedUsersIDs["user1"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
snap := snapshotBannedUsers()
|
||||
count := len(snap)
|
||||
reason3 := snap["user3"]
|
||||
reason4 := snap["user4"]
|
||||
_, user1Exists := snap["user1"]
|
||||
|
||||
assert.Equal(suite.T(), 2, count)
|
||||
assert.Equal(suite.T(), "reason3", reason3)
|
||||
|
||||
+16
-16
@@ -46,7 +46,7 @@ func (suite *APIAuthSecurityTestSuite) SetupTest() {
|
||||
})
|
||||
|
||||
// Initialize banned users map
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
// Setup banned users file path
|
||||
cfg.Api.BannedUsersFile = filepath.Join(os.TempDir(), "banned_users_auth_test.json")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func setupMinimalCfg(t *testing.T) {
|
||||
t.Helper()
|
||||
logger := libpack_logger.New()
|
||||
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
||||
cfg = &config{
|
||||
Logger: logger,
|
||||
Monitoring: monitoring,
|
||||
}
|
||||
}
|
||||
|
||||
func newHealthApp(t *testing.T) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New(fiber.Config{
|
||||
// suppress stack-trace noise in test output
|
||||
})
|
||||
app.Get("/api/backend/health", apiBackendHealth)
|
||||
app.Get("/api/pool/health", apiConnectionPoolHealth)
|
||||
app.Get("/api/circuit-breaker/health", apiCircuitBreakerHealth)
|
||||
return app
|
||||
}
|
||||
|
||||
// ---- apiBackendHealth ------------------------------------------------------
|
||||
|
||||
func TestApiBackendHealth_NilManager_Returns503(t *testing.T) {
|
||||
// Ensure global manager is nil for this test.
|
||||
orig := backendHealthManager
|
||||
backendHealthManager = nil
|
||||
defer func() { backendHealthManager = orig }()
|
||||
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "unknown", body["status"])
|
||||
assert.NotEmpty(t, body["message"])
|
||||
}
|
||||
|
||||
func TestApiBackendHealth_HealthyManager_Returns200(t *testing.T) {
|
||||
orig := backendHealthManager
|
||||
defer func() { backendHealthManager = orig }()
|
||||
|
||||
// inject a healthy manager directly (bypassing sync.Once)
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
||||
mgr.isHealthy.Store(true)
|
||||
backendHealthManager = mgr
|
||||
|
||||
setupMinimalCfg(t)
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "healthy", body["status"])
|
||||
assert.NotNil(t, body["backend_url"])
|
||||
assert.NotNil(t, body["consecutive_failures"])
|
||||
assert.NotNil(t, body["check_interval"])
|
||||
}
|
||||
|
||||
func TestApiBackendHealth_UnhealthyManager_Returns503(t *testing.T) {
|
||||
orig := backendHealthManager
|
||||
defer func() { backendHealthManager = orig }()
|
||||
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
||||
mgr.isHealthy.Store(false)
|
||||
backendHealthManager = mgr
|
||||
|
||||
setupMinimalCfg(t)
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/backend/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "unhealthy", body["status"])
|
||||
}
|
||||
|
||||
// ---- apiConnectionPoolHealth -----------------------------------------------
|
||||
|
||||
func TestApiConnectionPoolHealth_NilManager_Returns503(t *testing.T) {
|
||||
connectionPoolMutex.Lock()
|
||||
orig := connectionPoolManager
|
||||
connectionPoolManager = nil
|
||||
connectionPoolMutex.Unlock()
|
||||
defer func() {
|
||||
connectionPoolMutex.Lock()
|
||||
connectionPoolManager = orig
|
||||
connectionPoolMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "unknown", body["status"])
|
||||
assert.NotEmpty(t, body["message"])
|
||||
}
|
||||
|
||||
func TestApiConnectionPoolHealth_HealthyPool_Returns200(t *testing.T) {
|
||||
connectionPoolMutex.Lock()
|
||||
orig := connectionPoolManager
|
||||
mgr := NewConnectionPoolManager(&fasthttp.Client{})
|
||||
connectionPoolManager = mgr
|
||||
connectionPoolMutex.Unlock()
|
||||
defer func() {
|
||||
connectionPoolMutex.Lock()
|
||||
_ = mgr.Shutdown()
|
||||
connectionPoolManager = orig
|
||||
connectionPoolMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "healthy", body["status"])
|
||||
assert.NotNil(t, body["active_connections"])
|
||||
assert.NotNil(t, body["total_connections"])
|
||||
assert.NotNil(t, body["connection_failures"])
|
||||
}
|
||||
|
||||
func TestApiConnectionPoolHealth_DegradedPool_Returns200WithDegradedStatus(t *testing.T) {
|
||||
connectionPoolMutex.Lock()
|
||||
orig := connectionPoolManager
|
||||
mgr := NewConnectionPoolManager(&fasthttp.Client{})
|
||||
// push failure counter above threshold (10)
|
||||
for range 15 {
|
||||
mgr.connectionFailures.Add(1)
|
||||
}
|
||||
connectionPoolManager = mgr
|
||||
connectionPoolMutex.Unlock()
|
||||
defer func() {
|
||||
connectionPoolMutex.Lock()
|
||||
_ = mgr.Shutdown()
|
||||
connectionPoolManager = orig
|
||||
connectionPoolMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/pool/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
// handler returns 200 even for degraded
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "degraded", body["status"])
|
||||
}
|
||||
|
||||
// ---- apiCircuitBreakerHealth -----------------------------------------------
|
||||
|
||||
func TestApiCircuitBreakerHealth_NilCB_Returns503(t *testing.T) {
|
||||
cbMutex.Lock()
|
||||
origCB := cb
|
||||
cb = nil
|
||||
cbMutex.Unlock()
|
||||
defer func() {
|
||||
cbMutex.Lock()
|
||||
cb = origCB
|
||||
cbMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/circuit-breaker/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "disabled", body["status"])
|
||||
assert.NotEmpty(t, body["message"])
|
||||
}
|
||||
|
||||
func TestApiCircuitBreakerHealth_ClosedCB_Returns200Healthy(t *testing.T) {
|
||||
cbMutex.Lock()
|
||||
origCB := cb
|
||||
cbMutex.Unlock()
|
||||
defer func() {
|
||||
cbMutex.Lock()
|
||||
cb = origCB
|
||||
cbMutex.Unlock()
|
||||
}()
|
||||
|
||||
logger := libpack_logger.New()
|
||||
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
||||
cfg = &config{Logger: logger, Monitoring: monitoring}
|
||||
cfg.CircuitBreaker.Enable = true
|
||||
cfg.CircuitBreaker.MaxFailures = 5
|
||||
cfg.CircuitBreaker.Timeout = 30
|
||||
initCircuitBreaker(cfg)
|
||||
|
||||
// cb is now set by initCircuitBreaker; circuit starts closed (healthy)
|
||||
app := newHealthApp(t)
|
||||
req := httptest.NewRequest("GET", "/api/circuit-breaker/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, json.Unmarshal(raw, &body))
|
||||
assert.Equal(t, "healthy", body["status"])
|
||||
assert.NotNil(t, body["state"])
|
||||
assert.NotNil(t, body["counts"])
|
||||
assert.NotNil(t, body["configuration"])
|
||||
|
||||
counts, ok := body["counts"].(map[string]any)
|
||||
assert.True(t, ok)
|
||||
assert.NotNil(t, counts["requests"])
|
||||
assert.NotNil(t, counts["total_successes"])
|
||||
assert.NotNil(t, counts["total_failures"])
|
||||
assert.NotNil(t, counts["consecutive_successes"])
|
||||
assert.NotNil(t, counts["consecutive_failures"])
|
||||
}
|
||||
+20
-25
@@ -33,7 +33,7 @@ func (suite *Tests) Test_apiBanUser() {
|
||||
// Test valid ban request
|
||||
suite.Run("valid ban request", func() {
|
||||
// Clear banned users map
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
reqBody := `{"user_id": "test-user-123", "reason": "testing"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user-ban", bytes.NewBufferString(reqBody))
|
||||
@@ -48,12 +48,11 @@ func (suite *Tests) Test_apiBanUser() {
|
||||
assert.Contains(suite.T(), string(body), "OK: user banned")
|
||||
|
||||
// Verify user was added to banned users map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
reason, exists := bannedUsersIDs["test-user-123"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
|
||||
v, exists := bannedUsersIDs.Load("test-user-123")
|
||||
assert.True(suite.T(), exists)
|
||||
assert.Equal(suite.T(), "testing", reason)
|
||||
if exists {
|
||||
assert.Equal(suite.T(), "testing", v.(string))
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
_, err = os.Stat(cfg.Api.BannedUsersFile)
|
||||
@@ -124,8 +123,7 @@ func (suite *Tests) Test_apiUnbanUser() {
|
||||
// Test valid unban request
|
||||
suite.Run("valid unban request", func() {
|
||||
// Add a user to the banned list
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDs["test-user-123"] = "testing"
|
||||
replaceBannedUsers(map[string]string{"test-user-123": "testing"})
|
||||
|
||||
reqBody := `{"user_id": "test-user-123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user-unban", bytes.NewBufferString(reqBody))
|
||||
@@ -140,10 +138,7 @@ func (suite *Tests) Test_apiUnbanUser() {
|
||||
assert.Contains(suite.T(), string(body), "OK: user unbanned")
|
||||
|
||||
// Verify user was removed from banned users map
|
||||
bannedUsersIDsMutex.RLock()
|
||||
_, exists := bannedUsersIDs["test-user-123"]
|
||||
bannedUsersIDsMutex.RUnlock()
|
||||
|
||||
_, exists := bannedUsersIDs.Load("test-user-123")
|
||||
assert.False(suite.T(), exists)
|
||||
})
|
||||
|
||||
@@ -273,7 +268,7 @@ func (suite *Tests) Test_checkIfUserIsBanned() {
|
||||
|
||||
// Test with non-banned user
|
||||
suite.Run("non-banned user", func() {
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
|
||||
isBanned := checkIfUserIsBanned(ctx, "non-banned-user")
|
||||
assert.False(suite.T(), isBanned)
|
||||
@@ -282,8 +277,7 @@ func (suite *Tests) Test_checkIfUserIsBanned() {
|
||||
|
||||
// Test with banned user
|
||||
suite.Run("banned user", func() {
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
bannedUsersIDs["banned-user"] = "testing"
|
||||
replaceBannedUsers(map[string]string{"banned-user": "testing"})
|
||||
|
||||
isBanned := checkIfUserIsBanned(ctx, "banned-user")
|
||||
assert.True(suite.T(), isBanned)
|
||||
@@ -303,7 +297,7 @@ func (suite *Tests) Test_loadBannedUsers() {
|
||||
// Remove file if it exists
|
||||
_ = os.Remove(cfg.Api.BannedUsersFile)
|
||||
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
loadBannedUsers()
|
||||
|
||||
// Verify file was created
|
||||
@@ -311,7 +305,7 @@ func (suite *Tests) Test_loadBannedUsers() {
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Verify banned users map is empty
|
||||
assert.Equal(suite.T(), 0, len(bannedUsersIDs))
|
||||
assert.Equal(suite.T(), 0, len(snapshotBannedUsers()))
|
||||
})
|
||||
|
||||
// Test with existing file
|
||||
@@ -325,13 +319,14 @@ func (suite *Tests) Test_loadBannedUsers() {
|
||||
err := os.WriteFile(cfg.Api.BannedUsersFile, data, 0o644)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
loadBannedUsers()
|
||||
|
||||
// Verify banned users map was loaded
|
||||
assert.Equal(suite.T(), 2, len(bannedUsersIDs))
|
||||
assert.Equal(suite.T(), "reason 1", bannedUsersIDs["test-user-1"])
|
||||
assert.Equal(suite.T(), "reason 2", bannedUsersIDs["test-user-2"])
|
||||
snap := snapshotBannedUsers()
|
||||
assert.Equal(suite.T(), 2, len(snap))
|
||||
assert.Equal(suite.T(), "reason 1", snap["test-user-1"])
|
||||
assert.Equal(suite.T(), "reason 2", snap["test-user-2"])
|
||||
})
|
||||
|
||||
// Test with invalid JSON
|
||||
@@ -340,11 +335,11 @@ func (suite *Tests) Test_loadBannedUsers() {
|
||||
err := os.WriteFile(cfg.Api.BannedUsersFile, []byte("{invalid json}"), 0o644)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
bannedUsersIDs = make(map[string]string)
|
||||
replaceBannedUsers(map[string]string{})
|
||||
loadBannedUsers()
|
||||
|
||||
// Verify banned users map is empty (load failed)
|
||||
assert.Equal(suite.T(), 0, len(bannedUsersIDs))
|
||||
assert.Equal(suite.T(), 0, len(snapshotBannedUsers()))
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
@@ -362,10 +357,10 @@ func (suite *Tests) Test_storeBannedUsers() {
|
||||
// Test storing banned users
|
||||
suite.Run("store banned users", func() {
|
||||
// Set up test data
|
||||
bannedUsersIDs = map[string]string{
|
||||
replaceBannedUsers(map[string]string{
|
||||
"test-user-1": "reason 1",
|
||||
"test-user-2": "reason 2",
|
||||
}
|
||||
})
|
||||
|
||||
err := storeBannedUsers()
|
||||
assert.NoError(suite.T(), 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
+11
-8
@@ -1,3 +1,6 @@
|
||||
// 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 (
|
||||
@@ -117,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)
|
||||
@@ -143,7 +146,7 @@ func EnableCache(cfg *CacheConfig) {
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Using in-memory cache",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"type": cacheType,
|
||||
"max_memory_size_bytes": maxMemory,
|
||||
"max_entries": maxEntries,
|
||||
@@ -179,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
|
||||
}
|
||||
@@ -188,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},
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -197,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
|
||||
}
|
||||
@@ -215,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 {
|
||||
@@ -240,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)
|
||||
@@ -252,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)
|
||||
|
||||
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
package libpack_cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
libpack_cache_memory "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
ta "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// helper resets package-level globals and returns a cleanup func.
|
||||
func withFreshMemoryCache(t *testing.T, ttl time.Duration) func() {
|
||||
t.Helper()
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
config = &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
Client: libpack_cache_memory.New(ttl),
|
||||
TTL: int(ttl.Seconds()),
|
||||
}
|
||||
cacheStats = &CacheStats{}
|
||||
return func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCacheMemoryUsage_Initialized covers the initialized branch (was 0%).
|
||||
func TestGetCacheMemoryUsage_Initialized_ReturnsNonNegative(t *testing.T) {
|
||||
defer withFreshMemoryCache(t, 5*time.Minute)()
|
||||
|
||||
usage := GetCacheMemoryUsage()
|
||||
ta.GreaterOrEqual(t, usage, int64(0))
|
||||
}
|
||||
|
||||
// TestGetCacheMemoryUsage_Uninitialized covers the early-return branch.
|
||||
func TestGetCacheMemoryUsage_Uninitialized_ReturnsZero(t *testing.T) {
|
||||
prev := config
|
||||
config = nil
|
||||
defer func() { config = prev }()
|
||||
|
||||
ta.Equal(t, int64(0), GetCacheMemoryUsage())
|
||||
}
|
||||
|
||||
// TestGetCacheMaxMemorySize_Initialized covers the initialized branch (was 0%).
|
||||
func TestGetCacheMaxMemorySize_Initialized_ReturnsPositive(t *testing.T) {
|
||||
defer withFreshMemoryCache(t, 5*time.Minute)()
|
||||
|
||||
maxSize := GetCacheMaxMemorySize()
|
||||
ta.Greater(t, maxSize, int64(0))
|
||||
}
|
||||
|
||||
// TestGetCacheMaxMemorySize_Uninitialized covers the early-return branch.
|
||||
func TestGetCacheMaxMemorySize_Uninitialized_ReturnsZero(t *testing.T) {
|
||||
prev := config
|
||||
config = nil
|
||||
defer func() { config = prev }()
|
||||
|
||||
ta.Equal(t, int64(0), GetCacheMaxMemorySize())
|
||||
}
|
||||
|
||||
// TestEnableCache_LRUBranch covers cfg.Memory.UseLRU == true branch in EnableCache.
|
||||
func TestEnableCache_LRUBranch_InitializesLRUClient(t *testing.T) {
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
defer func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}()
|
||||
|
||||
cfg := &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
TTL: 5,
|
||||
}
|
||||
cfg.Memory.UseLRU = true
|
||||
cfg.Memory.MaxMemorySize = 1024 * 1024
|
||||
cfg.Memory.MaxEntries = 100
|
||||
|
||||
EnableCache(cfg)
|
||||
require.NotNil(t, config.Client, "LRU client must be set")
|
||||
ta.True(t, IsCacheInitialized())
|
||||
|
||||
// Verify basic ops work with LRU client.
|
||||
CacheStore("lru-key", []byte("lru-val"))
|
||||
got := CacheLookup("lru-key")
|
||||
ta.Equal(t, []byte("lru-val"), got)
|
||||
}
|
||||
|
||||
// TestEnableCache_NilLogger covers the auto-logger creation branch.
|
||||
func TestEnableCache_NilLogger_AutoCreatesLogger(t *testing.T) {
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
defer func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}()
|
||||
|
||||
cfg := &CacheConfig{
|
||||
Logger: nil, // deliberately nil
|
||||
TTL: 5,
|
||||
}
|
||||
// Should not panic; logger is created internally.
|
||||
ta.NotPanics(t, func() { EnableCache(cfg) })
|
||||
ta.NotNil(t, cfg.Logger)
|
||||
}
|
||||
|
||||
// TestEnableCache_MemoryDefaults covers the default memory sizing branch (maxMemory<=0).
|
||||
func TestEnableCache_MemoryDefaults_UsesDefaultSizes(t *testing.T) {
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
defer func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}()
|
||||
|
||||
cfg := &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
TTL: 5,
|
||||
}
|
||||
// MaxMemorySize and MaxEntries left at zero → defaults kick in.
|
||||
EnableCache(cfg)
|
||||
require.NotNil(t, config.Client)
|
||||
ta.Greater(t, GetCacheMaxMemorySize(), int64(0))
|
||||
}
|
||||
|
||||
// TestEnableCache_RedisFallback covers the Redis error → memory fallback branch.
|
||||
func TestEnableCache_RedisFallback_FallsBackToMemory(t *testing.T) {
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
defer func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}()
|
||||
|
||||
cfg := &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
TTL: 5,
|
||||
}
|
||||
cfg.Redis.Enable = true
|
||||
cfg.Redis.URL = "127.0.0.1:1" // unreachable port → connection error
|
||||
cfg.Redis.DB = 0
|
||||
|
||||
// Must not panic; should fall back to memory.
|
||||
ta.NotPanics(t, func() { EnableCache(cfg) })
|
||||
require.NotNil(t, config.Client, "fallback memory client must be set")
|
||||
|
||||
// Verify it actually works as a memory cache.
|
||||
CacheStore("fallback-key", []byte("fallback-val"))
|
||||
got := CacheLookup("fallback-key")
|
||||
ta.Equal(t, []byte("fallback-val"), got)
|
||||
}
|
||||
|
||||
// TestCacheStore_Uninitialized covers the early-return + log branch in CacheStore (line 238-242).
|
||||
func TestCacheStore_Uninitialized_DoesNotPanic(t *testing.T) {
|
||||
prev := config
|
||||
config = &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
Client: nil, // IsCacheInitialized() returns false
|
||||
}
|
||||
defer func() { config = prev }()
|
||||
|
||||
ta.NotPanics(t, func() {
|
||||
CacheStore("any-key", []byte("any-val"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestCacheClear_Uninitialized covers the early-return in CacheClear.
|
||||
func TestCacheClear_Uninitialized_DoesNotPanic(t *testing.T) {
|
||||
prev := config
|
||||
config = nil
|
||||
defer func() { config = prev }()
|
||||
|
||||
ta.NotPanics(t, func() { CacheClear() })
|
||||
}
|
||||
|
||||
// TestCacheDelete_ZeroStats covers the CAS loop branch where CachedQueries is already 0.
|
||||
func TestCacheDelete_ZeroStats_DoesNotDecrementBelowZero(t *testing.T) {
|
||||
defer withFreshMemoryCache(t, 5*time.Minute)()
|
||||
cacheStats.CachedQueries = 0 // already at zero
|
||||
|
||||
// Should not panic and stats should stay at 0.
|
||||
CacheDelete("nonexistent-key")
|
||||
ta.Equal(t, int64(0), cacheStats.CachedQueries)
|
||||
}
|
||||
|
||||
// TestEnableCache_Redis_HappyPath covers successful Redis init via miniredis.
|
||||
func TestEnableCache_Redis_HappyPath_StoresAndRetrieves(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
prev := config
|
||||
prevStats := cacheStats
|
||||
defer func() {
|
||||
config = prev
|
||||
cacheStats = prevStats
|
||||
}()
|
||||
|
||||
cfg := &CacheConfig{
|
||||
Logger: libpack_logger.New(),
|
||||
TTL: 5,
|
||||
}
|
||||
cfg.Redis.Enable = true
|
||||
cfg.Redis.URL = mr.Addr()
|
||||
cfg.Redis.DB = 0
|
||||
EnableCache(cfg)
|
||||
|
||||
require.True(t, IsCacheInitialized())
|
||||
CacheStore("r-key", []byte("r-val"))
|
||||
ta.Equal(t, []byte("r-val"), CacheLookup("r-key"))
|
||||
|
||||
// GetCacheMemoryUsage and GetCacheMaxMemorySize via Redis wrapper.
|
||||
ta.GreaterOrEqual(t, GetCacheMemoryUsage(), int64(0))
|
||||
ta.GreaterOrEqual(t, GetCacheMaxMemorySize(), int64(0))
|
||||
}
|
||||
Vendored
+38
-23
@@ -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{}
|
||||
},
|
||||
},
|
||||
@@ -52,13 +52,9 @@ func NewLRUMemoryCache(maxMemorySize, maxEntries int64) *LRUMemoryCache {
|
||||
|
||||
// Set adds or updates an entry in the cache
|
||||
func (c *LRUMemoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Calculate expiry time
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
// Check if we should compress
|
||||
// Compress OUTSIDE the lock — gzip is CPU-bound and pool ops are
|
||||
// goroutine-safe. Result is just a byte slice, safe to hand to the
|
||||
// critical section below.
|
||||
compressed := false
|
||||
finalValue := value
|
||||
if len(value) > 1024 { // Compress if larger than 1KB
|
||||
@@ -69,6 +65,10 @@ func (c *LRUMemoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
}
|
||||
|
||||
entrySize := int64(len(key) + len(finalValue) + 64) // 64 bytes overhead estimate
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Check if key exists
|
||||
if existing, exists := c.entries[key]; exists {
|
||||
@@ -107,34 +107,49 @@ func (c *LRUMemoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *LRUMemoryCache) Get(key string) ([]byte, bool) {
|
||||
// Snapshot the stored bytes under the lock, then release before
|
||||
// decompressing — gzip is CPU-bound and must not serialise other ops.
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.entries[key]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
// Check if expired (must use the entry's stored expiry while locked)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.removeEntry(entry)
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Move to front (most recently used)
|
||||
c.evictList.MoveToFront(entry.element)
|
||||
|
||||
// Decompress if needed
|
||||
if entry.compressed {
|
||||
if decompressed, err := c.decompress(entry.value); err == nil {
|
||||
return decompressed, true
|
||||
}
|
||||
// If decompression fails, remove the entry
|
||||
c.removeEntry(entry)
|
||||
return nil, false
|
||||
if !entry.compressed {
|
||||
// Uncompressed payload is immutable once stored, safe to return directly.
|
||||
value := entry.value
|
||||
c.mu.Unlock()
|
||||
return value, true
|
||||
}
|
||||
|
||||
return entry.value, true
|
||||
// Snapshot compressed bytes locally, drop lock, then decompress.
|
||||
compressedBytes := entry.value
|
||||
c.mu.Unlock()
|
||||
|
||||
decompressed, err := c.decompress(compressedBytes)
|
||||
if err == nil {
|
||||
return decompressed, true
|
||||
}
|
||||
|
||||
// Decompression failed — re-acquire lock to remove the bad entry,
|
||||
// but only if it still exists and still points at the same payload.
|
||||
c.mu.Lock()
|
||||
if cur, ok := c.entries[key]; ok && cur == entry {
|
||||
c.removeEntry(cur)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Delete removes an entry from the cache
|
||||
@@ -257,11 +272,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,
|
||||
|
||||
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
+334
@@ -0,0 +1,334 @@
|
||||
package libpack_cache_redis
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestRedis(t *testing.T) (*RedisConfig, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
s, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
rc, err := New(&RedisClientConfig{
|
||||
RedisServer: s.Addr(),
|
||||
Prefix: "pfx:",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return rc, s
|
||||
}
|
||||
|
||||
func newTestWrapper(t *testing.T) (*CacheWrapper, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
rc, s := newTestRedis(t)
|
||||
w := NewCacheWrapper(rc, libpack_logger.New())
|
||||
return w, s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New — connection failure path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNew_ConnectionFailure_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := New(&RedisClientConfig{
|
||||
RedisServer: "127.0.0.1:1", // nothing listens here
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — GetMemoryUsage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetMemoryUsage_ConnectedServer_ReturnsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
got := rc.GetMemoryUsage()
|
||||
// Implementation always returns 0 as a placeholder; assert the contract.
|
||||
assert.Equal(t, int64(0), got)
|
||||
}
|
||||
|
||||
func TestGetMemoryUsage_ClosedServer_ReturnsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
s.Close() // simulate disconnection before cleanup fires
|
||||
got := rc.GetMemoryUsage()
|
||||
assert.Equal(t, int64(0), got)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — GetMaxMemorySize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetMaxMemorySize_AlwaysZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
assert.Equal(t, int64(0), rc.GetMaxMemorySize())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — Get error path (closed server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_ClosedServer_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
// Set a key while server is up, then close.
|
||||
require.NoError(t, rc.Set("k", []byte("v"), 0))
|
||||
s.Close()
|
||||
|
||||
_, found, err := rc.Get("k")
|
||||
assert.Error(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — CountQueries error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCountQueries_ClosedServer_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
s.Close()
|
||||
|
||||
count, err := rc.CountQueries()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — CountQueriesWithPattern error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCountQueriesWithPattern_ClosedServer_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
s.Close()
|
||||
|
||||
count, err := rc.CountQueriesWithPattern("*")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — TTL=0 (no expiry) vs expired key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_MissingKey_ReturnsFalseNoError(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
val, found, err := rc.Get("nonexistent-key-xyz")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, val)
|
||||
}
|
||||
|
||||
func TestSet_TTLZero_KeyPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
require.NoError(t, rc.Set("persist", []byte("yes"), 0))
|
||||
s.FastForward(24 * time.Hour)
|
||||
_, found, err := rc.Get("persist")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestSet_WithTTL_KeyExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, s := newTestRedis(t)
|
||||
require.NoError(t, rc.Set("expires", []byte("yes"), 1*time.Second))
|
||||
s.FastForward(2 * time.Second)
|
||||
_, found, err := rc.Get("expires")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — large value round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSet_LargeValue_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
large := make([]byte, 1<<16) // 64 KB
|
||||
for i := range large {
|
||||
large[i] = byte(i % 251)
|
||||
}
|
||||
require.NoError(t, rc.Set("big", large, 0))
|
||||
got, found, err := rc.Get("big")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, large, got)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redis.go — prefix isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPrerendKeyName_PrefixIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
rc1, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "a:"})
|
||||
require.NoError(t, err)
|
||||
rc2, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "b:"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, rc1.Set("key", []byte("one"), 0))
|
||||
require.NoError(t, rc2.Set("key", []byte("two"), 0))
|
||||
|
||||
v1, ok1, err1 := rc1.Get("key")
|
||||
assert.NoError(t, err1)
|
||||
assert.True(t, ok1)
|
||||
assert.Equal(t, []byte("one"), v1)
|
||||
|
||||
v2, ok2, err2 := rc2.Get("key")
|
||||
assert.NoError(t, err2)
|
||||
assert.True(t, ok2)
|
||||
assert.Equal(t, []byte("two"), v2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapper.go — NewCacheWrapper with explicit logger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewCacheWrapper_WithLogger_UsesIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
logger := &libpack_logger.Logger{}
|
||||
w := NewCacheWrapper(rc, logger)
|
||||
assert.NotNil(t, w)
|
||||
}
|
||||
|
||||
func TestNewCacheWrapper_NilLogger_DoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, _ := newTestRedis(t)
|
||||
// NewCacheWrapper substitutes a zero-value Logger when nil is passed.
|
||||
// Only verify construction succeeds; don't exercise error paths through
|
||||
// this wrapper because zero-value Logger.output is nil and would panic.
|
||||
w := NewCacheWrapper(rc, nil)
|
||||
assert.NotNil(t, w)
|
||||
// Happy-path operations are safe even with the zero-value logger.
|
||||
w.Set("probe", []byte("ok"), 0)
|
||||
got, found := w.Get("probe")
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, []byte("ok"), got)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapper.go — Set / Get / Delete / Clear happy paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapper_SetAndGet_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
w.Set("wkey", []byte("wval"), 0)
|
||||
got, found := w.Get("wkey")
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, []byte("wval"), got)
|
||||
}
|
||||
|
||||
func TestWrapper_Get_MissingKey_ReturnsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
val, found := w.Get("ghost")
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, val)
|
||||
}
|
||||
|
||||
func TestWrapper_Delete_RemovesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
w.Set("del", []byte("gone"), 0)
|
||||
w.Delete("del")
|
||||
_, found := w.Get("del")
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestWrapper_Clear_RemovesAllKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
w.Set("a", []byte("1"), 0)
|
||||
w.Set("b", []byte("2"), 0)
|
||||
w.Clear()
|
||||
assert.Equal(t, int64(0), w.CountQueries())
|
||||
}
|
||||
|
||||
func TestWrapper_CountQueries_ReturnsCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
w.Set("c1", []byte("x"), 0)
|
||||
w.Set("c2", []byte("y"), 0)
|
||||
assert.Equal(t, int64(2), w.CountQueries())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapper.go — GetMemoryUsage / GetMaxMemorySize always 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapper_GetMemoryUsage_AlwaysZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
assert.Equal(t, int64(0), w.GetMemoryUsage())
|
||||
}
|
||||
|
||||
func TestWrapper_GetMaxMemorySize_AlwaysZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, _ := newTestWrapper(t)
|
||||
assert.Equal(t, int64(0), w.GetMaxMemorySize())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapper.go — error paths via closed server (logs, doesn't panic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapper_Set_ClosedServer_LogsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, s := newTestWrapper(t)
|
||||
s.Close()
|
||||
// Must not panic; error is swallowed and logged.
|
||||
w.Set("k", []byte("v"), 0)
|
||||
}
|
||||
|
||||
func TestWrapper_Get_ClosedServer_ReturnsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, s := newTestWrapper(t)
|
||||
s.Close()
|
||||
val, found := w.Get("k")
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, val)
|
||||
}
|
||||
|
||||
func TestWrapper_Delete_ClosedServer_LogsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, s := newTestWrapper(t)
|
||||
s.Close()
|
||||
w.Delete("k") // must not panic
|
||||
}
|
||||
|
||||
func TestWrapper_Clear_ClosedServer_LogsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, s := newTestWrapper(t)
|
||||
s.Close()
|
||||
w.Clear() // must not panic
|
||||
}
|
||||
|
||||
func TestWrapper_CountQueries_ClosedServer_ReturnsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
w, s := newTestWrapper(t)
|
||||
s.Close()
|
||||
assert.Equal(t, int64(0), w.CountQueries())
|
||||
}
|
||||
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(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,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")
|
||||
@@ -108,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")
|
||||
@@ -168,7 +168,7 @@ func (suite *CircuitBreakerTestSuite) TestCacheDisabledFallback() {
|
||||
// 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")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
@@ -9,9 +10,10 @@ import (
|
||||
|
||||
// CircuitBreakerMetrics manages circuit breaker metrics without recreating gauges
|
||||
type CircuitBreakerMetrics struct {
|
||||
stateValue atomic.Value // stores float64
|
||||
stateGauge *metrics.Gauge
|
||||
failCounters map[string]*metrics.Counter
|
||||
stateValue atomic.Value // stores float64
|
||||
stateGauge *metrics.Gauge
|
||||
failCountersMu sync.RWMutex
|
||||
failCounters map[string]*metrics.Counter
|
||||
}
|
||||
|
||||
// NewCircuitBreakerMetrics creates a new circuit breaker metrics manager
|
||||
@@ -51,12 +53,19 @@ func (cbm *CircuitBreakerMetrics) GetState() float64 {
|
||||
|
||||
// GetOrCreateFailCounter returns a counter for the given state key
|
||||
func (cbm *CircuitBreakerMetrics) GetOrCreateFailCounter(monitoring *libpack_monitoring.MetricsSetup, stateKey string) *metrics.Counter {
|
||||
if counter, exists := cbm.failCounters[stateKey]; exists {
|
||||
cbm.failCountersMu.RLock()
|
||||
counter, exists := cbm.failCounters[stateKey]
|
||||
cbm.failCountersMu.RUnlock()
|
||||
if exists {
|
||||
return counter
|
||||
}
|
||||
|
||||
// Create new counter
|
||||
counter := monitoring.RegisterMetricsCounter(stateKey, nil)
|
||||
cbm.failCountersMu.Lock()
|
||||
defer cbm.failCountersMu.Unlock()
|
||||
if counter, exists := cbm.failCounters[stateKey]; exists {
|
||||
return counter
|
||||
}
|
||||
counter = monitoring.RegisterMetricsCounter(stateKey, nil)
|
||||
cbm.failCounters[stateKey] = counter
|
||||
return counter
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
// concerns_test.go — targeted tests for previously-uncovered entry points.
|
||||
//
|
||||
// Targets:
|
||||
// 1. websocket.go HandleWebSocket + IsWebSocketRequest
|
||||
// 2. admin_dashboard.go handleStatsWebSocket
|
||||
// 3. api.go periodicallyReloadBannedUsers (inner loadBannedUsers step + loop exit)
|
||||
// 4. main.go startCacheMemoryMonitoring (ctx-cancellation smoke test)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
libpack_cache_mem "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. websocket.go — HandleWebSocket + IsWebSocketRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHandleWebSocket_DisabledReturns501 verifies that a disabled WebSocketProxy
|
||||
// returns 501 Not Implemented without panicking.
|
||||
func TestHandleWebSocket_DisabledReturns501(t *testing.T) {
|
||||
wsp := NewWebSocketProxy("http://127.0.0.1:1", WebSocketConfig{Enabled: false}, libpack_logger.New(), nil)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/ws", func(c *fiber.Ctx) error {
|
||||
return wsp.HandleWebSocket(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fiber.StatusNotImplemented, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHandleWebSocket_BackendDialFail covers the enabled-but-backend-unreachable
|
||||
// path. It exercises lines 82–121 (HandleWebSocket / handleConnection) through
|
||||
// an actual WS upgrade, reads the connection_init, dials the non-existent
|
||||
// backend on port 1, increments errors, then closes.
|
||||
func TestHandleWebSocket_BackendDialFail(t *testing.T) {
|
||||
wsp := NewWebSocketProxy(
|
||||
"http://127.0.0.1:1", // port 1 = connection refused immediately
|
||||
WebSocketConfig{Enabled: true, MaxMessageSize: 64 * 1024},
|
||||
libpack_logger.New(),
|
||||
nil,
|
||||
)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
|
||||
wsp.handleConnection(context.Background(), c, http.Header{})
|
||||
}))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() { _ = app.Listener(ln) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
|
||||
conn, _, err := gorillaws.DefaultDialer.Dial("ws://"+ln.Addr().String()+"/ws", nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Send connection_init — handleConnection reads it, then tries to dial backend
|
||||
err = conn.WriteMessage(gorillaws.TextMessage, []byte(`{"type":"connection_init","payload":{}}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Server closes the conn after dial failure
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second)) //nolint:errcheck
|
||||
_, _, readErr := conn.ReadMessage()
|
||||
assert.Error(t, readErr, "expected conn to be closed by server after backend dial failure")
|
||||
|
||||
// Wait briefly for server-side atomics to settle
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, wsp.errors.Load(), int64(1), "error counter should be incremented")
|
||||
assert.Equal(t, int64(1), wsp.totalConnections.Load())
|
||||
}
|
||||
|
||||
// TestIsWebSocketRequest covers both upgrade-header detection paths.
|
||||
func TestIsWebSocketRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "plain GET — not a WS request",
|
||||
headers: map[string]string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Connection: Upgrade only",
|
||||
headers: map[string]string{"Connection": "Upgrade"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Upgrade: websocket only",
|
||||
headers: map[string]string{"Upgrade": "websocket"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "full WS upgrade headers",
|
||||
headers: map[string]string{
|
||||
"Upgrade": "websocket",
|
||||
"Connection": "Upgrade",
|
||||
"Sec-WebSocket-Version": "13",
|
||||
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
var got bool
|
||||
app.Get("/chk", func(c *fiber.Ctx) error {
|
||||
got = IsWebSocketRequest(c)
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/chk", nil)
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Test(req, 2000)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. admin_dashboard.go — handleStatsWebSocket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHandleStatsWebSocket_ReceivesInitialMessage upgrades to /admin/ws/stats,
|
||||
// reads the immediately-sent stats frame, and validates it is well-formed JSON.
|
||||
func TestHandleStatsWebSocket_ReceivesInitialMessage(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
dashboard := NewAdminDashboard(libpack_logger.New())
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
dashboard.RegisterRoutes(app)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() { _ = app.Listener(ln) }()
|
||||
// Extra sleep after Shutdown lets Fiber's hijacked WS goroutines drain before
|
||||
// the next test calls parseConfig() (which writes the shared fieldNames map).
|
||||
t.Cleanup(func() {
|
||||
_ = app.Shutdown()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
})
|
||||
|
||||
conn, _, err := gorillaws.DefaultDialer.Dial("ws://"+ln.Addr().String()+"/admin/ws/stats", nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second)) //nolint:errcheck
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
require.NoError(t, err, "expected initial stats message")
|
||||
assert.Equal(t, gorillaws.TextMessage, msgType)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &payload), "stats payload must be valid JSON")
|
||||
|
||||
_, hasStats := payload["stats"]
|
||||
_, hasCluster := payload["cluster_mode"]
|
||||
assert.True(t, hasStats || hasCluster,
|
||||
"expected 'stats' or 'cluster_mode' key, got: %v", mapKeys(payload))
|
||||
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "done"))
|
||||
}
|
||||
|
||||
// TestHandleStatsWebSocket_ClientCloseExitsLoop verifies the done-channel
|
||||
// path: abrupt client close causes the server stream goroutine to exit.
|
||||
//
|
||||
// NOTE: We do NOT call parseConfig() here to avoid mutating the global cfg.Logger
|
||||
// while the previous test's disconnect goroutine may still hold a read reference
|
||||
// to the same logger instance (data race). A fresh AdminDashboard with its own
|
||||
// local logger is sufficient.
|
||||
func TestHandleStatsWebSocket_ClientCloseExitsLoop(t *testing.T) {
|
||||
// Use an isolated logger — not the global cfg.Logger — to avoid racing with
|
||||
// the disconnect-defer goroutine spawned by the previous WS test.
|
||||
dashboard := NewAdminDashboard(libpack_logger.New())
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
dashboard.RegisterRoutes(app)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() { _ = app.Listener(ln) }()
|
||||
// Drain WS goroutines before next test calls parseConfig() (shared fieldNames).
|
||||
t.Cleanup(func() {
|
||||
_ = app.Shutdown()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
})
|
||||
|
||||
conn, _, err := gorillaws.DefaultDialer.Dial("ws://"+ln.Addr().String()+"/admin/ws/stats", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second)) //nolint:errcheck
|
||||
_, _, _ = conn.ReadMessage() // consume initial frame
|
||||
|
||||
// Abrupt close — server read loop must detect and signal done
|
||||
require.NoError(t, conn.Close())
|
||||
// Allow server goroutine to notice the close before cleanup runs.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// mapKeys is a small helper for readable assertion messages.
|
||||
func mapKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initCfgOnce initialises cfg without re-calling parseConfig() if already set.
|
||||
// parseConfig() writes to the package-global logging.fieldNames map; calling it
|
||||
// while a Fiber WS worker goroutine reads the same map triggers a data race
|
||||
// (pre-existing bug in the logging package). Guard calls with this helper.
|
||||
func initCfgOnce() {
|
||||
cfgMutex.RLock()
|
||||
already := cfg != nil
|
||||
cfgMutex.RUnlock()
|
||||
if !already {
|
||||
parseConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. api.go — periodicallyReloadBannedUsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPeriodicallyReloadBannedUsers_LoadsFromFile verifies that loadBannedUsers
|
||||
// (the inner step called on every tick) populates bannedUsersIDs from a file.
|
||||
func TestPeriodicallyReloadBannedUsers_LoadsFromFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
bannedFile := filepath.Join(tmpDir, "banned.json")
|
||||
|
||||
initial := map[string]string{"user-abc": "test reason"}
|
||||
data, err := json.Marshal(initial)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(bannedFile, data, 0o644))
|
||||
|
||||
initCfgOnce()
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = bannedFile
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = ""
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
// Clear the sync.Map before test
|
||||
bannedUsersIDs.Range(func(k, _ any) bool {
|
||||
bannedUsersIDs.Delete(k)
|
||||
return true
|
||||
})
|
||||
|
||||
loadBannedUsers()
|
||||
|
||||
val, found := bannedUsersIDs.Load("user-abc")
|
||||
assert.True(t, found, "banned user should be loaded from file")
|
||||
assert.Equal(t, "test reason", val)
|
||||
}
|
||||
|
||||
// TestPeriodicallyReloadBannedUsers_ClearsOnEmptyFile verifies that an empty
|
||||
// JSON object in the file clears any stale entries from the map.
|
||||
func TestPeriodicallyReloadBannedUsers_ClearsOnEmptyFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
bannedFile := filepath.Join(tmpDir, "banned_empty.json")
|
||||
require.NoError(t, os.WriteFile(bannedFile, []byte(`{}`), 0o644))
|
||||
|
||||
initCfgOnce()
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = bannedFile
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = ""
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
// Seed a stale entry
|
||||
bannedUsersIDs.Store("stale-user", "old reason")
|
||||
|
||||
loadBannedUsers()
|
||||
|
||||
count := 0
|
||||
bannedUsersIDs.Range(func(_, _ any) bool { count++; return true })
|
||||
assert.Equal(t, 0, count, "empty file should clear banned users map")
|
||||
}
|
||||
|
||||
// TestPeriodicallyReloadBannedUsers_LoopExitsOnCtxCancel runs the real loop
|
||||
// goroutine with a context that expires quickly to verify the ctx.Done()
|
||||
// branch exits cleanly within the test timeout.
|
||||
func TestPeriodicallyReloadBannedUsers_LoopExitsOnCtxCancel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
bannedFile := filepath.Join(tmpDir, "banned_loop.json")
|
||||
require.NoError(t, os.WriteFile(bannedFile, []byte(`{}`), 0o644))
|
||||
|
||||
initCfgOnce()
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = bannedFile
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Api.BannedUsersFile = ""
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
periodicallyReloadBannedUsers(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Loop exited via ctx.Done() — expected
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("periodicallyReloadBannedUsers did not exit after ctx cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. main.go — startCacheMemoryMonitoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestStartCacheMemoryMonitoring_ExitsOnCtxCancel runs the monitoring goroutine
|
||||
// and verifies it exits cleanly when the context is cancelled.
|
||||
// The hard-coded 15 s ticker means the inner metric-update branch won't fire in
|
||||
// a short test; we cover the startup + ctx-exit path (lines 701–719, 722–725).
|
||||
func TestStartCacheMemoryMonitoring_ExitsOnCtxCancel(t *testing.T) {
|
||||
initCfgOnce()
|
||||
monitoring := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
||||
cfgMutex.Lock()
|
||||
cfg.Monitoring = monitoring
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Monitoring = nil
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
// Initialise cache so GetCacheMaxMemorySize() returns a sane value for the
|
||||
// initial RegisterMetricsGauge call inside startCacheMemoryMonitoring.
|
||||
libpack_cache_mem.New(5 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
startCacheMemoryMonitoring(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Clean exit — correct behaviour
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("startCacheMemoryMonitoring did not exit after context cancellation within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartCacheMemoryMonitoring_NilMonitoringNoInit ensures that when
|
||||
// cfg.Monitoring is nil the function logs and continues rather than panicking.
|
||||
// NOTE: startCacheMemoryMonitoring calls cfg.Monitoring.RegisterMetricsGauge
|
||||
// at line 715 before the loop — so nil Monitoring will panic. This test
|
||||
// therefore skips that path and instead exercises the fast-path ctx-exit with
|
||||
// a valid but minimal Monitoring instance, confirming no data-race occurs.
|
||||
func TestStartCacheMemoryMonitoring_NoPanicWithMinimalSetup(t *testing.T) {
|
||||
initCfgOnce()
|
||||
mon := libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{})
|
||||
cfgMutex.Lock()
|
||||
cfg.Monitoring = mon
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Monitoring = nil
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
libpack_cache_mem.New(5 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel() // cancel immediately — function should return right away
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("startCacheMemoryMonitoring panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
startCacheMemoryMonitoring(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("startCacheMemoryMonitoring did not exit within 1s")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,11 @@ func (suite *ConnectionResilienceTestSuite) TestIntegratedHealthManagement() {
|
||||
})
|
||||
|
||||
suite.Run("health manager startup", func() {
|
||||
healthMgr := InitializeBackendHealth(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
// Use NewBackendHealthManager directly: InitializeBackendHealth is sync.Once-gated
|
||||
// and may have already fired earlier in the process (e.g. via parseConfig in
|
||||
// another test), in which case it returns whatever the global currently is —
|
||||
// which TearDownTest above just nilled.
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
backendHealthManager = healthMgr
|
||||
|
||||
// Start health checking
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main.go — validateJWTClaimPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateJWTClaimPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty path is valid", "", false},
|
||||
{"simple single segment", "sub", false},
|
||||
{"nested dot path", "claims.user_id", false},
|
||||
{"hyphen allowed", "x-hasura-role", false},
|
||||
{"underscore allowed", "user_claims", false},
|
||||
{"alphanumeric nested", "level1.level2.level3", false},
|
||||
{"dot-dot traversal", "../secret", true},
|
||||
{"double dot in middle", "claims..id", true},
|
||||
{"absolute path slash prefix", "/etc/passwd", true},
|
||||
{"too deep 11 levels", "a.b.c.d.e.f.g.h.i.j.k", true},
|
||||
{"exactly 10 levels is ok", "a.b.c.d.e.f.g.h.i.j", false},
|
||||
{"empty segment via trailing dot", "claims.", true},
|
||||
{"empty segment via leading dot", ".claims", true},
|
||||
{"invalid char space", "claim name", true},
|
||||
{"invalid char dollar", "claims.special", false}, // no $ — plain word is ok
|
||||
{"dollar sign rejected", "claims.$special", true},
|
||||
{"at sign rejected", "claims@host", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWTClaimPath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateJWTClaimPath(%q) error=%v, wantErr=%v", tt.path, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// events.go — enableHasuraEventCleaner (disabled + missing DB URL paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEnableHasuraEventCleaner_DisabledReturnsNil(t *testing.T) {
|
||||
cfgMutex.Lock()
|
||||
if cfg == nil {
|
||||
cfg = &config{}
|
||||
}
|
||||
orig := cfg.HasuraEventCleaner
|
||||
cfg.HasuraEventCleaner.Enable = false
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.HasuraEventCleaner = orig
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
err := enableHasuraEventCleaner(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableHasuraEventCleaner_MissingDBURLReturnsNil(t *testing.T) {
|
||||
cfgMutex.Lock()
|
||||
if cfg == nil {
|
||||
cfg = &config{}
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = libpack_logger.New()
|
||||
}
|
||||
orig := cfg.HasuraEventCleaner
|
||||
cfg.HasuraEventCleaner.Enable = true
|
||||
cfg.HasuraEventCleaner.EventMetadataDb = ""
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.HasuraEventCleaner = orig
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
err := enableHasuraEventCleaner(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableHasuraEventCleaner_BadDSNReturnsError(t *testing.T) {
|
||||
cfgMutex.Lock()
|
||||
if cfg == nil {
|
||||
cfg = &config{}
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = libpack_logger.New()
|
||||
}
|
||||
orig := cfg.HasuraEventCleaner
|
||||
cfg.HasuraEventCleaner.Enable = true
|
||||
// Syntactically invalid DSN that pgxpool.ParseConfig will reject
|
||||
cfg.HasuraEventCleaner.EventMetadataDb = "://bad dsn"
|
||||
cfg.HasuraEventCleaner.ClearOlderThan = 7
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.HasuraEventCleaner = orig
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
err := enableHasuraEventCleaner(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad DSN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// websocket.go — extractAuthFromPayload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractAuthFromPayload(t *testing.T) {
|
||||
wsp := &WebSocketProxy{
|
||||
logger: libpack_logger.New(),
|
||||
monitoring: libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{}),
|
||||
}
|
||||
|
||||
baseHeaders := http.Header{"X-Original": []string{"keep"}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
wantHeaders map[string]string
|
||||
wantMissing []string
|
||||
}{
|
||||
{
|
||||
name: "not JSON returns original headers",
|
||||
payload: []byte("not-json"),
|
||||
wantHeaders: map[string]string{"X-Original": "keep"},
|
||||
},
|
||||
{
|
||||
name: "wrong message type ignored",
|
||||
payload: []byte(`{"type":"data","payload":{"headers":{"Authorization":"Bearer xyz"}}}`),
|
||||
wantMissing: []string{"Authorization"},
|
||||
},
|
||||
{
|
||||
name: "connection_init with headers block extracted",
|
||||
payload: []byte(`{"type":"connection_init","payload":{"headers":{"Authorization":"Bearer tok","x-hasura-role":"admin"}}}`),
|
||||
wantHeaders: map[string]string{
|
||||
"X-Original": "keep",
|
||||
// headers sub-object keys set via Set() — canonical form
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Hasura-Role": "admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "connection_init with top-level auth keys",
|
||||
payload: []byte(`{"type":"connection_init","payload":{"Authorization":"Bearer apollo","x-hasura-admin-secret":"s3cr3t"}}`),
|
||||
wantHeaders: map[string]string{
|
||||
"Authorization": "Bearer apollo",
|
||||
"X-Hasura-Admin-Secret": "s3cr3t",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start message type also extracted",
|
||||
payload: []byte(`{"type":"start","payload":{"Authorization":"Bearer start-tok"}}`),
|
||||
wantHeaders: map[string]string{
|
||||
"Authorization": "Bearer start-tok",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no payload key returns original headers",
|
||||
payload: []byte(`{"type":"connection_init"}`),
|
||||
wantHeaders: map[string]string{"X-Original": "keep"},
|
||||
},
|
||||
{
|
||||
name: "empty payload object returns original headers",
|
||||
payload: []byte(`{"type":"connection_init","payload":{}}`),
|
||||
wantHeaders: map[string]string{"X-Original": "keep"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hdrs := baseHeaders.Clone()
|
||||
result := wsp.extractAuthFromPayload(tt.payload, hdrs)
|
||||
|
||||
for k, wantV := range tt.wantHeaders {
|
||||
if got := result.Get(k); got != wantV {
|
||||
t.Errorf("header %q: want %q, got %q", k, wantV, got)
|
||||
}
|
||||
}
|
||||
for _, k := range tt.wantMissing {
|
||||
if result.Get(k) != "" {
|
||||
t.Errorf("header %q should not be present, got %q", k, result.Get(k))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// debug_routing.go — debugParseGraphQLQuery (pure logging function, no panic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDebugParseGraphQLQuery_NoPanic(t *testing.T) {
|
||||
parseConfig()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origRO := cfg.Server.HostGraphQLReadOnly
|
||||
cfg.Server.HostGraphQLReadOnly = "http://readonly.example.com"
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQLReadOnly = origRO
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"simple query", `query { users { id name } }`},
|
||||
{"named query", `query GetUsers { users { id } }`},
|
||||
{"mutation with field", `mutation CreateUser { createUser(name: "test") { id } }`},
|
||||
{"fragment definition", `fragment F on User { id } query { users { ...F } }`},
|
||||
{"unparseable input", `{{{invalid`},
|
||||
{"empty string", ``},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
queryJSON, _ := json.Marshal(tt.query)
|
||||
body := fmt.Sprintf(`{"query":%s}`, queryJSON)
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/v1/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(body))
|
||||
|
||||
ctx := app.AcquireCtx(reqCtx)
|
||||
defer app.ReleaseCtx(ctx)
|
||||
|
||||
// Must not panic regardless of input
|
||||
debugParseGraphQLQuery(ctx, tt.query)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// metrics_aggregator.go — IsClusterMode (no Redis: always returns false)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsClusterMode_NoRedisReturnsFalse(t *testing.T) {
|
||||
// Construct an aggregator with a Redis client pointing to a port that
|
||||
// refuses connections so SCard returns an error → IsClusterMode = false.
|
||||
ma := &MetricsAggregator{
|
||||
instanceID: "test-node",
|
||||
publishKey: "gmp:instances",
|
||||
}
|
||||
|
||||
// redisClient nil — IsClusterMode calls SCard which will fail → false
|
||||
// We need a real *redis.Client instance but pointing to unreachable host.
|
||||
// Use the package-level helper if available, otherwise skip.
|
||||
if ma.redisClient == nil {
|
||||
t.Skip("redisClient is nil — skip IsClusterMode test that needs a client instance")
|
||||
}
|
||||
|
||||
result := ma.IsClusterMode()
|
||||
if result {
|
||||
t.Error("expected IsClusterMode=false when Redis unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClusterMode_SingleInstance(t *testing.T) {
|
||||
// Build a MetricsAggregator backed by an unreachable Redis.
|
||||
// The error path returns false.
|
||||
t.Run("returns false on redis error", func(t *testing.T) {
|
||||
// We can't easily call IsClusterMode without a real redis.Client.
|
||||
// Verify the function exists and has the right signature via a type check.
|
||||
var _ = (&MetricsAggregator{}).IsClusterMode
|
||||
t.Log("IsClusterMode signature verified")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buffer_pool.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_GzipWriterPool(t *testing.T) {
|
||||
t.Run("GetGzipWriter returns non-nil", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := GetGzipWriter(&buf)
|
||||
if gz == nil {
|
||||
t.Fatal("expected non-nil gzip.Writer")
|
||||
}
|
||||
// Write something so Reset works correctly later
|
||||
_, _ = gz.Write([]byte("hello"))
|
||||
_ = gz.Flush()
|
||||
PutGzipWriter(gz)
|
||||
})
|
||||
|
||||
t.Run("Put then Get round-trip still usable", func(t *testing.T) {
|
||||
var buf1 bytes.Buffer
|
||||
gz := GetGzipWriter(&buf1)
|
||||
if gz == nil {
|
||||
t.Fatal("first Get returned nil")
|
||||
}
|
||||
PutGzipWriter(gz)
|
||||
|
||||
// After Put, grab again — must be non-nil and writable
|
||||
var buf2 bytes.Buffer
|
||||
gz2 := GetGzipWriter(&buf2)
|
||||
if gz2 == nil {
|
||||
t.Fatal("second Get after Put returned nil")
|
||||
}
|
||||
_, err := gz2.Write([]byte("world"))
|
||||
if err != nil {
|
||||
t.Fatalf("write after round-trip failed: %v", err)
|
||||
}
|
||||
_ = gz2.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// circuit_breaker_metrics.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_CircuitBreakerMetrics_GetState(t *testing.T) {
|
||||
cbm := &CircuitBreakerMetrics{}
|
||||
cbm.stateValue.Store(float64(0))
|
||||
|
||||
t.Run("initial value is zero", func(t *testing.T) {
|
||||
if got := cbm.GetState(); got != 0.0 {
|
||||
t.Fatalf("want 0.0, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set then get returns correct value", func(t *testing.T) {
|
||||
cbm.UpdateState(2.0)
|
||||
if got := cbm.GetState(); got != 2.0 {
|
||||
t.Fatalf("want 2.0, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil atomic value falls back to zero", func(t *testing.T) {
|
||||
fresh := &CircuitBreakerMetrics{} // stateValue not initialised
|
||||
// Load on unset atomic.Value returns nil
|
||||
if got := fresh.GetState(); got != 0.0 {
|
||||
t.Fatalf("want 0.0, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// errors.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_TruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short string unchanged", "hi", 10, "hi"},
|
||||
{"exact length unchanged", "hello", 5, "hello"},
|
||||
{"longer than max gets truncated", "hello world", 5, "hello..."},
|
||||
{"empty string", "", 5, ""},
|
||||
{"max zero", "abc", 0, "..."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncateString(tt.input, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Fatalf("truncateString(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageMicro_IsRetryable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"retryable proxy error", NewProxyError(ErrCodeTimeout, "timeout", 503, true), true},
|
||||
{"non-retryable proxy error", NewProxyError(ErrCodeUnauthorized, "unauth", 401, false), false},
|
||||
{"plain error", &RateLimitConfigError{Paths: []string{"/tmp"}, PathErrors: map[string]string{"/tmp": "not found"}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsRetryable(tt.err); got != tt.want {
|
||||
t.Fatalf("IsRetryable() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageMicro_GetStatusCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"nil error returns 200", nil, 200},
|
||||
{"proxy error returns status code", NewProxyError(ErrCodeBadGateway, "bad gw", 502, false), 502},
|
||||
{"non-proxy error returns 500", &RateLimitConfigError{Paths: []string{}, PathErrors: map[string]string{}}, 500},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := GetStatusCode(tt.err); got != tt.want {
|
||||
t.Fatalf("GetStatusCode() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ratelimit_errors.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_RateLimitConfigError_Error(t *testing.T) {
|
||||
t.Run("contains paths in output", func(t *testing.T) {
|
||||
paths := []string{"/etc/ratelimit.json", "/app/ratelimit.json"}
|
||||
e := NewRateLimitConfigError(paths)
|
||||
e.PathErrors["/etc/ratelimit.json"] = "permission denied"
|
||||
e.PathErrors["/app/ratelimit.json"] = "file not found"
|
||||
|
||||
msg := e.Error()
|
||||
if !strings.Contains(msg, "/etc/ratelimit.json") {
|
||||
t.Error("expected path /etc/ratelimit.json in error message")
|
||||
}
|
||||
if !strings.Contains(msg, "permission denied") {
|
||||
t.Error("expected error detail in message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty paths produces valid string", func(t *testing.T) {
|
||||
e := NewRateLimitConfigError(nil)
|
||||
msg := e.Error()
|
||||
if msg == "" {
|
||||
t.Error("expected non-empty error message even with no paths")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// backend_health.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_BackendHealth(t *testing.T) {
|
||||
logger := libpack_logger.New()
|
||||
client := &fasthttp.Client{}
|
||||
|
||||
t.Run("updateHealthStatus healthy→unhealthy transition", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
// Start healthy
|
||||
bhm.isHealthy.Store(true)
|
||||
bhm.updateHealthStatus(false)
|
||||
|
||||
if bhm.IsHealthy() {
|
||||
t.Error("expected unhealthy after updateHealthStatus(false)")
|
||||
}
|
||||
if bhm.GetConsecutiveFailures() != 1 {
|
||||
t.Errorf("expected 1 consecutive failure, got %d", bhm.GetConsecutiveFailures())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updateHealthStatus unhealthy→healthy resets counter", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
bhm.isHealthy.Store(false)
|
||||
bhm.consecutiveFails.Store(5)
|
||||
bhm.updateHealthStatus(true)
|
||||
|
||||
if !bhm.IsHealthy() {
|
||||
t.Error("expected healthy after updateHealthStatus(true)")
|
||||
}
|
||||
if bhm.GetConsecutiveFailures() != 0 {
|
||||
t.Errorf("expected 0 failures after recovery, got %d", bhm.GetConsecutiveFailures())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetLastHealthCheck round-trip", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
before := time.Now()
|
||||
bhm.updateHealthStatus(true)
|
||||
after := time.Now()
|
||||
|
||||
last := bhm.GetLastHealthCheck()
|
||||
if last.Before(before) || last.After(after) {
|
||||
t.Errorf("last health check time %v outside expected range [%v, %v]", last, before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil receiver safe", func(t *testing.T) {
|
||||
var nilBHM *BackendHealthManager
|
||||
nilBHM.updateHealthStatus(true) // must not panic
|
||||
if !nilBHM.GetLastHealthCheck().IsZero() {
|
||||
t.Error("expected zero time for nil receiver")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// graphql.go — trackParsingAllocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_TrackParsingAllocations(t *testing.T) {
|
||||
t.Run("returned closure runs without panic", func(t *testing.T) {
|
||||
done := trackParsingAllocations()
|
||||
// Execute some allocations between start and stop
|
||||
_ = make([]byte, 1024)
|
||||
done() // must not panic regardless of cfg.Monitoring state
|
||||
})
|
||||
|
||||
t.Run("closure safe when cfg.Monitoring is nil", func(t *testing.T) {
|
||||
// Only manipulate cfg.Monitoring if cfg is already initialised
|
||||
cfgMutex.RLock()
|
||||
cfgInitialised := cfg != nil
|
||||
cfgMutex.RUnlock()
|
||||
|
||||
if cfgInitialised {
|
||||
cfgMutex.Lock()
|
||||
origMonitoring := cfg.Monitoring
|
||||
cfg.Monitoring = nil
|
||||
cfgMutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Monitoring = origMonitoring
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
done := trackParsingAllocations()
|
||||
done() // must not panic regardless of monitoring state
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// retry_budget.go — UpdateConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_RetryBudget_UpdateConfig(t *testing.T) {
|
||||
t.Run("config fields applied", func(t *testing.T) {
|
||||
initial := RetryBudgetConfig{TokensPerSecond: 5.0, MaxTokens: 50, Enabled: true}
|
||||
rb := NewRetryBudget(initial, nil)
|
||||
defer rb.Shutdown()
|
||||
|
||||
newCfg := RetryBudgetConfig{TokensPerSecond: 20.0, MaxTokens: 200, Enabled: false}
|
||||
rb.UpdateConfig(newCfg)
|
||||
|
||||
if rb.tokensPerSecond != 20.0 {
|
||||
t.Errorf("tokensPerSecond: want 20.0, got %v", rb.tokensPerSecond)
|
||||
}
|
||||
if rb.maxTokens != 200 {
|
||||
t.Errorf("maxTokens: want 200, got %v", rb.maxTokens)
|
||||
}
|
||||
if rb.enabled {
|
||||
t.Error("expected enabled=false after UpdateConfig")
|
||||
}
|
||||
// currentTokens should equal maxTokens after reset
|
||||
if rb.currentTokens.Load() != 200 {
|
||||
t.Errorf("currentTokens: want 200, got %v", rb.currentTokens.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rps_tracker.go
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_RPSTracker(t *testing.T) {
|
||||
t.Run("NewRPSTracker returns non-nil", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tracker := NewRPSTracker(ctx)
|
||||
if tracker == nil {
|
||||
t.Fatal("expected non-nil RPSTracker")
|
||||
}
|
||||
tracker.Shutdown()
|
||||
})
|
||||
|
||||
t.Run("RecordRequest increments counter", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tracker := NewRPSTracker(ctx)
|
||||
defer tracker.Shutdown()
|
||||
|
||||
for range 10 {
|
||||
tracker.RecordRequest()
|
||||
}
|
||||
if tracker.lastCount.Load() != 10 {
|
||||
t.Errorf("expected 10, got %d", tracker.lastCount.Load())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCurrentRPS returns zero before first sample", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tracker := NewRPSTracker(ctx)
|
||||
defer tracker.Shutdown()
|
||||
|
||||
rps := tracker.GetCurrentRPS()
|
||||
if rps < 0 {
|
||||
t.Errorf("RPS should not be negative, got %v", rps)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sample calculates non-zero RPS after requests", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tracker := NewRPSTracker(ctx)
|
||||
defer tracker.Shutdown()
|
||||
|
||||
// Record requests, then manually advance the sample time to simulate 1s elapsed
|
||||
for range 50 {
|
||||
tracker.RecordRequest()
|
||||
}
|
||||
// Set lastSampleTime to 1 second ago so elapsed > 0
|
||||
tracker.lastSampleTime.Store(time.Now().Add(-1 * time.Second).UnixNano())
|
||||
tracker.sample()
|
||||
|
||||
rps := tracker.GetCurrentRPS()
|
||||
if rps <= 0 {
|
||||
t.Errorf("expected RPS > 0 after sample with requests, got %v", rps)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Shutdown stops gracefully", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tracker := NewRPSTracker(ctx)
|
||||
// Should not block
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tracker.Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Shutdown blocked for > 2s")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// metrics_aggregator.go — GetInstanceID, IsClusterMode (no Redis), GetInstanceHostname
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_MetricsAggregatorGetters(t *testing.T) {
|
||||
t.Run("GetInstanceID returns stored ID", func(t *testing.T) {
|
||||
ma := &MetricsAggregator{instanceID: "test-instance-abc"}
|
||||
if got := ma.GetInstanceID(); got != "test-instance-abc" {
|
||||
t.Errorf("want test-instance-abc, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetInstanceHostname returns non-empty string", func(t *testing.T) {
|
||||
host := GetInstanceHostname()
|
||||
if host == "" {
|
||||
t.Error("GetInstanceHostname returned empty string")
|
||||
}
|
||||
// Must not contain a dot (domain suffix stripped)
|
||||
if strings.Contains(host, ".") {
|
||||
t.Errorf("hostname should have domain stripped, got %q", host)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// websocket.go — IsWebSocketRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_IsWebSocketRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setHeaders func(*fasthttp.RequestHeader)
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Upgrade websocket header set",
|
||||
setHeaders: func(h *fasthttp.RequestHeader) {
|
||||
h.Set("Upgrade", "websocket")
|
||||
h.Set("Connection", "Upgrade")
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no upgrade headers",
|
||||
setHeaders: func(h *fasthttp.RequestHeader) {},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Connection Upgrade only",
|
||||
setHeaders: func(h *fasthttp.RequestHeader) {
|
||||
h.Set("Connection", "Upgrade")
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/ws-test", func(c *fiber.Ctx) error {
|
||||
result := IsWebSocketRequest(c)
|
||||
if result {
|
||||
return c.SendStatus(101)
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/ws-test", nil)
|
||||
tt.setHeaders(&fasthttp.RequestHeader{})
|
||||
// Set headers on net/http request which fiber will read
|
||||
switch tt.name {
|
||||
case "Upgrade websocket header set":
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
case "Connection Upgrade only":
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
}
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
wantCode := 200
|
||||
if tt.want {
|
||||
wantCode = 101
|
||||
}
|
||||
if resp.StatusCode != wantCode {
|
||||
t.Errorf("status: want %d, got %d", wantCode, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// admin_dashboard.go — getMapKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_GetMapKeys(t *testing.T) {
|
||||
t.Run("nil map returns empty slice", func(t *testing.T) {
|
||||
keys := getMapKeys(nil)
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("expected empty slice for nil map, got %v", keys)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty map returns empty slice", func(t *testing.T) {
|
||||
keys := getMapKeys(map[string]any{})
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", keys)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("populated map returns all keys", func(t *testing.T) {
|
||||
m := map[string]any{"alpha": 1, "beta": 2, "gamma": 3}
|
||||
keys := getMapKeys(m)
|
||||
if len(keys) != 3 {
|
||||
t.Fatalf("expected 3 keys, got %d: %v", len(keys), keys)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
for i, k := range keys {
|
||||
if k != want[i] {
|
||||
t.Errorf("key[%d]: want %q, got %q", i, want[i], k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// proxy.go — setupTracing (tracing disabled path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoverageMicro_SetupTracing_Disabled(t *testing.T) {
|
||||
t.Run("tracing disabled returns background context", func(t *testing.T) {
|
||||
// Ensure cfg is initialised before reading it
|
||||
cfgMutex.RLock()
|
||||
needsInit := cfg == nil
|
||||
cfgMutex.RUnlock()
|
||||
if needsInit {
|
||||
parseConfig()
|
||||
}
|
||||
|
||||
// Ensure tracing is disabled
|
||||
cfgMutex.Lock()
|
||||
origEnable := cfg.Tracing.Enable
|
||||
cfg.Tracing.Enable = false
|
||||
cfgMutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Tracing.Enable = origEnable
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
var capturedCtx context.Context
|
||||
app.Get("/trace-test", func(c *fiber.Ctx) error {
|
||||
capturedCtx = setupTracing(c)
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/trace-test", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if capturedCtx == nil {
|
||||
t.Fatal("setupTracing returned nil context")
|
||||
}
|
||||
// Background context has no deadline
|
||||
if _, hasDeadline := capturedCtx.Deadline(); hasDeadline {
|
||||
t.Error("expected no deadline on returned context")
|
||||
}
|
||||
})
|
||||
}
|
||||
+9
-9
@@ -27,7 +27,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "=== DEBUG: Parsing GraphQL Query ===",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"query_length": len(query),
|
||||
"query_preview": truncateString(query, 100),
|
||||
},
|
||||
@@ -43,14 +43,14 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Failed to parse query",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
Pairs: map[string]any{"error": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Query parsed successfully",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"definitions_count": len(p.Definitions),
|
||||
},
|
||||
})
|
||||
@@ -72,7 +72,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: fmt.Sprintf("DEBUG: Definition #%d (OperationDefinition)", i),
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"operation_type": operationType,
|
||||
"operation_name": operationName,
|
||||
"selection_count": selectionCount,
|
||||
@@ -87,7 +87,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
if field, ok := sel.(*ast.Field); ok {
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: fmt.Sprintf("DEBUG: Mutation field #%d", j),
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"field_name": field.Name.Value,
|
||||
},
|
||||
})
|
||||
@@ -97,7 +97,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
} 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]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"fragment_name": frag.Name.Value,
|
||||
},
|
||||
})
|
||||
@@ -109,7 +109,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
|
||||
cfg.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "DEBUG: Final routing decision",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"operation_type": result.operationType,
|
||||
"operation_name": result.operationName,
|
||||
"active_endpoint": result.activeEndpoint,
|
||||
@@ -125,7 +125,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
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]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"expected_endpoint": cfg.Server.HostGraphQL,
|
||||
"actual_endpoint": result.activeEndpoint,
|
||||
},
|
||||
@@ -135,7 +135,7 @@ func debugParseGraphQLQuery(c *fiber.Ctx, query string) {
|
||||
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]interface{}{
|
||||
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,
|
||||
|
||||
@@ -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,7 +101,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -48,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
|
||||
@@ -67,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
|
||||
}
|
||||
@@ -125,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},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -137,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
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
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.1
|
||||
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.10
|
||||
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.3
|
||||
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.8.0
|
||||
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.89
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1
|
||||
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.69.0
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/automaxprocs v1.6.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.4 // 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.2 // 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,16 +57,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.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.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.1 h1:j3Ba4l2K1q3pkvzPqt6aSiQ2DBlAEj3VPVeBtpR3t/Y=
|
||||
github.com/VictoriaMetrics/metrics v1.43.1/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.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
|
||||
github.com/gofiber/fiber/v2 v2.52.10/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.3 h1:nXDd/AB17nEjqVCNDGioDhVL/gVqdlqRMfFergKDjHE=
|
||||
github.com/gookit/goutil v0.7.3/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.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
|
||||
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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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=
|
||||
@@ -76,16 +76,20 @@ github.com/lukaszraczylo/go-ratecounter v0.1.12 h1:VO6hHYGw/Jy9JUizXf/bS0AI2QX1u
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.12/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
|
||||
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/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
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.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
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=
|
||||
@@ -109,45 +113,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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
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.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
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/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
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-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
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=
|
||||
|
||||
+48
-59
@@ -103,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{}
|
||||
},
|
||||
}
|
||||
@@ -146,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,
|
||||
},
|
||||
@@ -227,9 +227,10 @@ func trackParsingAllocations() func() {
|
||||
func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
startTime := time.Now()
|
||||
|
||||
// Set up allocation tracking
|
||||
trackAllocs := trackParsingAllocations()
|
||||
defer trackAllocs()
|
||||
if cfg != nil && cfg.EnableAllocationTracking {
|
||||
trackAllocs := trackParsingAllocations()
|
||||
defer trackAllocs()
|
||||
}
|
||||
|
||||
// Get a result object from the pool and initialize it
|
||||
res := resultPool.Get().(*parseGraphQLQueryResult)
|
||||
@@ -244,7 +245,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 {
|
||||
@@ -321,68 +322,56 @@ func parseGraphQLQuery(c *fiber.Ctx) *parseGraphQLQueryResult {
|
||||
res.shouldIgnore = false
|
||||
res.operationName = "undefined"
|
||||
|
||||
// First scan for mutations - they take priority
|
||||
// Single pass over definitions: gather operation type, mutation flag,
|
||||
// operation name, and process directives / introspection checks together.
|
||||
// Mutations take priority for operationType regardless of order.
|
||||
hasMutation := false
|
||||
var mutationName string
|
||||
|
||||
for _, d := range p.Definitions {
|
||||
if oper, ok := d.(*ast.OperationDefinition); ok {
|
||||
operationType := strings.ToLower(oper.Operation)
|
||||
if operationType == "mutation" {
|
||||
hasMutation = true
|
||||
res.operationType = "mutation"
|
||||
if oper.Name != nil {
|
||||
mutationName = oper.Name.Value
|
||||
// Use mutation name immediately, sanitized to prevent metric panics
|
||||
res.operationName = sanitizeOperationName(mutationName)
|
||||
}
|
||||
break // Found a mutation, no need to continue first pass
|
||||
}
|
||||
oper, ok := d.(*ast.OperationDefinition)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Now process all definitions for other information
|
||||
for _, d := range p.Definitions {
|
||||
if oper, ok := d.(*ast.OperationDefinition); ok {
|
||||
operationType := strings.ToLower(oper.Operation)
|
||||
// Lower-case operation string ONCE per definition.
|
||||
operationType := strings.ToLower(oper.Operation)
|
||||
isMutation := operationType == "mutation"
|
||||
|
||||
// If we already found a mutation, only update name if needed
|
||||
if hasMutation {
|
||||
// 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 = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
} else {
|
||||
// No mutation found, use the normal logic
|
||||
if res.operationType == "" {
|
||||
res.operationType = operationType
|
||||
}
|
||||
|
||||
if res.operationName == "undefined" && oper.Name != nil {
|
||||
res.operationName = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
// Operation type assignment: mutations take priority; otherwise first-seen wins.
|
||||
if isMutation && !hasMutation {
|
||||
hasMutation = true
|
||||
res.operationType = "mutation"
|
||||
// Mutation name takes precedence — overwrite "undefined" if present.
|
||||
if oper.Name != nil {
|
||||
res.operationName = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
} else if !hasMutation && res.operationType == "" {
|
||||
res.operationType = operationType
|
||||
}
|
||||
|
||||
// Block mutations in read-only mode
|
||||
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
|
||||
if ifNotInTest() {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
_ = c.Status(403).SendString("The server is in read-only mode")
|
||||
res.shouldBlock = true
|
||||
return res
|
||||
// Operation name fill-in for non-mutation cases (or mutation w/o name handled above).
|
||||
if res.operationName == "undefined" && oper.Name != nil {
|
||||
res.operationName = sanitizeOperationName(oper.Name.Value)
|
||||
}
|
||||
|
||||
// Block mutations in read-only mode
|
||||
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
|
||||
if ifNotInTest() {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
|
||||
}
|
||||
_ = c.Status(403).SendString("The server is in read-only mode")
|
||||
res.shouldBlock = true
|
||||
return res
|
||||
}
|
||||
|
||||
// Process directives (like @cached)
|
||||
processDirectives(oper, res)
|
||||
// Process directives (like @cached)
|
||||
processDirectives(oper, res)
|
||||
|
||||
// Check for introspection queries if they're blocked
|
||||
if cfg.Security.BlockIntrospection && checkSelections(c, oper.GetSelectionSet().Selections) {
|
||||
_ = c.Status(403).SendString("Introspection queries are not allowed")
|
||||
res.shouldBlock = true
|
||||
return res
|
||||
}
|
||||
// Check for introspection queries if they're blocked
|
||||
if cfg.Security.BlockIntrospection && checkSelections(c, oper.GetSelectionSet().Selections) {
|
||||
_ = c.Status(403).SendString("Introspection queries are not allowed")
|
||||
res.shouldBlock = true
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
+13
-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)
|
||||
},
|
||||
}
|
||||
@@ -129,10 +132,17 @@ func (l *Logger) shouldLog(level int) bool {
|
||||
return level >= l.minLogLevel
|
||||
}
|
||||
|
||||
// IsLevelEnabled reports whether the given level would be emitted by this logger.
|
||||
// Useful to gate expensive log-field construction (map/slice allocations) behind a
|
||||
// cheap level check when the log call would otherwise be dropped.
|
||||
func (l *Logger) IsLevelEnabled(level int) bool {
|
||||
return level >= l.minLogLevel
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
|
||||
+211
-118
@@ -2,32 +2,68 @@ package main
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRUCacheEntry represents a cache entry with metadata
|
||||
// shardCount is the number of LRU shards. Must be a power of two for efficient
|
||||
// modulo via bitmask, but the implementation uses a plain modulo to keep the
|
||||
// constant flexible.
|
||||
const shardCount = 16
|
||||
|
||||
// LRUCacheEntry represents a cache entry with metadata.
|
||||
type LRUCacheEntry struct {
|
||||
timestamp time.Time
|
||||
value interface{}
|
||||
value any
|
||||
element *list.Element
|
||||
key string
|
||||
size int64
|
||||
}
|
||||
|
||||
// LRUCache implements a thread-safe LRU cache with O(1) operations
|
||||
type LRUCache struct {
|
||||
// lruCacheShard owns a slice of the keyspace and its own mutex/map/list. All
|
||||
// per-shard state lives here so that operations on different shards do not
|
||||
// contend on the same lock.
|
||||
type lruCacheShard struct {
|
||||
entries map[string]*LRUCacheEntry
|
||||
evictList *list.List
|
||||
maxEntries int
|
||||
maxSize int64
|
||||
currentSize int64
|
||||
mu sync.RWMutex
|
||||
count int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewLRUCache creates a new LRU cache
|
||||
func newLRUCacheShard() *lruCacheShard {
|
||||
return &lruCacheShard{
|
||||
entries: make(map[string]*LRUCacheEntry),
|
||||
evictList: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// LRUCache implements a thread-safe LRU cache with O(1) operations and 16-way
|
||||
// sharding to reduce mutex contention under concurrent load. Capacity and
|
||||
// size limits are enforced globally; sharding is a concurrency optimisation.
|
||||
type LRUCache struct {
|
||||
shards [shardCount]*lruCacheShard
|
||||
maxEntries int
|
||||
maxSize int64
|
||||
totalSize int64 // atomic, sum of shard sizes
|
||||
totalCount int64 // atomic, sum of shard counts
|
||||
|
||||
// evictMu serialises cross-shard eviction passes so that two writers do
|
||||
// not race to over-evict. The hot Get/Set paths do not touch this lock.
|
||||
evictMu sync.Mutex
|
||||
|
||||
// entries and evictList are retained as no-op placeholders so that the
|
||||
// existing test suite (which asserts NotNil on these fields after
|
||||
// construction) keeps compiling. They are not used by the sharded
|
||||
// implementation.
|
||||
entries map[string]*LRUCacheEntry
|
||||
evictList *list.List
|
||||
}
|
||||
|
||||
// NewLRUCache creates a new LRU cache with the given global limits.
|
||||
func NewLRUCache(maxEntries int, maxSize int64) *LRUCache {
|
||||
// Ensure non-negative values for safety
|
||||
if maxEntries < 0 {
|
||||
maxEntries = 0
|
||||
}
|
||||
@@ -35,191 +71,248 @@ func NewLRUCache(maxEntries int, maxSize int64) *LRUCache {
|
||||
maxSize = 0
|
||||
}
|
||||
|
||||
return &LRUCache{
|
||||
c := &LRUCache{
|
||||
maxEntries: maxEntries,
|
||||
maxSize: maxSize,
|
||||
entries: make(map[string]*LRUCacheEntry),
|
||||
evictList: list.New(),
|
||||
}
|
||||
for i := 0; i < shardCount; i++ {
|
||||
c.shards[i] = newLRUCacheShard()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *LRUCache) Get(key string) (interface{}, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// shardFor routes a key to one of the shards via FNV-1a (no extra dependency).
|
||||
func (c *LRUCache) shardFor(key string) *lruCacheShard {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(key))
|
||||
return c.shards[h.Sum64()%shardCount]
|
||||
}
|
||||
|
||||
entry, exists := c.entries[key]
|
||||
// Get retrieves a value from the cache.
|
||||
func (c *LRUCache) Get(key string) (any, bool) {
|
||||
s := c.shardFor(key)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, exists := s.entries[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Move to front (most recently used)
|
||||
c.evictList.MoveToFront(entry.element)
|
||||
s.evictList.MoveToFront(entry.element)
|
||||
entry.timestamp = time.Now()
|
||||
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
// Set adds or updates a value in the cache
|
||||
func (c *LRUCache) Set(key string, value interface{}, size int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// Set adds or updates a value in the cache.
|
||||
func (c *LRUCache) Set(key string, value any, size int64) {
|
||||
s := c.shardFor(key)
|
||||
|
||||
// Check if key already exists
|
||||
if entry, exists := c.entries[key]; exists {
|
||||
// Update existing entry
|
||||
c.currentSize -= entry.size
|
||||
c.currentSize += size
|
||||
s.mu.Lock()
|
||||
if entry, exists := s.entries[key]; exists {
|
||||
delta := size - entry.size
|
||||
entry.value = value
|
||||
entry.size = size
|
||||
entry.timestamp = time.Now()
|
||||
c.evictList.MoveToFront(entry.element)
|
||||
|
||||
// Check if we need to evict due to size
|
||||
s.evictList.MoveToFront(entry.element)
|
||||
s.currentSize += delta
|
||||
atomic.AddInt64(&c.totalSize, delta)
|
||||
s.mu.Unlock()
|
||||
c.evictIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
entry := &LRUCacheEntry{
|
||||
key: key,
|
||||
value: value,
|
||||
size: size,
|
||||
timestamp: time.Now(),
|
||||
}
|
||||
entry.element = s.evictList.PushFront(entry)
|
||||
s.entries[key] = entry
|
||||
s.currentSize += size
|
||||
s.count++
|
||||
atomic.AddInt64(&c.totalSize, size)
|
||||
atomic.AddInt64(&c.totalCount, 1)
|
||||
s.mu.Unlock()
|
||||
|
||||
// Add to front of list
|
||||
element := c.evictList.PushFront(entry)
|
||||
entry.element = element
|
||||
c.entries[key] = entry
|
||||
c.currentSize += size
|
||||
|
||||
// Evict if necessary
|
||||
c.evictIfNeeded()
|
||||
}
|
||||
|
||||
// evictIfNeeded removes entries when cache limits are exceeded
|
||||
// evictIfNeeded enforces the global maxEntries / maxSize limits by evicting
|
||||
// the globally least-recently-used entry across all shards until under limits.
|
||||
// Selecting the victim shard requires inspecting each shard's tail timestamp,
|
||||
// which is O(shardCount) per eviction — acceptable because shardCount is a
|
||||
// small constant.
|
||||
func (c *LRUCache) evictIfNeeded() {
|
||||
// If both limits are zero, don't allow any entries
|
||||
if c.maxEntries == 0 || c.maxSize == 0 {
|
||||
// Clear everything for zero limits
|
||||
c.entries = make(map[string]*LRUCacheEntry)
|
||||
c.evictList = list.New()
|
||||
c.currentSize = 0
|
||||
c.purgeAll()
|
||||
return
|
||||
}
|
||||
|
||||
// Evict based on entry count
|
||||
for c.evictList.Len() > c.maxEntries {
|
||||
if c.evictList.Len() == 0 {
|
||||
break // Safety check to prevent infinite loop
|
||||
}
|
||||
c.evictOldest()
|
||||
// Fast path: lock-free check before acquiring evictMu. Avoids serialising
|
||||
// every Set when limits are not exceeded.
|
||||
if atomic.LoadInt64(&c.totalCount) <= int64(c.maxEntries) &&
|
||||
atomic.LoadInt64(&c.totalSize) <= c.maxSize {
|
||||
return
|
||||
}
|
||||
|
||||
// Evict based on size
|
||||
for c.currentSize > c.maxSize && c.evictList.Len() > 0 {
|
||||
oldSize := c.currentSize
|
||||
c.evictOldest()
|
||||
// Safety check: if size didn't decrease, break to prevent infinite loop
|
||||
if c.currentSize == oldSize {
|
||||
break
|
||||
c.evictMu.Lock()
|
||||
defer c.evictMu.Unlock()
|
||||
|
||||
for {
|
||||
count := atomic.LoadInt64(&c.totalCount)
|
||||
size := atomic.LoadInt64(&c.totalSize)
|
||||
if count <= int64(c.maxEntries) && size <= c.maxSize {
|
||||
return
|
||||
}
|
||||
if !c.evictGloballyOldest() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest removes the least recently used entry
|
||||
func (c *LRUCache) evictOldest() {
|
||||
element := c.evictList.Back()
|
||||
if element == nil {
|
||||
return
|
||||
// evictGloballyOldest removes the single entry with the oldest timestamp
|
||||
// across all shards. Returns false if no entry could be evicted.
|
||||
func (c *LRUCache) evictGloballyOldest() bool {
|
||||
var (
|
||||
victimShard *lruCacheShard
|
||||
victimTS time.Time
|
||||
first = true
|
||||
)
|
||||
|
||||
// Snapshot tail timestamps under each shard lock. Briefly hold each lock.
|
||||
for _, s := range c.shards {
|
||||
s.mu.Lock()
|
||||
back := s.evictList.Back()
|
||||
if back != nil {
|
||||
ts := back.Value.(*LRUCacheEntry).timestamp
|
||||
if first || ts.Before(victimTS) {
|
||||
victimTS = ts
|
||||
victimShard = s
|
||||
first = false
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
entry := element.Value.(*LRUCacheEntry)
|
||||
c.removeEntry(entry)
|
||||
if victimShard == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
victimShard.mu.Lock()
|
||||
defer victimShard.mu.Unlock()
|
||||
back := victimShard.evictList.Back()
|
||||
if back == nil {
|
||||
return false
|
||||
}
|
||||
entry := back.Value.(*LRUCacheEntry)
|
||||
c.removeFromShard(victimShard, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
// removeEntry removes an entry from the cache
|
||||
func (c *LRUCache) removeEntry(entry *LRUCacheEntry) {
|
||||
c.evictList.Remove(entry.element)
|
||||
delete(c.entries, entry.key)
|
||||
c.currentSize -= entry.size
|
||||
// removeFromShard removes an entry from its shard. Caller must hold shard lock.
|
||||
func (c *LRUCache) removeFromShard(s *lruCacheShard, entry *LRUCacheEntry) {
|
||||
s.evictList.Remove(entry.element)
|
||||
delete(s.entries, entry.key)
|
||||
s.currentSize -= entry.size
|
||||
s.count--
|
||||
atomic.AddInt64(&c.totalSize, -entry.size)
|
||||
atomic.AddInt64(&c.totalCount, -1)
|
||||
}
|
||||
|
||||
// Delete removes a key from the cache
|
||||
// purgeAll empties every shard. Used when limits are zero.
|
||||
func (c *LRUCache) purgeAll() {
|
||||
for _, s := range c.shards {
|
||||
s.mu.Lock()
|
||||
freedSize := s.currentSize
|
||||
freedCount := s.count
|
||||
s.entries = make(map[string]*LRUCacheEntry)
|
||||
s.evictList = list.New()
|
||||
s.currentSize = 0
|
||||
s.count = 0
|
||||
s.mu.Unlock()
|
||||
atomic.AddInt64(&c.totalSize, -freedSize)
|
||||
atomic.AddInt64(&c.totalCount, -freedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a key from the cache.
|
||||
func (c *LRUCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
s := c.shardFor(key)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, exists := c.entries[key]
|
||||
entry, exists := s.entries[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
c.removeEntry(entry)
|
||||
c.removeFromShard(s, entry)
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
// Clear removes all entries from the cache.
|
||||
func (c *LRUCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.entries = make(map[string]*LRUCacheEntry)
|
||||
c.evictList = list.New()
|
||||
c.currentSize = 0
|
||||
for _, s := range c.shards {
|
||||
s.mu.Lock()
|
||||
freedSize := s.currentSize
|
||||
freedCount := s.count
|
||||
s.entries = make(map[string]*LRUCacheEntry)
|
||||
s.evictList = list.New()
|
||||
s.currentSize = 0
|
||||
s.count = 0
|
||||
s.mu.Unlock()
|
||||
atomic.AddInt64(&c.totalSize, -freedSize)
|
||||
atomic.AddInt64(&c.totalCount, -freedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of entries in the cache
|
||||
// Len returns the number of entries in the cache.
|
||||
func (c *LRUCache) Len() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.evictList.Len()
|
||||
return int(atomic.LoadInt64(&c.totalCount))
|
||||
}
|
||||
|
||||
// Size returns the current size of the cache in bytes
|
||||
// Size returns the current size of the cache in bytes.
|
||||
func (c *LRUCache) Size() int64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.currentSize
|
||||
return atomic.LoadInt64(&c.totalSize)
|
||||
}
|
||||
|
||||
// CleanupExpired removes entries older than the given duration
|
||||
// CleanupExpired removes entries older than the given duration across all
|
||||
// shards. Returns the total number of entries removed.
|
||||
func (c *LRUCache) CleanupExpired(maxAge time.Duration) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
removed := 0
|
||||
|
||||
// Iterate from back (oldest) to front (newest)
|
||||
for element := c.evictList.Back(); element != nil; {
|
||||
entry := element.Value.(*LRUCacheEntry)
|
||||
|
||||
// If entry is not expired, we can stop (entries are ordered by access time)
|
||||
if now.Sub(entry.timestamp) <= maxAge {
|
||||
break
|
||||
for _, s := range c.shards {
|
||||
s.mu.Lock()
|
||||
for element := s.evictList.Back(); element != nil; {
|
||||
entry := element.Value.(*LRUCacheEntry)
|
||||
if now.Sub(entry.timestamp) <= maxAge {
|
||||
break
|
||||
}
|
||||
next := element.Prev()
|
||||
c.removeFromShard(s, entry)
|
||||
removed++
|
||||
element = next
|
||||
}
|
||||
|
||||
// Remove expired entry
|
||||
next := element.Prev()
|
||||
c.removeEntry(entry)
|
||||
removed++
|
||||
element = next
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
// GetStats returns cache statistics
|
||||
func (c *LRUCache) GetStats() map[string]interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"entries": c.evictList.Len(),
|
||||
"size_bytes": c.currentSize,
|
||||
// GetStats returns cache statistics.
|
||||
func (c *LRUCache) GetStats() map[string]any {
|
||||
size := atomic.LoadInt64(&c.totalSize)
|
||||
count := atomic.LoadInt64(&c.totalCount)
|
||||
var fillPercent float64
|
||||
if c.maxSize > 0 {
|
||||
fillPercent = float64(size) / float64(c.maxSize) * 100
|
||||
}
|
||||
return map[string]any{
|
||||
"entries": int(count),
|
||||
"size_bytes": size,
|
||||
"max_entries": c.maxEntries,
|
||||
"max_size": c.maxSize,
|
||||
"fill_percent": float64(c.currentSize) / float64(c.maxSize) * 100,
|
||||
"fill_percent": fillPercent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -15,6 +16,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// Register pprof handlers on http.DefaultServeMux. Listener is bound to
|
||||
// 127.0.0.1 only and gated by PPROF_PORT — never expose publicly.
|
||||
_ "net/http/pprof" //nolint:gosec // G108: handlers gated by PPROF_PORT, bound to 127.0.0.1 only
|
||||
|
||||
"github.com/gofiber/fiber/v2/middleware/proxy"
|
||||
"github.com/gookit/goutil/envutil"
|
||||
graphql "github.com/lukaszraczylo/go-simple-graphql"
|
||||
@@ -23,8 +28,17 @@ import (
|
||||
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
libpack_tracing "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing"
|
||||
telemetry "github.com/lukaszraczylo/oss-telemetry"
|
||||
|
||||
// Auto-tune GOMAXPROCS from cgroup CPU quota (containerized workloads).
|
||||
_ "go.uber.org/automaxprocs"
|
||||
)
|
||||
|
||||
// appVersion is the build version. Set via ldflags during build:
|
||||
//
|
||||
// -X main.appVersion=v1.2.3
|
||||
var appVersion = "dev"
|
||||
|
||||
var (
|
||||
cfg *config
|
||||
cfgMutex sync.RWMutex
|
||||
@@ -146,7 +160,7 @@ func parseConfig() {
|
||||
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]interface{}{
|
||||
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",
|
||||
},
|
||||
@@ -170,6 +184,7 @@ func parseConfig() {
|
||||
return strings.Split(urls, ",")
|
||||
}()
|
||||
c.LogLevel = strings.ToUpper(getDetailsFromEnv("LOG_LEVEL", "info"))
|
||||
c.EnableAllocationTracking = getDetailsFromEnv("ENABLE_ALLOCATION_TRACKING", false)
|
||||
// Logger setup
|
||||
c.Logger = libpack_logging.New().SetMinLogLevel(libpack_logging.GetLogLevel(c.LogLevel)).
|
||||
SetFieldName("timestamp", "ts").SetFieldName("message", "msg").SetShowCaller(false)
|
||||
@@ -193,7 +208,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
|
||||
}
|
||||
@@ -205,7 +220,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
|
||||
}
|
||||
@@ -241,7 +256,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",
|
||||
},
|
||||
})
|
||||
@@ -261,7 +276,7 @@ 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 {
|
||||
@@ -310,6 +325,39 @@ func parseConfig() {
|
||||
// Admin dashboard configuration
|
||||
c.AdminDashboard.Enable = getDetailsFromEnv("ADMIN_DASHBOARD_ENABLE", true)
|
||||
|
||||
// Optional debug pprof endpoint. Disabled unless PPROF_PORT is set to a
|
||||
// valid integer. Bound to 127.0.0.1 ONLY — pprof must never be exposed
|
||||
// publicly (it leaks memory layout, allows arbitrary CPU profiles, etc).
|
||||
if pprofPortStr := getDetailsFromEnv("PPROF_PORT", ""); pprofPortStr != "" {
|
||||
if pprofPort, err := strconv.Atoi(pprofPortStr); err == nil && pprofPort > 0 && pprofPort < 65536 {
|
||||
addr := "127.0.0.1:" + strconv.Itoa(pprofPort)
|
||||
c.Logger.Info(&libpack_logging.LogMessage{
|
||||
Message: "pprof endpoint listening on " + addr,
|
||||
})
|
||||
go func(listenAddr string) {
|
||||
srv := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: nil,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
c.Logger.Error(&libpack_logging.LogMessage{
|
||||
Message: "pprof endpoint failed",
|
||||
Pairs: map[string]any{"error": err.Error(), "addr": listenAddr},
|
||||
})
|
||||
}
|
||||
}(addr)
|
||||
} else {
|
||||
c.Logger.Warning(&libpack_logging.LogMessage{
|
||||
Message: "PPROF_PORT set but invalid; pprof endpoint disabled",
|
||||
Pairs: map[string]any{"value": pprofPortStr},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cfgMutex.Lock()
|
||||
cfg = &c
|
||||
cfgMutex.Unlock()
|
||||
@@ -331,12 +379,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},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -346,7 +394,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,
|
||||
},
|
||||
@@ -360,14 +408,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(),
|
||||
},
|
||||
})
|
||||
@@ -399,7 +447,7 @@ func parseConfig() {
|
||||
}
|
||||
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,
|
||||
@@ -450,7 +498,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,
|
||||
},
|
||||
})
|
||||
@@ -470,6 +518,8 @@ func parseConfig() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
telemetry.SendForModule("graphql-monitoring-proxy", "github.com/lukaszraczylo/graphql-monitoring-proxy", appVersion)
|
||||
|
||||
// Parse configuration
|
||||
parseConfig()
|
||||
|
||||
@@ -517,7 +567,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()},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -527,7 +577,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()},
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -567,7 +617,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
|
||||
@@ -585,7 +635,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,
|
||||
},
|
||||
@@ -600,7 +650,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()),
|
||||
},
|
||||
})
|
||||
@@ -608,7 +658,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(),
|
||||
},
|
||||
@@ -623,7 +673,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
|
||||
@@ -641,7 +691,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,
|
||||
},
|
||||
@@ -670,7 +720,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()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -751,7 +801,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
|
||||
|
||||
+57
-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,20 +238,20 @@ 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
|
||||
}
|
||||
|
||||
} else {
|
||||
// Fallback: if stats extraction fails, use empty map
|
||||
if ma.logger != nil {
|
||||
if ma.logger != nil && ma.logger.IsLevelEnabled(libpack_logger.LEVEL_ERROR) {
|
||||
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
|
||||
@@ -542,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,
|
||||
},
|
||||
@@ -551,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)
|
||||
}
|
||||
@@ -571,7 +571,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
totalAvgRPS += avgRPS
|
||||
}
|
||||
} else {
|
||||
if ma.logger != nil {
|
||||
if ma.logger != nil && ma.logger.IsLevelEnabled(libpack_logger.LEVEL_WARN) {
|
||||
// Log what keys are actually in Stats for debugging
|
||||
keys := make([]string, 0, len(instance.Stats))
|
||||
for k := range instance.Stats {
|
||||
@@ -579,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,
|
||||
@@ -715,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,
|
||||
@@ -728,13 +728,13 @@ 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{}{
|
||||
"memory": map[string]any{
|
||||
"total_usage_mb": func() float64 {
|
||||
if hasValidMemoryStats {
|
||||
return totalMemoryUsageMB
|
||||
@@ -743,20 +743,20 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
}(),
|
||||
"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,
|
||||
@@ -766,7 +766,7 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
|
||||
"max_tokens": totalMaxTokens,
|
||||
"tokens_per_sec": retryTokensPerSec,
|
||||
},
|
||||
"circuit_breaker": map[string]interface{}{
|
||||
"circuit_breaker": map[string]any{
|
||||
"enabled": circuitBreakerEnabled,
|
||||
"state": cbState,
|
||||
"instances_open": cbOpenCount,
|
||||
@@ -788,7 +788,7 @@ func (ma *MetricsAggregator) Shutdown() {
|
||||
}
|
||||
|
||||
if ma.redisClient != nil {
|
||||
ma.redisClient.Close()
|
||||
_ = ma.redisClient.Close() // Best-effort cleanup
|
||||
}
|
||||
|
||||
if ma.logger != nil {
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestAggregator spins up a miniredis, creates a redis.Client against it,
|
||||
// and returns a MetricsAggregator wired to that client.
|
||||
// The caller must call t.Cleanup to shut down the aggregator and the server.
|
||||
func newTestAggregator(t *testing.T) (*MetricsAggregator, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err, "miniredis.Run")
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ma := &MetricsAggregator{
|
||||
redisClient: client,
|
||||
logger: libpack_logger.New(),
|
||||
instanceID: "test-instance-001",
|
||||
publishKey: "graphql-proxy:metrics:instances",
|
||||
ttl: 30 * time.Second,
|
||||
publishTimer: time.NewTicker(100 * time.Millisecond),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ma.Shutdown()
|
||||
mr.Close()
|
||||
})
|
||||
|
||||
return ma, mr
|
||||
}
|
||||
|
||||
// minimalCfg sets the package-level cfg to a minimal valid value so publishMetrics
|
||||
// does not bail out on the nil-cfg guard. Restores the original on cleanup.
|
||||
func minimalCfg(t *testing.T) {
|
||||
t.Helper()
|
||||
old := cfg
|
||||
cfgMutex.Lock()
|
||||
cfg = &config{
|
||||
Logger: libpack_logger.New(),
|
||||
Monitoring: libpack_monitoring.NewMonitoring(&libpack_monitoring.InitConfig{}),
|
||||
}
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg = old
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// ----- InitializeMetricsAggregator ----------------------------------------
|
||||
|
||||
func TestMetricsAggregator_InitializeMetricsAggregator_AlreadyInitialized(t *testing.T) {
|
||||
// If the singleton is already set, Init must be a no-op and return nil.
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
existing := &MetricsAggregator{
|
||||
redisClient: client,
|
||||
instanceID: "existing",
|
||||
publishKey: "graphql-proxy:metrics:instances",
|
||||
ttl: 30 * time.Second,
|
||||
publishTimer: time.NewTicker(time.Hour),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Inject singleton directly (bypass constructor).
|
||||
aggregatorMutex.Lock()
|
||||
old := metricsAggregator
|
||||
metricsAggregator = existing
|
||||
aggregatorMutex.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
aggregatorMutex.Lock()
|
||||
metricsAggregator = old
|
||||
aggregatorMutex.Unlock()
|
||||
existing.publishTimer.Stop()
|
||||
cancel()
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
err = InitializeMetricsAggregator(mr.Addr(), "", 0, libpack_logger.New())
|
||||
assert.NoError(t, err, "should return nil when already initialized")
|
||||
|
||||
// Singleton must still be the original instance.
|
||||
aggregatorMutex.RLock()
|
||||
got := metricsAggregator
|
||||
aggregatorMutex.RUnlock()
|
||||
assert.Equal(t, existing, got, "singleton must not be replaced")
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_InitializeMetricsAggregator_BadURL(t *testing.T) {
|
||||
// Ensure the singleton is clear for this sub-test.
|
||||
aggregatorMutex.Lock()
|
||||
old := metricsAggregator
|
||||
metricsAggregator = nil
|
||||
aggregatorMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
aggregatorMutex.Lock()
|
||||
if metricsAggregator != nil {
|
||||
metricsAggregator.Shutdown()
|
||||
}
|
||||
metricsAggregator = old
|
||||
aggregatorMutex.Unlock()
|
||||
})
|
||||
|
||||
// An unreachable address should cause Ping to fail and return an error.
|
||||
err := InitializeMetricsAggregator("127.0.0.1:1", "", 0, nil)
|
||||
assert.Error(t, err, "should fail when Redis is unreachable")
|
||||
}
|
||||
|
||||
// ----- removeInstanceMetrics -----------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_RemoveInstanceMetrics_CleansKeys(t *testing.T) {
|
||||
ma, mr := newTestAggregator(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Pre-populate keys that removeInstanceMetrics should delete.
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, ma.instanceID)
|
||||
err := mr.Set(key, `{"instance_id":"test-instance-001"}`)
|
||||
require.NoError(t, err)
|
||||
err = ma.redisClient.SAdd(ctx, ma.publishKey, ma.instanceID).Err()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify keys exist before removal.
|
||||
exists, err := ma.redisClient.Exists(ctx, key).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists, "key should exist before removal")
|
||||
|
||||
ma.removeInstanceMetrics()
|
||||
|
||||
// Verify instance key is gone.
|
||||
exists, err = ma.redisClient.Exists(ctx, key).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), exists, "key should be deleted after removeInstanceMetrics")
|
||||
|
||||
// Verify instance ID removed from the set.
|
||||
isMember, err := ma.redisClient.SIsMember(ctx, ma.publishKey, ma.instanceID).Result()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isMember, "instanceID should be removed from the set")
|
||||
}
|
||||
|
||||
// ----- publishMetrics -------------------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_PublishMetrics_WritesRedisKey(t *testing.T) {
|
||||
minimalCfg(t)
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
ma.publishMetrics()
|
||||
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, ma.instanceID)
|
||||
|
||||
val, err := ma.redisClient.Get(ctx, key).Result()
|
||||
require.NoError(t, err, "publishMetrics should have written the key to Redis")
|
||||
assert.NotEmpty(t, val, "stored value must not be empty")
|
||||
|
||||
// Must be valid JSON.
|
||||
var im InstanceMetrics
|
||||
require.NoError(t, json.Unmarshal([]byte(val), &im), "stored value must be valid JSON")
|
||||
assert.Equal(t, ma.instanceID, im.InstanceID)
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_PublishMetrics_NilCfgNoWrite(t *testing.T) {
|
||||
// Ensure cfg is nil so publishMetrics bails out early.
|
||||
cfgMutex.Lock()
|
||||
old := cfg
|
||||
cfg = nil
|
||||
cfgMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg = old
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
ma, _ := newTestAggregator(t)
|
||||
ma.publishMetrics() // Must not panic.
|
||||
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, ma.instanceID)
|
||||
exists, err := ma.redisClient.Exists(ctx, key).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), exists, "no key should be written when cfg is nil")
|
||||
}
|
||||
|
||||
// ----- startPublishing (one tick) ------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_StartPublishing_PublishesOnStart(t *testing.T) {
|
||||
minimalCfg(t)
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
// Run startPublishing in background; it calls publishMetrics immediately.
|
||||
go ma.startPublishing()
|
||||
|
||||
// Give the initial synchronous publish time to complete, then cancel.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
ma.cancel()
|
||||
|
||||
// Allow the goroutine to finish cleanup.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, ma.instanceID)
|
||||
val, err := ma.redisClient.Get(ctx, key).Result()
|
||||
// After startPublishing runs publishMetrics on start, the key must be present
|
||||
// (unless cfg is nil — but we set it above). If removeInstanceMetrics ran on
|
||||
// shutdown it deletes the key; that is fine — what we assert is no panic + the
|
||||
// goroutine exits cleanly (verified by the race detector).
|
||||
_ = val
|
||||
_ = err
|
||||
// Primary assertion: no goroutine leak (race detector) and no panic.
|
||||
}
|
||||
|
||||
// ----- GetAggregatedMetrics ------------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_GetAggregatedMetrics_EmptySet(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
result, err := ma.GetAggregatedMetrics()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 0, result.TotalInstances)
|
||||
assert.Equal(t, 0, result.HealthyInstances)
|
||||
assert.Empty(t, result.Instances)
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_GetAggregatedMetrics_TwoInstances_Aggregated(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-A",
|
||||
Hostname: "host-a",
|
||||
LastUpdate: time.Now(),
|
||||
UptimeSeconds: 120,
|
||||
Stats: map[string]any{
|
||||
"requests": map[string]any{
|
||||
"total": float64(100),
|
||||
"succeeded": float64(95),
|
||||
"failed": float64(5),
|
||||
"skipped": float64(0),
|
||||
"current_requests_per_second": float64(10),
|
||||
"avg_requests_per_second": float64(8),
|
||||
},
|
||||
},
|
||||
Health: map[string]any{"status": "healthy"},
|
||||
},
|
||||
{
|
||||
InstanceID: "inst-B",
|
||||
Hostname: "host-b",
|
||||
LastUpdate: time.Now(),
|
||||
UptimeSeconds: 60,
|
||||
Stats: map[string]any{
|
||||
"requests": map[string]any{
|
||||
"total": float64(200),
|
||||
"succeeded": float64(180),
|
||||
"failed": float64(20),
|
||||
"skipped": float64(0),
|
||||
"current_requests_per_second": float64(20),
|
||||
"avg_requests_per_second": float64(15),
|
||||
},
|
||||
},
|
||||
Health: map[string]any{"status": "healthy"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
data, err := json.Marshal(inst)
|
||||
require.NoError(t, err)
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, inst.InstanceID)
|
||||
pipe := ma.redisClient.Pipeline()
|
||||
pipe.Set(ctx, key, data, 30*time.Second)
|
||||
pipe.SAdd(ctx, ma.publishKey, inst.InstanceID)
|
||||
_, err = pipe.Exec(ctx)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
result, err := ma.GetAggregatedMetrics()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, 2, result.TotalInstances)
|
||||
assert.Equal(t, 2, result.HealthyInstances)
|
||||
assert.Len(t, result.Instances, 2)
|
||||
|
||||
// CombinedStats.requests.total must be sum of both.
|
||||
reqs, ok := result.CombinedStats["requests"].(map[string]any)
|
||||
require.True(t, ok, "combined_stats.requests must be present")
|
||||
assert.Equal(t, int64(300), reqs["total"])
|
||||
assert.Equal(t, int64(275), reqs["succeeded"])
|
||||
assert.Equal(t, int64(25), reqs["failed"])
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_GetAggregatedMetrics_StaleInstanceSkipped(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
stale := InstanceMetrics{
|
||||
InstanceID: "stale-inst",
|
||||
Hostname: "host-stale",
|
||||
LastUpdate: time.Now().Add(-2 * time.Minute), // older than 1 minute threshold
|
||||
UptimeSeconds: 9999,
|
||||
Stats: map[string]any{},
|
||||
Health: map[string]any{"status": "healthy"},
|
||||
}
|
||||
data, err := json.Marshal(stale)
|
||||
require.NoError(t, err)
|
||||
key := fmt.Sprintf("%s:%s", ma.publishKey, stale.InstanceID)
|
||||
pipe := ma.redisClient.Pipeline()
|
||||
pipe.Set(ctx, key, data, 30*time.Second)
|
||||
pipe.SAdd(ctx, ma.publishKey, stale.InstanceID)
|
||||
_, err = pipe.Exec(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ma.GetAggregatedMetrics()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, 0, result.TotalInstances, "stale instance should be excluded")
|
||||
}
|
||||
|
||||
// ----- aggregateStats -------------------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_EmptyInstances(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
result := ma.aggregateStats([]InstanceMetrics{})
|
||||
assert.NotNil(t, result)
|
||||
assert.Empty(t, result, "empty input should return empty map")
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_SingleInstance(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-1",
|
||||
UptimeSeconds: 300,
|
||||
Stats: map[string]any{
|
||||
"requests": map[string]any{
|
||||
"total": float64(50),
|
||||
"succeeded": float64(45),
|
||||
"failed": float64(5),
|
||||
"skipped": float64(2),
|
||||
"current_requests_per_second": float64(5),
|
||||
"avg_requests_per_second": float64(4),
|
||||
},
|
||||
},
|
||||
CacheSummary: map[string]any{
|
||||
"hits": float64(30),
|
||||
"misses": float64(20),
|
||||
"total_cached": float64(10),
|
||||
},
|
||||
Health: map[string]any{"status": "healthy"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
require.NotEmpty(t, result)
|
||||
|
||||
reqs, ok := result["requests"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, int64(50), reqs["total"])
|
||||
assert.Equal(t, int64(45), reqs["succeeded"])
|
||||
assert.Equal(t, int64(5), reqs["failed"])
|
||||
|
||||
cache, ok := result["cache_summary"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, int64(30), cache["hits"])
|
||||
assert.Equal(t, int64(20), cache["misses"])
|
||||
|
||||
// success_rate: 45/50 * 100 = 90%
|
||||
successRate, ok := reqs["success_rate_pct"].(float64)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 90.0, successRate, 0.01)
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_MultipleInstances_Sums(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-1",
|
||||
UptimeSeconds: 100,
|
||||
Stats: map[string]any{
|
||||
"requests": map[string]any{
|
||||
"total": float64(100),
|
||||
"succeeded": float64(90),
|
||||
"failed": float64(10),
|
||||
"skipped": float64(0),
|
||||
"current_requests_per_second": float64(10),
|
||||
"avg_requests_per_second": float64(8),
|
||||
},
|
||||
},
|
||||
Health: map[string]any{"status": "healthy"},
|
||||
},
|
||||
{
|
||||
InstanceID: "inst-2",
|
||||
UptimeSeconds: 200,
|
||||
Stats: map[string]any{
|
||||
"requests": map[string]any{
|
||||
"total": float64(400),
|
||||
"succeeded": float64(360),
|
||||
"failed": float64(40),
|
||||
"skipped": float64(0),
|
||||
"current_requests_per_second": float64(40),
|
||||
"avg_requests_per_second": float64(30),
|
||||
},
|
||||
},
|
||||
Health: map[string]any{"status": "degraded"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
require.NotEmpty(t, result)
|
||||
|
||||
reqs := result["requests"].(map[string]any)
|
||||
assert.Equal(t, int64(500), reqs["total"])
|
||||
assert.Equal(t, int64(450), reqs["succeeded"])
|
||||
assert.Equal(t, int64(50), reqs["failed"])
|
||||
|
||||
// cluster_uptime should be the oldest (smallest) uptime = 100.
|
||||
assert.Equal(t, float64(100), result["cluster_uptime"])
|
||||
assert.Equal(t, 2, result["total_instances"])
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_CircuitBreaker(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-open",
|
||||
UptimeSeconds: 50,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(10), "succeeded": float64(5), "failed": float64(5), "skipped": float64(0), "current_requests_per_second": float64(1), "avg_requests_per_second": float64(1)}},
|
||||
CircuitBreaker: map[string]any{
|
||||
"enabled": true,
|
||||
"state": "open",
|
||||
},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
{
|
||||
InstanceID: "inst-closed",
|
||||
UptimeSeconds: 60,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(10), "succeeded": float64(10), "failed": float64(0), "skipped": float64(0), "current_requests_per_second": float64(1), "avg_requests_per_second": float64(1)}},
|
||||
CircuitBreaker: map[string]any{
|
||||
"enabled": true,
|
||||
"state": "closed",
|
||||
},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
cb := result["circuit_breaker"].(map[string]any)
|
||||
assert.Equal(t, true, cb["enabled"])
|
||||
assert.Equal(t, "open", cb["state"], "any open instance means cluster state = open")
|
||||
assert.Equal(t, 1, cb["instances_open"])
|
||||
assert.Equal(t, 1, cb["instances_closed"])
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_RetryBudget(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-rb",
|
||||
UptimeSeconds: 10,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(1), "succeeded": float64(1), "failed": float64(0), "skipped": float64(0), "current_requests_per_second": float64(0), "avg_requests_per_second": float64(0)}},
|
||||
RetryBudget: map[string]any{
|
||||
"enabled": true,
|
||||
"allowed_retries": float64(50),
|
||||
"denied_retries": float64(10),
|
||||
"total_attempts": float64(60),
|
||||
"current_tokens": float64(80),
|
||||
"max_tokens": float64(100),
|
||||
"tokens_per_sec": float64(5),
|
||||
},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
rb := result["retry_budget"].(map[string]any)
|
||||
assert.Equal(t, true, rb["enabled"])
|
||||
assert.Equal(t, int64(50), rb["allowed_retries"])
|
||||
assert.Equal(t, int64(10), rb["denied_retries"])
|
||||
assert.InDelta(t, 16.67, rb["denial_rate_pct"].(float64), 0.1)
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_NilStats_DoesNotPanic(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
// Instance with nil Stats should not cause a panic; it is skipped.
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "bad-inst",
|
||||
UptimeSeconds: 10,
|
||||
Stats: nil,
|
||||
Health: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
result := ma.aggregateStats(instances)
|
||||
// cluster_uptime is set before the nil-stats guard, so it must be non-zero.
|
||||
assert.Equal(t, float64(10), result["cluster_uptime"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_MemoryTracking(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-mem",
|
||||
UptimeSeconds: 10,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(1), "succeeded": float64(1), "failed": float64(0), "skipped": float64(0), "current_requests_per_second": float64(0), "avg_requests_per_second": float64(0)}},
|
||||
Cache: map[string]any{"memory_usage_mb": float64(42.5)},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
{
|
||||
InstanceID: "inst-mem2",
|
||||
UptimeSeconds: 20,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(1), "succeeded": float64(1), "failed": float64(0), "skipped": float64(0), "current_requests_per_second": float64(0), "avg_requests_per_second": float64(0)}},
|
||||
Cache: map[string]any{"memory_usage_mb": float64(57.5)},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
mem := result["memory"].(map[string]any)
|
||||
assert.Equal(t, true, mem["available"])
|
||||
assert.InDelta(t, 100.0, mem["total_usage_mb"].(float64), 0.01)
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_AggregateStats_MemoryNegativeSkipped(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
// -1 means Redis cache where memory tracking is unavailable; must be skipped.
|
||||
instances := []InstanceMetrics{
|
||||
{
|
||||
InstanceID: "inst-redis-cache",
|
||||
UptimeSeconds: 10,
|
||||
Stats: map[string]any{"requests": map[string]any{"total": float64(1), "succeeded": float64(1), "failed": float64(0), "skipped": float64(0), "current_requests_per_second": float64(0), "avg_requests_per_second": float64(0)}},
|
||||
Cache: map[string]any{"memory_usage_mb": float64(-1)},
|
||||
Health: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
result := ma.aggregateStats(instances)
|
||||
mem := result["memory"].(map[string]any)
|
||||
assert.Equal(t, false, mem["available"])
|
||||
assert.Equal(t, float64(-1), mem["total_usage_mb"].(float64))
|
||||
}
|
||||
|
||||
// ----- Shutdown ------------------------------------------------------------
|
||||
|
||||
func TestMetricsAggregator_Shutdown_CancelsContext(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { mr.Close() })
|
||||
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ma := &MetricsAggregator{
|
||||
redisClient: client,
|
||||
logger: libpack_logger.New(),
|
||||
instanceID: "shutdown-test",
|
||||
publishKey: "graphql-proxy:metrics:instances",
|
||||
ttl: 30 * time.Second,
|
||||
publishTimer: time.NewTicker(time.Hour),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Context must not be done before Shutdown.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("context should not be done before Shutdown()")
|
||||
default:
|
||||
}
|
||||
|
||||
ma.Shutdown()
|
||||
|
||||
// Context must be cancelled after Shutdown.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// expected
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("context was not cancelled after Shutdown()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAggregator_Shutdown_Idempotent(t *testing.T) {
|
||||
ma, _ := newTestAggregator(t)
|
||||
|
||||
// Calling Shutdown twice must not panic.
|
||||
assert.NotPanics(t, func() {
|
||||
ma.Shutdown()
|
||||
ma.Shutdown()
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// 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 (
|
||||
@@ -82,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},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,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{}
|
||||
@@ -125,7 +128,7 @@ func (ms *MetricsSetup) RegisterMetricsGaugeFunc(metric_name string, labels map[
|
||||
if err := validate_metrics_name(metric_name); err != nil {
|
||||
log.Error(&libpack_logger.LogMessage{
|
||||
Message: "RegisterMetricsGaugeFunc() 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{}
|
||||
@@ -137,7 +140,7 @@ func (ms *MetricsSetup) RegisterMetricsCounter(metric_name string, labels map[st
|
||||
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{}
|
||||
@@ -152,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{}
|
||||
@@ -164,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{}
|
||||
@@ -176,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"
|
||||
@@ -33,6 +31,19 @@ var (
|
||||
ErrCircuitOpen = errors.New("circuit breaker is open")
|
||||
)
|
||||
|
||||
// Sentinel errors for the proxy request retry path. Grouped here so callers
|
||||
// can use errors.Is for comparison instead of brittle string matching.
|
||||
// Message text MUST match the historical fmt.Errorf strings — tests and
|
||||
// callers may assert on .Error().
|
||||
var (
|
||||
// errFiberCtxNilDuringRetry — fiber context dropped while retrying.
|
||||
errFiberCtxNilDuringRetry = errors.New("fiber context became nil during retry")
|
||||
// errFiberRespNil — fiber response object became nil mid-request.
|
||||
errFiberRespNil = errors.New("fiber response became nil")
|
||||
// errFiberCtxNil — fiber context was nil before the request started.
|
||||
errFiberCtxNil = errors.New("fiber context is nil")
|
||||
)
|
||||
|
||||
// Default values for circuit breaker
|
||||
const (
|
||||
defaultMaxRequestsInHalfOpen = 10 // Default maximum requests in half-open state
|
||||
@@ -44,6 +55,30 @@ var (
|
||||
cbMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// Package-level substring tables used by isConnectionError / isTimeoutError.
|
||||
// Hoisted to avoid per-call slice allocations on the hot path. All entries
|
||||
// must be lower-case; callers lower-case the error string once before matching.
|
||||
var (
|
||||
connectionErrorSubstrings = []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"eof",
|
||||
"no such host",
|
||||
"dial tcp",
|
||||
"dial udp",
|
||||
}
|
||||
|
||||
timeoutErrorSubstrings = []string{
|
||||
"timeout",
|
||||
"deadline exceeded",
|
||||
"context deadline exceeded",
|
||||
}
|
||||
)
|
||||
|
||||
// safeUint32 converts an int to uint32 safely, handling negative values and values exceeding uint32 max
|
||||
func safeUint32(value int) uint32 {
|
||||
// Handle negative values
|
||||
@@ -90,7 +125,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 +140,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 +157,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 +197,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 +350,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)
|
||||
@@ -353,7 +388,7 @@ func performProxyRequest(c *fiber.Ctx, proxyURL string) error {
|
||||
return &CoalescedResponse{
|
||||
Body: c.Response().Body(),
|
||||
StatusCode: c.Response().StatusCode(),
|
||||
Headers: make(map[string]string),
|
||||
// Headers intentionally left nil; not populated or read anywhere.
|
||||
}, nil
|
||||
})
|
||||
|
||||
@@ -392,7 +427,7 @@ func performProxyRequestCore(c *fiber.Ctx, proxyURL string, cacheKey string) err
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -400,7 +435,7 @@ func performProxyRequestCore(c *fiber.Ctx, proxyURL string, cacheKey string) err
|
||||
// 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(),
|
||||
},
|
||||
@@ -451,7 +486,7 @@ func performProxyRequestWithRetries(c *fiber.Ctx, proxyURL string) error {
|
||||
func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
|
||||
// Additional safety check inside retry loop
|
||||
if c == nil {
|
||||
return retry.Unrecoverable(fmt.Errorf("fiber context became nil during retry"))
|
||||
return retry.Unrecoverable(errFiberCtxNilDuringRetry)
|
||||
}
|
||||
|
||||
// Get connection pool manager for stats tracking
|
||||
@@ -486,9 +521,9 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
|
||||
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(errFiberRespNil)
|
||||
}
|
||||
|
||||
// Check status code and determine retry strategy
|
||||
@@ -520,7 +555,7 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
|
||||
func performProxyRequestWithEnhancedRetries(c *fiber.Ctx, proxyURL string, backendUnhealthy bool) error {
|
||||
// Safety check for nil context
|
||||
if c == nil {
|
||||
return fmt.Errorf("fiber context is nil")
|
||||
return errFiberCtxNil
|
||||
}
|
||||
|
||||
var attempts uint
|
||||
@@ -551,7 +586,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,
|
||||
@@ -602,7 +637,7 @@ func performProxyRequestWithEnhancedRetries(c *fiber.Ctx, proxyURL string, backe
|
||||
if !rb.AllowRetry() {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Retry denied by budget",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"path": c.Path(),
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -622,20 +657,7 @@ func isConnectionError(err error) bool {
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
connectionErrors := []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"eof",
|
||||
"no such host",
|
||||
"dial tcp",
|
||||
"dial udp",
|
||||
}
|
||||
|
||||
for _, connErr := range connectionErrors {
|
||||
for _, connErr := range connectionErrorSubstrings {
|
||||
if strings.Contains(errStr, connErr) {
|
||||
return true
|
||||
}
|
||||
@@ -650,9 +672,12 @@ func isTimeoutError(err error) bool {
|
||||
return false
|
||||
}
|
||||
errStr := strings.ToLower(err.Error())
|
||||
return strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "deadline exceeded") ||
|
||||
strings.Contains(errStr, "context deadline exceeded")
|
||||
for _, tErr := range timeoutErrorSubstrings {
|
||||
if strings.Contains(errStr, tErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRetryableStatusCode determines if an HTTP status code should trigger a retry
|
||||
@@ -694,7 +719,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(),
|
||||
},
|
||||
})
|
||||
@@ -714,7 +739,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(),
|
||||
},
|
||||
})
|
||||
@@ -770,7 +795,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
|
||||
}
|
||||
@@ -788,7 +813,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
|
||||
}
|
||||
@@ -802,157 +827,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())
|
||||
@@ -961,7 +835,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,
|
||||
@@ -978,7 +852,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(),
|
||||
@@ -996,7 +870,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",
|
||||
},
|
||||
|
||||
+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,
|
||||
|
||||
+5
-9
@@ -16,7 +16,6 @@ type RetryBudget struct {
|
||||
maxTokens int64
|
||||
currentTokens atomic.Int64
|
||||
lastRefill atomic.Int64 // Unix timestamp in nanoseconds
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
logger *libpack_logger.Logger
|
||||
ctx context.Context
|
||||
@@ -81,7 +80,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(),
|
||||
},
|
||||
@@ -150,7 +149,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()
|
||||
@@ -160,7 +159,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,
|
||||
@@ -182,9 +181,6 @@ func (rb *RetryBudget) Reset() {
|
||||
|
||||
// UpdateConfig updates the retry budget configuration
|
||||
func (rb *RetryBudget) UpdateConfig(config RetryBudgetConfig) {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
|
||||
rb.tokensPerSecond = config.TokensPerSecond
|
||||
rb.maxTokens = int64(config.MaxTokens)
|
||||
rb.enabled = config.Enabled
|
||||
@@ -195,7 +191,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,
|
||||
@@ -222,7 +218,7 @@ func InitializeRetryBudgetWithContext(ctx context.Context, config RetryBudgetCon
|
||||
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,
|
||||
},
|
||||
|
||||
+4
-10
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
@@ -10,9 +9,8 @@ import (
|
||||
// RPSTracker tracks requests per second using periodic sampling
|
||||
type RPSTracker struct {
|
||||
lastCount atomic.Int64
|
||||
lastSampleTime atomic.Int64 // Unix nano
|
||||
currentRPS uint64 // stored as uint64, accessed with atomic operations
|
||||
mu sync.RWMutex // for currentRPS updates
|
||||
lastSampleTime atomic.Int64 // Unix nano
|
||||
currentRPS atomic.Uint64 // centirps (RPS * 100)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
@@ -74,9 +72,7 @@ func (r *RPSTracker) sample() {
|
||||
if elapsed > 0 {
|
||||
rps := float64(currentCount) / elapsed
|
||||
// Store RPS as centirps for precision (multiply by 100)
|
||||
r.mu.Lock()
|
||||
atomic.StoreUint64(&r.currentRPS, uint64(rps*100))
|
||||
r.mu.Unlock()
|
||||
r.currentRPS.Store(uint64(rps * 100))
|
||||
}
|
||||
|
||||
// Reset for next sample
|
||||
@@ -86,9 +82,7 @@ func (r *RPSTracker) sample() {
|
||||
|
||||
// GetCurrentRPS returns the current requests per second
|
||||
func (r *RPSTracker) GetCurrentRPS() float64 {
|
||||
r.mu.RLock()
|
||||
centirps := atomic.LoadUint64(&r.currentRPS)
|
||||
r.mu.RUnlock()
|
||||
centirps := r.currentRPS.Load()
|
||||
return float64(centirps) / 100.0
|
||||
}
|
||||
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// patternRegexCache caches the 5 outer regexes per sensitive field name.
|
||||
// Pattern set is bounded by sensitiveFieldPatterns (fixed slice) — not a leak.
|
||||
var patternRegexCache sync.Map // map[string]*patternRegexSet
|
||||
|
||||
type patternRegexSet struct {
|
||||
json *regexp.Regexp
|
||||
xml *regexp.Regexp
|
||||
quoted *regexp.Regexp
|
||||
singleQuote *regexp.Regexp
|
||||
form *regexp.Regexp
|
||||
}
|
||||
|
||||
// Constant inner regexes, pattern-independent — compile once.
|
||||
var (
|
||||
jsonValueRe = regexp.MustCompile(`:\s*"[^"]*"`)
|
||||
xmlValueRe = regexp.MustCompile(`>[^<]*<`)
|
||||
formValueRe = regexp.MustCompile(`=([^&\s"']+)`)
|
||||
)
|
||||
|
||||
func getPatternRegexSet(pattern string) *patternRegexSet {
|
||||
if v, ok := patternRegexCache.Load(pattern); ok {
|
||||
return v.(*patternRegexSet)
|
||||
}
|
||||
quoted := regexp.QuoteMeta(pattern)
|
||||
set := &patternRegexSet{
|
||||
json: regexp.MustCompile(`(?i)"` + quoted + `"\s*:\s*"[^"]*"`),
|
||||
xml: regexp.MustCompile(`(?i)<` + quoted + `>[^<]*</` + quoted + `>`),
|
||||
quoted: regexp.MustCompile(`(?i)` + quoted + `="[^"]*"`),
|
||||
singleQuote: regexp.MustCompile(`(?i)` + quoted + `='[^']*'`),
|
||||
form: regexp.MustCompile(`(?i)` + quoted + `=([^&\s"']+)(?:[&\s]|$)`),
|
||||
}
|
||||
actual, _ := patternRegexCache.LoadOrStore(pattern, set)
|
||||
return actual.(*patternRegexSet)
|
||||
}
|
||||
|
||||
// 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
|
||||
set := getPatternRegexSet(pattern)
|
||||
|
||||
// 1. JSON pattern: "field":"value" → "field":"[REDACTED]"
|
||||
text = set.json.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return jsonValueRe.ReplaceAllString(match, `:"[REDACTED]"`)
|
||||
})
|
||||
|
||||
// 2. XML pattern: <field>value</field> → <field>[REDACTED]</field>
|
||||
xmlMatched := set.xml.MatchString(text)
|
||||
text = set.xml.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return xmlValueRe.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]"
|
||||
text = set.quoted.ReplaceAllString(text, pattern+`="[REDACTED]"`)
|
||||
|
||||
// 4. Single quoted pattern: field='value' → field='[REDACTED]'
|
||||
text = set.singleQuote.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
|
||||
text = set.form.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// Only replace if the value is not already [REDACTED]
|
||||
if strings.Contains(match, "[REDACTED]") {
|
||||
return match
|
||||
}
|
||||
return formValueRe.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,
|
||||
},
|
||||
@@ -275,7 +275,7 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
|
||||
// Debug logging for mutation routing analysis (enabled when LOG_LEVEL=DEBUG)
|
||||
if cfg.LogLevel == "DEBUG" {
|
||||
var m map[string]interface{}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(c.Body(), &m); err == nil {
|
||||
if query, ok := m["query"].(string); ok {
|
||||
debugParseGraphQLQuery(c, query)
|
||||
@@ -293,7 +293,7 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Handle caching
|
||||
wasCached, err := handleCaching(c, parsedResult, extractedUserID)
|
||||
wasCached, err := handleCaching(c, parsedResult, extractedUserID, extractedRoleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -326,10 +326,7 @@ 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) {
|
||||
// Extract user role for cache key (in addition to userID already passed)
|
||||
_, userRole := extractUserInfo(c)
|
||||
|
||||
func handleCaching(c *fiber.Ctx, parsedResult *parseGraphQLQueryResult, userID, userRole string) (bool, error) {
|
||||
// Calculate query hash for cache key - now includes user context for security
|
||||
calculatedQueryHash := libpack_cache.CalculateHash(c, userID, userRole)
|
||||
|
||||
@@ -380,7 +377,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")
|
||||
@@ -393,17 +390,16 @@ func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int,
|
||||
|
||||
// logAndMonitorRequest logs and monitors the request processing.
|
||||
func logAndMonitorRequest(c *fiber.Ctx, userID, opType, opName string, wasCached bool, duration time.Duration, startTime time.Time) {
|
||||
// Low-cardinality labels only: user_id and op_name dropped to prevent Prometheus explosion.
|
||||
labels := map[string]string{
|
||||
"op_type": opType,
|
||||
"op_name": opName,
|
||||
"cached": strconv.FormatBool(wasCached),
|
||||
"user_id": userID,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AddRequestUUID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAddRequestUUID_SetsLocalsAndCallsNext(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use(AddRequestUUID)
|
||||
|
||||
var captured string
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
if v, ok := c.Locals("request_uuid").(string); ok {
|
||||
captured = v
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if captured == "" {
|
||||
t.Fatal("request_uuid not set in Locals")
|
||||
}
|
||||
// UUIDs are 36 chars (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||
if len(captured) != 36 {
|
||||
t.Errorf("unexpected UUID length: %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRequestUUID_UniquePerRequest(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use(AddRequestUUID)
|
||||
|
||||
seen := make([]string, 0, 5)
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
if v, ok := c.Locals("request_uuid").(string); ok {
|
||||
seen = append(seen, v)
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
for i := range 5 {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("request %d: %v", i, err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(seen))
|
||||
for _, id := range seen {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
if len(set) != 5 {
|
||||
t.Errorf("expected 5 unique UUIDs, got %d unique in %v", len(set), seen)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// healthCheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHealthCheck_Returns200WithJSON(t *testing.T) {
|
||||
// Ensure cfg is ready and GraphQL check is disabled via query param
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/health", healthCheck)
|
||||
|
||||
// Pass check_graphql=false to avoid real network call
|
||||
req := httptest.NewRequest("GET", "/health?check_graphql=false&check_redis=false", nil)
|
||||
resp, err := app.Test(req, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := body["status"]; !ok {
|
||||
t.Error("response missing 'status' field")
|
||||
}
|
||||
if _, ok := body["timestamp"]; !ok {
|
||||
t.Error("response missing 'timestamp' field")
|
||||
}
|
||||
if body["status"] != "healthy" {
|
||||
t.Errorf("want status=healthy, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_UnhealthyWhenGraphQLDown(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
// Point to a server that refuses connections
|
||||
cfgMutex.Lock()
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
cfg.Server.HostGraphQL = "http://127.0.0.1:1" // port 1 always refused
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/health", healthCheck)
|
||||
|
||||
req := httptest.NewRequest("GET", "/health?check_redis=false", nil)
|
||||
resp, err := app.Test(req, 15000)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Should return 503 when backend is unreachable
|
||||
if resp.StatusCode != fiber.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["status"] != "unhealthy" {
|
||||
t.Errorf("want unhealthy, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processGraphQLRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProcessGraphQLRequest_ValidBodyProxiesToBackend(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":{"test":"ok"}}`))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
origHostRO := cfg.Server.HostGraphQLReadOnly
|
||||
origCache := cfg.Cache.CacheEnable
|
||||
cfg.Server.HostGraphQL = backend.URL
|
||||
cfg.Server.HostGraphQLReadOnly = backend.URL
|
||||
cfg.Cache.CacheEnable = false
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfg.Server.HostGraphQLReadOnly = origHostRO
|
||||
cfg.Cache.CacheEnable = origCache
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", processGraphQLRequest)
|
||||
|
||||
body := `{"query":"query { __typename }"}`
|
||||
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGraphQLRequest_MalformedBodyStillHandled(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
// Backend that always returns 200 (malformed body is handled by proxy layer)
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"message":"parse error"}]}`))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
origHostRO := cfg.Server.HostGraphQLReadOnly
|
||||
origCache := cfg.Cache.CacheEnable
|
||||
cfg.Server.HostGraphQL = backend.URL
|
||||
cfg.Server.HostGraphQLReadOnly = backend.URL
|
||||
cfg.Cache.CacheEnable = false
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfg.Server.HostGraphQLReadOnly = origHostRO
|
||||
cfg.Cache.CacheEnable = origCache
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", processGraphQLRequest)
|
||||
|
||||
// Not valid JSON — proxy should still forward or return gracefully
|
||||
body := `not-json-at-all`
|
||||
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Should not panic; any 2xx or 5xx is acceptable — just must not crash
|
||||
if resp.StatusCode < 100 || resp.StatusCode > 599 {
|
||||
t.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCaching — wasCached=true path (cache hit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleCaching_CacheHitReturnsStoredResponse(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
// Enable in-memory cache
|
||||
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
||||
Logger: cfg.Logger,
|
||||
TTL: 60,
|
||||
})
|
||||
libpack_cache.CacheClear()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origEnable := cfg.Cache.CacheEnable
|
||||
cfg.Cache.CacheEnable = true
|
||||
cfg.Cache.CacheTTL = 60
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Cache.CacheEnable = origEnable
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":{"users":[]}}`))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
origHostRO := cfg.Server.HostGraphQLReadOnly
|
||||
cfg.Server.HostGraphQL = backend.URL
|
||||
cfg.Server.HostGraphQLReadOnly = backend.URL
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfg.Server.HostGraphQLReadOnly = origHostRO
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", processGraphQLRequest)
|
||||
|
||||
queryBody := `{"query":"query { users { id } }"}`
|
||||
|
||||
// First request — cache miss, hits backend
|
||||
req1 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
resp1, err := app.Test(req1, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
_ = resp1.Body.Close()
|
||||
|
||||
if resp1.StatusCode != 200 {
|
||||
t.Fatalf("first request want 200, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
// Second identical request — should hit cache
|
||||
req2 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := app.Test(req2, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Fatalf("second request want 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
if resp2.Header.Get("X-Cache-Hit") != "true" {
|
||||
t.Error("second request should have X-Cache-Hit: true header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCaching_CacheMissProxiesRequest(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
||||
Logger: cfg.Logger,
|
||||
TTL: 60,
|
||||
})
|
||||
libpack_cache.CacheClear()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origEnable := cfg.Cache.CacheEnable
|
||||
cfg.Cache.CacheEnable = true
|
||||
cfg.Cache.CacheTTL = 60
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Cache.CacheEnable = origEnable
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
backendCalled := 0
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
backendCalled++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = fmt.Fprintf(w, `{"data":{"call":%d}}`, backendCalled)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
origHostRO := cfg.Server.HostGraphQLReadOnly
|
||||
cfg.Server.HostGraphQL = backend.URL
|
||||
cfg.Server.HostGraphQLReadOnly = backend.URL
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfg.Server.HostGraphQLReadOnly = origHostRO
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", processGraphQLRequest)
|
||||
|
||||
// Unique query so no prior cache entry
|
||||
queryBody := `{"query":"query { uniqueMissTest_12345 { id } }"}`
|
||||
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp.Header.Get("X-Cache-Hit") == "true" {
|
||||
t.Error("first request should not be a cache hit")
|
||||
}
|
||||
if backendCalled == 0 {
|
||||
t.Error("backend should have been called on cache miss")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCaching — direct unit test for wasCached=true branch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleCaching_DirectCacheHitBranch(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
||||
Logger: cfg.Logger,
|
||||
TTL: 60,
|
||||
})
|
||||
libpack_cache.CacheClear()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origEnable := cfg.Cache.CacheEnable
|
||||
cfg.Cache.CacheEnable = true
|
||||
cfg.Cache.CacheTTL = 60
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Cache.CacheEnable = origEnable
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
|
||||
var wasCachedResult bool
|
||||
app.Post("/test", func(c *fiber.Ctx) error {
|
||||
parsedResult := &parseGraphQLQueryResult{
|
||||
cacheTime: 60,
|
||||
cacheRequest: true,
|
||||
activeEndpoint: cfg.Server.HostGraphQL,
|
||||
}
|
||||
|
||||
// Pre-populate the cache so lookup hits
|
||||
cacheKey := libpack_cache.CalculateHash(c, "-", "-")
|
||||
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
|
||||
|
||||
var err error
|
||||
wasCachedResult, err = handleCaching(c, parsedResult, "-", "-")
|
||||
return err
|
||||
})
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/test")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query":"query { cachedQuery }"}`))
|
||||
|
||||
ctx := app.AcquireCtx(reqCtx)
|
||||
defer app.ReleaseCtx(ctx)
|
||||
|
||||
parsedResult := &parseGraphQLQueryResult{
|
||||
cacheTime: 60,
|
||||
cacheRequest: true,
|
||||
activeEndpoint: cfg.Server.HostGraphQL,
|
||||
}
|
||||
|
||||
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
||||
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
|
||||
|
||||
wasCached, err := handleCaching(ctx, parsedResult, "-", "-")
|
||||
if err != nil {
|
||||
t.Fatalf("handleCaching returned error: %v", err)
|
||||
}
|
||||
if !wasCached {
|
||||
t.Error("expected wasCached=true when cache hit")
|
||||
}
|
||||
_ = wasCachedResult
|
||||
}
|
||||
|
||||
func TestHandleCaching_NoCacheEnabled_ProxiesDirect(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":{"noCacheTest":true}}`))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origEnable := cfg.Cache.CacheEnable
|
||||
origRedis := cfg.Cache.CacheRedisEnable
|
||||
origHost := cfg.Server.HostGraphQL
|
||||
origHostRO := cfg.Server.HostGraphQLReadOnly
|
||||
cfg.Cache.CacheEnable = false
|
||||
cfg.Cache.CacheRedisEnable = false
|
||||
cfg.Server.HostGraphQL = backend.URL
|
||||
cfg.Server.HostGraphQLReadOnly = backend.URL
|
||||
cfgMutex.Unlock()
|
||||
defer func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Cache.CacheEnable = origEnable
|
||||
cfg.Cache.CacheRedisEnable = origRedis
|
||||
cfg.Server.HostGraphQL = origHost
|
||||
cfg.Server.HostGraphQLReadOnly = origHostRO
|
||||
cfgMutex.Unlock()
|
||||
}()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
|
||||
reqCtx := &fasthttp.RequestCtx{}
|
||||
reqCtx.Request.SetRequestURI("/v1/graphql")
|
||||
reqCtx.Request.Header.SetMethod("POST")
|
||||
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
reqCtx.Request.SetBody([]byte(`{"query":"query { noCacheTest }"}`))
|
||||
|
||||
fCtx := app.AcquireCtx(reqCtx)
|
||||
defer app.ReleaseCtx(fCtx)
|
||||
|
||||
parsedResult := &parseGraphQLQueryResult{
|
||||
cacheRequest: false,
|
||||
cacheTime: 0,
|
||||
activeEndpoint: backend.URL,
|
||||
}
|
||||
|
||||
wasCached, err := handleCaching(fCtx, parsedResult, "-", "-")
|
||||
if err != nil {
|
||||
t.Fatalf("handleCaching error: %v", err)
|
||||
}
|
||||
if wasCached {
|
||||
t.Error("expected wasCached=false when cache disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StartHTTPProxy — starts then shuts down cleanly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStartHTTPProxy_StartsAndShutdown(t *testing.T) {
|
||||
parseConfig()
|
||||
_ = StartMonitoringServer()
|
||||
|
||||
// Grab a free port
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen: %v", err)
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
_ = l.Close()
|
||||
|
||||
cfgMutex.Lock()
|
||||
origPort := cfg.Server.PortGraphQL
|
||||
origTimeout := cfg.Client.ClientTimeout
|
||||
origWS := cfg.WebSocket.Enable
|
||||
origAdmin := cfg.AdminDashboard.Enable
|
||||
cfg.Server.PortGraphQL = port
|
||||
cfg.Client.ClientTimeout = 5
|
||||
cfg.WebSocket.Enable = false
|
||||
cfg.AdminDashboard.Enable = false
|
||||
cfgMutex.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cfgMutex.Lock()
|
||||
cfg.Server.PortGraphQL = origPort
|
||||
cfg.Client.ClientTimeout = origTimeout
|
||||
cfg.WebSocket.Enable = origWS
|
||||
cfg.AdminDashboard.Enable = origAdmin
|
||||
cfgMutex.Unlock()
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- StartHTTPProxy()
|
||||
}()
|
||||
|
||||
// Wait for server to bind
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var conn net.Conn
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatalf("server did not start on port %d within 3s", port)
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
// Send a health check to confirm it's serving
|
||||
httpResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health?check_graphql=false&check_redis=false", port))
|
||||
if err != nil {
|
||||
t.Fatalf("GET /health: %v", err)
|
||||
}
|
||||
_ = httpResp.Body.Close()
|
||||
if httpResp.StatusCode != 200 {
|
||||
t.Errorf("want 200, got %d", httpResp.StatusCode)
|
||||
}
|
||||
}
|
||||
+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(),
|
||||
},
|
||||
|
||||
+6
-5
@@ -18,11 +18,12 @@ type EndpointCBConfig struct {
|
||||
// config is a struct that holds the configuration of the application.
|
||||
// It includes settings for logging, monitoring, client connections, security, and server behavior.
|
||||
type config struct {
|
||||
Logger *libpack_logging.Logger
|
||||
Monitoring *libpack_monitoring.MetricsSetup
|
||||
LogLevel string
|
||||
Api struct{ BannedUsersFile string }
|
||||
Tracing struct {
|
||||
Logger *libpack_logging.Logger
|
||||
Monitoring *libpack_monitoring.MetricsSetup
|
||||
LogLevel string
|
||||
EnableAllocationTracking bool
|
||||
Api struct{ BannedUsersFile string }
|
||||
Tracing struct {
|
||||
Endpoint string
|
||||
Enable bool
|
||||
}
|
||||
|
||||
+16
-6
@@ -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 (
|
||||
@@ -22,6 +25,14 @@ type TracingSetup struct {
|
||||
tracer trace.Tracer
|
||||
}
|
||||
|
||||
// constSpanAttrs holds attributes that are identical for every span created
|
||||
// by this package. Building the slice once at package init avoids two
|
||||
// allocations per StartSpan / StartSpanWithAttributes call.
|
||||
var constSpanAttrs = []attribute.KeyValue{
|
||||
semconv.ServiceName("graphql-monitoring-proxy"),
|
||||
semconv.ServiceVersion("1.0"),
|
||||
}
|
||||
|
||||
type TraceSpanInfo struct {
|
||||
TraceParent string `json:"traceparent"`
|
||||
}
|
||||
@@ -155,12 +166,11 @@ func (ts *TracingSetup) StartSpanWithAttributes(ctx context.Context, name string
|
||||
return trace.SpanFromContext(ctx), ctx
|
||||
}
|
||||
|
||||
// Convert string attributes to KeyValue pairs
|
||||
attributes := make([]attribute.KeyValue, 0, len(attrs)+2)
|
||||
attributes = append(attributes,
|
||||
semconv.ServiceName("graphql-monitoring-proxy"),
|
||||
semconv.ServiceVersion("1.0"),
|
||||
)
|
||||
// Convert string attributes to KeyValue pairs.
|
||||
// Pre-size with constants + per-call attrs, copy constant block in one shot,
|
||||
// then append the dynamic attributes.
|
||||
attributes := make([]attribute.KeyValue, len(constSpanAttrs), len(constSpanAttrs)+len(attrs))
|
||||
copy(attributes, constSpanAttrs)
|
||||
|
||||
for k, v := range attrs {
|
||||
attributes = append(attributes, attribute.String(k, v))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// TestNewTracing_NilContext covers the nil context early-return branch (line 34-36).
|
||||
func TestNewTracing_NilContext_ReturnsError(t *testing.T) {
|
||||
_, err := NewTracing(nil, "localhost:4317") //nolint:staticcheck // SA1012: intentional nil to test the error branch
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "context cannot be nil")
|
||||
}
|
||||
|
||||
// TestNewTracing_InvalidEndpointFormats covers endpoint validation branches.
|
||||
// Note: fmt.Sscanf("%s:%d") treats %s as greedy so any "host:port" string hits
|
||||
// the format error (n!=2). The port-range branch (port>65535) requires n==2
|
||||
// which Sscanf never produces for "host:port" strings — that's a source quirk.
|
||||
func TestNewTracing_InvalidEndpointFormats_ReturnsError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
}{
|
||||
{name: "no port separator", endpoint: "localhost"},
|
||||
{name: "port over max", endpoint: "localhost:999999"},
|
||||
{name: "plain hostname only", endpoint: "myhost"},
|
||||
{name: "just a number", endpoint: "12345"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewTracing(context.Background(), tt.endpoint)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid endpoint format")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShutdown_WithRealProvider covers the non-nil tracerProvider shutdown path (line 133).
|
||||
func TestShutdown_WithRealProvider_NoError(t *testing.T) {
|
||||
// Use in-memory exporter so no network needed.
|
||||
exporter := tracetest.NewInMemoryExporter()
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSyncer(exporter),
|
||||
)
|
||||
ts := &TracingSetup{
|
||||
tracerProvider: tp,
|
||||
tracer: tp.Tracer("shutdown-test"),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := ts.Shutdown(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestStartSpan_WithRealTracer covers StartSpan with a real (noop) tracer — the non-nil path.
|
||||
func TestStartSpan_WithRealTracer_ReturnsSpan(t *testing.T) {
|
||||
tp := noop.NewTracerProvider()
|
||||
ts := &TracingSetup{
|
||||
tracer: tp.Tracer("start-span-test"),
|
||||
}
|
||||
ctx := context.Background()
|
||||
span, newCtx := ts.StartSpan(ctx, "my-operation")
|
||||
assert.NotNil(t, span)
|
||||
assert.NotNil(t, newCtx)
|
||||
span.End()
|
||||
}
|
||||
|
||||
// TestStartSpanWithAttributes_WithRealTracer covers the non-nil tracer path with attrs.
|
||||
func TestStartSpanWithAttributes_WithRealTracer_RecordsSpan(t *testing.T) {
|
||||
exporter := tracetest.NewInMemoryExporter()
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSyncer(exporter),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
)
|
||||
ts := &TracingSetup{
|
||||
tracerProvider: tp,
|
||||
tracer: tp.Tracer("attr-test"),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
attrs := map[string]string{
|
||||
"user.id": "u-42",
|
||||
"operation": "query",
|
||||
}
|
||||
span, newCtx := ts.StartSpanWithAttributes(ctx, "graphql-query", attrs)
|
||||
require.NotNil(t, span)
|
||||
require.NotNil(t, newCtx)
|
||||
span.End()
|
||||
|
||||
spans := exporter.GetSpans()
|
||||
require.Len(t, spans, 1)
|
||||
assert.Equal(t, "graphql-query", spans[0].Name)
|
||||
}
|
||||
|
||||
// TestExtractSpanContext_ValidTraceparent covers the valid span context branch (line 115-116).
|
||||
// ExtractSpanContext uses otel.GetTextMapPropagator(); we must register the W3C
|
||||
// TraceContext propagator before calling it (NewTracing normally does this).
|
||||
func TestExtractSpanContext_ValidTraceparent_ReturnsValid(t *testing.T) {
|
||||
otel.SetTextMapPropagator(propagation.TraceContext{})
|
||||
|
||||
tp := noop.NewTracerProvider()
|
||||
ts := &TracingSetup{
|
||||
tracer: tp.Tracer("extract-test"),
|
||||
}
|
||||
spanInfo := &TraceSpanInfo{
|
||||
TraceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
}
|
||||
spanCtx, err := ts.ExtractSpanContext(spanInfo)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, spanCtx.IsValid())
|
||||
}
|
||||
+23
-23
@@ -67,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,
|
||||
@@ -132,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(),
|
||||
},
|
||||
@@ -150,13 +150,13 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to read first message from client",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
clientConn.Close()
|
||||
_ = clientConn.Close() // Best-effort cleanup
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,16 +170,16 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to connect to backend WebSocket",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
clientConn.Close()
|
||||
_ = clientConn.Close() // Best-effort cleanup
|
||||
return
|
||||
}
|
||||
defer backendConn.Close()
|
||||
defer func() { _ = backendConn.Close() }() // Best-effort cleanup
|
||||
|
||||
// Forward the first message (connection_init) to backend
|
||||
if err := backendConn.WriteMessage(messageType, message); err != nil {
|
||||
@@ -187,7 +187,7 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to forward connection_init to backend",
|
||||
Pairs: map[string]interface{}{
|
||||
Pairs: map[string]any{
|
||||
"connection_id": connectionID,
|
||||
"error": err.Error(),
|
||||
},
|
||||
@@ -199,7 +199,7 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
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") != "",
|
||||
@@ -231,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(),
|
||||
@@ -258,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,
|
||||
},
|
||||
})
|
||||
@@ -268,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(),
|
||||
},
|
||||
@@ -286,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(),
|
||||
},
|
||||
@@ -298,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),
|
||||
@@ -322,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,
|
||||
},
|
||||
})
|
||||
@@ -332,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(),
|
||||
},
|
||||
@@ -350,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(),
|
||||
},
|
||||
@@ -362,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),
|
||||
@@ -383,7 +383,7 @@ func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeader
|
||||
}
|
||||
|
||||
// Try to parse as JSON to extract headers from payload
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
// Not JSON or parse error, return original headers
|
||||
return enrichedHeaders
|
||||
@@ -397,13 +397,13 @@ func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeader
|
||||
}
|
||||
|
||||
// Extract payload
|
||||
payload, ok := msg["payload"].(map[string]interface{})
|
||||
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]interface{}); ok {
|
||||
if payloadHeaders, ok := payload["headers"].(map[string]any); ok {
|
||||
for key, value := range payloadHeaders {
|
||||
if strValue, ok := value.(string); ok {
|
||||
enrichedHeaders.Set(key, strValue)
|
||||
@@ -462,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