diff --git a/docs/10-tracing-observability.md b/docs/10-tracing-observability.md index 8b32b859..f69f998f 100644 --- a/docs/10-tracing-observability.md +++ b/docs/10-tracing-observability.md @@ -193,10 +193,20 @@ worker.Stop() | Parameter | Type | Description | |-----------|------|-------------| +| `q` | string | Contains search across trace ID, trace previews, session/channel labels, joined agent/channel labels, and span previews/tool names | | `agent_id` | UUID | Filter by agent | | `user_id` | string | Filter by user | -| `status` | string | Filter by status (running, success, error, cancelled) | -| `from` / `to` | timestamp | Date range filter | +| `session_key` | string | Filter by session key | +| `status` | string | Filter by status (running, completed, error, cancelled) | +| `channel` | string | Filter by raw channel | +| `agent` | string | Contains search over agent display name and key | +| `channel_query` | string | Contains search over tenant-scoped channel instance labels | +| `from` / `to` | timestamp | `start_time` range filter; `from` inclusive, `to` exclusive | +| `min_input_tokens` / `max_input_tokens` | int | Input token range | +| `min_output_tokens` / `max_output_tokens` | int | Output token range | +| `min_tool_calls` / `max_tool_calls` | int | Tool-call count range | +| `tool_name` | string | Contains search over span tool names | +| `has_tool_calls` | boolean | Filter traces with or without tool calls | | `limit` | int | Page size (default 50) | | `offset` | int | Pagination offset | diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 93dbf079..f8c24449 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -1358,7 +1358,26 @@ LLM call tracing and cost analysis. | `GET` | `/v1/traces/{traceID}/export` | Export trace tree (gzipped JSON) | | `GET` | `/v1/runs/{runID}/timeline` | Get persisted run archive timeline items | -**Filters:** `agent_id`, `user_id`, `session_key`, `status`, `channel` +`GET /v1/traces` query params: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `q` | string | Contains search across trace ID, trace name, input/output previews, session key, raw channel, joined agent/channel labels, and span tool/input/output previews | +| `agent_id` | UUID | Exact agent UUID filter | +| `user_id` | string | Exact user filter; non-admin callers are always forced to their own user scope | +| `session_key` | string | Exact session key filter | +| `status` | string | Exact trace status filter | +| `channel` | string | Exact raw channel filter | +| `agent` | string | Contains search over joined agent display name and key | +| `channel_query` | string | Contains search over tenant-scoped channel instance name, display name, and type | +| `from` / `to` | RFC 3339 timestamp | Start-time range; `from` is inclusive, `to` is exclusive | +| `min_input_tokens` / `max_input_tokens` | int | Input token range | +| `min_output_tokens` / `max_output_tokens` | int | Output token range | +| `min_tool_calls` / `max_tool_calls` | int | Tool-call count range | +| `tool_name` | string | Contains search over span tool names | +| `has_tool_calls` | boolean | Convenience filter for traces with or without tool calls | +| `limit` | int | Page size, default 50, max 200 | +| `offset` | int | Pagination offset | `GET /v1/traces/follow` requires `session_key` or `agent_id`. Query params: `session_key`, `agent_id`, `status`, `channel`, `since` (RFC 3339), `limit` (default 50, max 200), `include_spans` (default false). Non-admin callers only see their own traces. When `since` is provided, the server returns traces matching existing filters and `(created_at > since OR end_time > since OR status = "running")`. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 2959092f..0123c6f6 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,23 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-06-11 +### Trace search and advanced filters (issue #152) + +**Changes** + +- Extended `GET /v1/traces` with keyword search, partial Trace ID search, date + ranges, token ranges, tool-call filters, and agent/channel label search. +- Added equivalent PostgreSQL and SQLite query-builder behavior with tenant + guards on joined channel/span search. +- Added Web Traces search/filter controls with active filter chips. + +**Tests** + +- Added HTTP contract coverage for advanced trace filter parsing and user scope. +- Added PostgreSQL and SQLite where-builder coverage for search, ranges, tool + filters, tenant predicates, and wildcard escaping. +- Added Web filter serialization and trace i18n key coverage. + ### Secure CLI GitHub credential runtime diagnostics (issues #138, #151) **Fixes** diff --git a/internal/http/traces.go b/internal/http/traces.go index a1a27146..c0961243 100644 --- a/internal/http/traces.go +++ b/internal/http/traces.go @@ -48,35 +48,87 @@ func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { } func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + query := r.URL.Query() opts := store.TraceListOpts{ Limit: 50, Offset: 0, } - if v := r.URL.Query().Get("agent_id"); v != "" { + if v := query.Get("agent_id"); v != "" { id, err := uuid.Parse(v) if err == nil { opts.AgentID = &id } } - if v := r.URL.Query().Get("user_id"); v != "" { + if v := query.Get("user_id"); v != "" { opts.UserID = v } - if v := r.URL.Query().Get("session_key"); v != "" { + if v := query.Get("session_key"); v != "" { opts.SessionKey = v } - if v := r.URL.Query().Get("status"); v != "" { + if v := query.Get("status"); v != "" { opts.Status = v } - if v := r.URL.Query().Get("channel"); v != "" { + if v := query.Get("channel"); v != "" { opts.Channel = v } - if v := r.URL.Query().Get("limit"); v != "" { + if v := strings.TrimSpace(query.Get("q")); v != "" { + opts.Query = v + } + if v := strings.TrimSpace(query.Get("agent")); v != "" { + opts.AgentQuery = v + } + if v := strings.TrimSpace(query.Get("channel_query")); v != "" { + opts.ChannelQuery = v + } + if v := strings.TrimSpace(query.Get("tool_name")); v != "" { + opts.ToolName = v + } + if parsed, ok, err := parseRFC3339Query(query.Get("from")); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "from must be RFC3339")}) + return + } else if ok { + opts.From = &parsed + } + if parsed, ok, err := parseRFC3339Query(query.Get("to")); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "to must be RFC3339")}) + return + } else if ok { + opts.To = &parsed + } + if n, ok := parseNonNegativeIntQuery(query.Get("min_input_tokens")); ok { + opts.MinInputTokens = &n + } + if n, ok := parseNonNegativeIntQuery(query.Get("max_input_tokens")); ok { + opts.MaxInputTokens = &n + } + if n, ok := parseNonNegativeIntQuery(query.Get("min_output_tokens")); ok { + opts.MinOutputTokens = &n + } + if n, ok := parseNonNegativeIntQuery(query.Get("max_output_tokens")); ok { + opts.MaxOutputTokens = &n + } + if n, ok := parseNonNegativeIntQuery(query.Get("min_tool_calls")); ok { + opts.MinToolCalls = &n + } + if n, ok := parseNonNegativeIntQuery(query.Get("max_tool_calls")); ok { + opts.MaxToolCalls = &n + } + if v := query.Get("has_tool_calls"); v != "" { + parsed, err := strconv.ParseBool(v) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "has_tool_calls must be boolean")}) + return + } + opts.HasToolCalls = &parsed + } + if v := query.Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { opts.Limit = n } } - if v := r.URL.Query().Get("offset"); v != "" { + if v := query.Get("offset"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { opts.Offset = n } @@ -105,6 +157,30 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) { }) } +func parseRFC3339Query(v string) (time.Time, bool, error) { + v = strings.TrimSpace(v) + if v == "" { + return time.Time{}, false, nil + } + parsed, err := time.Parse(time.RFC3339, v) + if err != nil { + return time.Time{}, false, err + } + return parsed, true, nil +} + +func parseNonNegativeIntQuery(v string) (int, bool) { + v = strings.TrimSpace(v) + if v == "" { + return 0, false + } + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + func (h *TracesHandler) handleFollow(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) query := r.URL.Query() diff --git a/internal/http/traces_follow_test.go b/internal/http/traces_follow_test.go index 432106e6..94ca325c 100644 --- a/internal/http/traces_follow_test.go +++ b/internal/http/traces_follow_test.go @@ -204,3 +204,68 @@ func TestTracesFollowRouteIsNotCapturedAsTraceID(t *testing.T) { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } + +func TestTracesListParsesAdvancedFiltersAndScopesViewer(t *testing.T) { + token := setupTraceReadToken(t, "caller") + tracing := &mockTracingStore{} + mux := http.NewServeMux() + NewTracesHandler(tracing).RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/traces?q=abc-123&from=2026-06-10T01:02:03Z&to=2026-06-11T04:05:06Z&user_id=other&status=error&channel=telegram&agent=helper&channel_query=ops&min_input_tokens=10&max_input_tokens=20&min_output_tokens=30&max_output_tokens=40&min_tool_calls=1&max_tool_calls=3&tool_name=web_%25&has_tool_calls=true&limit=25&offset=50", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if len(tracing.listOpts) != 1 { + t.Fatalf("ListTraces calls = %d, want 1", len(tracing.listOpts)) + } + opts := tracing.listOpts[0] + if opts.UserID != "caller" { + t.Fatalf("opts.UserID = %q, want caller", opts.UserID) + } + if opts.Query != "abc-123" || opts.AgentQuery != "helper" || opts.ChannelQuery != "ops" || opts.ToolName != "web_%" { + t.Fatalf("search opts = %+v, want query/agent/channel/tool filters", opts) + } + if opts.From == nil || opts.From.Format(time.RFC3339) != "2026-06-10T01:02:03Z" { + t.Fatalf("From = %v, want parsed RFC3339", opts.From) + } + if opts.To == nil || opts.To.Format(time.RFC3339) != "2026-06-11T04:05:06Z" { + t.Fatalf("To = %v, want parsed RFC3339", opts.To) + } + if opts.MinInputTokens == nil || *opts.MinInputTokens != 10 || + opts.MaxInputTokens == nil || *opts.MaxInputTokens != 20 || + opts.MinOutputTokens == nil || *opts.MinOutputTokens != 30 || + opts.MaxOutputTokens == nil || *opts.MaxOutputTokens != 40 || + opts.MinToolCalls == nil || *opts.MinToolCalls != 1 || + opts.MaxToolCalls == nil || *opts.MaxToolCalls != 3 { + t.Fatalf("range opts = %+v, want parsed token/tool ranges", opts) + } + if opts.HasToolCalls == nil || !*opts.HasToolCalls { + t.Fatalf("HasToolCalls = %v, want true", opts.HasToolCalls) + } + if opts.Status != store.TraceStatusError || opts.Channel != "telegram" || opts.Limit != 25 || opts.Offset != 50 { + t.Fatalf("existing opts = %+v, want status/channel/pagination preserved", opts) + } +} + +func TestTracesListRejectsInvalidDateRange(t *testing.T) { + token := setupTraceReadToken(t, "caller") + tracing := &mockTracingStore{} + mux := http.NewServeMux() + NewTracesHandler(tracing).RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/traces?from=not-a-time", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if len(tracing.listOpts) != 0 { + t.Fatalf("ListTraces called %d times, want 0", len(tracing.listOpts)) + } +} diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index 9046bad0..c8ee5cfe 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -121,6 +121,87 @@ func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []a args = append(args, opts.Channel) argIdx++ } + if opts.From != nil { + conditions = append(conditions, fmt.Sprintf("start_time >= $%d", argIdx)) + args = append(args, *opts.From) + argIdx++ + } + if opts.To != nil { + conditions = append(conditions, fmt.Sprintf("start_time < $%d", argIdx)) + args = append(args, *opts.To) + argIdx++ + } + if opts.MinInputTokens != nil { + conditions = append(conditions, fmt.Sprintf("total_input_tokens >= $%d", argIdx)) + args = append(args, *opts.MinInputTokens) + argIdx++ + } + if opts.MaxInputTokens != nil { + conditions = append(conditions, fmt.Sprintf("total_input_tokens <= $%d", argIdx)) + args = append(args, *opts.MaxInputTokens) + argIdx++ + } + if opts.MinOutputTokens != nil { + conditions = append(conditions, fmt.Sprintf("total_output_tokens >= $%d", argIdx)) + args = append(args, *opts.MinOutputTokens) + argIdx++ + } + if opts.MaxOutputTokens != nil { + conditions = append(conditions, fmt.Sprintf("total_output_tokens <= $%d", argIdx)) + args = append(args, *opts.MaxOutputTokens) + argIdx++ + } + if opts.MinToolCalls != nil { + conditions = append(conditions, fmt.Sprintf("tool_call_count >= $%d", argIdx)) + args = append(args, *opts.MinToolCalls) + argIdx++ + } + if opts.MaxToolCalls != nil { + conditions = append(conditions, fmt.Sprintf("tool_call_count <= $%d", argIdx)) + args = append(args, *opts.MaxToolCalls) + argIdx++ + } + if opts.HasToolCalls != nil { + if *opts.HasToolCalls { + conditions = append(conditions, "tool_call_count > 0") + } else { + conditions = append(conditions, "tool_call_count = 0") + } + } + if opts.Query != "" { + placeholder := fmt.Sprintf("$%d", argIdx) + conditions = append(conditions, fmt.Sprintf(`( + CAST(id AS text) ILIKE %[1]s ESCAPE '\' OR + COALESCE(name, '') ILIKE %[1]s ESCAPE '\' OR + COALESCE(input_preview, '') ILIKE %[1]s ESCAPE '\' OR + COALESCE(output_preview, '') ILIKE %[1]s ESCAPE '\' OR + COALESCE(session_key, '') ILIKE %[1]s ESCAPE '\' OR + COALESCE(channel, '') ILIKE %[1]s ESCAPE '\' OR + EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id AND (COALESCE(a.display_name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(a.agent_key, '') ILIKE %[1]s ESCAPE '\')) OR + EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id AND (COALESCE(ci.display_name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(ci.name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(ci.channel_type, '') ILIKE %[1]s ESCAPE '\')) OR + EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id AND (COALESCE(s.tool_name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(s.input_preview, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(s.output_preview, '') ILIKE %[1]s ESCAPE '\')) + )`, placeholder)) + args = append(args, containsPattern(opts.Query)) + argIdx++ + } + if opts.AgentQuery != "" { + placeholder := fmt.Sprintf("$%d", argIdx) + conditions = append(conditions, fmt.Sprintf(`EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id AND (COALESCE(a.display_name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(a.agent_key, '') ILIKE %[1]s ESCAPE '\'))`, placeholder)) + args = append(args, containsPattern(opts.AgentQuery)) + argIdx++ + } + if opts.ChannelQuery != "" { + placeholder := fmt.Sprintf("$%d", argIdx) + conditions = append(conditions, fmt.Sprintf(`EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id AND (COALESCE(ci.display_name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(ci.name, '') ILIKE %[1]s ESCAPE '\' OR COALESCE(ci.channel_type, '') ILIKE %[1]s ESCAPE '\'))`, placeholder)) + args = append(args, containsPattern(opts.ChannelQuery)) + argIdx++ + } + if opts.ToolName != "" { + placeholder := fmt.Sprintf("$%d", argIdx) + conditions = append(conditions, fmt.Sprintf(`EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id AND s.tool_name ILIKE %[1]s ESCAPE '\')`, placeholder)) + args = append(args, containsPattern(opts.ToolName)) + argIdx++ + } if opts.ChangedAfter != nil { conditions = append(conditions, fmt.Sprintf("(created_at > $%d OR end_time > $%d OR status = $%d)", argIdx, argIdx, argIdx+1)) args = append(args, *opts.ChangedAfter, store.TraceStatusRunning) @@ -134,6 +215,23 @@ func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []a return where, args } +func containsPattern(value string) string { + return "%" + escapeLike(value) + "%" +} + +func escapeLike(value string) string { + var b strings.Builder + b.Grow(len(value)) + for _, r := range value { + switch r { + case '\\', '%', '_': + b.WriteRune('\\') + } + b.WriteRune(r) + } + return b.String() +} + func (s *PGTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) { where, args := buildTraceWhere(ctx, opts) var count int diff --git a/internal/store/pg/tracing_follow_test.go b/internal/store/pg/tracing_follow_test.go index 0fb0f23d..9fe53e23 100644 --- a/internal/store/pg/tracing_follow_test.go +++ b/internal/store/pg/tracing_follow_test.go @@ -1,6 +1,7 @@ package pg import ( + "strings" "testing" "time" @@ -37,3 +38,60 @@ func TestBuildTraceWhereChangedAfterKeepsExistingFiltersGrouped(t *testing.T) { t.Fatalf("running status arg = %#v, want %q", args[7], store.TraceStatusRunning) } } + +func TestBuildTraceWhereAdvancedFilters(t *testing.T) { + tenantID := uuid.New() + from := time.Date(2026, 6, 10, 1, 2, 3, 0, time.UTC) + to := time.Date(2026, 6, 11, 4, 5, 6, 0, time.UTC) + minIn, maxIn := 10, 20 + minOut, maxOut := 30, 40 + minTools, maxTools := 1, 3 + hasTools := true + + where, args := buildTraceWhere(store.WithTenantID(t.Context(), tenantID), store.TraceListOpts{ + Query: `abc_%\`, + From: &from, + To: &to, + AgentQuery: "helper", + ChannelQuery: "ops", + MinInputTokens: &minIn, + MaxInputTokens: &maxIn, + MinOutputTokens: &minOut, + MaxOutputTokens: &maxOut, + MinToolCalls: &minTools, + MaxToolCalls: &maxTools, + ToolName: `web_%`, + HasToolCalls: &hasTools, + }) + + for _, fragment := range []string{ + "tenant_id = $1", + "start_time >= $2", + "start_time < $3", + "total_input_tokens >= $4", + "total_input_tokens <= $5", + "total_output_tokens >= $6", + "total_output_tokens <= $7", + "tool_call_count >= $8", + "tool_call_count <= $9", + "tool_call_count > 0", + "CAST(id AS text) ILIKE $10 ESCAPE '\\'", + "EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id", + "EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id", + "EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id", + "s.tool_name ILIKE $13 ESCAPE '\\'", + } { + if !strings.Contains(where, fragment) { + t.Fatalf("where missing %q:\n%s", fragment, where) + } + } + if len(args) != 13 { + t.Fatalf("args len = %d, want 13: %#v", len(args), args) + } + if args[9] != `%abc\_\%\\%` { + t.Fatalf("query arg = %#v, want escaped contains pattern", args[9]) + } + if args[12] != `%web\_\%%` { + t.Fatalf("tool arg = %#v, want escaped contains pattern", args[12]) + } +} diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go index e0ab0cae..d0e7a9e3 100644 --- a/internal/store/sqlitestore/tracing.go +++ b/internal/store/sqlitestore/tracing.go @@ -108,6 +108,76 @@ func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []a conditions = append(conditions, "channel = ?") args = append(args, opts.Channel) } + if opts.From != nil { + conditions = append(conditions, "start_time >= ?") + args = append(args, *opts.From) + } + if opts.To != nil { + conditions = append(conditions, "start_time < ?") + args = append(args, *opts.To) + } + if opts.MinInputTokens != nil { + conditions = append(conditions, "total_input_tokens >= ?") + args = append(args, *opts.MinInputTokens) + } + if opts.MaxInputTokens != nil { + conditions = append(conditions, "total_input_tokens <= ?") + args = append(args, *opts.MaxInputTokens) + } + if opts.MinOutputTokens != nil { + conditions = append(conditions, "total_output_tokens >= ?") + args = append(args, *opts.MinOutputTokens) + } + if opts.MaxOutputTokens != nil { + conditions = append(conditions, "total_output_tokens <= ?") + args = append(args, *opts.MaxOutputTokens) + } + if opts.MinToolCalls != nil { + conditions = append(conditions, "tool_call_count >= ?") + args = append(args, *opts.MinToolCalls) + } + if opts.MaxToolCalls != nil { + conditions = append(conditions, "tool_call_count <= ?") + args = append(args, *opts.MaxToolCalls) + } + if opts.HasToolCalls != nil { + if *opts.HasToolCalls { + conditions = append(conditions, "tool_call_count > 0") + } else { + conditions = append(conditions, "tool_call_count = 0") + } + } + if opts.Query != "" { + conditions = append(conditions, `( + CAST(id AS TEXT) LIKE ? ESCAPE '\' OR + COALESCE(name, '') LIKE ? ESCAPE '\' OR + COALESCE(input_preview, '') LIKE ? ESCAPE '\' OR + COALESCE(output_preview, '') LIKE ? ESCAPE '\' OR + COALESCE(session_key, '') LIKE ? ESCAPE '\' OR + COALESCE(channel, '') LIKE ? ESCAPE '\' OR + EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id AND (COALESCE(a.display_name, '') LIKE ? ESCAPE '\' OR COALESCE(a.agent_key, '') LIKE ? ESCAPE '\')) OR + EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id AND (COALESCE(ci.display_name, '') LIKE ? ESCAPE '\' OR COALESCE(ci.name, '') LIKE ? ESCAPE '\' OR COALESCE(ci.channel_type, '') LIKE ? ESCAPE '\')) OR + EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id AND (COALESCE(s.tool_name, '') LIKE ? ESCAPE '\' OR COALESCE(s.input_preview, '') LIKE ? ESCAPE '\' OR COALESCE(s.output_preview, '') LIKE ? ESCAPE '\')) + )`) + pattern := containsPattern(opts.Query) + for range 14 { + args = append(args, pattern) + } + } + if opts.AgentQuery != "" { + conditions = append(conditions, `EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id AND (COALESCE(a.display_name, '') LIKE ? ESCAPE '\' OR COALESCE(a.agent_key, '') LIKE ? ESCAPE '\'))`) + pattern := containsPattern(opts.AgentQuery) + args = append(args, pattern, pattern) + } + if opts.ChannelQuery != "" { + conditions = append(conditions, `EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id AND (COALESCE(ci.display_name, '') LIKE ? ESCAPE '\' OR COALESCE(ci.name, '') LIKE ? ESCAPE '\' OR COALESCE(ci.channel_type, '') LIKE ? ESCAPE '\'))`) + pattern := containsPattern(opts.ChannelQuery) + args = append(args, pattern, pattern, pattern) + } + if opts.ToolName != "" { + conditions = append(conditions, `EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id AND s.tool_name LIKE ? ESCAPE '\')`) + args = append(args, containsPattern(opts.ToolName)) + } if opts.ChangedAfter != nil { conditions = append(conditions, "(created_at > ? OR end_time > ? OR status = ?)") args = append(args, *opts.ChangedAfter, *opts.ChangedAfter, store.TraceStatusRunning) @@ -119,6 +189,10 @@ func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []a return " WHERE " + strings.Join(conditions, " AND "), args } +func containsPattern(value string) string { + return "%" + escapeLike(value) + "%" +} + func (s *SQLiteTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) { where, args := buildTraceWhere(ctx, opts) var count int diff --git a/internal/store/sqlitestore/tracing_follow_test.go b/internal/store/sqlitestore/tracing_follow_test.go index 339319c6..1f7d3b90 100644 --- a/internal/store/sqlitestore/tracing_follow_test.go +++ b/internal/store/sqlitestore/tracing_follow_test.go @@ -3,6 +3,7 @@ package sqlitestore import ( + "strings" "testing" "time" @@ -39,3 +40,60 @@ func TestBuildTraceWhereChangedAfterKeepsExistingFiltersGrouped(t *testing.T) { t.Fatalf("running status arg = %#v, want %q", args[8], store.TraceStatusRunning) } } + +func TestBuildTraceWhereAdvancedFilters(t *testing.T) { + tenantID := uuid.New() + from := time.Date(2026, 6, 10, 1, 2, 3, 0, time.UTC) + to := time.Date(2026, 6, 11, 4, 5, 6, 0, time.UTC) + minIn, maxIn := 10, 20 + minOut, maxOut := 30, 40 + minTools, maxTools := 1, 3 + hasTools := false + + where, args := buildTraceWhere(store.WithTenantID(t.Context(), tenantID), store.TraceListOpts{ + Query: `abc_%\`, + From: &from, + To: &to, + AgentQuery: "helper", + ChannelQuery: "ops", + MinInputTokens: &minIn, + MaxInputTokens: &maxIn, + MinOutputTokens: &minOut, + MaxOutputTokens: &maxOut, + MinToolCalls: &minTools, + MaxToolCalls: &maxTools, + ToolName: `web_%`, + HasToolCalls: &hasTools, + }) + + for _, fragment := range []string{ + "tenant_id = ?", + "start_time >= ?", + "start_time < ?", + "total_input_tokens >= ?", + "total_input_tokens <= ?", + "total_output_tokens >= ?", + "total_output_tokens <= ?", + "tool_call_count >= ?", + "tool_call_count <= ?", + "tool_call_count = 0", + "CAST(id AS TEXT) LIKE ? ESCAPE '\\'", + "EXISTS (SELECT 1 FROM agents a WHERE a.id = traces.agent_id AND a.tenant_id = traces.tenant_id", + "EXISTS (SELECT 1 FROM channel_instances ci WHERE ci.name = traces.channel AND ci.tenant_id = traces.tenant_id", + "EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = traces.id AND s.tenant_id = traces.tenant_id", + "s.tool_name LIKE ? ESCAPE '\\'", + } { + if !strings.Contains(where, fragment) { + t.Fatalf("where missing %q:\n%s", fragment, where) + } + } + if len(args) != 29 { + t.Fatalf("args len = %d, want 29: %#v", len(args), args) + } + if args[9] != `%abc\_\%\\%` { + t.Fatalf("query arg = %#v, want escaped contains pattern", args[9]) + } + if args[28] != `%web\_\%%` { + t.Fatalf("tool arg = %#v, want escaped contains pattern", args[28]) + } +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 98d8e1f7..2f652754 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -99,14 +99,27 @@ type SpanData struct { // TraceListOpts configures trace listing. type TraceListOpts struct { - AgentID *uuid.UUID - UserID string - SessionKey string - Status string - Channel string - ChangedAfter *time.Time - Limit int - Offset int + AgentID *uuid.UUID + UserID string + SessionKey string + Status string + Channel string + Query string + AgentQuery string + ChannelQuery string + From *time.Time + To *time.Time + MinInputTokens *int + MaxInputTokens *int + MinOutputTokens *int + MaxOutputTokens *int + MinToolCalls *int + MaxToolCalls *int + ToolName string + HasToolCalls *bool + ChangedAfter *time.Time + Limit int + Offset int } // CostSummaryOpts configures cost aggregation queries. diff --git a/ui/web/src/i18n/locales/en/traces.json b/ui/web/src/i18n/locales/en/traces.json index 25d37082..bfa20d35 100644 --- a/ui/web/src/i18n/locales/en/traces.json +++ b/ui/web/src/i18n/locales/en/traces.json @@ -5,6 +5,49 @@ "filter": "Filter", "allAgents": "All agents", "allChannels": "All channels", + "status": { + "running": "Running", + "completed": "Completed", + "error": "Error", + "cancelled": "Cancelled" + }, + "filters": { + "search": "Search traces or trace ID...", + "status": "Status", + "allStatuses": "All statuses", + "advanced": "Advanced", + "from": "From", + "to": "To", + "minInputTokens": "Min input tokens", + "maxInputTokens": "Max input tokens", + "minOutputTokens": "Min output tokens", + "maxOutputTokens": "Max output tokens", + "minToolCalls": "Min tool calls", + "maxToolCalls": "Max tool calls", + "toolName": "Tool name", + "hasToolCalls": "Tool calls", + "anyToolCalls": "Any tool calls", + "withToolCalls": "With tool calls", + "withoutToolCalls": "Without tool calls", + "clearFilter": "Clear filter", + "clearAll": "Clear all", + "chips": { + "query": "Search", + "agentId": "Agent", + "status": "Status", + "channel": "Channel", + "from": "From", + "to": "To", + "minInputTokens": "Min input", + "maxInputTokens": "Max input", + "minOutputTokens": "Min output", + "maxOutputTokens": "Max output", + "minToolCalls": "Min tools", + "maxToolCalls": "Max tools", + "toolName": "Tool", + "hasToolCalls": "Tool calls" + } + }, "emptyTitle": "No traces", "emptyDescription": "No traces found. Traces are recorded when agents process requests.", "columns": { diff --git a/ui/web/src/i18n/locales/vi/traces.json b/ui/web/src/i18n/locales/vi/traces.json index 5fdeda05..d981bd88 100644 --- a/ui/web/src/i18n/locales/vi/traces.json +++ b/ui/web/src/i18n/locales/vi/traces.json @@ -5,6 +5,49 @@ "filter": "Lọc", "allAgents": "Tất cả agent", "allChannels": "Tất cả kênh", + "status": { + "running": "Đang chạy", + "completed": "Hoàn tất", + "error": "Lỗi", + "cancelled": "Đã huỷ" + }, + "filters": { + "search": "Tìm trace hoặc trace ID...", + "status": "Trạng thái", + "allStatuses": "Tất cả trạng thái", + "advanced": "Nâng cao", + "from": "Từ", + "to": "Đến", + "minInputTokens": "Input token tối thiểu", + "maxInputTokens": "Input token tối đa", + "minOutputTokens": "Output token tối thiểu", + "maxOutputTokens": "Output token tối đa", + "minToolCalls": "Tool call tối thiểu", + "maxToolCalls": "Tool call tối đa", + "toolName": "Tên tool", + "hasToolCalls": "Tool call", + "anyToolCalls": "Bất kỳ tool call", + "withToolCalls": "Có tool call", + "withoutToolCalls": "Không có tool call", + "clearFilter": "Xoá bộ lọc", + "clearAll": "Xoá tất cả", + "chips": { + "query": "Tìm", + "agentId": "Agent", + "status": "Trạng thái", + "channel": "Kênh", + "from": "Từ", + "to": "Đến", + "minInputTokens": "Input tối thiểu", + "maxInputTokens": "Input tối đa", + "minOutputTokens": "Output tối thiểu", + "maxOutputTokens": "Output tối đa", + "minToolCalls": "Tool tối thiểu", + "maxToolCalls": "Tool tối đa", + "toolName": "Tool", + "hasToolCalls": "Tool call" + } + }, "emptyTitle": "Không có dữ liệu theo dõi", "emptyDescription": "Không tìm thấy dữ liệu theo dõi. Theo dõi được ghi lại khi agent xử lý yêu cầu.", "columns": { diff --git a/ui/web/src/i18n/locales/zh/traces.json b/ui/web/src/i18n/locales/zh/traces.json index 2eaf16bf..e78d6d42 100644 --- a/ui/web/src/i18n/locales/zh/traces.json +++ b/ui/web/src/i18n/locales/zh/traces.json @@ -37,6 +37,49 @@ "allChannels": "所有渠道", "filter": "筛选", "filterPlaceholder": "按Agent ID 筛选...", + "status": { + "running": "运行中", + "completed": "已完成", + "error": "错误", + "cancelled": "已取消" + }, + "filters": { + "search": "搜索追踪或追踪 ID...", + "status": "状态", + "allStatuses": "所有状态", + "advanced": "高级", + "from": "开始", + "to": "结束", + "minInputTokens": "最小输入令牌", + "maxInputTokens": "最大输入令牌", + "minOutputTokens": "最小输出令牌", + "maxOutputTokens": "最大输出令牌", + "minToolCalls": "最小工具调用", + "maxToolCalls": "最大工具调用", + "toolName": "工具名", + "hasToolCalls": "工具调用", + "anyToolCalls": "任意工具调用", + "withToolCalls": "有工具调用", + "withoutToolCalls": "无工具调用", + "clearFilter": "清除筛选", + "clearAll": "全部清除", + "chips": { + "query": "搜索", + "agentId": "Agent", + "status": "状态", + "channel": "渠道", + "from": "开始", + "to": "结束", + "minInputTokens": "最小输入", + "maxInputTokens": "最大输入", + "minOutputTokens": "最小输出", + "maxOutputTokens": "最大输出", + "minToolCalls": "最小工具", + "maxToolCalls": "最大工具", + "toolName": "工具", + "hasToolCalls": "工具调用" + } + }, "span": { "cacheRead": "读取", "cacheWrite": "写入", diff --git a/ui/web/src/pages/traces/__tests__/trace-filter-params.test.ts b/ui/web/src/pages/traces/__tests__/trace-filter-params.test.ts new file mode 100644 index 00000000..282ebd8c --- /dev/null +++ b/ui/web/src/pages/traces/__tests__/trace-filter-params.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildTraceRequestParams, getActiveTraceFilterChips } from "../trace-filter-params"; + +describe("trace filter params", () => { + it("serializes advanced filters without empty values", () => { + const params = buildTraceRequestParams({ + query: "abc-123", + agentId: "agent-1", + channel: "telegram", + status: "error", + from: "2026-06-10T01:02:03.000Z", + to: "2026-06-11T04:05:06.000Z", + minInputTokens: "10", + maxInputTokens: "20", + minOutputTokens: "30", + maxOutputTokens: "40", + minToolCalls: "1", + maxToolCalls: "3", + toolName: "web_search", + hasToolCalls: "true", + limit: 25, + offset: 50, + }); + + expect(params).toEqual({ + q: "abc-123", + agent_id: "agent-1", + channel: "telegram", + status: "error", + from: "2026-06-10T01:02:03.000Z", + to: "2026-06-11T04:05:06.000Z", + min_input_tokens: "10", + max_input_tokens: "20", + min_output_tokens: "30", + max_output_tokens: "40", + min_tool_calls: "1", + max_tool_calls: "3", + tool_name: "web_search", + has_tool_calls: "true", + limit: "25", + offset: "50", + }); + }); + + it("builds chips for every non-pagination filter", () => { + const chips = getActiveTraceFilterChips({ + query: "abc", + status: "running", + hasToolCalls: "false", + limit: 50, + offset: 0, + }); + expect(chips.map((chip) => chip.key)).toEqual(["query", "status", "hasToolCalls"]); + }); +}); diff --git a/ui/web/src/pages/traces/__tests__/traces-i18n.test.ts b/ui/web/src/pages/traces/__tests__/traces-i18n.test.ts new file mode 100644 index 00000000..4fd30833 --- /dev/null +++ b/ui/web/src/pages/traces/__tests__/traces-i18n.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +describe("traces i18n filter keys", () => { + it("all trace locales expose the same top-level keys", async () => { + const en = await import("@/i18n/locales/en/traces.json"); + const vi = await import("@/i18n/locales/vi/traces.json"); + const zh = await import("@/i18n/locales/zh/traces.json"); + + const enKeys = Object.keys(en as Record).sort(); + expect(Object.keys(vi as Record).sort()).toEqual(enKeys); + expect(Object.keys(zh as Record).sort()).toEqual(enKeys); + }); + + it("filter labels exist in all locales", async () => { + for (const locale of ["en", "vi", "zh"]) { + const mod = await import(`@/i18n/locales/${locale}/traces.json`); + const data = mod as unknown as { filters?: Record }; + for (const key of ["search", "status", "advanced", "from", "to", "toolName", "hasToolCalls", "clearAll"]) { + expect(data.filters?.[key], `${locale} missing filters.${key}`).toBeTruthy(); + } + } + }); +}); diff --git a/ui/web/src/pages/traces/hooks/use-traces.ts b/ui/web/src/pages/traces/hooks/use-traces.ts index 27afb1b5..598afe9f 100644 --- a/ui/web/src/pages/traces/hooks/use-traces.ts +++ b/ui/web/src/pages/traces/hooks/use-traces.ts @@ -3,17 +3,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; import { queryKeys } from "@/lib/query-keys"; import type { TraceData, SpanData } from "@/types/trace"; +import { buildTraceRequestParams, type TraceFilters } from "../trace-filter-params"; export type { TraceData, SpanData }; - -export interface TraceFilters { - agentId?: string; - userId?: string; - status?: string; - channel?: string; - limit?: number; - offset?: number; -} +export type { TraceFilters }; export function useTraces(filters: TraceFilters = {}) { const http = useHttp(); @@ -24,14 +17,7 @@ export function useTraces(filters: TraceFilters = {}) { const { data, isLoading: loading, isFetching } = useQuery({ queryKey, queryFn: async () => { - const params: Record = {}; - if (filters.agentId) params.agent_id = filters.agentId; - if (filters.userId) params.user_id = filters.userId; - if (filters.status) params.status = filters.status; - if (filters.channel) params.channel = filters.channel; - if (filters.limit) params.limit = String(filters.limit); - if (filters.offset !== undefined) params.offset = String(filters.offset); - + const params = buildTraceRequestParams(filters); const res = await http.get<{ traces: TraceData[]; total?: number }>("/v1/traces", params); return { traces: res.traces ?? [], total: res.total ?? 0 }; }, diff --git a/ui/web/src/pages/traces/trace-filter-bar.tsx b/ui/web/src/pages/traces/trace-filter-bar.tsx new file mode 100644 index 00000000..e4f6eaf4 --- /dev/null +++ b/ui/web/src/pages/traces/trace-filter-bar.tsx @@ -0,0 +1,147 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Filter, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SearchInput } from "@/components/shared/search-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + clearTraceFilter, + getActiveTraceFilterChips, + type TraceFilters, +} from "./trace-filter-params"; + +interface TraceFilterBarProps { + filters: TraceFilters; + agents: Array<{ id: string; display_name?: string; agent_key?: string }>; + channels: Array<{ id: string; name: string; display_name?: string }>; + onChange: (filters: TraceFilters) => void; +} + +const STATUS_OPTIONS = ["running", "completed", "error", "cancelled"]; + +export function TraceFilterBar({ filters, agents, channels, onChange }: TraceFilterBarProps) { + const { t } = useTranslation("traces"); + const [advancedOpen, setAdvancedOpen] = useState(false); + const chips = useMemo(() => getActiveTraceFilterChips(filters), [filters]); + + const update = (patch: Partial) => { + onChange(cleanFilters({ ...filters, ...patch })); + }; + const clearOne = (key: keyof TraceFilters) => onChange(clearTraceFilter(filters, key)); + const clearAll = () => onChange({}); + + return ( +
+
+ update({ query })} + placeholder={t("filters.search")} + className="min-w-[220px] flex-1 sm:max-w-sm" + /> + + + + +
+ + {advancedOpen && ( +
+ update({ from })} /> + update({ to })} /> + update({ minInputTokens })} /> + update({ maxInputTokens })} /> + update({ minOutputTokens })} /> + update({ maxOutputTokens })} /> + update({ minToolCalls })} /> + update({ maxToolCalls })} /> + update({ toolName })} /> +
+ + +
+
+ )} + + {chips.length > 0 && ( +
+ {chips.map((chip) => ( + + {t(`filters.chips.${chip.key}`)}: {chip.value} + + + ))} + +
+ )} +
+ ); +} + +function FilterField({ label, value, onChange, type = "text" }: { label: string; value?: string; onChange: (value: string) => void; type?: string }) { + return ( +
+ + onChange(e.target.value)} className="text-base md:text-sm" /> +
+ ); +} + +function cleanFilters(filters: TraceFilters): TraceFilters { + return Object.fromEntries( + Object.entries(filters).filter(([, value]) => value !== undefined && String(value).trim() !== ""), + ) as TraceFilters; +} diff --git a/ui/web/src/pages/traces/trace-filter-params.ts b/ui/web/src/pages/traces/trace-filter-params.ts new file mode 100644 index 00000000..77725042 --- /dev/null +++ b/ui/web/src/pages/traces/trace-filter-params.ts @@ -0,0 +1,84 @@ +export type TraceToolCallFilter = "true" | "false"; + +export interface TraceFilters { + agentId?: string; + userId?: string; + status?: string; + channel?: string; + query?: string; + from?: string; + to?: string; + minInputTokens?: string; + maxInputTokens?: string; + minOutputTokens?: string; + maxOutputTokens?: string; + minToolCalls?: string; + maxToolCalls?: string; + toolName?: string; + hasToolCalls?: TraceToolCallFilter; + limit?: number; + offset?: number; +} + +export interface TraceFilterChip { + key: keyof TraceFilters; + value: string; +} + +const PARAMS: Array<[keyof TraceFilters, string]> = [ + ["query", "q"], + ["agentId", "agent_id"], + ["userId", "user_id"], + ["status", "status"], + ["channel", "channel"], + ["from", "from"], + ["to", "to"], + ["minInputTokens", "min_input_tokens"], + ["maxInputTokens", "max_input_tokens"], + ["minOutputTokens", "min_output_tokens"], + ["maxOutputTokens", "max_output_tokens"], + ["minToolCalls", "min_tool_calls"], + ["maxToolCalls", "max_tool_calls"], + ["toolName", "tool_name"], + ["hasToolCalls", "has_tool_calls"], +]; + +export function buildTraceRequestParams(filters: TraceFilters): Record { + const params: Record = {}; + for (const [key, param] of PARAMS) { + const value = filters[key]; + if (typeof value === "string" && value.trim() !== "") { + params[param] = key === "from" || key === "to" + ? toRFC3339(value.trim()) + : value.trim(); + } + } + if (filters.limit) params.limit = String(filters.limit); + if (filters.offset !== undefined) params.offset = String(filters.offset); + return params; +} + +function toRFC3339(value: string): string { + if (/[zZ]$|[+-]\d{2}:\d{2}$/.test(value)) { + return value; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString(); +} + +export function getActiveTraceFilterChips(filters: TraceFilters): TraceFilterChip[] { + return PARAMS + .filter(([key]) => key !== "userId") + .flatMap(([key]) => { + const value = filters[key]; + return typeof value === "string" && value.trim() !== "" + ? [{ key, value: value.trim() }] + : []; + }); +} + +export function clearTraceFilter(filters: TraceFilters, key: keyof TraceFilters): TraceFilters { + const next = { ...filters }; + delete next[key]; + return next; +} diff --git a/ui/web/src/pages/traces/traces-page.tsx b/ui/web/src/pages/traces/traces-page.tsx index 15a45a92..04727ffe 100644 --- a/ui/web/src/pages/traces/traces-page.tsx +++ b/ui/web/src/pages/traces/traces-page.tsx @@ -3,13 +3,6 @@ import { useTranslation } from "react-i18next"; import { Activity, GitFork, RefreshCw, Square, Bot, User, Users, Clock, Network, Globe, CheckCircle2, XCircle, Loader2, CircleDot, CircleDashed } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { Pagination } from "@/components/shared/pagination"; @@ -30,6 +23,8 @@ import { useWsEvent } from "@/hooks/use-ws-event"; import { Methods, Events } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "@/stores/use-toast-store"; +import { TraceFilterBar } from "./trace-filter-bar"; +import type { TraceFilters } from "./trace-filter-params"; /** Strip media placeholder tags like from preview text */ function cleanPreview(text: string): string { @@ -64,8 +59,7 @@ export function TracesPage() { const tz = useUiStore((s) => s.timezone); const globalPageSize = useUiStore((s) => s.pageSize); const setGlobalPageSize = useUiStore((s) => s.setPageSize); - const [agentFilter, setAgentFilter] = useState(); - const [channelFilter, setChannelFilter] = useState(); + const [filters, setFilters] = useState({}); const [selectedTraceId, setSelectedTraceId] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSizeRaw] = useState(globalPageSize); @@ -92,8 +86,7 @@ export function TracesPage() { const [abortingRunId, setAbortingRunId] = useState(null); const { traces, total, loading, fetching, refresh, getTrace } = useTraces({ - agentId: agentFilter, - channel: channelFilter, + ...filters, limit: pageSize, offset: (page - 1) * pageSize, }); @@ -160,39 +153,12 @@ export function TracesPage() { } /> -
- {/* Agent filter */} - - - {/* Channel filter */} - -
+ { setFilters(next); setPage(1); }} + />
{showSkeleton ? (