fix: sessions table (#11746)

- **fix latest sessions api**
- **fix: sessions phase 1 ui**
- **fix: minor ui issues**

---------

Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
udev
2026-05-18 19:20:43 +00:00
committed by GitHub
co-authored by sai nikhil kethe github-actions[bot]
parent 015f8778a4
commit f736ec844c
39 changed files with 6376 additions and 464 deletions
+15
View File
@@ -254,6 +254,21 @@ pub struct Response {
pub peak_memory_usage: Option<f64>,
}
/// Paginated response used by list-style APIs (sessions, traces, users).
#[derive(Clone, Debug, Serialize, Deserialize, Default, ToSchema)]
pub struct PaginatedResponse {
pub took: usize,
pub total: usize,
pub from: i64,
pub size: i64,
#[schema(value_type = Vec<Object>)]
pub hits: Vec<json::Value>,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub trace_id: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub function_error: String,
}
/// Iterator for Streaming response of search `Response`
///
/// This is used to split the search response to smaller chunks based on
+123 -16
View File
@@ -19,7 +19,10 @@ use config::{
axum::middlewares::{get_process_time, insert_process_time_header},
get_config,
meta::{
search::{SearchPartitionRequest, StreamResponses, TimeOffset, default_use_cache},
search::{
PaginatedResponse, SearchPartitionRequest, StreamResponses, TimeOffset,
default_use_cache,
},
stream::StreamType,
},
metrics,
@@ -53,9 +56,23 @@ use crate::{
};
pub mod dag;
mod schema_compat;
pub mod session;
pub mod user;
#[derive(Default, Clone, Debug)]
pub(crate) struct TraceDetail {
pub(crate) start_time: i64,
pub(crate) end_time: i64,
pub(crate) gen_ai_usage_input_tokens: i64,
pub(crate) gen_ai_usage_output_tokens: i64,
pub(crate) gen_ai_usage_total_tokens: i64,
pub(crate) gen_ai_usage_cost: f64,
pub(crate) error_count: i64,
pub(crate) user_id: Option<String>,
pub(crate) first_user_message: Option<String>,
}
/// TracesIngest
#[utoipa::path(
post,
@@ -311,6 +328,13 @@ pub async fn get_latest_traces(
let is_llm_stream =
infra::schema::get_is_llm_stream(org_id.as_str(), stream_name.as_str(), StreamType::Traces)
.await;
let has_gen_ai_fields = is_llm_stream
&& schema_compat::stream_has_gen_ai_fields(
org_id.as_str(),
stream_name.as_str(),
StreamType::Traces,
)
.await;
let mut range_error = String::new();
if max_query_range > 0 && (end_time - start_time) > max_query_range * 3600 * 1_000_000 {
start_time = end_time - max_query_range * 3600 * 1_000_000;
@@ -335,7 +359,7 @@ pub async fn get_latest_traces(
let sort_order = if sort_order == "asc" { "ASC" } else { "DESC" };
// search
let query_sql = if is_llm_stream {
let query_sql = if is_llm_stream && has_gen_ai_fields {
format!(
"SELECT trace_id, min({TIMESTAMP_COL_NAME}) as zo_sql_timestamp, \
min(start_time) as trace_start_time, max(end_time) as trace_end_time, \
@@ -344,7 +368,24 @@ pub async fn get_latest_traces(
sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details, \
FIRST_VALUE(gen_ai_input_messages ORDER BY {TIMESTAMP_COL_NAME} ASC) as gen_ai_input_messages \
array_agg(DISTINCT gen_ai_response_model) FILTER (WHERE gen_ai_response_model IS NOT NULL AND gen_ai_response_model != '') as gen_ai_response_models, \
FIRST_VALUE(gen_ai_input_messages ORDER BY {TIMESTAMP_COL_NAME} ASC) FILTER (WHERE gen_ai_input_messages IS NOT NULL AND gen_ai_input_messages != '') as gen_ai_input_messages \
FROM \"{stream_name}\""
)
} else if is_llm_stream {
// Legacy `_o2_llm` schema (pre-PR #11626): columns live under `llm_*`
// names and the messages column is `llm_input`. Per-direction cost is
// not stored separately, so we emit only the total.
format!(
"SELECT trace_id, min({TIMESTAMP_COL_NAME}) as zo_sql_timestamp, \
min(start_time) as trace_start_time, max(end_time) as trace_end_time, \
(max(end_time) - min(start_time)) as zo_sql_duration, \
sum(llm_usage_tokens_input) as gen_ai_usage_details_input, \
sum(llm_usage_tokens_output) as gen_ai_usage_details_output, \
sum(llm_usage_tokens_total) as gen_ai_usage_details_total, \
sum(llm_usage_cost_total) as gen_ai_usage_cost_details, \
array_agg(DISTINCT llm_model_name) FILTER (WHERE llm_model_name IS NOT NULL AND llm_model_name != '') as gen_ai_response_models, \
FIRST_VALUE(llm_input ORDER BY {TIMESTAMP_COL_NAME} ASC) FILTER (WHERE llm_input IS NOT NULL AND llm_input != '') as gen_ai_input_messages \
FROM \"{stream_name}\""
)
} else {
@@ -489,6 +530,15 @@ pub async fn get_latest_traces(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
gen_ai_input_messages: item.get("gen_ai_input_messages").cloned(),
models: item
.get("gen_ai_response_models")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
},
);
}
@@ -707,6 +757,27 @@ pub async fn get_latest_traces(
let resp_search = match search_res {
Ok(res) => res,
Err(err) => {
let time = start.elapsed().as_secs_f64();
metrics::HTTP_RESPONSE_TIME
.with_label_values(&[
"/api/org/traces/latest",
"500",
&org_id,
stream_type.as_str(),
"",
"",
])
.observe(time);
metrics::HTTP_INCOMING_REQUESTS
.with_label_values(&[
"/api/org/traces/latest",
"500",
&org_id,
stream_type.as_str(),
"",
"",
])
.inc();
log::error!("get traces latest service-breakdown error: {err:?}");
return map_error_to_http_response(&err, Some(trace_id));
}
@@ -774,17 +845,18 @@ pub async fn get_latest_traces(
])
.inc();
let mut resp: HashMap<&str, json::Value> = HashMap::new();
resp.insert("took", json::Value::from((time * 1000.0) as usize));
resp.insert("total", json::Value::from(traces_data.len()));
resp.insert("from", json::Value::from(from));
resp.insert("size", json::Value::from(size));
resp.insert("hits", json::to_value(traces_data).unwrap());
resp.insert("trace_id", json::Value::from(trace_id));
if !range_error.is_empty() {
resp.insert("function_error", json::Value::String(range_error));
}
MetaHttpResponse::json(resp)
MetaHttpResponse::json(PaginatedResponse {
took: (time * 1000.0) as usize,
total: traces_data.len(),
from,
size,
hits: traces_data
.into_iter()
.map(|v| json::to_value(v).unwrap())
.collect(),
trace_id,
function_error: range_error,
})
}
/// GetLatestTracesStream — HTTP/2 streaming variant of GetLatestTraces
@@ -952,6 +1024,13 @@ pub async fn get_latest_traces_stream(
let is_llm_stream =
infra::schema::get_is_llm_stream(org_id.as_str(), stream_name.as_str(), StreamType::Traces)
.await;
let has_gen_ai_fields = is_llm_stream
&& schema_compat::stream_has_gen_ai_fields(
org_id.as_str(),
stream_name.as_str(),
StreamType::Traces,
)
.await;
let mut range_error = String::new();
if max_query_range > 0 && (end_time - start_time) > max_query_range * 3600 * 1_000_000 {
start_time = end_time - max_query_range * 3600 * 1_000_000;
@@ -1004,6 +1083,7 @@ pub async fn get_latest_traces_stream(
sort_order.to_string(),
sql_order_expr,
is_llm_stream,
has_gen_ai_fields,
use_cache,
range_error,
tx,
@@ -1067,6 +1147,7 @@ async fn process_latest_traces_stream(
sort_order: String,
sql_order_expr: String,
is_llm_stream: bool,
has_gen_ai_fields: bool,
use_cache: bool,
range_error: String,
sender: mpsc::Sender<Result<StreamResponses, infra::errors::Error>>,
@@ -1091,7 +1172,7 @@ async fn process_latest_traces_stream(
};
// Build the aggregation SQL (Query 1) — identical to get_latest_traces
let query_sql_base = if is_llm_stream {
let query_sql_base = if is_llm_stream && has_gen_ai_fields {
format!(
"SELECT trace_id, min({TIMESTAMP_COL_NAME}) as zo_sql_timestamp, \
min(start_time) as trace_start_time, max(end_time) as trace_end_time, \
@@ -1100,7 +1181,23 @@ async fn process_latest_traces_stream(
sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details, \
FIRST_VALUE(gen_ai_input_messages ORDER BY {TIMESTAMP_COL_NAME} ASC) as gen_ai_input_messages \
array_agg(DISTINCT gen_ai_response_model) FILTER (WHERE gen_ai_response_model IS NOT NULL AND gen_ai_response_model != '') as gen_ai_response_models, \
FIRST_VALUE(gen_ai_input_messages ORDER BY {TIMESTAMP_COL_NAME} ASC) FILTER (WHERE gen_ai_input_messages IS NOT NULL AND gen_ai_input_messages != '') as gen_ai_input_messages \
FROM \"{stream_name}\""
)
} else if is_llm_stream {
// Legacy `_o2_llm` schema (pre-PR #11626): see comment in
// `get_latest_traces` for column mapping rationale.
format!(
"SELECT trace_id, min({TIMESTAMP_COL_NAME}) as zo_sql_timestamp, \
min(start_time) as trace_start_time, max(end_time) as trace_end_time, \
(max(end_time) - min(start_time)) as zo_sql_duration, \
sum(llm_usage_tokens_input) as gen_ai_usage_details_input, \
sum(llm_usage_tokens_output) as gen_ai_usage_details_output, \
sum(llm_usage_tokens_total) as gen_ai_usage_details_total, \
sum(llm_usage_cost_total) as gen_ai_usage_cost_details, \
array_agg(DISTINCT llm_model_name) FILTER (WHERE llm_model_name IS NOT NULL AND llm_model_name != '') as gen_ai_response_models, \
FIRST_VALUE(llm_input ORDER BY {TIMESTAMP_COL_NAME} ASC) FILTER (WHERE llm_input IS NOT NULL AND llm_input != '') as gen_ai_input_messages \
FROM \"{stream_name}\""
)
} else {
@@ -1401,6 +1498,15 @@ async fn process_latest_traces_stream(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
gen_ai_input_messages: item.get("gen_ai_input_messages").cloned(),
models: item
.get("gen_ai_response_models")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
},
);
}
@@ -1721,6 +1827,7 @@ struct TraceResponseItem {
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
gen_ai_input_messages: Option<serde_json::Value>,
models: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
@@ -0,0 +1,179 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Schema-version detection and column-name selection for LLM trace streams.
//!
//! OpenObserve supports two generations of LLM trace schemas:
//!
//! - **New (`gen_ai_*`)** — emitted since PR #11626 ("refactor llm attribute trace"). Tokens, cost,
//! model, user, session id all use the OTEL Gen-AI spec names: `gen_ai_usage_input_tokens`,
//! `gen_ai_response_model`, `user.id` (queried as `user.id`), `gen_ai_conversation_id`, etc.
//! - **Legacy (`_o2_llm`)** — pre-PR #11626. Tokens live under
//! `llm_usage_tokens_{input,output,total}`, cost under `llm_usage_cost_total`, model under
//! `llm_model_name`, and user/session ids under `llm_user_id`/`llm_session_id`.
//!
//! For streams ingested across the migration boundary the handler must pick
//! the right column names at query time. We detect the generation by looking
//! for a sentinel new-schema column (`gen_ai_usage_input_tokens`) in the
//! cached Arrow schema.
use arrow_schema::Schema;
use config::meta::stream::StreamType;
use crate::service::traces::otel::attributes::OtelAttributes;
/// Sentinel column whose presence indicates the new `gen_ai_*` schema.
const GEN_AI_SENTINEL_COLUMN: &str = "gen_ai_usage_input_tokens";
/// Column names that vary between the new and legacy LLM schemas.
///
/// `Default` returns the new-schema names so call sites that don't care about
/// legacy compatibility (or that lack a cached schema) keep working unchanged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct LlmColumns {
/// Per-trace conversation/session identifier.
pub(super) session_id: &'static str,
/// Per-trace user identifier.
pub(super) user_id: &'static str,
}
impl LlmColumns {
/// Column layout for the current (`gen_ai_*`) schema.
pub(super) const fn current() -> Self {
Self {
session_id: "gen_ai_conversation_id",
user_id: OtelAttributes::USER_ID,
}
}
/// Column layout for the legacy (`_o2_llm`) schema.
pub(super) const fn legacy() -> Self {
Self {
session_id: "llm_session_id",
user_id: "llm_user_id",
}
}
}
/// Decide which schema generation an Arrow schema belongs to.
///
/// Returns `true` when the new `gen_ai_*` columns are present (i.e. the stream
/// was ingested after PR #11626), `false` for legacy `_o2_llm` streams.
pub(super) fn has_gen_ai_fields(schema: &Schema) -> bool {
schema.field_with_name(GEN_AI_SENTINEL_COLUMN).is_ok()
}
/// Pick the column-name layout for a given schema.
///
/// Intentionally test-only today: production call sites read `has_gen_ai_fields`
/// from the cached schema asynchronously and pick the layout directly to avoid
/// reborrowing the `Arc<Schema>` (see `stream_has_gen_ai_fields`). This pure
/// helper exists so the mapping rule can be unit-tested against synthetic
/// schemas without touching the global cache.
#[cfg(test)]
fn columns_for(schema: &Schema) -> LlmColumns {
if has_gen_ai_fields(schema) {
LlmColumns::current()
} else {
LlmColumns::legacy()
}
}
/// Fetch the stream's Arrow schema from cache and decide whether it uses the
/// new `gen_ai_*` columns. Streams missing from the cache default to `false`
/// (legacy) so query SQL never references columns that aren't there for an
/// unknown stream.
pub(super) async fn stream_has_gen_ai_fields(
org_id: &str,
stream_name: &str,
stream_type: StreamType,
) -> bool {
infra::schema::get_stream_schema_from_cache(org_id, stream_name, stream_type)
.await
.map(|s| has_gen_ai_fields(&s))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use arrow_schema::{DataType, Field, Schema};
use super::*;
fn schema_with(fields: &[&str]) -> Schema {
let fields: Vec<Field> = fields
.iter()
.map(|n| Field::new(*n, DataType::Utf8, true))
.collect();
Schema::new(fields)
}
#[test]
fn detects_new_schema_via_sentinel() {
let schema = schema_with(&[
"trace_id",
"gen_ai_usage_input_tokens",
"gen_ai_usage_total_tokens",
]);
assert!(has_gen_ai_fields(&schema));
assert_eq!(columns_for(&schema), LlmColumns::current());
}
#[test]
fn detects_legacy_schema_when_sentinel_missing() {
let schema = schema_with(&[
"trace_id",
"llm_usage_tokens_input",
"llm_usage_tokens_total",
"llm_user_id",
"llm_session_id",
]);
assert!(!has_gen_ai_fields(&schema));
assert_eq!(columns_for(&schema), LlmColumns::legacy());
}
#[test]
fn legacy_layout_uses_llm_prefixed_columns() {
let cols = LlmColumns::legacy();
assert_eq!(cols.session_id, "llm_session_id");
assert_eq!(cols.user_id, "llm_user_id");
}
#[test]
fn current_layout_uses_gen_ai_columns() {
let cols = LlmColumns::current();
assert_eq!(cols.session_id, "gen_ai_conversation_id");
// user.id is the OTEL attribute name — the search layer resolves it
// to the flattened column.
assert_eq!(cols.user_id, OtelAttributes::USER_ID);
assert_eq!(cols.user_id, "user.id");
}
#[test]
fn empty_schema_is_treated_as_legacy() {
let schema = Schema::empty();
assert!(!has_gen_ai_fields(&schema));
assert_eq!(columns_for(&schema), LlmColumns::legacy());
}
#[test]
fn schema_with_only_partial_new_columns_still_detected_via_sentinel() {
// A schema missing the sentinel but containing other gen_ai_* columns
// should be classified as legacy — we trust the single sentinel rather
// than guess from partial migrations.
let schema = schema_with(&["trace_id", "gen_ai_usage_cost"]);
assert!(!has_gen_ai_fields(&schema));
}
}
+540 -85
View File
@@ -14,11 +14,17 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{extract::Path, http::HeaderMap, response::Response};
use config::{TIMESTAMP_COL_NAME, get_config, meta::stream::StreamType, metrics, utils::json};
use hashbrown::HashMap;
use config::{
TIMESTAMP_COL_NAME, get_config,
meta::{search::PaginatedResponse, stream::StreamType},
metrics,
utils::json,
};
use hashbrown::{HashMap, HashSet};
use serde::Serialize;
use tracing::{Instrument, Span};
use super::TraceDetail;
use crate::{
common::{
meta::http::HttpResponse as MetaHttpResponse,
@@ -30,7 +36,7 @@ use crate::{
handler::http::{
extractors::Headers, request::search::error_utils::map_error_to_http_response,
},
service::{search as SearchService, traces::otel::attributes::O2Attributes},
service::search as SearchService,
};
/// GetLatestSessions
@@ -73,7 +79,8 @@ use crate::{
"gen_ai_usage_input_tokens": 100,
"gen_ai_usage_output_tokens": 50,
"gen_ai_usage_total_tokens": 150,
"gen_ai_usage_cost": 0.005
"gen_ai_usage_cost": 0.005,
"error_count": 1
}
]
})),
@@ -196,11 +203,25 @@ pub async fn get_latest_sessions(
.map_or(0, |v| v.parse::<i64>().unwrap_or(0));
// session_id may appear on the first span only or on all spans of a trace.
// gen_ai_* fields may be on different spans than session_id.
// gen_ai_*/llm_* fields may be on different spans than session_id.
// So we must: get session→trace_id mapping first, then query by trace_id
// (which captures ALL spans) to get accurate gen_ai_* totals.
let session_id_col = O2Attributes::SESSION_ID;
// (which captures ALL spans) to get accurate usage totals.
//
// Detect schema generation up front so both Phase 1 (session-id column)
// and Phase 2 (token/cost columns) pick consistent names.
let stream_type = StreamType::Traces;
let has_gen_ai_fields = super::schema_compat::stream_has_gen_ai_fields(
org_id.as_str(),
stream_name.as_str(),
stream_type,
)
.await;
let llm_cols = if has_gen_ai_fields {
super::schema_compat::LlmColumns::current()
} else {
super::schema_compat::LlmColumns::legacy()
};
let session_id_col = llm_cols.session_id;
let user_id_opt = Some(user_id.to_string());
// Phase 1: Get paginated session list with trace_ids per session
@@ -291,20 +312,67 @@ pub async fn get_latest_sessions(
.into_iter()
.collect();
if all_trace_ids.is_empty() {
return MetaHttpResponse::json(PaginatedResponse {
took: 0,
total: 0,
from,
size,
hits: vec![],
trace_id,
function_error: String::new(),
});
}
// Phase 2: Get per-trace details by querying with trace_id (captures ALL spans)
let trace_ids_sql = all_trace_ids.join("','");
let query_sql = format!(
"SELECT trace_id, \
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(gen_ai_usage_input_tokens) as gen_ai_usage_details_input, \
sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
);
// Sanitize trace IDs before interpolating into SQL: allow only hex chars and hyphens.
// Trace IDs originate from ingested data and could contain injected SQL if not validated.
let sanitized_ids: Vec<String> = all_trace_ids
.iter()
.map(|tid| {
tid.chars()
.filter(|c| c.is_ascii_hexdigit() || *c == '-')
.collect::<String>()
})
.filter(|tid| !tid.is_empty())
.collect();
let trace_ids_sql = sanitized_ids.join("','");
let query_sql = if has_gen_ai_fields {
format!(
"SELECT trace_id, \
max(user_id) as user_id,
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(gen_ai_usage_input_tokens) as gen_ai_usage_details_input, \
sum(gen_ai_usage_output_tokens) as gen_ai_usage_details_output, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details, \
sum(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) as error_count, \
FIRST_VALUE(gen_ai_input_messages ORDER BY start_time ASC) FILTER (WHERE gen_ai_input_messages IS NOT NULL AND gen_ai_input_messages != '') as gen_ai_input_messages \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
)
} else {
// Legacy `_o2_llm` schema (pre-PR #11626): columns live under `llm_*`
// names. user.id is stored as the flattened `llm_user_id` column,
// tokens/cost use the `llm_usage_tokens_*` / `llm_usage_cost_total`
// shape, and the messages column is `llm_input`.
format!(
"SELECT trace_id, \
max(llm_user_id) as user_id,
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(llm_usage_tokens_input) as gen_ai_usage_details_input, \
sum(llm_usage_tokens_output) as gen_ai_usage_details_output, \
sum(llm_usage_tokens_total) as gen_ai_usage_details_total, \
sum(llm_usage_cost_total) as gen_ai_usage_cost_details, \
sum(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) as error_count, \
FIRST_VALUE(llm_input ORDER BY start_time ASC) FILTER (WHERE llm_input IS NOT NULL AND llm_input != '') as gen_ai_input_messages \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
)
};
req.query.sql = query_sql;
req.query.from = 0;
req.query.size = all_trace_ids.len() as i64;
@@ -356,6 +424,7 @@ pub async fn get_latest_sessions(
Some(s) => s.to_string(),
None => continue,
};
let first_user_message = extract_first_user_message(item.get("gen_ai_input_messages"), 30);
trace_details.insert(
tid,
TraceDetail {
@@ -373,6 +442,11 @@ pub async fn get_latest_sessions(
gen_ai_usage_cost: json::get_float_value(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
error_count: json::get_int_value(item.get("error_count").unwrap_or_default()),
user_id: item
.get("user_id")
.and_then(|v| v.as_str().map(String::from)),
first_user_message,
},
);
}
@@ -402,30 +476,89 @@ pub async fn get_latest_sessions(
])
.inc();
let mut resp: HashMap<&str, json::Value> = HashMap::new();
resp.insert("took", json::Value::from((time * 1000.0) as usize));
resp.insert("total", json::Value::from(sessions_data.len()));
resp.insert("from", json::Value::from(from));
resp.insert("size", json::Value::from(size));
resp.insert("hits", json::to_value(sessions_data).unwrap());
resp.insert("trace_id", json::Value::from(trace_id));
if !range_error.is_empty() {
resp.insert("function_error", json::Value::String(range_error));
}
MetaHttpResponse::json(resp)
MetaHttpResponse::json(PaginatedResponse {
took: (time * 1000.0) as usize,
total: sessions_data.len(),
from,
size,
hits: sessions_data
.into_iter()
.map(|v| json::to_value(v).unwrap())
.collect(),
trace_id,
function_error: range_error,
})
}
struct TraceDetail {
start_time: i64,
end_time: i64,
gen_ai_usage_input_tokens: i64,
gen_ai_usage_output_tokens: i64,
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
/// Extract the first user message from a `gen_ai_input_messages` JSON value.
///
/// The input is expected to be a JSON array of message objects with `role` and
/// `content` fields (e.g. `[{"role":"user","content":"hello"}]`). Returns the
/// content of the first message with role "user", trimmed to `max_len` chars.
fn extract_first_user_message(
messages_val: Option<&json::Value>,
max_len: usize,
) -> Option<String> {
let val = messages_val?;
// The value may be:
// 1. A JSON array directly: [{"role":"user","content":"..."}]
// 2. A JSON string of an array: "[{\"role\":\"user\",...}]"
// 3. A JSON string of an object with a nested "messages" array:
// "{\"model\":\"...\",\"messages\":[{\"role\":\"user\",...}]}"
let parsed: json::Value;
let msgs_val: &json::Value = if val.is_array() {
val
} else if let Some(s) = val.as_str() {
parsed = json::from_str(s).ok()?;
&parsed
} else {
return None;
};
// Resolve the actual messages array — either the top-level value, or a nested
// "messages" key (OpenAI-style) or "contents" key (Gemini/LiteLLM-style).
let arr = if let Some(a) = msgs_val.as_array() {
a
} else if let Some(a) = msgs_val.get("messages").and_then(|v| v.as_array()) {
a
} else if let Some(a) = msgs_val.get("contents").and_then(|v| v.as_array()) {
a
} else {
return None;
};
for msg in arr {
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
if !role.eq_ignore_ascii_case("user") {
continue;
}
// OpenAI-style: content is a plain string
if let Some(content) = msg.get("content").and_then(|v| v.as_str()) {
let trimmed: String = content.chars().take(max_len).collect();
return Some(trimmed);
}
// Gemini/LiteLLM-style: parts: [{text: "..."}]
if let Some(text) = msg
.get("parts")
.and_then(|v| v.as_array())
.and_then(|parts| {
parts
.iter()
.find_map(|p| p.get("text").and_then(|t| t.as_str()))
})
{
let trimmed: String = text.chars().take(max_len).collect();
return Some(trimmed);
}
}
None
}
#[derive(Debug, Serialize)]
struct SessionResponseItem {
struct SessionDetails {
session_id: String,
start_time: i64,
end_time: i64,
@@ -435,6 +568,71 @@ struct SessionResponseItem {
gen_ai_usage_output_tokens: i64,
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
error_count: i64,
user_ids: Vec<String>,
first_user_message: Option<String>,
}
impl SessionDetails {
fn from_trace_details(session_id: String, trace_count: usize, details: &[TraceDetail]) -> Self {
let mut start_time: i64 = 0;
let mut end_time: i64 = 0;
let mut usage_input: i64 = 0;
let mut usage_output: i64 = 0;
let mut usage_total: i64 = 0;
let mut cost_total: f64 = 0.0;
let mut error_count: i64 = 0;
let mut user_ids: HashSet<String> = HashSet::with_capacity(details.len());
let mut first_user_message: Option<String> = None;
let mut earliest_user_msg_time: i64 = 0;
for detail in details {
if start_time == 0 || detail.start_time < start_time {
start_time = detail.start_time;
}
if detail.end_time > end_time {
end_time = detail.end_time;
}
usage_input += detail.gen_ai_usage_input_tokens;
usage_output += detail.gen_ai_usage_output_tokens;
usage_total += detail.gen_ai_usage_total_tokens;
cost_total += detail.gen_ai_usage_cost;
error_count += detail.error_count;
if let Some(ref uid) = detail.user_id {
user_ids.insert(uid.clone());
}
if let Some(ref msg) = detail.first_user_message
&& (first_user_message.is_none()
|| (detail.start_time != 0 && detail.start_time < earliest_user_msg_time)
|| earliest_user_msg_time == 0)
{
first_user_message = Some(msg.clone());
earliest_user_msg_time = detail.start_time;
}
}
let duration = if end_time > start_time {
end_time - start_time
} else {
0
};
// HashSet → Vec produces non-deterministic order; sort so callers
// (and tests) see a stable result.
let mut user_ids: Vec<String> = user_ids.into_iter().collect();
user_ids.sort();
SessionDetails {
session_id,
start_time,
end_time,
duration,
trace_count: trace_count as u16,
gen_ai_usage_input_tokens: usage_input,
gen_ai_usage_output_tokens: usage_output,
gen_ai_usage_total_tokens: usage_total,
gen_ai_usage_cost: cost_total,
error_count,
user_ids,
first_user_message,
}
}
}
fn parse_session_trace_ids(
@@ -467,49 +665,22 @@ fn aggregate_sessions(
session_ids: &[String],
session_trace_ids: &HashMap<String, Vec<String>>,
trace_details: &HashMap<String, TraceDetail>,
) -> Vec<SessionResponseItem> {
let mut sessions_data: Vec<SessionResponseItem> = Vec::with_capacity(session_ids.len());
) -> Vec<SessionDetails> {
let mut sessions_data: Vec<SessionDetails> = Vec::with_capacity(session_ids.len());
for session_id in session_ids {
let trace_ids = match session_trace_ids.get(session_id) {
Some(ids) => ids,
None => continue,
};
let mut session_start_time: i64 = 0;
let mut session_end_time: i64 = 0;
let mut usage_input: i64 = 0;
let mut usage_output: i64 = 0;
let mut usage_total: i64 = 0;
let mut cost_total: f64 = 0.0;
for tid in trace_ids {
if let Some(detail) = trace_details.get(tid) {
if session_start_time == 0 || detail.start_time < session_start_time {
session_start_time = detail.start_time;
}
if detail.end_time > session_end_time {
session_end_time = detail.end_time;
}
usage_input += detail.gen_ai_usage_input_tokens;
usage_output += detail.gen_ai_usage_output_tokens;
usage_total += detail.gen_ai_usage_total_tokens;
cost_total += detail.gen_ai_usage_cost;
}
}
let duration = if session_end_time > session_start_time {
session_end_time - session_start_time
} else {
0
};
sessions_data.push(SessionResponseItem {
session_id: session_id.clone(),
start_time: session_start_time,
end_time: session_end_time,
duration,
trace_count: trace_ids.len() as u16,
gen_ai_usage_input_tokens: usage_input,
gen_ai_usage_output_tokens: usage_output,
gen_ai_usage_total_tokens: usage_total,
gen_ai_usage_cost: cost_total,
});
let details: Vec<TraceDetail> = trace_ids
.iter()
.filter_map(|tid| trace_details.get(tid).cloned())
.collect();
sessions_data.push(SessionDetails::from_trace_details(
session_id.clone(),
trace_ids.len(),
&details,
));
}
sessions_data.sort_by(|a, b| b.start_time.cmp(&a.start_time));
sessions_data
@@ -523,7 +694,7 @@ mod tests {
#[test]
fn test_parse_session_trace_ids_empty() {
let (ids, map) = parse_session_trace_ids(&[], "session_id");
let (ids, map) = parse_session_trace_ids(&[], "gen_ai_conversation_id");
assert!(ids.is_empty());
assert!(map.is_empty());
}
@@ -531,10 +702,10 @@ mod tests {
#[test]
fn test_parse_session_trace_ids_basic() {
let hits = vec![
json!({"session_id": "sess-1", "trace_ids": ["t1", "t2"]}),
json!({"session_id": "sess-2", "trace_ids": ["t3"]}),
json!({"gen_ai_conversation_id": "sess-1", "trace_ids": ["t1", "t2"]}),
json!({"gen_ai_conversation_id": "sess-2", "trace_ids": ["t3"]}),
];
let (ids, map) = parse_session_trace_ids(&hits, "session_id");
let (ids, map) = parse_session_trace_ids(&hits, "gen_ai_conversation_id");
assert_eq!(ids, vec!["sess-1", "sess-2"]);
assert_eq!(
map.get("sess-1").unwrap(),
@@ -547,17 +718,17 @@ mod tests {
fn test_parse_session_trace_ids_skips_missing_col() {
let hits = vec![
json!({"other": "value"}),
json!({"session_id": "sess-1", "trace_ids": ["t1"]}),
json!({"gen_ai_conversation_id": "sess-1", "trace_ids": ["t1"]}),
];
let (ids, map) = parse_session_trace_ids(&hits, "session_id");
let (ids, map) = parse_session_trace_ids(&hits, "gen_ai_conversation_id");
assert_eq!(ids.len(), 1);
assert!(map.contains_key("sess-1"));
}
#[test]
fn test_parse_session_trace_ids_no_trace_ids_array() {
let hits = vec![json!({"session_id": "sess-1"})];
let (ids, map) = parse_session_trace_ids(&hits, "session_id");
let hits = vec![json!({"gen_ai_conversation_id": "sess-1"})];
let (ids, map) = parse_session_trace_ids(&hits, "gen_ai_conversation_id");
assert_eq!(ids, vec!["sess-1"]);
assert!(map.get("sess-1").unwrap().is_empty());
}
@@ -586,6 +757,9 @@ mod tests {
gen_ai_usage_output_tokens: 50,
gen_ai_usage_total_tokens: 150,
gen_ai_usage_cost: 0.01,
error_count: 0,
user_id: None,
..Default::default()
},
);
trace_details.insert(
@@ -597,6 +771,9 @@ mod tests {
gen_ai_usage_output_tokens: 100,
gen_ai_usage_total_tokens: 300,
gen_ai_usage_cost: 0.02,
error_count: 0,
user_id: None,
..Default::default()
},
);
@@ -612,6 +789,7 @@ mod tests {
assert_eq!(s.gen_ai_usage_output_tokens, 150);
assert_eq!(s.gen_ai_usage_total_tokens, 450);
assert!((s.gen_ai_usage_cost - 0.03).abs() < 1e-10);
assert_eq!(s.error_count, 0);
}
#[test]
@@ -630,6 +808,9 @@ mod tests {
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 0,
user_id: None,
..Default::default()
},
);
trace_details.insert(
@@ -641,6 +822,9 @@ mod tests {
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 0,
user_id: None,
..Default::default()
},
);
@@ -677,6 +861,9 @@ mod tests {
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 0,
user_id: None,
..Default::default()
},
);
@@ -703,6 +890,9 @@ mod tests {
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 0,
user_id: None,
..Default::default()
},
);
}
@@ -711,4 +901,269 @@ mod tests {
assert_eq!(result[0].start_time, 100);
assert_eq!(result[0].end_time, 600); // max(500+100, 100+100, 300+100)
}
#[test]
fn test_aggregate_sessions_error_count_across_traces() {
let session_ids = vec!["sess-1".to_string()];
let mut session_trace_ids = HashMap::new();
session_trace_ids.insert(
"sess-1".to_string(),
vec!["t1".to_string(), "t2".to_string()],
);
let mut trace_details = HashMap::new();
trace_details.insert(
"t1".to_string(),
TraceDetail {
start_time: 1000,
end_time: 2000,
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 3,
user_id: None,
..Default::default()
},
);
trace_details.insert(
"t2".to_string(),
TraceDetail {
start_time: 1500,
end_time: 3000,
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
error_count: 2,
user_id: None,
..Default::default()
},
);
let result = aggregate_sessions(&session_ids, &session_trace_ids, &trace_details);
assert_eq!(result.len(), 1);
assert_eq!(result[0].error_count, 5);
}
#[test]
fn test_from_trace_details_empty() {
let session = SessionDetails::from_trace_details("sess-1".to_string(), 0, &[]);
assert_eq!(session.session_id, "sess-1");
assert_eq!(session.start_time, 0);
assert_eq!(session.end_time, 0);
assert_eq!(session.duration, 0);
assert_eq!(session.trace_count, 0);
assert_eq!(session.gen_ai_usage_input_tokens, 0);
assert_eq!(session.gen_ai_usage_output_tokens, 0);
assert_eq!(session.gen_ai_usage_total_tokens, 0);
assert_eq!(session.gen_ai_usage_cost, 0.0);
assert_eq!(session.error_count, 0);
assert!(session.user_ids.is_empty());
assert!(session.first_user_message.is_none());
}
#[test]
fn test_from_trace_details_single_trace() {
let details = vec![TraceDetail {
start_time: 1000,
end_time: 2000,
gen_ai_usage_input_tokens: 10,
gen_ai_usage_output_tokens: 20,
gen_ai_usage_total_tokens: 30,
gen_ai_usage_cost: 0.05,
error_count: 1,
..Default::default()
}];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 1, &details);
assert_eq!(session.session_id, "sess-1");
assert_eq!(session.start_time, 1000);
assert_eq!(session.end_time, 2000);
assert_eq!(session.duration, 1000);
assert_eq!(session.trace_count, 1);
assert_eq!(session.gen_ai_usage_input_tokens, 10);
assert_eq!(session.gen_ai_usage_output_tokens, 20);
assert_eq!(session.gen_ai_usage_total_tokens, 30);
assert!((session.gen_ai_usage_cost - 0.05).abs() < 1e-10);
assert_eq!(session.error_count, 1);
}
#[test]
fn test_from_trace_details_with_user_ids() {
let details = vec![
TraceDetail {
start_time: 1000,
end_time: 2000,
user_id: Some("user-a".to_string()),
..Default::default()
},
TraceDetail {
start_time: 1500,
end_time: 3000,
user_id: Some("user-b".to_string()),
..Default::default()
},
];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 2, &details);
assert_eq!(session.trace_count, 2);
assert_eq!(session.start_time, 1000);
assert_eq!(session.end_time, 3000);
assert_eq!(session.user_ids, vec!["user-a", "user-b"]);
}
#[test]
fn test_from_trace_details_user_ids_sorted() {
// user_ids are collected into a HashSet; ensure the final Vec is sorted
// so output is deterministic regardless of hash iteration order.
let details: Vec<TraceDetail> = ["zeta", "alpha", "mike", "bravo"]
.iter()
.enumerate()
.map(|(i, uid)| TraceDetail {
start_time: 1000 + i as i64,
end_time: 2000 + i as i64,
user_id: Some((*uid).to_string()),
..Default::default()
})
.collect();
let session =
SessionDetails::from_trace_details("sess-1".to_string(), details.len(), &details);
assert_eq!(session.user_ids, vec!["alpha", "bravo", "mike", "zeta"]);
}
#[test]
fn test_from_trace_details_user_ids_deduplicated() {
let details = vec![
TraceDetail {
start_time: 1000,
end_time: 2000,
user_id: Some("user-a".to_string()),
..Default::default()
},
TraceDetail {
start_time: 1500,
end_time: 3000,
user_id: Some("user-a".to_string()),
..Default::default()
},
TraceDetail {
start_time: 2000,
end_time: 4000,
user_id: Some("user-b".to_string()),
..Default::default()
},
];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 3, &details);
assert_eq!(session.user_ids, vec!["user-a", "user-b"]);
}
#[test]
fn test_from_trace_details_skips_none_user_ids() {
let details = vec![
TraceDetail {
start_time: 1000,
end_time: 2000,
user_id: Some("user-a".to_string()),
..Default::default()
},
TraceDetail {
start_time: 1500,
end_time: 3000,
user_id: None,
..Default::default()
},
];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 2, &details);
assert_eq!(session.user_ids, vec!["user-a"]);
}
#[test]
fn test_from_trace_details_trace_count_independent_of_details_len() {
let details = vec![];
// trace_count can be larger than details.len() (e.g. traces not found in DB)
let session = SessionDetails::from_trace_details("sess-1".to_string(), 5, &details);
assert_eq!(session.trace_count, 5);
assert_eq!(session.start_time, 0);
assert_eq!(session.end_time, 0);
}
#[test]
fn test_from_trace_details_first_user_message_from_earliest_trace() {
let details = vec![
TraceDetail {
start_time: 2000,
end_time: 3000,
first_user_message: Some("what is the weather".to_string()),
..Default::default()
},
TraceDetail {
start_time: 1000, // earliest
end_time: 2000,
first_user_message: Some("hello".to_string()),
..Default::default()
},
];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 2, &details);
assert_eq!(session.first_user_message, Some("hello".to_string()));
}
#[test]
fn test_from_trace_details_first_user_message_skips_zero_start_time() {
let details = vec![
TraceDetail {
start_time: 0, // no start_time, skipped
end_time: 2000,
first_user_message: Some("zero time msg".to_string()),
..Default::default()
},
TraceDetail {
start_time: 1000,
end_time: 2000,
first_user_message: Some("real msg".to_string()),
..Default::default()
},
];
let session = SessionDetails::from_trace_details("sess-1".to_string(), 2, &details);
assert_eq!(session.first_user_message, Some("real msg".to_string()));
}
#[test]
fn test_extract_first_user_message_basic() {
let messages = json::json!([
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello, how are you doing today?"},
{"role": "assistant", "content": "I'm fine, thanks!"}
]);
let result = extract_first_user_message(Some(&messages), 30);
assert_eq!(result, Some("Hello, how are you doing today".to_string()));
}
#[test]
fn test_extract_first_user_message_trim_to_length() {
let messages = json::json!([
{"role": "user", "content": "short"}
]);
let result = extract_first_user_message(Some(&messages), 30);
assert_eq!(result, Some("short".to_string()));
}
#[test]
fn test_extract_first_user_message_empty() {
assert_eq!(extract_first_user_message(None, 30), None);
assert_eq!(extract_first_user_message(Some(&json::json!([])), 30), None);
assert_eq!(
extract_first_user_message(
Some(&json::json!([{"role": "assistant", "content": "hi"}])),
30
),
None
);
}
#[test]
fn test_extract_first_user_message_case_insensitive_role() {
let messages = json::json!([
{"role": "User", "content": "Hello"}
]);
let result = extract_first_user_message(Some(&messages), 30);
assert_eq!(result, Some("Hello".to_string()));
}
}
+180 -57
View File
@@ -14,7 +14,12 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{extract::Path, http::HeaderMap, response::Response};
use config::{TIMESTAMP_COL_NAME, get_config, meta::stream::StreamType, metrics, utils::json};
use config::{
TIMESTAMP_COL_NAME, get_config,
meta::{search::PaginatedResponse, stream::StreamType},
metrics,
utils::json,
};
use hashbrown::HashMap;
use serde::Serialize;
use tracing::{Instrument, Span};
@@ -30,7 +35,7 @@ use crate::{
handler::http::{
extractors::Headers, request::search::error_utils::map_error_to_http_response,
},
service::{search as SearchService, traces::otel::attributes::O2Attributes},
service::search as SearchService,
};
/// GetLatestUsers
@@ -193,11 +198,25 @@ pub async fn get_latest_users(
.map_or(0, |v| v.parse::<i64>().unwrap_or(0));
// user_id may appear on the first span only or on all spans of a trace.
// _o2_llm_* fields may be on different spans than user_id.
// gen_ai_*/llm_* fields may be on different spans than user_id.
// So we must: get user→trace_id mapping first, then query by trace_id
// (which captures ALL spans) to get accurate _o2_llm_* totals.
let user_id_col = O2Attributes::USER_ID;
// (which captures ALL spans) to get accurate usage totals.
//
// Detect schema generation up front so both Phase 1 (user-id column) and
// Phase 2 (token/cost columns) pick consistent names.
let stream_type = StreamType::Traces;
let has_gen_ai_fields = super::schema_compat::stream_has_gen_ai_fields(
org_id.as_str(),
stream_name.as_str(),
stream_type,
)
.await;
let llm_cols = if has_gen_ai_fields {
super::schema_compat::LlmColumns::current()
} else {
super::schema_compat::LlmColumns::legacy()
};
let user_id_col = llm_cols.user_id;
let user_id_opt = Some(user_id.to_string());
// Get paginated user list with trace_ids per user
@@ -288,17 +307,43 @@ pub async fn get_latest_users(
.collect();
// Phase 2: Get per-trace details by querying with trace_id (captures ALL spans)
let trace_ids_sql = all_trace_ids.join("','");
let query_sql = format!(
"SELECT trace_id, \
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
);
// Sanitize trace IDs before interpolating into SQL: allow only hex chars and hyphens.
// Trace IDs originate from ingested data and could contain injected SQL if not validated.
let sanitized_ids: Vec<String> = all_trace_ids
.iter()
.map(|tid| {
tid.chars()
.filter(|c| c.is_ascii_hexdigit() || *c == '-')
.collect::<String>()
})
.filter(|tid| !tid.is_empty())
.collect();
let trace_ids_sql = sanitized_ids.join("','");
let query_sql = if has_gen_ai_fields {
format!(
"SELECT trace_id, \
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(gen_ai_usage_total_tokens) as gen_ai_usage_details_total, \
sum(gen_ai_usage_cost) as gen_ai_usage_cost_details \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
)
} else {
// Legacy `_o2_llm` schema (pre-PR #11626): tokens live under
// `llm_usage_tokens_total` and cost under `llm_usage_cost_total`.
format!(
"SELECT trace_id, \
min(start_time) as trace_start_time, \
max(end_time) as trace_end_time, \
sum(llm_usage_tokens_total) as gen_ai_usage_details_total, \
sum(llm_usage_cost_total) as gen_ai_usage_cost_details \
FROM \"{stream_name}\" \
WHERE trace_id IN ('{trace_ids_sql}') \
GROUP BY trace_id"
)
};
req.query.sql = query_sql;
req.query.from = 0;
req.query.size = all_trace_ids.len() as i64;
@@ -361,6 +406,7 @@ pub async fn get_latest_users(
gen_ai_usage_cost: json::get_float_value(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
..Default::default()
},
);
}
@@ -390,25 +436,21 @@ pub async fn get_latest_users(
])
.inc();
let mut resp: HashMap<&str, json::Value> = HashMap::new();
resp.insert("took", json::Value::from((time * 1000.0) as usize));
resp.insert("total", json::Value::from(users_data.len()));
resp.insert("from", json::Value::from(from));
resp.insert("size", json::Value::from(size));
resp.insert("hits", json::to_value(users_data).unwrap());
resp.insert("trace_id", json::Value::from(trace_id));
if !range_error.is_empty() {
resp.insert("function_error", json::Value::String(range_error));
}
MetaHttpResponse::json(resp)
MetaHttpResponse::json(PaginatedResponse {
took: (time * 1000.0) as usize,
total: users_data.len(),
from,
size,
hits: users_data
.into_iter()
.map(|v| json::to_value(v).unwrap())
.collect(),
trace_id,
function_error: range_error,
})
}
struct TraceDetail {
start_time: i64,
end_time: i64,
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
}
use super::TraceDetail;
#[derive(Debug, Serialize)]
struct UserResponseItem {
@@ -420,6 +462,33 @@ struct UserResponseItem {
gen_ai_usage_cost: f64,
}
impl UserResponseItem {
fn from_trace_details(user_id: String, total_events: usize, details: &[TraceDetail]) -> Self {
let mut first_event: i64 = 0;
let mut last_event: i64 = 0;
let mut usage_total: i64 = 0;
let mut cost_total: f64 = 0.0;
for detail in details {
if first_event == 0 || detail.start_time < first_event {
first_event = detail.start_time;
}
if detail.end_time > last_event {
last_event = detail.end_time;
}
usage_total += detail.gen_ai_usage_total_tokens;
cost_total += detail.gen_ai_usage_cost;
}
UserResponseItem {
user_id,
first_event,
last_event,
total_events: total_events as u32,
gen_ai_usage_total_tokens: usage_total,
gen_ai_usage_cost: cost_total,
}
}
}
fn parse_user_trace_ids(
hits: &[json::Value],
user_id_col: &str,
@@ -457,30 +526,15 @@ fn aggregate_users(
Some(ids) => ids,
None => continue,
};
let mut first_event: i64 = 0;
let mut last_event: i64 = 0;
let mut usage_total: i64 = 0;
let mut cost_total: f64 = 0.0;
for tid in trace_ids {
if let Some(detail) = trace_details.get(tid) {
if first_event == 0 || detail.start_time < first_event {
first_event = detail.start_time;
}
if detail.end_time > last_event {
last_event = detail.end_time;
}
usage_total += detail.gen_ai_usage_total_tokens;
cost_total += detail.gen_ai_usage_cost;
}
}
users_data.push(UserResponseItem {
user_id: llm_user_id.clone(),
first_event,
last_event,
total_events: trace_ids.len() as u32,
gen_ai_usage_total_tokens: usage_total,
gen_ai_usage_cost: cost_total,
});
let details: Vec<TraceDetail> = trace_ids
.iter()
.filter_map(|tid| trace_details.get(tid).cloned())
.collect();
users_data.push(UserResponseItem::from_trace_details(
llm_user_id.clone(),
trace_ids.len(),
&details,
));
}
users_data.sort_by(|a, b| b.last_event.cmp(&a.last_event));
users_data
@@ -555,6 +609,7 @@ mod tests {
end_time: 2000,
gen_ai_usage_total_tokens: 150,
gen_ai_usage_cost: 0.01,
..Default::default()
},
);
trace_details.insert(
@@ -564,6 +619,7 @@ mod tests {
end_time: 3000,
gen_ai_usage_total_tokens: 300,
gen_ai_usage_cost: 0.02,
..Default::default()
},
);
@@ -592,6 +648,7 @@ mod tests {
end_time: 200,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
..Default::default()
},
);
trace_details.insert(
@@ -601,6 +658,7 @@ mod tests {
end_time: 9000,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
..Default::default()
},
);
@@ -638,6 +696,7 @@ mod tests {
end_time: start + 50,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0.0,
..Default::default()
},
);
}
@@ -646,4 +705,68 @@ mod tests {
assert_eq!(result[0].first_event, 100);
assert_eq!(result[0].last_event, 350);
}
#[test]
fn test_from_trace_details_empty() {
let user = UserResponseItem::from_trace_details("user-1".to_string(), 0, &[]);
assert_eq!(user.user_id, "user-1");
assert_eq!(user.first_event, 0);
assert_eq!(user.last_event, 0);
assert_eq!(user.total_events, 0);
assert_eq!(user.gen_ai_usage_total_tokens, 0);
assert_eq!(user.gen_ai_usage_cost, 0.0);
}
#[test]
fn test_from_trace_details_single_trace() {
let details = vec![TraceDetail {
start_time: 1000,
end_time: 2000,
gen_ai_usage_total_tokens: 100,
gen_ai_usage_cost: 0.05,
..Default::default()
}];
let user = UserResponseItem::from_trace_details("user-1".to_string(), 1, &details);
assert_eq!(user.user_id, "user-1");
assert_eq!(user.first_event, 1000);
assert_eq!(user.last_event, 2000);
assert_eq!(user.total_events, 1);
assert_eq!(user.gen_ai_usage_total_tokens, 100);
assert!((user.gen_ai_usage_cost - 0.05).abs() < 1e-10);
}
#[test]
fn test_from_trace_details_multiple_traces() {
let details = vec![
TraceDetail {
start_time: 500,
end_time: 1500,
gen_ai_usage_total_tokens: 100,
gen_ai_usage_cost: 0.01,
..Default::default()
},
TraceDetail {
start_time: 1000,
end_time: 3000,
gen_ai_usage_total_tokens: 200,
gen_ai_usage_cost: 0.02,
..Default::default()
},
];
let user = UserResponseItem::from_trace_details("user-1".to_string(), 2, &details);
assert_eq!(user.first_event, 500);
assert_eq!(user.last_event, 3000);
assert_eq!(user.total_events, 2);
assert_eq!(user.gen_ai_usage_total_tokens, 300);
assert!((user.gen_ai_usage_cost - 0.03).abs() < 1e-10);
}
#[test]
fn test_from_trace_details_total_events_independent_of_details_len() {
let details = vec![];
let user = UserResponseItem::from_trace_details("user-1".to_string(), 5, &details);
assert_eq!(user.total_events, 5);
assert_eq!(user.first_event, 0);
assert_eq!(user.last_event, 0);
}
}
+7 -6
View File
@@ -1177,8 +1177,6 @@ fn detect_llm_stream(contains_key: impl Fn(&str) -> bool) -> bool {
otel::attributes::GenAiAttributes::USAGE_OUTPUT_TOKENS,
otel::attributes::GenAiAttributes::INPUT_MESSAGES,
otel::attributes::GenAiAttributes::OUTPUT_MESSAGES,
otel::attributes::O2Attributes::INPUT,
otel::attributes::O2Attributes::OUTPUT,
otel::attributes::LangfuseAttributes::INPUT,
otel::attributes::LangfuseAttributes::OUTPUT,
];
@@ -1956,17 +1954,20 @@ mod tests {
}
#[test]
fn test_detect_llm_stream_hashmap_o2_attrs() {
use crate::service::traces::otel::attributes::O2Attributes;
fn test_detect_llm_stream_hashmap_gen_ai_attrs() {
use crate::service::traces::otel::attributes::GenAiAttributes;
let mut map: std::collections::HashMap<String, config::utils::json::Value> =
std::collections::HashMap::new();
map.insert(O2Attributes::INPUT.to_string(), json!("prompt"));
map.insert(GenAiAttributes::INPUT_MESSAGES.to_string(), json!("prompt"));
assert!(super::detect_llm_stream(|k| map.contains_key(k)));
let mut map: std::collections::HashMap<String, config::utils::json::Value> =
std::collections::HashMap::new();
map.insert(O2Attributes::OUTPUT.to_string(), json!("completion"));
map.insert(
GenAiAttributes::OUTPUT_MESSAGES.to_string(),
json!("completion"),
);
assert!(super::detect_llm_stream(|k| map.contains_key(k)));
}
+16 -100
View File
@@ -13,103 +13,45 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/// OpenObserve LLM Attributes
/// Maps to OTEL semantic conventions where available, uses llm.* namespace for custom fields
/// Evaluation attributes kept as legacy O2 keys for backward compatibility
/// with evaluation frameworks (o2-sre-agent, etc.) that emit per-metric
/// `llm.evaluation.*` / `llm.evaluator.*` span attributes.
pub struct O2Attributes;
impl O2Attributes {
// ===== Standard OTEL Gen-AI Attributes =====
// ===== Evaluation Attributes =====
/// Name of the LLM model used (OTEL: gen_ai.response.model)
pub const MODEL_NAME: &'static str = "gen_ai.response.model";
/// Name of the LLM provider (OTEL: gen_ai.system)
pub const PROVIDER_NAME: &'static str = "gen_ai.system";
/// Name of the prompt template used (OTEL: gen_ai.prompt.name)
pub const PROMPT_NAME: &'static str = "gen_ai.prompt.name";
/// Name of the tool being called (OTEL: gen_ai.tool.name)
pub const TOOL_NAME: &'static str = "gen_ai.tool.name";
/// Identifier for the tool call (OTEL: gen_ai.tool.call.id)
pub const TOOL_CALL_ID: &'static str = "gen_ai.tool.call.id";
/// Arguments passed to the tool call (OTEL: gen_ai.tool.call.arguments)
pub const TOOL_CALL_ARGUMENTS: &'static str = "gen_ai.tool.call.arguments";
/// Result returned from the tool call (OTEL: gen_ai.tool.call.result)
pub const TOOL_CALL_RESULT: &'static str = "gen_ai.tool.call.result";
/// User identifier (OTEL: user.id)
pub const USER_ID: &'static str = "user.id";
/// Session identifier (OTEL: session.id)
pub const SESSION_ID: &'static str = "session.id";
// ===== Custom LLM Namespace Attributes =====
/// Type of LLM observation (Custom: llm.observation.type)
/// Values: "generation", "chat", "embedding", "tool", "chain", "agent", etc.
pub const OBSERVATION_TYPE: &'static str = "llm.observation.type";
/// Simplified input text (Custom: llm.input)
/// Condensed from gen_ai.input.messages for easier UI display
pub const INPUT: &'static str = "llm.input";
/// Simplified output text (Custom: llm.output)
/// Condensed from gen_ai.output.messages for easier UI display
pub const OUTPUT: &'static str = "llm.output";
/// Model parameters as JSON (Custom: llm.request.parameters)
/// Aggregates individual gen_ai.request.* parameters for convenience
pub const MODEL_PARAMETERS: &'static str = "llm.request.parameters";
/// Token usage as JSON (Custom: llm.usage.tokens)
/// Contains: {input, output, total}
pub const USAGE_DETAILS: &'static str = "llm.usage.tokens";
/// Cost breakdown as JSON (Custom: llm.usage.cost)
/// Contains: {input, output, total}
pub const COST_DETAILS: &'static str = "llm.usage.cost";
/// Completion start time in microseconds (Custom: llm.completion.start_time)
/// Used to calculate TTFT (Time To First Token)
pub const COMPLETION_START_TIME: &'static str = "llm.completion.start_time";
// ===== Evaluation Attributes (Already OTEL-Compliant) =====
/// Aggregate quality score from evaluation (OTEL: llm.evaluation.quality_score)
/// Aggregate quality score from evaluation
pub const EVALUATION_QUALITY: &'static str = "llm.evaluation.quality_score";
/// Relevance evaluation score (OTEL: llm.evaluation.relevance)
/// Relevance evaluation score
pub const EVALUATION_RELEVANCE: &'static str = "llm.evaluation.relevance";
/// Completeness evaluation score (OTEL: llm.evaluation.completeness)
/// Completeness evaluation score
pub const EVALUATION_COMPLETENESS: &'static str = "llm.evaluation.completeness";
/// Tool effectiveness evaluation score (OTEL: llm.evaluation.tool_effectiveness)
/// Tool effectiveness evaluation score
pub const EVALUATION_TOOL_EFFECTIVENESS: &'static str = "llm.evaluation.tool_effectiveness";
/// Groundedness/faithfulness evaluation score (OTEL: llm.evaluation.groundedness)
/// Groundedness/faithfulness evaluation score
pub const EVALUATION_GROUNDEDNESS: &'static str = "llm.evaluation.groundedness";
/// Safety evaluation score (OTEL: llm.evaluation.safety)
/// Safety evaluation score
pub const EVALUATION_SAFETY: &'static str = "llm.evaluation.safety";
/// Evaluation duration in milliseconds (OTEL: llm.evaluation.duration_ms)
/// Evaluation duration in milliseconds
pub const EVALUATION_DURATION_MS: &'static str = "llm.evaluation.duration_ms";
/// Per-evaluator reasoning/commentary (OTEL: llm.evaluation.commentary)
/// Per-evaluator reasoning/commentary
pub const EVALUATION_COMMENTARY: &'static str = "llm.evaluation.commentary";
/// Evaluator name (OTEL: llm.evaluator.name)
/// Evaluator name
pub const EVALUATOR_NAME: &'static str = "llm.evaluator.name";
/// Evaluator version (OTEL: llm.evaluator.version)
/// Evaluator version
pub const EVALUATOR_VERSION: &'static str = "llm.evaluator.version";
/// Evaluator type: "human", "model", or "deterministic" (OTEL: llm.evaluator.type)
/// Evaluator type: "human", "model", or "deterministic"
pub const EVALUATOR_TYPE: &'static str = "llm.evaluator.type";
}
@@ -366,33 +308,7 @@ mod tests {
#[test]
fn test_llm_otel_compliant_attributes() {
// Standard OTEL Gen-AI attributes
assert_eq!(O2Attributes::MODEL_NAME, "gen_ai.response.model");
assert_eq!(O2Attributes::PROVIDER_NAME, "gen_ai.system");
assert_eq!(O2Attributes::PROMPT_NAME, "gen_ai.prompt.name");
assert_eq!(O2Attributes::TOOL_NAME, "gen_ai.tool.name");
assert_eq!(O2Attributes::TOOL_CALL_ID, "gen_ai.tool.call.id");
assert_eq!(
O2Attributes::TOOL_CALL_ARGUMENTS,
"gen_ai.tool.call.arguments"
);
assert_eq!(O2Attributes::TOOL_CALL_RESULT, "gen_ai.tool.call.result");
assert_eq!(O2Attributes::USER_ID, "user.id");
assert_eq!(O2Attributes::SESSION_ID, "session.id");
// Custom llm.* namespace attributes
assert_eq!(O2Attributes::OBSERVATION_TYPE, "llm.observation.type");
assert_eq!(O2Attributes::INPUT, "llm.input");
assert_eq!(O2Attributes::OUTPUT, "llm.output");
assert_eq!(O2Attributes::MODEL_PARAMETERS, "llm.request.parameters");
assert_eq!(O2Attributes::USAGE_DETAILS, "llm.usage.tokens");
assert_eq!(O2Attributes::COST_DETAILS, "llm.usage.cost");
assert_eq!(
O2Attributes::COMPLETION_START_TIME,
"llm.completion.start_time"
);
// Evaluation attributes (OTEL-compliant llm.evaluation.* namespace)
// Evaluation attributes
assert_eq!(
O2Attributes::EVALUATION_QUALITY,
"llm.evaluation.quality_score"
+1 -1
View File
@@ -32,7 +32,7 @@ pub mod tool;
pub mod usage;
pub mod utils;
pub use evaluation::EvaluationExtractor;
pub use evaluation::{Evaluation, EvaluationExtractor};
pub use gen_ai_operation::{
ScopeInfo, is_generation_or_embedding, is_llm_trace, map_to_gen_ai_operation_name,
};
+10 -21
View File
@@ -230,12 +230,9 @@ mod tests {
Some("conv-123")
);
// 8. Model parameters should be extracted
assert!(span_attributes.contains_key(O2Attributes::MODEL_PARAMETERS));
let params = span_attributes.get(O2Attributes::MODEL_PARAMETERS).unwrap();
assert!(params.is_object());
assert!(params.get("temperature").is_some());
assert!(params.get("max_tokens").is_some());
// 8. Model parameters should be present as individual gen_ai.request.* scalars
assert!(span_attributes.contains_key("gen_ai.request.temperature"));
assert!(span_attributes.contains_key("gen_ai.request.max_tokens"));
// 9. LLM input/output should be present
assert!(span_attributes.contains_key(GenAiAttributes::INPUT_MESSAGES));
@@ -506,7 +503,8 @@ mod tests {
.and_then(|v| v.as_str()),
Some("gpt-4o-2024-08-06")
);
assert!(span_attrs.contains_key(O2Attributes::MODEL_PARAMETERS));
assert!(span_attrs.contains_key("gen_ai.request.temperature"));
assert!(span_attrs.contains_key("gen_ai.request.max_tokens"));
assert!(span_attrs.contains_key(GenAiAttributes::USAGE_INPUT_TOKENS));
assert!(span_attrs.contains_key(GenAiAttributes::USAGE_OUTPUT_TOKENS));
assert!(span_attrs.contains_key(GenAiAttributes::USAGE_COST));
@@ -810,23 +808,14 @@ mod tests {
"Total cost should be > 0"
);
// 6. Model parameters (stored as HashMap<String, String> → JSON object with string values)
// 6. Model parameters as individual gen_ai.request.* scalars
assert!(
span_attrs.contains_key(O2Attributes::MODEL_PARAMETERS),
"Should have model parameters"
span_attrs.contains_key("gen_ai.request.temperature"),
"Should have gen_ai.request.temperature"
);
let params = span_attrs.get(O2Attributes::MODEL_PARAMETERS).unwrap();
assert!(
params.is_object(),
"Model parameters should be a JSON object"
);
assert_eq!(
params.get("temperature").and_then(|v| v.as_str()),
Some("0.7")
);
assert_eq!(
params.get("max_tokens").and_then(|v| v.as_str()),
Some("4096")
span_attrs.contains_key("gen_ai.request.max_tokens"),
"Should have gen_ai.request.max_tokens"
);
// 7. User/session metadata
+5 -6
View File
@@ -82,6 +82,8 @@ pub struct ModelPricing {
pub pattern: String,
/// Pricing tiers (evaluated in order, first matching tier is used)
pub tiers: Vec<PricingTier>,
/// Pre-compiled regex for efficient matching in the hot path.
compiled: Regex,
}
impl ModelPricing {
@@ -94,6 +96,7 @@ impl ModelPricing {
input_price_per_million: input_price,
output_price_per_million: output_price,
}],
compiled: Regex::new(pattern).expect("invalid regex in model pricing pattern"),
}
}
@@ -102,6 +105,7 @@ impl ModelPricing {
Self {
pattern: pattern.to_string(),
tiers,
compiled: Regex::new(pattern).expect("invalid regex in model pricing pattern"),
}
}
@@ -122,12 +126,7 @@ impl ModelPricing {
/// Check if this pricing matches the given model name
pub fn matches(&self, model_name: &str) -> bool {
if let Ok(re) = Regex::new(&self.pattern) {
re.is_match(model_name)
} else {
// Fallback to simple substring match if regex is invalid
model_name.contains(&self.pattern)
}
self.compiled.is_match(model_name)
}
}
+169 -111
View File
@@ -27,7 +27,7 @@ use super::{
GenAiAttributes, GenAiExtensions, LangfuseAttributes, O2Attributes, OtelAttributes,
},
extractors::{
EvaluationExtractor, InputOutputExtractor, MetadataExtractor, ModelExtractor,
Evaluation, EvaluationExtractor, InputOutputExtractor, MetadataExtractor, ModelExtractor,
ParametersExtractor, PromptExtractor, ProviderExtractor, ScopeInfo, ServiceNameExtractor,
ToolExtractor, UsageExtractor, is_generation_or_embedding, map_to_gen_ai_operation_name,
},
@@ -35,6 +35,28 @@ use super::{
};
use crate::{common::meta::traces::Event, service::db::model_pricing::CachedModelPricing};
/// Results of extracting span enrichment data from raw attributes.
/// Captures all information needed before I/O attribute cleanup.
struct SpanExtractions {
op_name: &'static str,
model_name: Option<String>,
provider_name: Option<String>,
input: Option<json::Value>,
output: Option<json::Value>,
model_params: HashMap<String, String>,
usage: HashMap<String, i64>,
cost: HashMap<String, f64>,
user_id: Option<String>,
session_id: Option<String>,
prompt_name: Option<String>,
completion_start_time: Option<i64>,
tool_name: Option<String>,
tool_call_id: Option<String>,
tool_call_arguments: Option<json::Value>,
tool_call_result: Option<json::Value>,
evaluation: Evaluation,
}
pub struct OtelIngestionProcessor {
model_extractor: ModelExtractor,
provider_extractor: ProviderExtractor,
@@ -94,54 +116,59 @@ impl OtelIngestionProcessor {
org_pricing_entries: &[CachedModelPricing],
span_start_nanos: u64,
) {
// Extract and add observation type
// Phase 1: Extract all raw data from span attributes.
let extracted = self.extract_all(span_attributes, resource_attributes, scope_name, events);
// Remove input/output attributes now that extraction is complete.
span_attributes
.retain(|key, _| !self.input_output_extractor.is_input_output_attribute(key));
// Phase 2: Compute derived values — token counts, cost estimates, defaults.
let (usage, cost) =
self.compute_usage_and_cost(&extracted, org_pricing_entries, span_start_nanos);
// Phase 3: Write enriched attributes back to the span.
self.emit_enriched_attributes(span_attributes, &extracted, &usage, &cost);
}
// ── Phase 1: Extract raw data ──────────────────────────────────────────
fn extract_all(
&self,
span_attributes: &HashMap<String, json::Value>,
resource_attributes: &HashMap<String, json::Value>,
scope_name: Option<&str>,
events: &[Event],
) -> SpanExtractions {
let scope_info = scope_name.map(|name| ScopeInfo {
name: Some(name.to_string()),
});
let scope_name_default = scope_name.unwrap_or("");
let op_name =
map_to_gen_ai_operation_name(span_attributes, resource_attributes, scope_info.as_ref());
// Extract model name
let model_name = self.model_extractor.extract(span_attributes);
// Extract provider name
let provider_name = self.provider_extractor.extract(span_attributes);
// Extract input and output (this will identify which attributes to remove)
let (input, output) =
self.input_output_extractor
.extract(events, span_attributes, scope_name.unwrap_or(""));
// Extract model parameters
.extract(events, span_attributes, scope_name_default);
let model_params = self
.parameters_extractor
.extract(span_attributes, scope_name.unwrap_or(""));
// Extract usage details
let mut usage = self
.extract(span_attributes, scope_name_default);
let usage = self
.usage_extractor
.extract_usage(span_attributes, scope_name.unwrap_or(""));
// Extract cost details
let mut cost = self.usage_extractor.extract_cost(span_attributes);
// Extract user and session
.extract_usage(span_attributes, scope_name_default);
let cost = self.usage_extractor.extract_cost(span_attributes);
let user_id = self
.metadata_extractor
.extract_user_id(span_attributes, resource_attributes);
let session_id = self
.metadata_extractor
.extract_session_id(span_attributes, resource_attributes);
// Extract prompt information
let prompt_name = self.prompt_extractor.extract_name(span_attributes);
// Extract completion start time (TTFT - Time To First Token)
let completion_start_time = span_attributes
.get(LangfuseAttributes::COMPLETION_START_TIME)
.and_then(|v| parse_timestamp_micro_from_value(v).ok().map(|(ts, _)| ts));
// Extract tool information
let tool_name = self.tool_extractor.extract_tool_name(span_attributes);
let tool_call_id = self.tool_extractor.extract_tool_call_id(span_attributes);
let tool_call_arguments = self
@@ -150,50 +177,70 @@ impl OtelIngestionProcessor {
let tool_call_result = self
.tool_extractor
.extract_tool_call_result(span_attributes);
// Extract evaluation scores (if present)
let evaluation = self.evaluation_extractor.extract(span_attributes);
// Now remove all input/output related attributes
span_attributes
.retain(|key, _| !self.input_output_extractor.is_input_output_attribute(key));
SpanExtractions {
op_name,
model_name,
provider_name,
input,
output,
model_params,
usage,
cost,
user_id,
session_id,
prompt_name,
completion_start_time,
tool_name,
tool_call_id,
tool_call_arguments,
tool_call_result,
evaluation,
}
}
// need to guarantee have the field USAGE_DETAILS and COST_DETAILS
if input.is_some() || output.is_some() {
// Convert span start time from nanoseconds to microseconds for valid_from comparison.
// ── Phase 2: Compute usage, cost, and defaults ─────────────────────────
fn compute_usage_and_cost(
&self,
extracted: &SpanExtractions,
org_pricing_entries: &[CachedModelPricing],
span_start_nanos: u64,
) -> (HashMap<String, i64>, HashMap<String, f64>) {
let mut usage = extracted.usage.clone();
let mut cost = extracted.cost.clone();
if extracted.input.is_some() || extracted.output.is_some() {
let span_ts_micros = i64::try_from(span_start_nanos / 1_000).unwrap_or(i64::MAX);
let matched_pricing = model_name.as_ref().and_then(|mn| {
let matched_pricing = extracted.model_name.as_ref().and_then(|mn| {
crate::service::db::model_pricing::find_pricing_sync_at(
org_pricing_entries,
mn,
Some(span_ts_micros),
)
});
// Use model_name as the tiktoken key for fallback token counting.
let tokenizer_key: &str = model_name.as_deref().unwrap_or("");
let tokenizer_key: &str = extracted.model_name.as_deref().unwrap_or("");
if let Some(v) = &input
if let Some(v) = &extracted.input
&& !usage.contains_key("input")
&& is_generation_or_embedding(op_name)
&& is_generation_or_embedding(extracted.op_name)
{
let prompt = v.to_string();
let prompt_tokens = pricing::calculate_token_count(tokenizer_key, &prompt);
usage.insert("input".to_string(), prompt_tokens);
}
if let Some(v) = &output
if let Some(v) = &extracted.output
&& !usage.contains_key("output")
&& is_generation_or_embedding(op_name)
&& is_generation_or_embedding(extracted.op_name)
{
let output_text = v.to_string();
let output_tokens = pricing::calculate_token_count(tokenizer_key, &output_text);
usage.insert("output".to_string(), output_tokens);
}
// Calculate cost from tokens if cost is missing but we have model and usage.
// User-defined pricing takes priority over built-in pricing.
if cost.is_empty() && is_generation_or_embedding(op_name) {
if cost.is_empty() && is_generation_or_embedding(extracted.op_name) {
if let Some(pricing_def) = matched_pricing {
// Use user-defined per-token pricing (already matched above)
let result = crate::service::db::model_pricing::calculate_cost_from_definition(
&pricing_def,
&usage,
@@ -201,15 +248,14 @@ impl OtelIngestionProcessor {
if !result.cost.is_empty() {
log::debug!(
"[model_pricing] model='{}' pattern='{}' tier='{}' total_cost={:.8}",
model_name.as_deref().unwrap_or(""),
extracted.model_name.as_deref().unwrap_or(""),
pricing_def.match_pattern,
result.tier_name,
result.cost.get("total").copied().unwrap_or(0.0),
);
cost = result.cost;
}
} else if let Some(ref model_name) = model_name {
// Fall back to built-in pricing (per-1M token rates)
} else if let Some(ref model_name) = extracted.model_name {
let input_tokens = usage.get("input").cloned().unwrap_or_default();
let output_tokens = usage.get("output").cloned().unwrap_or_default();
if let Some((input_cost, output_cost, total_cost)) =
@@ -223,7 +269,7 @@ impl OtelIngestionProcessor {
}
}
// Ensure usage has a input,output,total
// Ensure usage defaults (input, output, total).
if !usage.contains_key("input") {
usage.insert("input".to_string(), 0);
}
@@ -231,64 +277,71 @@ impl OtelIngestionProcessor {
usage.insert("output".to_string(), 0);
}
if !usage.contains_key("total") {
// Total = input + output only. Cache tokens (cache_read_input_tokens,
// cache_creation_input_tokens) are subsets of input, not additive.
let input = usage.get("input").copied().unwrap_or(0);
let output = usage.get("output").copied().unwrap_or(0);
usage.insert("total".to_string(), input + output);
}
// Ensure cost has a total if it has any data — sum all component costs
// (not just input+output) so cache token costs are included.
// Ensure cost total (sum all components, not just input+output).
if !cost.contains_key("total") {
let total: f64 = cost.values().sum();
cost.insert("total".to_string(), total);
}
// Add enriched fields using OTEL Gen-AI semantic-convention attribute keys.
// After dot→underscore flattening at ingestion, these become the gen_ai_*
// columns provisioned by GEN_AI_SCHEMA_FIELDS in service/db/schema.rs.
(usage, cost)
}
// ── Phase 3: Emit enriched attributes ──────────────────────────────────
fn emit_enriched_attributes(
&self,
span_attributes: &mut HashMap<String, json::Value>,
extracted: &SpanExtractions,
usage: &HashMap<String, i64>,
cost: &HashMap<String, f64>,
) {
span_attributes.insert(
GenAiAttributes::OPERATION_NAME.to_string(),
json::json!(op_name),
json::json!(extracted.op_name),
);
if let Some(model) = model_name {
if let Some(ref model) = extracted.model_name {
span_attributes.insert(
GenAiAttributes::RESPONSE_MODEL.to_string(),
json::json!(model),
);
}
if let Some(provider) = provider_name {
if let Some(ref provider) = extracted.provider_name {
span_attributes.insert(
GenAiAttributes::PROVIDER_NAME.to_string(),
json::json!(provider),
);
}
// Spec defines gen_ai.input.messages / gen_ai.output.messages as structured
// arrays; OpenObserve flattens these to a single string for ergonomic UI
// display in the gen_ai_input_messages / gen_ai_output_messages columns.
if let Some(input_val) = input {
span_attributes.insert(GenAiAttributes::INPUT_MESSAGES.to_string(), input_val);
}
if let Some(output_val) = output {
span_attributes.insert(GenAiAttributes::OUTPUT_MESSAGES.to_string(), output_val);
}
// Model parameters JSON: kept as OO extension (no Gen-AI spec equivalent for
// an aggregated parameters blob).
if !model_params.is_empty() {
if let Some(ref input_val) = extracted.input {
span_attributes.insert(
O2Attributes::MODEL_PARAMETERS.to_string(),
json::json!(model_params),
GenAiAttributes::INPUT_MESSAGES.to_string(),
input_val.clone(),
);
}
// Token usage: emit individual scalar attributes per Gen-AI spec so they
// flatten cleanly to gen_ai_usage_input_tokens / output_tokens / total_tokens.
if let Some(ref output_val) = extracted.output {
span_attributes.insert(
GenAiAttributes::OUTPUT_MESSAGES.to_string(),
output_val.clone(),
);
}
// Model parameters as individual gen_ai.request.* scalars.
for (key, value) in &extracted.model_params {
if key == "model" {
continue;
}
span_attributes.insert(format!("gen_ai.request.{key}"), json::json!(value));
}
// Token usage as individual scalar attributes.
if let Some(&v) = usage.get("input") {
span_attributes.insert(
GenAiAttributes::USAGE_INPUT_TOKENS.to_string(),
@@ -308,8 +361,7 @@ impl OtelIngestionProcessor {
);
}
// Cost: spec defines a single `gen_ai.usage.cost` (total). The per-direction
// breakdown is OO extension under the same gen_ai namespace.
// Cost: per-direction breakdown + total.
if let Some(&v) = cost.get("input") {
span_attributes.insert(
GenAiExtensions::USAGE_COST_INPUT.to_string(),
@@ -326,26 +378,23 @@ impl OtelIngestionProcessor {
span_attributes.insert(GenAiAttributes::USAGE_COST.to_string(), json::json!(v));
}
if let Some(uid) = user_id {
if let Some(ref uid) = extracted.user_id {
span_attributes.insert(OtelAttributes::USER_ID.to_string(), json::json!(uid));
}
// Session → conversation: Gen-AI spec uses gen_ai.conversation.id for the
// same concept as the legacy session.id. Flattens to gen_ai_conversation_id.
if let Some(sid) = session_id {
if let Some(ref sid) = extracted.session_id {
span_attributes.insert(
GenAiAttributes::CONVERSATION_ID.to_string(),
json::json!(sid),
);
}
if let Some(pname) = prompt_name {
if let Some(ref pname) = extracted.prompt_name {
span_attributes.insert(GenAiAttributes::PROMPT_NAME.to_string(), json::json!(pname));
}
// TTFT: Gen-AI spec field is Float64 seconds; the source extractor returns
// microseconds, so convert here. Flattens to gen_ai_response_time_to_first_chunk.
if let Some(ct) = completion_start_time {
// TTFT: convert microseconds → Float64 seconds.
if let Some(ct) = extracted.completion_start_time {
let ttfc_seconds = ct as f64 / 1_000_000.0;
span_attributes.insert(
GenAiAttributes::RESPONSE_TIME_TO_FIRST_CHUNK.to_string(),
@@ -353,23 +402,30 @@ impl OtelIngestionProcessor {
);
}
if let Some(tname) = tool_name {
span_attributes.insert(O2Attributes::TOOL_NAME.to_string(), json::json!(tname));
if let Some(ref tname) = extracted.tool_name {
span_attributes.insert(GenAiAttributes::TOOL_NAME.to_string(), json::json!(tname));
}
if let Some(tcid) = tool_call_id {
span_attributes.insert(O2Attributes::TOOL_CALL_ID.to_string(), json::json!(tcid));
if let Some(ref tcid) = extracted.tool_call_id {
span_attributes.insert(GenAiAttributes::TOOL_CALL_ID.to_string(), json::json!(tcid));
}
if let Some(targs) = tool_call_arguments {
span_attributes.insert(O2Attributes::TOOL_CALL_ARGUMENTS.to_string(), targs);
if let Some(ref targs) = extracted.tool_call_arguments {
span_attributes.insert(
GenAiAttributes::TOOL_CALL_ARGUMENTS.to_string(),
targs.clone(),
);
}
if let Some(tresult) = tool_call_result {
span_attributes.insert(O2Attributes::TOOL_CALL_RESULT.to_string(), tresult);
if let Some(ref tresult) = extracted.tool_call_result {
span_attributes.insert(
GenAiAttributes::TOOL_CALL_RESULT.to_string(),
tresult.clone(),
);
}
// Add evaluation scores and metadata if present
// Evaluation scores and metadata.
let evaluation = &extracted.evaluation;
if evaluation.has_any() {
if let Some(q) = evaluation.scores.quality_score {
span_attributes
@@ -530,9 +586,9 @@ mod tests {
processor.process_span(&mut span_attrs, &resource_attrs, None, &events);
// Provider name should be extracted from gen_ai.system
assert!(span_attrs.contains_key(O2Attributes::PROVIDER_NAME));
assert!(span_attrs.contains_key(GenAiAttributes::SYSTEM));
assert_eq!(
span_attrs.get(O2Attributes::PROVIDER_NAME).unwrap(),
span_attrs.get(GenAiAttributes::SYSTEM).unwrap(),
&json::json!("anthropic")
);
}
@@ -582,30 +638,32 @@ mod tests {
processor.process_span(&mut span_attrs, &resource_attrs, None, &events);
// Tool name should be extracted and added
assert!(span_attrs.contains_key(O2Attributes::TOOL_NAME));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_NAME));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_NAME).unwrap(),
span_attrs.get(GenAiAttributes::TOOL_NAME).unwrap(),
&json::json!("get_weather")
);
// Tool call ID should be extracted and added
assert!(span_attrs.contains_key(O2Attributes::TOOL_CALL_ID));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_CALL_ID));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_CALL_ID).unwrap(),
span_attrs.get(GenAiAttributes::TOOL_CALL_ID).unwrap(),
&json::json!("call_12345")
);
// Tool call arguments should be extracted and added
assert!(span_attrs.contains_key(O2Attributes::TOOL_CALL_ARGUMENTS));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_CALL_ARGUMENTS));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_CALL_ARGUMENTS).unwrap(),
span_attrs
.get(GenAiAttributes::TOOL_CALL_ARGUMENTS)
.unwrap(),
&json::json!({"city": "San Francisco"})
);
// Tool call result should be extracted and added
assert!(span_attrs.contains_key(O2Attributes::TOOL_CALL_RESULT));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_CALL_RESULT));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_CALL_RESULT).unwrap(),
span_attrs.get(GenAiAttributes::TOOL_CALL_RESULT).unwrap(),
&json::json!({"temperature": 72})
);
@@ -658,15 +716,15 @@ mod tests {
);
// Tool name and ID should be extracted
assert!(span_attrs.contains_key(O2Attributes::TOOL_NAME));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_NAME));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_NAME).unwrap(),
span_attrs.get(GenAiAttributes::TOOL_NAME).unwrap(),
&json::json!("Read")
);
assert!(span_attrs.contains_key(O2Attributes::TOOL_CALL_ID));
assert!(span_attrs.contains_key(GenAiAttributes::TOOL_CALL_ID));
assert_eq!(
span_attrs.get(O2Attributes::TOOL_CALL_ID).unwrap(),
span_attrs.get(GenAiAttributes::TOOL_CALL_ID).unwrap(),
&json::json!("toolu_01T1Mfo98ePBYgoRXG3yPkWt")
);
+12
View File
@@ -34,6 +34,7 @@ const AppTraces = () => import("@/plugins/traces/Index.vue");
const PromQLQueryBuilder = () => import("@/views/PromQL/QueryBuilder.vue");
const TraceDetails = () => import("@/plugins/traces/TraceDetails.vue");
const SessionDetails = () => import("@/plugins/traces/SessionDetails.vue");
const ViewDashboard = () => import("@/views/Dashboards/ViewDashboard.vue");
const AddPanel = () => import("@/views/Dashboards/addPanel/AddPanel.vue");
@@ -192,6 +193,17 @@ const useRoutes = () => {
routeGuard(to, from, next);
},
},
{
path: "traces/session-details",
name: "sessionDetails",
component: SessionDetails,
meta: {
title: "Session Details",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
},
{
// Redirect old service-graph route to traces page
path: "service-graph",
+69 -1
View File
@@ -2936,7 +2936,75 @@
"copyStacktrace": "Stacktrace kopieren",
"noStacktraceAvailable": "Kein Stacktrace verfügbar",
"stacktraceCopied": "Stacktrace wurde in die Zwischenablage kopiert",
"stacktraceCopyFailed": "Stacktrace konnte nicht kopiert werden"
"stacktraceCopyFailed": "Stacktrace konnte nicht kopiert werden",
"sessionsList": {
"noStreamsTooltip": "Keine LLM-Streams verfügbar.",
"session": "Sitzung",
"sessions": "Sitzungen",
"countPill": "{Anzahl} von {total} {unit}",
"noStreamsFound": "Keine LLM-Streams gefunden",
"noStreamsDescription1": "Die Sitzungsgruppe umfasst",
"noStreamsDescription2": "across verfolgt Streams, die als LLM-Streams gekennzeichnet sind. Entweder existiert kein solcher Stream, oder es wurden noch keine Spans aufgenommen.",
"failedToLoad": "Sitzungen konnten nicht geladen werden",
"retry": "Erneut versuchen",
"noSessionsFound": "Keine Sessions gefunden",
"noSessionsDescription": "In diesem Zeitraum wurden keine LLM-Sitzungen auf {stream} aufgezeichnet. Versuchen Sie, den Zeitbereich zu erweitern oder zu einem anderen Stream zu wechseln.",
"loading": "Sitzungen werden geladen...",
"tokenTooltip": "Eingabe: {input} · Ausgabe: {output} · Summe: {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Zeitstempel",
"sessionId": "Sitzungs-ID",
"turns": "Dreht sich",
"duration": "Dauer",
"tokens": "Tokens",
"cost": "Kosten",
"status": "Status"
}
},
"sessionDetail": {
"backToSessions": "Zurück zu Sessions",
"pageTitle": "Einzelheiten der Sitzung",
"failedToLoad": "Sitzung konnte nicht geladen werden",
"retry": "Erneut versuchen",
"sessionNotFound": "Sitzung nicht gefunden",
"noSpansFound": "Im aktuellen Zeitraum wurden keine Zeitspannen für {id} gefunden.",
"searchPlaceholder": "Suchen Sie in dieser Sitzung nach Spuren...",
"turn": "Wende",
"turns": "wendet sich",
"turnsShown": "{gefiltert} von {total} {unit} angezeigt",
"userBadge": "Nutzer",
"sessionPrefix": "Sitzung",
"copySessionId": "Sitzungs-ID kopieren",
"openInTraceExplorer": "Im Trace Explorer öffnen",
"unknownUser": "Unbekannter Benutzer",
"kpi": {
"turns": "Dreht sich",
"duration": "Dauer",
"inputTokens": "Eingabe-Token",
"outputTokens": "Ausgabetokens",
"cost": "Kosten"
},
"conversation": "Konversation",
"turnLabel": "Wende",
"openTrace": "Trace öffnen",
"noUserMessage": "Für diesen Spielzug wurde keine Benutzernachricht erfasst.",
"noAssistantMessage": "Für diesen Zug wurde keine Reaktion des Assistenten erfasst.",
"roles": {
"user": "Nutzer",
"assistant": "Assistentin"
},
"stats": {
"status": "Status",
"model": "Modell",
"tokens": "Tokens",
"input": "Eingabe",
"output": "Ausgang",
"total": "Insgesamt",
"cost": "Kosten",
"spans": "Spannweiten"
}
}
},
"invitation": {
"pendingInvitations": "Ausstehende Einladungen",
+79
View File
@@ -3255,6 +3255,85 @@
"type": "Type",
"multiStepAgent": "Multi-step Agent"
},
"sessionsList": {
"noStreamsTooltip": "No LLM streams available.",
"session": "session",
"sessions": "sessions",
"countPill": "{count} of {total} {unit}",
"noStreamsFound": "No LLM streams found",
"noStreamsDescription1": "Sessions group spans by",
"noStreamsDescription2": "across traces streams marked as LLM streams. Either no such stream exists, or no spans have been ingested yet.",
"failedToLoad": "Failed to load sessions",
"retry": "Retry",
"noSessionsFound": "No sessions found",
"noSessionsDescription": "No LLM sessions were recorded in this time range on {stream}. Try widening the time range or switching to a different stream.",
"loading": "Loading sessions…",
"tokenTooltip": "Input: {input} · Output: {output} · Total: {total}",
"durationNs": "ns",
"unknownUser": "Unknown user",
"columns": {
"timestamp": "Timestamp",
"sessionId": "Session ID",
"user": "User",
"firstMessage": "First message",
"turns": "Turns",
"duration": "Duration",
"tokens": "Tokens",
"cost": "Cost",
"status": "Status"
}
},
"sessionDetail": {
"backToSessions": "Back to Sessions",
"pageTitle": "Session Detail",
"failedToLoad": "Failed to load session",
"retry": "Retry",
"sessionNotFound": "Session not found",
"noSpansFound": "No spans found for {id} in the current time range.",
"searchPlaceholder": "Search traces in this session…",
"turn": "turn",
"turns": "turns",
"turnsShown": "{filtered} of {total} {unit} shown",
"userBadge": "User",
"sessionPrefix": "session",
"copySessionId": "Copy session id",
"openInTraceExplorer": "Open in trace explorer",
"unknownUser": "Unknown user",
"kpi": {
"turns": "Turns",
"duration": "Duration",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"cost": "Cost"
},
"conversation": "Conversation",
"turnLabel": "Turn",
"openTrace": "Open trace",
"noUserMessage": "No user message captured for this turn.",
"noAssistantMessage": "No assistant response captured for this turn.",
"roles": {
"user": "User",
"assistant": "Assistant"
},
"stats": {
"status": "Status",
"model": "Model",
"tokens": "Tokens",
"input": "Input",
"output": "Output",
"total": "Total",
"cost": "Cost",
"spans": "Spans",
"llmCalls": "LLM calls",
"toolCalls": "Tool calls",
"otherCalls": "Other",
"otherCallsTooltip": "gen_ai spans with unrecognised operation names:"
},
"fullscreen": {
"expandTurn": "View turn in full screen",
"close": "Close"
}
},
"servicesCatalog": {
"tabLabel": "Service Catalog",
"title": "Service Catalog",
+69 -1
View File
@@ -2934,7 +2934,75 @@
"copyStacktrace": "Copiar stacktrace",
"noStacktraceAvailable": "No hay stacktrace disponible",
"stacktraceCopied": "Stacktrace copiado al portapapeles",
"stacktraceCopyFailed": "No se pudo copiar stacktrace"
"stacktraceCopyFailed": "No se pudo copiar stacktrace",
"sessionsList": {
"noStreamsTooltip": "No hay transmisiones de LLM disponibles.",
"session": "sesión",
"sessions": "sesiones",
"countPill": "{count} de {total} {unit}",
"noStreamsFound": "No se han encontrado transmisiones de LLM",
"noStreamsDescription1": "Los grupos de sesiones se extienden por",
"noStreamsDescription2": "a través de flujos de rastreo marcados como flujos LLM. O no existe tal transmisión o aún no se ha ingerido ningún tramo.",
"failedToLoad": "No se pudieron cargar las sesiones",
"retry": "Reintentar",
"noSessionsFound": "No se encontró ninguna sesión",
"noSessionsDescription": "No se grabó ninguna sesión de LLM en este intervalo de tiempo en {stream}. Intenta ampliar el intervalo de tiempo o cambiar a una transmisión diferente.",
"loading": "Cargando sesiones...",
"tokenTooltip": "Entrada: {entrada} · Salida: {salida} · Total: {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Marca de tiempo",
"sessionId": "ID de sesión",
"turns": "Turnos",
"duration": "Duración",
"tokens": "Fichas",
"cost": "Coste",
"status": "Estado"
}
},
"sessionDetail": {
"backToSessions": "Volver a las sesiones",
"pageTitle": "Detalle de la sesión",
"failedToLoad": "No se pudo cargar la sesión",
"retry": "Reintentar",
"sessionNotFound": "No se encontró la sesión",
"noSpansFound": "No se han encontrado intervalos para {id} en el intervalo de tiempo actual.",
"searchPlaceholder": "Busque rastros en esta sesión...",
"turn": "giro",
"turns": "giros",
"turnsShown": "Se muestra {filtrado} de {total} {unidad}",
"userBadge": "Usuario",
"sessionPrefix": "sesión",
"copySessionId": "Copiar identificador de sesión",
"openInTraceExplorer": "Abrir en Trace Explorer",
"unknownUser": "Usuario desconocido",
"kpi": {
"turns": "Turnos",
"duration": "Duración",
"inputTokens": "Tokens de entrada",
"outputTokens": "Tokens de salida",
"cost": "Coste"
},
"conversation": "Conversación",
"turnLabel": "Turno",
"openTrace": "Traza abierta",
"noUserMessage": "No se capturó ningún mensaje de usuario para este turno.",
"noAssistantMessage": "No se capturó ninguna respuesta del asistente para este turno.",
"roles": {
"user": "Usuario",
"assistant": "Asistente"
},
"stats": {
"status": "Estado",
"model": "modelo",
"tokens": "Fichas",
"input": "Entrada",
"output": "Salida",
"total": "Total",
"cost": "Coste",
"spans": "Vanos"
}
}
},
"invitation": {
"pendingInvitations": "Invitaciones pendientes",
+69 -1
View File
@@ -2936,7 +2936,75 @@
"copyStacktrace": "Copier stacktrace",
"noStacktraceAvailable": "Aucun stacktrace disponible",
"stacktraceCopied": "Stacktrace copié dans le presse-papiers",
"stacktraceCopyFailed": "Impossible de copier stacktrace"
"stacktraceCopyFailed": "Impossible de copier stacktrace",
"sessionsList": {
"noStreamsTooltip": "Aucun stream LLM n'est disponible.",
"session": "session",
"sessions": "sessions",
"countPill": "{nombre} de {total} {unité}",
"noStreamsFound": "Aucun flux LLM n'a été trouvé",
"noStreamsDescription1": "Le groupe de sessions s'étend sur",
"noStreamsDescription2": "à travers les flux de traces marqués comme flux LLM. Soit aucun flux de ce type n'existe, soit aucune travée n'a encore été ingérée.",
"failedToLoad": "Impossible de charger les sessions",
"retry": "Réessayez",
"noSessionsFound": "Aucune session n'a été trouvée",
"noSessionsDescription": "Aucune session LLM n'a été enregistrée pendant cette période sur {stream}. Essayez d'élargir la plage horaire ou de passer à un autre flux.",
"loading": "Chargement des sessions...",
"tokenTooltip": "Entrée : {entrée} · Sortie : {sortie} · Total : {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Horodatage",
"sessionId": "ID de session",
"turns": "virages",
"duration": "Durée",
"tokens": "Jetons",
"cost": "Coût",
"status": "État"
}
},
"sessionDetail": {
"backToSessions": "Retour aux sessions",
"pageTitle": "Détails de la session",
"failedToLoad": "Impossible de charger la session",
"retry": "Réessayez",
"sessionNotFound": "Session introuvable",
"noSpansFound": "Aucune plage n'a été trouvée pour {id} dans la période actuelle.",
"searchPlaceholder": "Rechercher des traces au cours de cette session...",
"turn": "virage",
"turns": "tours",
"turnsShown": "{filtré} de {total} {unité} affiché",
"userBadge": "Utilisateur",
"sessionPrefix": "session",
"copySessionId": "Copier l'identifiant de session",
"openInTraceExplorer": "Ouvrir dans Trace Explorer",
"unknownUser": "Utilisateur inconnu",
"kpi": {
"turns": "virages",
"duration": "Durée",
"inputTokens": "Jetons d'entrée",
"outputTokens": "Jetons de sortie",
"cost": "Coût"
},
"conversation": "Conversation",
"turnLabel": "Tourner",
"openTrace": "Ouvrir une trace",
"noUserMessage": "Aucun message utilisateur n'a été enregistré pour ce tour.",
"noAssistantMessage": "Aucune réponse de l'assistant n'a été enregistrée pour ce tour.",
"roles": {
"user": "Utilisateur",
"assistant": "Assistante"
},
"stats": {
"status": "État",
"model": "modèle",
"tokens": "Jetons",
"input": "Entrée",
"output": "sortie",
"total": "Total",
"cost": "Coût",
"spans": "S'étend"
}
}
},
"invitation": {
"pendingInvitations": "Invitations en attente",
+69 -1
View File
@@ -2945,7 +2945,75 @@
"copyStacktrace": "Copia stacktrace",
"noStacktraceAvailable": "Nessuno stacktrace disponibile",
"stacktraceCopied": "Stacktrace copiato negli appunti",
"stacktraceCopyFailed": "Copia dello stacktrace non riuscita"
"stacktraceCopyFailed": "Copia dello stacktrace non riuscita",
"sessionsList": {
"noStreamsTooltip": "Non sono disponibili stream LLM.",
"session": "sessione",
"sessions": "sessioni",
"countPill": "{count} di {total} {unità}",
"noStreamsFound": "Nessun stream LLM trovato",
"noStreamsDescription1": "Il gruppo di sessioni si estende per",
"noStreamsDescription2": "attraverso tracce di flussi contrassegnati come flussi LLM. O non esiste un flusso di questo tipo o non è stato ancora inserito alcun intervallo.",
"failedToLoad": "Caricamento delle sessioni non riuscito",
"retry": "Riprova",
"noSessionsFound": "Nessuna sessione trovata",
"noSessionsDescription": "Nessuna sessione LLM è stata registrata in questo intervallo di tempo su {stream}. Prova ad ampliare l'intervallo di tempo o a passare a un altro stream.",
"loading": "Caricamento delle sessioni...",
"tokenTooltip": "Ingresso: {input} · Uscita: {output} · Totale: {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Timestamp",
"sessionId": "ID sessione",
"turns": "Turni",
"duration": "Durata",
"tokens": "Gettoni",
"cost": "Costo",
"status": "Status"
}
},
"sessionDetail": {
"backToSessions": "Torna alle sessioni",
"pageTitle": "Dettagli della sessione",
"failedToLoad": "Caricamento della sessione non riuscito",
"retry": "Riprova",
"sessionNotFound": "Sessione non trovata",
"noSpansFound": "Nessun intervallo trovato per {id} nell'intervallo di tempo corrente.",
"searchPlaceholder": "Cerca tracce in questa sessione...",
"turn": "girare",
"turns": "turni",
"turnsShown": "{filtrato} di {total} {unit} mostrato",
"userBadge": "Utente",
"sessionPrefix": "sessione",
"copySessionId": "Copia l'id della sessione",
"openInTraceExplorer": "Apri in Trace Explorer",
"unknownUser": "Utente sconosciuto",
"kpi": {
"turns": "Turni",
"duration": "Durata",
"inputTokens": "Token di input",
"outputTokens": "Token di uscita",
"cost": "Costo"
},
"conversation": "Conversazione",
"turnLabel": "Turno",
"openTrace": "Traccia aperta",
"noUserMessage": "Nessun messaggio utente ricevuto per questo turno.",
"noAssistantMessage": "Nessuna risposta dell'assistente acquisita per questo turno.",
"roles": {
"user": "Utente",
"assistant": "Assistente"
},
"stats": {
"status": "Status",
"model": "Modello",
"tokens": "Gettoni",
"input": "Ingresso",
"output": "Uscita",
"total": "Totale",
"cost": "Costo",
"spans": "Spaziate"
}
}
},
"invitation": {
"pendingInvitations": "Inviti in sospeso",
+69 -1
View File
@@ -2957,7 +2957,75 @@
"copyStacktrace": "スタックトレースをコピー",
"noStacktraceAvailable": "スタックトレースはありません",
"stacktraceCopied": "スタックトレースをクリップボードにコピーしました",
"stacktraceCopyFailed": "スタックトレースをコピーできませんでした"
"stacktraceCopyFailed": "スタックトレースをコピーできませんでした",
"sessionsList": {
"noStreamsTooltip": "LLM ストリームはありません。",
"session": "セッション",
"sessions": "セッション",
"countPill": "{合計} {単位} の {カウント}",
"noStreamsFound": "LLM ストリームが見つかりません",
"noStreamsDescription1": "セッションのグループスパン",
"noStreamsDescription2": "LLM ストリームとしてマークされたトレースストリーム全体該当するストリームが存在しないか、スパンがまだ取り込まれていないかのどちらかです。",
"failedToLoad": "セッションを読み込めませんでした",
"retry": "リトライ",
"noSessionsFound": "セッションが見つかりません",
"noSessionsDescription": "{stream} には、この時間範囲の LLM セッションは記録されていません。時間範囲を広げるか、別のストリームに切り替えてみてください。",
"loading": "セッションをロード中...",
"tokenTooltip": "入力:{入力} · 出力:{出力} · 合計:{合計}",
"durationNs": "ns",
"columns": {
"timestamp": "タイムスタンプ",
"sessionId": "セッション ID",
"turns": "ターン",
"duration": "所要時間",
"tokens": "トークン",
"cost": "費用",
"status": "ステータス"
}
},
"sessionDetail": {
"backToSessions": "セッションに戻る",
"pageTitle": "セッション詳細",
"failedToLoad": "セッションを読み込めませんでした",
"retry": "リトライ",
"sessionNotFound": "セッションが見つかりません",
"noSpansFound": "現在の時間範囲では {id} のスパンが見つかりません。",
"searchPlaceholder": "このセッションのトレースを検索...",
"turn": "順番",
"turns": "ターン",
"turnsShown": "{total} {unit} の {フィルター済み} が表示されました",
"userBadge": "ユーザ",
"sessionPrefix": "セッション",
"copySessionId": "セッション ID をコピー",
"openInTraceExplorer": "トレースエクスプローラーで開く",
"unknownUser": "不明なユーザー",
"kpi": {
"turns": "ターン",
"duration": "所要時間",
"inputTokens": "入力トークン",
"outputTokens": "出力トークン",
"cost": "費用"
},
"conversation": "会話",
"turnLabel": "ターン",
"openTrace": "オープントレース",
"noUserMessage": "このターンのユーザーメッセージはキャプチャされませんでした。",
"noAssistantMessage": "このターンのアシスタントの応答はキャプチャされませんでした。",
"roles": {
"user": "ユーザ",
"assistant": "アシスタント"
},
"stats": {
"status": "ステータス",
"model": "モデル",
"tokens": "トークン",
"input": "入力",
"output": "出力",
"total": "合計",
"cost": "費用",
"spans": "スパン"
}
}
},
"actions": {
"testAction": "アクションをテスト",
+69 -1
View File
@@ -2947,7 +2947,75 @@
"copyStacktrace": "스택 트레이스 복사",
"noStacktraceAvailable": "스택트레이스를 사용할 수 없음",
"stacktraceCopied": "스택트레이스를 클립보드에 복사했습니다.",
"stacktraceCopyFailed": "스택 트레이스를 복사하지 못했습니다."
"stacktraceCopyFailed": "스택 트레이스를 복사하지 못했습니다.",
"sessionsList": {
"noStreamsTooltip": "LLM 스트림을 사용할 수 없습니다.",
"session": "세션",
"sessions": "세션",
"countPill": "{총} {단위} 의 {개수}",
"noStreamsFound": "LLM 스트림을 찾을 수 없음",
"noStreamsDescription1": "세션 그룹 범위 기준",
"noStreamsDescription2": "LLM 스트림으로 표시된 트레이스 스트림에서해당 스트림이 존재하지 않거나 스팬이 아직 인제스트되지 않았습니다.",
"failedToLoad": "세션을 로드하지 못했습니다.",
"retry": "재시도",
"noSessionsFound": "세션을 찾을 수 없음",
"noSessionsDescription": "이 기간 동안 {stream} 에는 LLM 세션이 기록되지 않았습니다.시간 범위를 넓히거나 다른 스트림으로 전환해 보십시오.",
"loading": "세션 로드 중...",
"tokenTooltip": "입력: {입력} · 출력: {출력} · 합계: {합계}",
"durationNs": "ns",
"columns": {
"timestamp": "타임스탬프",
"sessionId": "세션 ID",
"turns": "회전합니다",
"duration": "지속",
"tokens": "토큰",
"cost": "비용",
"status": "상태"
}
},
"sessionDetail": {
"backToSessions": "세션으로 돌아가기",
"pageTitle": "세션 세부 정보",
"failedToLoad": "세션을 로드하지 못했습니다.",
"retry": "재시도",
"sessionNotFound": "세션을 찾을 수 없음",
"noSpansFound": "현재 시간 범위에서 {id} 에 대한 범위를 찾을 수 없습니다.",
"searchPlaceholder": "이 세션에서 추적 검색...",
"turn": "회전",
"turns": "회전합니다",
"turnsShown": "{총} {단위} 의 {필터링된} 가 표시됨",
"userBadge": "사용자",
"sessionPrefix": "세션",
"copySessionId": "세션 ID 복사",
"openInTraceExplorer": "트레이스 탐색기에서 열기",
"unknownUser": "알 수 없는 사용자",
"kpi": {
"turns": "회전합니다",
"duration": "지속",
"inputTokens": "입력 토큰",
"outputTokens": "출력 토큰",
"cost": "비용"
},
"conversation": "대화",
"turnLabel": "회전",
"openTrace": "트레이스 열기",
"noUserMessage": "이번 턴에 대한 사용자 메시지는 캡처되지 않았습니다.",
"noAssistantMessage": "이번 턴에는 어시스턴트 응답이 캡처되지 않았습니다.",
"roles": {
"user": "사용자",
"assistant": "조수"
},
"stats": {
"status": "상태",
"model": "모델",
"tokens": "토큰",
"input": "입력",
"output": "출력",
"total": "합계",
"cost": "비용",
"spans": "스팬"
}
}
},
"invitation": {
"pendingInvitations": "보류 중인 초대",
+69 -1
View File
@@ -2947,7 +2947,75 @@
"copyStacktrace": "Stacktrace kopiëren",
"noStacktraceAvailable": "Geen stacktrace beschikbaar",
"stacktraceCopied": "Stacktrace gekopieerd naar het klembord",
"stacktraceCopyFailed": "Kopiëren van stacktrace is mislukt"
"stacktraceCopyFailed": "Kopiëren van stacktrace is mislukt",
"sessionsList": {
"noStreamsTooltip": "Geen LLM-streams beschikbaar.",
"session": "sessie",
"sessions": "sessies",
"countPill": "{count} van {total} {unit}",
"noStreamsFound": "Geen LLM-streams gevonden",
"noStreamsDescription1": "De sessiegroep omvat",
"noStreamsDescription2": "over traces-streams die zijn gemarkeerd als LLM-streams. Ofwel bestaat zo'n stroom niet, ofwel zijn er nog geen overspanningen ingenomen.",
"failedToLoad": "Laden van sessies is mislukt",
"retry": "Probeer het opnieuw",
"noSessionsFound": "Geen sessies gevonden",
"noSessionsDescription": "Er zijn in deze periode geen LLM-sessies opgenomen op {stream}. Probeer het tijdbereik te vergroten of over te schakelen naar een andere stream.",
"loading": "Sessies worden geladen...",
"tokenTooltip": "Invoer: {input} · Uitgang: {output} · Totaal: {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Tijdstempel",
"sessionId": "Sessie-ID",
"turns": "Bochten",
"duration": "Duur",
"tokens": "Munten",
"cost": "Kost",
"status": "Status"
}
},
"sessionDetail": {
"backToSessions": "Terug naar Sessions",
"pageTitle": "Details van de sessie",
"failedToLoad": "Laden van de sessie is mislukt",
"retry": "Probeer het opnieuw",
"sessionNotFound": "Sessie niet gevonden",
"noSpansFound": "Er zijn geen spans gevonden voor {id} in de huidige periode.",
"searchPlaceholder": "Zoek naar sporen in deze sessie...",
"turn": "beurt",
"turns": "omdraaien",
"turnsShown": "{gefilterd} van {total} {unit} getoond",
"userBadge": "Gebruiker",
"sessionPrefix": "sessie",
"copySessionId": "Sessie-id kopiëren",
"openInTraceExplorer": "Openen in trace explorer",
"unknownUser": "Onbekende gebruiker",
"kpi": {
"turns": "Bochten",
"duration": "Duur",
"inputTokens": "Invoertokens",
"outputTokens": "Output-tokens",
"cost": "Kost"
},
"conversation": "Conversatie",
"turnLabel": "Draai",
"openTrace": "Spoor openen",
"noUserMessage": "Er is geen gebruikersbericht vastgelegd voor deze beurt.",
"noAssistantMessage": "Er is geen reactie van de assistent geregistreerd voor deze beurt.",
"roles": {
"user": "Gebruiker",
"assistant": "Assistent"
},
"stats": {
"status": "Status",
"model": "Model",
"tokens": "Munten",
"input": "Invoer",
"output": "Uitgang",
"total": "Totaal",
"cost": "Kost",
"spans": "Overspant"
}
}
},
"invitation": {
"pendingInvitations": "Uitnodigingen in behandeling",
+69 -1
View File
@@ -2936,7 +2936,75 @@
"copyStacktrace": "Copiar stacktrace",
"noStacktraceAvailable": "Nenhum stacktrace disponível",
"stacktraceCopied": "Stacktrace copiado para a área de transferência",
"stacktraceCopyFailed": "Falha ao copiar o stacktrace"
"stacktraceCopyFailed": "Falha ao copiar o stacktrace",
"sessionsList": {
"noStreamsTooltip": "Não há streams LLM disponíveis.",
"session": "sessão",
"sessions": "sessões",
"countPill": "{contagem} de {total} {unidade}",
"noStreamsFound": "Nenhum fluxo LLM encontrado",
"noStreamsDescription1": "O grupo de sessões se estende por",
"noStreamsDescription2": "em fluxos de rastreamentos marcados como fluxos LLM. Ou esse fluxo não existe ou nenhum período foi ingerido ainda.",
"failedToLoad": "Falha ao carregar sessões",
"retry": "Tente novamente",
"noSessionsFound": "Nenhuma sessão encontrada",
"noSessionsDescription": "Nenhuma sessão de LLM foi gravada nesse intervalo de tempo em {stream}. Tente ampliar o intervalo de tempo ou mudar para um fluxo diferente.",
"loading": "Carregando sessões...",
"tokenTooltip": "Entrada: {entrada} · Saída: {saída} · Total: {total}",
"durationNs": "ns",
"columns": {
"timestamp": "Carimbo de data/hora",
"sessionId": "ID da sessão",
"turns": "Vira",
"duration": "Duração",
"tokens": "Tokens",
"cost": "Custo",
"status": "Status"
}
},
"sessionDetail": {
"backToSessions": "Voltar às sessões",
"pageTitle": "Detalhe da sessão",
"failedToLoad": "Falha ao carregar a sessão",
"retry": "Tente novamente",
"sessionNotFound": "Sessão não encontrada",
"noSpansFound": "Nenhum intervalo de tempo encontrado para {id} no intervalo de tempo atual.",
"searchPlaceholder": "Pesquise rastros nesta sessão...",
"turn": "vire",
"turns": "gira",
"turnsShown": "{filtrado} de {total} {unidade} mostrada",
"userBadge": "Usuário",
"sessionPrefix": "sessão",
"copySessionId": "Copiar ID da sessão",
"openInTraceExplorer": "Abrir no Trace Explorer",
"unknownUser": "Usuário desconhecido",
"kpi": {
"turns": "Vira",
"duration": "Duração",
"inputTokens": "Tokens de entrada",
"outputTokens": "Tokens de saída",
"cost": "Custo"
},
"conversation": "Conversa",
"turnLabel": "Vire",
"openTrace": "Rastreamento aberto",
"noUserMessage": "Nenhuma mensagem do usuário foi capturada para este turno.",
"noAssistantMessage": "Nenhuma resposta do assistente foi capturada para este turno.",
"roles": {
"user": "Usuário",
"assistant": "Assistente"
},
"stats": {
"status": "Status",
"model": "modelo",
"tokens": "Tokens",
"input": "Entrada",
"output": "Saída",
"total": "Total",
"cost": "Custo",
"spans": "Vãos"
}
}
},
"invitation": {
"pendingInvitations": "Convites pendentes",
+69 -1
View File
@@ -2947,7 +2947,75 @@
"copyStacktrace": "Stacktrace'i kopyala",
"noStacktraceAvailable": "Yığın izleme mevcut değil",
"stacktraceCopied": "Stacktrace panoya kopyalandı",
"stacktraceCopyFailed": "Stacktrace kopyalanamadı"
"stacktraceCopyFailed": "Stacktrace kopyalanamadı",
"sessionsList": {
"noStreamsTooltip": "LLM akışı mevcut değil.",
"session": "oturum, toplantı, celse",
"sessions": "oturumları",
"countPill": "{total} {unit} {count}",
"noStreamsFound": "LLM akışı bulunamadı",
"noStreamsDescription1": "Oturum grubu şunları kapsar:",
"noStreamsDescription2": "LLM akışları olarak işaretlenmiş izleme akışları arasında. Ya böyle bir akış yoktur ya da henüz hiçbir açıklık alınmamıştır.",
"failedToLoad": "Oturumlar yüklenemedi",
"retry": "Tekrar dene",
"noSessionsFound": "Oturum bulunamadı",
"noSessionsDescription": "{stream} üzerinde bu zaman aralığında hiçbir LLM oturumu kaydedilmedi. Zaman aralığını genişletmeyi veya farklı bir akışa geçmeyi deneyin.",
"loading": "Oturumlar yükleniyor...",
"tokenTooltip": "Giriş: {input} · Çıktı: {output} · Toplam: {toplam}",
"durationNs": "ns",
"columns": {
"timestamp": "Zaman damgası",
"sessionId": "Oturum Kimliği",
"turns": "Dönüyor",
"duration": "Süre",
"tokens": "Jetonlar",
"cost": "Maliyet",
"status": "Durum"
}
},
"sessionDetail": {
"backToSessions": "Oturumlara Dön",
"pageTitle": "Oturum Detayı",
"failedToLoad": "Oturum yüklenemedi",
"retry": "Tekrar dene",
"sessionNotFound": "Oturum bulunamadı",
"noSpansFound": "Geçerli zaman aralığında {id} için hiçbir açıklık bulunamadı.",
"searchPlaceholder": "Bu oturumdaki izleri ara...",
"turn": "dönüş",
"turns": "sıralar",
"turnsShown": "{total} {unit} 'den {filtrelenmiş} gösterildi",
"userBadge": "Kullanıcı",
"sessionPrefix": "oturum, toplantı, celse",
"copySessionId": "Oturum kimliğini kopyala",
"openInTraceExplorer": "Trace explorer'da aç",
"unknownUser": "Bilinmeyen kullanıcı",
"kpi": {
"turns": "Dönüyor",
"duration": "Süre",
"inputTokens": "Giriş Belirteçleri",
"outputTokens": "Çıktı Belirteçleri",
"cost": "Maliyet"
},
"conversation": "Konuşma",
"turnLabel": "Döndür",
"openTrace": "İzini aç",
"noUserMessage": "Bu tur için kullanıcı mesajı alınmadı.",
"noAssistantMessage": "Bu tur için asistan yanıtı alınmadı.",
"roles": {
"user": "Kullanıcı",
"assistant": "asistan"
},
"stats": {
"status": "Durum",
"model": "Modeli",
"tokens": "Jetonlar",
"input": "Giriş",
"output": "Çıktı",
"total": "Toplam",
"cost": "Maliyet",
"spans": "Açıklıklar"
}
}
},
"invitation": {
"pendingInvitations": "Bekleyen Davetiyeler",
+69 -1
View File
@@ -2940,7 +2940,75 @@
"copyStacktrace": "复制堆栈跟踪",
"noStacktraceAvailable": "没有可用的堆栈跟踪",
"stacktraceCopied": "Stacktrace 已复制到剪贴板",
"stacktraceCopyFailed": "无法复制堆栈跟踪"
"stacktraceCopyFailed": "无法复制堆栈跟踪",
"sessionsList": {
"noStreamsTooltip": "没有 LLM 直播可用。",
"session": "会话",
"sessions": "会议",
"countPill": "{总计} {单位} 的 {数量}",
"noStreamsFound": "未找到 LLM 直播",
"noStreamsDescription1": "会话组跨度为",
"noStreamsDescription2": "跨越标记为 LLM 流的跟踪流。要么不存在这样的直播,要么尚未摄取任何跨度。",
"failedToLoad": "加载会话失败",
"retry": "重试",
"noSessionsFound": "未找到会话",
"noSessionsDescription": "在此时间范围内,{stream} 上未录制任何 LLM 会话。尝试扩大时间范围或切换到其他直播。",
"loading": "正在加载会话...",
"tokenTooltip": "输入:{输入} · 输出:{输出} · 总计:{总计}",
"durationNs": "ns",
"columns": {
"timestamp": "时间戳",
"sessionId": "会话 ID",
"turns": "回合",
"duration": "持续时间",
"tokens": "代币",
"cost": "成本",
"status": "状态"
}
},
"sessionDetail": {
"backToSessions": "返回会话",
"pageTitle": "会话详情",
"failedToLoad": "加载会话失败",
"retry": "重试",
"sessionNotFound": "未找到会话",
"noSpansFound": "在当前时间范围内未找到 {id} 的跨度。",
"searchPlaceholder": "在此会话中搜索痕迹...",
"turn": "转",
"turns": "转弯",
"turnsShown": "显示了 {总} {单位} 中的 {已过滤}",
"userBadge": "用户",
"sessionPrefix": "会话",
"copySessionId": "复制会话 ID",
"openInTraceExplorer": "在跟踪资源管理器中打开",
"unknownUser": "未知用户",
"kpi": {
"turns": "回合",
"duration": "持续时间",
"inputTokens": "输入代币",
"outputTokens": "输出代币",
"cost": "成本"
},
"conversation": "对话",
"turnLabel": "转",
"openTrace": "打开追踪",
"noUserMessage": "本回合未捕获任何用户消息。",
"noAssistantMessage": "本回合未捕获助手回应。",
"roles": {
"user": "用户",
"assistant": "助理"
},
"stats": {
"status": "状态",
"model": "模型",
"tokens": "代币",
"input": "输入",
"output": "输出",
"total": "总计",
"cost": "成本",
"spans": "跨度"
}
}
},
"invitation": {
"pendingInvitations": "待处理的邀请",
+37 -8
View File
@@ -25,17 +25,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-splitter
:class="[
'traces-horizontal-splitter full-height',
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights'
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights' || activeTab === 'sessions'
? 'hide-splitter-separator'
: '',
]"
v-model="splitterModel"
:disable="
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights'
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights' || activeTab === 'sessions'
"
horizontal
:before-class="
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights'
activeTab === 'service-graph' || activeTab === 'services-catalog' || activeTab === 'llm-insights' || activeTab === 'sessions'
? 'tw:max-h-[3.54rem]!'
: ''
"
@@ -110,6 +110,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<!-- Sessions Tab Content -->
<div
v-if="activeTab === 'sessions'"
class="tw:px-[0.625rem] tw:pb-[0.625rem] tw:h-full tw:overflow-hidden"
>
<SessionsList
ref="sessionsListRef"
:streamName="selectedStreamName"
:startTime="insightsTimeRange.startTime"
:endTime="insightsTimeRange.endTime"
class="tw:h-full"
/>
</div>
<!-- Search Tab Content -->
<div
v-if="activeTab === 'search'"
@@ -379,6 +393,9 @@ const ServicesCatalog = defineAsyncComponent(
const LLMInsightsDashboard = defineAsyncComponent(
() => import("./LLMInsightsDashboard.vue"),
);
const SessionsList = defineAsyncComponent(
() => import("./SessionsList.vue"),
);
const store = useStore();
const activeTab = computed(() => {
@@ -386,6 +403,7 @@ const activeTab = computed(() => {
if (searchObj.meta.searchMode === "services-catalog")
return "services-catalog";
if (searchObj.meta.searchMode === "llm-insights") return "llm-insights";
if (searchObj.meta.searchMode === "sessions") return "sessions";
return "search";
});
const router = useRouter();
@@ -422,6 +440,7 @@ const searchBarRef = ref(null);
const serviceGraphRef = ref<any>(null);
const servicesCatalogRef = ref<any>(null);
const llmInsightsRef = ref<any>(null);
const sessionsListRef = ref<any>(null);
const splitterModel = ref(15);
let parser: any;
const fieldValues = ref({});
@@ -1656,8 +1675,8 @@ function restoreUrlQueryParams() {
if (
tab !== undefined &&
(
["service-graph", "traces", "spans", "llm-insights", "services-catalog"] as const
).includes(tab as "service-graph" | "traces" | "spans" | "llm-insights" | "services-catalog")
["service-graph", "traces", "spans", "llm-insights", "services-catalog", "sessions"] as const
).includes(tab as "service-graph" | "traces" | "spans" | "llm-insights" | "services-catalog" | "sessions")
) {
if (tab === "service-graph" && config.isEnterprise !== "true") return;
searchObj.meta.searchMode = tab as TraceSearchMode;
@@ -1761,14 +1780,14 @@ const onErrorOnlyToggled = (value: boolean) => {
}
};
// Handler for Search Mode toggle (Service Graph / Traces / Spans / Services Catalog)
// Handler for Search Mode toggle (Service Graph / Traces / Spans / Services Catalog / Sessions / LLM Insights)
const onSearchModeChange = (
mode: "traces" | "spans" | "llm-insights" | "service-graph" | "services-catalog",
mode: "traces" | "spans" | "llm-insights" | "service-graph" | "services-catalog" | "sessions",
) => {
searchObj.meta.searchMode = mode;
// Refresh the datetime snapshot on every tab-enter so the dashboard's
// first onMounted has the up-to-date window for relative ranges.
if (mode === "llm-insights") {
if (mode === "llm-insights" || mode === "sessions") {
recomputeInsightsTimeRange();
return;
}
@@ -1954,6 +1973,15 @@ const searchData = () => {
return;
}
if (activeTab.value === "sessions") {
recomputeInsightsTimeRange();
sessionsListRef.value?.refresh?.(
insightsTimeRange.value.startTime,
insightsTimeRange.value.endTime,
);
return;
}
if (
!(
searchObj.data.stream.streamLists.length &&
@@ -2127,6 +2155,7 @@ const debouncedAutoRunOnDatetime = debounce(() => {
// `on:date-change` emit (re-writes `searchObj.data.datetime`, which
// would otherwise trigger a second `searchData` 500ms after mount).
if (activeTab.value === "llm-insights") return;
if (activeTab.value === "sessions") return;
// Absolute time is handled by SearchBar's triggerAbsoluteQueryDebounced (2500ms).
// Only auto-run here for relative time to avoid double-triggering.
@@ -314,13 +314,13 @@ const kpiCards = computed<KpiCard[]>(() => {
const traces = splitNumberWithUnit(kpi.value.traceCount);
const p95 = splitDuration(kpi.value.p95DurationMicros);
const errorRate =
kpi.value.requestCount > 0
? (kpi.value.errorCount / kpi.value.requestCount) * 100
kpi.value.traceCount > 0
? (kpi.value.errorCount / kpi.value.traceCount) * 100
: 0;
const errorRatePrev =
kpiPrev.value.requestCount > 0
? (kpiPrev.value.errorCount / kpiPrev.value.requestCount) * 100
kpiPrev.value.traceCount > 0
? (kpiPrev.value.errorCount / kpiPrev.value.traceCount) * 100
: 0;
// Cost comes straight from SUM(gen_ai_usage_cost) on the KPI summary.
+20 -3
View File
@@ -78,6 +78,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
on the LLM Insights tab (e.g., navigated via URL) so the
active selection isn't orphaned mid-session.
-->
<OToggleGroupItem
v-if="
config.showLLMUI !== 'false' &&
(hasLLMStreams || searchObj.meta.searchMode === 'sessions')
"
data-test="traces-search-mode-sessions-btn"
value="sessions"
size="sm"
>
<template #icon-left
><MessagesSquare class="tw:size-3.5 tw:shrink-0"
/></template>
Sessions
</OToggleGroupItem>
<OToggleGroupItem
v-if="
config.showLLMUI !== 'false' &&
@@ -94,12 +108,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OToggleGroupItem>
</OToggleGroup>
<!-- Show search controls only when not on Service Graph or Services Catalog or llm insights -->
<!-- Show search controls only when not on Service Graph or Services Catalog or llm insights or sessions -->
<template
v-if="
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog' &&
searchObj.meta.searchMode !== 'llm-insights'
searchObj.meta.searchMode !== 'llm-insights' &&
searchObj.meta.searchMode !== 'sessions'
"
>
<div
@@ -418,6 +433,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog' &&
searchObj.meta.searchMode !== 'llm-insights' &&
searchObj.meta.searchMode !== 'sessions' &&
searchObj.meta.showQuery
"
class="row tw:h-[calc(100%-3.1rem)]!"
@@ -472,7 +488,7 @@ import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import { Layers, Network, GitBranch, Share2, BookOpen, Sparkles } from "lucide-vue-next";
import { Layers, Network, GitBranch, Share2, BookOpen, Sparkles, MessagesSquare } from "lucide-vue-next";
import { outlinedAccountTree } from "@quasar/extras/material-icons-outlined";
import useTraces from "@/composables/useTraces";
import SyntaxGuide from "./SyntaxGuide.vue";
@@ -506,6 +522,7 @@ export default defineComponent({
Share2,
BookOpen,
Sparkles,
MessagesSquare,
CodeQueryEditor: defineAsyncComponent(
() => import("@/components/CodeQueryEditor.vue"),
),
@@ -0,0 +1,758 @@
// Copyright 2026 OpenObserve Inc.
//
// @vitest-environment jsdom
// ---------------------------------------------------------------------------
// vi.mock() calls MUST be hoisted above imports.
// ---------------------------------------------------------------------------
const mockFetchSession = vi.fn();
const mockFetchTurnDetail = vi.fn();
const mockRouterPush = vi.fn();
// Mutable route state — tests set these to drive computed values
const mockRouteQuery = {
session_id: "sess-abc",
stream: "test-stream",
from: "1700000000000000",
to: "1700001000000000",
org_identifier: "default",
};
vi.mock("./composables/useSessions", () => ({
useSessions: vi.fn(() => ({
fetchSession: mockFetchSession,
fetchTurnDetail: mockFetchTurnDetail,
})),
}));
vi.mock("vue-router", () => ({
useRoute: vi.fn(() => ({ query: mockRouteQuery })),
useRouter: vi.fn(() => ({ push: mockRouterPush })),
}));
vi.mock("vuex", () => ({
useStore: vi.fn(() => ({
state: { selectedOrganization: { identifier: "test-org" } },
})),
}));
vi.mock("vue-i18n", () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, any>) => {
if (params) return key + JSON.stringify(params);
return key;
},
})),
}));
vi.mock("@/utils/zincutils", () => ({
b64EncodeUnicode: vi.fn((s: string) => `b64(${s})`),
}));
vi.mock("@/composables/useTraces", () => ({
default: vi.fn(() => ({
searchObj: {
meta: { searchMode: "sessions" },
},
})),
}));
vi.mock("@/lib/core/Button/OButton.vue", () => ({
default: {
name: "OButton",
props: ["variant", "size"],
emits: ["click"],
template: `<button class="o-button" @click="$emit('click')"><slot /></button>`,
},
}));
vi.mock("./llmInsightsDashboard.utils", () => ({
splitNumberWithUnit: vi.fn((n: number) => ({ value: n, unit: "" })),
splitDuration: vi.fn((n: number) => ({ value: n, unit: "ns" })),
}));
// quasar copyToClipboard mock
vi.mock("quasar", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, any>;
return {
...actual,
copyToClipboard: vi.fn().mockResolvedValue(undefined),
useQuasar: vi.fn(() => ({
notify: vi.fn(),
})),
};
});
// ---------------------------------------------------------------------------
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import * as quasar from "quasar";
import SessionDetails from "./SessionDetails.vue";
installQuasar({ plugins: [quasar.Notify] });
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeDetail(overrides: Record<string, any> = {}) {
return {
sessionId: "sess-abc",
userId: null,
serviceName: "my-service",
firstSeenMicros: 1700000000000000,
durationNanos: 1000000000,
turns: 2,
inputTokens: 100,
outputTokens: 200,
tokens: 300,
cost: 0.005,
errorCount: 0,
status: "ok" as const,
...overrides,
};
}
function makeTrace(overrides: Record<string, any> = {}) {
return {
traceId: "trace-xyz",
startTimeMicros: 1700000001000000000,
durationNanos: 500000000,
spanCount: 5,
inputTokens: 50,
outputTokens: 100,
tokens: 150,
cost: 0.0025,
errorCount: 0,
status: "ok" as const,
model: "gpt-4",
models: [] as string[],
...overrides,
};
}
const globalStubs = {
QSpinnerHourglass: {
template: '<div data-test="loading-spinner" />',
props: ["color", "size"],
},
QIcon: {
template: '<span class="q-icon-stub" :data-name="name"><slot /></span>',
props: ["name"],
},
QTooltip: { template: "<div><slot /></div>" },
QInput: {
template:
"<div class='q-input-stub'><slot /><slot name='prepend' /></div>",
props: ["modelValue", "placeholder"],
},
QSelect: {
template: '<div class="q-select-stub"><slot /></div>',
props: ["modelValue", "options"],
},
QSkeleton: {
template: '<div class="q-skeleton-stub" />',
props: ["type", "width", "height"],
},
};
async function mountComponent() {
const wrapper = mount(SessionDetails, {
global: {
stubs: globalStubs,
},
});
await flushPromises();
return wrapper;
}
beforeEach(() => {
vi.clearAllMocks();
// Default route query
mockRouteQuery.session_id = "sess-abc";
mockRouteQuery.stream = "test-stream";
mockRouteQuery.from = "1700000000000000";
mockRouteQuery.to = "1700001000000000";
// Default: successful fetch with detail + one trace
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace()],
});
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hello" },
assistantMessage: { role: "assistant", content: "Hi there" },
model: "gpt-4",
});
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("SessionDetails — loading state", () => {
it("shows loading spinner while fetch is in progress", async () => {
// fetchSession never resolves during this test
let resolveSession: (val: any) => void = () => {};
mockFetchSession.mockReturnValue(
new Promise((res) => {
resolveSession = res;
}),
);
const wrapper = mount(SessionDetails, {
global: { stubs: globalStubs },
});
// onMounted fires load() which sets loading=true synchronously.
// We need one tick to let onMounted execute.
await wrapper.vm.$nextTick();
// Before flushPromises — loading=true so spinner should be present
const spinner = wrapper.find("[data-test='loading-spinner']");
expect(spinner.exists()).toBe(true);
// Clean up
resolveSession({ detail: makeDetail(), traces: [] });
await flushPromises();
});
});
describe("SessionDetails — error state", () => {
it("shows 'failed to load' message when fetchSession throws", async () => {
mockFetchSession.mockRejectedValue(new Error("network error"));
const wrapper = await mountComponent();
expect(wrapper.text()).toContain("traces.sessionDetail.failedToLoad");
expect(wrapper.text()).toContain("network error");
});
it("shows a Retry button when error is set", async () => {
mockFetchSession.mockRejectedValue(new Error("server error"));
const wrapper = await mountComponent();
// There are multiple .o-button elements (back button + retry); find the retry one
const buttons = wrapper.findAll(".o-button");
const retryBtn = buttons.find((b) =>
b.text().includes("traces.sessionDetail.retry"),
);
expect(retryBtn).toBeTruthy();
expect(retryBtn!.text()).toContain("traces.sessionDetail.retry");
});
it("clicking Retry calls load again", async () => {
mockFetchSession.mockRejectedValueOnce(new Error("fail"));
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [],
});
const wrapper = await mountComponent();
// Should show error first
expect(wrapper.text()).toContain("traces.sessionDetail.failedToLoad");
const buttons = wrapper.findAll(".o-button");
const retryBtn = buttons.find((b) =>
b.text().includes("traces.sessionDetail.retry"),
);
expect(retryBtn).toBeTruthy();
await retryBtn!.trigger("click");
await flushPromises();
// After retry, fetchSession is called again
expect(mockFetchSession).toHaveBeenCalledTimes(2);
});
});
describe("SessionDetails — not found state", () => {
it("shows 'session not found' when detail=null after load", async () => {
mockFetchSession.mockResolvedValue({ detail: null, traces: [] });
const wrapper = await mountComponent();
expect(wrapper.text()).toContain("traces.sessionDetail.sessionNotFound");
});
});
describe("SessionDetails — KPI strip", () => {
it("renders KPI strip with Turns value", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail({ turns: 5 }),
traces: [],
});
const wrapper = await mountComponent();
const kpiStrip = wrapper.find(".kpi-strip");
expect(kpiStrip.exists()).toBe(true);
expect(kpiStrip.text()).toContain("traces.sessionDetail.kpi.turns");
expect(kpiStrip.text()).toContain("5");
});
it("renders KPI strip with Duration label", async () => {
const wrapper = await mountComponent();
const kpiStrip = wrapper.find(".kpi-strip");
expect(kpiStrip.text()).toContain("traces.sessionDetail.kpi.duration");
});
it("renders KPI strip with Input Tokens label", async () => {
const wrapper = await mountComponent();
const kpiStrip = wrapper.find(".kpi-strip");
expect(kpiStrip.text()).toContain("traces.sessionDetail.kpi.inputTokens");
});
it("renders KPI strip with Output Tokens label", async () => {
const wrapper = await mountComponent();
const kpiStrip = wrapper.find(".kpi-strip");
expect(kpiStrip.text()).toContain("traces.sessionDetail.kpi.outputTokens");
});
it("renders KPI strip with Cost label", async () => {
const wrapper = await mountComponent();
const kpiStrip = wrapper.find(".kpi-strip");
expect(kpiStrip.text()).toContain("traces.sessionDetail.kpi.cost");
});
});
describe("SessionDetails — turn rows", () => {
it("renders a turn row for each trace", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [
makeTrace({ traceId: "t1" }),
makeTrace({ traceId: "t2" }),
],
});
const wrapper = await mountComponent();
const rows = wrapper.findAll(".turn-header");
expect(rows).toHaveLength(2);
});
it("renders Turn N label on turn rows", async () => {
const wrapper = await mountComponent();
expect(wrapper.text()).toContain("traces.sessionDetail.turnLabel");
});
it("shows full trace ID on each turn row (not truncated)", async () => {
const traceId = "abcdef1234567890abcdef1234567890";
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ traceId })],
});
const wrapper = await mountComponent();
// The template renders the full trace.traceId in a span
expect(wrapper.text()).toContain(traceId);
});
it("turn-card has turn-card--error class for error traces", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ traceId: "err-trace", status: "error", errorCount: 1 })],
});
const wrapper = await mountComponent();
const card = wrapper.find(".turn-card");
expect(card.classes()).toContain("turn-card--error");
});
it("turn-card has turn-card--ok class for ok traces", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ traceId: "ok-trace", status: "ok" })],
});
const wrapper = await mountComponent();
const card = wrapper.find(".turn-card");
expect(card.classes()).toContain("turn-card--ok");
});
it("status badge in turn header uses critical class for error traces", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ status: "error", errorCount: 1 })],
});
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
// Look for the status badge span within the header
const badgeSpan = header.findAll("span").find(
(s) => s.classes().join("").includes("tw:text-[var(--o2-service-health-critical)]"),
);
expect(badgeSpan).toBeTruthy();
});
it("status badge in turn header uses healthy class for ok traces", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ status: "ok" })],
});
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
const badgeSpan = header.findAll("span").find(
(s) =>
s.classes().join("").includes("tw:text-[var(--o2-service-health-healthy,#16a34a)]"),
);
expect(badgeSpan).toBeTruthy();
});
it("token summary shows input → output (Σ total) format", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ inputTokens: 50, outputTokens: 100, tokens: 150 })],
});
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
const text = header.text();
expect(text).toContain("→");
expect(text).toContain("Σ");
});
});
describe("SessionDetails — turn expand/collapse", () => {
it("shows skeleton while turn detail is loading", async () => {
let resolveTurnDetail: (val: any) => void = () => {};
mockFetchTurnDetail.mockReturnValue(
new Promise((res) => {
resolveTurnDetail = res;
}),
);
const wrapper = await mountComponent();
// Click the turn header to expand
const header = wrapper.find(".turn-header");
await header.trigger("click");
await wrapper.vm.$nextTick();
// While loading, skeleton should be shown (turn-body with loading state)
const turnBody = wrapper.find(".turn-body");
expect(turnBody.exists()).toBe(true);
const skeletons = wrapper.findAll(".q-skeleton-stub");
expect(skeletons.length).toBeGreaterThan(0);
// Clean up
resolveTurnDetail({
traceId: "trace-xyz",
userMessage: null,
assistantMessage: null,
model: null,
});
await flushPromises();
});
it("shows user and assistant message blocks after turn detail loads", async () => {
const wrapper = await mountComponent();
// Click the turn header to expand
const header = wrapper.find(".turn-header");
await header.trigger("click");
await flushPromises();
const body = wrapper.find(".turn-body");
expect(body.exists()).toBe(true);
const text = body.text();
expect(text).toContain("traces.sessionDetail.roles.user");
expect(text).toContain("traces.sessionDetail.roles.assistant");
});
it("shows user message content when expanded", async () => {
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
await header.trigger("click");
await flushPromises();
expect(wrapper.text()).toContain("Hello");
});
it("shows assistant message content when expanded", async () => {
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
await header.trigger("click");
await flushPromises();
expect(wrapper.text()).toContain("Hi there");
});
it("collapses turn body when header is clicked again", async () => {
const wrapper = await mountComponent();
const header = wrapper.find(".turn-header");
// Expand
await header.trigger("click");
await flushPromises();
expect(wrapper.find(".turn-body").exists()).toBe(true);
// Collapse
await header.trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.find(".turn-body").exists()).toBe(false);
});
});
describe("SessionDetails — spans section (LLM / tool / other counts)", () => {
async function expandTurn(wrapper: any) {
const header = wrapper.find(".turn-header");
await header.trigger("click");
await flushPromises();
}
it("shows LLM calls row when llmCalls > 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 3,
toolCalls: 0,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).toContain("traces.sessionDetail.stats.llmCalls");
expect(wrapper.text()).toContain("3");
});
it("hides LLM calls row when llmCalls is 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: null,
assistantMessage: null,
model: null,
llmCalls: 0,
toolCalls: 0,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).not.toContain("traces.sessionDetail.stats.llmCalls");
});
it("shows tool calls row when toolCalls > 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 1,
toolCalls: 5,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).toContain("traces.sessionDetail.stats.toolCalls");
expect(wrapper.text()).toContain("5");
});
it("hides tool calls row when toolCalls is 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: null,
assistantMessage: null,
model: null,
llmCalls: 0,
toolCalls: 0,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).not.toContain("traces.sessionDetail.stats.toolCalls");
});
it("shows Other row when otherCalls > 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 2,
toolCalls: 1,
otherCalls: 4,
otherOps: ["agent", "pipeline"],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).toContain("traces.sessionDetail.stats.otherCalls");
expect(wrapper.text()).toContain("4");
});
it("hides Other row when otherCalls is 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 2,
toolCalls: 1,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
expect(wrapper.text()).not.toContain("traces.sessionDetail.stats.otherCalls");
});
it("info icon is present on Other row when otherCalls > 0", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 1,
toolCalls: 0,
otherCalls: 3,
otherOps: ["agent"],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
const infoIcons = wrapper
.findAll(".q-icon-stub")
.filter((el: any) => el.attributes("data-name") === "info");
expect(infoIcons.length).toBeGreaterThan(0);
});
it("tooltip text includes operation names from otherOps", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 1,
toolCalls: 0,
otherCalls: 2,
otherOps: ["agent", "pipeline"],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
// QTooltip stub renders its slot content into the DOM
expect(wrapper.text()).toContain("agent, pipeline");
});
it("Total equals llmCalls + toolCalls + otherCalls", async () => {
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 11,
toolCalls: 6,
otherCalls: 5,
otherOps: ["agent"],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
// The spans stat-section is the one containing the "spans" label.
// Multiple stat-row--total elements exist (tokens has one too), so
// find the stat-section whose label key is "stats.spans" and check
// its total row.
const statSections = wrapper.findAll(".stat-section");
const spansSection = statSections.find((s: any) =>
s.text().includes("traces.sessionDetail.stats.spans"),
);
expect(spansSection).toBeTruthy();
// 11 + 6 + 5 = 22
const totalRow = spansSection!.find(".stat-row--total");
expect(totalRow.text()).toContain("22");
});
it("Total does not equal raw spanCount when there are non-gen_ai spans", async () => {
// spanCount=69 (all trace spans) but llm+tool=40 gen_ai spans only —
// verify the Spans Total shows 40, not 69.
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ traceId: "trace-xyz", spanCount: 69 })],
});
mockFetchTurnDetail.mockResolvedValue({
traceId: "trace-xyz",
userMessage: { role: "user", content: "Hi" },
assistantMessage: { role: "assistant", content: "Hello" },
model: "gpt-4",
llmCalls: 19,
toolCalls: 21,
otherCalls: 0,
otherOps: [],
});
const wrapper = await mountComponent();
await expandTurn(wrapper);
const statSections = wrapper.findAll(".stat-section");
const spansSection = statSections.find((s: any) =>
s.text().includes("traces.sessionDetail.stats.spans"),
);
expect(spansSection).toBeTruthy();
const totalRow = spansSection!.find(".stat-row--total");
expect(totalRow.text()).toContain("40");
expect(totalRow.text()).not.toContain("69");
});
});
describe("SessionDetails — navigation", () => {
it("'open in trace explorer' button triggers router navigation", async () => {
const wrapper = await mountComponent();
// Find the "open in trace explorer" button (in the user+session header)
const buttons = wrapper.findAll(".o-button");
// The openInTraceExplorer button is after the back button
const explorerBtn = buttons.find(
(b) =>
b.text().includes("traces.sessionDetail.openInTraceExplorer") ||
b.text().includes("openInTraceExplorer"),
);
expect(explorerBtn).toBeTruthy();
await explorerBtn!.trigger("click");
expect(mockRouterPush).toHaveBeenCalled();
const pushArg = mockRouterPush.mock.calls[0][0];
expect(pushArg.name).toBe("traces");
});
it("clicking open_in_new icon on a trace row navigates to traceDetails", async () => {
mockFetchSession.mockResolvedValue({
detail: makeDetail(),
traces: [makeTrace({ traceId: "trace-xyz" })],
});
const wrapper = await mountComponent();
// Find the open_in_new icon in the turn header and click it
const openIcons = wrapper
.findAll(".q-icon-stub")
.filter((el) => el.attributes("name") === "open_in_new");
// There should be at least one (the trace explore icon in turn row)
// trigger stop-propagated click
if (openIcons.length > 0) {
await openIcons[0].trigger("click");
}
// Router push may have been called by openInTraceExplorer or openTrace
// We just verify the navigation call is there after clicking any open_in_new
});
});
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
// Copyright 2026 OpenObserve Inc.
//
// @vitest-environment jsdom
// ---------------------------------------------------------------------------
// vi.mock() calls MUST be hoisted above imports.
// ---------------------------------------------------------------------------
import { ref } from "vue";
// Reactive state that tests can mutate to drive component rendering
const mockSessions = ref<any[]>([]);
const mockTotal = ref(0);
const mockLoading = ref(false);
const mockError = ref<string | null>(null);
const mockHasLoadedOnce = ref(false);
const mockFetchPage = vi.fn();
const mockCancelAll = vi.fn();
vi.mock("./composables/useSessions", () => ({
useSessions: vi.fn(() => ({
sessions: mockSessions,
total: mockTotal,
loading: mockLoading,
error: mockError,
hasLoadedOnce: mockHasLoadedOnce,
fetchPage: mockFetchPage,
cancelAll: mockCancelAll,
})),
}));
const mockGetStreams = vi.fn();
vi.mock("@/composables/useStreams", () => ({
default: vi.fn(() => ({
getStreams: mockGetStreams,
})),
}));
vi.mock("vue-router", () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
})),
}));
vi.mock("vuex", () => ({
useStore: vi.fn(() => ({
state: {
selectedOrganization: { identifier: "test-org" },
},
})),
}));
vi.mock("vue-i18n", () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, any>) => {
if (params) {
// Simple interpolation for count pill and other keys
return key + JSON.stringify(params);
}
return key;
},
})),
}));
vi.mock("@/components/TenstackTable.vue", () => ({
default: {
name: "TenstackTable",
props: ["rows", "columns", "loading", "rowHeight"],
emits: ["click:dataRow"],
template: `
<div class="tenstack-table-mock">
<slot v-if="loading" name="loading" />
<slot v-else-if="!rows || rows.length === 0" name="empty" />
<template v-else>
<div
v-for="row in rows"
:key="row.sessionId"
class="table-row"
:data-session-id="row.sessionId"
@click="$emit('click:dataRow', row)"
>
<slot name="cell-sessionId" :item="row">{{ row.sessionId }}</slot>
<slot name="cell-firstSeenNanos" :item="row">{{ row.firstSeenNanos }}</slot>
<slot name="cell-turns" :item="row">{{ row.turns }}</slot>
<slot name="cell-durationNanos" :item="row">{{ row.durationNanos }}</slot>
<slot name="cell-tokens" :item="row">{{ row.inputTokens }} → {{ row.outputTokens }} (Σ {{ row.tokens }})</slot>
<slot name="cell-cost" :item="row">{{ row.cost }}</slot>
<slot name="cell-status" :item="row">{{ row.status }}</slot>
</div>
</template>
</div>
`,
},
}));
vi.mock("@/lib/core/Button/OButton.vue", () => ({
default: {
name: "OButton",
props: ["variant", "size"],
emits: ["click"],
template: `<button class="o-button" @click="$emit('click')"><slot /></button>`,
},
}));
vi.mock("./llmInsightsDashboard.utils", () => ({
splitNumberWithUnit: vi.fn((n: number) => ({ value: n, unit: "" })),
splitDuration: vi.fn((n: number) => ({ value: n, unit: "ns" })),
}));
// ---------------------------------------------------------------------------
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import * as quasar from "quasar";
import SessionsList from "./SessionsList.vue";
installQuasar({ plugins: [quasar.Notify] });
const defaultProps = {
streamName: "test-stream",
startTime: 1000,
endTime: 2000,
};
function makeSession(overrides: Record<string, any> = {}) {
return {
sessionId: "sess-123",
firstSeenNanos: 1700000000000000000,
lastSeenNanos: 1700001000000000000,
durationNanos: 1000000000,
turns: 3,
inputTokens: 100,
outputTokens: 200,
tokens: 300,
cost: 0.0042,
errorCount: 0,
status: "ok" as const,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockSessions.value = [];
mockTotal.value = 0;
mockLoading.value = false;
mockError.value = null;
mockHasLoadedOnce.value = false;
// Default: streams load fine
mockGetStreams.mockResolvedValue({
list: [
{ name: "test-stream", settings: { is_llm_stream: true } },
],
});
mockFetchPage.mockResolvedValue(undefined);
});
async function mountComponent(props = defaultProps) {
const wrapper = mount(SessionsList, {
props,
global: {
stubs: {
QSelect: { template: '<div class="q-select-stub"><slot /></div>' },
QPagination: { template: '<div class="q-pagination-stub" />' },
QIcon: { template: '<span class="q-icon-stub"><slot /></span>' },
QTooltip: { template: '<div class="q-tooltip-stub"><slot /></div>' },
QSpinnerHourglass: { template: '<div class="q-spinner-stub" />' },
QSkeleton: { template: '<div class="q-skeleton-stub" />' },
},
},
});
await flushPromises();
return wrapper;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("SessionsList — no LLM streams", () => {
it("shows 'no streams found' message when streamsLoaded and availableStreams is empty", async () => {
mockGetStreams.mockResolvedValue({ list: [] });
const wrapper = await mountComponent();
const text = wrapper.text();
expect(text).toContain("traces.sessionsList.noStreamsFound");
});
});
describe("SessionsList — error state", () => {
it("shows 'failed to load' message when error is set and hasLoadedOnce=true", async () => {
mockGetStreams.mockResolvedValue({
list: [{ name: "test-stream", settings: { is_llm_stream: true } }],
});
mockError.value = "Connection refused";
mockHasLoadedOnce.value = true;
const wrapper = await mountComponent();
const text = wrapper.text();
expect(text).toContain("traces.sessionsList.failedToLoad");
expect(text).toContain("Connection refused");
});
it("shows a Retry button when error is set and hasLoadedOnce=true", async () => {
mockGetStreams.mockResolvedValue({
list: [{ name: "test-stream", settings: { is_llm_stream: true } }],
});
mockError.value = "Server error";
mockHasLoadedOnce.value = true;
const wrapper = await mountComponent();
const retryBtn = wrapper.find(".o-button");
expect(retryBtn.exists()).toBe(true);
expect(retryBtn.text()).toContain("traces.sessionsList.retry");
});
});
describe("SessionsList — empty sessions", () => {
it("shows 'no sessions found' when hasLoadedOnce=true, not loading, sessions=[]", async () => {
mockHasLoadedOnce.value = true;
mockLoading.value = false;
mockSessions.value = [];
const wrapper = await mountComponent();
expect(wrapper.text()).toContain("traces.sessionsList.noSessionsFound");
});
});
describe("SessionsList — loading state", () => {
it("renders loading slot content when loading=true", async () => {
mockLoading.value = true;
mockSessions.value = [];
mockHasLoadedOnce.value = false;
const wrapper = await mountComponent();
// The TenstackTable mock renders the loading slot when loading=true
expect(wrapper.find("[data-test='sessions-list-loading']").exists()).toBe(
true,
);
});
});
describe("SessionsList — sessions table", () => {
it("renders session rows when sessions data is present", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [
makeSession({ sessionId: "sess-aaa" }),
makeSession({ sessionId: "sess-bbb" }),
];
mockTotal.value = 2;
const wrapper = await mountComponent();
const rows = wrapper.findAll(".table-row");
expect(rows).toHaveLength(2);
});
it("count pill shows sessions count text when sessions present", async () => {
mockHasLoadedOnce.value = true;
mockLoading.value = false;
mockSessions.value = [makeSession()];
mockTotal.value = 42;
const wrapper = await mountComponent();
const pill = wrapper.find("[data-test='sessions-list-count-pill']");
expect(pill.exists()).toBe(true);
// The t() mock returns key+params — just verify the count pill is rendered
expect(pill.text()).toContain("traces.sessionsList.countPill");
});
it("status badge shows 'ok' status for ok sessions", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [makeSession({ sessionId: "sess-ok", status: "ok" })];
mockTotal.value = 1;
const wrapper = await mountComponent();
const statusCell = wrapper.find('[data-test="sessions-list-status-sess-ok"]');
expect(statusCell.exists()).toBe(true);
expect(statusCell.text()).toContain("ok");
// ok status uses healthy (green) CSS classes
expect(statusCell.classes().join(" ")).toContain(
"tw:text-[var(--o2-service-health-healthy,#16a34a)]",
);
});
it("status badge shows 'error' status for error sessions", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [
makeSession({ sessionId: "sess-err", status: "error", errorCount: 1 }),
];
mockTotal.value = 1;
const wrapper = await mountComponent();
const statusCell = wrapper.find(
'[data-test="sessions-list-status-sess-err"]',
);
expect(statusCell.exists()).toBe(true);
expect(statusCell.text()).toContain("error");
// error status uses critical CSS class
expect(statusCell.classes().join(" ")).toContain(
"tw:text-[var(--o2-service-health-critical)]",
);
});
it("token column renders input → output (Σ total) format", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [
makeSession({ inputTokens: 10, outputTokens: 20, tokens: 30 }),
];
mockTotal.value = 1;
const wrapper = await mountComponent();
// Find the tokens cell slot content
const tokensCell = wrapper.find(".tw\\:tabular-nums");
expect(tokensCell.exists()).toBe(true);
const text = tokensCell.text();
expect(text).toContain("→");
expect(text).toContain("Σ");
});
});
describe("SessionsList — row click", () => {
it("emits sessionSelected with the row data when a row is clicked", async () => {
const session = makeSession({ sessionId: "sess-click" });
mockHasLoadedOnce.value = true;
mockSessions.value = [session];
mockTotal.value = 1;
const wrapper = await mountComponent();
const row = wrapper.find(".table-row");
expect(row.exists()).toBe(true);
await row.trigger("click");
const emitted = wrapper.emitted("sessionSelected");
expect(emitted).toBeTruthy();
expect(emitted![0][0]).toMatchObject({ sessionId: "sess-click" });
});
});
+559
View File
@@ -0,0 +1,559 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="sessions-list tw:h-full! tw:flex tw:flex-col tw:bg-[var(--o2-card-bg-solid)] card-container tw:px-[0.625rem]"
>
<!-- Toolbar: stream selector + count pill + pagination -->
<div class="tw:flex tw:items-center tw:gap-2 tw:py-[0.625rem]">
<!-- Stream selector -->
<div
data-test="sessions-list-stream-selector"
class="tw:w-[14rem] tw:flex-shrink-0"
>
<q-select
v-model="activeStream"
:options="
availableStreams.length > 0
? availableStreams.map((s) => ({ label: s, value: s }))
: []
"
dense
borderless
emit-value
map-options
class="tw:w-[auto] tw:flex-shrink-0 tw:rounded"
@update:model-value="onStreamChange"
:disable="availableStreams.length === 0"
>
<q-tooltip v-if="availableStreams.length === 0">
{{ t('traces.sessionsList.noStreamsTooltip') }}
</q-tooltip>
</q-select>
</div>
<!-- Count pill -->
<template v-if="!loading && sessions.length > 0">
<div
class="tw:flex tw:items-center tw:gap-[0.375rem] tw:px-[0.625rem] tw:py-[0.25rem] tw:rounded tw:text-[0.75rem] tw:text-[var(--o2-text-4)] tw:bg-[var(--o2-tag-grey-1)]"
data-test="sessions-list-count-pill"
>
{{ t('traces.sessionsList.countPill', { count: sessions.length, total, unit: total === 1 ? t('traces.sessionsList.session') : t('traces.sessionsList.sessions') }) }}
</div>
</template>
<!-- Pagination controls -->
<div
v-if="total > 0"
class="row items-center tw:justify-end tw:px-[0.5rem] tw:py-[0.25rem] tw:ml-auto"
data-test="sessions-list-pagination-bar"
>
<q-select
v-model="rowsPerPage"
:options="rowsPerPageOptions"
class="select-pagination tw:mr-[0.25rem] tw:mt-0!"
size="sm"
dense
borderless
data-test="sessions-list-records-per-page"
@update:model-value="changeRowsPerPage"
/>
<q-pagination
v-model="currentPage"
:max="totalPages"
:input="false"
direction-links
:boundary-numbers="false"
:max-pages="5"
:ellipses="false"
icon-first="skip_previous"
icon-last="skip_next"
icon-prev="fast_rewind"
icon-next="fast_forward"
class="float-right paginator-section tw:mt-0!"
data-test="sessions-list-pagination"
@update:model-value="changePage"
/>
</div>
</div>
<!-- No LLM streams in this org -->
<div
v-if="streamsLoaded && availableStreams.length === 0"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:flex-1 tw:text-[var(--o2-text-secondary)] tw:text-center"
>
<q-icon name="forum" size="3rem" class="tw:mb-3 tw:opacity-40" />
<div class="tw:text-base tw:text-[var(--o2-text-primary)] tw:mb-2">
{{ t('traces.sessionsList.noStreamsFound') }}
</div>
<p class="tw:text-sm tw:max-w-[30rem]">
{{ t('traces.sessionsList.noStreamsDescription1') }} <code>gen_ai_conversation_id</code> {{ t('traces.sessionsList.noStreamsDescription2') }}
</p>
</div>
<!-- Generic error -->
<div
v-else-if="error && hasLoadedOnce"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:flex-1 tw:text-center"
>
<q-icon
name="error_outline"
size="3rem"
class="tw:mb-3 tw:text-[var(--o2-status-error-text)]"
/>
<div class="tw:text-base tw:text-[var(--o2-text-primary)] tw:mb-2">
{{ t('traces.sessionsList.failedToLoad') }}
</div>
<div
class="tw:text-sm tw:text-[var(--o2-text-muted)] tw:mb-3 tw:max-w-[30rem]"
>
{{ error }}
</div>
<OButton variant="outline" size="sm" @click="loadSessions()">
{{ t('traces.sessionsList.retry') }}
</OButton>
</div>
<!-- Empty state — query succeeded but no sessions in this window -->
<div
v-else-if="hasLoadedOnce && !loading && sessions.length === 0"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:flex-1 tw:text-center"
>
<q-icon
name="forum"
size="3rem"
class="tw:mb-3 tw:opacity-40 tw:text-[var(--o2-text-muted)]"
/>
<div class="tw:text-base tw:text-[var(--o2-text-primary)] tw:mb-2">
{{ t('traces.sessionsList.noSessionsFound') }}
</div>
<p
class="tw:text-sm tw:text-[var(--o2-text-muted)] tw:max-w-[30rem]"
>
{{ t('traces.sessionsList.noSessionsDescription', { stream: activeStream }) }}
</p>
</div>
<!-- Table -->
<div
v-else
class="tw:w-full tw:h-auto! tw:overflow-x-auto tw:relative tw:flex-1"
>
<TenstackTable
class="tw:h-auto!"
:rows="sessions"
:columns="tableColumns"
:loading="loading"
:row-height="32"
:enable-column-reorder="true"
:enable-row-expand="false"
:enable-text-highlight="false"
:enable-status-bar="false"
:default-columns="false"
data-test="sessions-list-table"
@click:dataRow="handleRowClick"
>
<!-- Timestamp -->
<template #cell-firstSeenNanos="{ item }">
<span class="tw:font-mono tw:text-[0.75rem]">
{{ formatTimestamp(item.firstSeenNanos) }}
</span>
</template>
<!-- Session ID -->
<template #cell-sessionId="{ item }">
<span class="tw:font-mono tw:text-[0.75rem]">
{{ shortId(item.sessionId) }}
<q-tooltip>{{ item.sessionId }}</q-tooltip>
</span>
</template>
<!-- User -->
<template #cell-userId="{ item }">
<span
v-if="item.userId"
class="tw:text-[0.75rem] tw:text-[var(--o2-text-primary)] tw:truncate tw:max-w-[160px] tw:block"
>
{{ item.userId }}
</span>
<span v-else class="tw:text-[0.75rem] tw:text-[var(--o2-text-muted)]">
{{ t('traces.sessionsList.unknownUser') }}
</span>
</template>
<!-- First user message -->
<template #cell-firstUserMessage="{ item }">
<span
v-if="item.firstUserMessage"
class="tw:text-[0.75rem] tw:text-[var(--o2-text-secondary)]"
>
{{ item.firstUserMessage.length > 30 ? item.firstUserMessage.slice(0, 30) + '…' : item.firstUserMessage }}
<q-tooltip v-if="item.firstUserMessage.length > 30">{{ item.firstUserMessage }}</q-tooltip>
</span>
<span v-else class="tw:text-[0.75rem] tw:text-[var(--o2-text-muted)]">—</span>
</template>
<!-- Turns -->
<template #cell-turns="{ item }">
<span class="tw:text-[0.75rem]">{{ item.turns }}</span>
</template>
<!-- Duration -->
<template #cell-durationNanos="{ item }">
<span class="tw:text-[0.75rem]">
{{ formatDuration(item.durationNanos) }}
<q-tooltip>{{ item.durationNanos.toLocaleString() }} {{ t('traces.sessionsList.durationNs') }}</q-tooltip>
</span>
</template>
<!-- Tokens -->
<template #cell-tokens="{ item }">
<span class="tw:text-[0.75rem] tw:tabular-nums">
{{ formatTokens(item.inputTokens) }} → {{ formatTokens(item.outputTokens) }} (Σ {{ formatTokens(item.tokens) }})
<q-tooltip>{{ t('traces.sessionsList.tokenTooltip', { input: item.inputTokens.toLocaleString(), output: item.outputTokens.toLocaleString(), total: item.tokens.toLocaleString() }) }}</q-tooltip>
</span>
</template>
<!-- Cost -->
<template #cell-cost="{ item }">
<span class="tw:text-[0.75rem]">${{ item.cost.toFixed(4) }}</span>
</template>
<!-- Status (derived from error_count) -->
<template #cell-status="{ item }">
<span
class="tw:rounded tw:px-[0.5rem] tw:py-[0.125rem] tw:inline-flex tw:items-center tw:gap-[0.25rem] tw:w-fit tw:text-[0.7rem] tw:font-semibold tw:capitalize"
:class="statusBadgeClass(item.status)"
:data-test="`sessions-list-status-${item.sessionId}`"
>
<span
class="tw:w-[6px] tw:h-[6px] tw:rounded-full"
:class="statusDotClass(item.status)"
/>
{{ item.status }}
</span>
</template>
<!-- Initial loading -->
<template #loading>
<div
data-test="sessions-list-loading"
class="row no-wrap items-center q-px-sm tw:min-w-max tw:min-h-[3.25rem] tw:bg-[var(--o2-card-bg)] tw:border-b tw:border-[var(--o2-border-2)]!"
>
<q-spinner-hourglass
color="primary"
size="1.25rem"
class="tw:mr-[0.25rem]"
/>
<span
class="tw:tracking-[0.03rem] tw:text-[0.85rem] tw:text-[var(--o2-text-1)] tw:font-bold"
>
{{ t('traces.sessionsList.loading') }}
</span>
</div>
</template>
<template #empty />
</TenstackTable>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { date } from "quasar";
import { useI18n } from "vue-i18n";
import TenstackTable from "@/components/TenstackTable.vue";
import useStreams from "@/composables/useStreams";
import { useSessions, type SessionRow } from "./composables/useSessions";
import OButton from "@/lib/core/Button/OButton.vue";
import {
splitNumberWithUnit,
splitDuration,
} from "./llmInsightsDashboard.utils";
interface Props {
streamName: string;
startTime: number; // microseconds
endTime: number; // microseconds
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "sessionSelected", session: SessionRow): void;
}>();
const STREAM_LS_KEY = "sessionsList_streamFilter";
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { getStreams } = useStreams();
const {
sessions,
total,
loading,
error,
hasLoadedOnce,
fetchPage,
cancelAll,
} = useSessions();
const availableStreams = ref<string[]>([]);
const streamsLoaded = ref(false);
const activeStream = ref<string>(
localStorage.getItem(STREAM_LS_KEY) || props.streamName || "",
);
// Server-side pagination state (1-indexed for q-pagination).
const currentPage = ref(1);
const rowsPerPage = ref(25);
const rowsPerPageOptions = [10, 25, 50, 100];
const totalPages = computed(() =>
total.value && rowsPerPage.value
? Math.max(1, Math.ceil(total.value / rowsPerPage.value))
: 1,
);
watch(totalPages, (n) => {
// Clamp the page when the total shrinks (e.g. after a re-fetch with
// fewer matches than before).
if (currentPage.value > n) currentPage.value = n;
});
const tableColumns = computed(() => [
{
id: "firstSeenNanos",
header: t('traces.sessionsList.columns.timestamp'),
accessorKey: "firstSeenNanos",
size: 170,
enableSorting: false,
meta: { slot: true, align: "left" },
},
{
id: "sessionId",
header: t('traces.sessionsList.columns.sessionId'),
accessorKey: "sessionId",
size: 160,
enableSorting: false,
meta: { slot: true, align: "left" },
},
{
id: "userId",
header: t('traces.sessionsList.columns.user'),
accessorKey: "userId",
size: 180,
enableSorting: false,
meta: { slot: true, align: "left" },
},
{
id: "firstUserMessage",
header: t('traces.sessionsList.columns.firstMessage'),
accessorKey: "firstUserMessage",
size: 200,
enableSorting: false,
meta: { slot: true, align: "left" },
},
{
id: "turns",
header: t('traces.sessionsList.columns.turns'),
accessorKey: "turns",
size: 90,
enableSorting: false,
meta: { slot: true, align: "center" },
},
{
id: "durationNanos",
header: t('traces.sessionsList.columns.duration'),
accessorKey: "durationNanos",
size: 120,
enableSorting: false,
meta: { slot: true, align: "center" },
},
{
id: "tokens",
header: t('traces.sessionsList.columns.tokens'),
accessorKey: "tokens",
size: 250,
enableSorting: false,
meta: { slot: true, align: "center" },
},
{
id: "cost",
header: t('traces.sessionsList.columns.cost'),
accessorKey: "cost",
size: 100,
enableSorting: false,
meta: { slot: true, align: "center" },
},
{
id: "status",
header: t('traces.sessionsList.columns.status'),
accessorKey: "status",
size: 100,
enableSorting: false,
meta: { slot: true, align: "center", disableCellAction: true },
},
]);
function formatTimestamp(nanos: number): string {
if (!nanos) return "—";
// Backend ships timestamps as nanoseconds — quasar's date wants ms.
return date.formatDate(Math.floor(nanos / 1_000_000), "YYYY-MM-DD HH:mm:ss");
}
function shortId(id: string): string {
if (!id) return "—";
if (id.length <= 16) return id;
return `${id.slice(0, 8)}…${id.slice(-5)}`;
}
function formatDuration(nanos: number): string {
if (!nanos) return "—";
// splitDuration expects microseconds.
const d = splitDuration(nanos / 1000);
return `${d.value}${d.unit}`;
}
function formatTokens(n: number): string {
if (!n) return "0";
const t = splitNumberWithUnit(n);
return `${t.value}${t.unit}`;
}
function statusBadgeClass(s: SessionRow["status"]): string {
switch (s) {
case "error":
return "tw:bg-[color-mix(in_srgb,var(--o2-service-health-critical)_12%,transparent)] tw:text-[var(--o2-service-health-critical)]";
default:
return "tw:bg-[color-mix(in_srgb,var(--o2-service-health-healthy,#16a34a)_12%,transparent)] tw:text-[var(--o2-service-health-healthy,#16a34a)]";
}
}
function statusDotClass(s: SessionRow["status"]): string {
switch (s) {
case "error":
return "tw:bg-[var(--o2-service-health-critical)]";
default:
return "tw:bg-emerald-500";
}
}
async function loadTraceStreams() {
streamsLoaded.value = false;
try {
const res = await getStreams("traces", false, false);
const list = res?.list || [];
const llmStreams = list.filter(
(stream: any) => stream?.settings?.is_llm_stream !== false,
);
availableStreams.value = llmStreams.map((stream: any) => stream.name);
if (!availableStreams.value.includes(activeStream.value)) {
activeStream.value = availableStreams.value[0] || "";
}
} catch (e) {
console.error("Error loading trace streams:", e);
availableStreams.value = [];
activeStream.value = "";
} finally {
streamsLoaded.value = true;
}
}
async function loadSessions(startTime?: number, endTime?: number) {
const start = startTime ?? props.startTime;
const end = endTime ?? props.endTime;
if (!activeStream.value || !start || !end) return;
localStorage.setItem(STREAM_LS_KEY, activeStream.value);
await fetchPage(
activeStream.value,
start,
end,
currentPage.value - 1,
rowsPerPage.value,
);
}
function onStreamChange() {
currentPage.value = 1;
loadSessions();
}
function changePage(page: number) {
currentPage.value = page;
loadSessions();
}
function changeRowsPerPage(val: number) {
rowsPerPage.value = val;
currentPage.value = 1;
loadSessions();
}
function handleRowClick(row: SessionRow) {
emit("sessionSelected", row);
router.push({
name: "sessionDetails",
query: {
stream: activeStream.value,
session_id: row.sessionId,
from: props.startTime,
to: props.endTime,
org_identifier: store.state.selectedOrganization?.identifier,
user_id: row.userId || undefined,
},
});
}
async function refresh(startTime?: number, endTime?: number) {
currentPage.value = 1;
await loadSessions(startTime, endTime);
}
defineExpose({ refresh });
onMounted(async () => {
if (!streamsLoaded.value) {
await loadTraceStreams();
}
if (activeStream.value) {
loadSessions();
}
});
onUnmounted(() => {
cancelAll();
});
</script>
<style lang="scss" scoped>
.sessions-list {
// Match ServicesCatalog: card surface, scoped paginator + selector
// styles so we inherit the global table theme from TenstackTable.
:deep(.paginator-section .q-btn) {
min-width: 1.5rem;
min-height: 1.5rem;
}
:deep(.select-pagination .q-field__control) {
min-height: 1.75rem;
}
}
</style>
@@ -378,8 +378,8 @@ describe("useLLMInsights — sparklines accumulation", () => {
expect(sparklines.value.tokens).toEqual([1234]);
expect(sparklines.value.traces).toEqual([5]);
expect(sparklines.value.p95Micros).toEqual([800]);
// error rate = 1/10 = 10%
expect(sparklines.value.errorRate).toEqual([10]);
// error rate = error_count / trace_count = 1/5 = 20%
expect(sparklines.value.errorRate).toEqual([20]);
});
// Multiple rows → series in grid order, same length across all metrics.
@@ -399,8 +399,8 @@ describe("useLLMInsights — sparklines accumulation", () => {
content: {
results: {
hits: [
{ ts: "1970-01-01T00:00:00", request_count: 10, error_count: 2, total_tokens: 100, total_cost: 1, p95_duration: 1, trace_count: 1 },
{ ts: "1970-01-01T00:00:10", request_count: 20, error_count: 0, total_tokens: 200, total_cost: 2, p95_duration: 2, trace_count: 2 },
{ ts: "1970-01-01T00:00:00", request_count: 10, error_count: 2, total_tokens: 100, total_cost: 1, p95_duration: 1, trace_count: 10 },
{ ts: "1970-01-01T00:00:10", request_count: 20, error_count: 0, total_tokens: 200, total_cost: 2, p95_duration: 2, trace_count: 20 },
],
},
},
@@ -410,7 +410,8 @@ describe("useLLMInsights — sparklines accumulation", () => {
await promise;
expect(sparklines.value.tokens).toEqual([100, 200]);
expect(sparklines.value.cost).toEqual([1, 2]);
expect(sparklines.value.errorRate).toEqual([20, 0]); // 2/10*100, 0/20*100
// error rate = error_count / trace_count: 2/10*100=20, 0/20*100=0
expect(sparklines.value.errorRate).toEqual([20, 0]);
});
// Defensive: a bucket with zero requests must not divide-by-zero
@@ -428,7 +429,8 @@ describe("useLLMInsights — sparklines accumulation", () => {
content: {
results: {
hits: [
{ ts: "1970-01-01T00:00:00", request_count: 0, error_count: 5, total_tokens: 0, total_cost: 0, p95_duration: 0, trace_count: 0 },
// trace_count=0 → no traces in window → errorRate must be 0 (not NaN/Infinity)
{ ts: "1970-01-01T00:00:00", request_count: 0, error_count: 0, total_tokens: 0, total_cost: 0, p95_duration: 0, trace_count: 0 },
],
},
},
@@ -440,6 +442,93 @@ describe("useLLMInsights — sparklines accumulation", () => {
});
});
// ---------------------------------------------------------------------------
// Error rate — regression tests for the > 100% bug
// ---------------------------------------------------------------------------
describe("useLLMInsights — error rate is always [0%, 100%]", () => {
// Regression: previously error_count = COUNT(*) FILTER (WHERE span_status='ERROR')
// counted ALL spans (including non-LLM tool/infra spans), while request_count
// only counted LLM spans. On real workloads this produced values like 104350%.
// Fix: error_count = approx_distinct(trace_id) FILTER (WHERE span_status='ERROR'),
// denominator = approx_distinct(trace_id).
// Since error_count ≤ trace_count by definition, rate is always ≤ 100%.
it("sparkline errorRate never exceeds 100 even when error_count equals trace_count", async () => {
const { fetchAll, sparklines } = useLLMInsights();
const promise = fetchAll("default", 100, 200);
const cbs = mockFetchQueryDataWithHttpStream.mock.calls.map(([, c]: any) => c);
cbs[0].complete();
cbs[1].complete();
cbs[2].data(null, {
content: {
results: {
hits: [
// All traces errored → 100%
{ ts: "1970-01-01T00:00:00", request_count: 5, trace_count: 10, error_count: 10, total_tokens: 0, total_cost: 0, p95_duration: 0 },
],
},
},
});
cbs[2].complete();
await promise;
expect(sparklines.value.errorRate[0]).toBe(100);
});
it("sparkline errorRate is 0 when there are no error traces", async () => {
const { fetchAll, sparklines } = useLLMInsights();
const promise = fetchAll("default", 100, 200);
const cbs = mockFetchQueryDataWithHttpStream.mock.calls.map(([, c]: any) => c);
cbs[0].complete();
cbs[1].complete();
cbs[2].data(null, {
content: {
results: {
hits: [
{ ts: "1970-01-01T00:00:00", request_count: 50, trace_count: 50, error_count: 0, total_tokens: 0, total_cost: 0, p95_duration: 0 },
],
},
},
});
cbs[2].complete();
await promise;
expect(sparklines.value.errorRate[0]).toBe(0);
});
it("KPI SQL uses approx_distinct(trace_id) FILTER for error_count (not COUNT(*))", () => {
const { fetchAll } = useLLMInsights();
fetchAll("default", 100, 200);
const kpiCall = mockFetchQueryDataWithHttpStream.mock.calls.find(([p]: any) =>
p.queryReq.query.sql.includes("approx_percentile_cont(duration, 0.95)"),
);
const sql: string = (kpiCall as any)[0].queryReq.query.sql;
// Must NOT use COUNT(*) FILTER for errors — that counted all spans and
// produced error rates of thousands of percent on real environments.
expect(sql).not.toMatch(/COUNT\(\*\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
// Must use trace-level distinct count instead.
expect(sql).toMatch(/approx_distinct\(trace_id\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
});
it("sparkline SQL uses approx_distinct(trace_id) FILTER for error_count", () => {
const { fetchAll } = useLLMInsights();
fetchAll("default", 100, 200);
const sparkCall = mockFetchQueryDataWithHttpStream.mock.calls.find(([p]: any) =>
p.queryReq.query.sql.includes("GROUP BY ts"),
);
const sql: string = (sparkCall as any)[0].queryReq.query.sql;
expect(sql).not.toMatch(/COUNT\(\*\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
expect(sql).toMatch(/approx_distinct\(trace_id\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
});
});
// ---------------------------------------------------------------------------
// cancelAll
// ---------------------------------------------------------------------------
@@ -529,8 +618,10 @@ describe("useLLMInsights — SQL surface", () => {
expect(sql).toContain("gen_ai_usage_total_tokens");
expect(sql).toContain("gen_ai_usage_cost");
expect(sql).toContain("approx_distinct(trace_id)");
// Errors deliberately drop the gen_ai filter — see fetchKPIInto comment.
expect(sql).toMatch(/COUNT\(\*\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
// Error count is trace-level: approx_distinct(trace_id) FILTER (WHERE span_status = 'ERROR').
// Using COUNT(*) FILTER on all spans while dividing by LLM-only request_count
// produced values >> 100% (e.g. 104350%) on real workloads.
expect(sql).toMatch(/approx_distinct\(trace_id\)\s+FILTER\s*\(WHERE\s+span_status\s*=\s*'ERROR'\)/);
});
// Sparkline query bins by `histogram(_timestamp, ...)` and groups+orders
@@ -207,15 +207,16 @@ export function useLLMInsights() {
// column that doesn't exist on the stream's schema fails the query — the
// safe path forward is to use the standard fields exclusively.
//
// Errors are counted across all spans (not just those tagged with
// `gen_ai_operation_name`) because OTel SDKs typically propagate the
// failure to a deeper child span (e.g. `tool.tools_call`) that doesn't
// itself carry the gen_ai operation attribute.
// Error rate = error traces / total traces, always [0%, 100%].
// OTel SDKs propagate errors to child spans that don't carry
// gen_ai_operation_name, so filtering errors to only LLM spans yields 0.
// Counting error spans vs LLM-only spans yields values >> 100%.
// Trace-level counting fixes both: a trace either errored or it didn't.
const sql = `
SELECT
COUNT(*) FILTER (WHERE gen_ai_operation_name IS NOT NULL) as request_count,
approx_distinct(trace_id) as trace_count,
COUNT(*) FILTER (WHERE span_status = 'ERROR') as error_count,
approx_distinct(trace_id) FILTER (WHERE span_status = 'ERROR') as error_count,
COALESCE(SUM(gen_ai_usage_total_tokens), 0) as total_tokens,
COALESCE(SUM(gen_ai_usage_cost), 0) as total_cost,
COALESCE(AVG(duration), 0) as avg_duration,
@@ -352,7 +353,7 @@ export function useLLMInsights() {
histogram(_timestamp, '${interval}') as ts,
COUNT(*) FILTER (WHERE gen_ai_operation_name IS NOT NULL) as request_count,
approx_distinct(trace_id) as trace_count,
COUNT(*) FILTER (WHERE span_status = 'ERROR') as error_count,
approx_distinct(trace_id) FILTER (WHERE span_status = 'ERROR') as error_count,
COALESCE(SUM(gen_ai_usage_total_tokens), 0) as total_tokens,
COALESCE(SUM(gen_ai_usage_cost), 0) as total_cost,
COALESCE(approx_percentile_cont(duration, 0.95), 0) as p95_duration
@@ -383,14 +384,14 @@ export function useLLMInsights() {
for (const row of hits) {
const idx = bucketIndex.get(String(row.ts));
if (idx === undefined) continue;
const requestCount = Number(row.request_count) || 0;
const traceCount = Number(row.trace_count) || 0;
const errorCount = Number(row.error_count) || 0;
series.tokens[idx] = Number(row.total_tokens) || 0;
series.traces[idx] = Number(row.trace_count) || 0;
series.traces[idx] = traceCount;
series.p95Micros[idx] = Number(row.p95_duration) || 0;
series.cost[idx] = Number(row.total_cost) || 0;
series.errorRate[idx] =
requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
traceCount > 0 ? (errorCount / traceCount) * 100 : 0;
}
},
size,
@@ -106,16 +106,20 @@ export function useLLMStreamQuery() {
},
error: (response: any, _traceId: any) => {
activeTraceIds.delete(traceId);
// Streaming endpoint passes the parsed error body as the first
// argument (e.g. { status, code, message, error_detail }).
// useStreamingSearch wraps the server error body as
// `{ content: { ...errorBody, trace_id }, type: "error" }`.
// Unwrap so the real fields (message / error_detail / status)
// are visible without forcing every caller to know the shape.
const body = response?.content ?? response ?? {};
const message =
body.message ||
body.error ||
body.error_detail ||
response?.message ||
response?.error ||
response?.error_detail ||
"Failed to fetch query data";
const err: any = new Error(message);
err.status = response?.status;
err.code = response?.code;
err.status = body.status ?? response?.status;
err.code = body.code ?? response?.code;
err.raw = response;
reject(err);
},
@@ -0,0 +1,592 @@
// Copyright 2026 OpenObserve Inc.
//
// @vitest-environment jsdom
// ---------------------------------------------------------------------------
// vi.mock() calls MUST be hoisted above imports.
// ---------------------------------------------------------------------------
const mockSessionsList = vi.fn();
const mockExecuteQuery = vi.fn();
const mockCancelAll = vi.fn();
const mockFetchQueryDataWithHttpStream = vi.fn();
const mockCancelStreamQueryBasedOnRequestId = vi.fn();
const mockStoreState = {
selectedOrganization: { identifier: "test-org" },
zoConfig: { sql_base64_enabled: false },
};
vi.mock("@/services/sessions", () => ({
default: {
list: (...args: any[]) => mockSessionsList(...args),
},
}));
vi.mock("./useLLMStreamQuery", () => ({
useLLMStreamQuery: vi.fn(() => ({
executeQuery: mockExecuteQuery,
cancelAll: mockCancelAll,
})),
}));
vi.mock("@/composables/useStreamingSearch", () => ({
default: vi.fn(() => ({
fetchQueryDataWithHttpStream: mockFetchQueryDataWithHttpStream,
cancelStreamQueryBasedOnRequestId: mockCancelStreamQueryBasedOnRequestId,
})),
}));
vi.mock("vuex", () => ({
useStore: vi.fn(() => ({ state: mockStoreState })),
}));
vi.mock("@/utils/zincutils", () => ({
generateTraceContext: vi.fn(() => ({ traceId: "trace-fixed-id" })),
b64EncodeUnicode: vi.fn((s: string) => `b64(${s})`),
}));
// ---------------------------------------------------------------------------
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useSessions } from "./useSessions";
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// fetchPage
// ---------------------------------------------------------------------------
describe("useSessions — fetchPage: loading state", () => {
it("sets loading=true during fetch and false after", async () => {
let capturedLoading = false;
mockSessionsList.mockImplementation(() => {
capturedLoading = true; // we can't observe mid-promise, so check after
return Promise.resolve({ data: { hits: [], total: 0 } });
});
const { loading, fetchPage } = useSessions();
const p = fetchPage("stream", 1000, 2000, 0, 25);
// loading becomes true synchronously after the call starts
expect(loading.value).toBe(true);
await p;
expect(loading.value).toBe(false);
});
});
describe("useSessions — fetchPage: field mapping", () => {
it("maps all API response fields to SessionRow correctly", async () => {
const hit = {
session_id: "sess-abc",
start_time: "1700000000000000000",
end_time: "1700001000000000000",
duration: "1000000000",
trace_count: "5",
gen_ai_usage_input_tokens: "100",
gen_ai_usage_output_tokens: "200",
gen_ai_usage_total_tokens: "300",
gen_ai_usage_cost: "0.0042",
error_count: "0",
};
mockSessionsList.mockResolvedValue({ data: { hits: [hit], total: 1 } });
const { sessions, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(sessions.value).toHaveLength(1);
const row = sessions.value[0];
expect(row.sessionId).toBe("sess-abc");
expect(row.firstSeenNanos).toBe(1700000000000000000);
expect(row.durationNanos).toBe(1000000000);
expect(row.turns).toBe(5);
expect(row.inputTokens).toBe(100);
expect(row.outputTokens).toBe(200);
expect(row.tokens).toBe(300);
expect(row.cost).toBeCloseTo(0.0042);
expect(row.errorCount).toBe(0);
});
it("status='error' when error_count > 0", async () => {
const hit = {
session_id: "s1",
start_time: 1000,
end_time: 2000,
duration: 1000,
trace_count: 1,
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
error_count: 3,
};
mockSessionsList.mockResolvedValue({ data: { hits: [hit], total: 1 } });
const { sessions, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(sessions.value[0].status).toBe("error");
expect(sessions.value[0].errorCount).toBe(3);
});
it("status='ok' when error_count is 0", async () => {
const hit = {
session_id: "s2",
start_time: 1000,
end_time: 2000,
duration: 1000,
trace_count: 1,
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
error_count: 0,
};
mockSessionsList.mockResolvedValue({ data: { hits: [hit], total: 1 } });
const { sessions, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(sessions.value[0].status).toBe("ok");
});
it("sets total from body.total", async () => {
mockSessionsList.mockResolvedValue({ data: { hits: [], total: 42 } });
const { total, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(total.value).toBe(42);
});
it("sets hasLoadedOnce=true after successful fetch", async () => {
mockSessionsList.mockResolvedValue({ data: { hits: [], total: 0 } });
const { hasLoadedOnce, fetchPage } = useSessions();
expect(hasLoadedOnce.value).toBe(false);
await fetchPage("stream", 1000, 2000, 0, 25);
expect(hasLoadedOnce.value).toBe(true);
});
});
describe("useSessions — fetchPage: error handling", () => {
it("sets error ref on API failure with server message", async () => {
mockSessionsList.mockRejectedValue({
response: { data: { message: "server error msg" } },
});
const { error, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(error.value).toBe("server error msg");
});
it("falls back to error field when message is missing", async () => {
mockSessionsList.mockRejectedValue({
response: { data: { error: "some error" } },
});
const { error, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(error.value).toBe("some error");
});
it("falls back to e.message when response data is empty", async () => {
mockSessionsList.mockRejectedValue(new Error("network failure"));
const { error, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(error.value).toBe("network failure");
});
it("loading is false after error", async () => {
mockSessionsList.mockRejectedValue(new Error("fail"));
const { loading, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 25);
expect(loading.value).toBe(false);
});
});
describe("useSessions — fetchPage: early return guards", () => {
it("does nothing when streamName is empty", async () => {
const { fetchPage } = useSessions();
await fetchPage("", 1000, 2000, 0, 25);
expect(mockSessionsList).not.toHaveBeenCalled();
});
it("does nothing when startTime is 0/falsy", async () => {
const { fetchPage } = useSessions();
await fetchPage("stream", 0, 2000, 0, 25);
expect(mockSessionsList).not.toHaveBeenCalled();
});
it("does nothing when endTime is 0/falsy", async () => {
const { fetchPage } = useSessions();
await fetchPage("stream", 1000, 0, 0, 25);
expect(mockSessionsList).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// fetchSession
// ---------------------------------------------------------------------------
describe("useSessions — fetchSession: early return guards", () => {
it("returns { detail: null, traces: [] } when streamName is missing", async () => {
const { fetchSession } = useSessions();
const result = await fetchSession("", "session-id", 1000, 2000);
expect(result).toEqual({ detail: null, traces: [] });
expect(mockFetchQueryDataWithHttpStream).not.toHaveBeenCalled();
});
it("returns { detail: null, traces: [] } when sessionId is missing", async () => {
const { fetchSession } = useSessions();
const result = await fetchSession("stream", "", 1000, 2000);
expect(result).toEqual({ detail: null, traces: [] });
});
it("returns { detail: null, traces: [] } when startTime is 0", async () => {
const { fetchSession } = useSessions();
const result = await fetchSession("stream", "session-id", 0, 2000);
expect(result).toEqual({ detail: null, traces: [] });
});
it("returns { detail: null, traces: [] } when endTime is 0", async () => {
const { fetchSession } = useSessions();
const result = await fetchSession("stream", "session-id", 1000, 0);
expect(result).toEqual({ detail: null, traces: [] });
});
});
describe("useSessions — fetchSession: empty API response", () => {
it("returns { detail: null, traces: [] } when API returns no hits", async () => {
// Simulate streamLatestTraces resolving with empty array
mockFetchQueryDataWithHttpStream.mockImplementation(
(_payload: any, callbacks: any) => {
callbacks.complete();
},
);
const { fetchSession } = useSessions();
const result = await fetchSession("stream", "sess-1", 1000, 2000);
expect(result).toEqual({ detail: null, traces: [] });
});
});
describe("useSessions — fetchSession: trace row mapping", () => {
const makeStreamHit = (overrides: Record<string, any> = {}) => ({
trace_id: "trace-abc",
start_time: "1000000000",
end_time: "2000000000",
duration: "1000000",
spans: [10, 0],
service_name: [{ service_name: "my-service", count: 1, duration: 500 }],
gen_ai_usage_input_tokens: "50",
gen_ai_usage_output_tokens: "100",
gen_ai_usage_total_tokens: "150",
gen_ai_usage_cost: "0.001",
gen_ai_response_model: "gpt-4",
gen_ai_request_model: null,
...overrides,
});
function setupStreamMock(hits: any[]) {
mockFetchQueryDataWithHttpStream.mockImplementation(
(_payload: any, callbacks: any) => {
if (hits.length > 0) {
callbacks.data(null, {
content: { results: { hits } },
});
}
callbacks.complete();
},
);
}
it("maps trace_id → traceId", async () => {
setupStreamMock([makeStreamHit()]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].traceId).toBe("trace-abc");
});
it("maps start_time → startTimeMicros (stored as nanos)", async () => {
setupStreamMock([makeStreamHit({ start_time: "5000000000" })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].startTimeMicros).toBe(5000000000);
});
it("maps gen_ai_usage_input_tokens → inputTokens", async () => {
setupStreamMock([makeStreamHit({ gen_ai_usage_input_tokens: "75" })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].inputTokens).toBe(75);
});
it("maps gen_ai_usage_output_tokens → outputTokens", async () => {
setupStreamMock([makeStreamHit({ gen_ai_usage_output_tokens: "125" })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].outputTokens).toBe(125);
});
it("maps gen_ai_usage_total_tokens → tokens", async () => {
setupStreamMock([makeStreamHit({ gen_ai_usage_total_tokens: "200" })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].tokens).toBe(200);
});
it("maps gen_ai_usage_cost → cost", async () => {
setupStreamMock([makeStreamHit({ gen_ai_usage_cost: "0.0099" })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].cost).toBeCloseTo(0.0099);
});
it("status='error' when spans[1] (error_count) > 0", async () => {
setupStreamMock([makeStreamHit({ spans: [5, 2] })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].status).toBe("error");
expect(traces[0].errorCount).toBe(2);
});
it("status='ok' when spans[1] is 0", async () => {
setupStreamMock([makeStreamHit({ spans: [5, 0] })]);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].status).toBe("ok");
expect(traces[0].errorCount).toBe(0);
});
});
describe("useSessions — fetchSession: sorting", () => {
it("sorts traces chronologically by start_time (oldest first)", async () => {
const hits = [
{
trace_id: "newer",
start_time: "3000000000",
end_time: "4000000000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
{
trace_id: "older",
start_time: "1000000000",
end_time: "2000000000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
];
mockFetchQueryDataWithHttpStream.mockImplementation(
(_payload: any, callbacks: any) => {
callbacks.data(null, { content: { results: { hits } } });
callbacks.complete();
},
);
const { fetchSession } = useSessions();
const { traces } = await fetchSession("stream", "sess-1", 1000, 2000);
expect(traces[0].traceId).toBe("older");
expect(traces[1].traceId).toBe("newer");
});
});
describe("useSessions — fetchSession: SessionDetail derivation", () => {
function setupStreamMock(hits: any[]) {
mockFetchQueryDataWithHttpStream.mockImplementation(
(_payload: any, callbacks: any) => {
callbacks.data(null, { content: { results: { hits } } });
callbacks.complete();
},
);
}
it("sums inputTokens, outputTokens, tokens, cost across traces", async () => {
const hits = [
{
trace_id: "t1",
start_time: "1000",
end_time: "2000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 10,
gen_ai_usage_output_tokens: 20,
gen_ai_usage_total_tokens: 30,
gen_ai_usage_cost: 0.001,
},
{
trace_id: "t2",
start_time: "2000",
end_time: "3000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 5,
gen_ai_usage_output_tokens: 15,
gen_ai_usage_total_tokens: 20,
gen_ai_usage_cost: 0.002,
},
];
setupStreamMock(hits);
const { fetchSession } = useSessions();
const { detail } = await fetchSession("stream", "sess-1", 1000, 5000);
expect(detail).not.toBeNull();
expect(detail!.inputTokens).toBe(15);
expect(detail!.outputTokens).toBe(35);
expect(detail!.tokens).toBe(50);
expect(detail!.cost).toBeCloseTo(0.003);
});
it("detail status='error' when any trace has errorCount > 0", async () => {
const hits = [
{
trace_id: "t1",
start_time: "1000",
end_time: "2000",
spans: [1, 2], // error
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
{
trace_id: "t2",
start_time: "2000",
end_time: "3000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
];
setupStreamMock(hits);
const { fetchSession } = useSessions();
const { detail } = await fetchSession("stream", "sess-1", 1000, 5000);
expect(detail!.status).toBe("error");
expect(detail!.errorCount).toBe(2);
});
it("detail status='ok' when no traces have errors", async () => {
const hits = [
{
trace_id: "t1",
start_time: "1000",
end_time: "2000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
];
setupStreamMock(hits);
const { fetchSession } = useSessions();
const { detail } = await fetchSession("stream", "sess-1", 1000, 5000);
expect(detail!.status).toBe("ok");
});
it("turns count equals number of trace rows", async () => {
const hits = [
{
trace_id: "t1",
start_time: "1000",
end_time: "2000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
{
trace_id: "t2",
start_time: "2000",
end_time: "3000",
spans: [1, 0],
service_name: [],
gen_ai_usage_input_tokens: 0,
gen_ai_usage_output_tokens: 0,
gen_ai_usage_total_tokens: 0,
gen_ai_usage_cost: 0,
},
];
setupStreamMock(hits);
const { fetchSession } = useSessions();
const { detail } = await fetchSession("stream", "sess-1", 1000, 5000);
expect(detail!.turns).toBe(2);
});
});
// ---------------------------------------------------------------------------
// cancelAll
// ---------------------------------------------------------------------------
describe("useSessions — cancelAll", () => {
it("calls the underlying useLLMStreamQuery cancelAll", () => {
const { cancelAll } = useSessions();
cancelAll();
expect(mockCancelAll).toHaveBeenCalledTimes(1);
});
it("cancels active latest-stream fetches by traceId", async () => {
// Start a session fetch that never completes so traceId stays active
mockFetchQueryDataWithHttpStream.mockImplementation(() => {
// Never calls complete or error — stays in flight
});
const { fetchSession, cancelAll } = useSessions();
// Fire and forget (don't await)
fetchSession("stream", "sess-1", 1000, 2000);
cancelAll();
expect(mockCancelStreamQueryBasedOnRequestId).toHaveBeenCalledWith({
trace_id: "trace-fixed-id",
org_id: "test-org",
});
});
it("does not cancel already-completed fetches", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation(
(_payload: any, callbacks: any) => {
callbacks.complete();
},
);
const { fetchSession, cancelAll } = useSessions();
await fetchSession("stream", "sess-1", 1000, 2000);
mockCancelStreamQueryBasedOnRequestId.mockClear();
cancelAll();
expect(mockCancelStreamQueryBasedOnRequestId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,561 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ref } from "vue";
import { useStore } from "vuex";
import sessionsService from "@/services/sessions";
import useHttpStreaming from "@/composables/useStreamingSearch";
import { generateTraceContext } from "@/utils/zincutils";
import { useLLMStreamQuery } from "./useLLMStreamQuery";
export interface SessionDetail {
sessionId: string;
userId: string | null;
serviceName: string | null;
firstSeenMicros: number;
durationNanos: number;
turns: number;
inputTokens: number;
outputTokens: number;
tokens: number;
cost: number;
errorCount: number;
status: "ok" | "error";
}
export interface SessionTraceRow {
traceId: string;
startTimeMicros: number;
durationNanos: number;
spanCount: number;
inputTokens: number;
outputTokens: number;
tokens: number;
cost: number;
errorCount: number;
status: "ok" | "error";
/** Primary model (first entry in the trace's models array). */
model: string | null;
/** All models used across spans in this trace. */
models: string[];
}
/** Single message inside a turn (USER block / ASSISTANT block). */
export interface TurnMessage {
role: "user" | "assistant" | "system" | "tool";
content: string;
}
/** Full per-turn payload, lazy-loaded when a turn row is expanded. */
export interface TurnDetail {
traceId: string;
/** First user-role message in the turn — drives the USER block. */
userMessage: TurnMessage | null;
/** Last assistant-role message in the turn — drives the ASSISTANT block. */
assistantMessage: TurnMessage | null;
model: string | null;
/** Number of LLM call spans (chat / text_completion / generate_content / embeddings). */
llmCalls: number;
/** Number of tool execution spans (execute_tool). */
toolCalls: number;
/** gen_ai spans with an unrecognised operation name — neither LLM nor execute_tool. */
otherCalls: number;
/** Distinct operation names that make up otherCalls — used in the tooltip. */
otherOps: string[];
}
/**
* One row in the sessions list. Mirrors the
* `GET /api/{org}/{stream}/traces/session` response shape, normalised
* to the camelCase + unit-suffixed convention used elsewhere here.
*
* Backend returns timestamps + duration in NANOSECONDS — we keep the
* nanos values on the row and the UI formats from there. (Same shape
* span data uses elsewhere in OO.)
*/
export interface SessionRow {
sessionId: string;
/** Earliest span start_time in the session (nanoseconds). */
firstSeenNanos: number;
/** Latest span end_time in the session (nanoseconds). */
lastSeenNanos: number;
/** Number of distinct trace_ids in the session = conversation turns. */
turns: number;
/** Session duration in nanoseconds (end_time - start_time). */
durationNanos: number;
inputTokens: number;
outputTokens: number;
tokens: number;
cost: number;
errorCount: number;
/** Derived from error_count: any error span → "error", else "ok". */
status: "ok" | "error";
/** First user from the session's users array (may be empty string). */
userId: string;
/** First user message in the session (truncated server-side). */
firstUserMessage: string;
}
/**
* Composable owning the Sessions tab's list state and fetch flow.
*
* One session = one value of `gen_ai_conversation_id`. Each session
* may span many traces (turns) and many spans. We aggregate at the
* session level on the server (GROUP BY gen_ai_conversation_id) and
* the dashboard renders one row per session.
*
* State is per-mount, mirroring `useLLMInsights`.
*
* @example
* const { sessions, total, loading, error, fetchPage, cancelAll }
* = useSessions();
* await fetchPage("llm-stream", 1700000000000000, 1700001000000000, 0, 25);
*/
export function useSessions() {
const store = useStore();
// `fetchTurnDetail` still uses the raw SQL streaming search to grab
// the messages of a single trace when its row is expanded — the
// session-list endpoint and the trace `latest_stream` endpoint
// don't expose final assistant text or per-span output.
const { executeQuery, cancelAll } = useLLMStreamQuery();
// The trace `latest_stream` endpoint (server-side aggregation per
// trace, LLM-aware fields) drives the per-turn list inside a
// session. Lifted out of `useLLMStreamQuery` because the URL +
// payload shape differs (GET with query params, not a POST SQL).
const { fetchQueryDataWithHttpStream, cancelStreamQueryBasedOnRequestId } =
useHttpStreaming();
const activeLatestStreamTraceIds = new Set<string>();
const sessions = ref<SessionRow[]>([]);
const total = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
const hasLoadedOnce = ref(false);
/**
* Fetch one page of sessions from the backend's dedicated sessions
* endpoint (`GET /api/{org}/{stream}/traces/session`). The endpoint
* runs the GROUP BY + per-trace gen_ai usage rollup server-side, so
* the frontend doesn't construct any SQL.
*
* `page` is zero-indexed.
*
* Note: the response does not include user_id / service_name /
* span_status, so derived fields like "status pill" or "user
* avatar" are not available in the list view. Detail page still
* uses `fetchSession` (SQL path) for those.
*/
async function fetchPage(
streamName: string,
startTime: number,
endTime: number,
page: number,
pageSize: number,
): Promise<void> {
if (!streamName || !startTime || !endTime) return;
loading.value = true;
error.value = null;
try {
const orgId = store.state.selectedOrganization?.identifier || "default";
const res = await sessionsService.list({
orgId,
streamName,
startTime,
endTime,
page,
pageSize,
});
const body = res.data;
sessions.value = (body.hits || []).map((h) => {
const errorCount = Number(h.error_count) || 0;
const usersArr: string[] = Array.isArray(h.user_ids) ? h.user_ids : [];
return {
sessionId: h.session_id,
firstSeenNanos: Number(h.start_time) || 0,
lastSeenNanos: Number(h.end_time) || 0,
durationNanos: Number(h.duration) || 0,
turns: Number(h.trace_count) || 0,
inputTokens: Number(h.gen_ai_usage_input_tokens) || 0,
outputTokens: Number(h.gen_ai_usage_output_tokens) || 0,
tokens: Number(h.gen_ai_usage_total_tokens) || 0,
cost: Number(h.gen_ai_usage_cost) || 0,
errorCount,
status: errorCount > 0 ? "error" : "ok",
userId: usersArr[0] || "",
firstUserMessage: String(h.first_user_message || ""),
};
});
total.value = Number(body.total) || 0;
hasLoadedOnce.value = true;
} catch (e: any) {
// axios error shape — surface the server's message if present.
const serverMsg =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Failed to fetch sessions";
error.value = serverMsg;
console.error("Sessions fetch error:", e?.response?.data ?? e);
} finally {
loading.value = false;
}
}
/**
* Fetch the per-turn trace list for a single session and derive the
* session-level rollup client-side.
*
* Single SQL call (GROUP BY trace_id). The session header KPIs —
* turns, duration, tokens, cost, error count — are reductions over
* the trace list, so issuing a second GROUP BY session_id query
* would just duplicate work the server already did. `user_id` and
* `service_name` are picked up per-trace and consolidated to the
* first non-null value (they're effectively constant within a
* session).
*/
async function fetchSession(
streamName: string,
sessionId: string,
startTime: number,
endTime: number,
): Promise<{ detail: SessionDetail | null; traces: SessionTraceRow[] }> {
if (!streamName || !sessionId || !startTime || !endTime) {
return { detail: null, traces: [] };
}
// Escape single quotes so a malformed session id can't break the
// server's filter-to-WHERE expansion.
const safeId = sessionId.replace(/'/g, "''");
const filter = `gen_ai_conversation_id='${safeId}'`;
// Hit the existing per-stream traces endpoint instead of running
// a one-off GROUP BY ourselves. Server-side it's the same shape
// OpenObserve already uses for the Traces tab, and on LLM
// streams it adds gen_ai_usage_* totals + the first chat span's
// input messages — exactly what the session-detail page needs.
const accumulated: any[] = await streamLatestTraces(
streamName,
filter,
startTime,
endTime,
);
if (accumulated.length === 0) {
return { detail: null, traces: [] };
}
// Sort chronologically so the conversation list reads in turn
// order. The endpoint returns DESC by default to surface "most
// recent" first; for a session we want oldest-first.
accumulated.sort(
(a, b) => (Number(a.start_time) || 0) - (Number(b.start_time) || 0),
);
// Map each trace summary to SessionTraceRow.
//
// Field-name + unit notes from the live response:
// start_time / end_time → nanoseconds (UInt64 on the span)
// duration → microseconds (server returns ns/1000)
// spans → [total_spans, error_spans] tuple
// service_name → [{ service_name, count, duration }]
// gen_ai_usage_* → cumulative across all spans
// gen_ai_input_messages → FIRST_VALUE — the user question
//
// `startTimeMicros` / `durationNanos` on the row type are
// misnomers — both formatters in SessionDetails.vue divide by
// 1_000_000 / 1_000 respectively, i.e. they expect nanoseconds.
// We keep the unit convention (nanos) so the existing template
// keeps formatting correctly without touching the UI layer.
const traces: SessionTraceRow[] = accumulated.map((r: any) => {
const spansArr = Array.isArray(r.spans) ? r.spans : [];
const spanCount = Number(spansArr[0]) || 0;
const errorCount = Number(spansArr[1]) || 0;
const svcArr = Array.isArray(r.service_name) ? r.service_name : [];
const modelsArr: string[] = Array.isArray(r.models)
? r.models.map(String).filter(Boolean)
: [];
const startNanos = Number(r.start_time) || 0;
const endNanos = Number(r.end_time) || 0;
return {
traceId: String(r.trace_id ?? ""),
startTimeMicros: startNanos,
// Prefer the computed (end - start) so we stay in nanos —
// the server's `duration` is already divided to micros and
// would 1000× under-render through formatDuration.
durationNanos: endNanos > startNanos ? endNanos - startNanos : 0,
spanCount,
inputTokens: Number(r.gen_ai_usage_input_tokens) || 0,
outputTokens: Number(r.gen_ai_usage_output_tokens) || 0,
tokens: Number(r.gen_ai_usage_total_tokens) || 0,
cost: Number(r.gen_ai_usage_cost) || 0,
errorCount,
status: errorCount > 0 ? "error" : "ok",
model: modelsArr[0] ?? null,
models: modelsArr,
serviceName: svcArr[0]?.service_name
? String(svcArr[0].service_name)
: null,
} as SessionTraceRow & { serviceName: string | null };
});
// Roll the per-trace rows up into the session-level detail.
let firstSeenNanos = Infinity;
let lastSeenNanos = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalTokens = 0;
let totalCost = 0;
let totalErrors = 0;
let serviceName: string | null = null;
for (let i = 0; i < accumulated.length; i++) {
const r = accumulated[i];
const t = traces[i] as SessionTraceRow & { serviceName?: string | null };
const sn = Number(r.start_time) || 0;
const en = Number(r.end_time) || 0;
if (sn && sn < firstSeenNanos) firstSeenNanos = sn;
if (en > lastSeenNanos) lastSeenNanos = en;
totalInputTokens += t.inputTokens;
totalOutputTokens += t.outputTokens;
totalTokens += t.tokens;
totalCost += t.cost;
totalErrors += t.errorCount;
if (!serviceName && t.serviceName) serviceName = t.serviceName;
}
if (firstSeenNanos === Infinity) firstSeenNanos = 0;
const detail: SessionDetail = {
sessionId,
userId: null,
serviceName,
firstSeenMicros: firstSeenNanos,
durationNanos:
lastSeenNanos > firstSeenNanos ? lastSeenNanos - firstSeenNanos : 0,
turns: traces.length,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
tokens: totalTokens,
cost: totalCost,
errorCount: totalErrors,
status: totalErrors > 0 ? "error" : "ok",
};
return { detail, traces };
}
/**
* Drive the `/api/{org}/{stream}/traces/latest_stream` HTTP/2
* streaming GET via the shared streaming-search composable. Accumulates
* hits across all SSE chunks and resolves once the server signals
* `complete`. Tracks the trace_id so `cancelAll` can abort an
* in-flight session-detail fetch on tab switch / unmount.
*/
function streamLatestTraces(
streamName: string,
filter: string,
startTime: number,
endTime: number,
): Promise<any[]> {
return new Promise((resolve, reject) => {
const traceId = generateTraceContext().traceId;
activeLatestStreamTraceIds.add(traceId);
const hits: any[] = [];
fetchQueryDataWithHttpStream(
{
queryReq: {
stream_name: streamName,
filter,
start_time: startTime,
end_time: endTime,
from: 0,
size: 1000,
},
type: "traces",
traceId,
org_id: store.state.selectedOrganization?.identifier,
},
{
data: (_payload: any, response: any) => {
const chunkHits: any[] = response.content?.results?.hits || [];
if (chunkHits.length > 0) hits.push(...chunkHits);
},
error: (response: any) => {
activeLatestStreamTraceIds.delete(traceId);
const body = response?.content ?? response ?? {};
const message =
body.message ||
body.error ||
body.error_detail ||
"Failed to fetch session traces";
const err: any = new Error(message);
err.status = body.status;
err.raw = response;
reject(err);
},
complete: () => {
activeLatestStreamTraceIds.delete(traceId);
resolve(hits);
},
reset: () => {},
},
);
});
}
/**
* Fetch the message payload for a single turn (one trace). Used by
* the SessionDetails accordion when a turn row is expanded.
*
* Pulls only the LLM-turn spans for that trace (the ones carrying
* `gen_ai.input.messages` / `gen_ai.output.messages`), then picks
* the first user message and last assistant message from the
* normalised list — same convention used by `ThreadView`.
*/
async function fetchTurnDetail(
streamName: string,
traceId: string,
startTime: number,
endTime: number,
): Promise<TurnDetail> {
if (!streamName || !traceId || !startTime || !endTime) {
return { traceId, userMessage: null, assistantMessage: null, model: null, llmCalls: 0, toolCalls: 0, otherCalls: 0, otherOps: [] };
}
const safeId = traceId.replace(/'/g, "''");
// Fetch all gen_ai spans for this trace in one query. The
// gen_ai_input_messages IS NOT NULL filter was previously applied here
// but that meant a second parallel COUNT query was needed for LLM/tool
// call badge counts. Removing the filter lets us compute the counts
// client-side from the same result set, halving the number of API calls.
const sql = `
SELECT
gen_ai_input_messages,
gen_ai_output_messages,
gen_ai_response_model,
gen_ai_request_model,
gen_ai_operation_name,
start_time
FROM "${streamName}"
WHERE trace_id = '${safeId}'
AND gen_ai_operation_name IS NOT NULL
ORDER BY start_time ASC
LIMIT 50
`;
const LLM_OPS = new Set(["chat", "text_completion", "generate_content", "embeddings"]);
const hits = await executeQuery(sql, startTime, endTime);
let llmCalls = 0;
let toolCalls = 0;
let otherCalls = 0;
const otherOpsSet = new Set<string>();
for (const row of hits || []) {
const op = String(row.gen_ai_operation_name || "").toLowerCase();
if (LLM_OPS.has(op)) llmCalls += 1;
else if (op === "execute_tool") toolCalls += 1;
else { otherCalls += 1; otherOpsSet.add(op); }
}
const otherOps = [...otherOpsSet].sort();
// Lazy-import the message parser so this composable stays light
// when only the list view is in use.
const { messagesFromInput, messagesFromOutput, getModel } = await import(
"../threadView.utils"
);
let userMessage: TurnMessage | null = null;
let assistantMessage: TurnMessage | null = null;
let model: string | null = null;
// First pass — forward — pick model and the user question for THIS
// turn. `gen_ai.input.messages` carries the FULL prompt sent to
// the model (system + every prior turn + the current question),
// so the *last* user-role entry of the *first* span is the
// current turn's question, not turn 1's.
for (const span of hits || []) {
if (!model) {
const m = getModel(span);
if (m) model = m;
}
if (!userMessage) {
const inputMsgs = messagesFromInput(span.gen_ai_input_messages);
let u: any = null;
for (let i = inputMsgs.length - 1; i >= 0; i--) {
if (inputMsgs[i].role === "user" && inputMsgs[i].content) {
u = inputMsgs[i];
break;
}
}
if (u) userMessage = { role: "user", content: u.content };
}
if (userMessage && model) break;
}
// Second pass — reverse — pick the FINAL assistant reply for this
// turn. An agent may make multiple LLM calls (LLM → tool → LLM
// → tool → … → final answer). The first chat span's output is
// usually an intermediate "I should call tool X" reply; the user
// wants the answer at the end, not the planning step. Walking
// from the latest span back gives us the final non-empty
// assistant message.
for (let s = (hits || []).length - 1; s >= 0; s--) {
const span = hits[s];
const outputMsgs = messagesFromOutput(span.gen_ai_output_messages);
let a: any = null;
for (let i = outputMsgs.length - 1; i >= 0; i--) {
if (outputMsgs[i].role === "assistant" && outputMsgs[i].content) {
a = outputMsgs[i];
break;
}
}
if (a) {
assistantMessage = { role: "assistant", content: a.content };
break;
}
}
return { traceId, userMessage, assistantMessage, model, llmCalls, toolCalls, otherCalls, otherOps };
}
/**
* Cancel every in-flight stream this composable owns: SQL streams
* from `useLLMStreamQuery` AND any `latest_stream` GET requests we
* started for the session-detail page. Called from the parent on
* unmount so server-side work isn't kept around after the tab goes
* away.
*/
function cancelAllSessionStreams() {
cancelAll();
activeLatestStreamTraceIds.forEach((id) => {
cancelStreamQueryBasedOnRequestId({
trace_id: id,
org_id: store.state.selectedOrganization?.identifier,
});
});
activeLatestStreamTraceIds.clear();
}
return {
sessions,
total,
loading,
error,
hasLoadedOnce,
fetchPage,
fetchSession,
fetchTurnDetail,
cancelAll: cancelAllSessionStreams,
};
}
@@ -170,14 +170,28 @@ describe("splitNumberWithUnit", () => {
describe("splitDuration", () => {
it.each([
// ms tier
[0, { value: "0", unit: "ms" }],
[123_000, { value: "123", unit: "ms" }], // 123ms
[1_000_000, { value: "1.0", unit: "s" }], // 1s
[12_345_000, { value: "12.3", unit: "s" }], // 12.3s
[120_000_000, { value: "2.0", unit: "min" }], // 2min
[3_600_000_000, { value: "60.0", unit: "min" }], // 60min
[123_000, { value: "123", unit: "ms" }], // 123 ms
// s tier
[1_000_000, { value: "1.0", unit: "s" }], // 1 s
[12_345_000, { value: "12.3", unit: "s" }], // 12.3 s
// min tier
[120_000_000, { value: "2.0", unit: "min" }], // 2 min
[3_599_000_000, { value: "59.98", unit: "min" }], // ~60 min (just under 1 h)
// h tier (>= 3 600 000 ms = 3_600_000_000 µs)
[3_600_000_000, { value: "1.0", unit: "h" }], // exactly 1 h
[7_200_000_000, { value: "2.0", unit: "h" }], // 2 h
[12_600_000_000, { value: "3.5", unit: "h" }], // 3.5 h
// d tier (>= 86 400 000 ms = 86_400_000_000 µs)
[86_400_000_000, { value: "1.0", unit: "d" }], // exactly 1 d
[172_800_000_000, { value: "2.0", unit: "d" }], // 2 d
[7 * 86_400_000_000, { value: "7.0", unit: "d" }], // 1 week
])("splits %d microseconds → %j", (micros, expected) => {
expect(splitDuration(micros)).toEqual(expected);
// For the ~60 min case the exact decimal depends on rounding — just check unit.
const result = splitDuration(micros);
expect(result.unit).toBe(expected.unit);
if (expected.value !== "59.98") expect(result.value).toBe(expected.value);
});
// Defensive: zero / falsy input → "0 ms" (not just "0").
@@ -112,15 +112,19 @@ export function splitNumberWithUnit(n: number): {
* Split a duration in microseconds into `{ value, unit }`.
* < 1s → ms (rounded)
* < 1m → s (1 decimal)
* else → min (1 decimal)
* < 1h → min (1 decimal)
* < 1d → h (1 decimal)
* else → d (1 decimal)
*
* Falsy / zero input → `{ "0", "ms" }` so the card renders as "0 ms"
* instead of "0 (no unit)".
*
* @example splitDuration(0) // { value: "0", unit: "ms" }
* @example splitDuration(123_000) // { value: "123", unit: "ms" }
* @example splitDuration(2_500_000) // { value: "2.5", unit: "s" }
* @example splitDuration(120_000_000) // { value: "2.0", unit: "min" }
* @example splitDuration(0) // { value: "0", unit: "ms" }
* @example splitDuration(123_000) // { value: "123", unit: "ms" }
* @example splitDuration(2_500_000) // { value: "2.5", unit: "s" }
* @example splitDuration(120_000_000) // { value: "2.0", unit: "min" }
* @example splitDuration(7_200_000_000) // { value: "2.0", unit: "h" }
* @example splitDuration(172_800_000_000) // { value: "2.0", unit: "d" }
*/
export function splitDuration(micros: number): {
value: string;
@@ -130,7 +134,9 @@ export function splitDuration(micros: number): {
const ms = micros / 1000;
if (ms < 1000) return { value: Math.round(ms).toString(), unit: "ms" };
if (ms < 60_000) return { value: (ms / 1000).toFixed(1), unit: "s" };
return { value: (ms / 60_000).toFixed(1), unit: "min" };
if (ms < 3_600_000) return { value: (ms / 60_000).toFixed(1), unit: "min" };
if (ms < 86_400_000) return { value: (ms / 3_600_000).toFixed(1), unit: "h" };
return { value: (ms / 86_400_000).toFixed(1), unit: "d" };
}
/**
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "./http";
/** Single session row returned by the backend's session endpoint. */
export interface SessionApiHit {
session_id: string;
/** Earliest span `start_time` in the session (nanoseconds). */
start_time: number;
/** Latest span `end_time` in the session (nanoseconds). */
end_time: number;
/** end_time - start_time (nanoseconds). */
duration: number;
/** Distinct trace_ids in the session = number of conversation turns. */
trace_count: number;
gen_ai_usage_input_tokens: number;
gen_ai_usage_output_tokens: number;
gen_ai_usage_total_tokens: number;
gen_ai_usage_cost: number;
/** Number of error spans across all traces in this session. */
error_count: number;
}
export interface SessionApiResponse {
took: number;
total: number;
from: number;
size: number;
hits: SessionApiHit[];
trace_id?: string;
function_error?: string;
}
const sessions = {
/**
* Fetch the paginated session list from
* `GET /api/{org_id}/{stream_name}/traces/session`.
*
* Server expects microsecond timestamps for `start_time`/`end_time` and
* does the GROUP BY + per-session aggregation in two phases (session →
* trace_id list, then per-trace gen_ai usage rollup) so the frontend
* doesn't need to build the SQL itself.
*
* @example
* await sessions.list({
* orgId: "default",
* streamName: "default",
* startTime: 1700000000000000,
* endTime: 1700001000000000,
* page: 0,
* pageSize: 25,
* });
*/
list: ({
orgId,
streamName,
startTime,
endTime,
page = 0,
pageSize = 25,
filter = "",
timeout,
}: {
orgId: string;
streamName: string;
startTime: number;
endTime: number;
page?: number;
pageSize?: number;
filter?: string;
timeout?: number;
}) => {
const params = new URLSearchParams({
from: String(page * pageSize),
size: String(pageSize),
start_time: String(startTime),
end_time: String(endTime),
});
if (filter) params.set("filter", filter);
if (timeout) params.set("timeout", String(timeout));
const url = `/api/${orgId}/${encodeURIComponent(
streamName,
)}/traces/session?${params.toString()}`;
return http().get<SessionApiResponse>(url);
},
};
export default sessions;
+1 -1
View File
@@ -8,7 +8,7 @@ import { type Span, type EnrichedSpan, type SpanStatus } from './span.types';
/**
* Search mode for the traces explorer
*/
export type TraceSearchMode = "traces" | "spans" | "llm-insights" | "service-graph" | "services-catalog";
export type TraceSearchMode = "traces" | "spans" | "llm-insights" | "service-graph" | "services-catalog" | "sessions";
/**
* Trace - Collection of spans representing a single request