Skip to content

Commit df8eec5

Browse files
author
Alexey Dertev
committed
[issue-7686] [BE][FE] feat: add OpenInference pretty formatting
1 parent 867d65e commit df8eec5

25 files changed

Lines changed: 2938 additions & 42 deletions

File tree

apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import com.comet.opik.domain.mapping.OpenTelemetryMappingRuleFactory;
77
import com.comet.opik.domain.mapping.otel.GenAIMappingRules;
88
import com.comet.opik.domain.mapping.otel.GeneralMappingRules;
9+
import com.comet.opik.domain.mapping.otel.OpenInferenceSpanNormalizer;
910
import com.comet.opik.domain.mapping.otel.ProviderResolvers;
1011
import com.comet.opik.domain.retention.RetentionUtils;
1112
import com.comet.opik.utils.JsonUtils;
13+
import com.fasterxml.jackson.databind.JsonNode;
1214
import com.fasterxml.jackson.databind.node.ObjectNode;
1315
import io.opentelemetry.proto.common.v1.AnyValue;
1416
import 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

apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/OpenInferenceMappingRules.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ public final class OpenInferenceMappingRules {
1616

1717
private static final List<OpenTelemetryMappingRule> RULES = List.of(
1818
OpenTelemetryMappingRule.builder()
19-
.rule("llm.invocation_parameters.*").isPrefix(true).source(SOURCE)
19+
// Keep the legacy unmarked-span fallback, but match the real semantic key.
20+
// Marked OpenInference spans are handled atomically by OpenInferenceSpanNormalizer.
21+
.rule("llm.invocation_parameters").source(SOURCE)
2022
.outcome(OpenTelemetryMappingRule.Outcome.INPUT).spanType(SpanType.llm).build(),
2123
OpenTelemetryMappingRule.builder()
2224
.rule("llm.model_name").source(SOURCE).outcome(OpenTelemetryMappingRule.Outcome.MODEL)

0 commit comments

Comments
 (0)