diff --git a/helm-charts/bifrost/README.md b/helm-charts/bifrost/README.md index dc4a12bd6a5..4e3bb4305b9 100644 --- a/helm-charts/bifrost/README.md +++ b/helm-charts/bifrost/README.md @@ -8,6 +8,11 @@ Official Helm charts for deploying [Bifrost](https://github.com/maximhq/bifrost) ## Changelog +### Upcoming + +- Added `traces_enabled` to OTEL config (single-profile and `profiles[*]`). When `false`, no traces are exported and `collector_url` is not required, giving a metrics-only profile. Renders into `traces_enabled`; defaults to `true`. +- Added `trace_headers` and `metrics_headers` to OTEL config (single-profile and `profiles[*]`). Common `headers` still go to both endpoints; these are overlaid on top for the trace / metrics endpoint respectively (same key wins), e.g. a Databricks table name required only on metrics. Render into `trace_headers` / `metrics_headers`. + ### 2.1.33 - Fixed Helm schema validation failure for multi-profile OTEL configs (`bifrost.plugins.otel.config.profiles`), introduced by the `export_timeout` default in 2.1.32. diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index e5acf5ad056..b83f27c5d00 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -1372,6 +1372,9 @@ false {{- if $inputConfig.service_name }} {{- $_ := set $otelConfig "service_name" $inputConfig.service_name }} {{- end }} +{{- if hasKey $inputConfig "traces_enabled" }} +{{- $_ := set $otelConfig "traces_enabled" $inputConfig.traces_enabled }} +{{- end }} {{- if $inputConfig.collector_url }} {{- $_ := set $otelConfig "collector_url" $inputConfig.collector_url }} {{- end }} @@ -1396,6 +1399,12 @@ false {{- if $inputConfig.headers }} {{- $_ := set $otelConfig "headers" $inputConfig.headers }} {{- end }} +{{- if $inputConfig.trace_headers }} +{{- $_ := set $otelConfig "trace_headers" $inputConfig.trace_headers }} +{{- end }} +{{- if $inputConfig.metrics_headers }} +{{- $_ := set $otelConfig "metrics_headers" $inputConfig.metrics_headers }} +{{- end }} {{- if $inputConfig.tls_ca_cert }} {{- $_ := set $otelConfig "tls_ca_cert" $inputConfig.tls_ca_cert }} {{- end }} @@ -1892,13 +1901,19 @@ Call this template at the beginning of deployment/stateful templates {{- $profileEnabled = $profile.enabled }} {{- end }} {{- if $profileEnabled }} +{{- $tracesEnabled := true }} +{{- if hasKey $profile "traces_enabled" }} +{{- $tracesEnabled = $profile.traces_enabled }} +{{- end }} +{{- if $tracesEnabled }} {{- if not $profile.collector_url }} -{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].collector_url is required for enabled OTEL profiles." $idx) }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].collector_url is required when traces_enabled is true." $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) }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].trace_type is required when traces_enabled is true. Supported values: genai_extension, vercel, open_inference" $idx) }} {{- end }} -{{- if not $profile.protocol }} +{{- end }} +{{- if and (or $tracesEnabled $profile.metrics_enabled) (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) }} @@ -1907,14 +1922,20 @@ Call this template at the beginning of deployment/stateful templates {{- end }} {{- end }} {{- else }} +{{- $tracesEnabled := true }} +{{- if hasKey $otelInputConfig "traces_enabled" }} +{{- $tracesEnabled = $otelInputConfig.traces_enabled }} +{{- end }} +{{- if $tracesEnabled }} {{- 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." }} +{{- fail "ERROR: bifrost.plugins.otel.config.collector_url is required when traces_enabled is true. Provide the URL of your OpenTelemetry collector." }} {{- end }} {{- 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" }} +{{- fail "ERROR: bifrost.plugins.otel.config.trace_type is required when traces_enabled is true. Supported values: genai_extension, vercel, open_inference" }} +{{- end }} {{- end }} -{{- if not $otelInputConfig.protocol }} -{{- fail "ERROR: bifrost.plugins.otel.config.protocol is required when OTEL plugin is enabled. Supported values: http, grpc" }} +{{- if and (or $tracesEnabled $otelInputConfig.metrics_enabled) (not $otelInputConfig.protocol) }} +{{- fail "ERROR: bifrost.plugins.otel.config.protocol is required. 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." }} diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index b903152c559..49a46d971e0 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -1059,11 +1059,12 @@ "type": "object", "properties": { "service_name": { - "type": "string" + "type": "string", + "description": "Name of the service to report to Datadog. Supports env.VAR_NAME prefix for environment variable substitution (e.g. env.BIFROST_DD_SERVICE)" }, "ml_app": { "type": "string", - "description": "ML application name for Datadog LLM Observability grouping (defaults to service_name)" + "description": "ML application name for Datadog LLM Observability grouping (defaults to service_name). Supports env.VAR_NAME prefix for environment variable substitution (e.g. env.BIFROST_DD_ML_APP)" }, "agent_addr": { "type": "string", @@ -1090,10 +1091,12 @@ "description": "DogStatsD server port for metrics, used with dogstatsd_host (agent mode only). Supports env.VAR_NAME prefix. Defaults to 8125; has no effect when dogstatsd_host is unset" }, "env": { - "type": "string" + "type": "string", + "description": "Environment tag (e.g. production, staging). Supports env.VAR_NAME prefix for environment variable substitution (e.g. env.BIFROST_DD_ENV)" }, "version": { - "type": "string" + "type": "string", + "description": "Service version tag. Supports env.VAR_NAME prefix for environment variable substitution (e.g. env.BIFROST_DD_VERSION)" }, "custom_tags": { "type": "object", @@ -4897,6 +4900,11 @@ "description": "Whether this profile exports traces and metrics", "default": true }, + "traces_enabled": { + "type": "boolean", + "description": "Enable trace export for this profile. When false, no traces are sent and collector_url is not required, giving a metrics-only profile (pair with metrics_enabled).", + "default": true + }, "service_name": { "type": "string", "description": "Service name to be used for tracing", @@ -4944,7 +4952,21 @@ "additionalProperties": { "type": "string" }, - "description": "Custom headers for the collector (supports env.VAR_NAME prefix)" + "description": "Custom headers sent to both the trace and metrics endpoints (supports env.VAR_NAME prefix)" + }, + "trace_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Extra headers sent only to the trace endpoint, overlaid on headers (same key wins). Supports env.VAR_NAME prefix." + }, + "metrics_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Extra headers sent only to the metrics endpoint, overlaid on headers (same key wins). Useful when a collector requires a per-signal header, e.g. a Databricks table name. Supports env.VAR_NAME prefix." }, "tls_ca_cert": { "type": "string", @@ -4984,14 +5006,28 @@ "allOf": [ { "if": { - "not": { - "properties": { - "enabled": { - "const": false + "allOf": [ + { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] } }, - "required": ["enabled"] - } + { + "not": { + "properties": { + "traces_enabled": { + "const": false + } + }, + "required": ["traces_enabled"] + } + } + ] }, "then": { "required": ["collector_url", "trace_type", "protocol"] @@ -4999,15 +5035,29 @@ }, { "if": { - "properties": { - "metrics_enabled": { - "const": true + "allOf": [ + { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] + } + }, + { + "properties": { + "metrics_enabled": { + "const": true + } + }, + "required": ["metrics_enabled"] } - }, - "required": ["metrics_enabled"] + ] }, "then": { - "required": ["metrics_endpoint"] + "required": ["metrics_endpoint", "protocol"] } } ], diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index 822fa46f226..d4c14a86a14 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -612,6 +612,7 @@ bifrost: # Multi-profile shape (use profiles OR the flat single-profile fields below, not both): # profiles: # - service_name: "bifrost" + # traces_enabled: true # Set false for a metrics-only profile (collector_url not required) # collector_url: "" # e.g., http://otel-collector:4318 (HTTP) or otel-collector:4317 (gRPC) # trace_type: "genai_extension" # genai_extension | vercel | open_inference # protocol: "grpc" # http | grpc @@ -619,7 +620,9 @@ bifrost: # metrics_enabled: false # metrics_endpoint: "" # e.g., http://otel-collector:4318/v1/metrics (HTTP) or otel-collector:4317 (gRPC) # metrics_push_interval: 15 # Push interval in seconds (1-300) - # headers: {} + # headers: {} # Sent to both endpoints (supports env.VAR_NAME prefix) + # trace_headers: {} # Sent only to the trace endpoint, on top of headers (same key overrides) + # metrics_headers: {} # Sent only to the metrics endpoint, on top of headers (e.g. a Databricks table name) # tls_ca_cert: "" # Path to TLS CA certificate file # insecure: true # Skip TLS verification (ignored if tls_ca_cert is set) # disable_content_logging: false @@ -629,6 +632,8 @@ bifrost: # # Single-profile shape: service_name: "bifrost" + # Set false for a metrics-only profile (collector_url not required) + # traces_enabled: true collector_url: "" # e.g., http://otel-collector:4318 (HTTP) or otel-collector:4317 (gRPC) trace_type: "genai_extension" # genai_extension | vercel | open_inference protocol: "grpc" # http | grpc @@ -640,8 +645,12 @@ bifrost: metrics_enabled: false metrics_endpoint: "" # e.g., http://otel-collector:4318/v1/metrics (HTTP) or otel-collector:4317 (gRPC) metrics_push_interval: 15 # Push interval in seconds (1-300) - # Custom headers for the collector (supports env.VAR_NAME prefix) + # Custom headers sent to both the trace and metrics endpoints (supports env.VAR_NAME prefix) headers: {} + # Extra headers sent only to the trace endpoint, on top of headers (same key overrides) + # trace_headers: {} + # Extra headers sent only to the metrics endpoint, on top of headers (e.g. a Databricks table name) + # metrics_headers: {} # TLS configuration tls_ca_cert: "" # Path to TLS CA certificate file # insecure: false # Skip TLS verification (ignored if tls_ca_cert is set) @@ -658,7 +667,7 @@ bifrost: enabled: false version: 1 config: - service_name: "bifrost" + service_name: "bifrost" # Service name reported to Datadog (supports env.VAR_NAME) # Datadog Agent address. Supports env.VAR_NAME references — e.g. set # agent_addr: "env.DD_AGENT_ADDR" and inject DD_AGENT_ADDR via the # top-level `env:` (e.g. from status.hostIP for a node-local agent DaemonSet). @@ -672,11 +681,11 @@ bifrost: # agent_port: "8126" # dogstatsd_host: "env.DD_AGENT_HOST" # dogstatsd_port: "8125" - env: "" - version: "" + env: "" # Environment tag, e.g. production (supports env.VAR_NAME) + version: "" # Service version tag (supports env.VAR_NAME) custom_tags: {} enable_traces: true - # ml_app: "" # ML app name for LLM Observability (defaults to service_name) + # ml_app: "" # ML app name for LLM Observability (defaults to service_name, supports env.VAR_NAME) # enable_metrics: true # enable_llm_obs: true # group_traces_by_session: false # Group requests sharing x-bf-session-id into one APM trace (agent mode only) diff --git a/plugins/otel/main.go b/plugins/otel/main.go index 78272b97436..6ffd751d47e 100644 --- a/plugins/otel/main.go +++ b/plugins/otel/main.go @@ -86,14 +86,21 @@ 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.SecretVar `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. + Enabled bool `json:"enabled"` + + // TracesEnabled gates trace export. When false, no trace client is built and + // CollectorURL is not required: a metrics-only profile. Defaults to true when omitted. + TracesEnabled bool `json:"traces_enabled"` + + ServiceName string `json:"service_name"` + CollectorURL *schemas.SecretVar `json:"collector_url"` + Headers map[string]string `json:"headers,omitempty"` // Shared between traces and metrics endpoints + TraceHeaders map[string]string `json:"trace_headers,omitempty"` // Traces only headers + MetricsHeaders map[string]string `json:"metrics_headers,omitempty"` // Metrics only headers + 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. // ExportTimeout bounds a single trace export, in seconds (default 5, max 60). // This is the only deadline on the export: the caller passes context.Background(), @@ -137,8 +144,9 @@ type Profile struct { func (p *Profile) UnmarshalJSON(data []byte) error { type alias Profile aux := struct { - Enabled *bool `json:"enabled"` - Insecure *bool `json:"insecure"` + Enabled *bool `json:"enabled"` + Insecure *bool `json:"insecure"` + TracesEnabled *bool `json:"traces_enabled"` *alias }{ alias: (*alias)(p), @@ -156,6 +164,12 @@ func (p *Profile) UnmarshalJSON(data []byte) error { } else { p.Enabled = *aux.Enabled } + // Default traces on so existing configs keep exporting spans. + if aux.TracesEnabled == nil { + p.TracesEnabled = true + } else { + p.TracesEnabled = *aux.TracesEnabled + } return nil } @@ -238,9 +252,12 @@ func hoistSpanFilter(data []byte) *PluginSpanFilter { // persistence. type profileForStorage struct { Enabled bool `json:"enabled"` + TracesEnabled bool `json:"traces_enabled"` ServiceName string `json:"service_name"` CollectorURL string `json:"collector_url"` Headers map[string]string `json:"headers,omitempty"` + TraceHeaders map[string]string `json:"trace_headers,omitempty"` + MetricsHeaders map[string]string `json:"metrics_headers,omitempty"` TraceType TraceType `json:"trace_type"` Protocol Protocol `json:"protocol"` TLSCACert string `json:"tls_ca_cert,omitempty"` @@ -276,9 +293,12 @@ func (c *Config) MarshalForStorage() ([]byte, error) { } out.Profiles = append(out.Profiles, profileForStorage{ Enabled: p.Enabled, + TracesEnabled: p.TracesEnabled, ServiceName: p.ServiceName, CollectorURL: schemas.SecretVarAsString(p.CollectorURL), Headers: p.Headers, + TraceHeaders: p.TraceHeaders, + MetricsHeaders: p.MetricsHeaders, TraceType: p.TraceType, Protocol: p.Protocol, TLSCACert: p.TLSCACert, @@ -317,12 +337,9 @@ func (c *Config) Redacted() *Config { 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) - } - } + rp.Headers = redactHeaderMap(p.Headers) + rp.TraceHeaders = redactHeaderMap(p.TraceHeaders) + rp.MetricsHeaders = redactHeaderMap(p.MetricsHeaders) redacted.Profiles = append(redacted.Profiles, &rp) } } @@ -339,6 +356,18 @@ func redactHeaderValue(v string) string { return schemas.SecretVarAsString(schemas.NewSecretVar(v).Redacted()) } +// redactHeaderMap redacts every value in a copy of h, returning nil for nil h. +func redactHeaderMap(h map[string]string) map[string]string { + if h == nil { + return nil + } + out := make(map[string]string, len(h)) + for k, v := range h { + out[k] = redactHeaderValue(v) + } + return out +} + // hideResolvedEnvValue returns v unchanged for literal values (URLs are not secrets). // For env var references it replaces a resolved Val with a redaction marker so API // consumers can tell the value exists without leaking env content. Unresolved env @@ -509,21 +538,39 @@ func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, erro 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) + // Both traces and metrics dial with this protocol, so validate it once. A profile + // with neither enabled is a no-op, so skip the check. + if profile.TracesEnabled || profile.MetricsEnabled { + switch profile.Protocol { + case ProtocolGRPC, ProtocolHTTP: + default: + return nil, fmt.Errorf("profile %d: invalid protocol type %q", index, profile.Protocol) + } + } + + // Common headers go to both endpoints; per-signal headers override on collision. + var ( + traceHeaders map[string]string + metricsHeaders map[string]string + err error + ) + if profile.TracesEnabled { + traceHeaders, err = mergedResolvedHeaders(profile.Headers, profile.TraceHeaders) + if err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) + } + } + if profile.MetricsEnabled { + metricsHeaders, err = mergedResolvedHeaders(profile.Headers, profile.MetricsHeaders) + if err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) + } } exportTimeout, err := resolveExportTimeout(profile.ExportTimeout) @@ -531,10 +578,8 @@ func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, erro return nil, fmt.Errorf("profile %d: %w", index, err) } - url := profile.CollectorURL.GetValue() target := &otelTarget{ serviceName: serviceName, - url: url, traceType: profile.TraceType, requestHeaders: slices.Clone(profile.RequestHeaders), disableContentLogging: profile.DisableContentLogging, @@ -543,37 +588,47 @@ func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, erro exportTimeout: exportTimeout, } - switch profile.Protocol { - case ProtocolGRPC: - // gRPC has no client-side timeout of its own; the per-export context deadline - // applied in Inject is what bounds it. - target.client, err = NewOtelClientGRPC(url, headers, profile.TLSCACert, profile.Insecure) - case ProtocolHTTP: - target.client, err = NewOtelClientHTTP(url, headers, profile.TLSCACert, profile.Insecure, exportTimeout) - 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) + // Build the trace client only when traces are enabled; Inject skips a nil client. + if profile.TracesEnabled { + if profile.CollectorURL == nil || profile.CollectorURL.GetValue() == "" { + return nil, fmt.Errorf("profile %d: collector url is required when traces_enabled is true", index) + } + url := profile.CollectorURL.GetValue() + target.url = url + switch profile.Protocol { + case ProtocolGRPC: + // gRPC has no client-side timeout of its own; the per-export context deadline + // applied in Inject is what bounds it. + target.client, err = NewOtelClientGRPC(url, traceHeaders, profile.TLSCACert, profile.Insecure) + case ProtocolHTTP: + target.client, err = NewOtelClientHTTP(url, traceHeaders, profile.TLSCACert, profile.Insecure, exportTimeout) + } + if err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) + } } // Initialize metrics exporter if enabled if profile.MetricsEnabled { if profile.MetricsEndpoint.GetValue() == "" { - target.client.Close() + if target.client != nil { + target.client.Close() + } return nil, fmt.Errorf("profile %d: metrics_endpoint is required when metrics_enabled is true", index) } pushInterval := profile.MetricsPushInterval if pushInterval <= 0 { pushInterval = 15 // default 15 seconds } else if pushInterval > 300 { - target.client.Close() + if target.client != nil { + 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: serviceName, Endpoint: profile.MetricsEndpoint.GetValue(), - Headers: headers, + Headers: metricsHeaders, Protocol: profile.Protocol, TLSCACert: profile.TLSCACert, Insecure: profile.Insecure, @@ -593,6 +648,18 @@ func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, erro return target, nil } +// mergedResolvedHeaders overlays overlay onto common (overlay wins) and resolves "env." +// references. Inputs are not mutated. +func mergedResolvedHeaders(common, overlay map[string]string) (map[string]string, error) { + merged := make(map[string]string, len(common)+len(overlay)) + maps.Copy(merged, common) + maps.Copy(merged, overlay) + if err := injectEnvToHeaders(merged); err != nil { + return nil, err + } + return merged, nil +} + // resolveExportTimeout validates a configured export_timeout (in seconds) and returns // the duration to use, falling back to DefaultExportTimeout when unset. func resolveExportTimeout(seconds int) (time.Duration, error) { diff --git a/plugins/otel/profiles_test.go b/plugins/otel/profiles_test.go index cf2e7b84706..bf784011661 100644 --- a/plugins/otel/profiles_test.go +++ b/plugins/otel/profiles_test.go @@ -355,6 +355,252 @@ func TestInitMultiProfileValidation(t *testing.T) { } } +// TestProfileTracesEnabledDefault verifies TracesEnabled defaults to true when omitted +// and is honored when set explicitly. +func TestProfileTracesEnabledDefault(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"protocol": "http", "metrics_enabled": true, "metrics_endpoint": "b:4318", "traces_enabled": false} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].TracesEnabled { + t.Errorf("profile 0 TracesEnabled = false, want true (default)") + } + if cfg.Profiles[1].TracesEnabled { + t.Errorf("profile 1 TracesEnabled = true, want false (explicit)") + } +} + +// TestInitMetricsOnlyProfile verifies a metrics-only profile (traces disabled, no +// collector_url) builds a target with a metrics exporter but no trace client. +func TestInitMetricsOnlyProfile(t *testing.T) { + raw := `{"profiles": [ + {"traces_enabled": false, "protocol": "http", "metrics_enabled": true, "metrics_endpoint": "localhost:4318"} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init metrics-only profile: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 1 { + t.Fatalf("targets len = %d, want 1", len(plugin.targets)) + } + if plugin.targets[0].client != nil { + t.Errorf("metrics-only target has a trace client, want nil") + } + if plugin.targets[0].metricsExporter == nil { + t.Errorf("metrics-only target has no metrics exporter, want one") + } +} + +// TestInitMetricsOnlyPushIntervalTooLarge verifies a metrics-only profile (nil trace +// client) with metrics_push_interval > 300 returns the validation error instead of +// panicking on a nil client.Close(). +func TestInitMetricsOnlyPushIntervalTooLarge(t *testing.T) { + raw := `{"profiles": [ + {"traces_enabled": false, "protocol": "http", "metrics_enabled": true, "metrics_endpoint": "localhost:4318", "metrics_push_interval": 301} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err == nil { + if plugin != nil { + _ = plugin.Cleanup() + } + t.Fatalf("expected error for metrics_push_interval > 300, got nil") + } +} + +// TestInitTracesOnlyProfileNeedsCollectorURL verifies a traces-enabled profile still +// requires collector_url. +func TestInitTracesOnlyProfileNeedsCollectorURL(t *testing.T) { + raw := `{"profiles": [ + {"trace_type": "genai_extension", "protocol": "grpc"} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, err := Init(context.Background(), &cfg, testLogger{}, nil, ""); err == nil { + t.Errorf("expected error for traces-enabled profile missing collector_url") + } +} + +// TestInitBothDisabledProfile verifies a profile with both traces and metrics disabled +// is allowed as a no-op (matching the telemetry plugin, where pull and push are +// independent and both may be off): it builds a target with no client or exporter. +func TestInitBothDisabledProfile(t *testing.T) { + raw := `{"profiles": [ + {"traces_enabled": false} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init both-disabled profile: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 1 { + t.Fatalf("targets len = %d, want 1", len(plugin.targets)) + } + if plugin.targets[0].client != nil || plugin.targets[0].metricsExporter != nil { + t.Errorf("both-disabled target should have no client or exporter") + } +} + +// TestTracesEnabledStorageRoundTrip verifies traces_enabled survives storage marshalling. +func TestTracesEnabledStorageRoundTrip(t *testing.T) { + raw := `{"profiles": [ + {"traces_enabled": false, "protocol": "http", "metrics_enabled": true, "metrics_endpoint": "localhost:4318"} + ]}` + 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) + } + var back Config + if err := json.Unmarshal(stored, &back); err != nil { + t.Fatalf("round-trip unmarshal: %v", err) + } + if back.Profiles[0].TracesEnabled { + t.Errorf("round-trip TracesEnabled = true, want false") + } +} + +// TestMergedResolvedHeaders verifies per-signal headers overlay the common ones (same key +// wins), env refs resolve, and the inputs are not mutated. +func TestMergedResolvedHeaders(t *testing.T) { + t.Setenv("OTEL_TOKEN", "resolved") + common := map[string]string{"Authorization": "env.OTEL_TOKEN", "X-Shared": "base"} + overlay := map[string]string{"X-Shared": "override", "X-Table": "my_table"} + + merged, err := mergedResolvedHeaders(common, overlay) + if err != nil { + t.Fatalf("mergedResolvedHeaders: %v", err) + } + if merged["Authorization"] != "resolved" { + t.Errorf("Authorization = %q, want resolved", merged["Authorization"]) + } + if merged["X-Shared"] != "override" { + t.Errorf("X-Shared = %q, want override (overlay wins)", merged["X-Shared"]) + } + if merged["X-Table"] != "my_table" { + t.Errorf("X-Table = %q, want my_table", merged["X-Table"]) + } + // Inputs untouched. + if common["Authorization"] != "env.OTEL_TOKEN" || common["X-Shared"] != "base" { + t.Errorf("common map was mutated: %v", common) + } + if overlay["X-Shared"] != "override" { + t.Errorf("overlay map was mutated: %v", overlay) + } +} + +// TestPerSignalHeadersStorageRoundTrip verifies trace_headers and metrics_headers survive +// storage marshalling and redaction. +func TestPerSignalHeadersStorageRoundTrip(t *testing.T) { + raw := `{"profiles": [ + { + "collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN"}, + "trace_headers": {"X-Trace": "t"}, + "metrics_enabled": true, "metrics_endpoint": "a:4318", + "metrics_headers": {"X-Databricks-Table": "my_table"} + } + ]}` + 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) + } + var back Config + if err := json.Unmarshal(stored, &back); err != nil { + t.Fatalf("round-trip unmarshal: %v", err) + } + p := back.Profiles[0] + if p.TraceHeaders["X-Trace"] != "t" { + t.Errorf("trace_headers lost: %v", p.TraceHeaders) + } + if p.MetricsHeaders["X-Databricks-Table"] != "my_table" { + t.Errorf("metrics_headers lost: %v", p.MetricsHeaders) + } + + // Redaction preserves env refs and masks literals across all three maps. + red := cfg.Redacted().Profiles[0] + if red.Headers["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("common env header not preserved: %q", red.Headers["Authorization"]) + } + if red.MetricsHeaders["X-Databricks-Table"] == "my_table" { + t.Errorf("metrics literal header was not masked") + } +} + +// TestInitMetricsOnlyIgnoresTraceHeaderEnv verifies a metrics-only profile does not +// resolve trace_headers, so an unset env reference there does not fail Init. +func TestInitMetricsOnlyIgnoresTraceHeaderEnv(t *testing.T) { + raw := `{"profiles": [ + { + "traces_enabled": false, "protocol": "http", + "trace_headers": {"X-Trace": "env.OTEL_UNSET_TRACE_XYZ"}, + "metrics_enabled": true, "metrics_endpoint": "localhost:4318" + } + ]}` + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init metrics-only with unset trace_headers env: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) +} + +// TestInitTracesOnlyIgnoresMetricsHeaderEnv verifies a traces-only profile does not +// resolve metrics_headers, so an unset env reference there does not fail Init. +func TestInitTracesOnlyIgnoresMetricsHeaderEnv(t *testing.T) { + raw := `{"profiles": [ + { + "collector_url": "localhost:4317", "trace_type": "genai_extension", "protocol": "grpc", + "metrics_enabled": false, + "metrics_headers": {"X-Table": "env.OTEL_UNSET_METRICS_XYZ"} + } + ]}` + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init traces-only with unset metrics_headers env: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) +} + type testLogger struct{} func (testLogger) Debug(string, ...any) {} diff --git a/tests/e2e/features/observability/pages/observability.page.ts b/tests/e2e/features/observability/pages/observability.page.ts index 3d645af5509..45983eab512 100644 --- a/tests/e2e/features/observability/pages/observability.page.ts +++ b/tests/e2e/features/observability/pages/observability.page.ts @@ -179,6 +179,11 @@ export class ObservabilityPage extends BasePage { */ async enableMetricsExport(): Promise { await this.selectConnector('otel') + // The metrics-export toggle lives in the profile's Metrics tab, which is not the + // default active tab, so select it before interacting with the toggle. + const metricsTab = this.page.getByTestId('otel-profile-0-tab-metrics') + await metricsTab.waitFor({ state: 'visible', timeout: 5000 }) + await metricsTab.click() const switch_ = this.page.getByTestId('otel-metrics-export-toggle') await switch_.waitFor({ state: 'visible', timeout: 5000 }) const checked = await switch_.getAttribute('data-state') === 'checked' @@ -290,6 +295,12 @@ export class ObservabilityPage extends BasePage { * so we also treat the "Enable Metrics Export" section as OTel content. */ async isMetricsEndpointVisible(): Promise { + // The metrics subsection lives in the profile's Metrics tab, which is not active by + // default; select it first so its content is mounted before checking visibility. + const metricsTab = this.page.getByTestId('otel-profile-0-tab-metrics') + await metricsTab.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}) + await metricsTab.click().catch(() => {}) + // Metrics endpoint input (only visible when Enable Metrics Export is on) const metricsInputByValue = this.page.locator('input[value*="/metrics"]') const valueVisible = await metricsInputByValue.isVisible().catch(() => false) diff --git a/transports/config.schema.json b/transports/config.schema.json index 5b32be6f704..fd18907a0e7 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -2653,12 +2653,12 @@ "properties": { "service_name": { "type": "string", - "description": "Name of the service to report to Datadog", + "description": "Name of the service to report to Datadog (supports env.VAR_NAME references)", "default": "bifrost" }, "ml_app": { "type": "string", - "description": "ML application name for LLM Observability grouping (defaults to service_name)" + "description": "ML application name for LLM Observability grouping (defaults to service_name, supports env.VAR_NAME references)" }, "agent_addr": { "type": "string", @@ -2690,11 +2690,11 @@ }, "env": { "type": "string", - "description": "Environment tag (e.g., production, staging)" + "description": "Environment tag (e.g., production or staging; supports env.VAR_NAME references)" }, "version": { "type": "string", - "description": "Service version tag" + "description": "Service version tag (supports env.VAR_NAME references)" }, "custom_tags": { "type": "object", @@ -3148,6 +3148,11 @@ "description": "Whether this profile exports traces and metrics", "default": true }, + "traces_enabled": { + "type": "boolean", + "description": "Enable trace export for this profile. When false, no traces are sent and collector_url is not required, giving a metrics-only profile (pair with metrics_enabled).", + "default": true + }, "service_name": { "type": "string", "description": "Service name to be used for tracing", @@ -3217,7 +3222,21 @@ "additionalProperties": { "type": "string" }, - "description": "Custom headers for the collector. Supports env.VAR_NAME prefix for environment variable substitution." + "description": "Custom headers sent to both the trace and metrics endpoints. Supports env.VAR_NAME prefix for environment variable substitution." + }, + "trace_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Extra headers sent only to the trace endpoint, overlaid on headers (same key wins). Supports env.VAR_NAME prefix." + }, + "metrics_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Extra headers sent only to the metrics endpoint, overlaid on headers (same key wins). Useful when a collector requires a per-signal header, e.g. a Databricks table name. Supports env.VAR_NAME prefix." }, "tls_ca_cert": { "type": "string", @@ -3235,14 +3254,28 @@ "allOf": [ { "if": { - "not": { - "properties": { - "enabled": { - "const": false + "allOf": [ + { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] } }, - "required": ["enabled"] - } + { + "not": { + "properties": { + "traces_enabled": { + "const": false + } + }, + "required": ["traces_enabled"] + } + } + ] }, "then": { "required": ["collector_url", "trace_type", "protocol"] @@ -3258,7 +3291,7 @@ "required": ["metrics_enabled"] }, "then": { - "required": ["metrics_endpoint"] + "required": ["metrics_endpoint", "protocol"] } } ], diff --git a/ui/app/workspace/observability/fragments/otelFormFragment.tsx b/ui/app/workspace/observability/fragments/otelFormFragment.tsx index 525abeb1794..864952630eb 100644 --- a/ui/app/workspace/observability/fragments/otelFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/otelFormFragment.tsx @@ -8,6 +8,7 @@ import { RequestHeadersTextarea } from "@/components/ui/requestHeadersTextarea"; import { SecretVarInput } from "@/components/ui/secretVarInput"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { otelFormSchema, type OtelFormSchema, type SecretVar } from "@/lib/types/schemas"; import { emptySecretVar, toSecretVarFormValue, toSecretVarMapFormValue } from "@/lib/utils/secretVarForm"; @@ -24,9 +25,12 @@ type ProfileForm = OtelFormSchema["profiles"][number]; // SecretVar fields may be plain strings or full objects). interface StoredOtelProfile { enabled?: boolean; + traces_enabled?: boolean; service_name?: string; collector_url?: string | SecretVar; headers?: Record; + trace_headers?: Record; + metrics_headers?: Record; trace_type?: "genai_extension" | "vercel" | "open_inference"; protocol?: "http" | "grpc"; tls_ca_cert?: string; @@ -89,9 +93,12 @@ const protocolOptions: { // emptyProfile returns a fresh profile with the same defaults a newly created collector uses. const emptyProfile = (): ProfileForm => ({ enabled: true, + traces_enabled: true, service_name: "bifrost", collector_url: emptySecretVar(), headers: {}, + trace_headers: {}, + metrics_headers: {}, trace_type: "genai_extension", protocol: "http", tls_ca_cert: "", @@ -109,9 +116,12 @@ const emptyProfile = (): ProfileForm => ({ // toProfileForm normalizes a stored profile into the SecretVar-based form representation. const toProfileForm = (p?: StoredOtelProfile): ProfileForm => ({ enabled: p?.enabled ?? true, + traces_enabled: p?.traces_enabled ?? true, service_name: p?.service_name ?? "bifrost", collector_url: toSecretVarFormValue(p?.collector_url), headers: toSecretVarMapFormValue(p?.headers), + trace_headers: toSecretVarMapFormValue(p?.trace_headers), + metrics_headers: toSecretVarMapFormValue(p?.metrics_headers), trace_type: p?.trace_type ?? "genai_extension", protocol: p?.protocol ?? "http", tls_ca_cert: p?.tls_ca_cert ?? "", @@ -310,15 +320,22 @@ interface OtelProfileSectionProps { function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, open, onOpenChange, onRemove }: OtelProfileSectionProps) { const base = `profiles.${index}` as const; const protocol = form.watch(`${base}.protocol`); + const tracesEnabled = form.watch(`${base}.traces_enabled`); 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 [activeTab, setActiveTab] = useState<"traces" | "metrics">("traces"); + + // Surface which tab holds a validation error so it's findable without expanding every section. + const profileErrors = form.formState.errors?.profiles?.[index]; + const hasError = Boolean(profileErrors); + const tracesFields = ["traces_enabled", "collector_url", "trace_type", "export_timeout", "request_headers"] as const; + const metricsFields = ["metrics_endpoint", "metrics_push_interval"] as const; + const hasTracesError = tracesFields.some((f) => Boolean(profileErrors?.[f])); + const hasMetricsError = metricsFields.some((f) => Boolean(profileErrors?.[f])); const collectorPreview = typeof collectorUrl === "string" @@ -337,6 +354,7 @@ function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, op {serviceName || `Profile ${index + 1}`} {!enabled && Disabled} + {enabled && !tracesEnabled && metricsEnabled && Metrics only} {hasError && Error} {collectorPreview && {collectorPreview}} @@ -380,6 +398,7 @@ function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, op
+ {/* Common connection settings, shared by trace and metrics export */} ( - - OTLP Collector URL -
- {protocol === "http" ? "http(s)://:/v1/traces" : ":"} -
- - - + + Protocol + Transport used for both trace and metrics export. + )} @@ -424,198 +444,19 @@ function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, op render={({ field }) => ( - - - - - )} - /> - ( - - - Request Headers (Optional) - - - Comma-separated list of request headers to capture and emit as span attributes. Supports exact names and wildcard patterns - (e.g. x-custom-* captures all headers with that prefix,{" "} - * captures all headers; note that * will capture - sensitive headers like Authorization). - - - - - - - )} - /> - ( - -
- Disable Content Logging - - When enabled, message content (input/output messages, tool definitions, and tool call arguments/results) is dropped from - exported spans. Only metadata such as model, tokens, and latency is sent to the collector. - -
- - - -
- )} - /> - ( - -
- Group Traces by Session - - When enabled, requests sharing the same x-bf-session-id header are grouped into a single trace, each request appearing - as a top-level sibling span. A request carrying an inbound W3C traceparent stays on its own distributed trace and is - unaffected. - -
- - - -
- )} - /> - ( - -
- Disable Root Span Content - - When enabled, input/output message content is dropped from the root span only; the underlying generation (llm.call) span - keeps the full content. - -
- - - -
- )} - /> -
- ( - - Format - - - - )} - /> - - ( - - Protocol - - - - )} - /> -
- - ( - - Export Timeout (seconds) - - field.onChange(e.target.value === "" ? null : Number(e.target.value))} + useSecretVarInput /> - - Maximum time for a single trace export (1-60 seconds). Traces are dropped rather than retried past this - limit, so an unreachable collector cannot slow down request handling. - + Sent to both the trace and metrics endpoints. )} /> - {/* TLS Configuration */}
- {/* Metrics Push Configuration */} -
- ( - -
-
-

- Enable Metrics Export BETA -

-

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

-
-
- -
-
-
- )} - /> + {/* Traces and Metrics tabs, each independently enable-able */} + setActiveTab(v as "traces" | "metrics")} className="border-t pt-4"> + + + Traces + {hasTracesError && ( + + ! + + )} + + + Metrics + {hasMetricsError && ( + + ! + + )} + + - {metricsEnabled && ( -
- ( - - Metrics Endpoint -
- {protocol === "http" ? "http(s)://:/v1/metrics" : ":"} + {/* Traces tab: exports spans to the OTLP collector */} + + ( + +
+
+

Enable Trace Export

+

+ Export spans to the OTLP collector. Turn off for a metrics-only profile. +

- - + - - - - )} - /> +
+
+
+ )} + /> - ( - - Push Interval (seconds) - - + ( + + OTLP Collector URL +
+ {protocol === "http" ? "http(s)://:/v1/traces" : ":"} +
+ + + + +
+ )} + /> + ( + + + + + Sent only to the trace endpoint, in addition to the common headers. + + + )} + /> + ( + + Format + + + + )} + /> + ( + + Export Timeout (seconds) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + /> + + + Maximum time for a single trace export (1-60 seconds). Traces are dropped rather than retried past this limit, so + an unreachable collector cannot slow down request handling. + + + + )} + /> + ( + + + Request Headers (Optional) + + + Comma-separated list of request headers to capture and emit as span attributes. Supports exact names and wildcard + patterns (e.g. x-custom-* captures all headers with that prefix,{" "} + * captures all headers; note that * will capture + sensitive headers like Authorization). + + + + + + + )} + /> + ( + +
+ Disable Content Logging + + When enabled, message content (input/output messages, tool definitions, and tool call arguments/results) is + dropped from exported spans. Only metadata such as model, tokens, and latency is sent to the collector. + +
+ + + +
+ )} + /> + ( + +
+ Group Traces by Session + + When enabled, requests sharing the same x-bf-session-id header are grouped into a single trace, each request + appearing as a top-level sibling span. A request carrying an inbound W3C traceparent stays on its own + distributed trace and is unaffected. + +
+ + + +
+ )} + /> + ( + +
+ Disable Root Span Content + + When enabled, input/output message content is dropped from the root span only; the underlying generation + (llm.call) span keeps the full content. + +
+ + + +
+ )} + /> +
+ )} + + + {/* Metrics tab: pushes OTLP metrics to a collector */} + + ( + +
+
+

+ Enable Metrics Export BETA +

+

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

+
+
+ field.onChange(e.target.value === "" ? null : Number(e.target.value))} /> - - How often to push metrics (1-300 seconds) - - - )} - /> -
- )} -
+
+
+
+ )} + /> + + {metricsEnabled && ( +
+ ( + + Metrics Endpoint +
+ {protocol === "http" ? "http(s)://:/v1/metrics" : ":"} +
+ + + + +
+ )} + /> + + ( + + + + + + Sent only to the metrics endpoint, in addition to the common headers (e.g. a Databricks table name). + + + + )} + /> + + ( + + Push Interval (seconds) + + field.onChange(e.target.value === "" ? null : Number(e.target.value))} + /> + + How often to push metrics (1-300 seconds) + + + )} + /> +
+ )} + +
diff --git a/ui/app/workspace/observability/views/plugins/otelView.tsx b/ui/app/workspace/observability/views/plugins/otelView.tsx index 5352871eae8..d67badd6139 100644 --- a/ui/app/workspace/observability/views/plugins/otelView.tsx +++ b/ui/app/workspace/observability/views/plugins/otelView.tsx @@ -25,6 +25,8 @@ export default function OtelView({ onDelete, isDeleting }: OtelViewProps) { const profiles = config.profiles.map((profile) => ({ ...profile, headers: toHeaderStringMap(profile.headers), + trace_headers: toHeaderStringMap(profile.trace_headers), + metrics_headers: toHeaderStringMap(profile.metrics_headers), })); return new Promise((resolve, reject) => { diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 160fbb38d14..ccaaf69085d 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -870,6 +870,8 @@ export const otelConfigSchema = z .object({ // Per-profile enable toggle. A disabled profile exports nothing and is not validated. enabled: z.boolean().default(true), + // Trace export toggle. When false the profile is metrics-only; collector_url isn't required. + traces_enabled: z.boolean().default(true), service_name: z.string().optional(), collector_url: secretVarSchema.default({ value: "" }), trace_type: z @@ -877,7 +879,10 @@ export const otelConfigSchema = z message: "Please select a trace type", }) .default("genai_extension"), + // Common headers go to both endpoints; per-signal headers override on collision. headers: z.record(z.string(), secretVarSchema).optional(), + trace_headers: z.record(z.string(), secretVarSchema).optional(), + metrics_headers: z.record(z.string(), secretVarSchema).optional(), protocol: z .enum(["http", "grpc"], { message: "Please select a protocol", @@ -952,21 +957,23 @@ export const otelConfigSchema = z return true; }; - // Collector address is required for an enabled profile. - if (!isSecretVarSet(data.collector_url)) { - ctx.addIssue({ - code: "custom", - path: ["collector_url"], - message: "Collector address is required", - }); - } + // collector_url is required and validated only when traces are enabled. + if (data.traces_enabled) { + if (!isSecretVarSet(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?.type === "plain_text" || !data.collector_url?.type) && protocol === "http") { - validateHttpUrl(collectorUrl, ["collector_url"]); - } else if (collectorUrl && (data.collector_url?.type === "plain_text" || !data.collector_url?.type) && protocol === "grpc") { - validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317"); + // Validate collector_url format — skip format check for env var references + const collectorUrl = (data.collector_url?.value || "").trim(); + if (collectorUrl && (data.collector_url?.type === "plain_text" || !data.collector_url?.type) && protocol === "http") { + validateHttpUrl(collectorUrl, ["collector_url"]); + } else if (collectorUrl && (data.collector_url?.type === "plain_text" || !data.collector_url?.type) && protocol === "grpc") { + validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317"); + } } // Validate metrics_endpoint when metrics_enabled is true