From 9ede45bbf7a8c4809af104f1f433f9db91dff7b1 Mon Sep 17 00:00:00 2001 From: Ashish Kolhe <35160958+oasisk@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:05 +0530 Subject: [PATCH] fix: Agent Behavior signals (loop/failure taxonomy) + Agent Graph fixes (#13279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> --- .claude/skills/ui-architect/SKILL.md | 7 + .../ui-architect/references/conventions.md | 23 + .../references/core-controls-table.md | 8 + src/api/src/handler/http/router/mod.rs | 2 + src/api_query/src/traces/mod.rs | 16 +- src/config/src/meta/agent_signals.rs | 373 +++++++++++ src/config/src/meta/mod.rs | 1 + src/config/src/meta/system_settings.rs | 2 + src/core/src/db/system_settings.rs | 75 +++ .../src/traces/agent_signals/aggregator.rs | 74 +++ src/core/src/traces/agent_signals/api.rs | 130 ++++ src/core/src/traces/agent_signals/mod.rs | 22 + .../src/traces/agent_signals/processor.rs | 137 +++++ src/core/src/traces/mod.rs | 1 + src/core/src/traces/service_graph/mod.rs | 2 + .../src/traces/service_graph/processor.rs | 15 +- web/src/enterprise/composables/router.ts | 10 +- .../AIObservability/AgentBehaviorPage.vue | 300 +++++++++ .../AIObservability/AgentBehaviorPanel.vue | 265 ++++++++ .../views/AIObservability/AgentGraphPage.vue | 60 +- .../AIObservability/AgentNodeBehaviorTab.vue | 265 ++++++++ .../AgentSignalDetailPanel.vue | 577 ++++++++++++++++++ .../views/AIObservability/Index.vue | 9 + web/src/lib/core/Table/OTable.vue | 1 + .../core/Table/sub-components/OTableEmpty.vue | 21 +- web/src/locales/languages/en-US.json | 62 +- .../traces/LLMInsightsDashboard.spec.ts | 4 + .../plugins/traces/LLMInsightsDashboard.vue | 27 +- web/src/plugins/traces/ServiceGraph.vue | 48 +- .../traces/ServiceGraphNodeSidePanel.vue | 56 ++ web/src/plugins/traces/SessionsList.spec.ts | 8 +- web/src/plugins/traces/SessionsList.vue | 149 +++-- web/src/services/agent_signals.ts | 62 ++ web/src/utils/traces/convertTraceData.ts | 8 + 34 files changed, 2707 insertions(+), 113 deletions(-) create mode 100644 src/config/src/meta/agent_signals.rs create mode 100644 src/core/src/traces/agent_signals/aggregator.rs create mode 100644 src/core/src/traces/agent_signals/api.rs create mode 100644 src/core/src/traces/agent_signals/mod.rs create mode 100644 src/core/src/traces/agent_signals/processor.rs create mode 100644 web/src/enterprise/views/AIObservability/AgentBehaviorPage.vue create mode 100644 web/src/enterprise/views/AIObservability/AgentBehaviorPanel.vue create mode 100644 web/src/enterprise/views/AIObservability/AgentNodeBehaviorTab.vue create mode 100644 web/src/enterprise/views/AIObservability/AgentSignalDetailPanel.vue create mode 100644 web/src/services/agent_signals.ts diff --git a/.claude/skills/ui-architect/SKILL.md b/.claude/skills/ui-architect/SKILL.md index 5d804df2a3..8062188cc7 100644 --- a/.claude/skills/ui-architect/SKILL.md +++ b/.claude/skills/ui-architect/SKILL.md @@ -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}`. diff --git a/.claude/skills/ui-architect/references/conventions.md b/.claude/skills/ui-architect/references/conventions.md index f82960045c..6462ab1e54 100644 --- a/.claude/skills/ui-architect/references/conventions.md +++ b/.claude/skills/ui-architect/references/conventions.md @@ -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 `` title. diff --git a/.claude/skills/ui-architect/references/core-controls-table.md b/.claude/skills/ui-architect/references/core-controls-table.md index 750671c4b7..3764fac12e 100644 --- a/.claude/skills/ui-architect/references/core-controls-table.md +++ b/.claude/skills/ui-architect/references/core-controls-table.md @@ -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` diff --git a/src/api/src/handler/http/router/mod.rs b/src/api/src/handler/http/router/mod.rs index 259eed0b95..dbe0bccf61 100644 --- a/src/api/src/handler/http/router/mod.rs +++ b/src/api/src/handler/http/router/mod.rs @@ -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)) diff --git a/src/api_query/src/traces/mod.rs b/src/api_query/src/traces/mod.rs index f8c4c5eff9..3a297f77aa 100644 --- a/src/api_query/src/traces/mod.rs +++ b/src/api_query/src/traces/mod.rs @@ -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 } diff --git a/src/config/src/meta/agent_signals.rs b/src/config/src/meta/agent_signals.rs new file mode 100644 index 0000000000..0ecabea8b3 --- /dev/null +++ b/src/config/src/meta/agent_signals.rs @@ -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 . + +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, +} + +/// 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, + + /// Failure-taxonomy rules, evaluated top-to-bottom (first match wins). + #[serde(default)] + pub failure_rules: Vec, +} + +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.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 = 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fail_class: Option, + pub count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub distinct_traces: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p95_latency_ns: Option, +} + +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()); + } +} diff --git a/src/config/src/meta/mod.rs b/src/config/src/meta/mod.rs index 9d36acadf7..87c6e3a0a6 100644 --- a/src/config/src/meta/mod.rs +++ b/src/config/src/meta/mod.rs @@ -14,6 +14,7 @@ // along with this program. If not, see . pub mod actions; +pub mod agent_signals; pub mod ai; pub mod alerts; pub mod cluster; diff --git a/src/config/src/meta/system_settings.rs b/src/config/src/meta/system_settings.rs index 595cfe14bc..f2ee60cb5b 100644 --- a/src/config/src/meta/system_settings.rs +++ b/src/config/src/meta/system_settings.rs @@ -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 diff --git a/src/core/src/db/system_settings.rs b/src/core/src/db/system_settings.rs index b90f7bc0e1..67312afdab 100644 --- a/src/core/src/db/system_settings.rs +++ b/src/core/src/db/system_settings.rs @@ -473,6 +473,81 @@ pub fn get_default_semantic_field_groups() -> Vec 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::(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 { + 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 diff --git a/src/core/src/traces/agent_signals/aggregator.rs b/src/core/src/traces/agent_signals/aggregator.rs new file mode 100644 index 0000000000..6e1e316f21 --- /dev/null +++ b/src/core/src/traces/agent_signals/aggregator.rs @@ -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 . + +//! 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, +) -> 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, +) -> Result<(), anyhow::Error> { + Ok(()) +} diff --git a/src/core/src/traces/agent_signals/api.rs b/src/core/src/traces/agent_signals/api.rs new file mode 100644 index 0000000000..0717a6ae51 --- /dev/null +++ b/src/core/src/traces/agent_signals/api.rs @@ -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 . + +//! 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, + pub end_time: Option, + /// Optional filter: "failure" | "loop" | "cost". + pub signal_type: Option, + /// Optional filter by source trace stream. + pub source_stream: Option, +} + +#[cfg(feature = "enterprise")] +pub async fn get_agent_signals( + axum::extract::Path(org_id): axum::extract::Path, + axum::extract::Query(query): axum::extract::Query, +) -> 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, + axum::extract::Query(_query): axum::extract::Query, +) -> HttpResponse { + MetaHttpResponse::forbidden("Not Supported") +} diff --git a/src/core/src/traces/agent_signals/mod.rs b/src/core/src/traces/agent_signals/mod.rs new file mode 100644 index 0000000000..91eedd12c1 --- /dev/null +++ b/src/core/src/traces/agent_signals/mod.rs @@ -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 . + +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; diff --git a/src/core/src/traces/agent_signals/processor.rs b/src/core/src/traces/agent_signals/processor.rs new file mode 100644 index 0000000000..2a43e931c2 --- /dev/null +++ b/src/core/src/traces/agent_signals/processor.rs @@ -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 . + +//! 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 = 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()); + } +} diff --git a/src/core/src/traces/mod.rs b/src/core/src/traces/mod.rs index ed3d337e71..219ac518e8 100644 --- a/src/core/src/traces/mod.rs +++ b/src/core/src/traces/mod.rs @@ -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; diff --git a/src/core/src/traces/service_graph/mod.rs b/src/core/src/traces/service_graph/mod.rs index 1b1a88bd1a..ca34415d7e 100644 --- a/src/core/src/traces/service_graph/mod.rs +++ b/src/core/src/traces/service_graph/mod.rs @@ -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 { diff --git a/src/core/src/traces/service_graph/processor.rs b/src/core/src/traces/service_graph/processor.rs index 8aa554fb63..b2ec237526 100644 --- a/src/core/src/traces/service_graph/processor.rs +++ b/src/core/src/traces/service_graph/processor.rs @@ -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, diff --git a/web/src/enterprise/composables/router.ts b/web/src/enterprise/composables/router.ts index 80e79e3adf..59c6e99cc3 100644 --- a/web/src/enterprise/composables/router.ts +++ b/web/src/enterprise/composables/router.ts @@ -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", diff --git a/web/src/enterprise/views/AIObservability/AgentBehaviorPage.vue b/web/src/enterprise/views/AIObservability/AgentBehaviorPage.vue new file mode 100644 index 0000000000..9c0dd0219e --- /dev/null +++ b/web/src/enterprise/views/AIObservability/AgentBehaviorPage.vue @@ -0,0 +1,300 @@ + + + + diff --git a/web/src/enterprise/views/AIObservability/AgentBehaviorPanel.vue b/web/src/enterprise/views/AIObservability/AgentBehaviorPanel.vue new file mode 100644 index 0000000000..5ccb0a2210 --- /dev/null +++ b/web/src/enterprise/views/AIObservability/AgentBehaviorPanel.vue @@ -0,0 +1,265 @@ + + + + diff --git a/web/src/enterprise/views/AIObservability/AgentGraphPage.vue b/web/src/enterprise/views/AIObservability/AgentGraphPage.vue index 26fae95091..0afbb6492b 100644 --- a/web/src/enterprise/views/AIObservability/AgentGraphPage.vue +++ b/web/src/enterprise/views/AIObservability/AgentGraphPage.vue @@ -37,9 +37,22 @@ along with this program. If not, see . searchObj.data.datetime.relativeTimePeriod ?? '15m' " data-test="ai-agent-graph-date-time" - class="h-[2rem]" + class="h-8" @on:date-change="onDateChange" /> + +
+ +
+ + + + diff --git a/web/src/enterprise/views/AIObservability/AgentSignalDetailPanel.vue b/web/src/enterprise/views/AIObservability/AgentSignalDetailPanel.vue new file mode 100644 index 0000000000..515f7d0d7a --- /dev/null +++ b/web/src/enterprise/views/AIObservability/AgentSignalDetailPanel.vue @@ -0,0 +1,577 @@ + + + + diff --git a/web/src/enterprise/views/AIObservability/Index.vue b/web/src/enterprise/views/AIObservability/Index.vue index 5608921386..bbe0f614de 100644 --- a/web/src/enterprise/views/AIObservability/Index.vue +++ b/web/src/enterprise/views/AIObservability/Index.vue @@ -71,6 +71,7 @@ const activeSection = computed(() => { 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"), diff --git a/web/src/lib/core/Table/OTable.vue b/web/src/lib/core/Table/OTable.vue index e6b26bb6b7..8b2017b7a4 100644 --- a/web/src/lib/core/Table/OTable.vue +++ b/web/src/lib/core/Table/OTable.vue @@ -1090,6 +1090,7 @@ defineExpose({