Skip to content

Commit a869ca7

Browse files
koki-developclaude
andcommitted
feat: add Prometheus /metrics endpoint for concurrency and queue gauges
Expose sandbox_concurrency_active, sandbox_queue_length, sandbox_concurrency_max, and sandbox_queue_max as Prometheus text exposition format gauges for autoscaling custom metrics. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0816c5a commit a869ca7

5 files changed

Lines changed: 112 additions & 12 deletions

File tree

cmd/serve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ func runServe(_ *cobra.Command, _ []string) error {
9595

9696
h := &handler.Handler{Runner: sandbox.NewRunner(cfg), MaxFiles: flagMaxFiles, MaxFileSize: flagMaxFileSize}
9797

98+
metrics := &intmw.ConcurrencyMetrics{}
99+
98100
e := echo.New()
99101
e.HTTPErrorHandler = handler.NewHTTPErrorHandler()
100102
e.Use(middleware.Recover())
@@ -103,10 +105,12 @@ func runServe(_ *cobra.Command, _ []string) error {
103105
e.GET("/healthz", func(c *echo.Context) error {
104106
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
105107
})
108+
e.GET("/metrics", intmw.MetricsHandler(metrics, flagMaxConcurrency, flagMaxQueueSize))
106109
e.POST("/v1/run", h.RunHandler, intmw.ConcurrencyLimiter(intmw.ConcurrencyConfig{
107110
MaxConcurrency: flagMaxConcurrency,
108111
MaxQueueSize: flagMaxQueueSize,
109112
QueueTimeout: time.Duration(flagQueueTimeout) * time.Second,
113+
Metrics: metrics,
110114
}))
111115

112116
sc := echo.StartConfig{

internal/middleware/concurrency.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ import (
99
"github.com/labstack/echo/v5"
1010
)
1111

12+
// ConcurrencyMetrics exposes live concurrency and queue counters for external
13+
// consumption (e.g. Prometheus /metrics endpoint).
14+
type ConcurrencyMetrics struct {
15+
Active atomic.Int64
16+
Queued atomic.Int64
17+
}
18+
1219
// ConcurrencyConfig holds parameters for the concurrency limiter middleware.
1320
type ConcurrencyConfig struct {
1421
MaxConcurrency int
1522
MaxQueueSize int
1623
QueueTimeout time.Duration
24+
Metrics *ConcurrencyMetrics
1725
}
1826

1927
// ConcurrencyLimiter returns an Echo middleware that limits concurrent handler
@@ -22,21 +30,24 @@ type ConcurrencyConfig struct {
2230
// than QueueTimeout receive 503 (SERVER_BUSY).
2331
func ConcurrencyLimiter(cfg ConcurrencyConfig) echo.MiddlewareFunc {
2432
sem := make(chan struct{}, cfg.MaxConcurrency)
25-
var queued atomic.Int64
2633

2734
return func(next echo.HandlerFunc) echo.HandlerFunc {
2835
return func(c *echo.Context) error {
2936
// Fast path: try to acquire a semaphore slot without blocking.
3037
select {
3138
case sem <- struct{}{}:
32-
defer func() { <-sem }()
39+
cfg.Metrics.Active.Add(1)
40+
defer func() {
41+
<-sem
42+
cfg.Metrics.Active.Add(-1)
43+
}()
3344
return next(c)
3445
default:
3546
}
3647

3748
// Slow path: semaphore is full — enter the queue.
38-
q := queued.Add(1)
39-
defer queued.Add(-1)
49+
q := cfg.Metrics.Queued.Add(1)
50+
defer cfg.Metrics.Queued.Add(-1)
4051

4152
if q > int64(cfg.MaxQueueSize) {
4253
return c.JSON(http.StatusServiceUnavailable, handler.ErrorResponse{
@@ -50,7 +61,11 @@ func ConcurrencyLimiter(cfg ConcurrencyConfig) echo.MiddlewareFunc {
5061

5162
select {
5263
case sem <- struct{}{}:
53-
defer func() { <-sem }()
64+
cfg.Metrics.Active.Add(1)
65+
defer func() {
66+
<-sem
67+
cfg.Metrics.Active.Add(-1)
68+
}()
5469
return next(c)
5570
case <-timer.C:
5671
return c.JSON(http.StatusServiceUnavailable, handler.ErrorResponse{

internal/middleware/concurrency_test.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313
"github.com/labstack/echo/v5"
1414
)
1515

16-
func setupEcho(cfg ConcurrencyConfig, gate chan struct{}) (*echo.Echo, echo.MiddlewareFunc) {
16+
func setupEcho(cfg ConcurrencyConfig, gate chan struct{}) (*echo.Echo, *ConcurrencyMetrics) {
17+
if cfg.Metrics == nil {
18+
cfg.Metrics = &ConcurrencyMetrics{}
19+
}
1720
e := echo.New()
1821
mw := ConcurrencyLimiter(cfg)
1922

@@ -25,7 +28,7 @@ func setupEcho(cfg ConcurrencyConfig, gate chan struct{}) (*echo.Echo, echo.Midd
2528
}
2629

2730
e.POST("/v1/run", h, mw)
28-
return e, mw
31+
return e, cfg.Metrics
2932
}
3033

3134
func doRequest(e *echo.Echo) *httptest.ResponseRecorder {
@@ -42,8 +45,18 @@ func doRequestWithContext(e *echo.Echo, ctx context.Context) *httptest.ResponseR
4245
return rec
4346
}
4447

48+
func assertMetricsZero(t *testing.T, metrics *ConcurrencyMetrics) {
49+
t.Helper()
50+
if v := metrics.Active.Load(); v != 0 {
51+
t.Errorf("expected Active=0 after completion, got %d", v)
52+
}
53+
if v := metrics.Queued.Load(); v != 0 {
54+
t.Errorf("expected Queued=0 after completion, got %d", v)
55+
}
56+
}
57+
4558
func TestConcurrencyLimiter_UnderLimit(t *testing.T) {
46-
e, _ := setupEcho(ConcurrencyConfig{
59+
e, metrics := setupEcho(ConcurrencyConfig{
4760
MaxConcurrency: 2,
4861
MaxQueueSize: 5,
4962
QueueTimeout: 5 * time.Second,
@@ -53,11 +66,12 @@ func TestConcurrencyLimiter_UnderLimit(t *testing.T) {
5366
if rec.Code != http.StatusOK {
5467
t.Fatalf("expected 200, got %d", rec.Code)
5568
}
69+
assertMetricsZero(t, metrics)
5670
}
5771

5872
func TestConcurrencyLimiter_QueueAndSucceed(t *testing.T) {
5973
gate := make(chan struct{})
60-
e, _ := setupEcho(ConcurrencyConfig{
74+
e, metrics := setupEcho(ConcurrencyConfig{
6175
MaxConcurrency: 1,
6276
MaxQueueSize: 5,
6377
QueueTimeout: 5 * time.Second,
@@ -98,11 +112,12 @@ func TestConcurrencyLimiter_QueueAndSucceed(t *testing.T) {
98112
if queuedRec.Code != http.StatusOK {
99113
t.Fatalf("queued request: expected 200, got %d", queuedRec.Code)
100114
}
115+
assertMetricsZero(t, metrics)
101116
}
102117

103118
func TestConcurrencyLimiter_QueueFull(t *testing.T) {
104119
gate := make(chan struct{})
105-
e, _ := setupEcho(ConcurrencyConfig{
120+
e, metrics := setupEcho(ConcurrencyConfig{
106121
MaxConcurrency: 1,
107122
MaxQueueSize: 1,
108123
QueueTimeout: 5 * time.Second,
@@ -142,11 +157,12 @@ func TestConcurrencyLimiter_QueueFull(t *testing.T) {
142157
// Cleanup: release gate and wait.
143158
close(gate)
144159
wg.Wait()
160+
assertMetricsZero(t, metrics)
145161
}
146162

147163
func TestConcurrencyLimiter_QueueTimeout(t *testing.T) {
148164
gate := make(chan struct{})
149-
e, _ := setupEcho(ConcurrencyConfig{
165+
e, metrics := setupEcho(ConcurrencyConfig{
150166
MaxConcurrency: 1,
151167
MaxQueueSize: 5,
152168
QueueTimeout: 100 * time.Millisecond,
@@ -178,11 +194,12 @@ func TestConcurrencyLimiter_QueueTimeout(t *testing.T) {
178194
// Cleanup.
179195
close(gate)
180196
wg.Wait()
197+
assertMetricsZero(t, metrics)
181198
}
182199

183200
func TestConcurrencyLimiter_ClientCancel(t *testing.T) {
184201
gate := make(chan struct{})
185-
e, _ := setupEcho(ConcurrencyConfig{
202+
e, metrics := setupEcho(ConcurrencyConfig{
186203
MaxConcurrency: 1,
187204
MaxQueueSize: 5,
188205
QueueTimeout: 10 * time.Second,
@@ -227,4 +244,5 @@ func TestConcurrencyLimiter_ClientCancel(t *testing.T) {
227244
// Cleanup.
228245
close(gate)
229246
wg.Wait()
247+
assertMetricsZero(t, metrics)
230248
}

internal/middleware/metrics.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package middleware
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/labstack/echo/v5"
8+
)
9+
10+
// MetricsHandler returns an Echo handler that renders Prometheus text exposition
11+
// format for the sandbox concurrency and queue gauges.
12+
func MetricsHandler(metrics *ConcurrencyMetrics, maxConcurrency, maxQueueSize int) echo.HandlerFunc {
13+
return func(c *echo.Context) error {
14+
body := fmt.Sprintf("# HELP sandbox_concurrency_active Number of requests currently executing.\n"+
15+
"# TYPE sandbox_concurrency_active gauge\n"+
16+
"sandbox_concurrency_active %d\n"+
17+
"# HELP sandbox_queue_length Number of requests waiting in queue.\n"+
18+
"# TYPE sandbox_queue_length gauge\n"+
19+
"sandbox_queue_length %d\n"+
20+
"# HELP sandbox_concurrency_max Configured maximum concurrent executions.\n"+
21+
"# TYPE sandbox_concurrency_max gauge\n"+
22+
"sandbox_concurrency_max %d\n"+
23+
"# HELP sandbox_queue_max Configured maximum queue size.\n"+
24+
"# TYPE sandbox_queue_max gauge\n"+
25+
"sandbox_queue_max %d\n",
26+
metrics.Active.Load(), metrics.Queued.Load(), maxConcurrency, maxQueueSize)
27+
c.Response().Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
28+
return c.String(http.StatusOK, body)
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/labstack/echo/v5"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestMetricsHandler(t *testing.T) {
14+
metrics := &ConcurrencyMetrics{}
15+
metrics.Active.Store(3)
16+
metrics.Queued.Store(7)
17+
18+
e := echo.New()
19+
e.GET("/metrics", MetricsHandler(metrics, 10, 50))
20+
21+
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
22+
rec := httptest.NewRecorder()
23+
e.ServeHTTP(rec, req)
24+
25+
require.Equal(t, http.StatusOK, rec.Code)
26+
assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
27+
28+
body := rec.Body.String()
29+
assert.Contains(t, body, "sandbox_concurrency_active 3")
30+
assert.Contains(t, body, "sandbox_queue_length 7")
31+
assert.Contains(t, body, "sandbox_concurrency_max 10")
32+
assert.Contains(t, body, "sandbox_queue_max 50")
33+
}

0 commit comments

Comments
 (0)