Skip to main content

Observability

The gateway ships with four observability layers: Prometheus metrics, OpenTelemetry tracing, structured log output, and a deep health endpoint.

Prometheus metricsโ€‹

Metrics are exposed at GET /metrics in the standard Prometheus text format. Scrape this endpoint with your Prometheus server. All metric names use the gateway_ prefix.

Available metricsโ€‹

MetricTypeLabelsDescription
gateway_requests_totalCounterprovider, model, statusTotal requests processed (status: success|error|rejected)
gateway_request_duration_secondsHistogramprovider, modelEnd-to-end request latency
gateway_tokens_input_totalCounterprovider, modelPrompt tokens sent to providers
gateway_tokens_output_totalCounterprovider, modelCompletion tokens received from providers
gateway_provider_errors_totalCounterprovider, error_typeProvider errors by type (provider_error|circuit_open|timeout)
gateway_circuit_breaker_stateGaugeproviderCircuit breaker state (0=closed, 1=open, 2=half-open)
gateway_rate_limit_rejections_totalCounterkey_typeRequests rejected by rate limiting (key_type: ip|api_key|plugin)
gateway_request_cost_usd_totalCounterprovider, modelEstimated cumulative request cost in USD (public pricing tables)
gateway_server_connections_currentGaugestateCurrent inbound HTTP connections by state
gateway_server_connection_transitions_totalCounterstateInbound HTTP connection state transitions
gateway_hook_events_dropped_totalCountersubjectHook dispatches dropped because the worker queue was full
gateway_catalog_loads_totalCountersource, resultModel-catalog load attempts (source: remote|fallback; result: success|error)

Example Prometheus scrape configโ€‹

scrape_configs:
- job_name: ferrogw
static_configs:
- targets: ["localhost:8080"]
metrics_path: /metrics
scrape_interval: 15s

Useful PromQL queriesโ€‹

# Request rate by provider
rate(gateway_requests_total[5m])

# P99 request latency
histogram_quantile(0.99, rate(gateway_request_duration_seconds_bucket[5m]))

# Error rate percentage (failed requests over all requests)
sum(rate(gateway_requests_total{status="error"}[5m]))
/ sum(rate(gateway_requests_total[5m])) * 100

# Token throughput per minute (input + output)
(rate(gateway_tokens_input_total[1m]) + rate(gateway_tokens_output_total[1m])) * 60

# Estimated spend rate (USD/hour) by model
rate(gateway_request_cost_usd_total[5m]) * 3600

# Open circuit breakers
gateway_circuit_breaker_state == 1

OpenTelemetry tracingโ€‹

Added in v1.1.0. The gateway emits OpenTelemetry traces over OTLP. Tracing is opt-in โ€” until you configure an OTLP endpoint or an exporter, the gateway uses a zero-allocation no-op tracer with no overhead on the hot path.

Each request opens a gateway.request root span stamped with gen_ai.* and ferro.* attributes (model, token counts, estimated cost, routing decision). Plugin executions and MCP tool calls emit child spans, and outbound provider calls are instrumented with otelhttp so the W3C traceparent is propagated upstream.

Unified trace IDโ€‹

The OTel trace_id, the structured-log trace_id, and the X-Request-ID response header are all the same value. Copy a trace ID from a log line and look it up directly in your tracing backend.

Configurationโ€‹

Configure tracing under the observability block in your gateway config:

observability:
tracing:
enabled: true
endpoint: "" # host:port; blank falls back to OTEL_EXPORTER_OTLP_ENDPOINT
protocol: grpc # grpc | http/protobuf (https:// endpoint โ‡’ TLS, else insecure)
service_name: ferrogw
sample_ratio: 1.0 # head sampler, 0.0โ€“1.0
privacy_level: metadata # none | metadata (default) | full
shutdown_grace: 10s # max drain time for in-flight exports on shutdown
exporters: # plugin exporters receiving completed/failed events
- name: langsmith
enabled: false
config: {}

Standard OpenTelemetry environment variables take precedence over the config file:

VariablePurpose
OTEL_EXPORTER_OTLP_ENDPOINTOTLP collector endpoint; enables tracing when set
OTEL_EXPORTER_OTLP_HEADERSHeaders sent to the collector (e.g. auth tokens)
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARGHead-sampler overrides

When both a config value and the matching OTEL_* variable are present, the environment variable wins.

Export headersโ€‹

To attach static metadata or backend auth tokens to every OTLP export, set the in-config observability.tracing.headers map. Each value supports ${ENV_VAR} (and $ENV_VAR) interpolation that is resolved at exporter-build time โ€” so secrets live in the environment, never literally in the in-memory config. A header whose value resolves to empty (e.g. it references an unset variable) is dropped and a warning is logged.

observability:
tracing:
enabled: true
endpoint: api.honeycomb.io:443
protocol: grpc
headers:
x-honeycomb-team: ${HONEYCOMB_API_KEY} # resolved from the environment
x-ferro-env: production # literal value, passed through

The standard OTEL_EXPORTER_OTLP_HEADERS environment variable is also honored and takes precedence.

Span attributesโ€‹

Each span is stamped with two attribute groups. Group A follows the OpenTelemetry GenAI semantic conventions; Group B is the Ferro ferro.* extension namespace. Every build advertises its attribute schema version via ferro.schema.version (currently 1.0.0-draft), so exporters can branch on schema migrations.

Group A โ€” gen_ai.* (OpenTelemetry GenAI conventions)

AttributeMeaning
gen_ai.systemProvider system (e.g. openai, anthropic)
gen_ai.operation.nameOperation (e.g. chat)
gen_ai.request.modelRequested model ID
gen_ai.response.modelModel that actually served the response
gen_ai.request.max_tokensRequested max output tokens
gen_ai.request.temperatureSampling temperature
gen_ai.request.top_pNucleus-sampling top-p
gen_ai.request.is_streamWhether streaming was requested
gen_ai.usage.input_tokensPrompt tokens
gen_ai.usage.output_tokensCompletion tokens
gen_ai.usage.reasoning_tokensReasoning tokens (reasoning models)
gen_ai.response.finish_reasonsFinish reasons

Group B โ€” ferro.* (Ferro extension)

AttributeMeaning
ferro.schema.versionAttribute schema version (1.0.0-draft)
ferro.gateway.trace_idUnified trace ID (same value as the log trace_id / X-Request-ID)
ferro.gateway.versionGateway build version
ferro.routing.strategyRouting strategy used (fallback, loadbalance, โ€ฆ)
ferro.routing.target_keySelected target / virtual key
ferro.routing.attemptAttempt number within the strategy
ferro.routing.ab_variant_labelA/B variant label, when applicable
ferro.cost.usdEstimated total request cost in USD
ferro.cost.input_usd / ferro.cost.output_usdEstimated input / output cost
ferro.cost.cache_read_usd / ferro.cost.cache_write_usdEstimated prompt-cache read / write cost
ferro.cost.reasoning_usdEstimated reasoning-token cost
ferro.cost.model_foundWhether the model was found in the catalog for pricing
ferro.cache.hitWhether a response-cache hit served the request
ferro.cache.kindCache kind
ferro.plugin.name / ferro.plugin.kindPlugin identity (child spans)
ferro.plugin.stagePlugin stage (before_request, after_request, on_error)
ferro.plugin.outcome / ferro.plugin.reasonPlugin outcome and reason
ferro.mcp.server / ferro.mcp.toolMCP server and tool name (child spans)
ferro.mcp.depth / ferro.mcp.latency_msMCP call depth and latency
ferro.stream.time_to_first_token_msStreaming time-to-first-token
ferro.stream.time_to_last_token_msStreaming time-to-last-token
ferro.circuit_breaker.state / ferro.circuit_breaker.openedCircuit-breaker state and whether it opened during the request
ferro.request.api_key_id / ferro.request.tenant_idRequest API key ID and tenant ID
ferro.error.upstream_statusUpstream HTTP status on failure
ferro.error.retry_countNumber of retries performed

Privacy levelsโ€‹

privacy_level controls how much request content reaches your tracing backend:

LevelBehavior
noneNo prompt/response content; error messages omitted
metadataDefault. Metadata only; error messages redacted (email, JWT, AWS keys)
fullRaw error text included โ€” use only in trusted environments

Exportersโ€‹

Beyond raw OTLP, the gateway exposes an exporter event seam: registered exporters receive gateway.request.completed / gateway.request.failed events for bridging to backends like LangSmith, Langfuse, or Datadog. No exporters ship in the core gateway โ€” they live in the separate ai-gateway-plugins repo. List enabled exporters under observability.exporters; unknown or failed exporters are logged and skipped (non-fatal).

Structured JSON logsโ€‹

The gateway writes structured JSON to stdout. Each log line includes:

FieldDescription
timeISO 8601 timestamp
leveldebug, info, warn, error
trace_idPer-request UUID for log correlation
msgLog message
providerProvider name (on request/response lines)
modelModel ID
latency_msProvider round-trip latency in milliseconds
statusHTTP status code
tokens_promptPrompt token count
tokens_completionCompletion token count

Example log line:

{
"time": "2026-03-11T10:23:45Z",
"level": "info",
"trace_id": "a3f9b1c2-d4e5-4678-8901-abcdef012345",
"msg": "request complete",
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022",
"latency_ms": 412,
"status": 200,
"tokens_prompt": 312,
"tokens_completion": 87
}

Log levelโ€‹

Set the log level with LOG_LEVEL (default: info). Use LOG_FORMAT=text for human-readable output during development.

export LOG_LEVEL=debug
export LOG_FORMAT=text

Health endpointโ€‹

GET /health returns a deep health check with per-provider availability and latency:

{
"status": "ok",
"providers": {
"openai": { "healthy": true, "latency_ms": 245 },
"anthropic": { "healthy": true, "latency_ms": 312 },
"groq": { "healthy": false, "error": "connection refused" }
}
}

Returns 200 OK if at least one provider is healthy, 503 Service Unavailable if all providers are down.

For monitoring in production, see Monitoring.