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
57 changes: 39 additions & 18 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -4573,7 +4573,8 @@ func (bifrost *Bifrost) handleRequest(ctx *schemas.BifrostContext, req *schemas.
// Start span for fallback attempt
tracer := bifrost.getTracer()
spanCtx, handle := tracer.StartSpan(ctx, fmt.Sprintf("fallback.%s.%s", fallback.Provider, fallback.Model), schemas.SpanKindFallback)
tracer.SetAttribute(handle, schemas.AttrProviderName, string(fallback.Provider))
tracer.SetAttribute(handle, schemas.AttrProviderName, schemas.OTelProviderName(fallback.Provider))
tracer.SetAttribute(handle, schemas.AttrBifrostProviderName, string(fallback.Provider)) // raw Bifrost short name, mirrors canonical gen_ai.provider.name
tracer.SetAttribute(handle, schemas.AttrRequestModel, fallback.Model)
tracer.SetAttribute(handle, "fallback.index", i+1)
ctx.SetValue(schemas.BifrostContextKeySpanID, spanCtx.Value(schemas.BifrostContextKeySpanID))
Expand Down Expand Up @@ -4654,7 +4655,8 @@ func (bifrost *Bifrost) handleStreamRequest(ctx *schemas.BifrostContext, req *sc
// Start span for fallback attempt
tracer := bifrost.getTracer()
spanCtx, handle := tracer.StartSpan(ctx, fmt.Sprintf("fallback.%s.%s", fallback.Provider, fallback.Model), schemas.SpanKindFallback)
tracer.SetAttribute(handle, schemas.AttrProviderName, string(fallback.Provider))
tracer.SetAttribute(handle, schemas.AttrProviderName, schemas.OTelProviderName(fallback.Provider))
tracer.SetAttribute(handle, schemas.AttrBifrostProviderName, string(fallback.Provider)) // raw Bifrost short name, mirrors canonical gen_ai.provider.name
tracer.SetAttribute(handle, schemas.AttrRequestModel, fallback.Model)
tracer.SetAttribute(handle, "fallback.index", i+1)
ctx.SetValue(schemas.BifrostContextKeySpanID, spanCtx.Value(schemas.BifrostContextKeySpanID))
Expand Down Expand Up @@ -5268,10 +5270,11 @@ func executeRequestWithRetries[T any](
var keyHandle schemas.SpanHandle
if keyTracer != nil {
keySpanCtx, keyHandle = keyTracer.StartSpan(ctx, "key.selection", schemas.SpanKindInternal)
keyTracer.SetAttribute(keyHandle, schemas.AttrProviderName, string(providerKey))
keyTracer.SetAttribute(keyHandle, schemas.AttrProviderName, schemas.OTelProviderName(providerKey))
keyTracer.SetAttribute(keyHandle, schemas.AttrBifrostProviderName, string(providerKey)) // raw Bifrost short name, mirrors canonical gen_ai.provider.name
keyTracer.SetAttribute(keyHandle, schemas.AttrRequestModel, model)
if attempts > 0 {
keyTracer.SetAttribute(keyHandle, "retry.count", attempts)
keyTracer.SetAttribute(keyHandle, schemas.AttrLegacyRetryCount, attempts)
}
}

Expand Down Expand Up @@ -5341,50 +5344,68 @@ func executeRequestWithRetries[T any](
}
var spanName string
var spanKind schemas.SpanKind
otelOp := schemas.OTelOperationName(requestType)
if attempts > 0 {
spanName = fmt.Sprintf("retry.attempt.%d", attempts)
spanKind = schemas.SpanKindRetry
} else {
spanName = "llm.call"
// Span name format per OTel GenAI semconv: "{operation} {model}".
spanName = fmt.Sprintf("%s %s", otelOp, model)
spanKind = schemas.SpanKindLLMCall
}
spanCtx, handle := tracer.StartSpan(ctx, spanName, spanKind)
tracer.SetAttribute(handle, schemas.AttrProviderName, string(providerKey))
tracer.SetAttribute(handle, schemas.AttrProviderName, schemas.OTelProviderName(providerKey))
tracer.SetAttribute(handle, schemas.AttrBifrostProviderName, string(providerKey)) // raw Bifrost short name, mirrors canonical gen_ai.provider.name
tracer.SetAttribute(handle, schemas.AttrRequestModel, model)
tracer.SetAttribute(handle, "request.type", string(requestType))
tracer.SetAttribute(handle, schemas.AttrOperationName, otelOp)
tracer.SetAttribute(handle, schemas.AttrLegacyRequestType, string(requestType)) // legacy: replaced by gen_ai.operation.name
if attempts > 0 {
tracer.SetAttribute(handle, "retry.count", attempts)
tracer.SetAttribute(handle, schemas.AttrLegacyRetryCount, attempts) // legacy: bare key with no semconv prefix
}

// Add context-related attributes (selected key, virtual key, team, customer, etc.)
// Each AttrXxx (gen_ai.*) emission below is LEGACY namespace pollution: the
// Bifrost-internal concept does not belong under gen_ai.*. The bifrost.* mirrors
// are the canonical home going forward; once all dashboards migrate, drop the
// gen_ai.* lines (grep for "// legacy:" in this block).
if selectedKeyID, ok := ctx.Value(schemas.BifrostContextKeySelectedKeyID).(string); ok && selectedKeyID != "" {
tracer.SetAttribute(handle, schemas.AttrSelectedKeyID, selectedKeyID)
tracer.SetAttribute(handle, schemas.AttrSelectedKeyID, selectedKeyID) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostSelectedKeyID, selectedKeyID)
}
if selectedKeyName, ok := ctx.Value(schemas.BifrostContextKeySelectedKeyName).(string); ok && selectedKeyName != "" {
tracer.SetAttribute(handle, schemas.AttrSelectedKeyName, selectedKeyName)
tracer.SetAttribute(handle, schemas.AttrSelectedKeyName, selectedKeyName) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostSelectedKeyName, selectedKeyName)
}
if virtualKeyID, ok := ctx.Value(schemas.BifrostContextKeyGovernanceVirtualKeyID).(string); ok && virtualKeyID != "" {
tracer.SetAttribute(handle, schemas.AttrVirtualKeyID, virtualKeyID)
tracer.SetAttribute(handle, schemas.AttrVirtualKeyID, virtualKeyID) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostVirtualKeyID, virtualKeyID)
}
if virtualKeyName, ok := ctx.Value(schemas.BifrostContextKeyGovernanceVirtualKeyName).(string); ok && virtualKeyName != "" {
tracer.SetAttribute(handle, schemas.AttrVirtualKeyName, virtualKeyName)
tracer.SetAttribute(handle, schemas.AttrVirtualKeyName, virtualKeyName) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostVirtualKeyName, virtualKeyName)
}
if teamID, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamID).(string); ok && teamID != "" {
tracer.SetAttribute(handle, schemas.AttrTeamID, teamID)
tracer.SetAttribute(handle, schemas.AttrTeamID, teamID) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostTeamID, teamID)
}
if teamName, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamName).(string); ok && teamName != "" {
tracer.SetAttribute(handle, schemas.AttrTeamName, teamName)
tracer.SetAttribute(handle, schemas.AttrTeamName, teamName) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostTeamName, teamName)
}
if customerID, ok := ctx.Value(schemas.BifrostContextKeyGovernanceCustomerID).(string); ok && customerID != "" {
tracer.SetAttribute(handle, schemas.AttrCustomerID, customerID)
tracer.SetAttribute(handle, schemas.AttrCustomerID, customerID) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostCustomerID, customerID)
}
if customerName, ok := ctx.Value(schemas.BifrostContextKeyGovernanceCustomerName).(string); ok && customerName != "" {
tracer.SetAttribute(handle, schemas.AttrCustomerName, customerName)
tracer.SetAttribute(handle, schemas.AttrCustomerName, customerName) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostCustomerName, customerName)
}
if fallbackIndex, ok := ctx.Value(schemas.BifrostContextKeyFallbackIndex).(int); ok {
tracer.SetAttribute(handle, schemas.AttrFallbackIndex, fallbackIndex)
tracer.SetAttribute(handle, schemas.AttrFallbackIndex, fallbackIndex) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostFallbackIndex, fallbackIndex)
}
tracer.SetAttribute(handle, schemas.AttrNumberOfRetries, attempts)
tracer.SetAttribute(handle, schemas.AttrNumberOfRetries, attempts) // legacy: gen_ai.* placement of bifrost-internal attr
tracer.SetAttribute(handle, schemas.AttrBifrostRetries, attempts)

// Surface caller-supplied extra headers (from x-bf-eh-* and direct-allowlist
// header forwarding) as span attributes so observability backends see the
Expand Down
46 changes: 44 additions & 2 deletions core/mcp/pluginpipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (m *MCPManager) runWithPluginPipeline(
ctx *schemas.BifrostContext,
req *schemas.BifrostMCPRequest,
op MCPOpFunc,
) (*schemas.BifrostMCPResponse, *schemas.BifrostError) {
) (finalResponse *schemas.BifrostMCPResponse, finalError *schemas.BifrostError) {
// Ensure a request ID exists so plugin hooks have something to correlate on.
// Connect/ping/list_tools fire from background contexts that typically lack one.
if ctx != nil {
Expand All @@ -57,9 +57,51 @@ func (m *MCPManager) runWithPluginPipeline(
spanName = fmt.Sprintf("%s.%s", spanName, req.ClientName)
}
_, spanHandle = tracer.StartSpan(ctx, spanName, schemas.SpanKindMCPClient)
// Emit OTel GenAI tool-execution attributes on execute-tool spans so downstream
// backends can correlate tool calls with their requesting llm.call.
if req != nil && req.RequestType.IsExecuteTool() {
tracer.SetAttribute(spanHandle, schemas.AttrOperationName, schemas.OTelOperationNameExecuteTool)
tracer.SetAttribute(spanHandle, schemas.AttrToolType, "function")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if name := req.GetToolName(); name != "" {
tracer.SetAttribute(spanHandle, schemas.AttrToolName, name)
}
// GetToolArguments returns interface{}; the Responses branch boxes a
// *string, so a nil pointer survives the != nil guard. Unwrap and skip
// it explicitly, and deref non-nil so the attribute is the JSON string.
if args := req.GetToolArguments(); args != nil {
if p, ok := args.(*string); ok {
if p != nil {
tracer.SetAttribute(spanHandle, schemas.AttrToolCallArguments, *p)
}
} else {
tracer.SetAttribute(spanHandle, schemas.AttrToolCallArguments, args)
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if req.ChatAssistantMessageToolCall != nil && req.ChatAssistantMessageToolCall.ID != nil {
tracer.SetAttribute(spanHandle, schemas.AttrToolCallID, *req.ChatAssistantMessageToolCall.ID)
} else if req.ResponsesToolMessage != nil && req.ResponsesToolMessage.CallID != nil {
tracer.SetAttribute(spanHandle, schemas.AttrToolCallID, *req.ResponsesToolMessage.CallID)
}
}
}
defer func() {
if tracer != nil {
if tracer == nil {
return
}
// Tool-call result captured via named returns — set just before EndSpan so the
// attribute lands on the open span before it's frozen.
if finalResponse != nil && req != nil && req.RequestType.IsExecuteTool() {
if data, err := schemas.MarshalString(finalResponse); err == nil {
tracer.SetAttribute(spanHandle, schemas.AttrToolCallResult, data)
}
}
if finalError != nil {
msg := ""
if finalError.Error != nil {
msg = finalError.Error.Message
}
tracer.EndSpan(spanHandle, schemas.SpanStatusError, msg)
} else {
tracer.EndSpan(spanHandle, schemas.SpanStatusOk, "")
}
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
50 changes: 50 additions & 0 deletions core/schemas/otelconv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package schemas

// OTelOperationNameExecuteTool is the gen_ai.operation.name value for MCP tool
// executions. execute_tool is an MCPRequestType, not a Bifrost RequestType, so it
// can't flow through OTelOperationName.
const OTelOperationNameExecuteTool = "execute_tool"

// OTelOperationName maps a Bifrost RequestType to the value that should be
// emitted under gen_ai.operation.name. Values not modeled by the spec fall
// through to the raw RequestType string.
func OTelOperationName(rt RequestType) string {
switch rt {
case ChatCompletionRequest, ChatCompletionStreamRequest,
ResponsesRequest, ResponsesStreamRequest:
return "chat"
case TextCompletionRequest, TextCompletionStreamRequest:
return "text_completion"
case EmbeddingRequest:
return "embeddings"
case SpeechRequest, SpeechStreamRequest,
TranscriptionRequest, TranscriptionStreamRequest,
ImageGenerationRequest, ImageGenerationStreamRequest,
ImageEditRequest, ImageEditStreamRequest:
return "generate_content"
default:
return string(rt)
}
}

// OTelProviderName maps a Bifrost ModelProvider to the value that should be
// emitted under gen_ai.provider.name. Providers not covered by the spec keep
// their Bifrost short name.
func OTelProviderName(p ModelProvider) string {
switch p {
case Bedrock:
return "aws.bedrock"
case Vertex:
return "gcp.vertex_ai"
case Gemini:
return "gcp.gemini"
Comment thread
roroghost17 marked this conversation as resolved.
case XAI:
return "x_ai"
case Mistral:
return "mistral_ai"
case Azure:
return "azure.ai.openai"
default:
return string(p)
}
}
79 changes: 71 additions & 8 deletions core/schemas/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ const (
// and are compatible with both OTEL and Datadog backends.
const (
// Provider and Model Attributes
AttrProviderName = "gen_ai.provider.name"
AttrRequestModel = "gen_ai.request.model"
AttrProviderName = "gen_ai.provider.name"
AttrRequestModel = "gen_ai.request.model"
AttrOperationName = "gen_ai.operation.name"

// Request Parameter Attributes
AttrMaxTokens = "gen_ai.request.max_tokens"
Expand All @@ -217,11 +218,16 @@ const (
AttrEcho = "gen_ai.request.echo"
AttrLogitBias = "gen_ai.request.logit_bias"
AttrLogProbs = "gen_ai.request.logprobs"
AttrN = "gen_ai.request.n"
AttrN = "gen_ai.request.n" // legacy: replaced by AttrChoiceCount
AttrChoiceCount = "gen_ai.request.choice.count"
Comment thread
roroghost17 marked this conversation as resolved.
// AttrEmbeddingsDimensionCount is the OTel spec key for embedding dimensions
// (Bifrost historically emitted AttrDimensions = gen_ai.request.dimensions).
AttrEmbeddingsDimensionCount = "gen_ai.embeddings.dimension.count"
AttrSeed = "gen_ai.request.seed"
AttrSuffix = "gen_ai.request.suffix"
AttrDimensions = "gen_ai.request.dimensions"
AttrEncodingFormat = "gen_ai.request.encoding_format"
AttrDimensions = "gen_ai.request.dimensions" // legacy: replaced by AttrEmbeddingsDimensionCount
AttrEncodingFormat = "gen_ai.request.encoding_format" // legacy: singular form; replaced by AttrEncodingFormats (string[])
AttrEncodingFormats = "gen_ai.request.encoding_formats"
AttrLanguage = "gen_ai.request.language"
AttrPrompt = "gen_ai.request.prompt"
AttrResponseFormat = "gen_ai.request.response_format"
Expand Down Expand Up @@ -251,18 +257,26 @@ const (
AttrPluginErrorCount = "plugin.error_count"

// Usage Attributes
// legacy: AttrPromptTokens / AttrCompletionTokens are the deprecated OTel names;
// new code should use AttrInputTokens / AttrOutputTokens. Kept for dashboards.
AttrPromptTokens = "gen_ai.usage.prompt_tokens"
AttrCompletionTokens = "gen_ai.usage.completion_tokens"
AttrTotalTokens = "gen_ai.usage.total_tokens"
AttrInputTokens = "gen_ai.usage.input_tokens"
AttrOutputTokens = "gen_ai.usage.output_tokens"
AttrUsageCost = "gen_ai.usage.cost"
// OTel GenAI spec keys for cache tokens (flat namespace).
AttrUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
AttrUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
// Chat completion usage detail attributes
// legacy: nested namespace; OTel spec uses flat gen_ai.usage.cache_read.input_tokens
// and gen_ai.usage.cache_creation.input_tokens for the cached_* entries. The
// non-cached fields below have no spec equivalent and stay as-is.
AttrPromptTokenDetailsText = "gen_ai.usage.prompt_token_details.text_tokens"
AttrPromptTokenDetailsAudio = "gen_ai.usage.prompt_token_details.audio_tokens"
AttrPromptTokenDetailsImage = "gen_ai.usage.prompt_token_details.image_tokens"
AttrPromptTokenDetailsCachedRead = "gen_ai.usage.prompt_token_details.cached_read_tokens"
AttrPromptTokenDetailsCachedWrite = "gen_ai.usage.prompt_token_details.cached_write_tokens"
AttrPromptTokenDetailsCachedRead = "gen_ai.usage.prompt_token_details.cached_read_tokens" // legacy: see AttrUsageCacheReadInputTokens
AttrPromptTokenDetailsCachedWrite = "gen_ai.usage.prompt_token_details.cached_write_tokens" // legacy: see AttrUsageCacheCreationInputTokens
AttrPromptTokenDetailsCachedWrite5m = "gen_ai.usage.prompt_token_details.cached_write_tokens_5m"
AttrPromptTokenDetailsCachedWrite1h = "gen_ai.usage.prompt_token_details.cached_write_tokens_1h"
AttrCompletionTokenDetailsText = "gen_ai.usage.completion_token_details.text_tokens"
Expand All @@ -275,7 +289,9 @@ const (
AttrCompletionTokenDetailsSearch = "gen_ai.usage.completion_token_details.num_search_queries"

// Error Attributes
AttrError = "gen_ai.error"
AttrError = "gen_ai.error"
// legacy: AttrErrorType is the gen_ai.* placement; OTel general semconv uses the
// unprefixed "error.type". Emitted in parallel from PopulateErrorAttributes.
AttrErrorType = "gen_ai.error.type"
AttrErrorCode = "gen_ai.error.code"

Expand All @@ -287,6 +303,9 @@ const (
AttrOutputMessages = "gen_ai.output.messages"

// Bifrost Context Attributes
// legacy: every key below sits under gen_ai.* but represents a Bifrost-internal
// concept (governance / routing). The bifrost.* mirrors are the canonical home
// going forward; these will be dropped once dashboards migrate.
AttrRequestID = "gen_ai.request_id"
AttrVirtualKeyID = "gen_ai.virtual_key_id"
AttrVirtualKeyName = "gen_ai.virtual_key_name"
Expand Down Expand Up @@ -396,6 +415,50 @@ const (
AttrOutputTokenDetailsCite = "gen_ai.usage.output_token_details.citation_tokens"
AttrOutputTokenDetailsSearch = "gen_ai.usage.output_token_details.num_search_queries"

// Tool execution attributes (OTel GenAI spec) used on MCP tool spans.
AttrToolName = "gen_ai.tool.name"
AttrToolCallID = "gen_ai.tool.call.id"
AttrToolCallArguments = "gen_ai.tool.call.arguments"
AttrToolCallResult = "gen_ai.tool.call.result"
AttrToolType = "gen_ai.tool.type"

// =====================================================================
// Bifrost-namespaced attributes (bifrost.*)
//
// Canonical home for everything that is NOT part of the OTel GenAI spec:
// - Bifrost-internal concepts (routing/governance, request id, retry counters)
// - Raw Bifrost short names that mirror canonicalized gen_ai.* values
// - Back-compat fallbacks for shape changes (e.g. comma-joined stop_sequences)
//
// The corresponding legacy gen_ai.* emissions are tagged "// legacy:" at their
// call sites and will be removed once dashboards migrate over.
// =====================================================================
AttrBifrostProviderName = "bifrost.provider.name"
AttrBifrostRequestID = "bifrost.request.id"
AttrBifrostVirtualKeyID = "bifrost.virtual_key.id"
AttrBifrostVirtualKeyName = "bifrost.virtual_key.name"
AttrBifrostSelectedKeyID = "bifrost.selected_key.id"
AttrBifrostSelectedKeyName = "bifrost.selected_key.name"
AttrBifrostRoutingRuleID = "bifrost.routing_rule.id"
AttrBifrostRoutingRuleName = "bifrost.routing_rule.name"
AttrBifrostTeamID = "bifrost.team.id"
AttrBifrostTeamName = "bifrost.team.name"
AttrBifrostCustomerID = "bifrost.customer.id"
AttrBifrostCustomerName = "bifrost.customer.name"
AttrBifrostRetries = "bifrost.retries"
AttrBifrostFallbackIndex = "bifrost.fallback_index"
AttrBifrostStopSequencesJoined = "bifrost.request.stop_sequences"

// OTel general semconv (no gen_ai prefix). Emitted alongside the legacy
// gen_ai.error.type from PopulateErrorAttributes.
AttrErrorTypeSpec = "error.type"

// legacy: bare unprefixed keys retained for back-compat with existing dashboards.
// "request.type" is superseded by AttrOperationName; "retry.count" has no spec
// equivalent but stays under bifrost.retries going forward.
AttrLegacyRequestType = "request.type"
AttrLegacyRetryCount = "retry.count"

// File Operation Attributes
AttrFileID = "gen_ai.file.id"
AttrFileObject = "gen_ai.file.object"
Expand Down
Loading
Loading