fix: Agent Behavior signals (loop/failure taxonomy) + Agent Graph fixes (#13279)

## What

Adds **Agent Behavior signals** — a PB-safe rollup that surfaces two
behavioural questions the platform couldn't answer before: *is an agent
stuck in a loop?* and *why is it failing?* Plus a batch of Agent Graph /
AI Observability UX and correctness fixes found while building it.

Companion changes land in **o2-enterprise**
(`feat/agent-signals-enterprise-split`): the SQL builders, mappers,
config-driven failure taxonomy, the service-graph agent-classification
fix, and the RBAC route entry.

## Backend (agent-signals)
- Bounded-key rollup co-located in the service-graph job
(alert-manager-only, reuses its watermark + self-ingest). Groups only on
agent/tool/class — never trace_id — so it stays PB-safe.
- Config-driven **failure taxonomy** (rules as data, evolvable without
code) + config-driven error-detail columns; ancestor-agent resolution so
error spans attribute to the owning agent.
- Read API `GET /{org}/traces/agent_signals` (dual `#[cfg]`, reads the
derived `_agent_signals` stream) + RBAC route entry (enterprise
`route_permissions`) mapping it to `traces` read.
- OSS/enterprise split respected: pure SQL/mapping in enterprise, thin
gated I/O in OSS.
- Fix: `traces/latest` only narrows to a UUIDv7 trace_id's embedded time
when that time is inside the caller's window (fixes "trace not found"
for time-shifted/re-ingested traces).

## Frontend (AI Observability)
- New **Agent Behavior** page under Monitor: looping-agents +
failure-taxonomy tables, each row opening an actionable detail drawer
(real grouped error messages / worst-repeat traces), click-through to
Trace Details in a new tab.
- Agent-node **Behavior tab** on the Agent Graph node drawer (reuses the
same signals).
- Nav rename: LLM Insights → **Insights**, LLM Sessions → **Sessions**.
- **Agent** is the default scope across the AI pages, with the
Agent|Stream toggle placed consistently (left scope bar); explicit
URL/localStorage choices still win.
- Removed the duplicate in-table refresh on Sessions.

## Agent Graph fixes
- **Agent classification**: agents that also call tools now correctly
classify as `service_type: "agent"` (they glow), fixing a latent
precedence bug where the tool/model client-role overrode the agent role
and `is_real_service` forced agents to plain services. Rust
`classify_entity` brought in line with the (already-correct) TS twin.
- **Drag-to-pan restored**: the shared ChartRenderer armed
`dataZoomSelect` cursor unconditionally, hijacking drag away from roam
pan. Disabled for this graph (no dataZoom).
- **Nodes scale on zoom** (`nodeScaleRatio: 1`) so zooming enlarges
nodes/labels, not just spacing.

## Testing
- Enterprise unit tests: agent-signals SQL/mappers/config,
classification precedence, RBAC route resolution — all pass.
- FE: type-check + lint clean.
- Verified end-to-end on real re-ingested introspection data: taxonomy
unclassified rate ~100% → ~1–3%, agents resolved, both agents
classify/glow, trace-open works.

## Notes
- `Cargo.toml`/`Cargo.lock` and unrelated Slack-bot files were
intentionally excluded from the commits (local build-config drift).

---------

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
This commit is contained in:
Ashish Kolhe
2026-07-21 10:45:05 +00:00
committed by GitHub
co-authored by ktx-vaidehi ktx-vaidehi
parent 36518e49c1
commit 9ede45bbf7
34 changed files with 2707 additions and 113 deletions
+7
View File
@@ -229,6 +229,13 @@ considering the UI done:
- [ ] Zero `px` values (except a `1px` hairline) — including inside class
arbitrary values (`w-[320px]`, `text-[13px]`). Sizes use rem / % / vh / vw
or Tailwind's rem scale.
- [ ] **Spacing copies a sibling, never invented per page** — padding/margins/
gaps and card surface classes are taken verbatim from the sibling panel
family the screen joins (`card-container` alone styles nothing — cards
need explicit `bg-* border-* rounded-*` classes). A page of stacked
panels uses the full-height split (`flex-1 min-h-0` cards + fill-height
tables) so the page itself never scrolls — see
[conventions § Cards & stacked-panel pages](references/conventions.md).
- [ ] Corner radius is `rounded-default` / `rounded-surface` / `rounded-full`
only — no bare `rounded`, no `rounded-[..]`, no retired
`rounded-{sm,md,lg,xl}`.
@@ -21,6 +21,29 @@ search · refresh · column-toggle toolbar, and the empty-state contract are in
[§ Page anatomy & the listing-page recipe](#page-anatomy--the-listing-page-recipe)
and [references/page-recipes.md](page-recipes.md).
### Cards & stacked-panel pages — consistency over invention
- **Never invent spacing.** Padding/margins/gaps are not a per-page decision:
a new panel copies the **exact** spacing classes of the sibling family it
joins (same header inset, same `gap-*`, same card classes), so the pages read
as one surface. If your panel "needed" a padding value no sibling uses,
that's the smell — go read the sibling, don't tune by eye.
- **`card-container` styles nothing.** It's a bare marker class with no CSS
behind it — a card that relies on it alone renders as floating text (no
background, no border). Every card must carry explicit surface classes, e.g.
`bg-card-glass-bg rounded-default border border-border-default` (the AI
Observability panel family) or `bg-surface-base border border-border-default
rounded-default` (synthetics). Copy the class string from the sibling family
verbatim.
- **A fixed set of stacked panels must not make the page scroll.** When a page
is N cards tall (dashboards, signal panels), give it the full-height split:
body `flex flex-col` (no `overflow-auto`), each card
`flex flex-col flex-1 min-h-0 overflow-hidden`, and each `OTable` in
fill-height mode with `class="flex-1 min-h-0"`. Rows scroll **inside** the
table; the pagination bar stays pinned to the card; the page itself never
scrolls. Empty states fill the card without forcing overflow (OTableEmpty's
`min-h-75` floor applies only to non-fill-height tables).
### Dialog Spacing (`ODialog`)
- Title comes from the `:title` prop — don't render a manual `<h*>` title.
@@ -235,6 +235,10 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
- **Pagination** (`pagination`: `"client"` | `"server"` | `"none"`, default `"client"`)
- `pageSize` (number, default `20`), `pageSizeOptions` (number[], default `[20, 50, 100, 250, 500]`)
- ⚠️ A custom `pageSize` **must be a member of `pageSizeOptions`** — otherwise
the "Records per page" select renders with nothing selected (e.g.
`:page-size="10"` against the default options). Prefer the default `20`;
only override `pageSize` together with a matching `pageSizeOptions`.
- `currentPage` (number, 1-based) — `v-model:currentPage` for server mode
- `totalCount` (number) — required for server-side pagination
- `keepPageOnDataChange` (boolean, default false) — don't reset page when `data` changes
@@ -269,6 +273,10 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
- `dense` (default `true`), `bordered` (default `true`), `frame` (default `false` — outer frame border), `striped` (default false)
- `stickyHeader` (default `true`), `showHeader` (default `true`), `wrap` (default false), `horizontalScroll` (natural-width cells + horizontal scroll; pair with `wrap=false`)
- `fillHeight` (default `true` — set false to shrink to content), `virtualScroll` (default false, `virtualScrollItemSize` default `48`), `maxHeight`, `width`
- Empty state: fill-height tables let the empty state fill the available
space; non-fill-height tables reserve a `min-h-75` floor so the empty
state has presence. In a height-constrained card, keep `fillHeight`
(default) so the empty state never forces a scrollbar.
- Row styling: `rowClass`, `getRowStyle`, `getRowStatusColor` (4px left status bar), `getCellStyle`, `enableCellCopy`
- Highlight: `highlightText`, `highlightFields`
+2
View File
@@ -973,6 +973,8 @@ pub fn service_routes() -> Router {
// Topology
.route("/{org_id}/traces/service_graph/topology/current", get(traces::get_current_topology))
.route("/{org_id}/traces/service_graph/edge/history", get(traces::get_edge_history))
// Agent behavior signals (loop / failure / cost) — reads the derived _agent_signals stream
.route("/{org_id}/traces/agent_signals", get(traces::get_agent_signals))
// Patterns
.route("/{org_id}/streams/{stream_name}/patterns/extract", post(patterns::extract_patterns))
+15 -1
View File
@@ -36,6 +36,8 @@ use tracing::{Instrument, Span};
#[cfg(feature = "cloud")]
use crate::service::ingestion::check_ingestion_allowed;
// Re-export agent-signals read API handler
pub use crate::service::traces::agent_signals::get_agent_signals;
// Re-export service graph API handlers
pub use crate::service::traces::service_graph::{self, get_current_topology, get_edge_history};
use crate::{
@@ -325,7 +327,19 @@ pub async fn get_latest_traces(
0
};
if start_time_from_trace_id > 0 {
// A UUID v7 trace_id encodes its creation time, which lets us narrow the
// scan to a tight [t-60s, t+1h] window instead of the caller's broad range.
// BUT only when that encoded time actually falls inside the window the
// caller asked for. If it doesn't — e.g. spans re-ingested with a
// `_timestamp` shifted away from the trace_id's embedded time — the encoded
// time points at an empty window and the lookup would spuriously return
// "trace not found". In that case we trust the caller's window, which is
// where the data actually is. (On real data the two always agree, so this
// is a safety net, not a behavior change for normal traffic.)
if start_time_from_trace_id > 0
&& start_time_from_trace_id >= start_time
&& start_time_from_trace_id <= end_time
{
start_time = start_time_from_trace_id - 60 * 1_000_000; //60 seconds earlier
end_time = std::cmp::min(now_micros(), start_time_from_trace_id + 3600 * 1_000_000); //1 hour later
}
+373
View File
@@ -0,0 +1,373 @@
// 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/>.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::utils::json;
/// Caps for the failure-taxonomy config. These bound the size of the generated
/// SQL `CASE` and keep org-supplied input from ballooning the query.
const MAX_ERROR_DETAIL_FIELDS: usize = 20;
const MAX_ERROR_DETAIL_FIELD_LEN: usize = 256;
const MAX_FAILURE_RULES: usize = 100;
const MAX_PATTERNS_PER_RULE: usize = 50;
const MAX_CLASS_LEN: usize = 128;
const MAX_PATTERN_LEN: usize = 512;
/// One failure-taxonomy rule: a class name and the SQL LIKE patterns that map to
/// it. Patterns are stored bare — the enterprise SQL builder adds `%` wildcards
/// and escapes at generation time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct FailureRule {
pub class: String,
pub patterns: Vec<String>,
}
/// The agent-signals failure taxonomy: which span columns carry error detail (in
/// priority order) and the ordered, first-match-wins classification rules.
///
/// Resolved per-org as: org override (system_settings) → repo file default →
/// embedded fallback → OSS minimal default. Mirrors [`super::gen_ai`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AgentSignalsTaxonomy {
/// Span columns that may carry error detail, in priority order. COALESCEd into
/// a single string the classifier matches against.
#[serde(default)]
pub error_detail_fields: Vec<String>,
/// Failure-taxonomy rules, evaluated top-to-bottom (first match wins).
#[serde(default)]
pub failure_rules: Vec<FailureRule>,
}
impl AgentSignalsTaxonomy {
/// Trim and validate on save/load. Enforces caps and rejects any `'` or `;`
/// in patterns/classes — these values are compiled into generated SQL `LIKE`
/// clauses, so org-supplied input is the trust boundary here.
pub fn normalize_and_validate(mut self) -> Result<Self, String> {
self.error_detail_fields = self
.error_detail_fields
.into_iter()
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect();
if self.error_detail_fields.len() > MAX_ERROR_DETAIL_FIELDS {
return Err(format!(
"error_detail_fields cannot exceed {MAX_ERROR_DETAIL_FIELDS} fields"
));
}
let mut seen_fields = HashSet::new();
for field in &self.error_detail_fields {
if field.len() > MAX_ERROR_DETAIL_FIELD_LEN {
return Err(format!(
"error_detail_fields field '{field}' cannot exceed {MAX_ERROR_DETAIL_FIELD_LEN} characters"
));
}
if !is_safe_column(field) {
return Err(format!(
"error_detail_fields field '{field}' contains invalid characters"
));
}
if !seen_fields.insert(field.as_str()) {
return Err(format!(
"error_detail_fields contains duplicate field '{field}'"
));
}
}
let mut rules = Vec::with_capacity(self.failure_rules.len());
for rule in self.failure_rules {
let class = rule.class.trim().to_string();
if class.is_empty() {
return Err("failure rule class cannot be empty".to_string());
}
if class.len() > MAX_CLASS_LEN {
return Err(format!(
"failure rule class '{class}' cannot exceed {MAX_CLASS_LEN} characters"
));
}
if contains_sql_meta(&class) {
return Err(format!(
"failure rule class '{class}' must not contain ' or ;"
));
}
let patterns: Vec<String> = rule
.patterns
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
if patterns.is_empty() {
return Err(format!("failure rule '{class}' has no patterns"));
}
if patterns.len() > MAX_PATTERNS_PER_RULE {
return Err(format!(
"failure rule '{class}' cannot exceed {MAX_PATTERNS_PER_RULE} patterns"
));
}
for pat in &patterns {
if pat.len() > MAX_PATTERN_LEN {
return Err(format!(
"failure rule '{class}' pattern cannot exceed {MAX_PATTERN_LEN} characters"
));
}
if contains_sql_meta(pat) {
return Err(format!(
"failure rule '{class}' pattern '{pat}' must not contain ' or ;"
));
}
}
rules.push(FailureRule { class, patterns });
}
if rules.len() > MAX_FAILURE_RULES {
return Err(format!(
"failure_rules cannot exceed {MAX_FAILURE_RULES} rules"
));
}
self.failure_rules = rules;
Ok(self)
}
}
/// A span column name is safe if it looks like an identifier path: starts with a
/// letter or `_`, and contains only alphanumerics, `_`, or `.` after that.
fn is_safe_column(field: &str) -> bool {
let Some(first) = field.chars().next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& field
.chars()
.skip(1)
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
}
/// Reject the two characters that would break out of a generated SQL `LIKE`
/// clause or the CASE statement. Patterns are otherwise free-form substrings
/// (including `%` and spaces), so we do not restrict them to identifiers.
fn contains_sql_meta(s: &str) -> bool {
s.contains('\'') || s.contains(';')
}
/// One agent-signals rollup record. Written to the `_agent_signals` stream.
/// Grouped only on bounded keys (agent, tool, fail_class) — never trace_id.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AgentSignalRecord {
pub timestamp: i64,
pub org_id: String,
pub source_stream: String,
/// "failure" | "loop" | "cost"
pub signal_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fail_class: Option<String>,
pub count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub calls: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub distinct_traces: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p95_latency_ns: Option<u64>,
}
impl AgentSignalRecord {
/// Serialize for ingestion: `timestamp` becomes `_timestamp`, None optionals omitted.
pub fn to_json(&self) -> json::Value {
let mut v = json::to_value(self).unwrap_or(json::Value::Null);
if let Some(obj) = v.as_object_mut()
&& let Some(ts) = obj.remove("timestamp")
{
obj.insert("_timestamp".to_string(), ts);
}
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_record() -> AgentSignalRecord {
AgentSignalRecord {
timestamp: 1_000_000,
org_id: "default".to_string(),
source_stream: "sre_agent_traces".to_string(),
signal_type: "failure".to_string(),
agent_name: Some("sre_rca_agent".to_string()),
tool_name: None,
fail_class: Some("malformed_tool_call".to_string()),
count: 161,
calls: None,
distinct_traces: None,
cost: None,
tokens: None,
errors: None,
p95_latency_ns: None,
}
}
#[test]
fn test_to_json_maps_timestamp_and_required_fields() {
let val = make_record().to_json();
assert_eq!(val["_timestamp"], 1_000_000_i64);
assert_eq!(val["org_id"], "default");
assert_eq!(val["signal_type"], "failure");
assert_eq!(val["fail_class"], "malformed_tool_call");
assert_eq!(val["count"], 161_u64);
}
#[test]
fn test_to_json_omits_none_optionals() {
let val = make_record().to_json();
assert!(val.get("tool_name").is_none());
assert!(val.get("cost").is_none());
}
fn valid_taxonomy() -> AgentSignalsTaxonomy {
AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string(), "error_message".to_string()],
failure_rules: vec![FailureRule {
class: "rate_limited".to_string(),
patterns: vec!["RateLimit".to_string(), "429".to_string()],
}],
}
}
#[test]
fn test_taxonomy_json_round_trip() {
let taxonomy = valid_taxonomy();
let json_str = serde_json::to_string(&taxonomy).unwrap();
let back: AgentSignalsTaxonomy = serde_json::from_str(&json_str).unwrap();
assert_eq!(taxonomy, back);
}
#[test]
fn test_taxonomy_normalizes_and_trims() {
let taxonomy = AgentSignalsTaxonomy {
error_detail_fields: vec![" status_message ".to_string(), "".to_string()],
failure_rules: vec![FailureRule {
class: " rate_limited ".to_string(),
patterns: vec![" 429 ".to_string(), "".to_string()],
}],
}
.normalize_and_validate()
.unwrap();
assert_eq!(taxonomy.error_detail_fields, vec!["status_message"]);
assert_eq!(taxonomy.failure_rules[0].class, "rate_limited");
assert_eq!(taxonomy.failure_rules[0].patterns, vec!["429"]);
}
#[test]
fn test_taxonomy_rejects_single_quote_in_pattern() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string()],
failure_rules: vec![FailureRule {
class: "inject".to_string(),
patterns: vec!["x' OR '1'='1".to_string()],
}],
}
.normalize_and_validate()
.unwrap_err();
assert!(err.contains("' or ;"), "unexpected err: {err}");
}
#[test]
fn test_taxonomy_rejects_semicolon_in_pattern() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string()],
failure_rules: vec![FailureRule {
class: "inject".to_string(),
patterns: vec!["x; DROP TABLE".to_string()],
}],
}
.normalize_and_validate()
.unwrap_err();
assert!(err.contains("' or ;"), "unexpected err: {err}");
}
#[test]
fn test_taxonomy_rejects_unsafe_error_detail_field() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message; DROP".to_string()],
failure_rules: vec![],
}
.normalize_and_validate()
.unwrap_err();
assert!(err.contains("invalid characters"), "unexpected err: {err}");
}
#[test]
fn test_taxonomy_rejects_empty_class() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string()],
failure_rules: vec![FailureRule {
class: " ".to_string(),
patterns: vec!["x".to_string()],
}],
}
.normalize_and_validate()
.unwrap_err();
assert!(
err.contains("class cannot be empty"),
"unexpected err: {err}"
);
}
#[test]
fn test_taxonomy_rejects_rule_with_no_patterns() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string()],
failure_rules: vec![FailureRule {
class: "empty".to_string(),
patterns: vec!["".to_string(), " ".to_string()],
}],
}
.normalize_and_validate()
.unwrap_err();
assert!(err.contains("no patterns"), "unexpected err: {err}");
}
#[test]
fn test_taxonomy_rejects_duplicate_error_detail_field() {
let err = AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string(), "status_message".to_string()],
failure_rules: vec![],
}
.normalize_and_validate()
.unwrap_err();
assert!(err.contains("duplicate"), "unexpected err: {err}");
}
#[test]
fn test_taxonomy_valid_passes() {
assert!(valid_taxonomy().normalize_and_validate().is_ok());
}
}
+1
View File
@@ -14,6 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod actions;
pub mod agent_signals;
pub mod ai;
pub mod alerts;
pub mod cluster;
+2
View File
@@ -141,6 +141,8 @@ pub mod keys {
pub const SEMANTIC_FIELD_GROUPS: &str = "semantic_field_groups";
/// Gen-AI agent name/id fallback mapping
pub const GEN_AI_AGENT_MAPPING: &str = "gen_ai_agent_mapping";
/// Agent-signals failure taxonomy (error-detail fields + classification rules)
pub const AGENT_SIGNALS_TAXONOMY: &str = "agent_signals_taxonomy";
/// UI theme preference
pub const THEME: &str = "theme";
/// Scrape interval for metrics
+75
View File
@@ -473,6 +473,81 @@ pub fn get_default_semantic_field_groups() -> Vec<config::meta::correlation::Fie
}
}
/// Get the agent-signals failure taxonomy for an organization.
///
/// Resolution order (highest wins):
/// 1. Org-level override in system settings
/// 2. Repo file default (enterprise, fetched + cached with embedded fallback)
/// 3. OSS minimal default (`status_message`, no rules → everything `unclassified`)
///
/// An invalid or malformed org override is logged and ignored — we fall through
/// to the default rather than crash or emit unsafe SQL.
pub async fn get_agent_signals_taxonomy(
org_id: &str,
) -> config::meta::agent_signals::AgentSignalsTaxonomy {
use config::meta::{
agent_signals::AgentSignalsTaxonomy, system_settings::keys::AGENT_SIGNALS_TAXONOMY,
};
if let Ok(Some(setting)) = get_resolved(Some(org_id), None, AGENT_SIGNALS_TAXONOMY).await {
match serde_json::from_value::<AgentSignalsTaxonomy>(setting.setting_value) {
Ok(taxonomy) => match taxonomy.normalize_and_validate() {
Ok(taxonomy) => return taxonomy,
Err(e) => {
log::warn!("Ignoring invalid agent-signals taxonomy for org {org_id}: {e}")
}
},
Err(e) => log::warn!("Ignoring malformed agent-signals taxonomy for org {org_id}: {e}"),
}
}
get_default_agent_signals_taxonomy()
}
/// Save the agent-signals failure taxonomy for an organization.
///
/// Validates before persisting — org input is compiled into generated SQL, so an
/// invalid taxonomy is rejected here rather than at query time.
pub async fn set_agent_signals_taxonomy(
org_id: &str,
user_id: &str,
taxonomy: config::meta::agent_signals::AgentSignalsTaxonomy,
) -> Result<SystemSetting> {
use config::meta::system_settings::{SettingCategory, keys::AGENT_SIGNALS_TAXONOMY};
let taxonomy = taxonomy.normalize_and_validate().map_err(|e| {
infra::errors::Error::Message(format!("invalid agent-signals taxonomy: {e}"))
})?;
let value = serde_json::to_value(taxonomy)?;
let mut setting = SystemSetting::new_org(org_id, AGENT_SIGNALS_TAXONOMY, value)
.with_category(SettingCategory::Correlation)
.with_description("Agent-signals failure taxonomy (error-detail fields + rules)");
setting.updated_by = Some(user_id.to_string());
set(&setting).await
}
/// Get the default agent-signals taxonomy.
///
/// For enterprise builds, loads from the repo file (fetched + cached) with an
/// embedded fallback. For OSS builds, returns a minimal default: `status_message`
/// as the sole error-detail column and no rules (classifier labels everything
/// `unclassified`).
pub fn get_default_agent_signals_taxonomy() -> config::meta::agent_signals::AgentSignalsTaxonomy {
#[cfg(feature = "enterprise")]
{
o2_enterprise::enterprise::common::agent_signals_config::load_defaults_from_file()
}
#[cfg(not(feature = "enterprise"))]
{
config::meta::agent_signals::AgentSignalsTaxonomy {
error_detail_fields: vec!["status_message".to_string()],
failure_rules: vec![],
}
}
}
/// Normalize a semantic group category name to a consistent identity set ID
///
/// Converts category names to lowercase, dash-separated format while maintaining
@@ -0,0 +1,74 @@
// 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/>.
//! Agent Signals Aggregator
//!
//! Self-ingests `AgentSignalRecord`s into the `_agent_signals` stream via the
//! existing `IngestionRequest` gRPC path, mirroring
//! `traces::service_graph::aggregator::write_sql_aggregated_edges`. The
//! record→JSON transform (`records_to_json`) is a pure helper that lives in the
//! enterprise crate; this OSS layer owns only the self-ingest I/O.
use config::meta::agent_signals::AgentSignalRecord;
/// Write agent-signal records to the `_agent_signals` stream.
///
/// Forwards to an ingester via gRPC using the internal token — no root
/// credentials required. Mirrors
/// `service_graph::aggregator::write_sql_aggregated_edges`.
#[cfg(feature = "enterprise")]
pub async fn write_agent_signals(
org_id: &str,
records: Vec<AgentSignalRecord>,
) -> Result<(), anyhow::Error> {
use proto::cluster_rpc;
if records.is_empty() {
return Ok(());
}
let record_count = records.len();
let enriched = o2_enterprise::enterprise::agent_signals::records_to_json(&records);
let req = cluster_rpc::IngestionRequest {
org_id: org_id.to_string(),
// Reuse ServiceGraph stream_type; the stream *name* separates the data.
stream_type: config::meta::stream::StreamType::ServiceGraph
.as_str()
.to_string(),
stream_name: "_agent_signals".to_string(),
data: Some(cluster_rpc::IngestionData {
data: serde_json::to_vec(&enriched)?,
}),
ingestion_type: Some(cluster_rpc::IngestionType::Json as i32),
metadata: None,
};
crate::service::ingestion::ingestion_service::ingest(req)
.await
.map(|_| ())
.map_err(|e| anyhow::anyhow!("{e}"))
.inspect_err(|e| {
log::error!("[AgentSignals] Failed to write agent signal records: {e}");
})?;
log::info!("[AgentSignals] Wrote {record_count} agent signal records for {org_id}");
Ok(())
}
#[cfg(not(feature = "enterprise"))]
pub async fn write_agent_signals(
_org_id: &str,
_records: Vec<AgentSignalRecord>,
) -> Result<(), anyhow::Error> {
Ok(())
}
+130
View File
@@ -0,0 +1,130 @@
// 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/>.
//! Agent Signals read API.
//!
//! Reads the pre-computed `_agent_signals` derived stream (produced by the
//! rollup) and returns the signals for a window. This is a small, bounded
//! read — it never touches raw trace content. HTTP handler stays in OSS with a
//! dual `#[cfg]` impl (enterprise reads the stream; OSS returns 403), mirroring
//! `service_graph::api::get_current_topology`.
use axum::response::Response as HttpResponse;
use serde::Deserialize;
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
/// Query params for the agent-signals read endpoint.
#[derive(Debug, Deserialize)]
pub struct AgentSignalsQuery {
pub start_time: Option<i64>,
pub end_time: Option<i64>,
/// Optional filter: "failure" | "loop" | "cost".
pub signal_type: Option<String>,
/// Optional filter by source trace stream.
pub source_stream: Option<String>,
}
#[cfg(feature = "enterprise")]
pub async fn get_agent_signals(
axum::extract::Path(org_id): axum::extract::Path<String>,
axum::extract::Query(query): axum::extract::Query<AgentSignalsQuery>,
) -> HttpResponse {
use config::meta::stream::StreamType;
let stream_name = "_agent_signals";
let (start_time, end_time) =
if let (Some(start), Some(end)) = (query.start_time, query.end_time) {
(start, end)
} else {
let now = chrono::Utc::now().timestamp_micros();
let window_micros =
super::super::service_graph::DEFAULT_QUERY_WINDOW_MINUTES * 60 * 1_000_000;
(now - window_micros, now)
};
// Build a bounded read over the tiny derived stream.
let mut filters = format!("org_id = '{org_id}'");
if let Some(st) = query.signal_type.as_deref() {
// signal_type is a fixed small enum; guard against quote-breaking just in case.
let st = st.replace('\'', "");
filters.push_str(&format!(" AND signal_type = '{st}'"));
}
if let Some(src) = query.source_stream.as_deref() {
let src = src.replace('\'', "");
filters.push_str(&format!(" AND source_stream = '{src}'"));
}
let sql = format!(
"SELECT * FROM \"{stream_name}\" \
WHERE _timestamp >= {start_time} AND _timestamp < {end_time} AND {filters} \
ORDER BY _timestamp DESC LIMIT 10000"
);
// If the stream doesn't exist yet (feature never ran), return an empty list.
let schema = infra::schema::get(&org_id, stream_name, StreamType::Logs).await;
if schema.is_err() {
return MetaHttpResponse::json(serde_json::json!({ "signals": [] }));
}
let req = config::meta::search::Request {
query: config::meta::search::Query {
sql,
from: 0,
size: 10000,
start_time,
end_time,
quick_mode: false,
query_type: "".to_string(),
track_total_hits: false,
uses_zo_fn: false,
query_fn: None,
skip_wal: false,
action_id: None,
histogram_interval: 0,
streaming_id: None,
streaming_output: false,
sampling_config: None,
sampling_ratio: None,
timezone: None,
},
encoding: config::meta::search::RequestEncoding::Empty,
regions: vec![],
clusters: vec![],
timeout: 30,
search_type: None,
search_event_context: None,
use_cache: false,
clear_cache: false,
local_mode: Some(false),
};
let trace_id = config::ider::generate();
match crate::service::search::search(&trace_id, &org_id, StreamType::Logs, None, &req).await {
Ok(resp) => MetaHttpResponse::json(serde_json::json!({ "signals": resp.hits })),
Err(e) => {
log::error!("[AgentSignals] read query failed for org '{org_id}': {e}");
MetaHttpResponse::json(serde_json::json!({ "signals": [] }))
}
}
}
#[cfg(not(feature = "enterprise"))]
pub async fn get_agent_signals(
axum::extract::Path(_org_id): axum::extract::Path<String>,
axum::extract::Query(_query): axum::extract::Query<AgentSignalsQuery>,
) -> HttpResponse {
MetaHttpResponse::forbidden("Not Supported")
}
+22
View File
@@ -0,0 +1,22 @@
// 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/>.
pub mod aggregator;
pub mod api;
pub mod processor;
pub use aggregator::write_agent_signals;
pub use api::get_agent_signals;
#[cfg(feature = "enterprise")]
pub use processor::process_agent_signals_stream;
@@ -0,0 +1,137 @@
// 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/>.
//! OSS orchestrator for agent-behavior signals.
//!
//! This layer owns only the I/O: it runs the bounded-key SQL (built in
//! `o2_enterprise::enterprise::agent_signals`) via the shared search path and
//! self-ingests the mapped records. All pure transforms (SQL construction,
//! hit→record mapping) live in the enterprise crate — mirroring how the
//! service-graph feature splits compute (enterprise) from I/O (OSS).
#![cfg(feature = "enterprise")]
use o2_enterprise::enterprise::{
agent_signals::{
build_cost_sql, build_failure_sql, build_loop_ratio_sql, map_cost_hits, map_failure_hits,
map_loop_hits,
},
common::config::get_config as get_o2_config,
};
/// Run the three bounded passes for one stream+window and self-ingest the results.
/// Early-returns Ok when the feature is disabled (no query issued).
pub async fn process_agent_signals_stream(
org_id: &str,
stream_name: &str,
start_time: i64,
end_time: i64,
) -> Result<(), anyhow::Error> {
if !get_o2_config().agent_signals.enabled {
return Ok(());
}
let ts = end_time; // stamp records at window end
// Schema-gate each pass: streams from different frameworks carry different
// columns. Referencing a missing column is a hard search error, so only run a
// pass when its required columns exist (mirrors the service-graph gating).
let schema = infra::schema::get(
org_id,
stream_name,
config::meta::stream::StreamType::Traces,
)
.await;
let has_field = |name: &str| {
schema
.as_ref()
.map(|s| s.field_with_name(name).is_ok())
.unwrap_or(false)
};
// Failure classification reads the resolved taxonomy (org override → repo file
// → embedded fallback), not a hardcoded set. Keep ONLY the configured columns
// that actually exist on this stream — referencing a missing column is a hard
// search error, so the SQL must COALESCE existing columns only.
let taxonomy = crate::service::db::system_settings::get_agent_signals_taxonomy(org_id).await;
let detail_fields: Vec<String> = taxonomy
.error_detail_fields
.iter()
.filter(|f| has_field(f))
.cloned()
.collect();
let has_failure_cols = !detail_fields.is_empty();
let has_tool = has_field("gen_ai_tool_name");
let has_cost = has_field("gen_ai_usage_cost");
let mut records = Vec::new();
// R1 failure taxonomy — classify from the configured error-detail columns,
// using the configured (evolvable) failure-rule taxonomy.
if has_failure_cols {
let sql = build_failure_sql(
stream_name,
start_time,
end_time,
&detail_fields,
&taxonomy.failure_rules,
);
match crate::service::traces::service_graph::run_graph_search(
org_id, sql, start_time, end_time,
)
.await
{
Ok(hits) => records.extend(map_failure_hits(org_id, stream_name, ts, &hits)),
Err(e) => {
log::warn!("[AgentSignals] failure pass failed for {org_id}/{stream_name}: {e}")
}
}
}
// R2 loop ratio — needs gen_ai_tool_name.
if has_tool {
let sql = build_loop_ratio_sql(stream_name, start_time, end_time);
match crate::service::traces::service_graph::run_graph_search(
org_id, sql, start_time, end_time,
)
.await
{
Ok(hits) => records.extend(map_loop_hits(org_id, stream_name, ts, &hits)),
Err(e) => log::warn!("[AgentSignals] loop pass failed for {org_id}/{stream_name}: {e}"),
}
}
// R4 cost/diagnosis — needs gen_ai_usage_cost.
if has_cost {
let sql = build_cost_sql(stream_name, start_time, end_time);
match crate::service::traces::service_graph::run_graph_search(
org_id, sql, start_time, end_time,
)
.await
{
Ok(hits) => records.extend(map_cost_hits(org_id, stream_name, ts, &hits)),
Err(e) => log::warn!("[AgentSignals] cost pass failed for {org_id}/{stream_name}: {e}"),
}
}
super::aggregator::write_agent_signals(org_id, records).await
}
#[cfg(all(test, feature = "enterprise"))]
mod test {
// process_agent_signals_stream must early-return Ok when disabled — no panic, no query.
#[tokio::test]
async fn test_process_stream_noop_when_disabled() {
// enabled defaults to false; the fn must return Ok without attempting a search.
let r = super::process_agent_signals_stream("default", "nostream", 0, 1).await;
assert!(r.is_ok());
}
}
+1
View File
@@ -51,6 +51,7 @@ use opentelemetry_proto::tonic::{
use prost::Message;
use serde_json::Map;
pub mod agent_signals;
pub mod inferred;
pub mod otel;
pub mod schema_compat;
+2
View File
@@ -45,6 +45,8 @@ pub use o2_enterprise::enterprise::service_graph::{
};
// Re-export processor for compactor
pub use processor::process_service_graph;
#[cfg(feature = "enterprise")]
pub(crate) use processor::run_graph_search;
#[cfg(test)]
mod tests {
+14 -1
View File
@@ -156,6 +156,19 @@ pub async fn process_service_graph() -> Result<(), anyhow::Error> {
log::error!("[ServiceGraph] Failed to process stream {org_id}/{stream_name}: {e}");
continue; // Don't fail entire job if one stream fails
}
// Agent-signals rollup: co-located, same window, same node. Self-guards on config.
if let Err(e) = crate::service::traces::agent_signals::process_agent_signals_stream(
&org_id,
&stream_name,
last_updated_at,
next_updated_at,
)
.await
{
log::error!("[AgentSignals] Failed for stream {org_id}/{stream_name}: {e}");
// Non-fatal: agent signals must never break the service graph.
}
}
// update last updated at
@@ -605,7 +618,7 @@ async fn process_stream(
/// the raw result hits. Shared by the instrumented self-join query and the
/// inferred-dependency query.
#[cfg(feature = "enterprise")]
async fn run_graph_search(
pub(crate) async fn run_graph_search(
org_id: &str,
sql: String,
start_time: i64,
+9 -1
View File
@@ -31,6 +31,8 @@ const AISessionsPage = () =>
import("@/enterprise/views/AIObservability/SessionsPage.vue");
const AIAgentGraphPage = () =>
import("@/enterprise/views/AIObservability/AgentGraphPage.vue");
const AIAgentBehaviorPage = () =>
import("@/enterprise/views/AIObservability/AgentBehaviorPage.vue");
// Reused for the AI/LLM session drill-down so it lives under /ai (keeps the
// AI menu item active) instead of the Traces session-details route.
const SessionDetails = () =>
@@ -87,7 +89,7 @@ const useEnvRoutes = () => {
path: "llm-insights",
name: "aiLLMInsights",
component: AILLMInsightsPage,
meta: { title: "LLM Insights", keepAlive: false },
meta: { title: "Insights", keepAlive: false },
},
{
path: "sessions",
@@ -101,6 +103,12 @@ const useEnvRoutes = () => {
component: AIAgentGraphPage,
meta: { title: "Agent Graph", keepAlive: false },
},
{
path: "agent-behavior",
name: "aiAgentBehavior",
component: AIAgentBehaviorPage,
meta: { title: "Agent Behavior", keepAlive: false },
},
{
path: "evaluations",
name: "aiEvaluations",
@@ -0,0 +1,300 @@
<!--
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>
<OPageLayout
data-test="ai-agent-behavior-page"
:title="t('aiObservability.nav.agentBehavior')"
:subtitle="t('aiObservability.subtitle.agentBehavior')"
icon="troubleshoot"
bleed
:scroll="false"
>
<template #actions>
<date-time
ref="dateTimeRef"
auto-apply
menu-align="end"
:default-type="dateState.valueType"
:default-absolute-time="{
startTime: dateState.startTime ?? 0,
endTime: dateState.endTime ?? 0,
}"
:default-relative-time="dateState.relativeTimePeriod ?? ''"
data-test="ai-agent-behavior-date-time"
class="h-8"
@on:date-change="onDateChange"
/>
<!-- Last-refresh + refresh control, consistent with LLM Insights /
Sessions page headers. -->
<div
class="inline-flex items-center border border-border-default rounded-default px-1 h-8 overflow-hidden"
>
<ORefreshButton
:last-run-at="behaviorLastRunAt"
:loading="isLoading"
:disabled="isLoading"
data-test="ai-agent-behavior-refresh-btn"
@click="refresh"
/>
</div>
</template>
<!-- Scope control — same Stream/Agent pattern as Agent Graph, so the AI
pages read as one product. Stream tab shows every agent's signals for
the stream; Agent tab narrows to one discovered agent (and follows its
source_stream). Lives in OPageLayout's #subnav (full-bleed divider). -->
<template #subnav>
<div class="flex items-center gap-3 px-page-edge py-2">
<OToggleGroup
:model-value="filterMode"
type="single"
data-test="agent-behavior-filter-mode"
@update:model-value="onFilterModeChange"
>
<OToggleGroupItem value="agent" size="sm">{{
t("aiObservability.agentGraph.agent")
}}</OToggleGroupItem>
<OToggleGroupItem value="stream" size="sm">{{
t("aiObservability.agentGraph.stream")
}}</OToggleGroupItem>
</OToggleGroup>
<div
v-if="filterMode === 'agent'"
data-test="agent-behavior-agent-selector"
class="w-56 flex-shrink-0"
>
<SkeletonBox
v-if="!agentsLoaded"
width="100%"
height="2.125rem"
:rounded="true"
/>
<OSelect
v-else
v-model="activeAgentKey"
:label="t('aiObservability.agentGraph.agent')"
label-position="inside"
:options="agentSelectOptions"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
/>
</div>
<div
v-else
data-test="agent-behavior-stream-selector"
class="w-56 flex-shrink-0"
>
<OSelect
v-model="activeStream"
:label="t('aiObservability.agentGraph.stream')"
label-position="inside"
:options="availableStreams.map((s) => ({ label: s, value: s }))"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
/>
</div>
</div>
</template>
<!-- Full-height column: the panel splits the available height between its
two cards, so the page itself never scrolls — each table scrolls
internally when its rows overflow. -->
<div class="flex-1 min-h-0 flex flex-col px-page-edge py-4">
<AgentBehaviorPanel
ref="panelRef"
class="flex-1 min-h-0"
:source-stream="effectiveStream"
:agent-filter="agentFilter"
:start-time="timeRange.startTime"
:end-time="timeRange.endTime"
/>
</div>
</OPageLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import DateTime from "@/components/DateTime.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import AgentBehaviorPanel from "./AgentBehaviorPanel.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import SkeletonBox from "@/components/shared/SkeletonBox.vue";
import useStreams from "@/composables/useStreams";
import { useStore } from "vuex";
import genAiAgentMappingService, {
type GenAiAgentListItem,
} from "@/services/gen-ai-agent-mapping.service";
import { getConsumableRelativeTime } from "@/utils/date";
import {
useAiDateRange,
resolveAiDateWindow,
} from "@/enterprise/composables/useAiDateRange";
defineOptions({ name: "AgentBehaviorPage" });
const { t } = useI18n();
const { getStreams } = useStreams();
const store = useStore();
const DEFAULT_RELATIVE = "15m";
// Shared with LLM Insights / Sessions / Quality — see useAiDateRange.ts.
const { state: dateState } = useAiDateRange();
const timeRange = ref({ startTime: 0, endTime: 0 });
// Agent is the default scope: the page is about a specific agent's behaviour,
// so it opens focused on one agent. Stream mode widens to every agent's signals.
const filterMode = ref<"stream" | "agent">("agent");
const availableStreams = ref<string[]>([]);
const activeStream = ref<string>("");
const dateTimeRef = ref<any>(null);
const panelRef = ref<any>(null);
const isRefreshing = ref(false);
// Agent-mode selection — mirrors Agent Graph. Same stream-scoped identity so
// same-named agents in different streams don't collide.
const agents = ref<GenAiAgentListItem[]>([]);
const agentsLoaded = ref(false);
const activeAgentKey = ref<string>("");
const agentKey = (a: GenAiAgentListItem) => `${a.source_stream}::${a.name}`;
const agentSelectOptions = computed(() =>
agents.value.map((a) => ({
label: a.id ? `${a.name} (${a.id})` : a.name,
value: agentKey(a),
})),
);
const selectedAgent = computed<GenAiAgentListItem | null>(
() => agents.value.find((a) => agentKey(a) === activeAgentKey.value) ?? null,
);
// The stream the panel queries: the picked stream, or the selected agent's
// source_stream (deriving stream from the agent is the point of Agent mode).
const effectiveStream = computed(() =>
filterMode.value === "agent"
? (selectedAgent.value?.source_stream ?? activeStream.value)
: activeStream.value,
);
// In Agent mode, narrow the panel to that one agent's name; Stream mode shows all.
const agentFilter = computed(() =>
filterMode.value === "agent" ? (selectedAgent.value?.name ?? "") : "",
);
function onFilterModeChange(mode?: string | number | null) {
if (mode === "stream" || mode === "agent") filterMode.value = mode;
if (mode === "agent" && !agentsLoaded.value) loadAgents();
}
async function loadAgents() {
try {
const org = store.state.selectedOrganization?.identifier as string;
const res = await genAiAgentMappingService.listAgents(
org,
timeRange.value.startTime,
timeRange.value.endTime,
);
agents.value = res.agents ?? [];
if (!activeAgentKey.value && agents.value.length) {
activeAgentKey.value = agentKey(agents.value[0]);
}
} catch {
agents.value = [];
} finally {
agentsLoaded.value = true;
}
}
// Last-refresh + loading for the header's ORefreshButton — the panel stamps
// `lastRunAt` when its fetch settles and exposes its own `loading`.
const behaviorLastRunAt = computed<number | null>(
() => panelRef.value?.lastRunAt ?? null,
);
const isLoading = computed(
() => isRefreshing.value || panelRef.value?.loading || false,
);
function applyRelative(period: string) {
const range = getConsumableRelativeTime(period);
if (!range) return;
timeRange.value = { startTime: range.startTime, endTime: range.endTime };
dateState.value = {
...dateState.value,
valueType: "relative",
relativeTimePeriod: period,
startTime: range.startTime,
endTime: range.endTime,
};
}
function onDateChange(value: any) {
if (value?.valueType === "relative" && value.relativeTimePeriod) {
applyRelative(value.relativeTimePeriod);
} else {
dateState.value = {
valueType: "absolute",
startTime: value.startTime,
endTime: value.endTime,
relativeTimePeriod: null,
};
timeRange.value = { startTime: value.startTime, endTime: value.endTime };
}
}
async function refresh() {
if (isRefreshing.value) return;
isRefreshing.value = true;
try {
if (dateState.value.valueType === "relative") {
applyRelative(dateState.value.relativeTimePeriod ?? DEFAULT_RELATIVE);
}
await panelRef.value?.refresh?.();
} finally {
isRefreshing.value = false;
}
}
onMounted(async () => {
const window = resolveAiDateWindow(dateState.value);
if (window) {
timeRange.value = window;
if (dateState.value.valueType === "relative") {
applyRelative(dateState.value.relativeTimePeriod ?? DEFAULT_RELATIVE);
}
} else {
applyRelative(DEFAULT_RELATIVE);
}
try {
const res = await getStreams("traces", false, false);
availableStreams.value = (res?.list ?? []).map((s: any) => s.name);
if (availableStreams.value.length && !activeStream.value) {
activeStream.value = availableStreams.value[0];
}
} catch {
availableStreams.value = [];
}
// Agent is the default scope, so the agent list must be ready on first paint.
await loadAgents();
});
</script>
@@ -0,0 +1,265 @@
<!--
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="flex flex-col gap-2.5" data-test="agent-behavior-panel">
<!-- Looping agents. Same card/header/table shape as the sibling LLM Insights
panels (LLMErrorTable) so the whole page reads as one surface. -->
<div
class="bg-card-glass-bg llm-trend-panel rounded-default flex flex-col flex-1 min-h-0 overflow-hidden border border-border-default"
data-test="agent-behavior-loops-card"
>
<div class="flex flex-col mb-2 px-4 pt-4">
<div class="text-sm font-semibold text-text-heading">
{{ t("aiObservability.behavior.loopsTitle") }}
</div>
<div class="text-2xs leading-normal mt-0.5 text-text-secondary">
{{ t("aiObservability.behavior.loopsHint") }}
</div>
</div>
<OTable
data-test="agent-behavior-loops-table"
:data="loopRows"
:columns="loopColumns"
:default-columns="false"
:frame="false"
:show-global-filter="false"
class="flex-1 min-h-0"
show-index
pagination="client"
:empty-message="t('aiObservability.behavior.noLoops')"
@row-click="(r: any) => openDetail('loop', r)"
/>
</div>
<!-- Failure taxonomy -->
<div
class="bg-card-glass-bg llm-trend-panel rounded-default flex flex-col flex-1 min-h-0 overflow-hidden border border-border-default"
data-test="agent-behavior-failures-card"
>
<div class="flex flex-col mb-2 px-4 pt-4">
<div class="text-sm font-semibold text-text-heading">
{{ t("aiObservability.behavior.failuresTitle") }}
</div>
<div class="text-2xs leading-normal mt-0.5 text-text-secondary">
{{ t("aiObservability.behavior.failuresHint") }}
</div>
</div>
<OTable
data-test="agent-behavior-failures-table"
:data="failureRows"
:columns="failureColumns"
:default-columns="false"
:frame="false"
:show-global-filter="false"
class="flex-1 min-h-0"
show-index
pagination="client"
:empty-message="t('aiObservability.behavior.noFailures')"
@row-click="(r: any) => openDetail('failure', r)"
/>
</div>
<!-- Cost is intentionally NOT a table here — LLM Insights already owns the
cost story (KPIs + trend panels). Per-agent cost drill-down is reachable
from a failure/loop row's detail panel instead of a duplicate table. -->
<!-- Details side panel — opens on row click, shows the underlying traces. -->
<AgentSignalDetailPanel
v-model:open="detailOpen"
:row="detailRow"
:source-stream="sourceStream"
:start-time="startTime"
:end-time="endTime"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import AgentSignalDetailPanel from "./AgentSignalDetailPanel.vue";
import agentSignalsService, {
type AgentSignalRecord,
} from "@/services/agent_signals";
const props = defineProps<{
startTime?: number;
endTime?: number;
sourceStream?: string;
/** When set (Agent mode), show only this agent's signals; empty = all agents. */
agentFilter?: string;
}>();
const { t } = useI18n();
const store = useStore();
const signals = ref<AgentSignalRecord[]>([]);
const loading = ref(false);
// When the last fetch settled — stamped for the page header's refresh control.
const lastRunAt = ref<number | null>(null);
// Details side panel state.
const detailOpen = ref(false);
const detailRow = ref<any>(null);
/** Open the details panel for a clicked Behavior row (maps the table row to a signal). */
const openDetail = (
signalType: "loop" | "failure" | "cost",
row: Record<string, any>,
) => {
detailRow.value = { signalType, ...row };
detailOpen.value = true;
};
const orgId = computed(
() => store.state.selectedOrganization?.identifier as string,
);
/** Signals scoped to the selected agent (Agent mode) or all agents (Stream mode). */
const scopedSignals = computed(() => {
const a = props.agentFilter?.trim();
if (!a) return signals.value;
return signals.value.filter((s) => (s.agent_name ?? "") === a);
});
/** Loop rows: rank (agent, tool) by calls-per-trace ratio. */
const loopRows = computed(() =>
scopedSignals.value
.filter((s) => s.signal_type === "loop")
.map((s) => ({
// `agent` is the DISPLAY string (may be the "(unknown agent)" fallback);
// `agentRaw` is the real value the drill-down must query on (null when the
// rollup couldn't resolve an agent — the drawer then drops the agent
// filter rather than searching for the literal fallback string).
agent: s.agent_name ?? t("aiObservability.behavior.unknownAgent"),
agentRaw: s.agent_name ?? null,
tool: s.tool_name ?? "",
calls: s.calls ?? 0,
traces: s.distinct_traces ?? 0,
ratio:
s.distinct_traces && s.distinct_traces > 0
? Math.round(((s.calls ?? 0) / s.distinct_traces) * 100) / 100
: 0,
}))
.sort((a, b) => b.ratio - a.ratio),
);
/** Failure rows: per (agent, class) with count. */
const failureRows = computed(() =>
scopedSignals.value
.filter((s) => s.signal_type === "failure")
.map((s) => ({
agent: s.agent_name ?? t("aiObservability.behavior.unknownAgent"),
agentRaw: s.agent_name ?? null,
failClass: s.fail_class ?? "unclassified",
count: s.count ?? 0,
}))
.sort((a, b) => b.count - a.count),
);
const loopColumns = computed<OTableColumnDef[]>(() => [
{
id: "agent",
header: t("aiObservability.behavior.colAgent"),
accessorKey: "agent",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "tool",
header: t("aiObservability.behavior.colTool"),
accessorKey: "tool",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "ratio",
header: t("aiObservability.behavior.colRatio"),
accessorKey: "ratio",
meta: { align: "right" },
sortable: true,
},
{
id: "calls",
header: t("aiObservability.behavior.colCalls"),
accessorKey: "calls",
meta: { align: "right" },
sortable: true,
},
{
id: "traces",
header: t("aiObservability.behavior.colTraces"),
accessorKey: "traces",
meta: { align: "right" },
sortable: true,
},
]);
const failureColumns = computed<OTableColumnDef[]>(() => [
{
id: "agent",
header: t("aiObservability.behavior.colAgent"),
accessorKey: "agent",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "failClass",
header: t("aiObservability.behavior.colFailClass"),
accessorKey: "failClass",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "count",
header: t("aiObservability.behavior.colCount"),
accessorKey: "count",
meta: { align: "right" },
sortable: true,
},
]);
const fetchSignals = async () => {
if (!orgId.value) return;
loading.value = true;
try {
const res = await agentSignalsService.getAgentSignals(orgId.value, {
start_time: props.startTime,
end_time: props.endTime,
source_stream: props.sourceStream,
});
signals.value = res.data?.signals ?? [];
} catch {
// Feature may be disabled (403) or stream not present yet — show empty state.
signals.value = [];
} finally {
loading.value = false;
lastRunAt.value = Date.now();
}
};
onMounted(fetchSignals);
watch(
() => [props.startTime, props.endTime, props.sourceStream, orgId.value],
fetchSignals,
);
defineExpose({ refresh: fetchSignals, lastRunAt, loading });
</script>
@@ -37,9 +37,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
searchObj.data.datetime.relativeTimePeriod ?? '15m'
"
data-test="ai-agent-graph-date-time"
class="h-[2rem]"
class="h-8"
@on:date-change="onDateChange"
/>
<!-- Last-refresh + refresh control, consistent with LLM Insights /
Sessions / Agent Behavior page headers. -->
<div
class="inline-flex items-center border border-border-default rounded-default px-1 h-8 overflow-hidden"
>
<ORefreshButton
:last-run-at="graphLastRunAt"
:loading="isGraphLoading"
:disabled="isGraphLoading"
data-test="ai-agent-graph-refresh-btn"
@click="refresh"
/>
</div>
</template>
<!-- Scope control — same Stream/Agent pattern as LLM Insights, so the two
@@ -54,12 +67,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="agent-graph-filter-mode"
@update:model-value="onFilterModeChange"
>
<OToggleGroupItem value="stream" size="sm">{{
t("aiObservability.agentGraph.stream")
}}</OToggleGroupItem>
<OToggleGroupItem value="agent" size="sm">{{
t("aiObservability.agentGraph.agent")
}}</OToggleGroupItem>
<OToggleGroupItem value="stream" size="sm">{{
t("aiObservability.agentGraph.stream")
}}</OToggleGroupItem>
</OToggleGroup>
<div
@@ -103,6 +116,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<ServiceGraph
ref="graphRef"
:stream-filter="effectiveStream"
hide-stream-selector
agent-highlight
@@ -120,6 +134,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
import SkeletonBox from "@/components/shared/SkeletonBox.vue";
import useTraces from "@/composables/useTraces";
import useStreams from "@/composables/useStreams";
@@ -141,10 +156,45 @@ const ServiceGraph = defineAsyncComponent(
const DEFAULT_RELATIVE = "15m";
const filterMode = ref<"stream" | "agent">("stream");
// Default scope is "agent" — the AI module is agent-centric (agents load on
// mount, so the default is ready on first paint).
const filterMode = ref<"stream" | "agent">("agent");
const availableStreams = ref<string[]>([]);
const activeStream = ref<string>("default");
// Graph child ref + header refresh state. ServiceGraph exposes
// { refresh, loading, lastRunAt }.
const graphRef = ref<any>(null);
const isRefreshing = ref(false);
const graphLastRunAt = computed<number | null>(
() => graphRef.value?.lastRunAt ?? null,
);
const isGraphLoading = computed(
() => isRefreshing.value || graphRef.value?.loading || false,
);
async function refresh() {
if (isRefreshing.value) return;
isRefreshing.value = true;
try {
// Re-anchor a relative window first so "last 15m" refreshes to now.
const dt = searchObj.data.datetime;
if (dt.type === "relative" && dt.relativeTimePeriod) {
const r = getConsumableRelativeTime(dt.relativeTimePeriod);
if (r) {
searchObj.data.datetime = {
...dt,
startTime: r.startTime,
endTime: r.endTime,
};
}
}
await graphRef.value?.refresh?.();
} finally {
isRefreshing.value = false;
}
}
const agents = ref<GenAiAgentListItem[]>([]);
const agentsLoaded = ref(false);
const activeAgentKey = ref<string>("");
@@ -0,0 +1,265 @@
<!--
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/>.
-->
<!--
Agent-scoped behavior signals shown INSIDE the Agent Graph node drawer.
This is the §4b cross-link from the Agent Signals functional design: clicking
an agent node in Agent Graph surfaces that one agent's loop + failure health
(and a cost headline — a KPI, not a table), reusing the same _agent_signals
rollup and the same AgentSignalDetailPanel drill-down the Agent Behavior page
uses. It is a per-node health lens, never topology.
Scope: this component queries all signals for the org/stream/window (bounded —
a few agents × classes/tools) and filters to the one clicked agent. Row click
opens the existing detail drawer scoped to that signal.
-->
<template>
<div class="flex flex-col gap-4 p-1" data-test="agent-node-behavior-tab">
<!-- Cost headline: a KPI line, not a table (design §2 keeps cost out of the
behavior tables — it stays a summary stat). -->
<div
v-if="costSummary"
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-secondary"
data-test="agent-node-behavior-cost"
>
<span>
{{ t("aiObservability.behavior.node.cost") }}:
<span class="text-text-heading font-semibold">{{ costSummary.cost }}</span>
</span>
<span>
{{ t("aiObservability.behavior.colTokens") }}:
<span class="text-text-heading font-semibold">{{ costSummary.tokens }}</span>
</span>
<span v-if="costSummary.errors">
{{ t("aiObservability.behavior.colErrors") }}:
<span class="text-text-heading font-semibold">{{ costSummary.errors }}</span>
</span>
</div>
<!-- Looping tools for this agent -->
<div class="flex flex-col gap-1.5">
<div class="text-xs font-semibold text-text-heading">
{{ t("aiObservability.behavior.loopsTitle") }}
</div>
<OTable
data-test="agent-node-behavior-loops"
:data="loopRows"
:columns="loopColumns"
:default-columns="false"
:frame="false"
:show-global-filter="false"
:fill-height="false"
pagination="client"
:page-size="5"
:empty-message="t('aiObservability.behavior.noLoops')"
@row-click="(r: any) => openDetail('loop', r)"
/>
</div>
<!-- Failure classes for this agent -->
<div class="flex flex-col gap-1.5">
<div class="text-xs font-semibold text-text-heading">
{{ t("aiObservability.behavior.failuresTitle") }}
</div>
<OTable
data-test="agent-node-behavior-failures"
:data="failureRows"
:columns="failureColumns"
:default-columns="false"
:frame="false"
:show-global-filter="false"
:fill-height="false"
pagination="client"
:page-size="5"
:empty-message="t('aiObservability.behavior.noFailures')"
@row-click="(r: any) => openDetail('failure', r)"
/>
</div>
<div
v-if="disabledHint"
class="text-xs text-text-secondary italic"
data-test="agent-node-behavior-disabled"
>
{{ disabledHint }}
</div>
<!-- Reuses the same drill-down drawer as the Agent Behavior page. -->
<AgentSignalDetailPanel
v-model:open="detailOpen"
:row="detailRow"
:source-stream="sourceStream"
:start-time="startTime"
:end-time="endTime"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import AgentSignalDetailPanel from "./AgentSignalDetailPanel.vue";
import agentSignalsService, {
type AgentSignalRecord,
} from "@/services/agent_signals";
const props = defineProps<{
/** The clicked agent's resolved name (from the graph node identity). */
agentName: string;
sourceStream?: string;
startTime?: number;
endTime?: number;
}>();
const { t } = useI18n();
const store = useStore();
const signals = ref<AgentSignalRecord[]>([]);
const disabledHint = ref("");
const detailOpen = ref(false);
const detailRow = ref<any>(null);
const orgId = computed(
() => store.state.selectedOrganization?.identifier as string,
);
/** Only this agent's signals — the drawer/page already resolve agent names the
* same way, so a name match is the scoping key (see design §4b id-vs-name note). */
const mySignals = computed(() =>
signals.value.filter((s) => (s.agent_name ?? "") === props.agentName),
);
const loopRows = computed(() =>
mySignals.value
.filter((s) => s.signal_type === "loop")
.map((s) => ({
agent: props.agentName,
agentRaw: s.agent_name ?? props.agentName ?? null,
tool: s.tool_name ?? "",
calls: s.calls ?? 0,
traces: s.distinct_traces ?? 0,
ratio:
s.distinct_traces && s.distinct_traces > 0
? Math.round(((s.calls ?? 0) / s.distinct_traces) * 100) / 100
: 0,
}))
.sort((a, b) => b.ratio - a.ratio),
);
const failureRows = computed(() =>
mySignals.value
.filter((s) => s.signal_type === "failure")
.map((s) => ({
agent: props.agentName,
agentRaw: s.agent_name ?? props.agentName ?? null,
failClass: s.fail_class ?? "unclassified",
count: s.count ?? 0,
}))
.sort((a, b) => b.count - a.count),
);
/** Cost is a headline summary, not a table (design §2). */
const costSummary = computed(() => {
const c = mySignals.value.find((s) => s.signal_type === "cost");
if (!c) return null;
return {
cost: c.cost != null ? `$${Number(c.cost).toFixed(2)}` : "—",
tokens: c.tokens ?? 0,
errors: c.errors ?? 0,
};
});
const openDetail = (
signalType: "loop" | "failure",
row: Record<string, any>,
) => {
detailRow.value = { signalType, ...row };
detailOpen.value = true;
};
const loopColumns = computed<OTableColumnDef[]>(() => [
{
id: "tool",
header: t("aiObservability.behavior.colTool"),
accessorKey: "tool",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "ratio",
header: t("aiObservability.behavior.colRatio"),
accessorKey: "ratio",
meta: { align: "right" },
sortable: true,
},
{
id: "traces",
header: t("aiObservability.behavior.colTraces"),
accessorKey: "traces",
meta: { align: "right" },
sortable: true,
},
]);
const failureColumns = computed<OTableColumnDef[]>(() => [
{
id: "failClass",
header: t("aiObservability.behavior.colFailClass"),
accessorKey: "failClass",
meta: { align: "left", autoWidth: true },
sortable: true,
},
{
id: "count",
header: t("aiObservability.behavior.colCount"),
accessorKey: "count",
meta: { align: "right" },
sortable: true,
},
]);
const fetchSignals = async () => {
if (!orgId.value || !props.sourceStream || !props.agentName) {
signals.value = [];
return;
}
try {
const res = await agentSignalsService.getAgentSignals(orgId.value, {
start_time: props.startTime,
end_time: props.endTime,
source_stream: props.sourceStream,
});
signals.value = res.data?.signals ?? [];
disabledHint.value = "";
} catch {
// 403 = feature/flag off for this deployment, or stream absent — name the
// config choice rather than showing a blank tab (design FR-6.5).
signals.value = [];
disabledHint.value = t("aiObservability.behavior.node.disabled");
}
};
watch(
() => [props.agentName, props.sourceStream, props.startTime, props.endTime, orgId.value],
fetchSignals,
{ immediate: true },
);
</script>
@@ -0,0 +1,577 @@
<!--
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>
<ODrawer
:open="open"
side="right"
:width="55"
:title="title"
data-test="agent-signal-detail-panel"
@update:open="(v: boolean) => emit('update:open', v)"
>
<!-- Signal-type icon in the drawer header, so the drawer's subject reads at
a glance (⟳ loop / ⚠ failure / $ cost). -->
<template #header-left>
<div
class="flex items-center justify-center h-7 w-7 rounded-default"
:class="signalIconWrap"
>
<OIcon :name="signalIcon" size="sm" />
</div>
</template>
<section
class="flex flex-col gap-3 px-5 pt-4 pb-6 min-h-full overflow-auto"
data-test="agent-signal-detail-body"
>
<!-- Headline: the finding, stated plainly -->
<div
class="flex items-start gap-2"
data-test="agent-signal-detail-headline"
>
<OIcon
:name="signalIcon"
size="sm"
class="mt-0.5 shrink-0"
:class="signalIconColor"
/>
<div class="flex flex-col gap-1">
<p class="m-0 text-compact leading-normal text-text-heading">
{{ headline }}
</p>
<p
class="m-0 text-2xs leading-normal text-text-secondary"
>
{{ explanation }}
</p>
</div>
</div>
<!-- FAILURE: the real error messages (the "read it, know the fix" section) -->
<section
v-if="signalType === 'failure'"
class="card-container py-3 px-3.5 pb-3.5 bg-surface-base border border-border-default rounded-surface"
>
<header class="mb-1.5 flex items-center gap-1.5">
<OIcon
name="error-outline"
size="xs"
class="text-badge-error-soft-text"
/>
<h4 class="m-0 text-compact font-semibold text-text-heading">
{{ t("aiObservability.behavior.detail.errorsTitle") }}
</h4>
</header>
<OTable
data-test="agent-signal-detail-errors"
:data="errorRows"
:columns="errorColumns"
:default-columns="false"
:frame="false"
wrap
>
<!-- Show the condensed one-liner; reveal the full raw error on demand
so the table stays scannable but nothing is lost. -->
<template #cell-message="{ row }">
<div class="flex flex-col gap-1 py-0.5">
<span
class="text-xs leading-normal text-text-heading whitespace-pre-wrap break-words"
>
{{ expandedErrors.has(row.full) ? row.full : row.message }}
</span>
<OButton
v-if="row.full && row.full !== row.message"
variant="ghost-primary"
size="chip"
class="self-start"
data-test="agent-signal-detail-toggle-error-btn"
@click.stop="toggleError(row.full)"
>
{{
expandedErrors.has(row.full)
? t("aiObservability.behavior.detail.showLess")
: t("aiObservability.behavior.detail.showFull")
}}
</OButton>
</div>
</template>
</OTable>
<OEmptyState
v-if="!loading && errorRows.length === 0"
preset="no-data"
:title="t('aiObservability.behavior.detail.noErrors')"
/>
</section>
<!-- LOOP / COST: the worst traces, ranked by what makes them bad -->
<section
class="card-container py-3 px-3.5 pb-3.5 bg-surface-base border border-border-default rounded-surface"
>
<header class="mb-1.5 flex items-center justify-between gap-2">
<h4 class="m-0 text-compact font-semibold text-text-heading">
{{ tracesTitle }}
</h4>
<span class="text-2xs text-text-secondary">
{{ t("aiObservability.behavior.detail.tracesHint") }}
</span>
</header>
<OTable
data-test="agent-signal-detail-traces"
:data="traceRows"
:columns="traceColumns"
:default-columns="false"
:frame="false"
pagination="client"
:page-size="10"
@row-click="openTrace"
>
<!-- Trace id renders with an "open in new tab" icon so it's clear the
row opens the trace in a new browser tab, not in place. -->
<template #cell-trace_id="{ row }">
<span
class="inline-flex items-center gap-1 text-text-link hover:underline"
:title="t('aiObservability.behavior.detail.openInNewTab')"
>
<OIcon name="open-in-new" size="xs" class="opacity-70" />
<span class="font-mono truncate">{{ row.trace_id }}</span>
</span>
</template>
</OTable>
<OEmptyState
v-if="!loading && traceRows.length === 0"
preset="no-data"
:title="t('aiObservability.behavior.detail.noTraces')"
/>
</section>
</section>
</ODrawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import searchService from "@/services/search";
import { escapeSingleQuotes } from "@/utils/queryUtils";
interface SignalRow {
signalType: "loop" | "failure" | "cost";
/** Display name (may be the "(unknown agent)" fallback). */
agent?: string;
/** Real agent value to query on; null when unresolved (drop the filter). */
agentRaw?: string | null;
tool?: string;
failClass?: string;
calls?: number;
traces?: number;
ratio?: number;
count?: number;
cost?: number;
tokens?: number;
errors?: number;
}
const props = defineProps<{
open: boolean;
row: SignalRow | null;
sourceStream?: string;
startTime?: number;
endTime?: number;
}>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const errorRows = ref<
Array<{
message: string;
full: string;
occurrences: number;
traces: number;
}>
>([]);
const traceRows = ref<Array<Record<string, any>>>([]);
const loading = ref(false);
// Which error rows are expanded to their full raw text (keyed by the raw
// message — the OTable cell slot doesn't expose a row index).
const expandedErrors = ref<Set<string>>(new Set());
const toggleError = (key: string) => {
const next = new Set(expandedErrors.value);
next.has(key) ? next.delete(key) : next.add(key);
expandedErrors.value = next;
};
const orgId = computed(
() => store.state.selectedOrganization?.identifier as string,
);
const signalType = computed(() => props.row?.signalType);
// Signal-type iconography — a distinct icon + accent per drawer so the subject
// (looping / failing / costly) reads at a glance.
const signalIcon = computed(() => {
if (signalType.value === "loop") return "autorenew";
if (signalType.value === "cost") return "attach-money";
return "warning"; // failure
});
const signalIconColor = computed(() => {
if (signalType.value === "loop") return "text-badge-warning-soft-text";
if (signalType.value === "cost") return "text-badge-primary-soft-text";
return "text-badge-error-soft-text";
});
const signalIconWrap = computed(() => {
if (signalType.value === "loop")
return "bg-badge-warning-soft-bg text-badge-warning-soft-text";
if (signalType.value === "cost")
return "bg-badge-primary-soft-bg text-badge-primary-soft-text";
return "bg-badge-error-soft-bg text-badge-error-soft-text";
});
const title = computed(() => {
const r = props.row;
if (!r) return t("aiObservability.behavior.detail.title");
const agent = r.agent || t("aiObservability.behavior.unknownAgent");
if (r.signalType === "loop") return `${agent} · ${r.tool ?? ""}`;
if (r.signalType === "failure") return `${agent} · ${r.failClass ?? ""}`;
return agent;
});
/** The finding, stated as a plain sentence a user can act on. */
const headline = computed(() => {
const r = props.row;
if (!r) return "";
const agent = r.agent || t("aiObservability.behavior.unknownAgent");
if (r.signalType === "loop") {
return t("aiObservability.behavior.detail.loopHeadline", {
agent,
tool: r.tool ?? "",
ratio: r.ratio ?? "?",
calls: r.calls ?? "?",
traces: r.traces ?? "?",
});
}
if (r.signalType === "failure") {
return t("aiObservability.behavior.detail.failHeadline", {
agent,
count: r.count ?? "?",
cls: r.failClass ?? "",
});
}
return t("aiObservability.behavior.detail.costHeadline", {
agent,
cost: r.cost ?? 0,
tokens: r.tokens ?? 0,
errors: r.errors ?? 0,
});
});
const explanation = computed(() => {
const r = props.row;
if (!r) return "";
if (r.signalType === "loop")
return t("aiObservability.behavior.detail.loopExplain");
if (r.signalType === "failure")
return t("aiObservability.behavior.detail.failExplain");
return t("aiObservability.behavior.detail.costExplain");
});
const tracesTitle = computed(() => {
const r = props.row;
if (r?.signalType === "loop")
return t("aiObservability.behavior.detail.worstLoopTraces");
if (r?.signalType === "cost")
return t("aiObservability.behavior.detail.topCostTraces");
return t("aiObservability.behavior.detail.tracesTitle");
});
// ── Error-message table (failure signals) ──────────────────────────────
const errorColumns = computed<OTableColumnDef[]>(() => [
{
id: "message",
header: t("aiObservability.behavior.detail.colError"),
accessorKey: "message",
// No autoWidth: let the message column take the remaining width and wrap
// (paired with the table's `wrap`). `break-words` breaks long unbroken
// strings (JSON blobs, URLs) so nothing is clipped or overflows.
meta: {
align: "left",
cellClass: "whitespace-pre-wrap break-words align-top",
},
},
{
id: "occurrences",
header: t("aiObservability.behavior.detail.colOccurrences"),
accessorKey: "occurrences",
meta: { align: "right" },
sortable: true,
},
{
id: "traces",
header: t("aiObservability.behavior.colTraces"),
accessorKey: "traces",
meta: { align: "right" },
sortable: true,
},
]);
// ── Trace table — columns adapt to signal type so the value is visible ──
const traceColumns = computed<OTableColumnDef[]>(() => {
const idCol: OTableColumnDef = {
id: "trace_id",
header: t("aiObservability.behavior.detail.colTraceId"),
accessorKey: "trace_id",
meta: { align: "left", autoWidth: true },
};
if (props.row?.signalType === "loop") {
return [
idCol,
{
id: "repeats",
header: t("aiObservability.behavior.detail.colRepeats"),
accessorKey: "repeats",
meta: { align: "right" },
sortable: true,
},
];
}
if (props.row?.signalType === "cost") {
return [
idCol,
{
id: "cost",
header: t("aiObservability.behavior.colCost"),
accessorKey: "cost",
meta: { align: "right" },
sortable: true,
},
{
id: "tokens",
header: t("aiObservability.behavior.colTokens"),
accessorKey: "tokens",
meta: { align: "right" },
sortable: true,
},
];
}
return [
idCol,
{
id: "spans",
header: t("aiObservability.behavior.detail.colSpans"),
accessorKey: "spans",
meta: { align: "right" },
sortable: true,
},
];
});
// Depth of the parent-chain climb used to attribute a tool/error span to its
// OWNING agent. MUST match the backend rollup (processor.rs AGENT_INHERIT_DEPTH
// / sql.rs agent_coalesce): the agent name usually lives on an ANCESTOR span,
// not on the tool/error span itself, so a span-local `gen_ai_agent_name = …`
// filter matches NOTHING (this is why the drawer showed "no traces" while the
// rollup — which climbs — counted 394 calls). We must climb the same way.
const AGENT_INHERIT_DEPTH = 4;
/** The nearest-ancestor-agent-or-service expression, resolving `c`'s owning
* agent by climbing `reference_parent_span_id` up to AGENT_INHERIT_DEPTH,
* falling back to service_name. Mirrors sql.rs::agent_coalesce. */
const callerExpr = () => {
const parts = ["c.gen_ai_agent_name"];
for (let k = 1; k <= AGENT_INHERIT_DEPTH; k++)
parts.push(`p${k}.gen_ai_agent_name`);
parts.push("c.service_name");
return `COALESCE(${parts.join(", ")})`;
};
/** The chained ancestor LEFT JOINs (p1 on c, p2 on p1, …), same trace. */
const ancestorJoins = (stream: string) => {
const joins: string[] = [];
for (let k = 1; k <= AGENT_INHERIT_DEPTH; k++) {
const prev = k === 1 ? "c" : `p${k - 1}`;
joins.push(
`LEFT JOIN "${stream}" AS p${k} ON ${prev}.reference_parent_span_id = p${k}.span_id AND ${prev}.trace_id = p${k}.trace_id`,
);
}
return joins.join(" ");
};
/** Agent-scoping predicate on the CLIMBED agent (not the span-local column). */
const agentClimbFilter = (agent?: string) =>
agent ? `${callerExpr()} = '${escapeSingleQuotes(agent)}'` : "1=1";
/**
* Condense a raw error string into a scannable one-liner. Framework errors are
* often a short prefix followed by a large JSON blob (with request_ids etc.) —
* unreadable in a table. We keep the meaningful gist: if the blob carries a
* `"message":"…"`, surface that; always collapse whitespace and cap length.
* The full text stays available via the row's expand toggle.
*/
const MAX_ERR_LEN = 160;
const condenseError = (raw: string): string => {
if (!raw) return "";
let s = String(raw).replace(/\s+/g, " ").trim();
// Pull the human-facing message out of an embedded JSON error payload.
const m = s.match(/"message"\s*:\s*"([^"]{3,})"/);
if (m) {
// Keep the leading error class (before the first "{") + the inner message.
const prefix = s.split("{")[0].replace(/[-:\s]+$/, "").trim();
s = prefix ? `${prefix} — ${m[1]}` : m[1];
}
if (s.length > MAX_ERR_LEN) s = s.slice(0, MAX_ERR_LEN).trimEnd() + "…";
return s;
};
const runQuery = async (sql: string): Promise<any[]> => {
if (!orgId.value) return [];
const now = Date.now() * 1000;
const start = props.startTime ?? now - 24 * 60 * 60 * 1_000_000;
const end = props.endTime ?? now;
try {
const res = await searchService.search({
org_identifier: orgId.value,
query: {
query: { sql, start_time: start, end_time: end, from: 0, size: 50 },
},
page_type: "traces",
});
return res.data?.hits ?? [];
} catch {
return [];
}
};
const fetchDetails = async () => {
const r = props.row;
expandedErrors.value = new Set();
if (!r || !props.sourceStream || !orgId.value) {
errorRows.value = [];
traceRows.value = [];
return;
}
loading.value = true;
const s = props.sourceStream;
// Agent is resolved by climbing the parent chain — same as the rollup that
// produced these counts. A span-local `gen_ai_agent_name =` filter would
// match nothing because tool/error spans don't carry the agent name.
const joins = ancestorJoins(s);
// Use the RAW agent value (null when the rollup couldn't resolve one) — never
// the "(unknown agent)" display string, which is not a real column value and
// would filter everything out. Null → no agent filter (show all of the class).
const af = agentClimbFilter(r.agentRaw ?? undefined);
// The error detail can live in status_message OR error_message depending on
// the framework — COALESCE so the message shows wherever it was populated
// (mirrors the rollup's configurable error-detail-field coalescing).
const detail =
"COALESCE(NULLIF(c.status_message, ''), NULLIF(c.error_message, ''))";
try {
if (r.signalType === "failure") {
// The real, grouped error messages behind this class — the actionable content.
errorRows.value = (
await runQuery(
`SELECT ${detail} AS message, COUNT(*) AS occurrences, approx_distinct(c.trace_id) AS traces FROM "${s}" AS c ${joins} WHERE c.span_status = 'ERROR' AND ${af} AND ${detail} IS NOT NULL GROUP BY ${detail} ORDER BY occurrences DESC LIMIT 20`,
)
).map((h: any) => ({
// `message` is the condensed one-line gist (scannable); `full` is the
// raw text, revealed on demand via the expand toggle.
message: condenseError(h.message),
full: h.message,
occurrences: h.occurrences,
traces: h.traces,
}));
traceRows.value = (
await runQuery(
`SELECT c.trace_id AS trace_id, COUNT(*) AS spans FROM "${s}" AS c ${joins} WHERE c.span_status = 'ERROR' AND ${af} GROUP BY c.trace_id ORDER BY spans DESC LIMIT 50`,
)
).map((h: any) => ({ trace_id: h.trace_id, spans: h.spans }));
} else if (r.signalType === "loop") {
// Worst traces first — ranked by how many times the tool repeated.
traceRows.value = (
await runQuery(
`SELECT c.trace_id AS trace_id, COUNT(*) AS repeats FROM "${s}" AS c ${joins} WHERE c.gen_ai_tool_name = '${escapeSingleQuotes(r.tool ?? "")}' AND c.gen_ai_operation_name = 'execute_tool' AND ${af} GROUP BY c.trace_id ORDER BY repeats DESC LIMIT 50`,
)
).map((h: any) => ({ trace_id: h.trace_id, repeats: h.repeats }));
} else {
// Cost outliers — the traces driving this agent's spend.
traceRows.value = (
await runQuery(
`SELECT c.trace_id AS trace_id, ROUND(SUM(c.gen_ai_usage_cost), 4) AS cost, SUM(c.gen_ai_usage_total_tokens) AS tokens FROM "${s}" AS c ${joins} WHERE ${af} AND c.gen_ai_usage_cost IS NOT NULL GROUP BY c.trace_id ORDER BY cost DESC LIMIT 50`,
)
).map((h: any) => ({
trace_id: h.trace_id,
cost: h.cost,
tokens: h.tokens,
}));
}
} finally {
loading.value = false;
}
};
// Open the trace in a NEW tab so the drawer + Agent Behavior page stay put —
// the user is triaging a list and shouldn't lose their place. Same
// resolve → window.open pattern the Trace Details page uses (TraceDetails.vue).
//
// Trace Details looks the trace up within [from, to] (µs). If we forwarded an
// undefined/0 window (which happens when the page's relative window hasn't been
// resolved to absolute bounds yet) the lookup gets `from=NaN` and reports
// "trace not found". So we always send a concrete, containing window: the
// panel's own bounds when present, else a wide fallback (last 30 days). Trace
// Details re-derives the exact trace time from its spans, so a wide window is
// safe — it only needs to contain the trace.
const openTrace = (row: { trace_id: string }) => {
if (!row?.trace_id) return;
const nowUs = Date.now() * 1000;
// Guarantee a valid, containing µs window. Trace Details re-derives the exact
// trace time from its spans, so a wide window is safe — it only needs to
// contain the trace. (A 0/undefined bound here would reach Trace Details as
// `from=NaN` and report "trace not found".) Params are sent as STRINGS, the
// same shape the built-in "expand to full view" navigation uses.
const fromUs =
props.startTime && props.startTime > 0
? props.startTime
: nowUs - 30 * 24 * 60 * 60 * 1_000_000;
const toUs = props.endTime && props.endTime > 0 ? props.endTime : nowUs;
const resolved = router.resolve({
name: "traceDetails",
query: {
stream: props.sourceStream,
trace_id: row.trace_id,
from: String(fromUs),
to: String(toUs),
org_identifier: orgId.value,
},
});
window.open(resolved.href, "_blank", "noopener,noreferrer");
};
watch(
() => [props.open, props.row],
() => {
if (props.open && props.row) fetchDetails();
},
);
</script>
@@ -71,6 +71,7 @@ const activeSection = computed<string>(() => {
if (route.name === "aiLLMInsights") return "llmInsights";
if (route.name === "aiSessions") return "sessions";
if (route.name === "aiAgentGraph") return "agentGraph";
if (route.name === "aiAgentBehavior") return "agentBehavior";
if (route.name === "aiEvaluations") {
const tab = (route.query.tab as string) || "quality";
return tab;
@@ -105,6 +106,14 @@ const sectionItems = computed<(SectionHubItem & { group: string })[]>(() => [
dataTest: "ai-secondary-nav-agent-graph",
group: "Monitor",
},
{
key: "agentBehavior",
label: t("aiObservability.nav.agentBehavior"),
icon: "troubleshoot",
to: { name: "aiAgentBehavior", query: orgQuery.value },
dataTest: "ai-secondary-nav-agent-behavior",
group: "Monitor",
},
{
key: "quality",
label: t("aiObservability.nav.quality"),
+1
View File
@@ -1090,6 +1090,7 @@ defineExpose({
<OTableEmpty
v-if="showEmpty"
:message="props.emptyMessage"
:floor="!props.fillHeight"
>
<template v-if="slots.empty" #default>
<slot name="empty" />
@@ -7,9 +7,19 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
const { t } = useI18n();
defineProps<{
message?: string;
}>();
withDefaults(
defineProps<{
message?: string;
/**
* Reserve a minimum height so the empty state has presence in
* content-sized (non-fill-height) tables. Fill-height tables turn this
* off: their container already has a definite height, and the floor
* would force a scrollbar when the table is shorter than the floor.
*/
floor?: boolean;
}>(),
{ floor: true },
);
defineSlots<{
default(): any;
@@ -19,7 +29,10 @@ defineSlots<{
<template>
<div
data-test="o2-table-empty"
class="flex flex-1 flex-col items-center justify-center min-h-75"
:class="[
'flex flex-1 flex-col items-center justify-center',
floor ? 'min-h-75' : '',
]"
>
<slot>
<OEmptyState size="inline" :title="message ?? t('search.noData')" />
+58 -4
View File
@@ -6848,7 +6848,7 @@
"lLMInsightsDashboard": {
"stream": "Stream",
"agent": "Agent",
"failedToLoad": "Failed to load LLM Insights",
"failedToLoad": "Failed to load Insights",
"retry": "Retry",
"noAgentsInRange": "No Agents In This Range",
"noAgentsDescription": "No GenAI agents were detected for the selected time window. Try a wider range, switch to the Stream tab, or configure agent mapping in settings.",
@@ -7699,9 +7699,10 @@
"evaluate": "Evaluate"
},
"nav": {
"llmInsights": "LLM Insights",
"sessions": "LLM Sessions",
"llmInsights": "Insights",
"sessions": "Sessions",
"agentGraph": "Agent Graph",
"agentBehavior": "Agent Behavior",
"quality": "Quality",
"evalJobs": "Eval Jobs",
"scorers": "Scorers",
@@ -7711,6 +7712,7 @@
"llmInsights": "Track latency, errors, tokens, and cost across your LLM traffic",
"sessions": "Browse and inspect agent sessions turn by turn",
"agentGraph": "See what your agents interact with — tools, models, services, and datastores",
"agentBehavior": "Spot agents stuck in loops and the errors making them fail",
"quality": "Score and track the quality of your LLM responses",
"evalJobs": "Run and monitor evaluation jobs across your sessions",
"scorers": "Create and manage the scorers that grade LLM outputs",
@@ -7765,6 +7767,58 @@
"title": "Recent errors",
"subtitle": "last 10 failed spans"
}
},
"behavior": {
"node": {
"tabLabel": "Behavior",
"cost": "Cost",
"disabled": "Agent behavior signals are turned off for this deployment"
},
"loopsTitle": "Looping agents",
"loopsHint": "Agents re-running the same tool many times per trace",
"failuresTitle": "Failure taxonomy",
"failuresHint": "Errors grouped by kind, per agent",
"costTitle": "Cost & failures by agent",
"costHint": "Which agents drive spend and errors",
"noLoops": "No looping behavior detected in this range",
"noFailures": "No agent failures in this range",
"noCost": "No agent cost recorded in this range",
"unknownAgent": "(unknown agent)",
"colAgent": "Agent",
"colTool": "Tool",
"colRatio": "Calls / trace",
"colCalls": "Calls",
"colTraces": "Traces",
"colFailClass": "Failure class",
"colCount": "Count",
"colCost": "Cost ($)",
"colTokens": "Tokens",
"colErrors": "Errors",
"sectionTitle": "Agent behavior",
"detail": {
"title": "Signal details",
"tracesTitle": "Traces",
"tracesHint": "Click a trace to open it",
"worstLoopTraces": "Worst traces (most repeats first)",
"topCostTraces": "Most expensive traces",
"noTraces": "No traces found for this signal",
"errorsTitle": "What the errors say",
"noErrors": "No error messages captured — open a trace to inspect",
"colTraceId": "Trace",
"colSpans": "Spans",
"colRepeats": "Repeats",
"openInNewTab": "Opens the trace in a new tab",
"showFull": "Show full error",
"showLess": "Show less",
"colError": "Error message",
"colOccurrences": "Times",
"loopHeadline": "{agent} called {tool} {ratio}× per trace on average ({calls} calls across {traces} traces).",
"failHeadline": "{agent} hit {count} “{cls}” errors.",
"costHeadline": "{agent}: ${cost} spend · {tokens} tokens · {errors} errors.",
"loopExplain": "A high repeat rate usually means the agent isn't caching results or is retrying. Open the worst traces below to see the repeated calls.",
"failExplain": "These are the actual error messages behind this failure class — read them to find the fix, or open a trace to see where it happened.",
"costExplain": "These traces drove this agent's spend. Open the most expensive ones to see what ran."
}
}
},
"onlineEvals": {
@@ -9759,4 +9813,4 @@
"sourceMapsRefresh": "Refresh list"
}
}
}
}
@@ -178,6 +178,10 @@ beforeEach(() => {
// Reset localStorage between tests so the dashboard's stream
// initialisation doesn't bleed across cases.
localStorage.clear();
// The dashboard defaults to Agent scope now ("the AI module is
// agent-centric"); this suite exercises the stream-mode paths, so opt into
// stream mode the way a returning user would — the persisted preference.
localStorage.setItem("llmInsights_filterMode", "stream");
// Default streams response.
mockGetStreams.mockResolvedValue({
list: [
+17 -10
View File
@@ -28,21 +28,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
instead of floating inside a padded box. -->
<div
v-if="availableStreams.length > 0"
class="flex items-center justify-end gap-2 px-page-edge py-2"
class="flex items-center gap-3 px-page-edge py-2 border-b border-border-default"
>
<!-- Filter mode: view a whole Stream, or a single Agent. Sits directly
beside the picker so switching mode and choosing the value are one
motion. On the Agent tab the stream + trace filter are derived from
the agents API (agent.source_stream), so there's no separate agent
filter. -->
<!-- Scope control — left-aligned Stream/Agent bar directly under the
header, matching Agent Graph / Agent Behavior / Sessions so every AI
page places its scope selector the same way. Switching mode and
choosing the value are one motion. On the Agent tab the stream +
trace filter are derived from the agents API (agent.source_stream). -->
<OToggleGroup
:model-value="filterMode"
type="single"
data-test="llm-insights-filter-mode"
@update:model-value="onFilterModeChange"
>
<OToggleGroupItem value="stream" size="sm">{{ t('traces.lLMInsightsDashboard.stream') }}</OToggleGroupItem>
<OToggleGroupItem value="agent" size="sm">{{ t('traces.lLMInsightsDashboard.agent') }}</OToggleGroupItem>
<OToggleGroupItem value="stream" size="sm">{{ t('traces.lLMInsightsDashboard.stream') }}</OToggleGroupItem>
</OToggleGroup>
<!-- Picker: Stream tab → stream picker; Agent tab → agent picker. -->
@@ -256,6 +256,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
</div>
<!-- Agent behavior signals (loops / failure taxonomy) moved to their own
dedicated "Agent Behavior" page under Monitor. LLM Insights keeps the
cost/latency/error story; behavior lives beside Agent Graph. -->
</div>
</div>
</template>
@@ -355,14 +359,17 @@ const switching = ref(false);
// Filter mode: "stream" = view a whole stream; "agent" = view a single agent,
// whose source stream + trace filter both come from the agents API.
const MODE_LS_KEY = "llmInsights_filterMode";
// Default scope is "agent" — the AI module is agent-centric. Explicit choices
// still win: a `?type=` URL param, then a saved localStorage preference; only
// when neither is present do we fall back to the agent default.
const filterMode = ref<"stream" | "agent">(
urlType === "agent"
? "agent"
: urlType === "stream"
? "stream"
: localStorage.getItem(MODE_LS_KEY) === "agent"
? "agent"
: "stream",
: localStorage.getItem(MODE_LS_KEY) === "stream"
? "stream"
: "agent",
);
// An agent name from the URL we still need to resolve to a concrete agent once
// the agents list loads (the URL carries the readable name, not the internal
+43 -5
View File
@@ -463,7 +463,7 @@ export default defineComponent({
},
},
emits: ["view-traces", "request:stream-change", "jump-to-stream-data"],
setup(props, { emit }) {
setup(props, { emit, expose }) {
const store = useStore();
const { isDark } = useTheme();
const router = useRouter();
@@ -472,6 +472,9 @@ export default defineComponent({
const { searchObj } = useTraces();
const loading = ref(false);
// Stamped when a graph load settles — lets a parent page show a
// "last refreshed" time next to its refresh control.
const lastRunAt = ref<number | null>(null);
const error = ref<string | null>(null);
const showSettings = ref(false);
const lastUpdated = ref("");
@@ -551,11 +554,13 @@ export default defineComponent({
// ── Zoom controls ─────────────────────────────────────────────────────────
// The mouse still zooms (roam:true) so you can focus an area with the wheel;
// scaleLimit on the series bounds it so it can't run away. The +/- buttons
// drive the SAME series `zoom` option via setOption, reading the chart's
// CURRENT zoom first so button + wheel stay in sync.
// scaleLimit on the series bounds it so it can't run away (the old "erratic"
// feel). The +/- buttons drive the SAME series `zoom` option via setOption,
// reading the chart's CURRENT zoom first so button + wheel stay in sync.
// Match the series `scaleLimit` (convertTraceData.ts) so the +/- buttons and
// box-zoom never compute a factor the chart will silently clamp away.
const ZOOM_MIN = 0.4;
const ZOOM_MAX = 4;
const ZOOM_MAX = 3;
// The live zoom level from the chart (kept in sync with wheel zoom), so a
// button press adjusts from where the user actually is, not a stale ref.
@@ -579,6 +584,7 @@ export default defineComponent({
const zoomIn = () => zoomBy(1.25);
const zoomOut = () => zoomBy(1 / 1.25);
// Fit-to-screen: recreate the chart (keyed on chartKey) so ECharts re-fits
// the full graph bounding box into the panel at zoom 1, re-centered — the
// reliable reset for both tree (layout:none) and graph series (also clears
@@ -587,6 +593,7 @@ export default defineComponent({
lastChartOptions.value = null;
chartKey.value++;
};
const toggleKindVisibility = (kind: string) => {
const s = new Set(hiddenKinds.value);
s.has(kind) ? s.delete(kind) : s.add(kind);
@@ -665,6 +672,33 @@ export default defineComponent({
// Key to control chart recreation - only change when layout/visualization type changes
const chartKey = ref(0);
// Restore drag-to-pan (map-style). The shared ChartRenderer arms the
// `dataZoomSelect` global cursor on every render, which turns a drag into a
// rectangle-select and HIJACKS it away from the graph's `roam` pan — the
// reason you couldn't move the graph left/right/up/down. This graph has no
// dataZoom, so we turn that cursor mode OFF: once the chart exists, and again
// after each `finished` render (ChartRenderer re-arms it on every setOption).
const disablePanBlockingCursor = (chart: any) => {
chart?.dispatchAction?.({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
};
const boundCursorClear = () =>
disablePanBlockingCursor(chartRendererRef.value?.chart);
watch(
[chartKey, () => chartRendererRef.value?.chart],
() => {
const chart = chartRendererRef.value?.chart;
if (!chart) return;
disablePanBlockingCursor(chart);
chart.off?.("finished", boundCursorClear);
chart.on?.("finished", boundCursorClear);
},
{ immediate: true, flush: "post" },
);
// Track last chart options to prevent unnecessary recreation for graph view
const lastChartOptions = ref<any>(null);
@@ -1576,6 +1610,7 @@ export default defineComponent({
}
} finally {
loading.value = false;
lastRunAt.value = Date.now();
}
};
@@ -1838,6 +1873,9 @@ export default defineComponent({
loadServiceGraph();
});
// Public API for parent pages (e.g. Agent Graph page's header refresh).
expose({ refresh: loadServiceGraph, loading, lastRunAt });
return {
t,
loading,
@@ -168,6 +168,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="capitalize"
data-test="service-graph-node-panel-tab-operations"
/>
<!-- Agent behavior (loops/failures) — only for agent nodes on
enterprise builds. See Agent Signals design §4b. -->
<OTab
v-if="showBehaviorTab"
name="behavior"
:label="t('aiObservability.behavior.node.tabLabel')"
class="capitalize"
data-test="service-graph-node-panel-tab-behavior"
/>
<OTab
v-for="cfg in activeResourceTabConfigs"
:key="cfg.id"
@@ -348,6 +357,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</OTabPanel>
<!-- Behavior Tab (agent nodes, enterprise) -->
<OTabPanel
v-if="showBehaviorTab"
name="behavior"
class="p-0! panel-section mb-0!"
data-test="service-graph-side-panel-behavior"
>
<AgentNodeBehaviorTab
:agent-name="behaviorAgentName"
:source-stream="streamFilter"
:start-time="timeRange.startTime"
:end-time="timeRange.endTime"
/>
</OTabPanel>
<!-- Nodes Tab -->
<!-- Dynamic OTEL resource tabs (Pods, Nodes, Hosts, Containers, Functions, ECS Tasks…) -->
<OTabPanel
@@ -610,6 +634,7 @@ import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import ServiceCatalogBarCell from "./components/ServiceCatalogBarCell.vue";
import config from "@/aws-exports";
const TelemetryCorrelationDashboard = defineAsyncComponent(
() => import("@/plugins/correlation/TelemetryCorrelationDashboard.vue"),
@@ -623,6 +648,12 @@ const TenstackTable = defineAsyncComponent(
() => import("@/components/TenstackTable.vue"),
);
// Agent-scoped behavior signals shown on agent nodes (enterprise). Async so the
// enterprise chunk only loads when an agent node's Behavior tab is opened.
const AgentNodeBehaviorTab = defineAsyncComponent(
() => import("@/enterprise/views/AIObservability/AgentNodeBehaviorTab.vue"),
);
// ---------------------------------------------------------------------------
// OTEL Resource Tab Configuration
// ---------------------------------------------------------------------------
@@ -859,6 +890,7 @@ export default defineComponent({
OCheckbox,
OIcon,
ServiceCatalogBarCell,
AgentNodeBehaviorTab,
},
props: {
selectedNode: {
@@ -1552,6 +1584,28 @@ export default defineComponent({
/** Whether the selected node is an inferred service (uninstrumented dependency). */
const isInferred = computed(() => !!props.selectedNode?.service_type);
// The clicked agent's display name (same value the graph node is keyed by
// and that AgentSignalDetailPanel filters on — see design §4b id-vs-name).
const behaviorAgentName = computed<string>(
() =>
props.selectedNode?.name ||
props.selectedNode?.label ||
props.selectedNode?.id ||
"",
);
// The Behavior tab (loop/failure signals) shows only for agent nodes on
// enterprise builds, and only when a concrete stream is selected (the
// signals stream is per-source-stream). The AgentNodeBehaviorTab itself
// degrades gracefully to a "feature off" hint if the rollup is disabled.
const showBehaviorTab = computed(
() =>
config.isEnterprise === "true" &&
props.selectedNode?.service_type === "agent" &&
props.streamFilter !== "all" &&
!!props.streamFilter,
);
// Tabs actually shown. For inferred services use the registry tabs;
// for instrumented services use the user-selected OTEL resource tabs.
const activeResourceTabConfigs = computed(() =>
@@ -2467,6 +2521,8 @@ export default defineComponent({
serviceHealth,
isAllStreamsSelected,
isInferred,
showBehaviorTab,
behaviorAgentName,
serviceNameField,
streamFieldSet,
formatNumber,
+6 -2
View File
@@ -347,7 +347,10 @@ describe("SessionsList — loading state", () => {
});
describe("SessionsList — sessions table", () => {
it("fetches stream sessions with no agent filter by default", async () => {
it("should fetch stream sessions with no agent filter when in stream mode", async () => {
// Default scope is "agent" now — opt into stream mode via the persisted
// preference, the same path a returning user takes.
localStorage.setItem("sessionsList_filterMode", "stream");
const wrapper = await mountComponent();
await refreshComponent(wrapper);
@@ -361,7 +364,8 @@ describe("SessionsList — sessions table", () => {
);
});
it("does not load agents while refreshing in stream mode", async () => {
it("should not load agents while refreshing when in stream mode", async () => {
localStorage.setItem("sessionsList_filterMode", "stream");
const wrapper = await mountComponent();
await refreshComponent(wrapper);
+69 -80
View File
@@ -28,12 +28,70 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OEmptyState size="hero" preset="no-llm-sessions" @action="onEmptyAction" />
</div>
<!-- Streams exist: OTable owns the whole surface — toolbar (stream filter +
column chooser), server-side pagination footer, column resize, and the
empty/error body. Rendering it unconditionally keeps the stream
selector reachable even when a window returns no sessions. -->
<!-- Scope control — left-aligned Stream/Agent bar directly under the page
header, matching Agent Graph / Agent Behavior / LLM Insights so every
AI page places its scope selector identically. Sits above the table
rather than inside the OTable toolbar. -->
<div
v-if="!(streamsLoaded && availableStreams.length === 0)"
class="flex items-center gap-3 px-page-edge py-2 border-b border-border-default"
>
<OToggleGroup
:model-value="filterMode"
type="single"
data-test="sessions-list-filter-mode"
@update:model-value="onFilterModeChange"
>
<OToggleGroupItem value="agent" size="sm">{{ t('traces.sessionsList.agent') }}</OToggleGroupItem>
<OToggleGroupItem value="stream" size="sm">{{ t('traces.sessionsList.stream') }}</OToggleGroupItem>
</OToggleGroup>
<div
v-if="filterMode === 'stream'"
data-test="sessions-list-stream-selector"
class="w-56 flex-shrink-0"
>
<OSkeleton type="text" v-if="!streamsLoaded" class="w-full h-8.5" />
<OSelect
v-else
v-model="activeStream"
:label="t('traces.sessionsList.streamLabel')"
label-position="inside"
:options="availableStreams.map((s) => ({ label: s, value: s }))"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
@update:model-value="onStreamChange"
/>
</div>
<div
v-else
data-test="sessions-list-agent-selector"
class="w-56 flex-shrink-0"
>
<OSkeleton type="text" v-if="!agentsLoaded" class="w-full h-8.5" />
<OSelect
v-else
v-model="activeAgent"
:label="t('traces.sessionsList.agent')"
label-position="inside"
:options="agentSelectOptions"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
@update:model-value="onAgentChange"
/>
</div>
</div>
<!-- Streams exist: OTable owns the data surface (column chooser, server-side
pagination footer, column resize, empty/error body). The scope control
lives in the page-level bar above; the header owns refresh + date.
NOTE: explicit v-if (not v-else) — the scope bar above carries its own
v-if, so a v-else here would chain to the bar and hide the table
whenever streams exist. -->
<OTable
v-else
v-if="!(streamsLoaded && availableStreams.length === 0)"
:data="sessions"
:columns="tableColumns"
:loading="loading"
@@ -57,77 +115,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@row-click="(row: any) => handleRowClick(row)"
@pagination-change="onPaginationChange"
>
<!-- Toolbar: Stream/Agent mode + matching picker aligned with the table actions. -->
<template #toolbar>
<div class="flex items-center justify-end gap-2 flex-1 min-w-0">
<OToggleGroup
:model-value="filterMode"
type="single"
data-test="sessions-list-filter-mode"
@update:model-value="onFilterModeChange"
>
<OToggleGroupItem value="stream" size="sm">{{ t('traces.sessionsList.stream') }}</OToggleGroupItem>
<OToggleGroupItem value="agent" size="sm">{{ t('traces.sessionsList.agent') }}</OToggleGroupItem>
</OToggleGroup>
<div class="flex items-center justify-end gap-2 min-w-0">
<div
v-if="filterMode === 'stream'"
data-test="sessions-list-stream-selector"
class="w-56 flex-shrink-0"
>
<!-- Hold a picker-shaped skeleton until the stream list lands, so
the selector doesn't flash an empty dropdown then populate. -->
<OSkeleton type="text" v-if="!streamsLoaded" class="w-full h-8.5" />
<OSelect
v-else
v-model="activeStream"
:label="t('traces.sessionsList.streamLabel')"
label-position="inside"
:options="availableStreams.map((s) => ({ label: s, value: s }))"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
@update:model-value="onStreamChange"
/>
</div>
<div
v-else
data-test="sessions-list-agent-selector"
class="w-56 flex-shrink-0"
>
<!-- Same treatment for agents: toggling to Agent mode kicks off the
listAgents fetch, so show the skeleton until it resolves
instead of an empty agent picker. -->
<OSkeleton type="text" v-if="!agentsLoaded" class="w-full h-8.5" />
<OSelect
v-else
v-model="activeAgent"
:label="t('traces.sessionsList.agent')"
label-position="inside"
:options="agentSelectOptions"
labelKey="label"
valueKey="value"
class="w-full rounded-default"
@update:model-value="onAgentChange"
/>
</div>
</div>
</div>
</template>
<template #toolbar-trailing>
<OButton
variant="outline"
size="icon-sm"
icon-left="refresh"
:loading="loading"
data-test="sessions-list-refresh-btn"
@click="() => refresh()"
>
<OTooltip side="bottom" :content="t('common.refresh')" shortcut-id="sessionsRefresh" />
</OButton>
</template>
<!-- Empty / error body — rendered inside the frame so the toolbar (and
thus the stream selector) stays visible. -->
@@ -249,7 +236,6 @@ import { useSessions, type SessionRow } from "./composables/useSessions";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
@@ -316,14 +302,17 @@ const activeStream = ref<string>(
);
const MODE_LS_KEY = "sessionsList_filterMode";
const AGENT_LS_KEY = "sessionsList_agentFilter";
// Default scope is "agent" — the AI module is agent-centric. Explicit choices
// still win (URL `?type=`, then saved localStorage preference); the agent
// default applies only when neither is present.
const filterMode = ref<"stream" | "agent">(
urlType === "agent"
? "agent"
: urlType === "stream"
? "stream"
: localStorage.getItem(MODE_LS_KEY) === "agent"
? "agent"
: "stream",
: localStorage.getItem(MODE_LS_KEY) === "stream"
? "stream"
: "agent",
);
const activeAgent = ref<string>(localStorage.getItem(AGENT_LS_KEY) || ALL_AGENTS_VALUE);
// `agents` / `agentsLoaded` are module-scoped (see useSessions) so the agent
+62
View File
@@ -0,0 +1,62 @@
// 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";
/** One row from the `_agent_signals` derived stream. */
export interface AgentSignalRecord {
_timestamp: number;
org_id: string;
source_stream: string;
/** "failure" | "loop" | "cost" */
signal_type: string;
agent_name?: string | null;
tool_name?: string | null;
fail_class?: string | null;
count: number;
calls?: number | null;
distinct_traces?: number | null;
cost?: number | null;
tokens?: number | null;
errors?: number | null;
p95_latency_ns?: number | null;
}
export interface AgentSignalsResponse {
signals: AgentSignalRecord[];
}
export interface AgentSignalsQuery {
start_time?: number;
end_time?: number;
signal_type?: "failure" | "loop" | "cost";
source_stream?: string;
}
/**
* Read pre-computed agent-behavior signals for an org over a window.
* Hits the small derived `_agent_signals` stream — never raw traces.
*/
const getAgentSignals = (
org_identifier: string,
query: AgentSignalsQuery = {},
) => {
return http().get<AgentSignalsResponse>(
`/api/${org_identifier}/traces/agent_signals`,
{ params: query },
);
};
export default { getAgentSignals };
+8
View File
@@ -616,6 +616,9 @@ export const convertServiceGraphToTree = (
// to extremes (the "erratic" feel), and wheel zoom centers on the cursor
// so you can focus an area. The +/- buttons drive the same zoom.
roam: true,
// Nodes + labels track zoom 1:1 (tree default 0.4 barely grew them);
// matches the graph view so both feel the same when focusing in.
nodeScaleRatio: 1,
scaleLimit: { min: 0.4, max: 4 },
selectedMode: "single",
label: {
@@ -1746,6 +1749,11 @@ export const convertServiceGraphToNetwork = (
links: edges,
// Pan + bounded wheel-zoom (scaleLimit below tames the extremes).
roam: true,
// Nodes + labels scale 1:1 with zoom (default 0.6 makes them grow
// sub-linearly, so zooming in spread the layout but barely enlarged the
// nodes — they looked static). 1.0 = "focus in and everything gets
// bigger", the expected map-like behaviour; bounded by scaleLimit.
nodeScaleRatio: 1,
draggable: false,
focusNodeAdjacency: true,
selectedMode: "single", // Enable single node selection