feat: adds support for multiple otel collectors - #3894
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughRefactors the OTEL plugin to a per-profile model: extracts TLS/header helpers, delegates TLS to shared builders, adds Profile/Config and otelTarget runtime, emits spans/metrics per-profile, and updates frontend schemas/UI to edit and save multiple profiles. ChangesBackend: TLS Extraction and Multi-Profile Runtime
Frontend: Multi-Profile Form and Schema
Sequence Diagram(s)sequenceDiagram
participant User as User
participant Form as OtelFormFragment
participant Profiles as Profiles array
participant Section as OtelProfileSection
participant View as OtelView
participant Backend as Backend API
User->>Form: Load multi-profile config
Form->>Profiles: Populate via useFieldArray
Profiles->>Section: Render collapsible per-profile
User->>Section: Edit profile settings / Add profile
Form->>View: Submit
View->>View: toHeaderStringMap(profile.headers)
View->>Backend: Send { config: { profiles: [...] }, enabled }
Backend-->>View: Configuration saved
sequenceDiagram
participant Init as Init()
participant Config as Config struct
participant Targets as otelTargets[]
participant Build as buildTarget()
participant Helpers as buildTLSConfig/injectEnvToHeaders
participant Client as HTTP/gRPC Client
participant Exporter as MetricsExporter
Init->>Config: Validate Profiles array has 1+ entry
Init->>Targets: For each enabled Profile
Targets->>Build: buildTarget(profile)
Build->>Helpers: injectEnvToHeaders(headers) / buildTLSConfig(tlsCACert, insecure)
Helpers-->>Build: injected headers / tls.Config
Build->>Client: Instantiate HTTP or gRPC client with tls.Config
Build->>Exporter: Optionally init MetricsExporter
Build-->>Targets: return otelTarget
Targets-->>Init: store target
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Comment |
ab9a1f6 to
af26c3d
Compare
8c3e42e to
b95e8e7
Compare
af26c3d to
f66934b
Compare
Confidence Score: 4/5Safe to merge after addressing the plugin_span_filter data-loss in the UI save path. The Go backend refactor is solid — legacy config normalisation, concurrent Inject fanout, and TLS consolidation are all correct. The one concrete defect is in the UI save path: otelView.tsx constructs the payload as ui/app/workspace/observability/views/plugins/otelView.tsx — the save payload needs to forward the existing plugin_span_filter from the current stored config. Important Files Changed
Reviews (5): Last reviewed commit: "feat: adds support for multiple otel col..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/otel/main.go`:
- Around line 81-106: The UnmarshalJSON implementation for Profile currently
defaults Insecure to true when the field is omitted, which enables insecure TLS
by default; update Profile.UnmarshalJSON so that when aux.Insecure is nil you do
not set p.Insecure = true but instead leave p.Insecure as the safe zero value
(or explicitly set p.Insecure = false), and only assign p.Insecure when
aux.Insecure != nil (i.e., change the aux.Insecure handling in
Profile.UnmarshalJSON to remove the default-true behavior while leaving Enabled
behavior unchanged).
- Around line 573-580: In ValidateConfig, currently only empty Protocol strings
are rejected which lets invalid values (e.g., "htp") pass and fail later in
buildTarget; update the validation to explicitly accept only ProtocolHTTP or
ProtocolGRPC (referencing the Protocol constants) and return an error like
"profile %d: unsupported protocol" when profile.Protocol is not one of those
values; locate the validation logic inside ValidateConfig (and related profile
checks) and add the explicit whitelist check before returning success so bad
configs are rejected at validation time rather than during buildTarget.
In `@plugins/otel/utils.go`:
- Around line 37-38: validateCACertPath cleans certPath using filepath.Clean but
buildTLSConfig still reads tlsCACert verbatim, causing mismatch for inputs like
"/path/to/ca.pem/"; update buildTLSConfig to use the cleaned path returned or
produced by validateCACertPath (or call filepath.Clean on tlsCACert before
os.ReadFile) so the same normalized path (e.g., cleanPath) is used for both
validation and I/O; make the same change for the other occurrence around lines
74-79 where CA path is read.
In `@ui/app/workspace/observability/fragments/otelFormFragment.tsx`:
- Around line 271-283: Add stable data-testid attributes to all interactive and
per-profile display elements so E2E can target each profile instance reliably:
add data-testid attributes (e.g., `data-testid={`profile-${index}-header`}`,
`profile-${index}-serviceName`, `profile-${index}-collectorPreview`,
`profile-${index}-enabled-toggle`, `profile-${index}-remove-btn`, etc.) to the
CollapsibleTrigger button and contained spans/buttons and likewise add matching
testids to the inputs/selectors referenced in this fragment (service name field,
collector URL input, format/protocol selectors, TLS controls, metrics fields)
and to any Badge elements that indicate disabled/error; use the `index` and
unique field names to construct testids so identical controls across profiles
are distinguishable (affecting CollapsibleTrigger/ChevronDown/button,
serviceName, collectorPreview, enabled, hasError and corresponding
inputs/selectors in the other ranges mentioned).
In `@ui/lib/types/schemas.ts`:
- Around line 854-857: Update otelFormSchema so profiles validation is
conditional on enabled: keep enabled: z.boolean().default(true) but make
profiles optional (e.g., profiles: z.array(otelConfigSchema).optional()) and add
a schema-level check using .refine or .superRefine on otelFormSchema that
enforces profiles is present and has at least one otelConfigSchema entry only
when enabled === true; reference the otelFormSchema, enabled, profiles, and
otelConfigSchema symbols when making this change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4aa263c0-3c02-4c3a-8849-8e13e5cd9791
📒 Files selected for processing (10)
plugins/otel/converter.goplugins/otel/grpc.goplugins/otel/http.goplugins/otel/main.goplugins/otel/metrics.goplugins/otel/utils.goui/app/workspace/observability/fragments/otelFormFragment.tsxui/app/workspace/observability/views/plugins/otelView.tsxui/lib/types/schemas.tsui/lib/utils/envVarForm.ts
f66934b to
44c643e
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
plugins/otel/main.go (1)
250-254: 💤 Low valuePreserving nil profiles in redacted output may surprise callers.
When a profile is nil, appending nil to
redacted.Profilespreserves slice indices but downstream code iterating over the redacted config may not expect nil elements. Consider skipping nil profiles entirely (likeMarshalForStoragedoes) for consistency.♻️ Suggested change
if p == nil { - redacted.Profiles = append(redacted.Profiles, nil) continue }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugins/otel/main.go` around lines 250 - 254, The redaction loop currently appends nil entries to redacted.Profiles when encountering a nil element (iterating over c.Profiles); change this to skip nil profiles entirely so the redacted slice contains only non-nil profiles (matching the behavior of MarshalForStorage). Locate the loop that iterates over c.Profiles and remove the branch that does redacted.Profiles = append(redacted.Profiles, nil); instead, simply continue without appending when p == nil and append only the redacted non-nil profile objects.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@plugins/otel/main.go`:
- Around line 250-254: The redaction loop currently appends nil entries to
redacted.Profiles when encountering a nil element (iterating over c.Profiles);
change this to skip nil profiles entirely so the redacted slice contains only
non-nil profiles (matching the behavior of MarshalForStorage). Locate the loop
that iterates over c.Profiles and remove the branch that does redacted.Profiles
= append(redacted.Profiles, nil); instead, simply continue without appending
when p == nil and append only the redacted non-nil profile objects.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: cc6c7cad-a378-414f-babf-8b7b4978f3ef
📒 Files selected for processing (10)
plugins/otel/converter.goplugins/otel/grpc.goplugins/otel/http.goplugins/otel/main.goplugins/otel/metrics.goplugins/otel/utils.goui/app/workspace/observability/fragments/otelFormFragment.tsxui/app/workspace/observability/views/plugins/otelView.tsxui/lib/types/schemas.tsui/lib/utils/envVarForm.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- plugins/otel/converter.go
- ui/app/workspace/observability/views/plugins/otelView.tsx
- ui/lib/types/schemas.ts
- plugins/otel/http.go
- ui/app/workspace/observability/fragments/otelFormFragment.tsx
- plugins/otel/grpc.go
- ui/lib/utils/envVarForm.ts
44c643e to
bfe9f6e
Compare
bfe9f6e to
dd219c7
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/otel/converter.go`:
- Around line 210-213: getInstrumentationScope currently sets
InstrumentationScope.Name to the per-profile serviceName which fragments
downstream grouping; change getInstrumentationScope(serviceName string) to use a
stable scope identity (e.g., set commonpb.InstrumentationScope.Name to
"bifrost") and keep Version as p.bifrostVersion, leaving serviceName only for
the Resource attributes elsewhere; update the function getInstrumentationScope
and any callers if needed so the scope name is constant while serviceName
continues to be emitted as a resource attribute.
In `@plugins/otel/main.go`:
- Around line 836-844: GetMetricsExporter on OtelPlugin currently returns only
the first non-nil t.metricsExporter from p.targets, collapsing multi-profile
exporters; change it to return a fan-out wrapper (e.g., a new composite type
that implements the MetricsExporter interface and forwards calls to all non-nil
t.metricsExporter instances found in p.targets) or alternatively change the API
to return a slice of *MetricsExporter so callers can send to all collectors;
update GetMetricsExporter to collect all non-nil t.metricsExporter from
p.targets and return either the composite fan-out MetricsExporter or the slice,
ensuring the returned type still satisfies callers' expectations.
- Around line 815-833: The Cleanup method for OtelPlugin currently calls
t.metricsExporter.Shutdown(context.Background()) which can block and only logs
errors; change OtelPlugin.Cleanup to create a per-exporter timeout context (use
context.WithTimeout and defer cancel) when calling t.metricsExporter.Shutdown so
shutdowns cannot hang indefinitely, and if Shutdown returns an error assign it
to firstErr (if firstErr is nil) instead of merely logging; preserve existing
behavior of closing t.client and returning firstErr so the first external-call
failure is propagated.
In `@plugins/otel/utils.go`:
- Around line 45-56: The current check only Lstat's final element (cleanPath)
and misses symlinked ancestor directories; update the validation so it walks
each path component from the root (or working dir) to cleanPath and calls
os.Lstat on every joined prefix, returning an error if any component's Mode()
has os.ModeSymlink set; keep the existing checks for the final element
(IsRegular() etc.) and continue to use cleanPath and certPath names as the
targets for errors so you can locate and replace the single-call
os.Lstat(cleanPath) with a loop that tests each component for symlinks.
In `@ui/app/workspace/observability/fragments/otelFormFragment.tsx`:
- Around line 152-155: The onSubmit handler currently calls
onSave(data).finally(...) but does not handle rejections, leading to unhandled
promise rejections when the mutation fails; update onSubmit to either (A) make
it async and await onSave(data) inside a try/catch/finally block (use try {
await onSave(data) } catch (err) { /* handle or log error and run toast/error
path if needed */ } finally { setIsSaving(false) }) or (B) attach a .catch(err
=> { /* handle/log error to avoid unhandled rejection */ }).ensure
setIsSaving(false) always runs (keep finally) and reference the existing
onSubmit, onSave, and setIsSaving symbols when implementing the change.
In `@ui/app/workspace/observability/views/plugins/otelView.tsx`:
- Around line 18-31: handleOtelConfigSave is currently sending updatePlugin({
name: "otel", data: { enabled: ..., config: { profiles } } }) which overwrites
any top-level OTEL config (e.g., plugin_span_filter). Instead, preserve existing
top-level fields by merging the stored OTEL config with the new profiles: obtain
the current OTEL plugin config (or use the original server-provided config
object available in the component state/props), spread its properties and
replace only profiles with the flattened profiles array, then pass that merged
object as data.config to updatePlugin; reference handleOtelConfigSave,
updatePlugin, profiles and plugin_span_filter when locating and applying the
change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4b06193b-cd5c-4af8-adb0-b8363bc0e446
📒 Files selected for processing (10)
plugins/otel/converter.goplugins/otel/grpc.goplugins/otel/http.goplugins/otel/main.goplugins/otel/metrics.goplugins/otel/utils.goui/app/workspace/observability/fragments/otelFormFragment.tsxui/app/workspace/observability/views/plugins/otelView.tsxui/lib/types/schemas.tsui/lib/utils/envVarForm.ts
dd219c7 to
9efa509
Compare
Merge activity
|
## Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
## Changes
- Introduced a `Profile` struct to hold per-collector configuration, replacing the flat fields on `Config`. `Config` now holds a `Profiles []*Profile` slice plus a shared `PluginSpanFilter`.
- `Config.UnmarshalJSON` normalizes both the new `{"profiles": [...]}` wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.
- Added an `otelTarget` runtime struct that pairs a trace client with an optional metrics exporter and a resolved service name. `OtelPlugin` now holds a `targets` slice instead of a single client/exporter.
- `convertTraceToResourceSpan`, `getResourceAttributes`, and `getInstrumentationScope` now accept a `serviceName` argument so each profile's resource attributes reflect its own identity. `convertSpanToOTELSpan` was promoted to a package-level function since it no longer needs plugin state.
- TLS config construction was consolidated into a single `buildTLSConfig` helper in a new `utils.go`, eliminating duplicated `crypto/tls` and `crypto/x509` setup across `grpc.go`, `http.go`, and `metrics.go`. `validateCACertPath` was also moved there.
- `injectEnvToHeaders` replaces `resolveHeaders`: headers are now stored as plain strings using the `"env.VAR_NAME"` convention and resolved at `Init` time, removing the `map[string]*schemas.EnvVar` header type from `Profile`.
- `MarshalForStorage` always emits the canonical `{"profiles": [...]}` wrapper regardless of input shape.
- `Redacted` and `redactHeaderValue` were updated to operate over the profiles slice; literal header values are masked while `"env."` references are preserved.
- `recordMetricsFromTrace` now accepts an explicit `*MetricsExporter` argument instead of reading from plugin state, allowing it to be called once per target.
- `Cleanup` shuts down every target's metrics exporter and closes every trace client, returning the first close error.
- The UI form was refactored around a `profiles` field array. Each profile renders as a collapsible `OtelProfileSection` with its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load via `buildDefaults`. `toHeaderStringMap` and `toEnvRefString` helpers were added to `envVarForm.ts` to flatten `EnvVar` form values to the plain-string map the backend expects. The `otelFormSchema` was updated to validate a `profiles` array, with per-profile `enabled` gating skipping validation for disabled profiles.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```
- [x] https
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] grpc (same setup should work)
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] with single collector config
- [x] with multiple collector config
- [x] instance attributes intact
- [x] header injection logic intact
- [x] helm (single + multiple)
```
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
- `validateCACertPath` rejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.
- `buildTLSConfig` enforces `tls.VersionTLS12` as the minimum TLS version across all transports.
- Header values that are literal strings are masked in `Redacted()` API responses; `"env."` references are returned as-is since they do not expose the secret value.
- `injectEnvToHeaders` errors if a referenced environment variable is unset, preventing silent misconfiguration.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Multi-profile OTEL support: add/remove/manage multiple independent export profiles with per-profile collector, service name, headers, protocol, TLS, and metrics toggles.
- **Behavior Changes**
- TLS handling consolidated: custom CA and insecure modes only permit plaintext/insecure when no CA is provided; TLS config centrally built otherwise.
- Export/metrics now operate per-profile.
- **Validation**
- Per-profile enabled flag (default true); enabled profiles require a collector URL.
- **UX**
- Form and header env-var handling improved for persistence and submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
## Changes
- Introduced a `Profile` struct to hold per-collector configuration, replacing the flat fields on `Config`. `Config` now holds a `Profiles []*Profile` slice plus a shared `PluginSpanFilter`.
- `Config.UnmarshalJSON` normalizes both the new `{"profiles": [...]}` wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.
- Added an `otelTarget` runtime struct that pairs a trace client with an optional metrics exporter and a resolved service name. `OtelPlugin` now holds a `targets` slice instead of a single client/exporter.
- `convertTraceToResourceSpan`, `getResourceAttributes`, and `getInstrumentationScope` now accept a `serviceName` argument so each profile's resource attributes reflect its own identity. `convertSpanToOTELSpan` was promoted to a package-level function since it no longer needs plugin state.
- TLS config construction was consolidated into a single `buildTLSConfig` helper in a new `utils.go`, eliminating duplicated `crypto/tls` and `crypto/x509` setup across `grpc.go`, `http.go`, and `metrics.go`. `validateCACertPath` was also moved there.
- `injectEnvToHeaders` replaces `resolveHeaders`: headers are now stored as plain strings using the `"env.VAR_NAME"` convention and resolved at `Init` time, removing the `map[string]*schemas.EnvVar` header type from `Profile`.
- `MarshalForStorage` always emits the canonical `{"profiles": [...]}` wrapper regardless of input shape.
- `Redacted` and `redactHeaderValue` were updated to operate over the profiles slice; literal header values are masked while `"env."` references are preserved.
- `recordMetricsFromTrace` now accepts an explicit `*MetricsExporter` argument instead of reading from plugin state, allowing it to be called once per target.
- `Cleanup` shuts down every target's metrics exporter and closes every trace client, returning the first close error.
- The UI form was refactored around a `profiles` field array. Each profile renders as a collapsible `OtelProfileSection` with its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load via `buildDefaults`. `toHeaderStringMap` and `toEnvRefString` helpers were added to `envVarForm.ts` to flatten `EnvVar` form values to the plain-string map the backend expects. The `otelFormSchema` was updated to validate a `profiles` array, with per-profile `enabled` gating skipping validation for disabled profiles.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```
- [x] https
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] grpc (same setup should work)
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] with single collector config
- [x] with multiple collector config
- [x] instance attributes intact
- [x] header injection logic intact
- [x] helm (single + multiple)
```
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
- `validateCACertPath` rejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.
- `buildTLSConfig` enforces `tls.VersionTLS12` as the minimum TLS version across all transports.
- Header values that are literal strings are masked in `Redacted()` API responses; `"env."` references are returned as-is since they do not expose the secret value.
- `injectEnvToHeaders` errors if a referenced environment variable is unset, preventing silent misconfiguration.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Multi-profile OTEL support: add/remove/manage multiple independent export profiles with per-profile collector, service name, headers, protocol, TLS, and metrics toggles.
- **Behavior Changes**
- TLS handling consolidated: custom CA and insecure modes only permit plaintext/insecure when no CA is provided; TLS config centrally built otherwise.
- Export/metrics now operate per-profile.
- **Validation**
- Per-profile enabled flag (default true); enabled profiles require a collector URL.
- **UX**
- Form and header env-var handling improved for persistence and submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
## Changes
- Introduced a `Profile` struct to hold per-collector configuration, replacing the flat fields on `Config`. `Config` now holds a `Profiles []*Profile` slice plus a shared `PluginSpanFilter`.
- `Config.UnmarshalJSON` normalizes both the new `{"profiles": [...]}` wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.
- Added an `otelTarget` runtime struct that pairs a trace client with an optional metrics exporter and a resolved service name. `OtelPlugin` now holds a `targets` slice instead of a single client/exporter.
- `convertTraceToResourceSpan`, `getResourceAttributes`, and `getInstrumentationScope` now accept a `serviceName` argument so each profile's resource attributes reflect its own identity. `convertSpanToOTELSpan` was promoted to a package-level function since it no longer needs plugin state.
- TLS config construction was consolidated into a single `buildTLSConfig` helper in a new `utils.go`, eliminating duplicated `crypto/tls` and `crypto/x509` setup across `grpc.go`, `http.go`, and `metrics.go`. `validateCACertPath` was also moved there.
- `injectEnvToHeaders` replaces `resolveHeaders`: headers are now stored as plain strings using the `"env.VAR_NAME"` convention and resolved at `Init` time, removing the `map[string]*schemas.EnvVar` header type from `Profile`.
- `MarshalForStorage` always emits the canonical `{"profiles": [...]}` wrapper regardless of input shape.
- `Redacted` and `redactHeaderValue` were updated to operate over the profiles slice; literal header values are masked while `"env."` references are preserved.
- `recordMetricsFromTrace` now accepts an explicit `*MetricsExporter` argument instead of reading from plugin state, allowing it to be called once per target.
- `Cleanup` shuts down every target's metrics exporter and closes every trace client, returning the first close error.
- The UI form was refactored around a `profiles` field array. Each profile renders as a collapsible `OtelProfileSection` with its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load via `buildDefaults`. `toHeaderStringMap` and `toEnvRefString` helpers were added to `envVarForm.ts` to flatten `EnvVar` form values to the plain-string map the backend expects. The `otelFormSchema` was updated to validate a `profiles` array, with per-profile `enabled` gating skipping validation for disabled profiles.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```
- [x] https
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] grpc (same setup should work)
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] with single collector config
- [x] with multiple collector config
- [x] instance attributes intact
- [x] header injection logic intact
- [x] helm (single + multiple)
```
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
- `validateCACertPath` rejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.
- `buildTLSConfig` enforces `tls.VersionTLS12` as the minimum TLS version across all transports.
- Header values that are literal strings are masked in `Redacted()` API responses; `"env."` references are returned as-is since they do not expose the secret value.
- `injectEnvToHeaders` errors if a referenced environment variable is unset, preventing silent misconfiguration.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Multi-profile OTEL support: add/remove/manage multiple independent export profiles with per-profile collector, service name, headers, protocol, TLS, and metrics toggles.
- **Behavior Changes**
- TLS handling consolidated: custom CA and insecure modes only permit plaintext/insecure when no CA is provided; TLS config centrally built otherwise.
- Export/metrics now operate per-profile.
- **Validation**
- Per-profile enabled flag (default true); enabled profiles require a collector URL.
- **UX**
- Form and header env-var handling improved for persistence and submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## ✨ Features - **OpenAI Compaction** — Added OpenAI conversation compaction support across core, framework, logging, and the API surface (#4053) - **Multi-Customer & Org Hierarchy** — Logs and usage tracking now support multiple customers, teams, and business units, including business unit CRUD, team assignment, and governance endpoints in the OpenAPI spec (#4066, #4041, #4082) - **Provider-Level Governance** — Budgets & limits are now scope-aware and can be applied at the virtual-key top level and per provider, wired from the model configs table, with UI filters for scope and providers (#3938, #3937, #3939, #3981, #3962) - **Customer Budgets** — Customers support multiple budgets and `calendar_aligned` budget windows (#3998, #3997) - **Virtual Key Attribution & Controls** — Added a `created_by` user attribution column and a `blacklisted_models` column for virtual key provider configs (#3672, #3653) - **Request Header Capture** — OTel and Maxim observability plugins capture `request_headers` by pattern, with wildcard support (e.g. `x-custom-*`); logging gained the same wildcard header capture (#4012, #3958) - **OTel Content Controls & Collectors** — New `disable_content_logging` option drops message/tool content from exported spans, plus support for multiple OTel collectors (#4064, #3894) - **xAI x_search** — Added xAI `x_search` tool support (#3976) - **URL Validation** — Added fetch URL validation with private-network configuration and link-local blocking (#3947, #3991) - **File Scheme Pricing URLs** — Pricing source URLs now accept the `file://` scheme for air-gapped and self-hosted deployments (#4045) - **Paginated Virtual Keys** — Virtual key fetching is paginated to handle deployments with very large numbers of keys (#3957) - **Client IP Resolution** — Resolve client IP from `X-Forwarded-For`/`X-Real-IP` headers - **SCIM Provisioning** — Added `attributeType`/`attributeValue` SCIM provisioning fields - **Helm/Config Schema** — Added `roles` RBAC governance config and `per_user_oauth` MCP auth to the Helm chart and config schema (#4004, #4009) - **Log Navigation UI** — Added a "View logs" menu item to customer, team, and virtual key tables, clickable links in log detail views, a customer detail sheet, and a reusable `BudgetDisplay` component (#4073, #4054, #4026, #4055) - **Faster First Paint** — Added an inline loading shell to `#root` before React mounts (#4063) - **Materialized View Alias** — Added an `alias` column to the materialized view with filter support (#4078) ## 🐞 Fixed - **Fetch URL IP Checks** — Hardened fetch URL IP checks against SSRF (#4092) - **Mantle Model Matching** — Broadened Mantle model matching to all `gpt` variants (#4091) - **Empty Thinking Blocks** — Strip thinking blocks when the signature is empty (#4079) - **OpenAI Stream Usage** — Removed usage from the `responses.created` event in the OpenAI stream (#4080) - **Prompt Cache Key** — Set the prompt cache key from the Anthropic integration (#4086) - **Upstream Failure Status** — Map upstream connection failures to 502 instead of 400 (#3929) (thanks [@chris-colinsky](https://github.com/chris-colinsky)!) - **Gemini Schema Constraints** — Accept numeric schema integer constraints for Gemini (#3994) (thanks [@yanhao98](https://github.com/yanhao98)!) - **Files Provider Param** — Accept the `?provider=` query param on `GET /v1/files` (#3971) (thanks [@alexef](https://github.com/alexef)!) - **Optional Batch Model** — Made the `model` field optional on `POST /v1/batches` (#3973) (thanks [@alexef](https://github.com/alexef)!) - **Helm Azure Config** — Added missing `azure_key_config` fields to the Helm schema (#3996) (thanks [@axelray-dev](https://github.com/axelray-dev)!) - **Text Completion Chunk Model** — Added the missing `Model` field to `TextCompletionChunkResponse` (#3970) (thanks [@kuishou68](https://github.com/kuishou68)!) - **MCP Inline stdio Env** — MCP stdio server configs accept inline environment variable assignments (#3861) (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) - **Orphaned Tool Results** — Orphaned tool results in the OpenAI to Anthropic conversion flow are no longer rejected by the Anthropic API (#3919) - **Node Usage Reconciliation** — Added a monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (#3664) - **Bedrock Output Assessments** — Corrected the type of `outputAssessments` in Bedrock responses (#4028) - **Model Pool Pricing Reloads** — Preserve non-pricing model pool entries across pricing reloads (#3999) - **Ghost Node Reconciliation** — Replicate the VK hierarchy flow for ghost node reconciliation (#4088) - **VK Double Usage Counting** — Fixed double usage counting when creating a virtual key (#4070) - **Model Config Lifecycle** — Cascade deletes for model configs and removal of stale in-memory model configs (#4051, #4043) - **FTS Index Cap** — Reduced the FTS index `left()` cap from 800k to 250k chars to stay within the tsvector limit (#4057) - **Sync Worker Drift** — Reduced the sync worker ticker period to 5m to prevent threshold drift (#4023) - **Passthrough** — Fixed passthrough budgets, gated passthrough models per VK, model extraction for Azure passthrough, and restricted fallbacks/provider selection to the VK boundary (#3941, #3988, #3983, #3924) - **Provider Response Headers** — Strip provider response headers and add a content-type filter (#3955, #4024) - **Stream Handling** — Drain non-SSE stream readers and retry stale connections (#3956, #3967) - **Azure Claude** — Strip Azure diagnostic property for Claude models (#3925) - **Compat max_tokens** — Preserve chat `max_tokens` during param filtering (#3992) - **Raw Request Flag** — Removed the raw request flag from providers that don't support it (#4058) - **UI Fixes** — Standardized page container layout, virtual key model configs UI, and dashboard chart tooltips (#4046, #4052, #4044) ## 🔧 Maintenance - **Dependency Upgrades** — Bumped transitive `golang.org/x` dependencies (crypto, net, sys, text) for Docker Scout CVE remediation and `recharts` to 3.8.1; cascaded version bumps across all modules (#3900, #4003)
## Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
## Changes
- Introduced a `Profile` struct to hold per-collector configuration, replacing the flat fields on `Config`. `Config` now holds a `Profiles []*Profile` slice plus a shared `PluginSpanFilter`.
- `Config.UnmarshalJSON` normalizes both the new `{"profiles": [...]}` wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.
- Added an `otelTarget` runtime struct that pairs a trace client with an optional metrics exporter and a resolved service name. `OtelPlugin` now holds a `targets` slice instead of a single client/exporter.
- `convertTraceToResourceSpan`, `getResourceAttributes`, and `getInstrumentationScope` now accept a `serviceName` argument so each profile's resource attributes reflect its own identity. `convertSpanToOTELSpan` was promoted to a package-level function since it no longer needs plugin state.
- TLS config construction was consolidated into a single `buildTLSConfig` helper in a new `utils.go`, eliminating duplicated `crypto/tls` and `crypto/x509` setup across `grpc.go`, `http.go`, and `metrics.go`. `validateCACertPath` was also moved there.
- `injectEnvToHeaders` replaces `resolveHeaders`: headers are now stored as plain strings using the `"env.VAR_NAME"` convention and resolved at `Init` time, removing the `map[string]*schemas.EnvVar` header type from `Profile`.
- `MarshalForStorage` always emits the canonical `{"profiles": [...]}` wrapper regardless of input shape.
- `Redacted` and `redactHeaderValue` were updated to operate over the profiles slice; literal header values are masked while `"env."` references are preserved.
- `recordMetricsFromTrace` now accepts an explicit `*MetricsExporter` argument instead of reading from plugin state, allowing it to be called once per target.
- `Cleanup` shuts down every target's metrics exporter and closes every trace client, returning the first close error.
- The UI form was refactored around a `profiles` field array. Each profile renders as a collapsible `OtelProfileSection` with its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load via `buildDefaults`. `toHeaderStringMap` and `toEnvRefString` helpers were added to `envVarForm.ts` to flatten `EnvVar` form values to the plain-string map the backend expects. The `otelFormSchema` was updated to validate a `profiles` array, with per-profile `enabled` gating skipping validation for disabled profiles.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```
- [x] https
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] grpc (same setup should work)
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] with single collector config
- [x] with multiple collector config
- [x] instance attributes intact
- [x] header injection logic intact
- [x] helm (single + multiple)
```
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
- `validateCACertPath` rejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.
- `buildTLSConfig` enforces `tls.VersionTLS12` as the minimum TLS version across all transports.
- Header values that are literal strings are masked in `Redacted()` API responses; `"env."` references are returned as-is since they do not expose the secret value.
- `injectEnvToHeaders` errors if a referenced environment variable is unset, preventing silent misconfiguration.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Multi-profile OTEL support: add/remove/manage multiple independent export profiles with per-profile collector, service name, headers, protocol, TLS, and metrics toggles.
- **Behavior Changes**
- TLS handling consolidated: custom CA and insecure modes only permit plaintext/insecure when no CA is provided; TLS config centrally built otherwise.
- Export/metrics now operate per-profile.
- **Validation**
- Per-profile enabled flag (default true); enabled profiles require a collector URL.
- **UX**
- Form and header env-var handling improved for persistence and submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## ✨ Features - **OpenAI Compaction** — Added OpenAI conversation compaction support across core, framework, logging, and the API surface (maximhq#4053) - **Multi-Customer & Org Hierarchy** — Logs and usage tracking now support multiple customers, teams, and business units, including business unit CRUD, team assignment, and governance endpoints in the OpenAPI spec (maximhq#4066, maximhq#4041, maximhq#4082) - **Provider-Level Governance** — Budgets & limits are now scope-aware and can be applied at the virtual-key top level and per provider, wired from the model configs table, with UI filters for scope and providers (maximhq#3938, maximhq#3937, maximhq#3939, maximhq#3981, maximhq#3962) - **Customer Budgets** — Customers support multiple budgets and `calendar_aligned` budget windows (maximhq#3998, maximhq#3997) - **Virtual Key Attribution & Controls** — Added a `created_by` user attribution column and a `blacklisted_models` column for virtual key provider configs (maximhq#3672, maximhq#3653) - **Request Header Capture** — OTel and Maxim observability plugins capture `request_headers` by pattern, with wildcard support (e.g. `x-custom-*`); logging gained the same wildcard header capture (maximhq#4012, maximhq#3958) - **OTel Content Controls & Collectors** — New `disable_content_logging` option drops message/tool content from exported spans, plus support for multiple OTel collectors (maximhq#4064, maximhq#3894) - **xAI x_search** — Added xAI `x_search` tool support (maximhq#3976) - **URL Validation** — Added fetch URL validation with private-network configuration and link-local blocking (maximhq#3947, maximhq#3991) - **File Scheme Pricing URLs** — Pricing source URLs now accept the `file://` scheme for air-gapped and self-hosted deployments (maximhq#4045) - **Paginated Virtual Keys** — Virtual key fetching is paginated to handle deployments with very large numbers of keys (maximhq#3957) - **Client IP Resolution** — Resolve client IP from `X-Forwarded-For`/`X-Real-IP` headers - **SCIM Provisioning** — Added `attributeType`/`attributeValue` SCIM provisioning fields - **Helm/Config Schema** — Added `roles` RBAC governance config and `per_user_oauth` MCP auth to the Helm chart and config schema (maximhq#4004, maximhq#4009) - **Log Navigation UI** — Added a "View logs" menu item to customer, team, and virtual key tables, clickable links in log detail views, a customer detail sheet, and a reusable `BudgetDisplay` component (maximhq#4073, maximhq#4054, maximhq#4026, maximhq#4055) - **Faster First Paint** — Added an inline loading shell to `#root` before React mounts (maximhq#4063) - **Materialized View Alias** — Added an `alias` column to the materialized view with filter support (maximhq#4078) ## 🐞 Fixed - **Fetch URL IP Checks** — Hardened fetch URL IP checks against SSRF (maximhq#4092) - **Mantle Model Matching** — Broadened Mantle model matching to all `gpt` variants (maximhq#4091) - **Empty Thinking Blocks** — Strip thinking blocks when the signature is empty (maximhq#4079) - **OpenAI Stream Usage** — Removed usage from the `responses.created` event in the OpenAI stream (maximhq#4080) - **Prompt Cache Key** — Set the prompt cache key from the Anthropic integration (maximhq#4086) - **Upstream Failure Status** — Map upstream connection failures to 502 instead of 400 (maximhq#3929) (thanks [@chris-colinsky](https://github.com/chris-colinsky)!) - **Gemini Schema Constraints** — Accept numeric schema integer constraints for Gemini (maximhq#3994) (thanks [@yanhao98](https://github.com/yanhao98)!) - **Files Provider Param** — Accept the `?provider=` query param on `GET /v1/files` (maximhq#3971) (thanks [@alexef](https://github.com/alexef)!) - **Optional Batch Model** — Made the `model` field optional on `POST /v1/batches` (maximhq#3973) (thanks [@alexef](https://github.com/alexef)!) - **Helm Azure Config** — Added missing `azure_key_config` fields to the Helm schema (maximhq#3996) (thanks [@axelray-dev](https://github.com/axelray-dev)!) - **Text Completion Chunk Model** — Added the missing `Model` field to `TextCompletionChunkResponse` (maximhq#3970) (thanks [@kuishou68](https://github.com/kuishou68)!) - **MCP Inline stdio Env** — MCP stdio server configs accept inline environment variable assignments (maximhq#3861) (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) - **Orphaned Tool Results** — Orphaned tool results in the OpenAI to Anthropic conversion flow are no longer rejected by the Anthropic API (maximhq#3919) - **Node Usage Reconciliation** — Added a monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (maximhq#3664) - **Bedrock Output Assessments** — Corrected the type of `outputAssessments` in Bedrock responses (maximhq#4028) - **Model Pool Pricing Reloads** — Preserve non-pricing model pool entries across pricing reloads (maximhq#3999) - **Ghost Node Reconciliation** — Replicate the VK hierarchy flow for ghost node reconciliation (maximhq#4088) - **VK Double Usage Counting** — Fixed double usage counting when creating a virtual key (maximhq#4070) - **Model Config Lifecycle** — Cascade deletes for model configs and removal of stale in-memory model configs (maximhq#4051, maximhq#4043) - **FTS Index Cap** — Reduced the FTS index `left()` cap from 800k to 250k chars to stay within the tsvector limit (maximhq#4057) - **Sync Worker Drift** — Reduced the sync worker ticker period to 5m to prevent threshold drift (maximhq#4023) - **Passthrough** — Fixed passthrough budgets, gated passthrough models per VK, model extraction for Azure passthrough, and restricted fallbacks/provider selection to the VK boundary (maximhq#3941, maximhq#3988, maximhq#3983, maximhq#3924) - **Provider Response Headers** — Strip provider response headers and add a content-type filter (maximhq#3955, maximhq#4024) - **Stream Handling** — Drain non-SSE stream readers and retry stale connections (maximhq#3956, maximhq#3967) - **Azure Claude** — Strip Azure diagnostic property for Claude models (maximhq#3925) - **Compat max_tokens** — Preserve chat `max_tokens` during param filtering (maximhq#3992) - **Raw Request Flag** — Removed the raw request flag from providers that don't support it (maximhq#4058) - **UI Fixes** — Standardized page container layout, virtual key model configs UI, and dashboard chart tooltips (maximhq#4046, maximhq#4052, maximhq#4044) ## 🔧 Maintenance - **Dependency Upgrades** — Bumped transitive `golang.org/x` dependencies (crypto, net, sys, text) for Docker Scout CVE remediation and `recharts` to 3.8.1; cascaded version bumps across all modules (maximhq#3900, maximhq#4003)
## Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
## Changes
- Introduced a `Profile` struct to hold per-collector configuration, replacing the flat fields on `Config`. `Config` now holds a `Profiles []*Profile` slice plus a shared `PluginSpanFilter`.
- `Config.UnmarshalJSON` normalizes both the new `{"profiles": [...]}` wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.
- Added an `otelTarget` runtime struct that pairs a trace client with an optional metrics exporter and a resolved service name. `OtelPlugin` now holds a `targets` slice instead of a single client/exporter.
- `convertTraceToResourceSpan`, `getResourceAttributes`, and `getInstrumentationScope` now accept a `serviceName` argument so each profile's resource attributes reflect its own identity. `convertSpanToOTELSpan` was promoted to a package-level function since it no longer needs plugin state.
- TLS config construction was consolidated into a single `buildTLSConfig` helper in a new `utils.go`, eliminating duplicated `crypto/tls` and `crypto/x509` setup across `grpc.go`, `http.go`, and `metrics.go`. `validateCACertPath` was also moved there.
- `injectEnvToHeaders` replaces `resolveHeaders`: headers are now stored as plain strings using the `"env.VAR_NAME"` convention and resolved at `Init` time, removing the `map[string]*schemas.EnvVar` header type from `Profile`.
- `MarshalForStorage` always emits the canonical `{"profiles": [...]}` wrapper regardless of input shape.
- `Redacted` and `redactHeaderValue` were updated to operate over the profiles slice; literal header values are masked while `"env."` references are preserved.
- `recordMetricsFromTrace` now accepts an explicit `*MetricsExporter` argument instead of reading from plugin state, allowing it to be called once per target.
- `Cleanup` shuts down every target's metrics exporter and closes every trace client, returning the first close error.
- The UI form was refactored around a `profiles` field array. Each profile renders as a collapsible `OtelProfileSection` with its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load via `buildDefaults`. `toHeaderStringMap` and `toEnvRefString` helpers were added to `envVarForm.ts` to flatten `EnvVar` form values to the plain-string map the backend expects. The `otelFormSchema` was updated to validate a `profiles` array, with per-profile `enabled` gating skipping validation for disabled profiles.
## Type of change
- [ ] Bug fix
- [x] Feature
- [x] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [x] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [x] Plugins
- [x] UI (React)
- [ ] Docs
## How to test
```
- [x] https
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] grpc (same setup should work)
- [x] single config (legacy)
- [x] multiple services, singe collector
- [x] multiple collector
- [x] with tls, multiple services + multiple collector
- [x] with single collector config
- [x] with multiple collector config
- [x] instance attributes intact
- [x] header injection logic intact
- [x] helm (single + multiple)
```
## Breaking changes
- [ ] Yes
- [x] No
## Security considerations
- `validateCACertPath` rejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.
- `buildTLSConfig` enforces `tls.VersionTLS12` as the minimum TLS version across all transports.
- Header values that are literal strings are masked in `Redacted()` API responses; `"env."` references are returned as-is since they do not expose the secret value.
- `injectEnvToHeaders` errors if a referenced environment variable is unset, preventing silent misconfiguration.
## Checklist
- [x] I read `docs/contributing/README.md` and followed the guidelines
- [x] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [x] I verified the CI pipeline passes locally if applicable
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Multi-profile OTEL support: add/remove/manage multiple independent export profiles with per-profile collector, service name, headers, protocol, TLS, and metrics toggles.
- **Behavior Changes**
- TLS handling consolidated: custom CA and insecure modes only permit plaintext/insecure when no CA is provided; TLS config centrally built otherwise.
- Export/metrics now operate per-profile.
- **Validation**
- Per-profile enabled flag (default true); enabled profiles require a collector URL.
- **UX**
- Form and header env-var handling improved for persistence and submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## ✨ Features - **OpenAI Compaction** — Added OpenAI conversation compaction support across core, framework, logging, and the API surface (maximhq#4053) - **Multi-Customer & Org Hierarchy** — Logs and usage tracking now support multiple customers, teams, and business units, including business unit CRUD, team assignment, and governance endpoints in the OpenAPI spec (maximhq#4066, maximhq#4041, maximhq#4082) - **Provider-Level Governance** — Budgets & limits are now scope-aware and can be applied at the virtual-key top level and per provider, wired from the model configs table, with UI filters for scope and providers (maximhq#3938, maximhq#3937, maximhq#3939, maximhq#3981, maximhq#3962) - **Customer Budgets** — Customers support multiple budgets and `calendar_aligned` budget windows (maximhq#3998, maximhq#3997) - **Virtual Key Attribution & Controls** — Added a `created_by` user attribution column and a `blacklisted_models` column for virtual key provider configs (maximhq#3672, maximhq#3653) - **Request Header Capture** — OTel and Maxim observability plugins capture `request_headers` by pattern, with wildcard support (e.g. `x-custom-*`); logging gained the same wildcard header capture (maximhq#4012, maximhq#3958) - **OTel Content Controls & Collectors** — New `disable_content_logging` option drops message/tool content from exported spans, plus support for multiple OTel collectors (maximhq#4064, maximhq#3894) - **xAI x_search** — Added xAI `x_search` tool support (maximhq#3976) - **URL Validation** — Added fetch URL validation with private-network configuration and link-local blocking (maximhq#3947, maximhq#3991) - **File Scheme Pricing URLs** — Pricing source URLs now accept the `file://` scheme for air-gapped and self-hosted deployments (maximhq#4045) - **Paginated Virtual Keys** — Virtual key fetching is paginated to handle deployments with very large numbers of keys (maximhq#3957) - **Client IP Resolution** — Resolve client IP from `X-Forwarded-For`/`X-Real-IP` headers - **SCIM Provisioning** — Added `attributeType`/`attributeValue` SCIM provisioning fields - **Helm/Config Schema** — Added `roles` RBAC governance config and `per_user_oauth` MCP auth to the Helm chart and config schema (maximhq#4004, maximhq#4009) - **Log Navigation UI** — Added a "View logs" menu item to customer, team, and virtual key tables, clickable links in log detail views, a customer detail sheet, and a reusable `BudgetDisplay` component (maximhq#4073, maximhq#4054, maximhq#4026, maximhq#4055) - **Faster First Paint** — Added an inline loading shell to `#root` before React mounts (maximhq#4063) - **Materialized View Alias** — Added an `alias` column to the materialized view with filter support (maximhq#4078) ## 🐞 Fixed - **Fetch URL IP Checks** — Hardened fetch URL IP checks against SSRF (maximhq#4092) - **Mantle Model Matching** — Broadened Mantle model matching to all `gpt` variants (maximhq#4091) - **Empty Thinking Blocks** — Strip thinking blocks when the signature is empty (maximhq#4079) - **OpenAI Stream Usage** — Removed usage from the `responses.created` event in the OpenAI stream (maximhq#4080) - **Prompt Cache Key** — Set the prompt cache key from the Anthropic integration (maximhq#4086) - **Upstream Failure Status** — Map upstream connection failures to 502 instead of 400 (maximhq#3929) (thanks [@chris-colinsky](https://github.com/chris-colinsky)!) - **Gemini Schema Constraints** — Accept numeric schema integer constraints for Gemini (maximhq#3994) (thanks [@yanhao98](https://github.com/yanhao98)!) - **Files Provider Param** — Accept the `?provider=` query param on `GET /v1/files` (maximhq#3971) (thanks [@alexef](https://github.com/alexef)!) - **Optional Batch Model** — Made the `model` field optional on `POST /v1/batches` (maximhq#3973) (thanks [@alexef](https://github.com/alexef)!) - **Helm Azure Config** — Added missing `azure_key_config` fields to the Helm schema (maximhq#3996) (thanks [@axelray-dev](https://github.com/axelray-dev)!) - **Text Completion Chunk Model** — Added the missing `Model` field to `TextCompletionChunkResponse` (maximhq#3970) (thanks [@kuishou68](https://github.com/kuishou68)!) - **MCP Inline stdio Env** — MCP stdio server configs accept inline environment variable assignments (maximhq#3861) (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) - **Orphaned Tool Results** — Orphaned tool results in the OpenAI to Anthropic conversion flow are no longer rejected by the Anthropic API (maximhq#3919) - **Node Usage Reconciliation** — Added a monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (maximhq#3664) - **Bedrock Output Assessments** — Corrected the type of `outputAssessments` in Bedrock responses (maximhq#4028) - **Model Pool Pricing Reloads** — Preserve non-pricing model pool entries across pricing reloads (maximhq#3999) - **Ghost Node Reconciliation** — Replicate the VK hierarchy flow for ghost node reconciliation (maximhq#4088) - **VK Double Usage Counting** — Fixed double usage counting when creating a virtual key (maximhq#4070) - **Model Config Lifecycle** — Cascade deletes for model configs and removal of stale in-memory model configs (maximhq#4051, maximhq#4043) - **FTS Index Cap** — Reduced the FTS index `left()` cap from 800k to 250k chars to stay within the tsvector limit (maximhq#4057) - **Sync Worker Drift** — Reduced the sync worker ticker period to 5m to prevent threshold drift (maximhq#4023) - **Passthrough** — Fixed passthrough budgets, gated passthrough models per VK, model extraction for Azure passthrough, and restricted fallbacks/provider selection to the VK boundary (maximhq#3941, maximhq#3988, maximhq#3983, maximhq#3924) - **Provider Response Headers** — Strip provider response headers and add a content-type filter (maximhq#3955, maximhq#4024) - **Stream Handling** — Drain non-SSE stream readers and retry stale connections (maximhq#3956, maximhq#3967) - **Azure Claude** — Strip Azure diagnostic property for Claude models (maximhq#3925) - **Compat max_tokens** — Preserve chat `max_tokens` during param filtering (maximhq#3992) - **Raw Request Flag** — Removed the raw request flag from providers that don't support it (maximhq#4058) - **UI Fixes** — Standardized page container layout, virtual key model configs UI, and dashboard chart tooltips (maximhq#4046, maximhq#4052, maximhq#4044) ## 🔧 Maintenance - **Dependency Upgrades** — Bumped transitive `golang.org/x` dependencies (crypto, net, sys, text) for Docker Scout CVE remediation and `recharts` to 3.8.1; cascaded version bumps across all modules (maximhq#3900, maximhq#4003)

Summary
The OTEL plugin previously supported a single collector target per configuration. This PR introduces multi-profile support, allowing Bifrost to export traces and metrics to multiple OTEL collectors simultaneously, each with its own service name, endpoint, TLS settings, headers, and metrics configuration.
Changes
Profilestruct to hold per-collector configuration, replacing the flat fields onConfig.Confignow holds aProfiles []*Profileslice plus a sharedPluginSpanFilter.Config.UnmarshalJSONnormalizes both the new{"profiles": [...]}wrapper shape and the legacy single-object shape, so existing stored configs continue to work without migration.otelTargetruntime struct that pairs a trace client with an optional metrics exporter and a resolved service name.OtelPluginnow holds atargetsslice instead of a single client/exporter.convertTraceToResourceSpan,getResourceAttributes, andgetInstrumentationScopenow accept aserviceNameargument so each profile's resource attributes reflect its own identity.convertSpanToOTELSpanwas promoted to a package-level function since it no longer needs plugin state.buildTLSConfighelper in a newutils.go, eliminating duplicatedcrypto/tlsandcrypto/x509setup acrossgrpc.go,http.go, andmetrics.go.validateCACertPathwas also moved there.injectEnvToHeadersreplacesresolveHeaders: headers are now stored as plain strings using the"env.VAR_NAME"convention and resolved atInittime, removing themap[string]*schemas.EnvVarheader type fromProfile.MarshalForStoragealways emits the canonical{"profiles": [...]}wrapper regardless of input shape.RedactedandredactHeaderValuewere updated to operate over the profiles slice; literal header values are masked while"env."references are preserved.recordMetricsFromTracenow accepts an explicit*MetricsExporterargument instead of reading from plugin state, allowing it to be called once per target.Cleanupshuts down every target's metrics exporter and closes every trace client, returning the first close error.profilesfield array. Each profile renders as a collapsibleOtelProfileSectionwith its own enable toggle and remove button. An "Add Profile" button appends a new empty profile. Both the new wrapper shape and the legacy single-object shape are normalized on load viabuildDefaults.toHeaderStringMapandtoEnvRefStringhelpers were added toenvVarForm.tsto flattenEnvVarform values to the plain-string map the backend expects. TheotelFormSchemawas updated to validate aprofilesarray, with per-profileenabledgating skipping validation for disabled profiles.Type of change
Affected areas
How to test
Breaking changes
Security considerations
validateCACertPathrejects relative paths, symlinks, and non-regular files to prevent path traversal when loading custom CA certificates.buildTLSConfigenforcestls.VersionTLS12as the minimum TLS version across all transports.Redacted()API responses;"env."references are returned as-is since they do not expose the secret value.injectEnvToHeaderserrors if a referenced environment variable is unset, preventing silent misconfiguration.Checklist
docs/contributing/README.mdand followed the guidelinesSummary by CodeRabbit
New Features
Behavior Changes
Validation
UX