Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions cmd/asb-api/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,22 @@ func newObservedHandler(logger *slog.Logger, metrics *observability.Metrics, nex
}

func registerRuntimeMetrics(runtime *bootstrap.ServiceRuntime, registerer prometheus.Registerer) error {
if runtime == nil || runtime.DBStats == nil {
if runtime == nil {
return nil
}
return observability.RegisterDBStats("asb", runtime.DBStats, observability.DBStatsOptions{
Registerer: registerer,
})
if runtime.DBStats != nil {
if err := observability.RegisterDBStats("asb", runtime.DBStats, observability.DBStatsOptions{
Registerer: registerer,
}); err != nil {
return err
}
}
if runtime.RedisStats != nil {
if err := observability.RegisterRedisPoolStats("asb", runtime.RedisStats, observability.RedisStatsOptions{
Registerer: registerer,
}); err != nil {
return err
}
}
return nil
}
44 changes: 44 additions & 0 deletions cmd/asb-api/observability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/evalops/asb/internal/bootstrap"
"github.com/evalops/service-runtime/observability"
"github.com/prometheus/client_golang/prometheus"
goredis "github.com/redis/go-redis/v9"
)

func TestNewObservedHandlerServesMetrics(t *testing.T) {
Expand Down Expand Up @@ -133,6 +134,49 @@ func TestRegisterRuntimeMetricsRegistersDBStats(t *testing.T) {
}
}

func TestRegisterRuntimeMetricsRegistersRedisStats(t *testing.T) {
t.Parallel()

registry := prometheus.NewRegistry()
runtime := &bootstrap.ServiceRuntime{
RedisStats: func() *goredis.PoolStats {
return &goredis.PoolStats{
Hits: 11,
TotalConns: 5,
IdleConns: 2,
PendingRequests: 1,
}
},
}

if err := registerRuntimeMetrics(runtime, registry); err != nil {
t.Fatalf("registerRuntimeMetrics() error = %v", err)
}

metrics, err := observability.NewMetrics("asb", observability.MetricsOptions{
Registerer: registry,
Gatherer: registry,
})
if err != nil {
t.Fatalf("NewMetrics() error = %v", err)
}

handler := newObservedHandler(discardLogger(), metrics, http.NewServeMux())
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))

body := recorder.Body.String()
if !strings.Contains(body, "asb_redis_pool_total_connections 5") {
t.Fatalf("metrics body = %q, want Redis total connection gauge", body)
}
if !strings.Contains(body, "asb_redis_pool_idle_connections 2") {
t.Fatalf("metrics body = %q, want Redis idle connection gauge", body)
}
if !strings.Contains(body, "asb_redis_pool_hits_total 11") {
t.Fatalf("metrics body = %q, want Redis hits counter", body)
}
}

func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
connectrpc.com/connect v1.19.1
github.com/alicebob/miniredis/v2 v2.37.0
github.com/evalops/proto v0.0.0-20260414000509-37b2bf5d6244
github.com/evalops/service-runtime v0.1.16
github.com/evalops/service-runtime v0.1.17
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx/v5 v5.9.1
github.com/pashagolub/pgxmock/v4 v4.9.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/evalops/proto v0.0.0-20260414000509-37b2bf5d6244 h1:MjoKPIxG/OisQLDqLvbvpleCAkPgr2InTrK4Q0D9F1o=
github.com/evalops/proto v0.0.0-20260414000509-37b2bf5d6244/go.mod h1:EXB8IcqMaV58Tt0w2GaQia3YOwzrEvnIWFZetHX+IxE=
github.com/evalops/service-runtime v0.1.16 h1:pgxFcQep8t7p6sanvTWvu9nxy0lKeaeOZlDqHvIAlQY=
github.com/evalops/service-runtime v0.1.16/go.mod h1:ZAB9GcnbFaFUq+X5TsoMUUth6KnAVR6uVHMKanXtQTY=
github.com/evalops/service-runtime v0.1.17 h1:Elnn2Z+qLoxweauNikJQnQKt/0smEEdzOc0qQCqxo5g=
github.com/evalops/service-runtime v0.1.17/go.mod h1:ZAB9GcnbFaFUq+X5TsoMUUth6KnAVR6uVHMKanXtQTY=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
Expand Down
29 changes: 19 additions & 10 deletions internal/bootstrap/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ type serviceOptions struct {
type readinessProbe func(context.Context) error

type ServiceRuntime struct {
Service *app.Service
Cleanup func()
Health *HealthChecker
DBStats func() sql.DBStats
Service *app.Service
Cleanup func()
Health *HealthChecker
DBStats func() sql.DBStats
RedisStats func() *goredis.PoolStats
}

type HealthChecker struct {
Expand Down Expand Up @@ -102,7 +103,7 @@ func NewServiceRuntime(ctx context.Context, logger *slog.Logger, options ...Serv
if err != nil {
return nil, err
}
runtimeStore, cleanupRuntime, redisProbe, err := newRuntimeStore(ctx)
runtimeStore, cleanupRuntime, redisProbe, redisStats, err := newRuntimeStore(ctx)
if err != nil {
cleanupRepository()
return nil, err
Expand Down Expand Up @@ -268,7 +269,8 @@ func NewServiceRuntime(ctx context.Context, logger *slog.Logger, options ...Serv
redisProbe: redisProbe,
sessionTokensReady: sessionTokens != nil,
},
DBStats: dbStats,
DBStats: dbStats,
RedisStats: redisStats,
}, nil
}

Expand Down Expand Up @@ -460,21 +462,21 @@ func newRepository(ctx context.Context) (core.Repository, func(), readinessProbe
return memstore.NewRepository(), func() {}, nil, nil, nil
}

func newRuntimeStore(ctx context.Context) (core.RuntimeStore, func(), readinessProbe, error) {
func newRuntimeStore(ctx context.Context) (core.RuntimeStore, func(), readinessProbe, func() *goredis.PoolStats, error) {
if addr := os.Getenv("ASB_REDIS_ADDR"); addr != "" {
client := goredis.NewClient(&goredis.Options{
Addr: addr,
Password: os.Getenv("ASB_REDIS_PASSWORD"),
DB: 0,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
return redisstore.NewRuntimeStore(client), func() { _ = client.Close() }, func(ctx context.Context) error {
return client.Ping(ctx).Err()
}, nil
}, redisPoolStats(client), nil
}
return memstore.NewRuntimeStore(), func() {}, nil, nil
return memstore.NewRuntimeStore(), func() {}, nil, nil, nil
}

func pgxPoolDBStats(pool *pgxpool.Pool) func() sql.DBStats {
Expand All @@ -492,6 +494,13 @@ func pgxPoolDBStats(pool *pgxpool.Pool) func() sql.DBStats {
}
}

func redisPoolStats(client goredis.UniversalClient) func() *goredis.PoolStats {
if client == nil {
return nil
}
return client.PoolStats
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interface nil check won't catch typed nil values

Low Severity

The redisPoolStats function accepts goredis.UniversalClient (an interface) and uses client == nil as a guard, but this check doesn't catch typed nil values — a well-known Go interface gotcha. If a caller passes a nil *goredis.Client, the interface wrapper is non-nil, so the guard is skipped and client.PoolStats would panic. The companion function pgxPoolDBStats avoids this by accepting the concrete pointer type *pgxpool.Pool, where the nil check works correctly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8fbb93d. Configure here.


func loadPublicKey(path string) (any, error) {
contents, err := os.ReadFile(path)
if err != nil {
Expand Down
Loading