diff --git a/docs/features/observability/otel.mdx b/docs/features/observability/otel.mdx index d33cddb74c0..edaf84dee10 100644 --- a/docs/features/observability/otel.mdx +++ b/docs/features/observability/otel.mdx @@ -115,7 +115,7 @@ func main() { // Initialize OTel plugin otelPlugin, err := otel.Init(ctx, &otel.Config{ ServiceName: "bifrost", - CollectorURL: "http://localhost:4318", + CollectorURL: "http://localhost:4318/v1/traces", TraceType: otel.TraceTypeGenAIExtension, Protocol: otel.ProtocolHTTP, Headers: map[string]string{ @@ -153,7 +153,7 @@ For Gateway mode, configure via `config.json`: "name": "otel", "config": { "service_name": "bifrost", - "collector_url": "http://localhost:4318", + "collector_url": "http://localhost:4318/v1/traces", "trace_type": "genai_extension", "protocol": "http", "headers": { @@ -165,7 +165,7 @@ For Gateway mode, configure via `config.json`: } ``` -If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert`: +If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert` and set insecure mode to `false`: ```json { @@ -178,6 +178,7 @@ If you need to connect to an OTEL collector that requires TLS, configure `tls_ca "collector_url": "localhost:4317", "trace_type": "genai_extension", "protocol": "grpc", + "insecure": false, "tls_ca_cert": "/path/to/your/ca.cert", "headers": { "Authorization": "env.OTEL_API_KEY" @@ -189,6 +190,65 @@ If you need to connect to an OTEL collector that requires TLS, configure `tls_ca ``` + + +For Gateway mode, configure via `config.json`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "http://localhost:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "Authorization": "env.OTEL_API_KEY" + } + } + ] + } + } + ] +} +``` + +If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert` and set insecure mode to `false`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "insecure": false, + "tls_ca_cert": "/path/to/your/ca.cert", + "headers": { + "Authorization": "env.OTEL_API_KEY" + } + } + ] + } + } + ] +} +``` + + --- @@ -221,12 +281,12 @@ services: tempo: image: grafana/tempo:latest container_name: tempo - command: [ "-config.file=/etc/tempo.yaml" ] + command: ["-target=all", "-config.file=/etc/tempo.yaml"] configs: - source: tempo-config target: /etc/tempo.yaml ports: - - "3200:3200" # tempo HTTP API + - "3200:3200" # Tempo HTTP API expose: - "4317" # OTLP gRPC (internal) volumes: @@ -236,14 +296,14 @@ services: prometheus: image: prom/prometheus:latest container_name: prometheus - depends_on: - - otel-collector command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" - "--web.console.libraries=/usr/share/prometheus/console_libraries" - "--web.console.templates=/usr/share/prometheus/consoles" - "--web.enable-remote-write-receiver" + - "--enable-feature=exemplar-storage" + - "--enable-feature=native-histograms" ports: - "9090:9090" volumes: @@ -251,6 +311,8 @@ services: configs: - source: prometheus-config target: /etc/prometheus/prometheus.yml + depends_on: + - otel-collector restart: unless-stopped grafana: @@ -264,9 +326,8 @@ services: GF_SECURITY_ADMIN_PASSWORD: admin GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "grafana-pyroscope-app,grafana-exploretraces-app,grafana-metricsdrilldown-app" - GF_PLUGINS_ENABLE_ALPHA: "true" GF_INSTALL_PLUGINS: "" + GF_FEATURE_TOGGLES_ENABLE: traceqlEditor ports: - "4000:3000" volumes: @@ -296,12 +357,12 @@ configs: namespace: otel const_labels: source: otelcol - + otlp/tempo: endpoint: tempo:4317 tls: insecure: true - + debug: verbosity: detailed @@ -346,7 +407,7 @@ configs: protocols: grpc: endpoint: 0.0.0.0:4317 - + ingester: max_block_duration: 5m trace_idle_period: 10s @@ -716,7 +777,7 @@ Uses HTTP/1.1 or HTTP/2 with JSON or Protobuf encoding: ```json { - "collector_url": "http://localhost:4318", + "collector_url": "http://localhost:4318/v1/traces", "protocol": "http" } ``` @@ -778,8 +839,37 @@ The OTel plugin supports **push-based metrics export** via OTLP, which is essent ] } ``` + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "http://otel-collector:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "metrics_enabled": true, + "metrics_endpoint": "http://otel-collector:4318/v1/metrics", + "metrics_push_interval": 15 + } + ] + } + } + ] +} +``` + ```json @@ -801,8 +891,36 @@ The OTel plugin supports **push-based metrics export** via OTLP, which is essent ] } ``` + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "otel-collector:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "metrics_enabled": true, + "metrics_endpoint": "otel-collector:4317", + "metrics_push_interval": 15 + } + ] + } + } + ] +} +``` + ### Pushed Metrics diff --git a/docs/providers/request-options.mdx b/docs/providers/request-options.mdx index d8629c3d6df..7fa85b3fef9 100644 --- a/docs/providers/request-options.mdx +++ b/docs/providers/request-options.mdx @@ -521,8 +521,13 @@ This flag affects only what is written to the log record (messages, params, tool Enable passthrough mode for extra parameters. When enabled, any parameters in `extra_params` (or provider-specific extra parameter fields) will be merged directly into the request sent to the provider. +How parameters are collected depends on the entrypoint: + +- Standard inference routes such as `/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`, and `/v1/images/generations` collect all unknown top-level JSON fields as extra parameters. +- OpenAI integration routes under `/openai` preserve the OpenAI request shape, so provider-specific fields must be put under `extra_params`. + - + ```bash curl --location 'http://localhost:8080/v1/chat/completions' \ --header 'x-bf-passthrough-extra-params: true' \ @@ -538,13 +543,31 @@ curl --location 'http://localhost:8080/v1/chat/completions' \ }' ```` + + +```bash +curl --location 'http://localhost:8080/openai/chat/completions' \ +--header 'x-bf-passthrough-extra-params: true' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}], + "extra_params": { + "custom_param": "value", + "nested_param": { + "a": "value", + "b": 123 + } + } +}' +```` ```go -ctx := context.Background() -ctx = context.WithValue(ctx, schemas.BifrostContextKeyPassthroughExtraParams, true) +ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ +response, err := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o-mini", Input: messages, diff --git a/docs/providers/supported-providers/anthropic.mdx b/docs/providers/supported-providers/anthropic.mdx index 5c81715b257..115661e95a1 100644 --- a/docs/providers/supported-providers/anthropic.mdx +++ b/docs/providers/supported-providers/anthropic.mdx @@ -72,25 +72,7 @@ Configure Anthropic as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "anthropic", - "keys": [ - { - "name": "anthropic-key-1", - "value": "env.ANTHROPIC_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/cerebras.mdx b/docs/providers/supported-providers/cerebras.mdx index 424993bae27..2b06ddc7805 100644 --- a/docs/providers/supported-providers/cerebras.mdx +++ b/docs/providers/supported-providers/cerebras.mdx @@ -72,25 +72,7 @@ Configure Cerebras as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "cerebras", - "keys": [ - { - "name": "cerebras-key-1", - "value": "env.CEREBRAS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/cohere.mdx b/docs/providers/supported-providers/cohere.mdx index 8c8202ab743..4f596f2b3bb 100644 --- a/docs/providers/supported-providers/cohere.mdx +++ b/docs/providers/supported-providers/cohere.mdx @@ -72,25 +72,7 @@ Configure Cohere as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "cohere", - "keys": [ - { - "name": "cohere-key-1", - "value": "env.COHERE_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/elevenlabs.mdx b/docs/providers/supported-providers/elevenlabs.mdx index fc3324cf917..f286d4388da 100644 --- a/docs/providers/supported-providers/elevenlabs.mdx +++ b/docs/providers/supported-providers/elevenlabs.mdx @@ -74,25 +74,7 @@ Configure ElevenLabs as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "elevenlabs", - "keys": [ - { - "name": "elevenlabs-key-1", - "value": "env.ELEVENLABS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/fireworks.mdx b/docs/providers/supported-providers/fireworks.mdx index e757dcb6e90..4220f9fe953 100644 --- a/docs/providers/supported-providers/fireworks.mdx +++ b/docs/providers/supported-providers/fireworks.mdx @@ -75,25 +75,7 @@ Configure Fireworks as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "fireworks", - "keys": [ - { - "name": "fireworks-key-1", - "value": "env.FIREWORKS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/gemini.mdx b/docs/providers/supported-providers/gemini.mdx index c41f8914de0..e2c910ee761 100644 --- a/docs/providers/supported-providers/gemini.mdx +++ b/docs/providers/supported-providers/gemini.mdx @@ -73,25 +73,7 @@ Configure Gemini as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "gemini", - "keys": [ - { - "name": "gemini-key-1", - "value": "env.GEMINI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). @@ -943,4 +925,4 @@ Bifrost supports the following content modalities through Gemini: **Behavior**: System instructions become `systemInstruction` field (separate from messages), not included in message array **Impact**: Structure differs from OpenAI's system message approach **Code**: `responses.go:34-46` - \ No newline at end of file + diff --git a/docs/providers/supported-providers/groq.mdx b/docs/providers/supported-providers/groq.mdx index 37bcc461b43..9bb52c7801e 100644 --- a/docs/providers/supported-providers/groq.mdx +++ b/docs/providers/supported-providers/groq.mdx @@ -74,25 +74,7 @@ Configure Groq as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "groq", - "keys": [ - { - "name": "groq-key-1", - "value": "env.GROQ_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/huggingface.mdx b/docs/providers/supported-providers/huggingface.mdx index c2708300650..396c54ac53f 100644 --- a/docs/providers/supported-providers/huggingface.mdx +++ b/docs/providers/supported-providers/huggingface.mdx @@ -83,25 +83,7 @@ Configure Hugging Face as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "huggingface", - "keys": [ - { - "name": "huggingface-key-1", - "value": "env.HUGGINGFACE_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/mistral.mdx b/docs/providers/supported-providers/mistral.mdx index e4918e11f37..e48833a66b6 100644 --- a/docs/providers/supported-providers/mistral.mdx +++ b/docs/providers/supported-providers/mistral.mdx @@ -79,25 +79,7 @@ Configure Mistral as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "mistral", - "keys": [ - { - "name": "mistral-key-1", - "value": "env.MISTRAL_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/nebius.mdx b/docs/providers/supported-providers/nebius.mdx index 71fe4299f31..dad778d5a22 100644 --- a/docs/providers/supported-providers/nebius.mdx +++ b/docs/providers/supported-providers/nebius.mdx @@ -72,25 +72,7 @@ Configure Nebius as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "nebius", - "keys": [ - { - "name": "nebius-key-1", - "value": "env.NEBIUS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/ollama.mdx b/docs/providers/supported-providers/ollama.mdx index 2a0f47da4aa..b43e3c2cfd8 100644 --- a/docs/providers/supported-providers/ollama.mdx +++ b/docs/providers/supported-providers/ollama.mdx @@ -161,28 +161,7 @@ Configure Ollama as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "ollama", - "keys": [ - { - "name": "ollama-local", - "value": "", - "models": [ - "*" - ], - "weight": 1.0, - "ollama_key_config": { - "url": "http://localhost:11434" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/openai.mdx b/docs/providers/supported-providers/openai.mdx index e17cda5fca9..3b3d0657c5f 100644 --- a/docs/providers/supported-providers/openai.mdx +++ b/docs/providers/supported-providers/openai.mdx @@ -69,28 +69,7 @@ Configure OpenAI as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "openai", - "keys": [ - { - "name": "openai-key-1", - "value": "env.OPENAI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ], - "openai_config": { - "disable_store": false - } -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/openrouter.mdx b/docs/providers/supported-providers/openrouter.mdx index 4adab2ee25a..e6e2f0949a7 100644 --- a/docs/providers/supported-providers/openrouter.mdx +++ b/docs/providers/supported-providers/openrouter.mdx @@ -74,25 +74,7 @@ Configure OpenRouter as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "openrouter", - "keys": [ - { - "name": "openrouter-key-1", - "value": "env.OPENROUTER_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/parasail.mdx b/docs/providers/supported-providers/parasail.mdx index 606ff5f5a2b..8630e94ff5f 100644 --- a/docs/providers/supported-providers/parasail.mdx +++ b/docs/providers/supported-providers/parasail.mdx @@ -72,25 +72,7 @@ Configure Parasail as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "parasail", - "keys": [ - { - "name": "parasail-key-1", - "value": "env.PARASAIL_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/perplexity.mdx b/docs/providers/supported-providers/perplexity.mdx index c232572ca59..3e6d36601df 100644 --- a/docs/providers/supported-providers/perplexity.mdx +++ b/docs/providers/supported-providers/perplexity.mdx @@ -72,25 +72,7 @@ Configure Perplexity as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "perplexity", - "keys": [ - { - "name": "perplexity-key-1", - "value": "env.PERPLEXITY_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/replicate.mdx b/docs/providers/supported-providers/replicate.mdx index 6ad0b7e0981..33478fc178b 100644 --- a/docs/providers/supported-providers/replicate.mdx +++ b/docs/providers/supported-providers/replicate.mdx @@ -81,28 +81,7 @@ Configure Replicate as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "replicate", - "keys": [ - { - "name": "replicate-key-1", - "value": "env.REPLICATE_API_TOKEN", - "models": [ - "*" - ], - "weight": 1.0, - "replicate_key_config": { - "use_deployments_endpoint": false - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/runway.mdx b/docs/providers/supported-providers/runway.mdx index 84184724073..106c2dfaf6e 100644 --- a/docs/providers/supported-providers/runway.mdx +++ b/docs/providers/supported-providers/runway.mdx @@ -108,25 +108,7 @@ Configure Runway as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "runway", - "keys": [ - { - "name": "runway-key-1", - "value": "env.RUNWAY_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/sgl.mdx b/docs/providers/supported-providers/sgl.mdx index 9a530422157..7ff7aa4ad2a 100644 --- a/docs/providers/supported-providers/sgl.mdx +++ b/docs/providers/supported-providers/sgl.mdx @@ -78,28 +78,7 @@ Configure SGLang as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "sgl", - "keys": [ - { - "name": "sgl-local", - "value": "", - "models": [ - "*" - ], - "weight": 1.0, - "sgl_key_config": { - "url": "http://localhost:8000" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/vllm.mdx b/docs/providers/supported-providers/vllm.mdx index 5f0977e3e76..cd78deeb648 100644 --- a/docs/providers/supported-providers/vllm.mdx +++ b/docs/providers/supported-providers/vllm.mdx @@ -79,29 +79,7 @@ Configure vLLM as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "vllm", - "keys": [ - { - "name": "vllm-local", - "value": "", - "models": [ - "meta-llama/Llama-3.2-1B-Instruct" - ], - "weight": 1.0, - "vllm_key_config": { - "url": "http://localhost:8000", - "model_name": "meta-llama/Llama-3.2-1B-Instruct" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/xai.mdx b/docs/providers/supported-providers/xai.mdx index 01c0435955c..753de7453c5 100644 --- a/docs/providers/supported-providers/xai.mdx +++ b/docs/providers/supported-providers/xai.mdx @@ -73,25 +73,7 @@ Configure xAI as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "xai", - "keys": [ - { - "name": "xai-key-1", - "value": "env.XAI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index b25854306a5..4a1b2172423 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -1114,6 +1114,12 @@ false {{- if .Values.bifrost.plugins.otel.enabled }} {{- $otelConfig := dict }} {{- $inputConfig := .Values.bifrost.plugins.otel.config | default dict }} +{{- if hasKey $inputConfig "profiles" }} +{{- $_ := set $otelConfig "profiles" $inputConfig.profiles }} +{{- if $inputConfig.plugin_span_filter }} +{{- $_ := set $otelConfig "plugin_span_filter" $inputConfig.plugin_span_filter }} +{{- end }} +{{- else }} {{- if $inputConfig.service_name }} {{- $_ := set $otelConfig "service_name" $inputConfig.service_name }} {{- end }} @@ -1144,6 +1150,10 @@ false {{- if hasKey $inputConfig "insecure" }} {{- $_ := set $otelConfig "insecure" $inputConfig.insecure }} {{- end }} +{{- if $inputConfig.plugin_span_filter }} +{{- $_ := set $otelConfig "plugin_span_filter" $inputConfig.plugin_span_filter }} +{{- end }} +{{- end }} {{- $plugin := dict "enabled" true "name" "otel" "config" $otelConfig }} {{- if hasKey .Values.bifrost.plugins.otel "version" }}{{- $_ := set $plugin "version" (.Values.bifrost.plugins.otel.version | int) }}{{- end }} {{- $plugins = append $plugins $plugin }} @@ -1340,15 +1350,45 @@ Call this template at the beginning of deployment/stateful templates {{/* Validate OTEL plugin when enabled */}} {{- if .Values.bifrost.plugins.otel.enabled }} -{{- if not .Values.bifrost.plugins.otel.config.collector_url }} +{{- $otelInputConfig := .Values.bifrost.plugins.otel.config | default dict }} +{{- if hasKey $otelInputConfig "profiles" }} +{{- if not $otelInputConfig.profiles }} +{{- fail "ERROR: bifrost.plugins.otel.config.profiles must contain at least one profile when OTEL plugin is enabled." }} +{{- end }} +{{- range $idx, $profile := $otelInputConfig.profiles }} +{{- $profileEnabled := true }} +{{- if hasKey $profile "enabled" }} +{{- $profileEnabled = $profile.enabled }} +{{- end }} +{{- if $profileEnabled }} +{{- if not $profile.collector_url }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].collector_url is required for enabled OTEL profiles." $idx) }} +{{- end }} +{{- if not $profile.trace_type }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].trace_type is required. Supported values: genai_extension, vercel, open_inference" $idx) }} +{{- end }} +{{- if not $profile.protocol }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].protocol is required. Supported values: http, grpc" $idx) }} +{{- end }} +{{- if and $profile.metrics_enabled (not $profile.metrics_endpoint) }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].metrics_endpoint is required when metrics_enabled is true." $idx) }} +{{- end }} +{{- end }} +{{- end }} +{{- else }} +{{- if not $otelInputConfig.collector_url }} {{- fail "ERROR: bifrost.plugins.otel.config.collector_url is required when OTEL plugin is enabled. Provide the URL of your OpenTelemetry collector." }} {{- end }} -{{- if not .Values.bifrost.plugins.otel.config.trace_type }} +{{- if not $otelInputConfig.trace_type }} {{- fail "ERROR: bifrost.plugins.otel.config.trace_type is required when OTEL plugin is enabled. Supported values: genai_extension, vercel, open_inference" }} {{- end }} -{{- if not .Values.bifrost.plugins.otel.config.protocol }} +{{- if not $otelInputConfig.protocol }} {{- fail "ERROR: bifrost.plugins.otel.config.protocol is required when OTEL plugin is enabled. Supported values: http, grpc" }} {{- end }} +{{- if and $otelInputConfig.metrics_enabled (not $otelInputConfig.metrics_endpoint) }} +{{- fail "ERROR: bifrost.plugins.otel.config.metrics_endpoint is required when metrics_enabled is true." }} +{{- end }} +{{- end }} {{- end }} {{/* Validate Maxim plugin when enabled */}} diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index 0442bbb06ad..50d4bb05594 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -835,70 +835,11 @@ "type": "boolean" }, "config": { - "type": "object", - "properties": { - "service_name": { - "type": "string", - "description": "Service name to be used for tracing", - "default": "bifrost" - }, - "collector_url": { - "type": "string", - "description": "URL of the OpenTelemetry collector" - }, - "trace_type": { - "type": "string", - "enum": ["genai_extension", "vercel", "open_inference"], - "description": "Type of trace to use for the OTEL collector" - }, - "protocol": { - "type": "string", - "enum": ["http", "grpc"], - "description": "Protocol to use for the OTEL collector" - }, - "metrics_enabled": { - "type": "boolean", - "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", - "default": false - }, - "metrics_endpoint": { - "type": "string", - "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" - }, - "metrics_push_interval": { - "type": "integer", - "description": "Metrics push interval in seconds", - "default": 15, - "minimum": 1, - "maximum": 300 - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Custom headers for the collector (supports env.VAR_NAME prefix)" - }, - "tls_ca_cert": { - "type": "string", - "description": "Path to TLS CA certificate file" - }, - "insecure": { - "type": "boolean", - "description": "Skip TLS verification (ignored if tls_ca_cert is set)" - } - }, - "if": { - "properties": { - "metrics_enabled": { - "const": true - } - }, - "required": ["metrics_enabled"] - }, - "then": { - "required": ["metrics_endpoint"] - } + "anyOf": [ + { "$ref": "#/$defs/otelProfileConfig" }, + { "$ref": "#/$defs/otelProfilesConfig" } + ], + "description": "Configuration for the OpenTelemetry plugin. Supports the legacy single-profile shape or the profiles wrapper for multiple collectors." } }, "if": { @@ -911,7 +852,10 @@ "then": { "properties": { "config": { - "required": ["collector_url", "trace_type", "protocol"] + "anyOf": [ + { "$ref": "#/$defs/otelProfileConfig" }, + { "$ref": "#/$defs/otelProfilesConfig" } + ] } } } @@ -3360,6 +3304,208 @@ } }, "$defs": { + "otelEndpoint": { + "type": "string", + "description": "OpenTelemetry endpoint URL or host:port. Supports env.VAR_NAME prefix for environment variable substitution.", + "anyOf": [ + { + "format": "uri" + }, + { + "pattern": "^[^:\\s]+:\\d+$" + }, + { + "pattern": "^env\\.[A-Za-z_][A-Za-z0-9_]*$" + }, + { + "const": "" + } + ] + }, + "otelPluginSpanFilter": { + "type": "object", + "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", + "properties": { + "mode": { + "type": "string", + "enum": ["include", "exclude"] + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "plugins"], + "additionalProperties": false + }, + "otelProfileConfig": { + "type": "object", + "description": "OpenTelemetry export profile. This legacy single-profile shape is still accepted directly as bifrost.plugins.otel.config.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this profile exports traces and metrics", + "default": true + }, + "service_name": { + "type": "string", + "description": "Service name to be used for tracing", + "default": "bifrost" + }, + "collector_url": { + "$ref": "#/$defs/otelEndpoint", + "description": "URL of the OpenTelemetry collector" + }, + "trace_type": { + "type": "string", + "enum": ["genai_extension", "vercel", "open_inference"], + "description": "Type of trace to use for the OTEL collector" + }, + "protocol": { + "type": "string", + "enum": ["http", "grpc"], + "description": "Protocol to use for the OTEL collector" + }, + "metrics_enabled": { + "type": "boolean", + "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", + "default": false + }, + "metrics_endpoint": { + "$ref": "#/$defs/otelEndpoint", + "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" + }, + "metrics_push_interval": { + "type": "integer", + "description": "Metrics push interval in seconds", + "default": 15, + "minimum": 1, + "maximum": 300 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers for the collector (supports env.VAR_NAME prefix)" + }, + "tls_ca_cert": { + "type": "string", + "description": "Path to TLS CA certificate file" + }, + "insecure": { + "type": "boolean", + "description": "Skip TLS verification (ignored if tls_ca_cert is set)", + "default": true + }, + "plugin_span_filter": { + "$ref": "#/$defs/otelPluginSpanFilter" + } + }, + "allOf": [ + { + "if": { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] + } + }, + "then": { + "required": ["collector_url", "trace_type", "protocol"] + } + }, + { + "if": { + "properties": { + "metrics_enabled": { + "const": true + } + }, + "required": ["metrics_enabled"] + }, + "then": { + "required": ["metrics_endpoint"] + } + } + ], + "additionalProperties": false + }, + "otelProfilesConfig": { + "type": "object", + "description": "OpenTelemetry plugin configuration with one or more export profiles.", + "properties": { + "profiles": { + "type": "array", + "description": "OpenTelemetry export profiles", + "items": { + "$ref": "#/$defs/otelProfileConfig" + }, + "minItems": 1 + }, + "plugin_span_filter": { + "$ref": "#/$defs/otelPluginSpanFilter" + }, + "enabled": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; kept only so Helm's default map merge does not reject values that switch from the legacy shape to profiles." + }, + "service_name": { + "type": "string", + "description": "Deprecated in the profiles wrapper; configure service_name per profile instead." + }, + "collector_url": { + "$ref": "#/$defs/otelEndpoint", + "description": "Deprecated in the profiles wrapper; configure collector_url per profile instead." + }, + "trace_type": { + "type": "string", + "enum": ["genai_extension", "vercel", "open_inference"], + "description": "Deprecated in the profiles wrapper; configure trace_type per profile instead." + }, + "protocol": { + "type": "string", + "enum": ["http", "grpc"], + "description": "Deprecated in the profiles wrapper; configure protocol per profile instead." + }, + "metrics_enabled": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; configure metrics_enabled per profile instead." + }, + "metrics_endpoint": { + "$ref": "#/$defs/otelEndpoint", + "description": "Deprecated in the profiles wrapper; configure metrics_endpoint per profile instead." + }, + "metrics_push_interval": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "description": "Deprecated in the profiles wrapper; configure metrics_push_interval per profile instead." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Deprecated in the profiles wrapper; configure headers per profile instead." + }, + "tls_ca_cert": { + "type": "string", + "description": "Deprecated in the profiles wrapper; configure tls_ca_cert per profile instead." + }, + "insecure": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; configure insecure per profile instead." + } + }, + "required": ["profiles"], + "additionalProperties": false + }, "authConfig": { "type": "object", "description": "Authentication configuration. Deprecated: Use governance.auth_config instead.", diff --git a/plugins/otel/converter.go b/plugins/otel/converter.go index 604dff5a105..7fce7e25f8e 100644 --- a/plugins/otel/converter.go +++ b/plugins/otel/converter.go @@ -127,15 +127,17 @@ func (p *OtelPlugin) buildReparentMap(spans []*schemas.Span) map[string]string { return filtered } -// convertTraceToResourceSpan converts a Bifrost trace to OTEL ResourceSpan -func (p *OtelPlugin) convertTraceToResourceSpan(trace *schemas.Trace) *ResourceSpan { +// convertTraceToResourceSpan converts a Bifrost trace to OTEL ResourceSpan for the given +// profile service name. Span filtering and instance attributes are shared across profiles; +// only the resource service name differs per profile. +func (p *OtelPlugin) convertTraceToResourceSpan(serviceName string, trace *schemas.Trace) *ResourceSpan { reparent := p.buildReparentMap(trace.Spans) otelSpans := make([]*Span, 0, len(trace.Spans)) for _, span := range trace.Spans { if !p.shouldExportSpan(span) { continue } - otelSpan := p.convertSpanToOTELSpan(trace.TraceID, span) + otelSpan := convertSpanToOTELSpan(trace.TraceID, span) // If the span's direct parent was filtered, rewrite its parent ID to the // nearest exported ancestor so the hierarchy stays connected. if effectiveParent, ok := reparent[span.ParentID]; ok { @@ -160,17 +162,17 @@ func (p *OtelPlugin) convertTraceToResourceSpan(trace *schemas.Trace) *ResourceS } return &ResourceSpan{ Resource: &resourcepb.Resource{ - Attributes: p.getResourceAttributes(), + Attributes: p.getResourceAttributes(serviceName), }, ScopeSpans: []*ScopeSpan{{ - Scope: p.getInstrumentationScope(), + Scope: p.getInstrumentationScope(serviceName), Spans: otelSpans, }}, } } // convertSpanToOTELSpan converts a single Bifrost span to OTEL format -func (p *OtelPlugin) convertSpanToOTELSpan(traceID string, span *schemas.Span) *Span { +func convertSpanToOTELSpan(traceID string, span *schemas.Span) *Span { otelSpan := &Span{ TraceId: hexToBytes(traceID, 16), SpanId: hexToBytes(span.SpanID, 8), @@ -192,9 +194,9 @@ func (p *OtelPlugin) convertSpanToOTELSpan(traceID string, span *schemas.Span) * } // getResourceAttributes returns the resource attributes for the OTEL span -func (p *OtelPlugin) getResourceAttributes() []*KeyValue { +func (p *OtelPlugin) getResourceAttributes(serviceName string) []*KeyValue { attrs := []*KeyValue{ - kvStr("service.name", p.serviceName), + kvStr("service.name", serviceName), kvStr("service.version", p.bifrostVersion), kvStr("telemetry.sdk.name", "bifrost"), kvStr("telemetry.sdk.language", "go"), @@ -205,9 +207,9 @@ func (p *OtelPlugin) getResourceAttributes() []*KeyValue { } // getInstrumentationScope returns the instrumentation scope for OTEL -func (p *OtelPlugin) getInstrumentationScope() *commonpb.InstrumentationScope { +func (p *OtelPlugin) getInstrumentationScope(serviceName string) *commonpb.InstrumentationScope { return &commonpb.InstrumentationScope{ - Name: p.serviceName, + Name: serviceName, Version: p.bifrostVersion, } } diff --git a/plugins/otel/grpc.go b/plugins/otel/grpc.go index 6cdd5f9a08a..a55c447ccf4 100644 --- a/plugins/otel/grpc.go +++ b/plugins/otel/grpc.go @@ -2,10 +2,6 @@ package otel import ( "context" - "crypto/tls" - "crypto/x509" - "fmt" - "os" collectorpb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" @@ -24,33 +20,15 @@ type OtelClientGRPC struct { // NewOtelClientGRPC creates a new OpenTelemetry client for gRPC func NewOtelClientGRPC(endpoint string, headers map[string]string, tlsCACert string, insecureMode bool) (*OtelClientGRPC, error) { var creds credentials.TransportCredentials - // TLS priority: custom CA > system roots > insecure - if tlsCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(tlsCACert); err != nil { - return nil, err - } - // Use custom CA certificate with MinVersion - caCert, err := os.ReadFile(tlsCACert) - if err != nil { - return nil, fmt.Errorf("fail to load provided CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("fail to parse provided CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - creds = credentials.NewTLS(tlsConfig) - } else if insecureMode { - // Skip TLS entirely + + // gRPC insecure mode uses plaintext (no TLS at all), not just skip-verify. + // buildTLSConfig is bypassed here to preserve that behaviour. + if tlsCACert == "" && insecureMode { creds = insecure.NewCredentials() } else { - // Use system root CAs with MinVersion - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, + tlsConfig, err := buildTLSConfig(tlsCACert, false) + if err != nil { + return nil, err } creds = credentials.NewTLS(tlsConfig) } diff --git a/plugins/otel/http.go b/plugins/otel/http.go index 08ba89284d3..9cab25fb23a 100644 --- a/plugins/otel/http.go +++ b/plugins/otel/http.go @@ -3,12 +3,9 @@ package otel import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "fmt" "io" "net/http" - "os" "strings" "time" @@ -30,35 +27,11 @@ func NewOtelClientHTTP(endpoint string, headers map[string]string, tlsCACert str transport.MaxIdleConnsPerHost = 10 transport.IdleConnTimeout = 120 * time.Second - // TLS priority: custom CA > system roots > insecure - if tlsCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(tlsCACert); err != nil { - return nil, err - } - caCert, err := os.ReadFile(tlsCACert) - if err != nil { - return nil, fmt.Errorf("fail to load provided CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("fail to add provided CA cert") - } - transport.TLSClientConfig = &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - } else if insecureMode { - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, // #nosec G402 - MinVersion: tls.VersionTLS12, - } - } else { - // Use system root CAs with MinVersion - transport.TLSClientConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - } + tlsConfig, err := buildTLSConfig(tlsCACert, insecureMode) + if err != nil { + return nil, err } + transport.TLSClientConfig = tlsConfig return &OtelClientHTTP{client: &http.Client{ Timeout: 30 * time.Second, diff --git a/plugins/otel/main.go b/plugins/otel/main.go index 005277e7dc3..e996ee58574 100644 --- a/plugins/otel/main.go +++ b/plugins/otel/main.go @@ -4,8 +4,10 @@ package otel import ( "context" "fmt" + "maps" "os" "strings" + "sync" "github.com/bytedance/sonic" bifrost "github.com/maximhq/bifrost/core" @@ -30,20 +32,13 @@ type TraceType string // TraceTypeGenAIExtension is the type of trace to use for the OTEL collector const TraceTypeGenAIExtension TraceType = "genai_extension" -// TraceTypeVercel is the type of trace to use for the OTEL collector -const TraceTypeVercel TraceType = "vercel" - -// TraceTypeOpenInference is the type of trace to use for the OTEL collector -const TraceTypeOpenInference TraceType = "open_inference" - // Protocol is the protocol to use for the OTEL collector type Protocol string -// ProtocolHTTP is the default protocol -const ProtocolHTTP Protocol = "http" - -// ProtocolGRPC is the second protocol -const ProtocolGRPC Protocol = "grpc" +const ( + ProtocolHTTP Protocol = "http" // default + ProtocolGRPC Protocol = "grpc" +) // PluginSpanFilterMode controls whether the plugins list is an allowlist or denylist. type PluginSpanFilterMode string @@ -60,82 +55,227 @@ type PluginSpanFilter struct { Plugins []string `json:"plugins"` } -type Config struct { - ServiceName string `json:"service_name"` - CollectorURL *schemas.EnvVar `json:"collector_url"` - Headers map[string]*schemas.EnvVar `json:"headers"` - TraceType TraceType `json:"trace_type"` - Protocol Protocol `json:"protocol"` - TLSCACert string `json:"tls_ca_cert"` - Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted. +// Profile is a single OTEL export target: a collector endpoint and an optional +// metrics-push destination. A Config holds one or more profiles; each profile gets +// its own trace client and (when enabled) metrics exporter at runtime. +// +// Headers are plain strings using the "env.VAR_NAME" convention; they are resolved +// against the environment at Init time via injectEnvToHeaders. +type Profile struct { + // Enabled gates whether this profile exports anything. The plugin itself is always on; + // a disabled profile builds no trace client or metrics exporter, so no traces/metrics + // are sent for it. Defaults to true when omitted. + Enabled bool `json:"enabled"` + ServiceName string `json:"service_name"` + CollectorURL *schemas.EnvVar `json:"collector_url"` + Headers map[string]string `json:"headers,omitempty"` + TraceType TraceType `json:"trace_type"` + Protocol Protocol `json:"protocol"` + TLSCACert string `json:"tls_ca_cert,omitempty"` + Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted. // Metrics push configuration MetricsEnabled bool `json:"metrics_enabled"` - MetricsEndpoint *schemas.EnvVar `json:"metrics_endpoint"` - MetricsPushInterval int `json:"metrics_push_interval"` // in seconds, default 15 + MetricsEndpoint *schemas.EnvVar `json:"metrics_endpoint,omitempty"` + MetricsPushInterval int `json:"metrics_push_interval,omitempty"` // in seconds, default 15 +} - // PluginSpanFilter is the DB-stored fallback when otel_plugin_span_filter is absent in config.json. - // The top-level config.json field takes precedence and is passed via Init's pluginSpanFilter param. +// UnmarshalJSON applies field defaults that the zero-value wouldn't capture. +// Specifically, Insecure defaults to true when the key is omitted so http:// +// collectors work out-of-the-box without forcing users to set it explicitly. +func (p *Profile) UnmarshalJSON(data []byte) error { + type alias Profile + aux := struct { + Enabled *bool `json:"enabled"` + Insecure *bool `json:"insecure"` + *alias + }{ + alias: (*alias)(p), + } + if err := sonic.Unmarshal(data, &aux); err != nil { + return err + } + if aux.Insecure == nil { + p.Insecure = true + } else { + p.Insecure = *aux.Insecure + } + if aux.Enabled == nil { + p.Enabled = true + } else { + p.Enabled = *aux.Enabled + } + return nil +} + +// Config is the OTEL plugin configuration: a set of export profiles plus a single +// shared span filter. It accepts two JSON shapes (see UnmarshalJSON): +// - the canonical wrapper {"profiles": [ ... ], "plugin_span_filter": { ... }} +// - a legacy single profile object, which is normalized into a one-element Profiles slice. +type Config struct { + Profiles []*Profile `json:"profiles"` + + // PluginSpanFilter is a single policy applied across every profile. In a legacy + // single-object config it is read from the object; in a profiles wrapper it is read + // from the top-level field (or hoisted from the first profile that carries one). PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` } +// UnmarshalJSON normalizes both supported config shapes into Profiles. A wrapper object +// (one with a "profiles" key) is read directly; any other object is treated as a single +// legacy profile, with its plugin_span_filter hoisted to the shared Config level. +func (c *Config) UnmarshalJSON(data []byte) error { + // Canonical wrapper shape. + if node, err := sonic.Get(data, "profiles"); err == nil && node.Exists() { + type wrapper Config + var w wrapper + if err := sonic.Unmarshal(data, &w); err != nil { + return err + } + *c = Config(w) + // Allow plugin_span_filter to live on the first profile too; hoist it if the + // top-level field was omitted. + if c.PluginSpanFilter == nil { + c.PluginSpanFilter = hoistSpanFilter(data) + } + return nil + } + + // Legacy single-object shape: the whole object is one profile. + var prof Profile + if err := sonic.Unmarshal(data, &prof); err != nil { + return err + } + c.Profiles = []*Profile{&prof} + c.PluginSpanFilter = spanFilterFrom(data) + return nil +} + +// spanFilterCarrier captures only the plugin_span_filter field from a config or profile object. +type spanFilterCarrier struct { + PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` +} + +// spanFilterFrom extracts a top-level plugin_span_filter from a JSON object, or nil. +func spanFilterFrom(data []byte) *PluginSpanFilter { + var c spanFilterCarrier + if err := sonic.Unmarshal(data, &c); err != nil { + return nil + } + return c.PluginSpanFilter +} + +// hoistSpanFilter returns the first plugin_span_filter found among the profiles of a +// wrapper-shaped config, used as a fallback when the top-level field is absent. +func hoistSpanFilter(data []byte) *PluginSpanFilter { + var w struct { + Profiles []spanFilterCarrier `json:"profiles"` + } + if err := sonic.Unmarshal(data, &w); err != nil { + return nil + } + for _, p := range w.Profiles { + if p.PluginSpanFilter != nil { + return p.PluginSpanFilter + } + } + return nil +} + +// profileForStorage is the persisted form of a single profile: *EnvVar fields are +// flattened to plain strings ("env.VAR_NAME" or the literal value) for DB/config-file +// persistence. +type profileForStorage struct { + Enabled bool `json:"enabled"` + ServiceName string `json:"service_name"` + CollectorURL string `json:"collector_url"` + Headers map[string]string `json:"headers,omitempty"` + TraceType TraceType `json:"trace_type"` + Protocol Protocol `json:"protocol"` + TLSCACert string `json:"tls_ca_cert,omitempty"` + Insecure bool `json:"insecure"` + MetricsEnabled bool `json:"metrics_enabled"` + MetricsEndpoint string `json:"metrics_endpoint,omitempty"` + MetricsPushInterval int `json:"metrics_push_interval,omitempty"` +} + +// configForStorage is the persisted wrapper shape. +type configForStorage struct { + Profiles []profileForStorage `json:"profiles"` + PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` +} + // MarshalForStorage serializes Config to JSON with *EnvVar fields as plain strings -// ("env.VAR_NAME" or the literal value) for database/config-file persistence. +// ("env.VAR_NAME" or the literal value) for database/config-file persistence. Output is +// always the canonical {"profiles": [...]} wrapper regardless of the input shape. // For HTTP API responses use json.Marshal directly so clients receive full EnvVar objects. func (c *Config) MarshalForStorage() ([]byte, error) { - type alias struct { - ServiceName string `json:"service_name"` - CollectorURL string `json:"collector_url"` - Headers map[string]string `json:"headers,omitempty"` - TraceType TraceType `json:"trace_type"` - Protocol Protocol `json:"protocol"` - TLSCACert string `json:"tls_ca_cert,omitempty"` - Insecure bool `json:"insecure"` - MetricsEnabled bool `json:"metrics_enabled"` - MetricsEndpoint string `json:"metrics_endpoint,omitempty"` - MetricsPushInterval int `json:"metrics_push_interval,omitempty"` - PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` - } - a := alias{ - ServiceName: c.ServiceName, - CollectorURL: schemas.EnvVarAsString(c.CollectorURL), - TraceType: c.TraceType, - Protocol: c.Protocol, - TLSCACert: c.TLSCACert, - Insecure: c.Insecure, - MetricsEnabled: c.MetricsEnabled, - MetricsEndpoint: schemas.EnvVarAsString(c.MetricsEndpoint), - MetricsPushInterval: c.MetricsPushInterval, - PluginSpanFilter: c.PluginSpanFilter, - } - if c.Headers != nil { - a.Headers = make(map[string]string, len(c.Headers)) - for k, v := range c.Headers { - a.Headers[k] = schemas.EnvVarAsString(v) - } + out := configForStorage{ + Profiles: make([]profileForStorage, 0, len(c.Profiles)), + PluginSpanFilter: c.PluginSpanFilter, } - return sonic.Marshal(a) + for _, p := range c.Profiles { + if p == nil { + continue + } + out.Profiles = append(out.Profiles, profileForStorage{ + Enabled: p.Enabled, + ServiceName: p.ServiceName, + CollectorURL: schemas.EnvVarAsString(p.CollectorURL), + Headers: p.Headers, + TraceType: p.TraceType, + Protocol: p.Protocol, + TLSCACert: p.TLSCACert, + Insecure: p.Insecure, + MetricsEnabled: p.MetricsEnabled, + MetricsEndpoint: schemas.EnvVarAsString(p.MetricsEndpoint), + MetricsPushInterval: p.MetricsPushInterval, + }) + } + return sonic.Marshal(out) } -// Redacted returns a copy of the config with sensitive EnvVar fields redacted for API responses. +// Redacted returns a copy of the config with sensitive fields redacted for API responses. // URLs (CollectorURL, MetricsEndpoint) are not secrets and are returned unchanged so the UI // can display and re-submit them without failing URL validation. For env var references on // those fields, only the resolved value is hidden; the env_var name is preserved. -// Header values may carry auth tokens and are masked. +// Header values may carry auth tokens, so literal values are masked while "env." references +// are preserved. func (c *Config) Redacted() *Config { if c == nil { return nil } - redacted := *c - redacted.CollectorURL = hideResolvedEnvValue(c.CollectorURL) - redacted.MetricsEndpoint = hideResolvedEnvValue(c.MetricsEndpoint) - if c.Headers != nil { - redacted.Headers = make(map[string]*schemas.EnvVar, len(c.Headers)) - for k, v := range c.Headers { - redacted.Headers[k] = v.Redacted() + redacted := &Config{PluginSpanFilter: c.PluginSpanFilter} + if c.Profiles != nil { + redacted.Profiles = make([]*Profile, 0, len(c.Profiles)) + for _, p := range c.Profiles { + if p == nil { + redacted.Profiles = append(redacted.Profiles, nil) + continue + } + rp := *p + rp.CollectorURL = hideResolvedEnvValue(p.CollectorURL) + rp.MetricsEndpoint = hideResolvedEnvValue(p.MetricsEndpoint) + if p.Headers != nil { + rp.Headers = make(map[string]string, len(p.Headers)) + for k, v := range p.Headers { + rp.Headers[k] = redactHeaderValue(v) + } + } + redacted.Profiles = append(redacted.Profiles, &rp) } } - return &redacted + return redacted +} + +// redactHeaderValue masks a plain-string header value for API responses. "env." references +// are returned unchanged (they are not secrets), while literal values are masked using the +// same scheme as EnvVar.Redacted so the API surface stays consistent. +func redactHeaderValue(v string) string { + if strings.HasPrefix(v, "env.") { + return v + } + return schemas.EnvVarAsString(schemas.NewEnvVar(v).Redacted()) } // hideResolvedEnvValue returns v unchanged for literal values (URLs are not secrets). @@ -149,53 +289,35 @@ func hideResolvedEnvValue(v *schemas.EnvVar) *schemas.EnvVar { return v.Redacted() } -// UnmarshalJSON applies field defaults that the zero-value wouldn't capture. -// Specifically, Insecure defaults to true when the key is omitted so http:// -// collectors work out-of-the-box without forcing users to set it explicitly. -func (c *Config) UnmarshalJSON(data []byte) error { - type alias Config - aux := struct { - Insecure *bool `json:"insecure"` - *alias - }{ - alias: (*alias)(c), - } - if err := sonic.Unmarshal(data, &aux); err != nil { - return err - } - if aux.Insecure == nil { - c.Insecure = true - } else { - c.Insecure = *aux.Insecure - } - return nil +// otelTarget is the runtime state for a single configured profile: one trace client +// plus an optional metrics exporter, along with the per-profile identity (service name) +// used when converting traces for this destination. +type otelTarget struct { + serviceName string + url string + traceType TraceType + client OtelClient + metricsExporter *MetricsExporter } // OtelPlugin is the plugin for OpenTelemetry. // It implements the ObservabilityPlugin interface to receive completed traces -// from the tracing middleware and forward them to an OTEL collector. +// from the tracing middleware and forward them to one or more OTEL collectors. type OtelPlugin struct { ctx context.Context cancel context.CancelFunc - serviceName string - url string - headers map[string]string - traceType TraceType - protocol Protocol + // targets holds one runtime per configured profile. Each completed trace is exported + // to every target's collector, and metrics are recorded against every target's exporter. + targets []*otelTarget bifrostVersion string attributesFromEnvironment []*commonpb.KeyValue instanceAttrs []*commonpb.KeyValue // machine ID + pod labels, added only to root spans - client OtelClient - pricingManager *modelcatalog.ModelCatalog - // Metrics push support - metricsExporter *MetricsExporter - pluginSpanFilter *PluginSpanFilter } @@ -208,7 +330,9 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa if pricingManager == nil { logger.Warn("otel plugin requires model catalog to calculate cost, all cost calculations will be skipped.") } - var err error + if len(config.Profiles) == 0 { + return nil, fmt.Errorf("at least one otel profile is required") + } if config.PluginSpanFilter != nil { switch config.PluginSpanFilter.Mode { case PluginSpanFilterModeInclude, PluginSpanFilterModeExclude: @@ -217,9 +341,6 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa config.PluginSpanFilter.Mode, PluginSpanFilterModeInclude, PluginSpanFilterModeExclude) } } - if config.ServiceName == "" { - config.ServiceName = "bifrost" - } // Loading attributes from environment attributesFromEnvironment := make([]*commonpb.KeyValue, 0) if attributes, ok := os.LookupEnv(OTELResponseAttributesEnvKey); ok { @@ -248,11 +369,6 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa } // Preparing the plugin p := &OtelPlugin{ - serviceName: config.ServiceName, - url: config.CollectorURL.GetValue(), - traceType: config.TraceType, - headers: resolveHeaders(config.Headers), - protocol: config.Protocol, pricingManager: pricingManager, bifrostVersion: bifrostVersion, attributesFromEnvironment: attributesFromEnvironment, @@ -260,54 +376,102 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa pluginSpanFilter: config.PluginSpanFilter, } p.ctx, p.cancel = context.WithCancel(ctx) - if config.Protocol == ProtocolGRPC { - p.client, err = NewOtelClientGRPC(config.CollectorURL.GetValue(), p.headers, config.TLSCACert, config.Insecure) - if err != nil { - return nil, err + + for i, profile := range config.Profiles { + // A disabled profile exports nothing — skip building its client/exporter entirely. + if profile != nil && !profile.Enabled { + logger.Info("OTEL profile %d is disabled, skipping", i) + continue } - } - if config.Protocol == ProtocolHTTP { - p.client, err = NewOtelClientHTTP(config.CollectorURL.GetValue(), p.headers, config.TLSCACert, config.Insecure) + target, err := p.buildTarget(i, profile) if err != nil { + // Tear down any targets already initialized so we don't leak clients/exporters. + _ = p.Cleanup() return nil, err } + p.targets = append(p.targets, target) } - if p.client == nil { - return nil, fmt.Errorf("otel client is not initialized. invalid protocol type") + + return p, nil +} + +// buildTarget constructs the runtime for a single profile: it resolves headers, validates +// the protocol, opens the trace client, and (when enabled) starts the metrics exporter. +func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, error) { + if profile == nil { + return nil, fmt.Errorf("profile %d is nil", index) + } + if profile.CollectorURL == nil || profile.CollectorURL.GetValue() == "" { + return nil, fmt.Errorf("profile %d: collector url is required", index) + } + + serviceName := profile.ServiceName + if serviceName == "" { + serviceName = "bifrost" + } + + // Copy headers before resolving so the stored config is never mutated, then resolve + // any "env." references against the environment (errors if a referenced var is unset). + headers := make(map[string]string, len(profile.Headers)) + maps.Copy(headers, profile.Headers) + if err := injectEnvToHeaders(headers); err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) + } + + url := profile.CollectorURL.GetValue() + target := &otelTarget{ + serviceName: serviceName, + url: url, + traceType: profile.TraceType, + } + + var err error + switch profile.Protocol { + case ProtocolGRPC: + target.client, err = NewOtelClientGRPC(url, headers, profile.TLSCACert, profile.Insecure) + case ProtocolHTTP: + target.client, err = NewOtelClientHTTP(url, headers, profile.TLSCACert, profile.Insecure) + default: + return nil, fmt.Errorf("profile %d: invalid protocol type %q", index, profile.Protocol) + } + if err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) } // Initialize metrics exporter if enabled - if config.MetricsEnabled { - if config.MetricsEndpoint.GetValue() == "" { - return nil, fmt.Errorf("metrics_endpoint is required when metrics_enabled is true") + if profile.MetricsEnabled { + if profile.MetricsEndpoint.GetValue() == "" { + target.client.Close() + return nil, fmt.Errorf("profile %d: metrics_endpoint is required when metrics_enabled is true", index) } - pushInterval := config.MetricsPushInterval + pushInterval := profile.MetricsPushInterval if pushInterval <= 0 { pushInterval = 15 // default 15 seconds } else if pushInterval > 300 { - return nil, fmt.Errorf("metrics_push_interval must be between 1 and 300 seconds, got %d", pushInterval) + target.client.Close() + return nil, fmt.Errorf("profile %d: metrics_push_interval must be between 1 and 300 seconds, got %d", index, pushInterval) } metricsConfig := &MetricsConfig{ - ServiceName: config.ServiceName, - Endpoint: config.MetricsEndpoint.GetValue(), - Headers: p.headers, - Protocol: config.Protocol, - TLSCACert: config.TLSCACert, - Insecure: config.Insecure, + ServiceName: serviceName, + Endpoint: profile.MetricsEndpoint.GetValue(), + Headers: headers, + Protocol: profile.Protocol, + TLSCACert: profile.TLSCACert, + Insecure: profile.Insecure, PushInterval: pushInterval, } - p.metricsExporter, err = NewMetricsExporter(p.ctx, metricsConfig) + target.metricsExporter, err = NewMetricsExporter(p.ctx, metricsConfig) if err != nil { // Clean up trace client if metrics exporter fails - if p.client != nil { - p.client.Close() + if target.client != nil { + target.client.Close() } - return nil, fmt.Errorf("failed to initialize metrics exporter: %w", err) + return nil, fmt.Errorf("profile %d: failed to initialize metrics exporter: %w", index, err) } - logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", config.MetricsEndpoint.GetValue(), pushInterval) + logger.Info("OTEL metrics push enabled for profile %d, pushing to %s every %d seconds", index, profile.MetricsEndpoint.GetValue(), pushInterval) } - return p, nil + return target, nil } // GetName function for the OTEL plugin @@ -372,50 +536,6 @@ func (p *OtelPlugin) HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, r return chunk, nil } -// ValidateConfig function for the OTEL plugin -func (p *OtelPlugin) ValidateConfig(config any) (*Config, error) { - var otelConfig Config - // Checking if its a string, then we will JSON parse and confirm - if configStr, ok := config.(string); ok { - if err := sonic.Unmarshal([]byte(configStr), &otelConfig); err != nil { - return nil, err - } - } - // Checking if its a map[string]any, then we will JSON parse and confirm - if configMap, ok := config.(map[string]any); ok { - configString, err := sonic.Marshal(configMap) - if err != nil { - return nil, err - } - if err := sonic.Unmarshal([]byte(configString), &otelConfig); err != nil { - return nil, err - } - } - // Checking if its a Config, then we will confirm - if config, ok := config.(*Config); ok { - otelConfig = *config - } - // Validating fields - if otelConfig.CollectorURL == nil || otelConfig.CollectorURL.GetValue() == "" { - return nil, fmt.Errorf("collector url is required") - } - if otelConfig.TraceType == "" { - return nil, fmt.Errorf("trace type is required") - } - if otelConfig.Protocol == "" { - return nil, fmt.Errorf("protocol is required") - } - if otelConfig.PluginSpanFilter != nil { - switch otelConfig.PluginSpanFilter.Mode { - case PluginSpanFilterModeInclude, PluginSpanFilterModeExclude: - default: - return nil, fmt.Errorf("plugin_span_filter.mode %q is invalid: must be %q or %q", - otelConfig.PluginSpanFilter.Mode, PluginSpanFilterModeInclude, PluginSpanFilterModeExclude) - } - } - return &otelConfig, nil -} - // PreLLMHook is a no-op - tracing is handled via the Inject method. // The OTEL plugin receives completed traces from TracingMiddleware. func (p *OtelPlugin) PreLLMHook(_ *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) { @@ -431,7 +551,7 @@ func (p *OtelPlugin) PreLLMHook(_ *schemas.BifrostContext, req *schemas.BifrostR // This is the ONLY place RecordCacheHit is called — do not also emit it from // recordMetricsFromTrace, or cache hits will double-count. func (p *OtelPlugin) PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { - if p.metricsExporter == nil || resp == nil { + if resp == nil || !p.anyMetricsEnabled() { return resp, bifrostErr, nil } extra := resp.GetExtraFields() @@ -449,11 +569,25 @@ func (p *OtelPlugin) PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.Bifr // cache hit has no span to read. attrs := append(buildContextAttrs(ctx, resp, bifrostErr), attribute.String("cache_type", cacheType)) - p.metricsExporter.RecordCacheHit(ctx, attrs...) + for _, t := range p.targets { + if t.metricsExporter != nil { + t.metricsExporter.RecordCacheHit(ctx, attrs...) + } + } return resp, bifrostErr, nil } +// anyMetricsEnabled reports whether at least one profile has a metrics exporter running. +func (p *OtelPlugin) anyMetricsEnabled() bool { + for _, t := range p.targets { + if t.metricsExporter != nil { + return true + } + } + return false +} + // Inject receives a completed trace and sends it to the OTEL collector. // Implements schemas.ObservabilityPlugin interface. // This method is called asynchronously by TracingMiddleware after the response @@ -462,19 +596,26 @@ func (p *OtelPlugin) Inject(ctx context.Context, trace *schemas.Trace) error { if trace == nil { return nil } - // Emit trace to collector if client is initialized - if p.client != nil { - // Convert schemas.Trace to OTEL ResourceSpan - resourceSpan := p.convertTraceToResourceSpan(trace) - // Emit to collector - if err := p.client.Emit(ctx, []*ResourceSpan{resourceSpan}); err != nil { - logger.Error("failed to emit trace %s: %v", trace.TraceID, err) - } - } - // Record metrics if metrics exporter is enabled - if p.metricsExporter != nil { - p.recordMetricsFromTrace(ctx, trace) + // Emit the trace to every configured profile's collector, and record metrics against + // each profile's exporter. Conversion is per-target because the resource service name + // differs per profile; everything else (filter, instance attrs) is shared. + var wg sync.WaitGroup + for _, t := range p.targets { + wg.Add(1) + go func(t *otelTarget) { + defer wg.Done() + if t.client != nil { + resourceSpan := p.convertTraceToResourceSpan(t.serviceName, trace) + if err := t.client.Emit(ctx, []*ResourceSpan{resourceSpan}); err != nil { + logger.Error("failed to emit trace %s to %s: %v", trace.TraceID, t.url, err) + } + } + if t.metricsExporter != nil { + p.recordMetricsFromTrace(ctx, t.metricsExporter, trace) + } + }(t) } + wg.Wait() return nil } @@ -574,8 +715,8 @@ func buildContextAttrs(ctx context.Context, resp *schemas.BifrostResponse, bifro // per llm.call/retry span so fallback attempts and failed retries are counted with // their own provider/model/fallback_index labels. Per-trace metrics (tokens, cost, // TTFT) are recorded once, keyed off the final (latest) attempt span. -func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas.Trace) { - if trace == nil || p.metricsExporter == nil { +func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, exporter *MetricsExporter, trace *schemas.Trace) { + if trace == nil || exporter == nil { return } @@ -587,17 +728,17 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. spanAttrs := buildSpanAttrs(span) - p.metricsExporter.RecordUpstreamRequest(ctx, spanAttrs...) + exporter.RecordUpstreamRequest(ctx, spanAttrs...) if !span.StartTime.IsZero() && !span.EndTime.IsZero() { latencySeconds := span.EndTime.Sub(span.StartTime).Seconds() - p.metricsExporter.RecordUpstreamLatency(ctx, latencySeconds, spanAttrs...) + exporter.RecordUpstreamLatency(ctx, latencySeconds, spanAttrs...) } if span.Status == schemas.SpanStatusError { - p.metricsExporter.RecordErrorRequest(ctx, spanAttrs...) + exporter.RecordErrorRequest(ctx, spanAttrs...) } else { - p.metricsExporter.RecordSuccessRequest(ctx, spanAttrs...) + exporter.RecordSuccessRequest(ctx, spanAttrs...) } if finalSpan == nil || span.EndTime.After(finalSpan.EndTime) { @@ -618,7 +759,7 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. // Record retries used for this request. Read off the final span (the last attempt's // attempt index) so the value is "total retries used", matching the Prometheus side. retries := getIntAttr(attrs, schemas.AttrNumberOfRetries) - p.metricsExporter.RecordRequestRetries(ctx, float64(retries), otelAttrs...) + exporter.RecordRequestRetries(ctx, float64(retries), otelAttrs...) // Record token usage - try both naming conventions inputTokens := getIntAttr(attrs, schemas.AttrPromptTokens) @@ -626,7 +767,7 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. inputTokens = getIntAttr(attrs, schemas.AttrInputTokens) } if inputTokens > 0 { - p.metricsExporter.RecordInputTokens(ctx, int64(inputTokens), otelAttrs...) + exporter.RecordInputTokens(ctx, int64(inputTokens), otelAttrs...) } outputTokens := getIntAttr(attrs, schemas.AttrCompletionTokens) @@ -634,20 +775,20 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. outputTokens = getIntAttr(attrs, schemas.AttrOutputTokens) } if outputTokens > 0 { - p.metricsExporter.RecordOutputTokens(ctx, int64(outputTokens), otelAttrs...) + exporter.RecordOutputTokens(ctx, int64(outputTokens), otelAttrs...) } // Record cost if available cost := getFloat64Attr(attrs, schemas.AttrUsageCost) if cost > 0 { - p.metricsExporter.RecordCost(ctx, cost, otelAttrs...) + exporter.RecordCost(ctx, cost, otelAttrs...) } // Record streaming latency metrics if available ttft := getFloat64Attr(attrs, schemas.AttrTimeToFirstToken) if ttft > 0 { // Convert from nanoseconds to seconds if needed (check the unit) - p.metricsExporter.RecordStreamFirstTokenLatency(ctx, ttft/1e9, otelAttrs...) + exporter.RecordStreamFirstTokenLatency(ctx, ttft/1e9, otelAttrs...) } // Record provider-side prompt cache tokens (cache_read / cache_creation). Unlike the @@ -657,59 +798,59 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. // API-family-specific keys that are mutually exclusive per request, so a fallback read // covers both. if n := getIntAttr(attrs, schemas.AttrUsageCacheReadInputTokens); n > 0 { - p.metricsExporter.RecordCacheReadInputTokens(ctx, int64(n), otelAttrs...) + exporter.RecordCacheReadInputTokens(ctx, int64(n), otelAttrs...) } if n := getIntAttr(attrs, schemas.AttrUsageCacheCreationInputTokens); n > 0 { - p.metricsExporter.RecordCacheWriteInputTokens(ctx, int64(n), otelAttrs...) + exporter.RecordCacheWriteInputTokens(ctx, int64(n), otelAttrs...) } cacheWrite5m := getIntAttr(attrs, schemas.AttrPromptTokenDetailsCachedWrite5m) if cacheWrite5m == 0 { cacheWrite5m = getIntAttr(attrs, schemas.AttrInputTokenDetailsCachedWrite5m) } if cacheWrite5m > 0 { - p.metricsExporter.RecordCacheWriteInputTokens5m(ctx, int64(cacheWrite5m), otelAttrs...) + exporter.RecordCacheWriteInputTokens5m(ctx, int64(cacheWrite5m), otelAttrs...) } cacheWrite1h := getIntAttr(attrs, schemas.AttrPromptTokenDetailsCachedWrite1h) if cacheWrite1h == 0 { cacheWrite1h = getIntAttr(attrs, schemas.AttrInputTokenDetailsCachedWrite1h) } if cacheWrite1h > 0 { - p.metricsExporter.RecordCacheWriteInputTokens1h(ctx, int64(cacheWrite1h), otelAttrs...) + exporter.RecordCacheWriteInputTokens1h(ctx, int64(cacheWrite1h), otelAttrs...) } } -// Cleanup function for the OTEL plugin +// Cleanup function for the OTEL plugin. It shuts down every profile's metrics exporter +// and closes every trace client, returning the first client-close error encountered. func (p *OtelPlugin) Cleanup() error { if p.cancel != nil { p.cancel() } - // Shutdown metrics exporter first - if p.metricsExporter != nil { - if err := p.metricsExporter.Shutdown(context.Background()); err != nil { - logger.Error("failed to shutdown metrics exporter: %v", err) + var firstErr error + for _, t := range p.targets { + // Shutdown metrics exporter first + if t.metricsExporter != nil { + if err := t.metricsExporter.Shutdown(context.Background()); err != nil { + logger.Error("failed to shutdown metrics exporter: %v", err) + } + } + if t.client != nil { + if err := t.client.Close(); err != nil && firstErr == nil { + firstErr = err + } } } - if p.client != nil { - return p.client.Close() - } - return nil + return firstErr } -// GetMetricsExporter returns the metrics exporter for external use (e.g., by telemetry plugin) +// GetMetricsExporter returns the first profile's metrics exporter for external use +// (e.g., by the telemetry plugin). Returns nil if no profile has metrics enabled. func (p *OtelPlugin) GetMetricsExporter() *MetricsExporter { - return p.metricsExporter -} - -// resolveHeaders converts a map of EnvVar header values to plain strings for use in HTTP/gRPC clients. -func resolveHeaders(in map[string]*schemas.EnvVar) map[string]string { - if in == nil { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - out[k] = v.GetValue() + for _, t := range p.targets { + if t.metricsExporter != nil { + return t.metricsExporter + } } - return out + return nil } // firstNonEmpty returns the first non-empty string from the provided values. diff --git a/plugins/otel/metrics.go b/plugins/otel/metrics.go index 57e41bac119..ccde3635c02 100644 --- a/plugins/otel/metrics.go +++ b/plugins/otel/metrics.go @@ -2,11 +2,8 @@ package otel import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "os" - "path/filepath" "sync" "time" @@ -244,43 +241,6 @@ func NewMetricsExporter(ctx context.Context, config *MetricsConfig) (*MetricsExp return m, nil } -// validateCACertPath validates the CA certificate path to prevent path traversal attacks. -// It ensures the path is absolute, cleaned of traversal sequences, and exists as a regular file. -func validateCACertPath(certPath string) error { - if certPath == "" { - return nil - } - - // Clean the path to resolve any .. or . components - cleanPath := filepath.Clean(certPath) - - // Require absolute paths to prevent relative path attacks - if !filepath.IsAbs(cleanPath) { - return fmt.Errorf("TLS CA cert path must be absolute: %s", certPath) - } - - // Check that the cleaned path doesn't differ significantly from input - // (indicates attempted traversal) - if cleanPath != filepath.Clean(filepath.FromSlash(certPath)) { - return fmt.Errorf("invalid TLS CA cert path: %s", certPath) - } - - // Verify the file exists and is not a symlink - info, err := os.Lstat(cleanPath) - if err != nil { - return fmt.Errorf("TLS CA cert path not accessible: %w", err) - } - // Reject symlinks to prevent symlink-based path traversal - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("TLS CA cert path cannot be a symlink: %s", certPath) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("TLS CA cert path is not a regular file: %s", certPath) - } - - return nil -} - func createHTTPExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.Exporter, error) { opts := []otlpmetrichttp.Option{ otlpmetrichttp.WithEndpointURL(config.Endpoint), @@ -290,34 +250,16 @@ func createHTTPExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.E opts = append(opts, otlpmetrichttp.WithHeaders(config.Headers)) } - // TLS priority: custom CA > system roots > insecure - if config.TLSCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(config.TLSCACert); err != nil { - return nil, err - } - // Use custom CA certificate - caCert, err := os.ReadFile(config.TLSCACert) + // HTTP metrics insecure mode disables TLS entirely (unlike the trace HTTP client + // which uses InsecureSkipVerify). buildTLSConfig is bypassed for that case. + if config.TLSCACert == "" && config.Insecure { + opts = append(opts, otlpmetrichttp.WithInsecure()) + } else { + tlsConfig, err := buildTLSConfig(config.TLSCACert, false) if err != nil { - return nil, fmt.Errorf("failed to read CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, + return nil, err } opts = append(opts, otlpmetrichttp.WithTLSClientConfig(tlsConfig)) - } else if config.Insecure { - // Skip TLS entirely - opts = append(opts, otlpmetrichttp.WithInsecure()) - } else { - // Use system root CAs (empty tls.Config uses system roots) - opts = append(opts, otlpmetrichttp.WithTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS12, - })) } return otlpmetrichttp.New(ctx, opts...) @@ -332,37 +274,15 @@ func createGRPCExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.E opts = append(opts, otlpmetricgrpc.WithHeaders(config.Headers)) } - // TLS priority: custom CA > system roots > insecure - if config.TLSCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(config.TLSCACert); err != nil { - return nil, err - } - // Use custom CA certificate with MinVersion - caCert, err := os.ReadFile(config.TLSCACert) - if err != nil { - return nil, fmt.Errorf("failed to read CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - creds := credentials.NewTLS(tlsConfig) - opts = append(opts, otlpmetricgrpc.WithTLSCredentials(creds)) - } else if config.Insecure { - // Skip TLS entirely + // gRPC insecure mode uses plaintext (no TLS at all). buildTLSConfig is bypassed for that case. + if config.TLSCACert == "" && config.Insecure { opts = append(opts, otlpmetricgrpc.WithTLSCredentials(insecure.NewCredentials())) } else { - // Use system root CAs with MinVersion - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, + tlsConfig, err := buildTLSConfig(config.TLSCACert, false) + if err != nil { + return nil, err } - creds := credentials.NewTLS(tlsConfig) - opts = append(opts, otlpmetricgrpc.WithTLSCredentials(creds)) + opts = append(opts, otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig))) } return otlpmetricgrpc.New(ctx, opts...) diff --git a/plugins/otel/profiles_test.go b/plugins/otel/profiles_test.go new file mode 100644 index 00000000000..61ce2380c43 --- /dev/null +++ b/plugins/otel/profiles_test.go @@ -0,0 +1,369 @@ +package otel + +import ( + "context" + "encoding/json" + "testing" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestConfigUnmarshalLegacySingleObject verifies that a legacy single-object config +// (no "profiles" key) is normalized into a one-element Profiles slice, with its +// plugin_span_filter hoisted to the shared Config level. +func TestConfigUnmarshalLegacySingleObject(t *testing.T) { + raw := `{ + "service_name": "svc", + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN"}, + "plugin_span_filter": {"mode": "exclude", "plugins": ["logging"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 1 { + t.Fatalf("Profiles len = %d, want 1", len(cfg.Profiles)) + } + p := cfg.Profiles[0] + if p.ServiceName != "svc" { + t.Errorf("ServiceName = %q, want svc", p.ServiceName) + } + if p.CollectorURL.GetValue() != "localhost:4317" { + t.Errorf("CollectorURL = %q, want localhost:4317", p.CollectorURL.GetValue()) + } + if p.Protocol != ProtocolGRPC { + t.Errorf("Protocol = %q, want grpc", p.Protocol) + } + if p.Headers["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("Headers[Authorization] = %q, want env.OTEL_TOKEN", p.Headers["Authorization"]) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("PluginSpanFilter not hoisted: %+v", cfg.PluginSpanFilter) + } +} + +// TestConfigUnmarshalWrapperArray verifies the canonical wrapper with multiple profiles. +func TestConfigUnmarshalWrapperArray(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "host-a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "host-b:4318", "trace_type": "genai_extension", "protocol": "http"} + ], + "plugin_span_filter": {"mode": "include", "plugins": ["guardrails"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Fatalf("Profiles len = %d, want 2", len(cfg.Profiles)) + } + if cfg.Profiles[0].CollectorURL.GetValue() != "host-a:4317" || cfg.Profiles[0].Protocol != ProtocolGRPC { + t.Errorf("profile 0 wrong: %+v", cfg.Profiles[0]) + } + if cfg.Profiles[1].CollectorURL.GetValue() != "host-b:4318" || cfg.Profiles[1].Protocol != ProtocolHTTP { + t.Errorf("profile 1 wrong: %+v", cfg.Profiles[1]) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeInclude { + t.Fatalf("PluginSpanFilter = %+v, want include", cfg.PluginSpanFilter) + } +} + +// TestConfigUnmarshalHoistFromFirstProfile verifies that when the top-level +// plugin_span_filter is absent in a wrapper, it is hoisted from the first profile +// that carries one. +func TestConfigUnmarshalHoistFromFirstProfile(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", + "plugin_span_filter": {"mode": "exclude", "plugins": ["telemetry"]}} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("PluginSpanFilter not hoisted from profile: %+v", cfg.PluginSpanFilter) + } + if len(cfg.PluginSpanFilter.Plugins) != 1 || cfg.PluginSpanFilter.Plugins[0] != "telemetry" { + t.Errorf("hoisted filter plugins = %v, want [telemetry]", cfg.PluginSpanFilter.Plugins) + } +} + +// TestProfileInsecureDefault verifies Insecure defaults to true when omitted and is +// honored when set explicitly — per profile. +func TestProfileInsecureDefault(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", "insecure": false} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Insecure { + t.Errorf("profile 0 Insecure = false, want true (default)") + } + if cfg.Profiles[1].Insecure { + t.Errorf("profile 1 Insecure = true, want false (explicit)") + } +} + +// TestProfileEnabledDefault verifies Enabled defaults to true when omitted and is honored +// when set explicitly. +func TestProfileEnabledDefault(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", "enabled": false} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Enabled { + t.Errorf("profile 0 Enabled = false, want true (default)") + } + if cfg.Profiles[1].Enabled { + t.Errorf("profile 1 Enabled = true, want false (explicit)") + } +} + +// TestInitSkipsDisabledProfile verifies a disabled profile is not field-validated, +// so an incomplete-but-disabled profile is allowed alongside a valid enabled one. +func TestInitSkipsDisabledProfile(t *testing.T) { + raw := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"enabled": false} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Errorf("profiles len = %d, want 2", len(cfg.Profiles)) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init with disabled incomplete profile: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 1 { + t.Errorf("targets len = %d, want 1", len(plugin.targets)) + } +} + +// TestMarshalForStorageRoundTrip verifies storage marshalling produces the canonical +// wrapper with EnvVar fields flattened to strings, and that it round-trips back. +func TestMarshalForStorageRoundTrip(t *testing.T) { + t.Setenv("OTEL_TOKEN", "secret-token") + t.Setenv("OTEL_SECOND_TOKEN", "second-token") + t.Setenv("OTEL_URL", "collector:4317") + raw := `{ + "profiles": [ + { + "service_name": "svc-a", + "collector_url": "env.OTEL_URL", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN", "X-Tenant": "acme"} + }, + { + "service_name": "svc-b", + "collector_url": "http://collector-b:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "headers": {"Authorization": "env.OTEL_SECOND_TOKEN", "X-Tenant": "beta"} + } + ], + "plugin_span_filter": {"mode": "exclude", "plugins": ["logging"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + stored, err := cfg.MarshalForStorage() + if err != nil { + t.Fatalf("MarshalForStorage: %v", err) + } + + // Storage form must be a wrapper object with a profiles array. + var asMap map[string]any + if err := sonic.Unmarshal(stored, &asMap); err != nil { + t.Fatalf("stored not an object: %v", err) + } + profiles, ok := asMap["profiles"].([]any) + if !ok || len(profiles) != 2 { + t.Fatalf("stored profiles = %v, want 2-element array", asMap["profiles"]) + } + if _, ok := asMap["plugin_span_filter"]; !ok { + t.Errorf("plugin_span_filter missing from stored config") + } + + // Round-trip back into a Config. + var back Config + if err := json.Unmarshal(stored, &back); err != nil { + t.Fatalf("round-trip unmarshal: %v", err) + } + if len(back.Profiles) != 2 { + t.Fatalf("round-trip profiles len = %d, want 2", len(back.Profiles)) + } + if back.PluginSpanFilter == nil || back.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("round-trip plugin_span_filter = %+v, want exclude", back.PluginSpanFilter) + } + if len(back.PluginSpanFilter.Plugins) != 1 || back.PluginSpanFilter.Plugins[0] != "logging" { + t.Errorf("round-trip plugin_span_filter plugins = %v, want [logging]", back.PluginSpanFilter.Plugins) + } + // Profile 0 CollectorURL was an env ref; stored as "env.OTEL_URL" and re-resolved on load. + if back.Profiles[0].CollectorURL.GetValue() != "collector:4317" { + t.Errorf("round-trip profile 0 collector_url = %q, want collector:4317", back.Profiles[0].CollectorURL.GetValue()) + } + if back.Profiles[0].Headers["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("round-trip profile 0 header env ref not preserved: %q", back.Profiles[0].Headers["Authorization"]) + } + if back.Profiles[0].Headers["X-Tenant"] != "acme" { + t.Errorf("round-trip profile 0 literal header lost: %q", back.Profiles[0].Headers["X-Tenant"]) + } + if back.Profiles[1].CollectorURL.GetValue() != "http://collector-b:4318/v1/traces" { + t.Errorf("round-trip profile 1 collector_url = %q, want http://collector-b:4318/v1/traces", back.Profiles[1].CollectorURL.GetValue()) + } + if back.Profiles[1].Headers["Authorization"] != "env.OTEL_SECOND_TOKEN" { + t.Errorf("round-trip profile 1 header env ref not preserved: %q", back.Profiles[1].Headers["Authorization"]) + } + if back.Profiles[1].Headers["X-Tenant"] != "beta" { + t.Errorf("round-trip profile 1 literal header lost: %q", back.Profiles[1].Headers["X-Tenant"]) + } +} + +// TestRedactedHeaders verifies header redaction: env references are preserved while +// literal values are masked. +func TestRedactedHeaders(t *testing.T) { + raw := `{ + "profiles": [ + { + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN", "X-Api-Key": "supersecretvalue123"} + } + ] + }` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Enabled { + t.Fatalf("profile Enabled = false, want true from JSON default") + } + if !cfg.Profiles[0].Insecure { + t.Fatalf("profile Insecure = false, want true from JSON default") + } + red := cfg.Redacted() + got := red.Profiles[0].Headers + if got["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("env header redacted = %q, want env.OTEL_TOKEN (preserved)", got["Authorization"]) + } + if got["X-Api-Key"] == "supersecretvalue123" { + t.Errorf("literal header was not masked") + } + // Original must be untouched. + if cfg.Profiles[0].Headers["X-Api-Key"] != "supersecretvalue123" { + t.Errorf("Redacted mutated the original config") + } +} + +// TestInjectEnvToHeaders verifies env resolution and the missing-var error. +func TestInjectEnvToHeaders(t *testing.T) { + t.Setenv("OTEL_TOKEN", "resolved") + h := map[string]string{"Authorization": "env.OTEL_TOKEN", "X-Plain": "literal"} + if err := injectEnvToHeaders(h); err != nil { + t.Fatalf("injectEnvToHeaders: %v", err) + } + if h["Authorization"] != "resolved" { + t.Errorf("Authorization = %q, want resolved", h["Authorization"]) + } + if h["X-Plain"] != "literal" { + t.Errorf("X-Plain = %q, want literal (unchanged)", h["X-Plain"]) + } + + missing := map[string]string{"Authorization": "env.OTEL_MISSING_VAR"} + if err := injectEnvToHeaders(missing); err == nil { + t.Errorf("expected error for missing env var, got nil") + } +} + +// TestInitMultiProfileValidation verifies per-profile validation errors. +func TestInitMultiProfileValidation(t *testing.T) { + // Missing profiles entirely. + var empty Config + if err := sonic.Unmarshal([]byte(`{"profiles": []}`), &empty); err != nil { + t.Fatalf("unmarshal empty profiles: %v", err) + } + if _, err := Init(context.Background(), &empty, testLogger{}, nil, ""); err == nil { + t.Errorf("expected error for empty profiles") + } + + // Second profile missing protocol. + bad := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension"} + ]}` + var badCfg Config + if err := sonic.Unmarshal([]byte(bad), &badCfg); err != nil { + t.Fatalf("unmarshal bad profiles: %v", err) + } + if _, err := Init(context.Background(), &badCfg, testLogger{}, nil, ""); err == nil { + t.Errorf("expected error for profile missing protocol") + } + + // Valid multi-profile. + good := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4318", "trace_type": "genai_extension", "protocol": "http"} + ]}` + var cfg Config + if err := sonic.Unmarshal([]byte(good), &cfg); err != nil { + t.Fatalf("unmarshal valid profiles: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Errorf("profiles len = %d, want 2", len(cfg.Profiles)) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init valid profiles: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 2 { + t.Errorf("targets len = %d, want 2", len(plugin.targets)) + } +} + +type testLogger struct{} + +func (testLogger) Debug(string, ...any) {} +func (testLogger) Info(string, ...any) {} +func (testLogger) Warn(string, ...any) {} +func (testLogger) Error(string, ...any) {} +func (testLogger) Fatal(string, ...any) {} +func (testLogger) SetLevel(schemas.LogLevel) {} +func (testLogger) SetOutputType(schemas.LoggerOutputType) {} +func (testLogger) LogHTTPRequest(schemas.LogLevel, string) schemas.LogEventBuilder { + return schemas.NoopLogEvent +} diff --git a/plugins/otel/utils.go b/plugins/otel/utils.go new file mode 100644 index 00000000000..7dec1977e1a --- /dev/null +++ b/plugins/otel/utils.go @@ -0,0 +1,91 @@ +package otel + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "path/filepath" + "strings" +) + +// injectEnvToHeaders converts any headers that start with "env." with their corresponding environment variable value +// errors out if any environment variable is not found +func injectEnvToHeaders(headers map[string]string) error { + if headers == nil { + return nil + } + for k, v := range headers { + if envKey, ok := strings.CutPrefix(v, "env."); ok { + envVal, found := os.LookupEnv(envKey) + if !found { + return fmt.Errorf("environment variable %s not found", envKey) + } + headers[k] = envVal + } + } + return nil +} + +// validateCACertPath validates the CA certificate path to prevent path traversal attacks. +// It ensures the path is absolute, cleaned of traversal sequences, and exists as a regular file. +func validateCACertPath(certPath string) error { + if certPath == "" { + return nil + } + + // Clean the path to resolve any .. or . components + cleanPath := filepath.Clean(certPath) + + // Require absolute paths to prevent relative path attacks + if !filepath.IsAbs(cleanPath) { + return fmt.Errorf("TLS CA cert path must be absolute: %s", certPath) + } + + // Verify the file exists and is not a symlink + info, err := os.Lstat(cleanPath) + if err != nil { + return fmt.Errorf("TLS CA cert path not accessible: %w", err) + } + // Reject symlinks to prevent symlink-based path traversal + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("TLS CA cert path cannot be a symlink: %s", certPath) + } + // Ensure path is a regular file, not directories, sockets, pipes, devices, etc. + if !info.Mode().IsRegular() { + return fmt.Errorf("TLS CA cert path is not a regular file: %s", certPath) + } + + return nil +} + +// Builds a TLS config with custom CA, insecure mode, or system roots CAs +// - use a custom CA pool if tlsCACert is provided +// - otherwise skip verification if insecureMode is enabled +// - otherwise use the system root CAs +func buildTLSConfig(tlsCACert string, insecureMode bool) (*tls.Config, error) { + cfg := tls.Config{ + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + // TLS priority: custom CA > system roots > insecure + if tlsCACert != "" { + if err := validateCACertPath(tlsCACert); err != nil { + return nil, err + } + caCert, err := os.ReadFile(tlsCACert) + if err != nil { + return nil, fmt.Errorf("failed to load provided CA cert: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to add provided CA cert") + } + cfg.RootCAs = caCertPool + } else if insecureMode { + cfg.InsecureSkipVerify = true // #nosec G402 + } + + return &cfg, nil +} diff --git a/transports/config.schema.json b/transports/config.schema.json index 1a8225e9bde..779ae3cd824 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -1553,94 +1553,11 @@ "required": ["config"], "properties": { "config": { - "type": "object", - "description": "Configuration for the OpenTelemetry plugin", - "properties": { - "service_name": { - "type": "string", - "description": "Service name to be used for tracing", - "default": "bifrost" - }, - "collector_url": { - "type": "string", - "description": "URL of the OpenTelemetry collector", - "anyOf": [ - { - "format": "uri" - }, - { - "pattern": "^[^:\\s]+:\\d+$" - } - ] - }, - "trace_type": { - "type": "string", - "description": "Type of trace to use for the OTEL collector", - "enum": ["genai_extension", "vercel", "open_inference"] - }, - "protocol": { - "type": "string", - "description": "Protocol to use for the OTEL collector", - "enum": ["http", "grpc"] - }, - "metrics_enabled": { - "type": "boolean", - "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", - "default": false - }, - "metrics_endpoint": { - "type": "string", - "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)", - "anyOf": [ - { - "format": "uri" - }, - { - "pattern": "^[^:\\s]+:\\d+$" - } - ] - }, - "metrics_push_interval": { - "type": "integer", - "description": "Metrics push interval in seconds", - "default": 15, - "minimum": 1, - "maximum": 300 - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Custom headers for the collector. Supports env.VAR_NAME prefix for environment variable substitution." - }, - "tls_ca_cert": { - "type": "string", - "description": "Path to TLS CA certificate file" - }, - "insecure": { - "type": "boolean", - "description": "Skip TLS verification (ignored if tls_ca_cert is set)" - }, - "plugin_span_filter": { - "type": "object", - "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", - "properties": { - "mode": { - "type": "string", - "enum": ["include", "exclude"] - }, - "plugins": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["mode", "plugins"], - "additionalProperties": false - } - }, - "required": ["collector_url", "trace_type", "protocol"], - "additionalProperties": false + "anyOf": [ + { "$ref": "#/$defs/otel_profile_config" }, + { "$ref": "#/$defs/otel_profiles_config" } + ], + "description": "Configuration for the OpenTelemetry plugin. Supports the legacy single-profile shape or the profiles wrapper for multiple collectors." } } } @@ -1730,6 +1647,153 @@ }, "additionalProperties": false, "$defs": { + "otel_endpoint": { + "type": "string", + "anyOf": [ + { + "format": "uri" + }, + { + "pattern": "^[^:\\s]+:\\d+$" + }, + { + "pattern": "^env\\.[A-Za-z_][A-Za-z0-9_]*$" + } + ] + }, + "otel_plugin_span_filter": { + "type": "object", + "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", + "properties": { + "mode": { + "type": "string", + "enum": ["include", "exclude"] + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "plugins"], + "additionalProperties": false + }, + "otel_profile_config": { + "type": "object", + "description": "OpenTelemetry export profile. This legacy single-profile shape is still accepted directly as the plugin config.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this profile exports traces and metrics", + "default": true + }, + "service_name": { + "type": "string", + "description": "Service name to be used for tracing", + "default": "bifrost" + }, + "collector_url": { + "$ref": "#/$defs/otel_endpoint", + "description": "URL of the OpenTelemetry collector" + }, + "trace_type": { + "type": "string", + "description": "Type of trace to use for the OTEL collector", + "enum": ["genai_extension", "vercel", "open_inference"] + }, + "protocol": { + "type": "string", + "description": "Protocol to use for the OTEL collector", + "enum": ["http", "grpc"] + }, + "metrics_enabled": { + "type": "boolean", + "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", + "default": false + }, + "metrics_endpoint": { + "$ref": "#/$defs/otel_endpoint", + "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" + }, + "metrics_push_interval": { + "type": "integer", + "description": "Metrics push interval in seconds", + "default": 15, + "minimum": 1, + "maximum": 300 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers for the collector. Supports env.VAR_NAME prefix for environment variable substitution." + }, + "tls_ca_cert": { + "type": "string", + "description": "Path to TLS CA certificate file" + }, + "insecure": { + "type": "boolean", + "description": "Skip TLS verification (ignored if tls_ca_cert is set)", + "default": true + }, + "plugin_span_filter": { + "$ref": "#/$defs/otel_plugin_span_filter" + } + }, + "allOf": [ + { + "if": { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] + } + }, + "then": { + "required": ["collector_url", "trace_type", "protocol"] + } + }, + { + "if": { + "properties": { + "metrics_enabled": { + "const": true + } + }, + "required": ["metrics_enabled"] + }, + "then": { + "required": ["metrics_endpoint"] + } + } + ], + "additionalProperties": false + }, + "otel_profiles_config": { + "type": "object", + "description": "OpenTelemetry plugin configuration with one or more export profiles.", + "properties": { + "profiles": { + "type": "array", + "description": "OpenTelemetry export profiles", + "items": { + "$ref": "#/$defs/otel_profile_config" + }, + "minItems": 1 + }, + "plugin_span_filter": { + "$ref": "#/$defs/otel_plugin_span_filter" + } + }, + "required": ["profiles"], + "additionalProperties": false + }, "feature_flags_config": { "type": "object", "description": "Boot-time overrides for feature flags. Flags themselves are declared in code via featureflags.Register; this block only sets initial values. Anything set here is rendered as locked in the UI - operators must edit the config (or Helm values) to change it.", diff --git a/ui/app/workspace/observability/fragments/otelFormFragment.tsx b/ui/app/workspace/observability/fragments/otelFormFragment.tsx index 15d4973f3a1..7a67012f81d 100644 --- a/ui/app/workspace/observability/fragments/otelFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/otelFormFragment.tsx @@ -1,5 +1,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { EnvVarInput } from "@/components/ui/envVarInput"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { HeadersTable } from "@/components/ui/headersTable"; @@ -8,28 +9,40 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { otelFormSchema, type EnvVar, type OtelFormSchema } from "@/lib/types/schemas"; -import { toEnvVarFormValue, toEnvVarMapFormValue } from "@/lib/utils/envVarForm"; +import { emptyEnvVar, toEnvVarFormValue, toEnvVarMapFormValue } from "@/lib/utils/envVarForm"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Trash2 } from "lucide-react"; +import { ChevronDown, Plus, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; -import { useForm, type Resolver } from "react-hook-form"; +import { useFieldArray, useForm, type Control, type Resolver, type UseFormReturn } from "react-hook-form"; + +// ProfileForm is a single profile's form shape, derived from the form schema. +type ProfileForm = OtelFormSchema["profiles"][number]; + +// StoredOtelProfile is one profile as persisted/returned by the API (headers are strings, +// EnvVar fields may be plain strings or full objects). +interface StoredOtelProfile { + enabled?: boolean; + service_name?: string; + collector_url?: string | EnvVar; + headers?: Record; + trace_type?: "genai_extension" | "vercel" | "open_inference"; + protocol?: "http" | "grpc"; + tls_ca_cert?: string; + insecure?: boolean; + metrics_enabled?: boolean; + metrics_endpoint?: string | EnvVar; + metrics_push_interval?: number; +} + +// StoredOtelConfig is either the canonical { profiles: [...] } wrapper or a legacy single +// profile object (no "profiles" key). +type StoredOtelConfig = (StoredOtelProfile & { profiles?: StoredOtelProfile[] }) | undefined; interface OtelFormFragmentProps { currentConfig?: { enabled?: boolean; - service_name?: string; - collector_url?: string | EnvVar; - headers?: Record; - trace_type?: "genai_extension" | "vercel" | "open_inference"; - protocol?: "http" | "grpc"; - // TLS configuration - tls_ca_cert?: string; - insecure?: boolean; - // Metrics push configuration - metrics_enabled?: boolean; - metrics_endpoint?: string | EnvVar; - metrics_push_interval?: number; + config?: StoredOtelConfig; }; onSave: (config: OtelFormSchema) => Promise; onDelete?: () => void; @@ -37,22 +50,83 @@ interface OtelFormFragmentProps { isLoading?: boolean; } -const buildDefaults = (initialConfig?: OtelFormFragmentProps["currentConfig"]): OtelFormSchema => ({ - enabled: initialConfig?.enabled ?? true, - otel_config: { - service_name: initialConfig?.service_name ?? "bifrost", - collector_url: toEnvVarFormValue(initialConfig?.collector_url), - headers: toEnvVarMapFormValue(initialConfig?.headers), - trace_type: initialConfig?.trace_type ?? "genai_extension", - protocol: initialConfig?.protocol ?? "http", - tls_ca_cert: initialConfig?.tls_ca_cert ?? "", - insecure: initialConfig?.insecure ?? true, - metrics_enabled: initialConfig?.metrics_enabled ?? false, - metrics_endpoint: toEnvVarFormValue(initialConfig?.metrics_endpoint), - metrics_push_interval: initialConfig?.metrics_push_interval ?? 15, +const traceTypeOptions: { + value: string; + label: string; + disabled?: boolean; + disabledReason?: string; +}[] = [ + { value: "genai_extension", label: "OTel GenAI Extension (Recommended)" }, + { + value: "vercel", + label: "Vercel AI SDK", + disabled: true, + disabledReason: "Coming soon", }, + { + value: "open_inference", + label: "Arize OpenInference", + disabled: true, + disabledReason: "Coming soon", + }, +]; +const protocolOptions: { + value: string; + label: string; + disabled?: boolean; + disabledReason?: string; +}[] = [ + { value: "http", label: "HTTP" }, + { value: "grpc", label: "GRPC" }, +]; + +// emptyProfile returns a fresh profile with the same defaults a newly created collector uses. +const emptyProfile = (): ProfileForm => ({ + enabled: true, + service_name: "bifrost", + collector_url: emptyEnvVar(), + headers: {}, + trace_type: "genai_extension", + protocol: "http", + tls_ca_cert: "", + insecure: true, + metrics_enabled: false, + metrics_endpoint: emptyEnvVar(), + metrics_push_interval: 15, +}); + +// toProfileForm normalizes a stored profile into the EnvVar-based form representation. +const toProfileForm = (p?: StoredOtelProfile): ProfileForm => ({ + enabled: p?.enabled ?? true, + service_name: p?.service_name ?? "bifrost", + collector_url: toEnvVarFormValue(p?.collector_url), + headers: toEnvVarMapFormValue(p?.headers), + trace_type: p?.trace_type ?? "genai_extension", + protocol: p?.protocol ?? "http", + tls_ca_cert: p?.tls_ca_cert ?? "", + insecure: p?.insecure ?? true, + metrics_enabled: p?.metrics_enabled ?? false, + metrics_endpoint: toEnvVarFormValue(p?.metrics_endpoint), + metrics_push_interval: p?.metrics_push_interval ?? 15, }); +// buildDefaults handles both stored shapes: the { profiles: [...] } wrapper and the legacy +// single-object config. Always yields at least one profile. +const buildDefaults = (initial?: OtelFormFragmentProps["currentConfig"]): OtelFormSchema => { + const cfg = initial?.config; + let profiles: ProfileForm[]; + if (cfg && Array.isArray(cfg.profiles)) { + profiles = cfg.profiles.map(toProfileForm); + } else if (cfg && (cfg.collector_url || cfg.service_name || cfg.protocol || cfg.trace_type)) { + // Legacy single-object config. + profiles = [toProfileForm(cfg)]; + } else { + profiles = []; + } + if (profiles.length === 0) profiles = [emptyProfile()]; + return { enabled: initial?.enabled ?? true, profiles }; +}; + export function OtelFormFragment({ currentConfig: initialConfig, onSave, @@ -62,306 +136,80 @@ export function OtelFormFragment({ }: OtelFormFragmentProps) { const hasOtelAccess = useRbac(RbacResource.Observability, RbacOperation.Update); const [isSaving, setIsSaving] = useState(false); - const form = useForm({ - resolver: zodResolver(otelFormSchema) as Resolver, + const [profileOpenState, setProfileOpenState] = useState>({}); + const form = useForm({ + resolver: zodResolver(otelFormSchema) as Resolver, mode: "onChange", reValidateMode: "onChange", defaultValues: buildDefaults(initialConfig), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "profiles", + }); + const onSubmit = (data: OtelFormSchema) => { setIsSaving(true); onSave(data).finally(() => setIsSaving(false)); }; - // Re-run validation on collector_url when protocol changes so cross-field - // refinement in the schema is applied immediately - const protocol = form.watch("otel_config.protocol"); - const metricsEnabled = form.watch("otel_config.metrics_enabled"); - useEffect(() => { - if (form.getValues("enabled") === false) return; - form.trigger("otel_config.collector_url"); - // Also re-validate metrics_endpoint when protocol changes - if (metricsEnabled) { - form.trigger("otel_config.metrics_endpoint"); - } - }, [protocol, form, metricsEnabled]); + const handleProfileOpenChange = (index: number, open: boolean) => { + setProfileOpenState((prev) => ({ ...prev, [index]: open })); + }; - // Re-run validation on metrics_endpoint when metrics_enabled changes - useEffect(() => { - if (metricsEnabled) { - form.trigger("otel_config.metrics_endpoint"); - } - }, [metricsEnabled, form]); + const handleRemoveProfile = (index: number) => { + remove(index); + setProfileOpenState((prev) => { + const next: Record = {}; + for (const [key, value] of Object.entries(prev)) { + const profileIndex = Number(key); + if (profileIndex < index) { + next[profileIndex] = value; + } else if (profileIndex > index) { + next[profileIndex - 1] = value; + } + } + return next; + }); + }; useEffect(() => { form.reset(buildDefaults(initialConfig)); }, [form, initialConfig]); - const traceTypeOptions: { value: string; label: string; disabled?: boolean; disabledReason?: string }[] = [ - { value: "genai_extension", label: "OTel GenAI Extension (Recommended)" }, - { value: "vercel", label: "Vercel AI SDK", disabled: true, disabledReason: "Coming soon" }, - { value: "open_inference", label: "Arize OpenInference", disabled: true, disabledReason: "Coming soon" }, - ]; - const protocolOptions: { value: string; label: string; disabled?: boolean; disabledReason?: string }[] = [ - { value: "http", label: "HTTP" }, - { value: "grpc", label: "GRPC" }, - ]; - return (
- {/* OTEL Configuration */} -
-
- ( - - Service Name - If kept empty, the service name will be set to "bifrost" - - - - - - )} - /> - ( - - OTLP Collector URL -
- {form.watch("otel_config.protocol") === "http" ? "http(s)://:/v1/traces" : ":"} -
- - - - -
- )} - /> - + {fields.map((field, index) => ( + ( - - - - - - - )} + index={index} + hasOtelAccess={hasOtelAccess} + canRemove={fields.length > 1} + open={profileOpenState[index] ?? true} + onOpenChange={(open) => handleProfileOpenChange(index, open)} + onRemove={() => handleRemoveProfile(index)} /> -
- ( - - Format - - - - )} - /> - - ( - - Protocol - - - - )} - /> -
- - {/* TLS Configuration */} -
- ( - -
-
- Insecure (Skip TLS) - - Skip TLS verification. Disable this to use TLS with system root CAs or a custom CA certificate. - -
-
- { - field.onChange(checked); - if (checked) { - form.setValue("otel_config.tls_ca_cert", ""); - } - }} - disabled={!hasOtelAccess} - /> -
-
-
- )} - /> - {!form.watch("otel_config.insecure") && ( - ( - - TLS CA Certificate Path - - File path to the CA certificate on the Bifrost server. Leave empty to use system root CAs. - - - - - - - )} - /> - )} -
-
+ ))}
- {/* Metrics Push Configuration */} -
- ( - -
-
-

- Enable Metrics Export BETA -

-

- Push metrics to an OTEL Collector for proper aggregation in cluster deployments -

-
-
- -
-
-
- )} - /> - - {form.watch("otel_config.metrics_enabled") && ( -
- ( - - Metrics Endpoint -
- {form.watch("otel_config.protocol") === "http" ? "http(s)://:/v1/metrics" : ":"} -
- - - - -
- )} - /> - - ( - - Push Interval (seconds) - - field.onChange(e.target.value === "" ? null : Number(e.target.value))} - /> - - How often to push metrics (1-300 seconds) - - - )} - /> -
- )} -
+ {/* Form Actions */} -
+
); +} + +interface OtelProfileSectionProps { + form: UseFormReturn; + control: Control; + index: number; + hasOtelAccess: boolean; + canRemove: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onRemove: () => void; +} + +// OtelProfileSection renders one collapsible profile. The header stays visible when collapsed +// and surfaces the profile identity plus its enable toggle and remove control. +function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, open, onOpenChange, onRemove }: OtelProfileSectionProps) { + const base = `profiles.${index}` as const; + const protocol = form.watch(`${base}.protocol`); + const metricsEnabled = form.watch(`${base}.metrics_enabled`); + const insecure = form.watch(`${base}.insecure`); + const enabled = form.watch(`${base}.enabled`); + const serviceName = form.watch(`${base}.service_name`); + const collectorUrl = form.watch(`${base}.collector_url`); + + // Surface whether this profile currently has any validation errors so the user can find it + // without expanding every collapsed section. + const hasError = Boolean(form.formState.errors?.profiles?.[index]); + + const collectorPreview = collectorUrl?.from_env ? collectorUrl.env_var : collectorUrl?.value; + + return ( + +
+ + + + + ( + + + + + + )} + /> + + {canRemove && ( + + )} +
+ + +
+ ( + + Service Name + If kept empty, the service name will be set to "bifrost" + + + + + + )} + /> + ( + + OTLP Collector URL +
+ {protocol === "http" ? "http(s)://:/v1/traces" : ":"} +
+ + + + +
+ )} + /> + ( + + + + + + + )} + /> +
+ ( + + Format + + + + )} + /> + + ( + + Protocol + + + + )} + /> +
+ + {/* TLS Configuration */} +
+ ( + +
+
+ Insecure (Skip TLS) + + Skip TLS verification. Disable this to use TLS with system root CAs or a custom CA certificate. + +
+
+ { + field.onChange(checked); + if (checked) { + form.setValue(`${base}.tls_ca_cert`, ""); + } + }} + disabled={!hasOtelAccess} + /> +
+
+
+ )} + /> + {!insecure && ( + ( + + TLS CA Certificate Path + + File path to the CA certificate on the Bifrost server. Leave empty to use system root CAs. + + + + + + + )} + /> + )} +
+ + {/* Metrics Push Configuration */} +
+ ( + +
+
+

+ Enable Metrics Export BETA +

+

+ Push metrics to an OTEL Collector for proper aggregation in cluster deployments +

+
+
+ +
+
+
+ )} + /> + + {metricsEnabled && ( +
+ ( + + Metrics Endpoint +
+ {protocol === "http" ? "http(s)://:/v1/metrics" : ":"} +
+ + + + +
+ )} + /> + + ( + + Push Interval (seconds) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + /> + + How often to push metrics (1-300 seconds) + + + )} + /> +
+ )} +
+
+
+
+ ); } \ No newline at end of file diff --git a/ui/app/workspace/observability/views/plugins/otelView.tsx b/ui/app/workspace/observability/views/plugins/otelView.tsx index 487b2460b2d..0eb3b2293af 100644 --- a/ui/app/workspace/observability/views/plugins/otelView.tsx +++ b/ui/app/workspace/observability/views/plugins/otelView.tsx @@ -1,5 +1,6 @@ import { getErrorMessage, useAppSelector, useUpdatePluginMutation } from "@/lib/store"; -import { OtelConfigSchema, OtelFormSchema } from "@/lib/types/schemas"; +import { OtelFormSchema } from "@/lib/types/schemas"; +import { toHeaderStringMap } from "@/lib/utils/envVarForm"; import { useMemo } from "react"; import { toast } from "sonner"; import { OtelFormFragment } from "../../fragments/otelFormFragment"; @@ -11,20 +12,23 @@ interface OtelViewProps { export default function OtelView({ onDelete, isDeleting }: OtelViewProps) { const selectedPlugin = useAppSelector((state) => state.plugin.selectedPlugin); - const currentConfig = useMemo( - () => ({ ...((selectedPlugin?.config as OtelConfigSchema) ?? {}), enabled: selectedPlugin?.enabled }), - [selectedPlugin], - ); + const currentConfig = useMemo(() => ({ config: selectedPlugin?.config, enabled: selectedPlugin?.enabled }), [selectedPlugin]); const [updatePlugin] = useUpdatePluginMutation(); - const baseUrl = `${window.location.protocol}//${window.location.host}`; const handleOtelConfigSave = (config: OtelFormSchema): Promise => { + // The backend stores headers as a plain "env.VAR"/literal string map, so flatten the + // EnvVar form values here. The config is sent as the { profiles: [...] } wrapper. + const profiles = config.profiles.map((profile) => ({ + ...profile, + headers: toHeaderStringMap(profile.headers), + })); + return new Promise((resolve, reject) => { updatePlugin({ name: "otel", data: { enabled: config.enabled, - config: config.otel_config, + config: { profiles }, }, }) .unwrap() diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index d97a06a5905..e74e7e3a50f 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -739,6 +739,8 @@ export type BetaHeadersFormSchema = z.infer; // OTEL Configuration Schema export const otelConfigSchema = z .object({ + // Per-profile enable toggle. A disabled profile exports nothing and is not validated. + enabled: z.boolean().default(true), service_name: z.string().optional(), collector_url: envVarSchema.default({ value: "", env_var: "", from_env: false }), trace_type: z @@ -761,6 +763,9 @@ export const otelConfigSchema = z metrics_push_interval: z.number().int().min(1).max(300).default(15), }) .superRefine((data, ctx) => { + // A disabled profile is not sent anywhere, so skip all validation for it. + if (data.enabled === false) return; + const protocol = data.protocol; const hostPortRegex = /^(?!https?:\/\/)([a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/; @@ -810,6 +815,15 @@ export const otelConfigSchema = z return true; }; + // Collector address is required for an enabled profile. + if (!isEnvVarSet(data.collector_url)) { + ctx.addIssue({ + code: "custom", + path: ["collector_url"], + message: "Collector address is required", + }); + } + // Validate collector_url format — skip format check for env var references const collectorUrl = (data.collector_url?.value || "").trim(); if (collectorUrl && !data.collector_url?.from_env && protocol === "http") { @@ -835,23 +849,12 @@ export const otelConfigSchema = z } }); -// OTEL form schema for the OtelFormFragment -export const otelFormSchema = z - .object({ - enabled: z.boolean().default(true), - otel_config: otelConfigSchema, - }) - .superRefine((data, ctx) => { - if (data.enabled) { - if (!isEnvVarSet(data.otel_config.collector_url)) { - ctx.addIssue({ - code: "custom", - path: ["otel_config", "collector_url"], - message: "Collector address is required", - }); - } - } - }); +// OTEL form schema for the OtelFormFragment. The plugin itself is gated by `enabled`; +// it carries one or more export profiles, each independently enable-able. +export const otelFormSchema = z.object({ + enabled: z.boolean().default(true), + profiles: z.array(otelConfigSchema).min(1, "At least one profile is required"), +}); // Maxim Configuration Schema export const maximConfigSchema = z.object({ diff --git a/ui/lib/utils/envVarForm.ts b/ui/lib/utils/envVarForm.ts index 207449a72fd..36569c96db9 100644 --- a/ui/lib/utils/envVarForm.ts +++ b/ui/lib/utils/envVarForm.ts @@ -26,6 +26,28 @@ export const toEnvVarMapFormValue = (map?: Record): Rec return Object.fromEntries(Object.entries(map).map(([k, v]) => [k, toEnvVarFormValue(v)])); }; +// toEnvRefString flattens an EnvVar form value to its persisted string form: +// the "env.VAR" reference when sourced from the environment, otherwise the literal value. +export const toEnvRefString = (field?: EnvVar): string => { + if (!field) return ""; + if (field.from_env) return (field.env_var || "").trim(); + return (field.value || "").trim(); +}; + +// toHeaderStringMap converts a map of EnvVar header values into the plain-string map the +// OTEL backend expects (Profile.Headers is map[string]string using the "env.VAR" convention). +// Empty entries are dropped. +export const toHeaderStringMap = (headers?: Record): Record => { + if (!headers) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + const key = k.trim(); + const value = toEnvRefString(v); + if (key && value) out[key] = value; + } + return out; +}; + export const toOptionalEnvVarPayload = (field?: { value?: string; env_var?: string; from_env?: boolean }) => { const envVar = field?.env_var?.trim(); const value = field?.value?.trim();