66import com .comet .opik .domain .mapping .OpenTelemetryMappingRuleFactory ;
77import com .comet .opik .domain .mapping .otel .GenAIMappingRules ;
88import com .comet .opik .domain .mapping .otel .GeneralMappingRules ;
9+ import com .comet .opik .domain .mapping .otel .OpenInferenceSpanNormalizer ;
910import com .comet .opik .domain .mapping .otel .ProviderResolvers ;
1011import com .comet .opik .domain .retention .RetentionUtils ;
1112import com .comet .opik .utils .JsonUtils ;
13+ import com .fasterxml .jackson .databind .JsonNode ;
1214import com .fasterxml .jackson .databind .node .ObjectNode ;
1315import io .opentelemetry .proto .common .v1 .AnyValue ;
1416import io .opentelemetry .proto .common .v1 .KeyValue ;
@@ -153,14 +155,15 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
153155 ObjectNode output = JsonUtils .createObjectNode ();
154156 ObjectNode metadata = JsonUtils .createObjectNode ();
155157 Set <String > tags = new HashSet <>();
158+ var openInference = OpenInferenceSpanNormalizer .normalize (attributes ).orElse (null );
156159 // Claude Code spans carry a lot of session/config attributes that aren't input. For that
157160 // integration the default bucket for unmapped attributes is metadata (not input), so only
158161 // the explicitly promoted content attributes land in input/output/usage.
159162 // Decided per span by name (not from the batch-level integrationName below): a single OTLP
160163 // batch can mix scopes from more than one integration, so gating this on the batch-wide
161164 // value could misroute a non-Claude span or skip routing for a real Claude Code span.
162165 boolean isClaudeCode = OpenTelemetryMappingRuleFactory .isClaudeCodeSpan (spanName );
163- ObjectNode defaultBucket = isClaudeCode ? metadata : input ;
166+ ObjectNode defaultBucket = isClaudeCode || openInference != null ? metadata : input ;
164167
165168 // Hold model and provider until the attribute loop completes so we can apply
166169 // post-processing (e.g. Elastic Inference Service routing) that needs both values.
@@ -180,6 +183,12 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
180183 var key = attribute .getKey ();
181184 var value = attribute .getValue ();
182185
186+ // OpenInference semantic attributes have already been normalized as one coherent shape.
187+ // Skipping them here prevents generic prefix rules from processing the same key again.
188+ if (openInference != null && openInference .consumes (key )) {
189+ continue ;
190+ }
191+
183192 // Claude Code's `new_context` is the latest message fed to the model on llm_request
184193 // spans (the real LLM input); on interaction/tool spans it just repeats the prompt /
185194 // tool result, so it's kept in metadata there rather than input.
@@ -278,6 +287,37 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
278287 extractToolOutputEvent (events , output );
279288 }
280289
290+ if (openInference != null ) {
291+ // User-supplied OpenInference metadata is already filtered by the normalizer. Apply
292+ // exact semantic metadata after common rules so marker/MIME/identity fields remain
293+ // authoritative and unknown OpenInference fields retain their original dotted key.
294+ metadata .setAll (openInference .metadata ());
295+
296+ // An explicit Opik thread_id wins regardless of OTLP attribute order. Otherwise use
297+ // the OpenInference session identifier as the trace-grouping thread id.
298+ var explicitThreadId = attributes .stream ()
299+ .filter (attribute -> "thread_id" .equals (attribute .getKey ()))
300+ .map (KeyValue ::getValue )
301+ .findFirst ();
302+ if (explicitThreadId .isPresent ()) {
303+ extractToJsonColumn (metadata , "thread_id" , explicitThreadId .get ());
304+ } else if (StringUtils .isNotBlank (openInference .sessionId ())) {
305+ metadata .put ("thread_id" , openInference .sessionId ());
306+ }
307+
308+ usage .putAll (openInference .usage ());
309+ tags .addAll (openInference .tags ());
310+ if (StringUtils .isNotBlank (openInference .model ())) {
311+ model = openInference .model ();
312+ }
313+ if (StringUtils .isNotBlank (openInference .provider ())) {
314+ provider = openInference .provider ();
315+ }
316+ if (openInference .totalEstimatedCost () != null ) {
317+ spanBuilder .totalEstimatedCost (openInference .totalEstimatedCost ());
318+ }
319+ }
320+
281321 // Fall back to the current `gen_ai.provider.name` only when the deprecated `gen_ai.system`
282322 // did not report a provider.
283323 // Both sides must be non-blank: a non-string or empty `gen_ai.provider.name` yields ""
@@ -300,6 +340,9 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
300340 if ("invoke_agent" .equals (metadata .path ("gen_ai.operation.name" ).asText (null ))) {
301341 spanBuilder .type (SpanType .general );
302342 }
343+ if (openInference != null ) {
344+ spanBuilder .type (openInference .spanType ());
345+ }
303346
304347 if (model != null ) {
305348 spanBuilder .model (model );
@@ -311,11 +354,17 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
311354 if (!metadata .isEmpty ()) {
312355 spanBuilder .metadata (metadata );
313356 }
314- if (!output .isEmpty ()) {
315- spanBuilder .output (output );
357+ JsonNode finalOutput = openInference == null
358+ ? (output .isEmpty () ? null : output )
359+ : openInference .composeOutput (output );
360+ JsonNode finalInput = openInference == null
361+ ? (input .isEmpty () ? null : input )
362+ : openInference .composeInput (input );
363+ if (finalOutput != null ) {
364+ spanBuilder .output (finalOutput );
316365 }
317- if (! input . isEmpty () ) {
318- spanBuilder .input (input );
366+ if (finalInput != null ) {
367+ spanBuilder .input (finalInput );
319368 }
320369 if (!usage .isEmpty ()) {
321370 // Some integrations (e.g. PydanticAI) send prompt_tokens and completion_tokens
0 commit comments