From 24b10ecd716097221148f25a467974f32880a5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Mon, 6 Jul 2026 16:01:56 +0000 Subject: [PATCH 1/5] feat: wire ai gateway logging --- docs/reference/cli/ai-gateway_start.md | 51 +++++++++++++++---- enterprise/cli/aigatewaystart.go | 34 ++++++++----- .../cli/aigatewaystart_internal_test.go | 21 ++++++++ .../coder_ai-gateway_start_--help.golden | 17 +++++-- 4 files changed, 98 insertions(+), 25 deletions(-) diff --git a/docs/reference/cli/ai-gateway_start.md b/docs/reference/cli/ai-gateway_start.md index f5c92b1e8ac..21d735e6c31 100644 --- a/docs/reference/cli/ai-gateway_start.md +++ b/docs/reference/cli/ai-gateway_start.md @@ -65,16 +65,6 @@ 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 - -| | | -|-------------|----------------------------------------| -| Type | bool | -| Environment | $CODER_AI_GATEWAY_VERBOSE | -| Default | false | - -Output debug-level logs. - ### --ai-gateway-max-concurrency | | | @@ -139,3 +129,44 @@ Allow users to provide their own LLM API keys or subscriptions. When disabled, o | Default | false | Enable the circuit breaker to protect against cascading failures from upstream AI provider overload (503, 529). + +### -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. diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index 6f9ecf7cf7f..a56c9a1f397 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -17,9 +17,9 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "cdr.dev/slog/v3/sloggers/sloghuman" "github.com/coder/coder/v2/aibridge" agpl "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd" @@ -42,7 +42,6 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { httpAddress string tlsCertFile string tlsKeyFile string - verbose bool ) vals := new(codersdk.DeploymentValues) @@ -80,10 +79,14 @@ 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.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 @@ -248,13 +251,6 @@ 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. @@ -285,6 +281,20 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { cmd.Options = append(cmd.Options, aiGatewayOpts...) + observabilityOpts := map[string]struct{}{ + "CODER_LOGGING_HUMAN": {}, + "CODER_LOGGING_JSON": {}, + "CODER_LOGGING_STACKDRIVER": {}, + "CODER_LOG_FILTER": {}, + "CODER_VERBOSE": {}, + } + for _, opt := range vals.Options() { + if _, ok := observabilityOpts[opt.Env]; !ok { + continue + } + cmd.Options = append(cmd.Options, opt) + } + return cmd } diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index db5309bdf4f..42fc82dbe69 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -215,3 +215,24 @@ func TestAIGatewayStart_DeploymentOptions(t *testing.T) { } require.ElementsMatch(t, want, got) } + +func TestAIGatewayStart_LoggingOptions(t *testing.T) { + t.Parallel() + + cmd := (&RootCmd{}).aiGatewayStart() + + for _, tc := range []struct { + flag string + env string + }{ + {flag: "log-human", env: "CODER_LOGGING_HUMAN"}, + {flag: "log-json", env: "CODER_LOGGING_JSON"}, + {flag: "log-stackdriver", env: "CODER_LOGGING_STACKDRIVER"}, + {flag: "log-filter", env: "CODER_LOG_FILTER"}, + {flag: "verbose", env: "CODER_VERBOSE"}, + } { + opt := cmd.Options.ByFlag(tc.flag) + require.NotNil(t, opt, "missing --%s", tc.flag) + require.Equal(t, tc.env, opt.Env) + } +} diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden index 8156fbbf122..d2b7cab6d7c 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,19 @@ 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. + ——— Run `coder --help` for a list of global options. From f7615ba8bf36ad5d2f5678b17708a6566b83eb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Mon, 6 Jul 2026 16:04:10 +0000 Subject: [PATCH 2/5] feat: expose ai gateway metrics --- docs/reference/cli/ai-gateway_start.md | 21 +++++++++++++++++++ enterprise/cli/aigatewaystart.go | 20 +++++++++++++++--- .../cli/aigatewaystart_internal_test.go | 21 +++++++++++++++++++ .../coder_ai-gateway_start_--help.golden | 7 +++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli/ai-gateway_start.md b/docs/reference/cli/ai-gateway_start.md index 21d735e6c31..4d20fcc43d7 100644 --- a/docs/reference/cli/ai-gateway_start.md +++ b/docs/reference/cli/ai-gateway_start.md @@ -130,6 +130,27 @@ Allow users to provide their own LLM API keys or subscriptions. When disabled, o Enable the circuit breaker to protect against cascading failures from upstream AI provider overload (503, 529). +### --prometheus-enable + +| | | +|-------------|----------------------------------------------| +| Type | bool | +| Environment | $CODER_PROMETHEUS_ENABLE | +| YAML | introspection.prometheus.enable | + +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. + ### -l, --log-filter | | | diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index a56c9a1f397..0a0957bef0e 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -13,11 +13,14 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" tracenoop "go.opentelemetry.io/otel/trace/noop" "golang.org/x/xerrors" "cdr.dev/slog/v3" "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" @@ -88,10 +91,10 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { 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") @@ -102,6 +105,15 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { if err != nil { return xerrors.Errorf("create request pool: %w", err) } + registry.MustRegister(keypool.NewStateCollector(pool.KeyPools)) + + 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() + } dialer := aibridged.NewWebsocketDialer(serverURL, transport, resolvedKey) aibridgedCtx, aibridgedCancel := context.WithCancel(context.Background()) @@ -287,6 +299,8 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { "CODER_LOGGING_STACKDRIVER": {}, "CODER_LOG_FILTER": {}, "CODER_VERBOSE": {}, + "CODER_PROMETHEUS_ENABLE": {}, + "CODER_PROMETHEUS_ADDRESS": {}, } for _, opt := range vals.Options() { if _, ok := observabilityOpts[opt.Env]; !ok { diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index 42fc82dbe69..7d367eccfc5 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -236,3 +236,24 @@ func TestAIGatewayStart_LoggingOptions(t *testing.T) { require.Equal(t, tc.env, opt.Env) } } + +func TestAIGatewayStart_MetricsOptions(t *testing.T) { + t.Parallel() + + cmd := (&RootCmd{}).aiGatewayStart() + + for _, tc := range []struct { + flag string + env string + }{ + {flag: "prometheus-enable", env: "CODER_PROMETHEUS_ENABLE"}, + {flag: "prometheus-address", env: "CODER_PROMETHEUS_ADDRESS"}, + } { + opt := cmd.Options.ByFlag(tc.flag) + require.NotNil(t, opt, "missing --%s", tc.flag) + require.Equal(t, tc.env, opt.Env) + } + + require.Nil(t, cmd.Options.ByFlag("prometheus-collect-agent-stats")) + require.Nil(t, cmd.Options.ByFlag("prometheus-collect-db-metrics")) +} diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden index d2b7cab6d7c..66b10090294 100644 --- a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden +++ b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden @@ -77,5 +77,12 @@ INTROSPECTION / LOGGING OPTIONS: --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. + ——— Run `coder --help` for a list of global options. From f595203a7118c7ba32d35f35ad8a0e0dbfb4292d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Mon, 6 Jul 2026 16:06:18 +0000 Subject: [PATCH 3/5] feat: enable ai gateway tracing --- cli/server.go | 5 +- coderd/tracing/httpmw.go | 46 +++--- docs/reference/cli/ai-gateway_start.md | 153 +++++++++++------- enterprise/cli/aigatewaystart.go | 110 +++++++++---- .../cli/aigatewaystart_internal_test.go | 116 ++++++++++--- enterprise/cli/proxyserver.go | 2 +- .../coder_ai-gateway_start_--help.golden | 14 ++ 7 files changed, 305 insertions(+), 141 deletions(-) diff --git a/cli/server.go b/cli/server.go index 6524d9d6d2c..ca9d47dbd4c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -499,7 +499,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // which is caught by goleaks. defer http.DefaultClient.CloseIdleConnections() - tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, vals) + tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, vals, "coderd") defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) @@ -2799,6 +2799,7 @@ func ConfigureTraceProvider( ctx context.Context, logger slog.Logger, cfg *codersdk.DeploymentValues, + serviceName string, ) (trace.TracerProvider, string, func(context.Context) error) { var ( tracerProvider = trace.NewNoopTracerProvider() @@ -2814,7 +2815,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 26b57a1d221..7c30fdadfd1 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,30 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } +// StartHTTPSpan starts a span for the request, propagating inbound trace context +// and writing the X-Trace-ID/X-Span-ID response headers. It returns the request +// carrying the span context. Caller must end the span. +func StartHTTPSpan(tracer trace.Tracer, rw http.ResponseWriter, r *http.Request, name string) (*http.Request, trace.Span) { + // Extract the trace context from the request headers. + 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()) + + // Inject the trace context into the response headers. + 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 4d20fcc43d7..8dcc63a641a 100644 --- a/docs/reference/cli/ai-gateway_start.md +++ b/docs/reference/cli/ai-gateway_start.md @@ -65,6 +65,97 @@ 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. +### --prometheus-enable + +| | | +|-------------|----------------------------------------------| +| Type | bool | +| Environment | $CODER_PROMETHEUS_ENABLE | +| YAML | introspection.prometheus.enable | + +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 | | | @@ -129,65 +220,3 @@ Allow users to provide their own LLM API keys or subscriptions. When disabled, o | Default | false | Enable the circuit breaker to protect against cascading failures from upstream AI provider overload (503, 529). - -### --prometheus-enable - -| | | -|-------------|----------------------------------------------| -| Type | bool | -| Environment | $CODER_PROMETHEUS_ENABLE | -| YAML | introspection.prometheus.enable | - -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. - -### -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. diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index 0a0957bef0e..704c5303f33 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" @@ -15,7 +16,9 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" - tracenoop "go.opentelemetry.io/otel/trace/noop" + 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" @@ -24,6 +27,7 @@ import ( 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" @@ -97,15 +101,14 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { metrics := aibridge.NewMetrics(registry) providerMetrics := aibridged.NewMetrics(registry) - tracer := tracenoop.NewTracerProvider().Tracer("aibridged") - // 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) - if err != nil { - return xerrors.Errorf("create request pool: %w", err) - } - registry.MustRegister(keypool.NewStateCollector(pool.KeyPools)) + tracerProvider, _, closeTracing := agpl.ConfigureTraceProvider(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("aibridged") if vals.Prometheus.Enable.Value() { logger.Info(signalCtx, "starting Prometheus endpoint", slog.F("address", vals.Prometheus.Address.String())) @@ -115,6 +118,14 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { defer closeFunc() } + // 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) + 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() @@ -194,7 +205,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { ) httpServer := &http.Server{ - Handler: mux, + Handler: tracingMiddleware(tracer)(mux), ReadHeaderTimeout: time.Minute, } @@ -265,9 +276,18 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { }, } - // 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{}{ + // The standalone Gateway inherits a subset of coderd's deployment options. + // Logging and tracing options are inherited by group. The remaining groups mix + // in coderd-only settings, so those options are inherited individually by env var: + // - "AI Gateway" holds coderd-only controls (budget, provider seeding), + // only the LLM-traffic options are inherited. + // - "Prometheus" holds agent/database collectors that a standalone + // Gateway has no source for. + inheritedGroups := map[string]struct{}{ + "Logging": {}, + "Tracing": {}, + } + inheritedEnvs := map[string]struct{}{ "CODER_AI_GATEWAY_ALLOW_BYOK": {}, "CODER_AI_GATEWAY_SEND_ACTOR_HEADERS": {}, "CODER_AI_GATEWAY_DUMP_DIR": {}, @@ -278,32 +298,26 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { "CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS": {}, "CODER_AI_GATEWAY_MAX_CONCURRENCY": {}, "CODER_AI_GATEWAY_RATE_LIMIT": {}, + "CODER_PROMETHEUS_ENABLE": {}, + "CODER_PROMETHEUS_ADDRESS": {}, + } + // excludedEnvs are options that live in an inherited group but do not apply + // to a standalone Gateway. CODER_ENABLE_TERRAFORM_DEBUG_MODE is grouped under + // Logging but controls provisioner behavior that coderd owns. + excludedEnvs := map[string]struct{}{ + "CODER_ENABLE_TERRAFORM_DEBUG_MODE": {}, } - var aiGatewayOpts serpent.OptionSet for _, opt := range vals.Options() { - if opt.Group == nil || opt.Group.Name != "AI Gateway" { + if _, excluded := excludedEnvs[opt.Env]; excluded { continue } - if _, ok := standaloneOpts[opt.Env]; !ok { - continue + _, byEnv := inheritedEnvs[opt.Env] + byGroup := false + if opt.Group != nil { + _, byGroup = inheritedGroups[opt.Group.Name] } - aiGatewayOpts = append(aiGatewayOpts, opt) - } - - cmd.Options = append(cmd.Options, aiGatewayOpts...) - - observabilityOpts := map[string]struct{}{ - "CODER_LOGGING_HUMAN": {}, - "CODER_LOGGING_JSON": {}, - "CODER_LOGGING_STACKDRIVER": {}, - "CODER_LOG_FILTER": {}, - "CODER_VERBOSE": {}, - "CODER_PROMETHEUS_ENABLE": {}, - "CODER_PROMETHEUS_ADDRESS": {}, - } - for _, opt := range vals.Options() { - if _, ok := observabilityOpts[opt.Env]; !ok { + if !byEnv && !byGroup { continue } cmd.Options = append(cmd.Options, opt) @@ -312,6 +326,36 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return cmd } +// tracingMiddleware records a span for every HTTP request served by the +// standalone Gateway. Unlike the shared coderd tracing middleware, it is not +// restricted to coderd's route patterns (/api, ...), so it also covers Gateway +// routes mounted at "/". It reuses tracing.StartHTTPSpan for span and context +// propagation setup, and wraps the ResponseWriter in a tracing.StatusWriter to +// capture the response status for the span. +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) { + r, span := coderdtracing.StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path)) + defer span.End() + + // Wrap the writer so the response status can be recorded on the span. + sw := &coderdtracing.StatusWriter{ResponseWriter: rw} + 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.Path), + 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 7d367eccfc5..b369fee3ca6 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -4,12 +4,15 @@ package cli import ( "context" + "net/http" + "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "github.com/stretchr/testify/require" + tracenoop "go.opentelemetry.io/otel/trace/noop" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -216,44 +219,109 @@ func TestAIGatewayStart_DeploymentOptions(t *testing.T) { require.ElementsMatch(t, want, got) } -func TestAIGatewayStart_LoggingOptions(t *testing.T) { +func TestAIGatewayStart_ObservabilityOptions(t *testing.T) { t.Parallel() cmd := (&RootCmd{}).aiGatewayStart() - for _, tc := range []struct { + type flagEnv struct { flag string env string + } + for _, group := range []struct { + name string + present []flagEnv + // absent lists flags from the same coderd option group that the + // standalone Gateway must not expose. + absent []string }{ - {flag: "log-human", env: "CODER_LOGGING_HUMAN"}, - {flag: "log-json", env: "CODER_LOGGING_JSON"}, - {flag: "log-stackdriver", env: "CODER_LOGGING_STACKDRIVER"}, - {flag: "log-filter", env: "CODER_LOG_FILTER"}, - {flag: "verbose", env: "CODER_VERBOSE"}, + { + name: "Logging", + present: []flagEnv{ + {flag: "log-human", env: "CODER_LOGGING_HUMAN"}, + {flag: "log-json", env: "CODER_LOGGING_JSON"}, + {flag: "log-stackdriver", env: "CODER_LOGGING_STACKDRIVER"}, + {flag: "log-filter", env: "CODER_LOG_FILTER"}, + {flag: "verbose", env: "CODER_VERBOSE"}, + }, + // enable-terraform-debug-mode is grouped under Logging but is a + // coderd/provisioner-only control and must not be inherited. + absent: []string{"enable-terraform-debug-mode"}, + }, + { + name: "Metrics", + present: []flagEnv{ + {flag: "prometheus-enable", env: "CODER_PROMETHEUS_ENABLE"}, + {flag: "prometheus-address", env: "CODER_PROMETHEUS_ADDRESS"}, + }, + absent: []string{ + "prometheus-collect-agent-stats", + "prometheus-collect-db-metrics", + }, + }, + { + name: "Tracing", + present: []flagEnv{ + {flag: "trace", env: "CODER_TRACE_ENABLE"}, + {flag: "trace-honeycomb-api-key", env: "CODER_TRACE_HONEYCOMB_API_KEY"}, + {flag: "trace-logs", env: "CODER_TRACE_LOGS"}, + {flag: "trace-datadog", env: "CODER_TRACE_DATADOG"}, + }, + absent: []string{ + "telemetry-enable", + "telemetry-url", + }, + }, } { - opt := cmd.Options.ByFlag(tc.flag) - require.NotNil(t, opt, "missing --%s", tc.flag) - require.Equal(t, tc.env, opt.Env) + t.Run(group.name, func(t *testing.T) { + t.Parallel() + + for _, tc := range group.present { + opt := cmd.Options.ByFlag(tc.flag) + require.NotNil(t, opt, "missing --%s", tc.flag) + require.Equal(t, tc.env, opt.Env) + } + for _, flag := range group.absent { + require.Nil(t, cmd.Options.ByFlag(flag), "unexpected --%s", flag) + } + }) } } -func TestAIGatewayStart_MetricsOptions(t *testing.T) { +// TestAIGatewayStart_TracingMiddleware verifies that the standalone Gateway's +// tracing middleware traces every route (including those mounted at "/") and +// does not panic when the ResponseWriter has not already been wrapped in a +// tracing.StatusWriter, while still propagating the downstream status code. +func TestAIGatewayStart_TracingMiddleware(t *testing.T) { t.Parallel() - cmd := (&RootCmd{}).aiGatewayStart() + tracer := tracenoop.NewTracerProvider().Tracer("test") - for _, tc := range []struct { - flag string - env string - }{ - {flag: "prometheus-enable", env: "CODER_PROMETHEUS_ENABLE"}, - {flag: "prometheus-address", env: "CODER_PROMETHEUS_ADDRESS"}, + // Includes coderd-style /api paths (which the shared middleware would try + // to trace and panic on without a StatusWriter) and Gateway paths mounted + // at "/" (which the shared middleware would skip entirely). + for _, path := range []string{ + "/", + "/api/v2/aibridge/v1/messages", + "/api/v2/ai-gateway/v1/messages", + "/anthropic/v1/messages", } { - opt := cmd.Options.ByFlag(tc.flag) - require.NotNil(t, opt, "missing --%s", tc.flag) - require.Equal(t, tc.env, opt.Env) - } + t.Run(path, func(t *testing.T) { + t.Parallel() - require.Nil(t, cmd.Options.ByFlag("prometheus-collect-agent-stats")) - require.Nil(t, cmd.Options.ByFlag("prometheus-collect-db-metrics")) + var gotPath string + handler := tracingMiddleware(tracer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusTeapot) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, path, nil) + require.NotPanics(t, func() { + handler.ServeHTTP(rec, req) + }) + require.Equal(t, path, gotPath, "handler should be invoked") + require.Equal(t, http.StatusTeapot, rec.Code) + }) + } } diff --git a/enterprise/cli/proxyserver.go b/enterprise/cli/proxyserver.go index 6a3f99a4a2c..0a95797093b 100644 --- a/enterprise/cli/proxyserver.go +++ b/enterprise/cli/proxyserver.go @@ -153,7 +153,7 @@ func (r *RootCmd) proxyServer() *serpent.Command { defer http.DefaultClient.CloseIdleConnections() closers.Add(http.DefaultClient.CloseIdleConnections) - tracer, _, closeTracing := cli.ConfigureTraceProvider(ctx, logger, cfg) + tracer, _, closeTracing := cli.ConfigureTraceProvider(ctx, logger, cfg, "coderd") defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden index 66b10090294..ee183747dd7 100644 --- a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden +++ b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden @@ -84,5 +84,19 @@ INTROSPECTION / PROMETHEUS OPTIONS: --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. From d4c952acea005b689b69101c5e7a652994ca1c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Wed, 8 Jul 2026 17:08:00 +0000 Subject: [PATCH 4/5] agentic review 1 --- cli/server.go | 12 ++- coderd/tracing/httpmw.go | 8 +- enterprise/cli/aigatewaystart.go | 82 ++++++++++------- .../cli/aigatewaystart_internal_test.go | 91 +++++++++++++------ enterprise/cli/proxyserver.go | 2 +- 5 files changed, 125 insertions(+), 70 deletions(-) diff --git a/cli/server.go b/cli/server.go index ca9d47dbd4c..7ca86b6a547 100644 --- a/cli/server.go +++ b/cli/server.go @@ -499,7 +499,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // which is caught by goleaks. defer http.DefaultClient.CloseIdleConnections() - tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, vals, "coderd") + tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, vals) defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) @@ -2799,6 +2799,16 @@ 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 ( diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 7c30fdadfd1..6c62ece2dd6 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -59,11 +59,9 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } -// StartHTTPSpan starts a span for the request, propagating inbound trace context -// and writing the X-Trace-ID/X-Span-ID response headers. It returns the request -// carrying the span context. Caller must end the span. +// 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) { - // Extract the trace context from the request headers. propagator := otel.GetTextMapPropagator() ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) @@ -75,8 +73,6 @@ func StartHTTPSpan(tracer trace.Tracer, rw http.ResponseWriter, r *http.Request, // 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. propagator.Inject(ctx, propagation.HeaderCarrier(rw.Header())) } diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index 704c5303f33..24dfa82b97d 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -37,6 +37,9 @@ 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)" ) @@ -102,7 +105,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { metrics := aibridge.NewMetrics(registry) providerMetrics := aibridged.NewMetrics(registry) - tracerProvider, _, closeTracing := agpl.ConfigureTraceProvider(signalCtx, logger, vals, "coder-ai-gateway") + tracerProvider, _, closeTracing := agpl.ConfigureTraceProviderWithService(signalCtx, logger, vals, "coder-ai-gateway") defer func() { logger.Debug(signalCtx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) @@ -150,7 +153,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. @@ -169,28 +172,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 { @@ -205,7 +187,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { ) httpServer := &http.Server{ - Handler: tracingMiddleware(tracer)(mux), + Handler: mux, ReadHeaderTimeout: time.Minute, } @@ -326,20 +308,50 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return cmd } -// tracingMiddleware records a span for every HTTP request served by the -// standalone Gateway. Unlike the shared coderd tracing middleware, it is not -// restricted to coderd's route patterns (/api, ...), so it also covers Gateway -// routes mounted at "/". It reuses tracing.StartHTTPSpan for span and context -// propagation setup, and wraps the ResponseWriter in a tracing.StatusWriter to -// capture the response status for the span. +// 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) { - r, span := coderdtracing.StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path)) + sw := &coderdtracing.StatusWriter{ResponseWriter: rw} + r, span := coderdtracing.StartHTTPSpan(tracer, sw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path)) defer span.End() - // Wrap the writer so the response status can be recorded on the span. - sw := &coderdtracing.StatusWriter{ResponseWriter: rw} next.ServeHTTP(sw, r) status := sw.Status @@ -348,7 +360,7 @@ func tracingMiddleware(tracer trace.Tracer) func(http.Handler) http.Handler { } span.SetAttributes( semconv.HTTPMethodKey.String(r.Method), - semconv.HTTPTargetKey.String(r.URL.Path), + semconv.HTTPTargetKey.String(r.URL.RequestURI()), semconv.HTTPStatusCodeKey.Int(status), ) span.SetStatus(httpconv.ServerStatus(status)) diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index b369fee3ca6..9fcf5c62516 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -12,10 +12,12 @@ import ( "testing" "github.com/stretchr/testify/require" - tracenoop "go.opentelemetry.io/otel/trace/noop" + 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" ) @@ -231,9 +233,7 @@ func TestAIGatewayStart_ObservabilityOptions(t *testing.T) { for _, group := range []struct { name string present []flagEnv - // absent lists flags from the same coderd option group that the - // standalone Gateway must not expose. - absent []string + absent []string }{ { name: "Logging", @@ -257,6 +257,7 @@ func TestAIGatewayStart_ObservabilityOptions(t *testing.T) { absent: []string{ "prometheus-collect-agent-stats", "prometheus-collect-db-metrics", + "prometheus-aggregate-agent-stats-by", }, }, { @@ -288,40 +289,76 @@ func TestAIGatewayStart_ObservabilityOptions(t *testing.T) { } } -// TestAIGatewayStart_TracingMiddleware verifies that the standalone Gateway's -// tracing middleware traces every route (including those mounted at "/") and -// does not panic when the ResponseWriter has not already been wrapped in a -// tracing.StatusWriter, while still propagating the downstream status code. +// 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() - tracer := tracenoop.NewTracerProvider().Tracer("test") - - // Includes coderd-style /api paths (which the shared middleware would try - // to trace and panic on without a StatusWriter) and Gateway paths mounted - // at "/" (which the shared middleware would skip entirely). - for _, path := range []string{ - "/", - "/api/v2/aibridge/v1/messages", - "/api/v2/ai-gateway/v1/messages", - "/anthropic/v1/messages", + 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(path, func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { t.Parallel() - var gotPath string - handler := tracingMiddleware(tracer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path + 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, path, nil) + req := httptest.NewRequest(http.MethodPost, tc.path, nil) require.NotPanics(t, func() { - handler.ServeHTTP(rec, req) + mux.ServeHTTP(rec, req) }) - require.Equal(t, path, gotPath, "handler should be invoked") - require.Equal(t, http.StatusTeapot, rec.Code) + 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") +} diff --git a/enterprise/cli/proxyserver.go b/enterprise/cli/proxyserver.go index 0a95797093b..6a3f99a4a2c 100644 --- a/enterprise/cli/proxyserver.go +++ b/enterprise/cli/proxyserver.go @@ -153,7 +153,7 @@ func (r *RootCmd) proxyServer() *serpent.Command { defer http.DefaultClient.CloseIdleConnections() closers.Add(http.DefaultClient.CloseIdleConnections) - tracer, _, closeTracing := cli.ConfigureTraceProvider(ctx, logger, cfg, "coderd") + tracer, _, closeTracing := cli.ConfigureTraceProvider(ctx, logger, cfg) defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) From 1c7baa472e59c79db4dbcff9b0063ee9f1554337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 9 Jul 2026 12:07:57 +0000 Subject: [PATCH 5/5] review 1: fixed tracer/logger names + env inheritence simplification --- cli/aibridged.go | 2 +- enterprise/cli/aigatewaystart.go | 89 +++++----- .../cli/aigatewaystart_internal_test.go | 166 +++++++----------- 3 files changed, 111 insertions(+), 146 deletions(-) diff --git a/cli/aibridged.go b/cli/aibridged.go index e898c282477..0fd2ae598dc 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/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index 24dfa82b97d..f9694cc92a7 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -44,6 +44,39 @@ const ( 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 ( @@ -94,6 +127,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return xerrors.Errorf("make logger: %w", err) } defer closeLogger() + logger = logger.Named("ai-gateway") logger.Debug(signalCtx, "started debug logging") logger.Sync() @@ -111,7 +145,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) logger.Debug(signalCtx, "tracing closed", slog.Error(traceCloseErr)) }() - tracer := tracerProvider.Tracer("aibridged") + tracer := tracerProvider.Tracer("ai-gateway") if vals.Prometheus.Enable.Value() { logger.Info(signalCtx, "starting Prometheus endpoint", slog.F("address", vals.Prometheus.Address.String())) @@ -121,9 +155,11 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { 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) } @@ -132,7 +168,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { 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) } @@ -143,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 { @@ -258,51 +294,10 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { }, } - // The standalone Gateway inherits a subset of coderd's deployment options. - // Logging and tracing options are inherited by group. The remaining groups mix - // in coderd-only settings, so those options are inherited individually by env var: - // - "AI Gateway" holds coderd-only controls (budget, provider seeding), - // only the LLM-traffic options are inherited. - // - "Prometheus" holds agent/database collectors that a standalone - // Gateway has no source for. - inheritedGroups := map[string]struct{}{ - "Logging": {}, - "Tracing": {}, - } - inheritedEnvs := 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": {}, - "CODER_PROMETHEUS_ENABLE": {}, - "CODER_PROMETHEUS_ADDRESS": {}, - } - // excludedEnvs are options that live in an inherited group but do not apply - // to a standalone Gateway. CODER_ENABLE_TERRAFORM_DEBUG_MODE is grouped under - // Logging but controls provisioner behavior that coderd owns. - excludedEnvs := map[string]struct{}{ - "CODER_ENABLE_TERRAFORM_DEBUG_MODE": {}, - } - for _, opt := range vals.Options() { - if _, excluded := excludedEnvs[opt.Env]; excluded { - continue - } - _, byEnv := inheritedEnvs[opt.Env] - byGroup := false - if opt.Group != nil { - _, byGroup = inheritedGroups[opt.Group.Name] - } - if !byEnv && !byGroup { - continue + if _, ok := aiGatewayInheritedEnvs[opt.Env]; ok { + cmd.Options = append(cmd.Options, opt) } - cmd.Options = append(cmd.Options, opt) } return cmd diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index 9fcf5c62516..cd0e97ede76 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -191,104 +191,6 @@ func TestResolveAIGatewayKey(t *testing.T) { } } -func TestAIGatewayStart_DeploymentOptions(t *testing.T) { - t.Parallel() - - cmd := (&RootCmd{}).aiGatewayStart() - - // 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) - } - } - - 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) -} - -func TestAIGatewayStart_ObservabilityOptions(t *testing.T) { - t.Parallel() - - cmd := (&RootCmd{}).aiGatewayStart() - - type flagEnv struct { - flag string - env string - } - for _, group := range []struct { - name string - present []flagEnv - absent []string - }{ - { - name: "Logging", - present: []flagEnv{ - {flag: "log-human", env: "CODER_LOGGING_HUMAN"}, - {flag: "log-json", env: "CODER_LOGGING_JSON"}, - {flag: "log-stackdriver", env: "CODER_LOGGING_STACKDRIVER"}, - {flag: "log-filter", env: "CODER_LOG_FILTER"}, - {flag: "verbose", env: "CODER_VERBOSE"}, - }, - // enable-terraform-debug-mode is grouped under Logging but is a - // coderd/provisioner-only control and must not be inherited. - absent: []string{"enable-terraform-debug-mode"}, - }, - { - name: "Metrics", - present: []flagEnv{ - {flag: "prometheus-enable", env: "CODER_PROMETHEUS_ENABLE"}, - {flag: "prometheus-address", env: "CODER_PROMETHEUS_ADDRESS"}, - }, - absent: []string{ - "prometheus-collect-agent-stats", - "prometheus-collect-db-metrics", - "prometheus-aggregate-agent-stats-by", - }, - }, - { - name: "Tracing", - present: []flagEnv{ - {flag: "trace", env: "CODER_TRACE_ENABLE"}, - {flag: "trace-honeycomb-api-key", env: "CODER_TRACE_HONEYCOMB_API_KEY"}, - {flag: "trace-logs", env: "CODER_TRACE_LOGS"}, - {flag: "trace-datadog", env: "CODER_TRACE_DATADOG"}, - }, - absent: []string{ - "telemetry-enable", - "telemetry-url", - }, - }, - } { - t.Run(group.name, func(t *testing.T) { - t.Parallel() - - for _, tc := range group.present { - opt := cmd.Options.ByFlag(tc.flag) - require.NotNil(t, opt, "missing --%s", tc.flag) - require.Equal(t, tc.env, opt.Env) - } - for _, flag := range group.absent { - require.Nil(t, cmd.Options.ByFlag(flag), "unexpected --%s", flag) - } - }) - } -} - // 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) { @@ -362,3 +264,71 @@ func TestAIGatewayStart_TracingOutermost(t *testing.T) { 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) + } + } + 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) +}