mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
sai nikhil kethe
github-actions[bot]
parent
015f8778a4
commit
f736ec844c
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アクションをテスト",
|
||||
|
||||
@@ -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": "보류 중인 초대",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "待处理的邀请",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user