Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -6176,6 +6176,10 @@ func executeRequestWithRetries[T any](
}
resp.PopulateExtraFields(requestType, providerKey, model, resolvedModelUsed)
tracer.PopulateLLMResponseAttributes(ctx, handle, resp, bifrostError)
} else if bifrostError != nil {
// Failed stream requests carry a chan result and miss the cast above;
// stamp error attributes explicitly so spans don't report unknown.
tracer.PopulateLLMResponseAttributes(ctx, handle, nil, bifrostError)
}

// End span with appropriate status
Expand Down
3 changes: 3 additions & 0 deletions core/providers/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3398,6 +3398,9 @@ func completeDeferredSpan(ctx *schemas.BifrostContext, result *schemas.BifrostRe
} else if result != nil {
// Fall back to final chunk if no accumulated data (shouldn't happen normally)
tracer.PopulateLLMResponseAttributes(ctx, handle, result, err)
} else if err != nil {
// Stream failed before the first chunk — still stamp error attributes.
tracer.PopulateLLMResponseAttributes(ctx, handle, nil, err)
}

// Finalize aggregated post-hook spans before ending the LLM span
Expand Down
94 changes: 91 additions & 3 deletions tests/e2e/api/runners/run-observability-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const providerName = `otel-e2e-${process.pid}-${Date.now()}`;
const modelName = "hello-world";
const requestedModel = `${providerName}/${modelName}`;
const requestID = `otel-e2e-request-${process.pid}-${Date.now()}`;
const errorRequestID = `otel-e2e-error-${process.pid}-${Date.now()}`;
const streamErrorRequestID = `otel-e2e-stream-error-${process.pid}-${Date.now()}`;

// Message marker that makes the mock provider return a 404 error body.
const ERROR_TRIGGER = "trigger-error";

const state = {
otelTraceRequests: [],
Expand Down Expand Up @@ -74,6 +79,19 @@ function createOpenAIMock() {
headers: req.headers,
body: body.toString("utf8"),
});
// Error scenarios: the trigger marker gets a provider-style 404, streaming
// or not — this is the pre-first-chunk failure path.
if (body.toString("utf8").includes(ERROR_TRIGGER)) {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({
error: {
message: "The model does not exist",
type: "invalid_request_error",
code: "model_not_found",
},
}));
return;
}
const now = Math.floor(Date.now() / 1000);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({
Expand Down Expand Up @@ -171,6 +189,7 @@ async function addLocalProvider(mockPort) {
is_key_less: true,
allowed_requests: {
chat_completion: true,
chat_completion_stream: true,
},
},
network_config: {
Expand Down Expand Up @@ -204,6 +223,24 @@ async function chatHelloWorld() {
}
}

// chatError fires a request the mock fails with a 404 error body; stream: true
// exercises the pre-first-chunk stream failure path.
async function chatError(id, stream) {
const res = await request("POST", "/v1/chat/completions", {
model: requestedModel,
messages: [{ role: "user", content: ERROR_TRIGGER }],
...(stream ? { stream: true } : {}),
}, {
"x-request-id": id,
});
if (res.ok) {
throw new Error(`error request (stream=${stream}) unexpectedly succeeded: ${res.text}`);
}
if (res.status !== 404) {
throw new Error(`error request (stream=${stream}) status=${res.status}, want 404: ${res.text}`);
}
}

async function poll(name, timeoutMs, fn) {
const started = Date.now();
let lastError;
Expand Down Expand Up @@ -270,6 +307,46 @@ async function assertOtelMetricsReceived() {
]);
}

// assertOtelErrorTrace checks the error span export carries the provider error
// attributes. The stream case pins core's deferred-span error stamping, which
// regressed silently before (spans exported with no gen_ai.error.* when a
// stream failed before its first chunk).
async function assertOtelErrorTrace(id, label) {
const entry = await poll(`OTEL ${label} trace receiver`, 20000,
() => state.otelTraceRequests.find((item) => item.body.includes(Buffer.from(id))));
assertBufferContainsAll(`OTEL ${label} trace export`, entry.body, [
id,
"gen_ai.error.type",
"invalid_request_error",
"gen_ai.error.code",
"model_not_found",
"http.response.status_code",
]);
}

// assertPrometheusErrorScrape checks bifrost_error_requests_total carries the
// status_code label for both the non-stream and stream error calls.
async function assertPrometheusErrorScrape() {
await poll("Prometheus error scrape", 20000, async () => {
const res = await request("GET", "/metrics");
if (!res.ok) {
throw new Error(`GET /metrics failed with ${res.status}: ${res.text}`);
}
const failures = [];
for (const method of ["chat_completion", "chat_completion_stream"]) {
const line = findPrometheusSample(res.text, "bifrost_error_requests_total",
{ provider: providerName, method, status_code: "404" });
if (!line || parsePrometheusValue(line) < 1) {
failures.push(method);
}
}
if (failures.length > 0) {
throw new Error(`bifrost_error_requests_total{status_code="404"} missing for: ${failures.join(", ")}`);
}
return true;
});
}

function assertBufferContainsAll(name, body, values) {
for (const value of values) {
if (!body.includes(Buffer.from(value))) {
Expand Down Expand Up @@ -414,9 +491,9 @@ function assertMetricsMatchLogs(metrics, log) {
}
}

function assertMockProviderRequest() {
if (state.mockRequests.length !== 1) {
throw new Error(`expected exactly one mock provider request, got ${state.mockRequests.length}`);
function assertMockProviderRequest(wantCount = 1) {
if (state.mockRequests.length !== wantCount) {
throw new Error(`expected ${wantCount} mock provider request(s), got ${state.mockRequests.length}`);
}
let body;
try {
Expand Down Expand Up @@ -482,11 +559,22 @@ async function main() {
const log = await assertLoggingTrace();
assertMetricsMatchLogs(metrics, log);

// Error scenarios: non-stream provider 404, then a stream failing before
// its first chunk. Both must export gen_ai.error.* span attributes and a
// status_code-labeled error counter.
await chatError(errorRequestID, false);
await assertOtelErrorTrace(errorRequestID, "error");
await chatError(streamErrorRequestID, true);
await assertOtelErrorTrace(streamErrorRequestID, "stream-error");
await assertPrometheusErrorScrape();
assertMockProviderRequest(3);

console.log(` OTEL trace exports received: ${state.otelTraceRequests.length}`);
console.log(` OTEL metric exports received: ${state.otelMetricRequests.length}`);
console.log(` Prometheus scrape includes provider="${providerName}" model="${requestedModel}"`);
console.log(` Metrics/logs token usage reconciled (scrape == logs)`);
console.log(` Logging trace API returned id="${requestID}"`);
console.log(` Error spans carry gen_ai.error.* and error counter has status_code (non-stream and stream).`);
console.log("Local observability API check passed.");
} finally {
try {
Expand Down
6 changes: 3 additions & 3 deletions ui/app/pprof/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ function LeakTable({
{candidates.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-zinc-500">
No obvious leak signatures all live allocations have normal retention ratios.
No obvious leak signatures; all live allocations have normal retention ratios.
</td>
</tr>
)}
Expand Down Expand Up @@ -1076,7 +1076,7 @@ export default function PprofPage() {
<span className="text-sm text-zinc-500">({sortedInuseAllocations.length} sites)</span>
</div>
<p className="mt-1 text-xs text-zinc-500">
Call stacks currently holding memory on the heap right now — expand a row to see the full stack.
Call stacks currently holding memory on the heap right now. Expand a row to see the full stack.
</p>
</div>
<AllocationTable
Expand All @@ -1100,7 +1100,7 @@ export default function PprofPage() {
<span className="text-sm text-zinc-500">({sortedAllocations.length} sites)</span>
</div>
<p className="mt-1 text-xs text-zinc-500">
Total bytes allocated since process start (includes memory already freed) — expand a row to see the full stack.
Total bytes allocated since process start (includes memory already freed). Expand a row to see the full stack.
</p>
</div>
<AllocationTable
Expand Down
14 changes: 7 additions & 7 deletions ui/app/workspace/config/views/loggingView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export default function LoggingView() {
</label>
<p className="text-muted-foreground text-sm">
When enabled, only usage metadata (latency, cost, token count, status, routing IDs, etc.) is logged. Request/response
content messages, params, tool calls, and any raw provider bytes is dropped from log records, even when{" "}
content (messages, params, tool calls, and any raw provider bytes) is dropped from log records, even when{" "}
<code className="text-xs">store_raw_request_response</code> is on. Raw-byte send-back to callers via{" "}
<code className="text-xs">send_back_raw_*</code> is unaffected.
</p>
Expand All @@ -144,8 +144,8 @@ export default function LoggingView() {
Retain Content in Object Storage
</label>
<p className="text-muted-foreground text-sm">
When enabled, requests with content logging disabled via the global setting above or the{" "}
<code className="text-xs">x-bf-disable-content-logging</code> header still have their full content offloaded to object
When enabled, requests with content logging disabled (via the global setting above or the{" "}
<code className="text-xs">x-bf-disable-content-logging</code> header) still have their full content offloaded to object
storage, but the content is never shown in logs: the database row stays metadata-only and the UI/API never fetch the payload
back. Content is then only readable with direct access to the storage bucket. When disabled, content for such requests is
dropped entirely (current behavior).
Expand Down Expand Up @@ -183,10 +183,10 @@ export default function LoggingView() {
When enabled, individual requests can override the global content logging setting using the{" "}
<code className="text-xs">x-bf-disable-content-logging</code> header or context key, and can opt-in to persisting raw
provider bytes in logs using the <code className="text-xs">x-bf-store-raw-request-response</code> header. Raw-byte storage
requires content logging to be on either globally, or via{" "}
requires content logging to be on, either globally, or via{" "}
<code className="text-xs">x-bf-disable-content-logging: false</code> on the same request. If content logging is off, raw
bytes are dropped from the log record even when <code className="text-xs">x-bf-store-raw-request-response: true</code>. Does
not control sending raw bytes back to callers see Allow Per-Request Raw Override.
not control sending raw bytes back to callers; see Allow Per-Request Raw Override.
</p>
</div>
<Switch
Expand All @@ -208,7 +208,7 @@ export default function LoggingView() {
<p className="text-muted-foreground text-sm">
When enabled, individual requests can send raw provider request/response bytes back to the caller using the{" "}
<code className="text-xs">x-bf-send-back-raw-request</code> and <code className="text-xs">x-bf-send-back-raw-response</code>{" "}
headers. Does not affect log storage raw-byte persistence in logs is controlled by Allow Per-Request Content Storage
headers. Does not affect log storage; raw-byte persistence in logs is controlled by Allow Per-Request Content Storage
Override.
</p>
</div>
Expand Down Expand Up @@ -273,7 +273,7 @@ export default function LoggingView() {
<p className="text-muted-foreground text-sm">
Comma-separated list of request headers to capture in log metadata. Supports exact names and wildcard patterns (e.g.{" "}
<code className="text-xs">x-custom-*</code> captures all headers with that prefix, <code className="text-xs">*</code> logs all
headers note that <code className="text-xs">*</code> will capture sensitive headers like Authorization). Values are
headers; note that <code className="text-xs">*</code> will capture sensitive headers like Authorization). Values are
extracted from incoming requests and stored in the metadata field of log entries. Headers with the{" "}
<code className="text-xs">x-bf-lh-</code> prefix are always captured automatically.
</p>
Expand Down
2 changes: 1 addition & 1 deletion ui/app/workspace/config/views/mcpView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ export default function MCPView() {
<AlertTitle>OAuth discovery will be disabled</AlertTitle>
<AlertDescription>
All MCP clients that authenticated via the OAuth consent
flow will lose access their JWTs will be rejected and
flow will lose access; their JWTs will be rejected and
their refresh tokens will become unusable. They will need
to reconfigure using a virtual key or api-key header.
</AlertDescription>
Expand Down
2 changes: 1 addition & 1 deletion ui/app/workspace/governance/views/teamSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export default function TeamSheet({ team, customers, onSave, onCancel }: TeamShe
<div className="flex-1">
<NumberAndSelect
id={`budgetMaxLimit-${idx}`}
label={`Budget #${idx + 1} Maximum Spend (USD)`}
label={`Budget #${idx + 1}: Maximum Spend (USD)`}
value={row.maxLimit}
selectValue={row.resetDuration}
onChangeNumber={(value) => updateBudgetRow(idx, { maxLimit: value })}
Expand Down
4 changes: 2 additions & 2 deletions ui/app/workspace/logs/sheets/logDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2196,13 +2196,13 @@ export function LogDetailView({

{log.is_large_payload_request && !log.input_history?.length && !log.responses_input_history?.length && (
<div className="rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
Large payload request input content was streamed directly to the provider and is not available for display.
Large payload request: input content was streamed directly to the provider and is not available for display.
{log.raw_request && " A truncated preview is available in the Raw JSON tab."}
</div>
)}
{log.is_large_payload_response && !log.output_message && !log.responses_output?.length && log.status !== "processing" && (
<div className="rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
Large payload response response content was streamed directly to the client and is not available for display.
Large payload response: response content was streamed directly to the client and is not available for display.
{log.raw_response && " A truncated preview is available in the Raw JSON tab."}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export function MCPLibraryAddServerSheet({ open, onClose }: MCPLibraryAddServerS
data-testid="mcp-add-envs-input"
{...register("envs")}
/>
<p className="text-muted-foreground text-xs">Only names users supply values at install time.</p>
<p className="text-muted-foreground text-xs">Only names; users supply values at install time.</p>
</div>
</div>
)}
Expand Down Expand Up @@ -250,7 +250,7 @@ export function MCPLibraryAddServerSheet({ open, onClose }: MCPLibraryAddServerS
data-testid="mcp-add-header-keys-input"
{...register("required_header_keys")}
/>
<p className="text-muted-foreground text-xs">Only names users supply values at install time.</p>
<p className="text-muted-foreground text-xs">Only names; users supply values at install time.</p>
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export const OAuth2Authorizer: React.FC<OAuth2AuthorizerProps> = ({
{status === "success" && (
<InfoBox variant="success" icon={<CheckCircle2 className="size-4" />}>
<p className="font-medium">Finishing setup and syncing available tools.</p>
<p className="text-xs opacity-80">You can close this dialog setup will complete in the background.</p>
<p className="text-xs opacity-80">You can close this dialog; setup will complete in the background.</p>
</InfoBox>
)}

Expand Down
4 changes: 2 additions & 2 deletions ui/app/workspace/mcp-sessions/views/sessionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default function SessionsTable({
<>
<AlertDialogTitle>Revoke this MCP session?</AlertDialogTitle>
<AlertDialogDescription>
Bifrost will remove the stored credential for this binding. The upstream OAuth token is not revoked at the provider it
Bifrost will remove the stored credential for this binding. The upstream OAuth token is not revoked at the provider; it
stays detached and expires naturally. Anyone using this binding will need to re-authenticate to obtain a fresh token.
</AlertDialogDescription>
</>
Expand Down Expand Up @@ -181,7 +181,7 @@ export default function SessionsTable({
<TableHead>
<HeaderWithTooltip
label="Type"
tooltip="OAuth: per-user OAuth credentialeither a stored token from a completed sign-in, or a pending sign-in flow. Headers: per-user header values (API keys / signed tokens) either stored or pending submission."
tooltip="OAuth: per-user OAuth credential, either a stored token from a completed sign-in, or a pending sign-in flow. Headers: per-user header values (API keys / signed tokens), either stored or pending submission."
/>
</TableHead>
<TableHead>
Expand Down
2 changes: 1 addition & 1 deletion ui/app/workspace/model-catalog/views/attributeSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export default function AttributeSheet({ model, onClose }: AttributeSheetProps)
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="A short description of this model shown anywhere additional_attributes.description is consumed."
placeholder="A short description of this model, shown anywhere additional_attributes.description is consumed."
data-testid="model-catalog-description-textarea"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting
<FormDescription>
Comma-separated list of request headers to capture and attach as trace tags. Supports exact names and wildcard patterns
(e.g. <code className="text-xs">x-custom-*</code> captures all headers with that prefix,{" "}
<code className="text-xs">*</code> captures all headers note that <code className="text-xs">*</code> will capture
<code className="text-xs">*</code> captures all headers; note that <code className="text-xs">*</code> will capture
sensitive headers like Authorization).
</FormDescription>
<FormControl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ function OtelProfileSection({ form, control, index, hasOtelAccess, canRemove, op
<FormDescription>
Comma-separated list of request headers to capture and emit as span attributes. Supports exact names and wildcard patterns
(e.g. <code className="text-xs">x-custom-*</code> captures all headers with that prefix,{" "}
<code className="text-xs">*</code> captures all headers note that <code className="text-xs">*</code> will capture
<code className="text-xs">*</code> captures all headers; note that <code className="text-xs">*</code> will capture
sensitive headers like Authorization).
</FormDescription>
<FormControl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function BetaHeadersFormFragment({ provider }: BetaHeadersFormFragmentPro

// Validate
if (KNOWN_PREFIXES.has(prefix)) {
setNewPrefixError("This is a known header use the override dropdown above instead");
setNewPrefixError("This is a known header; use the override dropdown above instead");
return;
}
if (overrides[prefix] !== undefined) {
Expand Down
Loading
Loading