Session Score Aggregation#
Overview#
The Sessions view in Langfuse displays per-session score metrics alongside usage and cost data. The aggregation is handled in ClickHouse using two separate CTEs — one for session-level event data (usage/cost) and one for score data from the scores table — which are then joined in the service layer.
Two-CTE Architecture#
Score data is not co-located in the events table; it lives in the dedicated scores table. The system therefore uses two separate CTEs composed by CTEQueryBuilder:
session_dataCTE — aggregates theevents_core/events_fulltable bysession_id(usage, cost, trace metadata)scores_aggCTE — aggregates thescorestable by(project_id, session_id)(numeric averages, categorical lists)
These are joined with a LEFT JOIN ON sc.score_session_id = s.session_id.
CTE 1: session_data — Event Aggregation#
Entry point: eventsSessionsAggregation() in query-fragments.ts
This function instantiates EventsSessionAggregationQueryBuilder, which groups the events table directly by session_id in a single step — avoiding the two-step trace→session re-aggregation approach .
The EVENTS_SESSION_AGGREGATION_FIELDS constant defines all aggregated columns:
| Column | ClickHouse expression |
|---|---|
trace_ids | groupUniqArray(trace_id) |
user_ids | groupUniqArrayIf(user_id, ...) |
trace_count | uniq(trace_id) |
total_observations | uniqIf(span_id, parent_span_id != '') |
duration | date_diff('second', min(start_time), max(end_time)) |
session_usage_details | sumMap(usage_details) |
session_cost_details | sumMap(cost_details) |
session_input_cost / session_output_cost | arraySum(mapValues(mapFilter(positionCaseInsensitive ...))) |
session_total_cost | sumMap(cost_details)['total'] |
The builder groups by session_id only . Filters for session_id IN (...) and start_time >= are applied via withSessionIds() and withStartTimeFrom().
CTE 2: scores_agg — Score Aggregation#
Entry point: eventsSessionScoresAggregation() in query-fragments.ts
This CTE queries the scores table with FINAL, grouped by (project_id, session_id) :
- Inner query:
avg(value)per(project_id, session_id, name, data_type, string_value)— pre-aggregates per score name to enable correct averaging - Outer query:
groupArrayIf(tuple(name, avg_value), data_type IN ('NUMERIC', 'BOOLEAN'))→scores_avggroupArrayIf(concat(name, ':', string_value), data_type IN ('CATEGORICAL', 'TEXT') AND notEmpty(string_value))→score_categories
The output schema is (project_id, score_session_id, scores_avg, score_categories) .
Service Layer: Composing and Executing the Query#
File: sessions-ui-table-events-service.ts — getSessionsTableFromEventsGeneric()
The service orchestrates the CTEs:
- Always builds
session_dataCTE fromeventsSessionsAggregation() - Conditionally adds
scores_aggCTE — only whenselect === "metrics"or a score-based filter/sort is active - Left-joins on
sc.score_session_id = s.session_id - In
"metrics"mode, selectssc.scores_avgandsc.score_categoriesalongside all session cost/usage fields - In
"rows"mode (identity-only columns for the table list), skips the scores join entirely
API exposure: The service is called from the TRPC router at web/src/server/api/routers/sessions.ts via the listFromEvents procedure.
Data Flow Summary#
events_core / events_full scores table (FINAL)
GROUP BY session_id GROUP BY (project_id, session_id, name, ...)
sumMap(usage_details) avg(value) per score name
sumMap(cost_details) → scores_avg[], score_categories[]
groupUniqArray(trace_id)
│ │
▼ ▼
session_data CTE scores_agg CTE
│ │
└──────── LEFT JOIN on session_id ──────┘
│
▼
getSessionsTableFromEventsGeneric()
→ Sessions UI table (rows + metrics)
Key Files#
| File | Role |
|---|---|
query-fragments.ts | eventsSessionsAggregation() and eventsSessionScoresAggregation() CTE builders |
event-query-builder.ts | EventsSessionAggregationQueryBuilder, EVENTS_SESSION_AGGREGATION_FIELDS |
sessions-ui-table-events-service.ts | getSessionsTableFromEventsGeneric() — composes and executes the query |
sessions.ts (router) | TRPC procedures listFromEvents, countAllFromEvents |