mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
1 parent
9203782ae3
commit
167b80e778
19 files changed
+957
-79
No files matched your search
@@ -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 |
|
||||
|
||||
|
||||
+20
-1
@@ -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")`.
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
+83
-7
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "写入",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).sort();
|
||||
expect(Object.keys(vi as Record<string, unknown>).sort()).toEqual(enKeys);
|
||||
expect(Object.keys(zh as Record<string, unknown>).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<string, string> };
|
||||
for (const key of ["search", "status", "advanced", "from", "to", "toolName", "hasToolCalls", "clearAll"]) {
|
||||
expect(data.filters?.[key], `${locale} missing filters.${key}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {};
|
||||
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 };
|
||||
},
|
||||
|
||||
@@ -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<TraceFilters>) => {
|
||||
onChange(cleanFilters({ ...filters, ...patch }));
|
||||
};
|
||||
const clearOne = (key: keyof TraceFilters) => onChange(clearTraceFilter(filters, key));
|
||||
const clearAll = () => onChange({});
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SearchInput
|
||||
value={filters.query ?? ""}
|
||||
onChange={(query) => update({ query })}
|
||||
placeholder={t("filters.search")}
|
||||
className="min-w-[220px] flex-1 sm:max-w-sm"
|
||||
/>
|
||||
<Select value={filters.agentId ?? "__all__"} onValueChange={(v) => update({ agentId: v === "__all__" ? undefined : v })}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder={t("allAgents")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("allAgents")}</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key || a.id}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.channel ?? "__all__"} onValueChange={(v) => update({ channel: v === "__all__" ? undefined : v })}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder={t("allChannels")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("allChannels")}</SelectItem>
|
||||
{channels.map((ch) => (
|
||||
<SelectItem key={ch.id} value={ch.name}>{ch.display_name || ch.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.status ?? "__all__"} onValueChange={(v) => update({ status: v === "__all__" ? undefined : v })}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder={t("filters.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filters.allStatuses")}</SelectItem>
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<SelectItem key={status} value={status}>{t(`status.${status}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={() => setAdvancedOpen((v) => !v)}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{t("filters.advanced")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-3 rounded-md border bg-muted/20 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FilterField label={t("filters.from")} type="datetime-local" value={filters.from} onChange={(from) => update({ from })} />
|
||||
<FilterField label={t("filters.to")} type="datetime-local" value={filters.to} onChange={(to) => update({ to })} />
|
||||
<FilterField label={t("filters.minInputTokens")} type="number" value={filters.minInputTokens} onChange={(minInputTokens) => update({ minInputTokens })} />
|
||||
<FilterField label={t("filters.maxInputTokens")} type="number" value={filters.maxInputTokens} onChange={(maxInputTokens) => update({ maxInputTokens })} />
|
||||
<FilterField label={t("filters.minOutputTokens")} type="number" value={filters.minOutputTokens} onChange={(minOutputTokens) => update({ minOutputTokens })} />
|
||||
<FilterField label={t("filters.maxOutputTokens")} type="number" value={filters.maxOutputTokens} onChange={(maxOutputTokens) => update({ maxOutputTokens })} />
|
||||
<FilterField label={t("filters.minToolCalls")} type="number" value={filters.minToolCalls} onChange={(minToolCalls) => update({ minToolCalls })} />
|
||||
<FilterField label={t("filters.maxToolCalls")} type="number" value={filters.maxToolCalls} onChange={(maxToolCalls) => update({ maxToolCalls })} />
|
||||
<FilterField label={t("filters.toolName")} value={filters.toolName} onChange={(toolName) => update({ toolName })} />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{t("filters.hasToolCalls")}</Label>
|
||||
<Select value={filters.hasToolCalls ?? "__all__"} onValueChange={(v) => update({ hasToolCalls: v === "__all__" ? undefined : v as "true" | "false" })}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder={t("filters.hasToolCalls")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filters.anyToolCalls")}</SelectItem>
|
||||
<SelectItem value="true">{t("filters.withToolCalls")}</SelectItem>
|
||||
<SelectItem value="false">{t("filters.withoutToolCalls")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.key} variant="secondary" className="gap-1 pr-1">
|
||||
<span>{t(`filters.chips.${chip.key}`)}: {chip.value}</span>
|
||||
<button type="button" className="rounded-full p-0.5 hover:bg-background/70" onClick={() => clearOne(chip.key)} aria-label={t("filters.clearFilter")}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={clearAll}>{t("filters.clearAll")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterField({ label, value, onChange, type = "text" }: { label: string; value?: string; onChange: (value: string) => void; type?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<Input type={type} value={value ?? ""} min={type === "number" ? 0 : undefined} onChange={(e) => onChange(e.target.value)} className="text-base md:text-sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanFilters(filters: TraceFilters): TraceFilters {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters).filter(([, value]) => value !== undefined && String(value).trim() !== ""),
|
||||
) as TraceFilters;
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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 <media:image> 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<string>();
|
||||
const [channelFilter, setChannelFilter] = useState<string>();
|
||||
const [filters, setFilters] = useState<TraceFilters>({});
|
||||
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSizeRaw] = useState(globalPageSize);
|
||||
@@ -92,8 +86,7 @@ export function TracesPage() {
|
||||
const [abortingRunId, setAbortingRunId] = useState<string | null>(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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{/* Agent filter */}
|
||||
<Select
|
||||
value={agentFilter ?? "__all__"}
|
||||
onValueChange={(v) => { setAgentFilter(v === "__all__" ? undefined : v); setPage(1); }}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder={t("allAgents")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("allAgents")}</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key || a.id}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Channel filter */}
|
||||
<Select
|
||||
value={channelFilter ?? "__all__"}
|
||||
onValueChange={(v) => { setChannelFilter(v === "__all__" ? undefined : v); setPage(1); }}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder={t("allChannels")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("allChannels")}</SelectItem>
|
||||
{channels.map((ch) => (
|
||||
<SelectItem key={ch.id} value={ch.name}>{ch.display_name || ch.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<TraceFilterBar
|
||||
filters={filters}
|
||||
agents={agents}
|
||||
channels={channels}
|
||||
onChange={(next) => { setFilters(next); setPage(1); }}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{showSkeleton ? (
|
||||
|
||||
Reference in new issue
Block a user