Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import com.comet.opik.domain.mapping.OpenTelemetryMappingRuleFactory;
import com.comet.opik.domain.mapping.otel.GenAIMappingRules;
import com.comet.opik.domain.mapping.otel.GeneralMappingRules;
import com.comet.opik.domain.mapping.otel.OpenInferenceSpanNormalizer;
import com.comet.opik.domain.mapping.otel.ProviderResolvers;
import com.comet.opik.domain.retention.RetentionUtils;
import com.comet.opik.utils.JsonUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.opentelemetry.proto.common.v1.AnyValue;
import io.opentelemetry.proto.common.v1.KeyValue;
Expand Down Expand Up @@ -153,14 +155,15 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
ObjectNode output = JsonUtils.createObjectNode();
ObjectNode metadata = JsonUtils.createObjectNode();
Set<String> tags = new HashSet<>();
var openInference = OpenInferenceSpanNormalizer.normalize(attributes).orElse(null);
// Claude Code spans carry a lot of session/config attributes that aren't input. For that
// integration the default bucket for unmapped attributes is metadata (not input), so only
// the explicitly promoted content attributes land in input/output/usage.
// Decided per span by name (not from the batch-level integrationName below): a single OTLP
// batch can mix scopes from more than one integration, so gating this on the batch-wide
// value could misroute a non-Claude span or skip routing for a real Claude Code span.
boolean isClaudeCode = OpenTelemetryMappingRuleFactory.isClaudeCodeSpan(spanName);
ObjectNode defaultBucket = isClaudeCode ? metadata : input;
ObjectNode defaultBucket = isClaudeCode || openInference != null ? metadata : input;

// Hold model and provider until the attribute loop completes so we can apply
// post-processing (e.g. Elastic Inference Service routing) that needs both values.
Expand All @@ -180,6 +183,12 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
var key = attribute.getKey();
var value = attribute.getValue();

// OpenInference semantic attributes have already been normalized as one coherent shape.
// Skipping them here prevents generic prefix rules from processing the same key again.
if (openInference != null && openInference.consumes(key)) {
continue;
Comment on lines +186 to +189

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaks existing online scoring paths

Marked spans now route semantic llm.output_messages.* keys to span.output.messages instead of span.input, so OnlineScoringEngine mappings such as input.llm.output_messages... silently produce no replacement for newly ingested spans — should we retain a compatibility alias or explicitly migrate/document this released consumer contract?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java around
lines 186-189, update enrichSpanWithAttributes so consumed OpenInference
llm.output_messages.* attributes remain available under the legacy span.input path used
by OnlineScoringEngine mappings, while preserving their canonical span.output.messages
representation. Add a compatibility alias during OpenInference input/output composition
(or an equivalent explicit migration path) and add regression coverage proving
input.llm.output_messages mappings still resolve for newly ingested marked spans.

}

// Claude Code's `new_context` is the latest message fed to the model on llm_request
// spans (the real LLM input); on interaction/tool spans it just repeats the prompt /
// tool result, so it's kept in metadata there rather than input.
Expand Down Expand Up @@ -278,6 +287,37 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
extractToolOutputEvent(events, output);
}

if (openInference != null) {
// User-supplied OpenInference metadata is already filtered by the normalizer. Apply
// exact semantic metadata after common rules so marker/MIME/identity fields remain
// authoritative and unknown OpenInference fields retain their original dotted key.
metadata.setAll(openInference.metadata());

// An explicit Opik thread_id wins regardless of OTLP attribute order. Otherwise use
// the OpenInference session identifier as the trace-grouping thread id.
var explicitThreadId = attributes.stream()
.filter(attribute -> "thread_id".equals(attribute.getKey()))
.map(KeyValue::getValue)
.findFirst();
if (explicitThreadId.isPresent()) {
extractToJsonColumn(metadata, "thread_id", explicitThreadId.get());
} else if (StringUtils.isNotBlank(openInference.sessionId())) {
metadata.put("thread_id", openInference.sessionId());
}

usage.putAll(openInference.usage());
tags.addAll(openInference.tags());
if (StringUtils.isNotBlank(openInference.model())) {
model = openInference.model();
}
if (StringUtils.isNotBlank(openInference.provider())) {
provider = openInference.provider();
}
if (openInference.totalEstimatedCost() != null) {
spanBuilder.totalEstimatedCost(openInference.totalEstimatedCost());
}
}

// Fall back to the current `gen_ai.provider.name` only when the deprecated `gen_ai.system`
// did not report a provider.
// Both sides must be non-blank: a non-string or empty `gen_ai.provider.name` yields ""
Expand All @@ -300,6 +340,9 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
if ("invoke_agent".equals(metadata.path("gen_ai.operation.name").asText(null))) {
spanBuilder.type(SpanType.general);
}
if (openInference != null) {
spanBuilder.type(openInference.spanType());
}

if (model != null) {
spanBuilder.model(model);
Expand All @@ -311,11 +354,17 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List<KeyVal
if (!metadata.isEmpty()) {
spanBuilder.metadata(metadata);
}
if (!output.isEmpty()) {
spanBuilder.output(output);
JsonNode finalOutput = openInference == null
? (output.isEmpty() ? null : output)
: openInference.composeOutput(output);
JsonNode finalInput = openInference == null
? (input.isEmpty() ? null : input)
: openInference.composeInput(input);
if (finalOutput != null) {
spanBuilder.output(finalOutput);
}
if (!input.isEmpty()) {
spanBuilder.input(input);
if (finalInput != null) {
spanBuilder.input(finalInput);
}
if (!usage.isEmpty()) {
// Some integrations (e.g. PydanticAI) send prompt_tokens and completion_tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public final class OpenInferenceMappingRules {

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