mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
co-authored by
ktx-vaidehi
ktx-vaidehi
parent
36518e49c1
commit
9ede45bbf7
@@ -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`
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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')" />
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user