package otel import ( "encoding/hex" "fmt" "strings" "github.com/maximhq/bifrost/core/schemas" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // kvStr creates a key-value pair with a string value func kvStr(k, v string) *KeyValue { return &KeyValue{Key: k, Value: &AnyValue{Value: &StringValue{StringValue: v}}} } // kvInt creates a key-value pair with an integer value func kvInt(k string, v int64) *KeyValue { return &KeyValue{Key: k, Value: &AnyValue{Value: &IntValue{IntValue: v}}} } // kvDbl creates a key-value pair with a double value func kvDbl(k string, v float64) *KeyValue { return &KeyValue{Key: k, Value: &AnyValue{Value: &DoubleValue{DoubleValue: v}}} } // kvBool creates a key-value pair with a boolean value func kvBool(k string, v bool) *KeyValue { return &KeyValue{Key: k, Value: &AnyValue{Value: &BoolValue{BoolValue: v}}} } // kvAny creates a key-value pair with an any value func kvAny(k string, v *AnyValue) *KeyValue { return &KeyValue{Key: k, Value: v} } // arrValue converts a list of any values to an OpenTelemetry array value func arrValue(vals ...*AnyValue) *AnyValue { return &AnyValue{Value: &ArrayValue{ArrayValue: &ArrayValueValue{Values: vals}}} } // listValue converts a list of key-value pairs to an OpenTelemetry list value func listValue(kvs ...*KeyValue) *AnyValue { return &AnyValue{Value: &ListValue{KvlistValue: &KeyValueList{Values: kvs}}} } // hexToBytes converts a hex string to bytes, padding/truncating as needed func hexToBytes(hexStr string, length int) []byte { // Remove any non-hex characters cleaned := strings.Map(func(r rune) rune { if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { return r } return -1 }, hexStr) // Ensure even length if len(cleaned)%2 != 0 { cleaned = "0" + cleaned } // Truncate or pad to desired length if len(cleaned) > length*2 { cleaned = cleaned[:length*2] } else if len(cleaned) < length*2 { cleaned = strings.Repeat("0", length*2-len(cleaned)) + cleaned } bytes, _ := hex.DecodeString(cleaned) return bytes } // convertTraceToResourceSpan converts a Bifrost trace to OTEL ResourceSpan func (p *OtelPlugin) convertTraceToResourceSpan(trace *schemas.Trace) *ResourceSpan { otelSpans := make([]*Span, 0, len(trace.Spans)) for _, span := range trace.Spans { otelSpans = append(otelSpans, p.convertSpanToOTELSpan(trace.TraceID, span)) } return &ResourceSpan{ Resource: &resourcepb.Resource{ Attributes: p.getResourceAttributes(), }, ScopeSpans: []*ScopeSpan{{ Scope: p.getInstrumentationScope(), Spans: otelSpans, }}, } } // convertSpanToOTELSpan converts a single Bifrost span to OTEL format func (p *OtelPlugin) convertSpanToOTELSpan(traceID string, span *schemas.Span) *Span { otelSpan := &Span{ TraceId: hexToBytes(traceID, 16), SpanId: hexToBytes(span.SpanID, 8), Name: span.Name, Kind: convertSpanKind(span.Kind), StartTimeUnixNano: uint64(span.StartTime.UnixNano()), EndTimeUnixNano: uint64(span.EndTime.UnixNano()), Attributes: convertAttributesToKeyValues(span.Attributes), Status: convertSpanStatus(span.Status, span.StatusMsg), Events: convertSpanEvents(span.Events), } // Set parent span ID if present if span.ParentID != "" { otelSpan.ParentSpanId = hexToBytes(span.ParentID, 8) } return otelSpan } // getResourceAttributes returns the resource attributes for the OTEL span func (p *OtelPlugin) getResourceAttributes() []*KeyValue { attrs := []*KeyValue{ kvStr("service.name", p.serviceName), kvStr("service.version", p.bifrostVersion), kvStr("telemetry.sdk.name", "bifrost"), kvStr("telemetry.sdk.language", "go"), } // Add environment attributes attrs = append(attrs, p.attributesFromEnvironment...) return attrs } // getInstrumentationScope returns the instrumentation scope for OTEL func (p *OtelPlugin) getInstrumentationScope() *commonpb.InstrumentationScope { return &commonpb.InstrumentationScope{ Name: p.serviceName, Version: p.bifrostVersion, } } // convertAttributesToKeyValues converts map[string]any to OTEL KeyValue slice func convertAttributesToKeyValues(attrs map[string]any) []*KeyValue { if attrs == nil { return nil } kvs := make([]*KeyValue, 0, len(attrs)) for k, v := range attrs { kv := anyToKeyValue(k, v) if kv != nil { kvs = append(kvs, kv) } } return kvs } // anyToKeyValue converts any Go value to OTEL KeyValue func anyToKeyValue(key string, value any) *KeyValue { if value == nil { return nil } switch v := value.(type) { case string: if v == "" { return nil } return kvStr(key, v) case int: return kvInt(key, int64(v)) case int32: return kvInt(key, int64(v)) case int64: return kvInt(key, v) case uint: return kvInt(key, int64(v)) case uint32: return kvInt(key, int64(v)) case uint64: return kvInt(key, int64(v)) case float32: return kvDbl(key, float64(v)) case float64: return kvDbl(key, v) case bool: return kvBool(key, v) case []string: if len(v) == 0 { return nil } vals := make([]*AnyValue, len(v)) for i, s := range v { vals[i] = &AnyValue{Value: &StringValue{StringValue: s}} } return kvAny(key, arrValue(vals...)) case []int: if len(v) == 0 { return nil } vals := make([]*AnyValue, len(v)) for i, n := range v { vals[i] = &AnyValue{Value: &IntValue{IntValue: int64(n)}} } return kvAny(key, arrValue(vals...)) case []int64: if len(v) == 0 { return nil } vals := make([]*AnyValue, len(v)) for i, n := range v { vals[i] = &AnyValue{Value: &IntValue{IntValue: n}} } return kvAny(key, arrValue(vals...)) case []float64: if len(v) == 0 { return nil } vals := make([]*AnyValue, len(v)) for i, n := range v { vals[i] = &AnyValue{Value: &DoubleValue{DoubleValue: n}} } return kvAny(key, arrValue(vals...)) case []any: if len(v) == 0 { return nil } vals := make([]*AnyValue, 0, len(v)) for _, item := range v { if kv := anyToKeyValue("_", item); kv != nil { vals = append(vals, kv.Value) } } if len(vals) == 0 { return nil } return kvAny(key, arrValue(vals...)) case map[string]any: if len(v) == 0 { return nil } kvList := make([]*KeyValue, 0, len(v)) for k, val := range v { kv := anyToKeyValue(k, val) if kv != nil { kvList = append(kvList, kv) } } return kvAny(key, listValue(kvList...)) default: data, err := schemas.MarshalSorted(v) if err != nil { return kvStr(key, fmt.Sprintf("%v", v)) } var generic any if err := schemas.Unmarshal(data, &generic); err != nil { return kvStr(key, string(data)) } return anyToKeyValue(key, generic) } } // convertSpanKind maps Bifrost SpanKind to OTEL SpanKind func convertSpanKind(kind schemas.SpanKind) tracepb.Span_SpanKind { switch kind { case schemas.SpanKindLLMCall: return tracepb.Span_SPAN_KIND_CLIENT case schemas.SpanKindHTTPRequest: return tracepb.Span_SPAN_KIND_SERVER case schemas.SpanKindPlugin: return tracepb.Span_SPAN_KIND_INTERNAL case schemas.SpanKindInternal: return tracepb.Span_SPAN_KIND_INTERNAL case schemas.SpanKindRetry: return tracepb.Span_SPAN_KIND_INTERNAL case schemas.SpanKindFallback: return tracepb.Span_SPAN_KIND_INTERNAL case schemas.SpanKindMCPTool: return tracepb.Span_SPAN_KIND_CLIENT case schemas.SpanKindEmbedding: return tracepb.Span_SPAN_KIND_CLIENT case schemas.SpanKindSpeech: return tracepb.Span_SPAN_KIND_CLIENT case schemas.SpanKindTranscription: return tracepb.Span_SPAN_KIND_CLIENT default: return tracepb.Span_SPAN_KIND_UNSPECIFIED } } // convertSpanStatus maps Bifrost SpanStatus to OTEL Status func convertSpanStatus(status schemas.SpanStatus, msg string) *tracepb.Status { switch status { case schemas.SpanStatusOk: return &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK} case schemas.SpanStatusError: return &tracepb.Status{Code: tracepb.Status_STATUS_CODE_ERROR, Message: msg} default: return &tracepb.Status{Code: tracepb.Status_STATUS_CODE_UNSET} } } // convertSpanEvents converts Bifrost span events to OTEL events func convertSpanEvents(events []schemas.SpanEvent) []*Event { if len(events) == 0 { return nil } otelEvents := make([]*Event, len(events)) for i, event := range events { otelEvents[i] = &Event{ TimeUnixNano: uint64(event.Timestamp.UnixNano()), Name: event.Name, Attributes: convertAttributesToKeyValues(event.Attributes), } } return otelEvents }