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
30 changes: 30 additions & 0 deletions config/metrics.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package config

import (
"path"
"strings"
)

// MetricsConfig holds observability configuration for Prometheus metrics
type MetricsConfig struct {
// Enabled controls whether Prometheus metrics are collected and exposed
Expand All @@ -10,3 +15,28 @@ type MetricsConfig struct {
// Default: "/metrics"
Endpoint string `yaml:"endpoint" env:"METRICS_ENDPOINT"`
}

// ResolveMetricsEndpoint returns the normalized, safe endpoint used by the
// HTTP server. Extensions should use the same value when excluding Prometheus
// scrapes from request instrumentation.
func ResolveMetricsEndpoint(endpoint string) string {
metricsPath := "/metrics"
if endpoint != "" {
metricsPath = path.Clean("/" + endpoint)
}
if metricsPath == "/v1" || strings.HasPrefix(metricsPath, "/v1/") ||
metricsPath == "/p" || strings.HasPrefix(metricsPath, "/p/") {
return "/metrics"
}
return metricsPath
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ResolveMetricsEndpointWithPprof also prevents the metrics route from
// shadowing an enabled pprof route.
func ResolveMetricsEndpointWithPprof(endpoint string, pprofEnabled bool) string {
metricsPath := ResolveMetricsEndpoint(endpoint)
if pprofEnabled && (metricsPath == "/debug/pprof" || strings.HasPrefix(metricsPath, "/debug/pprof/")) {
return "/metrics"
}
return metricsPath
}
43 changes: 43 additions & 0 deletions config/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package config

import "testing"

func TestResolveMetricsEndpoint(t *testing.T) {
tests := map[string]string{
"": "/metrics",
"metrics": "/metrics",
"/monitoring/metrics/": "/monitoring/metrics",
"/foo/../metrics-custom": "/metrics-custom",
"v1/models": "/metrics",
"../v1/models": "/metrics",
"/v1/models": "/metrics",
"/p/internal": "/metrics",
}
for input, want := range tests {
if got := ResolveMetricsEndpoint(input); got != want {
t.Errorf("ResolveMetricsEndpoint(%q) = %q, want %q", input, got, want)
}
}
}

func TestResolveMetricsEndpointWithPprof(t *testing.T) {
tests := map[string]struct {
endpoint string
pprofEnabled bool
want string
}{
"pprof disabled": {endpoint: "/debug/pprof", want: "/debug/pprof"},
"pprof root conflict": {endpoint: "/debug/pprof", pprofEnabled: true, want: "/metrics"},
"pprof child conflict": {
endpoint: "/debug/pprof/goroutine", pprofEnabled: true, want: "/metrics",
},
"custom endpoint": {endpoint: "monitoring/metrics", pprofEnabled: true, want: "/monitoring/metrics"},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if got := ResolveMetricsEndpointWithPprof(test.endpoint, test.pprofEnabled); got != test.want {
t.Errorf("ResolveMetricsEndpointWithPprof(%q, %v) = %q, want %q", test.endpoint, test.pprofEnabled, got, test.want)
}
})
}
}
8 changes: 4 additions & 4 deletions ext/ext.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Package ext is the public extension API for building custom gateway
// binaries on top of GoModel. External modules register request rewriters,
// HTTP middleware, extra routes, runtime settings, and a route selector on a
// Registry (usually ext.Default) before starting the gateway; core consumes
// an immutable snapshot of the registry at server construction. An empty
// registry adds zero request overhead.
// HTTP middleware, extra routes, runtime settings, upstream observers, and a
// route selector on a Registry (usually ext.Default) before startup. Core
// consumes an immutable snapshot at server construction; an empty registry
// adds zero request overhead.
package ext

import (
Expand Down
108 changes: 100 additions & 8 deletions ext/registry.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,66 @@
package ext

import (
"fmt"
"slices"
"sync"

"github.com/labstack/echo/v5"
)

// HTTPServerConfig exposes generation-specific HTTP settings needed when an
// extension constructs outer middleware. A new value is supplied on reload.
type HTTPServerConfig struct {
MetricsEndpoint string
}

// OuterMiddlewareFactory constructs middleware for one server generation.
// It is intended for middleware whose configuration can change on reload.
type OuterMiddlewareFactory func(HTTPServerConfig) (echo.MiddlewareFunc, error)

// Registry collects extensions to be consumed by the gateway at startup.
// Register everything before the server is constructed (before run.Run or
// app.New); core snapshots each registration list during initialization.
type Registry struct {
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
settings []RuntimeSetting
authenticators []RequestAuthenticator
mu sync.Mutex
rewriters []RequestRewriter
outerMiddleware []echo.MiddlewareFunc
outerFactories []OuterMiddlewareFactory
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
settings []RuntimeSetting
authenticators []RequestAuthenticator
observers []UpstreamObserver
}

// UseOuterMiddleware adds middleware at the outer HTTP boundary, after
// credential-like request URI values are redacted and before request logging,
// recovery, limits, audit capture, and authentication. It is intended for
// observability and correlation middleware that must cover the whole request.
// It must not depend on an authenticated identity.
func (r *Registry) UseOuterMiddleware(m echo.MiddlewareFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.outerMiddleware = append(r.outerMiddleware, m)
}

// UseOuterMiddlewareFactory registers generation-specific outer middleware.
// Core invokes the factory whenever it constructs or reloads the HTTP server.
func (r *Registry) UseOuterMiddlewareFactory(factory OuterMiddlewareFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.outerFactories = append(r.outerFactories, factory)
}

// RegisterUpstreamObserver adds an observer for logical provider calls.
// Observers run in registration order and may derive the context passed to
// later observers and to the provider request.
func (r *Registry) RegisterUpstreamObserver(observer UpstreamObserver) {
r.mu.Lock()
defer r.mu.Unlock()
r.observers = append(r.observers, observer)
}

// RegisterAuthenticator adds a request authentication mechanism. Core bearer
Expand Down Expand Up @@ -93,6 +135,36 @@ func (r *Registry) Middleware() []echo.MiddlewareFunc {
return slices.Clone(r.middleware)
}

// OuterMiddleware returns a defensive copy of registered outer middleware.
func (r *Registry) OuterMiddleware() []echo.MiddlewareFunc {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.outerMiddleware)
}

// OuterMiddlewareFor returns static outer middleware followed by middleware
// constructed for the supplied server generation.
func (r *Registry) OuterMiddlewareFor(cfg HTTPServerConfig) ([]echo.MiddlewareFunc, error) {
r.mu.Lock()
middleware := slices.Clone(r.outerMiddleware)
factories := slices.Clone(r.outerFactories)
r.mu.Unlock()

for i, factory := range factories {
if factory == nil {
continue
}
m, err := factory(cfg)
if err != nil {
return nil, fmt.Errorf("construct outer middleware %d: %w", i, err)
}
if m != nil {
middleware = append(middleware, m)
}
}
return middleware, nil
}

// Routes returns a defensive copy of the registered route callbacks.
func (r *Registry) Routes() []func(*echo.Echo) {
r.mu.Lock()
Expand Down Expand Up @@ -128,6 +200,13 @@ func (r *Registry) Authenticators() []RequestAuthenticator {
return slices.Clone(r.authenticators)
}

// UpstreamObservers returns a defensive copy of registered observers.
func (r *Registry) UpstreamObservers() []UpstreamObserver {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.observers)
}

// Default is the process-wide registry used by package-level helpers and, by
// default, by run.Run.
var Default = &Registry{}
Expand All @@ -138,6 +217,14 @@ func RegisterRewriter(rw RequestRewriter) { Default.RegisterRewriter(rw) }
// UseMiddleware registers middleware on the Default registry.
func UseMiddleware(m echo.MiddlewareFunc) { Default.UseMiddleware(m) }

// UseOuterMiddleware registers outer HTTP middleware on the Default registry.
func UseOuterMiddleware(m echo.MiddlewareFunc) { Default.UseOuterMiddleware(m) }

// UseOuterMiddlewareFactory registers generation-specific outer HTTP middleware.
func UseOuterMiddlewareFactory(factory OuterMiddlewareFactory) {
Default.UseOuterMiddlewareFactory(factory)
}

// RegisterRoutes registers a route callback on the Default registry.
func RegisterRoutes(fn func(e *echo.Echo)) { Default.RegisterRoutes(fn) }

Expand All @@ -154,3 +241,8 @@ func RegisterSetting(setting RuntimeSetting) { Default.RegisterSetting(setting)
func RegisterAuthenticator(authenticator RequestAuthenticator) {
Default.RegisterAuthenticator(authenticator)
}

// RegisterUpstreamObserver registers an observer on the Default registry.
func RegisterUpstreamObserver(observer UpstreamObserver) {
Default.RegisterUpstreamObserver(observer)
}
25 changes: 25 additions & 0 deletions ext/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import (

type namedRewriter struct{ name string }

type namedObserver struct{ name string }

func (o *namedObserver) Name() string { return o.name }
func (o *namedObserver) Start(ctx context.Context, _ UpstreamCall) context.Context {
return ctx
}
func (o *namedObserver) End(context.Context, UpstreamResult) {}

type namedAuthenticator struct{ name string }

func (a *namedAuthenticator) Name() string { return a.name }
Expand Down Expand Up @@ -71,9 +79,11 @@ func TestRegistrySnapshotsAreIsolated(t *testing.T) {

func TestRegistryCollectsMiddlewareAndRoutes(t *testing.T) {
reg := &Registry{}
reg.UseOuterMiddleware(func(next echo.HandlerFunc) echo.HandlerFunc { return next })
reg.UseMiddleware(func(next echo.HandlerFunc) echo.HandlerFunc { return next })
reg.RegisterRoutes(func(_ *echo.Echo) {})

assert.Len(t, reg.OuterMiddleware(), 1)
assert.Len(t, reg.Middleware(), 1)
assert.Len(t, reg.Routes(), 1)
}
Expand All @@ -89,6 +99,17 @@ func TestRegistryCollectsRequestAuthenticators(t *testing.T) {
assert.Len(t, snapshot, 1, "earlier snapshot must not grow")
}

func TestRegistryCollectsUpstreamObservers(t *testing.T) {
reg := &Registry{}
reg.RegisterUpstreamObserver(&namedObserver{name: "otel"})

snapshot := reg.UpstreamObservers()
require.Len(t, snapshot, 1)
assert.Equal(t, "otel", snapshot[0].Name())
reg.RegisterUpstreamObserver(&namedObserver{name: "other"})
assert.Len(t, snapshot, 1, "earlier snapshot must not grow")
}

func TestRegistryCollectsRuntimeSettings(t *testing.T) {
reg := &Registry{}
reg.RegisterSetting(&testRuntimeSetting{value: "high"})
Expand All @@ -108,16 +129,20 @@ func TestRegistryConcurrentRegistration(t *testing.T) {
for range workers {
wg.Go(func() {
reg.RegisterRewriter(&namedRewriter{name: "w"})
reg.UseOuterMiddleware(func(next echo.HandlerFunc) echo.HandlerFunc { return next })
reg.UseMiddleware(func(next echo.HandlerFunc) echo.HandlerFunc { return next })
reg.AddPublicPaths("/p")
reg.RegisterUpstreamObserver(&namedObserver{name: "w"})
_ = reg.Rewriters()
})
}
wg.Wait()

assert.Len(t, reg.Rewriters(), workers)
assert.Len(t, reg.OuterMiddleware(), workers)
assert.Len(t, reg.Middleware(), workers)
assert.Len(t, reg.PublicPaths(), workers)
assert.Len(t, reg.UpstreamObservers(), workers)
}

type namedSelector struct{ name string }
Expand Down
58 changes: 58 additions & 0 deletions ext/upstream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package ext

import (
"context"
"time"
)

// UpstreamCall describes one logical call from GoModel to a configured model
// provider. Transport retries are folded into the same call.
type UpstreamCall struct {
// Provider is the configured provider instance name. ProviderType is its
// implementation type (for example "openai" or "anthropic").
Provider string
ProviderType string
Model string
// Operation is the semantic GenAI operation selected by the provider
// adapter (for example "chat", "generate_content", or "embeddings").
// It is empty for calls that are not model inference operations.
Operation string
Endpoint string
Method string
Stream bool
// StreamUncertain is true when a bounded opaque-body peek could not
// determine request intent. A later first-chunk event can still confirm SSE.
StreamUncertain bool
}

// UpstreamResult describes a completed logical provider call. For streaming
// calls completion means that the upstream stream was established, not that
// its response body was fully consumed.
type UpstreamResult struct {
UpstreamCall
StatusCode int
Duration time.Duration
Err error
}

// UpstreamObserver observes calls to model providers without participating in
// request handling. Start may return a derived context (for example one that
// carries a trace span); the same context is passed to End and to the provider
// request. Implementations must be safe for concurrent use and should not
// block the request path.
//
// Core contains observer panics so optional instrumentation cannot fail model
// traffic. Every successful Start invocation is paired with one End call.
type UpstreamObserver interface {
Name() string
Start(ctx context.Context, call UpstreamCall) context.Context
End(ctx context.Context, result UpstreamResult)
}

// UpstreamStreamObserver optionally observes the first response chunk of a
// successful streaming call. Duration is measured from request issuance until
// the first body read that returns bytes; calls that end without bytes are not
// reported.
type UpstreamStreamObserver interface {
FirstResponseChunk(ctx context.Context, result UpstreamResult)
}
Loading