Repository: comet-ml/opik
Pull request: [OPIK-1553]: Add LLM Calls count column to traces
## Details
- Added llmCallCount to the Trace API model so each trace exposes the number of LLM calls count.
- Aggregated countIf(s.type = 'llm') in TraceDAO queries to compute the count and included it in trace mapping.
- Frontend Traces table includes the “Number of LLM Calls” column using the new field.

## Issues
Needed to add a new column called "LLM Calls count"  to each trace record.
As per issue (1553): "Add column "Number of LLM Calls" for a Trace the in Project table"
This allows to better understand the complexity of Traces without the need to click on each of them
Resolves #1553 
## Testing
Updated tests to ignore the new field when comparing traces and to verify the count of LLM calls (same as done for the similar field spanCount).

Screencast of it working:
https://github.com/user-attachments/assets/1bd96bf8-6e2b-497c-8625-b4baea70d699




Problem statement:
[FR]: Add column "Number of LLM Calls" for a Trace the in Project table
### Proposal summary

Add column "Number of LLM Calls" for a Trace the in Project table

### Motivation

This allows to better understand the complexity of Traces without the need to click on each of them

Hints from issue discussion:
I'll take it

Commit message: [OPIK-1553]: Code improvement

Patch to review:
diff --git a/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java b/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java
index da12015b36..8b38a90424 100644
--- a/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java
+++ b/apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java
@@ -63,7 +63,9 @@ public record Trace(
                 Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY, description = "Duration in milliseconds as a decimal number to support sub-millisecond precision") Double duration,
         @JsonView({Trace.View.Public.class, Trace.View.Write.class}) String threadId,
         @JsonView({
-                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) VisibilityMode visibilityMode){
+                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) VisibilityMode visibilityMode,
+        @JsonView({
+                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) int llmCallCount){
 
     @Builder(toBuilder = true)
     public record TracePage(
@@ -99,6 +101,7 @@ public enum TraceField {
         GUARDRAILS_VALIDATIONS("guardrails_validations"),
         TOTAL_ESTIMATED_COST("total_estimated_cost"),
         SPAN_COUNT("span_count"),
+        LLM_CALL_COUNT("llm_call_count"),
         DURATION("duration"),
         THREAD_ID("thread_id"),
         VISIBILITY_MODE("visibility_mode"),
diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
index 31de3f2560..3642912a44 100644
--- a/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
+++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
@@ -324,6 +324,7 @@ ORDER BY (workspace_id, project_id, id) DESC, last_updated_at DESC
                 sumMap(s.usage) as usage,
                 sum(s.total_estimated_cost) as total_estimated_cost,
                 COUNT(s.id) AS span_count,
+                countIf(s.type = 'llm') AS llm_call_count,
                 groupUniqArrayArray(c.comments_array) as comments,
                 any(fs.feedback_scores) as feedback_scores_list,
                 any(gr.guardrails) as guardrails_validations
@@ -345,7 +346,8 @@ LEFT JOIN (
                     trace_id,
                     usage,
                     total_estimated_cost,
-                    id
+                    id,
+                    type
                 FROM spans
                 WHERE workspace_id = :workspace_id
                   AND trace_id = :id
@@ -490,7 +492,8 @@ ORDER BY (workspace_id, project_id, entity_type, entity_id, id) DESC, last_updat
                     trace_id,
                     sumMap(usage) as usage,
                     sum(total_estimated_cost) as total_estimated_cost,
-                    COUNT(DISTINCT id) as span_count
+                    COUNT(DISTINCT id) as span_count,
+                    countIf(type = 'llm') as llm_call_count
                 FROM spans final
                 WHERE workspace_id = :workspace_id
                 AND project_id = :project_id
@@ -602,6 +605,7 @@ id NOT IN (SELECT entity_id FROM fsc)
                   <if(!exclude_comments)>, c.comments_array as comments <endif>
                   <if(!exclude_guardrails_validations)>, gagg.guardrails_list as guardrails_validations<endif>
                   <if(!exclude_span_count)>, s.span_count AS span_count<endif>
+                  <if(!exclude_llm_call_count)>, s.llm_call_count AS llm_call_count<endif>
              FROM traces_final t
              LEFT JOIN feedback_scores_agg fsagg ON fsagg.entity_id = t.id
              LEFT JOIN spans_agg s ON t.id = s.trace_id
@@ -1424,6 +1428,10 @@ private Publisher<Trace> mapToDto(Result result, Set<Trace.TraceField> exclude)
                 .spanCount(Optional
                         .ofNullable(getValue(exclude, Trace.TraceField.SPAN_COUNT, row, "span_count", Integer.class))
                         .orElse(0))
+                .llmCallCount(Optional
+                        .ofNullable(getValue(exclude, Trace.TraceField.LLM_CALL_COUNT, row, "llm_call_count",
+                                Integer.class))
+                        .orElse(0))
                 .usage(getValue(exclude, Trace.TraceField.USAGE, row, "usage", Map.class))
                 .totalEstimatedCost(Optional
                         .ofNullable(getValue(exclude, Trace.TraceField.TOTAL_ESTIMATED_COST, row,
@@ -1613,6 +1621,8 @@ private void bindTemplateExcludeFieldVariables(TraceSearchCriteria traceSearchCr
                         template.add("exclude_guardrails_validations",
                                 fields.contains(Trace.TraceField.GUARDRAILS_VALIDATIONS.getValue()));
                         template.add("exclude_span_count", fields.contains(Trace.TraceField.SPAN_COUNT.getValue()));
+                        template.add("exclude_llm_call_count",
+                                fields.contains(Trace.TraceField.LLM_CALL_COUNT.getValue()));
                     }
                 });
     }
diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/utils/traces/TraceAssertions.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/utils/traces/TraceAssertions.java
index babb6517f0..3acc5d8ad3 100644
--- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/utils/traces/TraceAssertions.java
+++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/utils/traces/TraceAssertions.java
@@ -19,7 +19,7 @@ public class TraceAssertions {
 
     public static final String[] IGNORED_FIELDS_TRACES = {"projectId", "projectName", "createdAt",
             "lastUpdatedAt", "feedbackScores", "createdBy", "lastUpdatedBy", "totalEstimatedCost", "spanCount",
-            "duration", "comments", "threadId", "guardrailsValidations"};
+            "llmCallCount", "duration", "comments", "threadId", "guardrailsValidations"};
 
     private static final String[] IGNORED_FIELDS_SCORES = {"createdAt", "lastUpdatedAt", "createdBy", "lastUpdatedBy"};
 
diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java
index bf3cd8ff4d..69524a35ab 100644
--- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java
+++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/TracesResourceTest.java
@@ -199,6 +199,7 @@ class TracesResourceTest {
         EXCLUDE_FUNCTIONS.put(Trace.TraceField.GUARDRAILS_VALIDATIONS,
                 it -> it.toBuilder().guardrailsValidations(null).build());
         EXCLUDE_FUNCTIONS.put(Trace.TraceField.SPAN_COUNT, it -> it.toBuilder().spanCount(0).build());
+        EXCLUDE_FUNCTIONS.put(Trace.TraceField.LLM_CALL_COUNT, it -> it.toBuilder().llmCallCount(0).build());
         EXCLUDE_FUNCTIONS.put(Trace.TraceField.TOTAL_ESTIMATED_COST,
                 it -> it.toBuilder().totalEstimatedCost(null).build());
         EXCLUDE_FUNCTIONS.put(Trace.TraceField.THREAD_ID, it -> it.toBuilder().threadId(null).build());
@@ -4941,6 +4942,7 @@ void createAndRetrieveTraces__spanCountReflectsActualSpans_andTotalCountMatches(
                 List<Span> spansForTrace = IntStream.range(0, trace.spanCount())
                         .mapToObj(j -> factory.manufacturePojo(Span.class).toBuilder()
                                 .projectName(projectName)
+                                .type(SpanType.llm)
                                 .traceId(trace.id())
                                 .build())
                         .toList();
@@ -4959,9 +4961,15 @@ void createAndRetrieveTraces__spanCountReflectsActualSpans_andTotalCountMatches(
                 returnedTraces.stream()
                         .filter(returned -> returned.id().equals(created.id()))
                         .findFirst()
-                        .ifPresentOrElse(returned -> assertThat(returned.spanCount())
-                                .as("Trace with id %s should have spanCount %d", created.id(), created.spanCount())
-                                .isEqualTo(created.spanCount()),
+                        .ifPresentOrElse(returned -> {
+                            assertThat(returned.spanCount())
+                                    .as("Trace with id %s should have spanCount %d", created.id(), created.spanCount())
+                                    .isEqualTo(created.spanCount());
+                            assertThat(returned.llmCallCount())
+                                    .as("Trace with id %s should have llmCallCount %d", created.id(),
+                                            created.spanCount())
+                                    .isEqualTo(created.spanCount());
+                        },
                                 () -> assertThat(false)
                                         .as("Trace with id %s should be present", created.id())
                                         .isTrue());
@@ -4975,6 +4983,15 @@ void createAndRetrieveTraces__spanCountReflectsActualSpans_andTotalCountMatches(
             assertThat(actualTotalSpanCount)
                     .as("Total spanCount across all traces should match the expected total")
                     .isEqualTo(expectedTotalSpanCount);
+
+            int actualTotalLlmCallCount = returnedTraces.stream()
+                    .filter(rt -> traces.stream().anyMatch(t -> t.id().equals(rt.id())))
+                    .mapToInt(Trace::llmCallCount)
+                    .sum();
+
+            assertThat(actualTotalLlmCallCount)
+                    .as("Total llmCallCount across all traces should match the expected total")
+                    .isEqualTo(expectedTotalSpanCount);
         }
         private Stream<Arguments> getTracesByProject__whenSortingByValidFields__thenReturnTracesSorted() {
 
diff --git a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab/TracesSpansTab.tsx b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab/TracesSpansTab.tsx
index e286b9615b..e2590154cd 100644
--- a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab/TracesSpansTab.tsx
+++ b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab/TracesSpansTab.tsx
@@ -489,6 +489,13 @@ export const TracesSpansTab: React.FC<TracesSpansTabProps> = ({
               type: COLUMN_TYPE.number,
               accessorFn: (row: BaseTraceData) => get(row, "span_count", "-"),
             },
+            {
+              id: "llm_call_count",
+              label: "LLM calls count",
+              type: COLUMN_TYPE.number,
+              accessorFn: (row: BaseTraceData) =>
+                get(row, "llm_call_count", "-"),
+            },
             {
               id: "thread_id",
               label: "Thread ID",
diff --git a/apps/opik-frontend/src/types/traces.ts b/apps/opik-frontend/src/types/traces.ts
index f66cffb88d..d2e966f640 100644
--- a/apps/opik-frontend/src/types/traces.ts
+++ b/apps/opik-frontend/src/types/traces.ts
@@ -51,6 +51,7 @@ export interface BaseTraceData {
 
 export interface Trace extends BaseTraceData {
   span_count?: number;
+  llm_call_count?: number;
   thread_id?: string;
   project_id: string;
   workspace_name?: string;


Code review comments:
diff_hunk: @@ -63,7 +63,9 @@ public record Trace(
                 Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY, description = "Duration in milliseconds as a decimal number to support sub-millisecond precision") Double duration,
         @JsonView({Trace.View.Public.class, Trace.View.Write.class}) String threadId,
         @JsonView({
-                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) VisibilityMode visibilityMode){
+                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) VisibilityMode visibilityMode,
+        @JsonView({
+                Trace.View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) int llmCallCount){
path: apps/opik-backend/src/main/java/com/comet/opik/api/Trace.java
text: @user1:
Please update to `llmSpanCount` everywhere in this PR (and `llm_span_count` and so on...).

@author:
Not sure I understand it completely. All the naming is done using the analogy to other fields. 
Can you provide some more details maybe some examples, please? 
@user1 

@author:
Maybe you could indicate some places where we already have the correct naming

@user1:
We use the `span` nomenclature, there's no notion of `call` in Opik. Please use `llmSpanCount` etc. as suggested.

@user1:
For the external UI label, it's fine to keep `LLM calls count` as proposed in the related Github issue.

@author:
Got it, thanks @user1 

@author:
The changes are done @user1