From bab8ce9d416b35508a879709d7875aa8d8082a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 9 Jul 2026 16:02:18 +0200 Subject: [PATCH] feat: setup logging, tracing and metrics in standalone AI Gateway (#27068) Adds logging, tracing and metrics setup to standalone AI Gateway. Existing options are re-used when possible. --- cli/aibridged.go | 2 +- cli/server.go | 13 +- coderd/tracing/httpmw.go | 42 ++-- docs/reference/cli/ai-gateway_start.md | 95 +++++++- enterprise/cli/aigatewaystart.go | 209 ++++++++++++------ .../cli/aigatewaystart_internal_test.go | 163 ++++++++++++-- .../coder_ai-gateway_start_--help.golden | 38 +++- 7 files changed, 441 insertions(+), 121 deletions(-) diff --git a/cli/aibridged.go b/cli/aibridged.go index e898c28247..0fd2ae598d 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -44,7 +44,7 @@ func newAIBridgeDaemon(coderAPI *coderd.API, cfg codersdk.AIBridgeConfig, reg pr ctx := context.Background() coderAPI.Logger.Debug(ctx, "starting in-memory aibridge daemon") - logger := coderAPI.Logger.Named("aibridged") + logger := coderAPI.Logger.Named("ai-gateway") providerMetrics := aibridged.NewMetrics(reg) tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName) diff --git a/cli/server.go b/cli/server.go index 6524d9d6d2..7ca86b6a54 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2799,6 +2799,17 @@ func ConfigureTraceProvider( ctx context.Context, logger slog.Logger, cfg *codersdk.DeploymentValues, +) (trace.TracerProvider, string, func(context.Context) error) { + return ConfigureTraceProviderWithService(ctx, logger, cfg, "coderd") +} + +// ConfigureTraceProviderWithService configures trace provider +// with a specified service name. +func ConfigureTraceProviderWithService( + ctx context.Context, + logger slog.Logger, + cfg *codersdk.DeploymentValues, + serviceName string, ) (trace.TracerProvider, string, func(context.Context) error) { var ( tracerProvider = trace.NewNoopTracerProvider() @@ -2814,7 +2825,7 @@ func ConfigureTraceProvider( ) if cfg.Trace.Enable.Value() || cfg.Trace.DataDog.Value() || cfg.Trace.HoneycombAPIKey != "" { - sdkTracerProvider, _closeTracing, err := tracing.TracerProvider(ctx, "coderd", tracing.TracerOpts{ + sdkTracerProvider, _closeTracing, err := tracing.TracerProvider(ctx, serviceName, tracing.TracerOpts{ Default: cfg.Trace.Enable.Value(), DataDog: cfg.Trace.DataDog.Value(), Honeycomb: cfg.Trace.HoneycombAPIKey.String(), diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 26b57a1d22..6c62ece2dd 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -41,26 +41,10 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han return } - // Extract the trace context from the request headers. - tmp := otel.GetTextMapPropagator() - hc := propagation.HeaderCarrier(r.Header) - ctx := tmp.Extract(r.Context(), hc) - - // start span with default span name. Span name will be updated to "method route" format once request finishes. - ctx, span := tracer.Start(ctx, fmt.Sprintf("%s %s", r.Method, r.RequestURI)) + // Start span with default span name. Span name will be updated to + // "method route" format once request finishes. + r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.RequestURI)) defer span.End() - r = r.WithContext(ctx) - - if span.SpanContext().HasTraceID() && span.SpanContext().HasSpanID() { - // Technically these values are included in the Traceparent - // header, but they are easier to read for humans this way. - rw.Header().Set("X-Trace-ID", span.SpanContext().TraceID().String()) - rw.Header().Set("X-Span-ID", span.SpanContext().SpanID().String()) - - // Inject the trace context into the response headers. - hc := propagation.HeaderCarrier(rw.Header()) - tmp.Inject(ctx, hc) - } sw, ok := rw.(*StatusWriter) if !ok { @@ -75,6 +59,26 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } +// StartHTTPSpan starts a span, propagating inbound trace context and writing +// X-Trace-ID/X-Span-ID response headers. The caller must end the span. +func StartHTTPSpan(tracer trace.Tracer, rw http.ResponseWriter, r *http.Request, name string) (*http.Request, trace.Span) { + propagator := otel.GetTextMapPropagator() + ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + + ctx, span := tracer.Start(ctx, name) + r = r.WithContext(ctx) + + if span.SpanContext().HasTraceID() && span.SpanContext().HasSpanID() { + // Technically these values are included in the Traceparent header, but + // they are easier to read for humans this way. + rw.Header().Set("X-Trace-ID", span.SpanContext().TraceID().String()) + rw.Header().Set("X-Span-ID", span.SpanContext().SpanID().String()) + propagator.Inject(ctx, propagation.HeaderCarrier(rw.Header())) + } + + return r, span +} + // EndHTTPSpan captures request and response data after the handler is done. func EndHTTPSpan(r *http.Request, status int, span trace.Span) { // set the resource name as we get it only once the handler is executed diff --git a/docs/reference/cli/ai-gateway_start.md b/docs/reference/cli/ai-gateway_start.md index f5c92b1e8a..8dcc63a641 100644 --- a/docs/reference/cli/ai-gateway_start.md +++ b/docs/reference/cli/ai-gateway_start.md @@ -65,15 +65,96 @@ Path to a PEM-encoded TLS certificate. Enables TLS termination when set together Path to a PEM-encoded TLS private key. Enables TLS termination when set together with --tls-cert-file. -### --verbose +### --prometheus-enable -| | | -|-------------|----------------------------------------| -| Type | bool | -| Environment | $CODER_AI_GATEWAY_VERBOSE | -| Default | false | +| | | +|-------------|----------------------------------------------| +| Type | bool | +| Environment | $CODER_PROMETHEUS_ENABLE | +| YAML | introspection.prometheus.enable | -Output debug-level logs. +Serve prometheus metrics on the address defined by prometheus address. + +### --prometheus-address + +| | | +|-------------|-----------------------------------------------| +| Type | host:port | +| Environment | $CODER_PROMETHEUS_ADDRESS | +| YAML | introspection.prometheus.address | +| Default | 127.0.0.1:2112 | + +The bind address to serve prometheus metrics. + +### --trace + +| | | +|-------------|-------------------------------------------| +| Type | bool | +| Environment | $CODER_TRACE_ENABLE | +| YAML | introspection.tracing.enable | + +Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md. + +### --trace-honeycomb-api-key + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_TRACE_HONEYCOMB_API_KEY | + +Enables trace exporting to Honeycomb.io using the provided API Key. + +### --trace-logs + +| | | +|-------------|------------------------------------------------| +| Type | bool | +| Environment | $CODER_TRACE_LOGS | +| YAML | introspection.tracing.captureLogs | + +Enables capturing of logs as events in traces. This is useful for debugging, but may result in a very large amount of events being sent to the tracing backend which may incur significant costs. + +### -l, --log-filter + +| | | +|-------------|-------------------------------------------| +| Type | string-array | +| Environment | $CODER_LOG_FILTER | +| YAML | introspection.logging.filter | + +Filter debug logs by matching against a given regex. Use .* to match all debug logs. + +### --log-human + +| | | +|-------------|----------------------------------------------| +| Type | string | +| Environment | $CODER_LOGGING_HUMAN | +| YAML | introspection.logging.humanPath | +| Default | /dev/stderr | + +Output human-readable logs to a given file. + +### --log-json + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_LOGGING_JSON | +| YAML | introspection.logging.jsonPath | + +Output JSON logs to a given file. + +### --log-stackdriver + +| | | +|-------------|----------------------------------------------------| +| Type | string | +| Environment | $CODER_LOGGING_STACKDRIVER | +| YAML | introspection.logging.stackdriverPath | + +Output Stackdriver compatible logs to a given file. ### --ai-gateway-max-concurrency diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index 6f9ecf7cf7..f9694cc92a 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -5,6 +5,7 @@ package cli import ( "context" "errors" + "fmt" "net" "net/http" "os" @@ -13,14 +14,20 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" - tracenoop "go.opentelemetry.io/otel/trace/noop" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + semconv "go.opentelemetry.io/otel/semconv/v1.14.0" + "go.opentelemetry.io/otel/semconv/v1.14.0/httpconv" + "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" "cdr.dev/slog/v3" - "cdr.dev/slog/v3/sloggers/sloghuman" "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/keypool" agpl "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/coderd/aibridged" + coderdtracing "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/retry" @@ -30,10 +37,46 @@ import ( const ( shutdownTimeout = 5 * time.Minute + healthzPath = "/healthz" + readyzPath = "/readyz" + keyFlagsExclusiveErr = "--key and --key-file options are mutually exclusive" keyFlagsMissingErr = "an AI Gateway key is required, set --key (CODER_AI_GATEWAY_KEY) or --key-file (CODER_AI_GATEWAY_KEY_FILE)" ) +// aiGatewayInheritedEnvs are the coderd deployment options, keyed by env var, +// that the standalone Gateway inherits. +var aiGatewayInheritedEnvs = map[string]struct{}{ + // Logging + "CODER_LOGGING_HUMAN": {}, + "CODER_LOGGING_JSON": {}, + "CODER_LOGGING_STACKDRIVER": {}, + "CODER_LOG_FILTER": {}, + "CODER_VERBOSE": {}, + + // Tracing + "CODER_TRACE_DATADOG": {}, + "CODER_TRACE_ENABLE": {}, + "CODER_TRACE_HONEYCOMB_API_KEY": {}, + "CODER_TRACE_LOGS": {}, + + // AI Gateway + "CODER_AI_GATEWAY_ALLOW_BYOK": {}, + "CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {}, + "CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD": {}, + "CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL": {}, + "CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS": {}, + "CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT": {}, + "CODER_AI_GATEWAY_DUMP_DIR": {}, + "CODER_AI_GATEWAY_MAX_CONCURRENCY": {}, + "CODER_AI_GATEWAY_RATE_LIMIT": {}, + "CODER_AI_GATEWAY_SEND_ACTOR_HEADERS": {}, + + // Prometheus + "CODER_PROMETHEUS_ADDRESS": {}, + "CODER_PROMETHEUS_ENABLE": {}, +} + // aiGatewayStart runs the AI Gateway as a standalone process. func (r *RootCmd) aiGatewayStart() *serpent.Command { var ( @@ -42,7 +85,6 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { httpAddress string tlsCertFile string tlsKeyFile string - verbose bool ) vals := new(codersdk.DeploymentValues) @@ -80,30 +122,53 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return xerrors.Errorf("configure Coder deployment connection: %w", err) } - logger := slog.Make(sloghuman.Sink(inv.Stderr)) - if verbose { - logger = logger.Leveled(slog.LevelDebug) + logger, closeLogger, err := clilog.New(clilog.FromDeploymentValues(vals)).Build(inv) + if err != nil { + return xerrors.Errorf("make logger: %w", err) } + defer closeLogger() + logger = logger.Named("ai-gateway") + + logger.Debug(signalCtx, "started debug logging") + logger.Sync() - // Metrics and tracing are not exposed by standalone mode yet - // (TODO AIGOV-317), but the pool and the reloader require a metrics - // object and a tracer. registry := prometheus.NewRegistry() + registry.MustRegister(collectors.NewGoCollector()) + registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + metrics := aibridge.NewMetrics(registry) providerMetrics := aibridged.NewMetrics(registry) - tracer := tracenoop.NewTracerProvider().Tracer("aibridged") + + tracerProvider, _, closeTracing := agpl.ConfigureTraceProviderWithService(signalCtx, logger, vals, "coder-ai-gateway") + defer func() { + logger.Debug(signalCtx, "closing tracing") + traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) + logger.Debug(signalCtx, "tracing closed", slog.Error(traceCloseErr)) + }() + tracer := tracerProvider.Tracer("ai-gateway") + + if vals.Prometheus.Enable.Value() { + logger.Info(signalCtx, "starting Prometheus endpoint", slog.F("address", vals.Prometheus.Address.String())) + closeFunc := agpl.ServeHandler(signalCtx, logger, promhttp.InstrumentMetricHandler( + registry, promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), + ), vals.Prometheus.Address.String(), "prometheus") + defer closeFunc() + } + + gatewayLogger := logger.Named("ai-gateway") // Standalone Gateway starts with an empty pool. Providers are // fetched later via GetAIProviders DRPC and pool is updated. - pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, logger.Named("pool"), metrics, tracer) + pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, gatewayLogger.Named("pool"), metrics, tracer) if err != nil { return xerrors.Errorf("create request pool: %w", err) } + registry.MustRegister(keypool.NewStateCollector(pool.KeyPools)) dialer := aibridged.NewWebsocketDialer(serverURL, transport, resolvedKey) aibridgedCtx, aibridgedCancel := context.WithCancel(context.Background()) defer aibridgedCancel() - srv, err := aibridged.New(aibridgedCtx, pool, dialer, logger.Named("aibridged"), tracer) + srv, err := aibridged.New(aibridgedCtx, pool, dialer, gatewayLogger, tracer) if err != nil { return xerrors.Errorf("start AI Gateway daemon: %w", err) } @@ -114,7 +179,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { // started below. The reloader's client acquisition honors the // context of each Reload call, so loadProviders is bounded by // signalCtx and the watch loop by watchCtx. - providerLogger := logger.Named("aibridge.providers") + providerLogger := gatewayLogger.Named("providers") reloader := agpl.NewPoolRPCReloader(pool, srv.ClientContext, vals.AI.BridgeConfig, providerLogger, metrics, providerMetrics) if err := loadProviders(signalCtx, reloader, providerLogger, srv.Done()); err != nil { if signalCtx.Err() != nil { @@ -124,7 +189,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return xerrors.Errorf("initialize ai providers: %w", err) } - mw := coderd.AIGatewayDataPlaneMiddleware(vals.AI.BridgeConfig) + mw := gatewayMiddleware(vals.AI.BridgeConfig, tracer) // Watch coderd for provider changes and refresh the pool on each // signal. @@ -143,28 +208,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { watchWG.Wait() }() - // The standalone listener is dedicated to Gateway traffic, so - // the daemon is served at the root. The /api/v2/ai-gateway - // and /api/v2/aibridge/ aliases are added for compatibility - // with the embedded route. - mux := http.NewServeMux() - mux.Handle("/api/v2/aibridge/", mw(http.StripPrefix("/api/v2/aibridge", srv))) - mux.Handle("/api/v2/ai-gateway/", mw(http.StripPrefix("/api/v2/ai-gateway", srv))) - mux.Handle("/", mw(srv)) - - // healthz: returns 200 once the HTTP server is listening. - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - // readyz: returns 200 only when the DRPC connection to coderd is established. - mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { - if srv.Ready() { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusServiceUnavailable) - }) + mux := newGatewayMux(srv, srv.Ready, mw) listener, err := net.Listen("tcp", httpAddress) if err != nil { @@ -248,46 +292,77 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { Description: "Path to a PEM-encoded TLS private key. Enables TLS termination when set together with --tls-cert-file.", Value: serpent.StringOf(&tlsKeyFile), }, - { - Flag: "verbose", - Env: "CODER_AI_GATEWAY_VERBOSE", - Description: "Output debug-level logs.", - Value: serpent.BoolOf(&verbose), - Default: "false", - }, } - // Standalone Gateway only uses part of the options from "AI Gateway" group. - // Other options from the group are coderd-only (eg. budget, provider-seeding). - standaloneOpts := map[string]struct{}{ - "CODER_AI_GATEWAY_ALLOW_BYOK": {}, - "CODER_AI_GATEWAY_SEND_ACTOR_HEADERS": {}, - "CODER_AI_GATEWAY_DUMP_DIR": {}, - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {}, - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD": {}, - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL": {}, - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT": {}, - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS": {}, - "CODER_AI_GATEWAY_MAX_CONCURRENCY": {}, - "CODER_AI_GATEWAY_RATE_LIMIT": {}, - } - - var aiGatewayOpts serpent.OptionSet for _, opt := range vals.Options() { - if opt.Group == nil || opt.Group.Name != "AI Gateway" { - continue + if _, ok := aiGatewayInheritedEnvs[opt.Env]; ok { + cmd.Options = append(cmd.Options, opt) } - if _, ok := standaloneOpts[opt.Env]; !ok { - continue - } - aiGatewayOpts = append(aiGatewayOpts, opt) } - cmd.Options = append(cmd.Options, aiGatewayOpts...) - return cmd } +// gatewayMiddleware composes the standalone gateway's per-request middleware. +// Tracing is outermost so request is traced even when the other guards short-circuit. +func gatewayMiddleware(cfg codersdk.AIBridgeConfig, tracer trace.Tracer) func(http.Handler) http.Handler { + mw := coderd.AIGatewayDataPlaneMiddleware(cfg) + traced := tracingMiddleware(tracer) + return func(next http.Handler) http.Handler { + return traced(mw(next)) + } +} + +// newGatewayMux builds the standalone gateway's HTTP routes. +// The middleware is applied only to the LLM data-plane routes. +func newGatewayMux(aibridgedHandler http.Handler, aibridgedReady func() bool, middleware func(http.Handler) http.Handler) *http.ServeMux { + mux := http.NewServeMux() + mux.Handle("/api/v2/aibridge/", middleware(http.StripPrefix("/api/v2/aibridge", aibridgedHandler))) + mux.Handle("/api/v2/ai-gateway/", middleware(http.StripPrefix("/api/v2/ai-gateway", aibridgedHandler))) + mux.Handle("/", middleware(aibridgedHandler)) + + // healthz: returns 200 once the HTTP server is listening. + mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // readyz: returns 200 only when the DRPC connection to coderd is established. + mux.HandleFunc(readyzPath, func(w http.ResponseWriter, _ *http.Request) { + if aibridgedReady() { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + }) + + return mux +} + +// tracingMiddleware traces every request to the wrapped handler, unlike +// tracing.Middleware which only spans coderd's route patterns. +func tracingMiddleware(tracer trace.Tracer) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + sw := &coderdtracing.StatusWriter{ResponseWriter: rw} + r, span := coderdtracing.StartHTTPSpan(tracer, sw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path)) + defer span.End() + + next.ServeHTTP(sw, r) + + status := sw.Status + if status == 0 { + status = http.StatusOK + } + span.SetAttributes( + semconv.HTTPMethodKey.String(r.Method), + semconv.HTTPTargetKey.String(r.URL.RequestURI()), + semconv.HTTPStatusCodeKey.Int(status), + ) + span.SetStatus(httpconv.ServerStatus(status)) + }) + } +} + // resolveAIGatewayKey resolves key from --key or --key-file flags. // If both are set, an error is returned. If neither is set, an empty string is returned. func resolveAIGatewayKey(key string, keyFile string) (string, error) { diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index db5309bdf4..cd0e97ede7 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -4,15 +4,20 @@ package cli import ( "context" + "net/http" + "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" "golang.org/x/xerrors" "cdr.dev/slog/v3" + agplaibridge "github.com/coder/coder/v2/coderd/aibridge" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -186,32 +191,144 @@ func TestResolveAIGatewayKey(t *testing.T) { } } -func TestAIGatewayStart_DeploymentOptions(t *testing.T) { +// TestAIGatewayStart_TracingMiddleware verifies the gateway mux built by +// newGatewayMux traces the LLM routes while leaving the health probes untraced. +func TestAIGatewayStart_TracingMiddleware(t *testing.T) { t.Parallel() - cmd := (&RootCmd{}).aiGatewayStart() + tracer := sdktrace.NewTracerProvider().Tracer("test") + for _, tc := range []struct { + name string + path string + ready bool + traced bool + wantStatus int + }{ + {name: "root LLM route", path: "/anthropic/v1/messages", ready: true, traced: true, wantStatus: http.StatusTeapot}, + {name: "aibridge alias", path: "/api/v2/aibridge/v1/messages", ready: true, traced: true, wantStatus: http.StatusTeapot}, + {name: "healthz", path: healthzPath, ready: true, traced: false, wantStatus: http.StatusOK}, + {name: "readyz ready", path: readyzPath, ready: true, traced: false, wantStatus: http.StatusOK}, + {name: "readyz not ready", path: readyzPath, ready: false, traced: false, wantStatus: http.StatusServiceUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - // Standalone Gateway only consumes deployment options used in LLM traffic. - // Coderd-only settings such as provider seeds, retention, - // structured logging, and Coder MCP injection must stay server-only. - var got []string - for _, opt := range cmd.Options { - if opt.Group != nil && opt.Group.Name == "AI Gateway" { - got = append(got, opt.Env) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + mux := newGatewayMux(handler, func() bool { return tc.ready }, tracingMiddleware(tracer)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + require.NotPanics(t, func() { + mux.ServeHTTP(rec, req) + }) + require.Equal(t, tc.wantStatus, rec.Code) + + if tc.traced { + require.NotEmpty(t, rec.Header().Get("X-Trace-ID"), "expected a span to be created") + } else { + require.Empty(t, rec.Header().Get("X-Trace-ID"), "health probes must not be traced") + } + }) + } +} + +// TestAIGatewayStart_TracingOutermost verifies the request +// rejected by AIGatewayDataPlaneMiddleware middleware is still traced. +func TestAIGatewayStart_TracingOutermost(t *testing.T) { + t.Parallel() + + tracer := sdktrace.NewTracerProvider().Tracer("test") + + cfg := codersdk.AIBridgeConfig{ + AllowBYOK: false, + } + + var handlerCalls atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalls.Add(1) + w.WriteHeader(http.StatusOK) + }) + wrapped := gatewayMiddleware(cfg, tracer)(handler) + + // BYOK request + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + req.Header.Set(agplaibridge.HeaderCoderToken, "byok-token") + + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + // req rejected but still traced + require.Equal(t, http.StatusForbidden, rec.Code) + require.NotEmpty(t, rec.Header().Get("X-Trace-ID"), "rejected requests must still be traced") + require.Equal(t, int32(0), handlerCalls.Load(), "rejected request must not reach the handler") +} + +// TestAIGatewayStart_InheritedOptions verifies that options inherited +// from coderd's deployment values are consciously used or dropped. +// A newly added option in these groups fails this test until it +// is consciously placed in one bucket, preventing silent drift +// in what the gateway exposes. +func TestAIGatewayStart_InheritedOptions(t *testing.T) { + t.Parallel() + + // Groups the gateway sources options from. + sourceGroups := map[string]struct{}{ + "Logging": {}, + "Tracing": {}, + "AI Gateway": {}, + "Prometheus": {}, + } + + // Options in the source groups that the gateway intentionally does not + // inherit because they only apply to coderd. + dropped := map[string]struct{}{ + // Logging + "CODER_ENABLE_TERRAFORM_DEBUG_MODE": {}, + + // AI Gateway (coderd-only: provider seeding, budgets, retention, etc.) + "CODER_AI_BUDGET_PERIOD": {}, + "CODER_AI_BUDGET_POLICY": {}, + "CODER_AI_GATEWAY_ANTHROPIC_BASE_URL": {}, + "CODER_AI_GATEWAY_ANTHROPIC_KEY": {}, + "CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY": {}, + "CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET": {}, + "CODER_AI_GATEWAY_BEDROCK_BASE_URL": {}, + "CODER_AI_GATEWAY_BEDROCK_MODEL": {}, + "CODER_AI_GATEWAY_BEDROCK_REGION": {}, + "CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL": {}, + "CODER_AI_GATEWAY_ENABLED": {}, + "CODER_AI_GATEWAY_INJECT_CODER_MCP_TOOLS": {}, + "CODER_AI_GATEWAY_OPENAI_BASE_URL": {}, + "CODER_AI_GATEWAY_OPENAI_KEY": {}, + "CODER_AI_GATEWAY_RETENTION": {}, + "CODER_AI_GATEWAY_STRUCTURED_LOGGING": {}, + + // Prometheus (coderd-only: agent/database collectors) + "CODER_PROMETHEUS_AGGREGATE_AGENT_STATS_BY": {}, + "CODER_PROMETHEUS_COLLECT_AGENT_STATS": {}, + "CODER_PROMETHEUS_COLLECT_DB_METRICS": {}, + } + + dv := codersdk.DeploymentValues{} + var unclassified []string + for _, opt := range dv.Options() { + if opt.Group == nil || opt.Env == "" { + continue + } + if _, ok := sourceGroups[opt.Group.Name]; !ok { + continue + } + _, inherited := aiGatewayInheritedEnvs[opt.Env] + _, drop := dropped[opt.Env] + require.Falsef(t, inherited && drop, "%s option is both inherited and dropped", opt.Env) + if !inherited && !drop { + unclassified = append(unclassified, opt.Env) } } - - want := []string{ - "CODER_AI_GATEWAY_ALLOW_BYOK", - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED", - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD", - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL", - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS", - "CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT", - "CODER_AI_GATEWAY_DUMP_DIR", - "CODER_AI_GATEWAY_MAX_CONCURRENCY", - "CODER_AI_GATEWAY_RATE_LIMIT", - "CODER_AI_GATEWAY_SEND_ACTOR_HEADERS", - } - require.ElementsMatch(t, want, got) + require.Emptyf(t, unclassified, + "options from source groups are neither inherited nor dropped.\n"+ + "Check if option is applicable for standalone AI Gateway.\n"+ + "If so, add it to aiGatewayInheritedEnvs, otherwise add it to the dropped set: %v", unclassified) } diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden index 8156fbbf12..ee183747dd 100644 --- a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden +++ b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden @@ -32,9 +32,6 @@ OPTIONS: Path to a PEM-encoded TLS private key. Enables TLS termination when set together with --tls-cert-file. - --verbose bool, $CODER_AI_GATEWAY_VERBOSE (default: false) - Output debug-level logs. - AI GATEWAY OPTIONS: --ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR Base directory for dumping AI Gateway request/response pairs to disk @@ -66,5 +63,40 @@ AI GATEWAY OPTIONS: making the request) and X-Ai-Bridge-Actor-Metadata-Username (their username). +INTROSPECTION / LOGGING OPTIONS: + --log-human string, $CODER_LOGGING_HUMAN (default: /dev/stderr) + Output human-readable logs to a given file. + + --log-json string, $CODER_LOGGING_JSON + Output JSON logs to a given file. + + -l, --log-filter string-array, $CODER_LOG_FILTER + Filter debug logs by matching against a given regex. Use .* to match + all debug logs. + + --log-stackdriver string, $CODER_LOGGING_STACKDRIVER + Output Stackdriver compatible logs to a given file. + +INTROSPECTION / PROMETHEUS OPTIONS: + --prometheus-address host:port, $CODER_PROMETHEUS_ADDRESS (default: 127.0.0.1:2112) + The bind address to serve prometheus metrics. + + --prometheus-enable bool, $CODER_PROMETHEUS_ENABLE + Serve prometheus metrics on the address defined by prometheus address. + +INTROSPECTION / TRACING OPTIONS: + --trace-logs bool, $CODER_TRACE_LOGS + Enables capturing of logs as events in traces. This is useful for + debugging, but may result in a very large amount of events being sent + to the tracing backend which may incur significant costs. + + --trace bool, $CODER_TRACE_ENABLE + Whether application tracing data is collected. It exports to a backend + configured by environment variables. See: + https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md. + + --trace-honeycomb-api-key string, $CODER_TRACE_HONEYCOMB_API_KEY + Enables trace exporting to Honeycomb.io using the provided API Key. + ——— Run `coder --help` for a list of global options.