From a8305857e145f163983a1dc4e24130aa9658be41 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 24 Jul 2026 09:56:30 +0200 Subject: [PATCH 1/9] feat(audit): audit log of user activity + skills UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit log capturing per-interaction detail (prompt, response, error, duration, resource) for admin review, plus configurable retention from central settings. Backend: - migration 000036: audit_events table (content + metadata, distinct from the metrics-only chat_events) - models.AuditEvent + AuditEventFilter, postgres repo (Insert/List/Cleanup), repository.AuditEventRepository interface - audit.RecordEvent helper: truncates content to a configurable limit and inserts asynchronously (never blocks the hot-path) - captured on the MCP facade (Claude/Cursor/Codex): agent chat, MCP tool, skill read, group debate - settings: observability_retention_days (migrated from YAML), audit_retention_days, audit_max_content_chars — all editable in Admin → General Configuration; cleanup job now reads retention from settings for both chat_events and audit_events - GET /api/admin/audit with filters (range, user, group, category, request_id, paging) Web: - /admin/audit page: activity table with expandable rows (prompt/response/error), filters and pagination; nav entry - skills reader: wider panel, proper scroll, collapsible sidebar section, frontmatter stripped from displayed content --- api/cmd/server/main.go | 41 +++- api/internal/audit/event.go | 46 ++++ api/internal/handlers/admin_audit.go | 70 ++++++ api/internal/mcpserver/group.go | 11 + api/internal/mcpserver/handler.go | 56 +++++ api/internal/models/audit_event.go | 57 +++++ .../repository/postgres/audit_event.go | 145 +++++++++++++ api/internal/repository/repository.go | 7 + api/internal/server/routes.go | 9 +- api/internal/settings/settings.go | 10 + api/migrations/000036_audit_events.down.sql | 1 + api/migrations/000036_audit_events.up.sql | 28 +++ web/src/app/admin/audit/page.tsx | 204 ++++++++++++++++++ web/src/components/admin/AdminNav.tsx | 3 +- web/src/components/agents/SkillsSection.tsx | 84 +++++--- web/src/lib/api.ts | 7 + web/src/lib/types.ts | 26 +++ 17 files changed, 761 insertions(+), 44 deletions(-) create mode 100644 api/internal/audit/event.go create mode 100644 api/internal/handlers/admin_audit.go create mode 100644 api/internal/models/audit_event.go create mode 100644 api/internal/repository/postgres/audit_event.go create mode 100644 api/migrations/000036_audit_events.down.sql create mode 100644 api/migrations/000036_audit_events.up.sql create mode 100644 web/src/app/admin/audit/page.tsx diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index c35ff35..bce3eaa 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -173,6 +173,7 @@ func main() { agentRepo := postgres.NewAgentRepository(pool) mcpRepo := postgres.NewMCPServerRepository(pool) skillRepo := postgres.NewSkillRepository(pool) + auditEventRepo := postgres.NewAuditEventRepository(pool) auditRepo := postgres.NewAuditRepository(pool) llmRepo := postgres.NewLLMModelRepository(pool, dataCipher) providerRepo := postgres.NewLLMProviderRepository(pool, dataCipher) @@ -261,8 +262,10 @@ func main() { logger.Info("MCP OAuth2 manager initialized", zap.String("callback_url", callbackURL)) } - // Start chat_events cleanup goroutine - if cfg.Metrics.Enabled && cfg.Metrics.RetentionDays > 0 { + // Start cleanup goroutine for chat_events (observability) and audit_events. + // Retention is now read from runtime settings on each tick (admin-editable), + // falling back to the settings defaults; the cleanup interval stays in YAML. + { cleanupInterval := time.Hour if cfg.Metrics.CleanupInterval != "" { if d, err := time.ParseDuration(cfg.Metrics.CleanupInterval); err == nil { @@ -273,18 +276,33 @@ func main() { ticker := time.NewTicker(cleanupInterval) defer ticker.Stop() for range ticker.C { - cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 30*time.Second) - deleted, err := chatEventRepo.Cleanup(cleanCtx, cfg.Metrics.RetentionDays) - cleanCancel() - if err != nil { - logger.Warn("chat_events cleanup failed", zap.Error(err)) - } else if deleted > 0 { - logger.Info("chat_events cleanup", zap.Int64("deleted", deleted)) + // Observability metrics (only if metrics are enabled at all). + if cfg.Metrics.Enabled { + if days := settingsService.Int(settings.KeyObservabilityRetentionDays); days > 0 { + cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + deleted, err := chatEventRepo.Cleanup(cleanCtx, days) + cancel() + if err != nil { + logger.Warn("chat_events cleanup failed", zap.Error(err)) + } else if deleted > 0 { + logger.Info("chat_events cleanup", zap.Int64("deleted", deleted)) + } + } + } + // Audit events (independent retention). + if days := settingsService.Int(settings.KeyAuditRetentionDays); days > 0 { + cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + deleted, err := auditEventRepo.Cleanup(cleanCtx, days) + cancel() + if err != nil { + logger.Warn("audit_events cleanup failed", zap.Error(err)) + } else if deleted > 0 { + logger.Info("audit_events cleanup", zap.Int64("deleted", deleted)) + } } } }() - logger.Info("chat_events cleanup started", - zap.Int("retention_days", cfg.Metrics.RetentionDays), + logger.Info("cleanup job started (chat_events + audit_events)", zap.Duration("interval", cleanupInterval)) } @@ -332,6 +350,7 @@ func main() { AgentRepo: agentRepo, MCPRepo: mcpRepo, SkillRepo: skillRepo, + AuditEventRepo: auditEventRepo, UserRepo: userRepo, AuditRepo: auditRepo, LLMRepo: llmRepo, diff --git a/api/internal/audit/event.go b/api/internal/audit/event.go new file mode 100644 index 0000000..da439df --- /dev/null +++ b/api/internal/audit/event.go @@ -0,0 +1,46 @@ +package audit + +import ( + "context" + "time" + + "github.com/dfradehubs/agentgram-api/internal/models" + "github.com/dfradehubs/agentgram-api/internal/repository" + "go.uber.org/zap" +) + +// auditInsertTimeout bounds the async DB write so a slow DB can't leak goroutines. +const auditInsertTimeout = 5 * time.Second + +// RecordEvent truncates the event's prompt/response to maxContentChars and +// inserts it asynchronously, so auditing never blocks the request hot-path. +// A nil repo makes this a no-op (auditing disabled / not wired). +func RecordEvent(repo repository.AuditEventRepository, ev *models.AuditEvent, maxContentChars int, logger *zap.Logger) { + if repo == nil || ev == nil { + return + } + if maxContentChars > 0 { + ev.Prompt = truncateRunes(ev.Prompt, maxContentChars) + ev.Response = truncateRunes(ev.Response, maxContentChars) + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), auditInsertTimeout) + defer cancel() + if err := repo.Insert(ctx, ev); err != nil && logger != nil { + logger.Warn("failed to insert audit_event", zap.Error(err)) + } + }() +} + +// truncateRunes cuts s to at most max runes (not bytes) so UTF-8 stays valid, +// appending an ellipsis marker when it actually truncates. +func truncateRunes(s string, max int) string { + if max <= 0 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…[truncated]" +} diff --git a/api/internal/handlers/admin_audit.go b/api/internal/handlers/admin_audit.go new file mode 100644 index 0000000..beb6387 --- /dev/null +++ b/api/internal/handlers/admin_audit.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/dfradehubs/agentgram-api/internal/models" + "github.com/dfradehubs/agentgram-api/internal/repository" + "go.uber.org/zap" +) + +// AdminAuditHandler serves the admin audit log (detailed activity with content). +type AdminAuditHandler struct { + repo repository.AuditEventRepository + logger *zap.Logger +} + +// NewAdminAuditHandler creates a new admin audit handler. +func NewAdminAuditHandler(repo repository.AuditEventRepository, logger *zap.Logger) *AdminAuditHandler { + return &AdminAuditHandler{repo: repo, logger: logger} +} + +// ListAuditEvents handles GET /api/admin/audit with filters: +// from, to (RFC3339), user, group, resource_type, request_id, limit, offset. +func (h *AdminAuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := models.AuditEventFilter{ + UserEmail: q.Get("user"), + Group: q.Get("group"), + ResourceType: q.Get("resource_type"), + RequestID: q.Get("request_id"), + } + if v := q.Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.From = &t + } + } + if v := q.Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.To = &t + } + } + f.Limit = 50 + if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 { + f.Limit = v + } + if v, err := strconv.Atoi(q.Get("offset")); err == nil && v >= 0 { + f.Offset = v + } + + events, total, err := h.repo.List(r.Context(), f) + if err != nil { + h.logger.Error("list audit events failed", zap.Error(err)) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + return + } + if events == nil { + events = []*models.AuditEvent{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "events": events, + "total": total, + "limit": f.Limit, + "offset": f.Offset, + }) +} diff --git a/api/internal/mcpserver/group.go b/api/internal/mcpserver/group.go index e0b248b..c1f58cf 100644 --- a/api/internal/mcpserver/group.go +++ b/api/internal/mcpserver/group.go @@ -190,6 +190,17 @@ func (h *Handler) handleGroupToolCall(w http.ResponseWriter, r *http.Request, re } // isError=true for incomplete outcomes as well as total agent failure. + groupAuditStatus := "ok" + if cr.isError { + groupAuditStatus = "error" + } + h.recordAudit(&models.AuditEvent{ + UserEmail: userEmail, UserGroups: userGroups, + ResourceType: models.AuditResourceGroup, ResourceID: groupID, ResourceName: group.Name, + Source: models.AuditSourceMCP, Action: models.AuditActionGroupDebate, + Prompt: args.Question, Response: responseText, Status: groupAuditStatus, + DurationMs: elapsedSeconds * 1000, + }) flushSSE(h.server.MarshalToolResult(req.ID, responseText, cr.isError)) } diff --git a/api/internal/mcpserver/handler.go b/api/internal/mcpserver/handler.go index 8f4323d..95de23b 100644 --- a/api/internal/mcpserver/handler.go +++ b/api/internal/mcpserver/handler.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/dfradehubs/agentgram-api/internal/agents" + "github.com/dfradehubs/agentgram-api/internal/audit" "github.com/dfradehubs/agentgram-api/internal/auth" "github.com/dfradehubs/agentgram-api/internal/config" "github.com/dfradehubs/agentgram-api/internal/identity" @@ -46,6 +47,7 @@ type Handler struct { moderator *orchestrator.Moderator moderatorResolver *orchestrator.ModeratorResolver settings *appsettings.Service + auditRepo repository.AuditEventRepository logger *zap.Logger } @@ -65,6 +67,7 @@ func NewHandler( mcpRepo repository.MCPServerRepository, llmRepo repository.LLMModelRepository, settingsService *appsettings.Service, + auditRepo repository.AuditEventRepository, ) *Handler { return &Handler{ server: NewServer(registry, mcpRegistry, userService, groupRepo, skillRepo, logger), @@ -81,6 +84,7 @@ func NewHandler( mcpRepo: mcpRepo, moderatorResolver: orchestrator.NewModeratorResolver(llmRepo, lfTracer, logger), settings: settingsService, + auditRepo: auditRepo, logger: logger, } } @@ -520,6 +524,7 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js sessionID string err error } + auditStart := time.Now() done := make(chan callResult, 1) go func() { result, resultSessionID, err := h.callAgent(r.Context(), agent, args.Question, sessionID, userEmail, userGroups) @@ -583,6 +588,14 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js if mcpSessionID != "" && cr.sessionID != "" { h.server.sessions.SetAgentSession(mcpSessionID, agentID, cr.sessionID) } + h.recordAudit(&models.AuditEvent{ + UserEmail: userEmail, UserGroups: userGroups, + ResourceType: models.AuditResourceAgent, ResourceID: agentID, ResourceName: agent.Name, + Source: models.AuditSourceMCP, Action: models.AuditActionChat, + Prompt: args.Question, Response: cr.text, + Status: "error", ErrorMsg: cr.err.Error(), + DurationMs: int(time.Since(auditStart).Milliseconds()), + }) flushSSE(h.server.MarshalToolResult(req.ID, responseText, true)) return } @@ -608,6 +621,13 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js lfTrace.End(true, truncateString(responseText, 2000)) } + h.recordAudit(&models.AuditEvent{ + UserEmail: userEmail, UserGroups: userGroups, + ResourceType: models.AuditResourceAgent, ResourceID: agentID, ResourceName: agent.Name, + Source: models.AuditSourceMCP, Action: models.AuditActionChat, + Prompt: args.Question, Response: cr.text, Status: "ok", + DurationMs: int(time.Since(auditStart).Milliseconds()), + }) jsonResponse := h.server.MarshalToolResult(req.ID, responseText, false) h.logger.Info("MCP tools/call response", zap.String("agent_id", agentID), @@ -617,6 +637,15 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js flushSSE(jsonResponse) } +// recordAudit truncates content per the runtime setting and records the audit +// event asynchronously. No-op when auditing isn't wired. +func (h *Handler) recordAudit(ev *models.AuditEvent) { + if h.auditRepo == nil { + return + } + audit.RecordEvent(h.auditRepo, ev, h.settings.Int(appsettings.KeyAuditMaxContentChars), h.logger) +} + // handleSkillToolCall handles a tools/call for a skill tool. It returns the // skill's stored instruction content verbatim after re-checking access. func (h *Handler) handleSkillToolCall(w http.ResponseWriter, r *http.Request, req jsonRPCRequest, skillID, userEmail string, userGroups []string) { @@ -636,6 +665,18 @@ func (h *Handler) handleSkillToolCall(w http.ResponseWriter, r *http.Request, re return } + h.recordAudit(&models.AuditEvent{ + UserEmail: userEmail, + UserGroups: userGroups, + ResourceType: models.AuditResourceSkill, + ResourceID: skill.ID, + ResourceName: skill.Name, + Source: models.AuditSourceMCP, + Action: models.AuditActionSkillRead, + Response: skill.Content, + Status: "ok", + }) + h.writeJSON(w, http.StatusOK, h.server.MarshalToolResult(req.ID, skill.Content, false)) } @@ -739,6 +780,7 @@ func (h *Handler) handleMCPToolCall(w http.ResponseWriter, r *http.Request, req result *mcp.ToolResult err error } + auditStart := time.Now() done := make(chan callResult, 1) go func() { result, err := server.Client.CallToolWithHeaders(r.Context(), toolName, args, extraHeaders) @@ -805,6 +847,20 @@ func (h *Handler) handleMCPToolCall(w http.ResponseWriter, r *http.Request, req text = "(no output)" } + toolIsError := cr.result != nil && cr.result.IsError + auditStatus := "ok" + if toolIsError { + auditStatus = "error" + } + argsJSON, _ := json.Marshal(args) + h.recordAudit(&models.AuditEvent{ + UserEmail: userEmail, UserGroups: userGroups, + ResourceType: models.AuditResourceMCP, ResourceID: serverID, ResourceName: server.Config.Name, + Source: models.AuditSourceMCP, Action: models.AuditActionMCPTool, + Prompt: fmt.Sprintf("%s %s", toolName, string(argsJSON)), Response: text, Status: auditStatus, + DurationMs: int(time.Since(auditStart).Milliseconds()), + }) + // End Langfuse spans if toolSpan != nil { toolSpan.End(truncateString(text, 2000)) diff --git a/api/internal/models/audit_event.go b/api/internal/models/audit_event.go new file mode 100644 index 0000000..b5db23b --- /dev/null +++ b/api/internal/models/audit_event.go @@ -0,0 +1,57 @@ +package models + +import "time" + +// Audit resource/source/action constants. +const ( + AuditResourceAgent = "agent" + AuditResourceMCP = "mcp" + AuditResourceSkill = "skill" + AuditResourceGroup = "group" + + AuditSourceWeb = "web" + AuditSourceMCP = "mcp" + AuditSourceSlack = "slack" + + AuditActionChat = "chat" + AuditActionMCPTool = "mcp_tool" + AuditActionSkillRead = "skill_read" + AuditActionGroupDebate = "group_debate" +) + +// AuditEvent is a detailed record of one user-facing interaction, including the +// prompt sent and the response returned (truncated to a configurable limit). +// Distinct from ChatEvent, which stores only aggregate metrics with no content. +type AuditEvent struct { + ID string `json:"id"` + RequestID string `json:"request_id,omitempty"` + UserEmail string `json:"user_email"` + UserGroups []string `json:"user_groups,omitempty"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name"` + Source string `json:"source"` + Action string `json:"action"` + Prompt string `json:"prompt"` + Response string `json:"response"` + ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"` + Status string `json:"status"` + ErrorType string `json:"error_type,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` + DurationMs int `json:"duration_ms"` + TTFBMs *int `json:"ttfb_ms,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditEventFilter narrows an audit-event list query. Zero values mean "no +// filter" for that field. +type AuditEventFilter struct { + From *time.Time + To *time.Time + UserEmail string + Group string + ResourceType string + RequestID string + Limit int + Offset int +} diff --git a/api/internal/repository/postgres/audit_event.go b/api/internal/repository/postgres/audit_event.go new file mode 100644 index 0000000..a3e74ee --- /dev/null +++ b/api/internal/repository/postgres/audit_event.go @@ -0,0 +1,145 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/dfradehubs/agentgram-api/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// AuditEventRepository implements repository.AuditEventRepository with PostgreSQL. +type AuditEventRepository struct { + pool *pgxpool.Pool +} + +// NewAuditEventRepository creates a new PostgreSQL audit event repository. +func NewAuditEventRepository(pool *pgxpool.Pool) *AuditEventRepository { + return &AuditEventRepository{pool: pool} +} + +func (r *AuditEventRepository) Insert(ctx context.Context, e *models.AuditEvent) error { + var toolCallsJSON []byte + if len(e.ToolCalls) > 0 { + toolCallsJSON, _ = json.Marshal(e.ToolCalls) + } + _, err := r.pool.Exec(ctx, + `INSERT INTO audit_events + (request_id, user_email, user_groups, resource_type, resource_id, resource_name, + source, action, prompt, response, tool_calls, status, error_type, error_msg, duration_ms, ttfb_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + nullStr(e.RequestID), e.UserEmail, e.UserGroups, e.ResourceType, e.ResourceID, nullStr(e.ResourceName), + e.Source, e.Action, e.Prompt, e.Response, toolCallsJSON, e.Status, nullStr(e.ErrorType), nullStr(e.ErrorMsg), + e.DurationMs, e.TTFBMs) + if err != nil { + return fmt.Errorf("insert audit event: %w", err) + } + return nil +} + +// List returns a filtered, paginated page of audit events plus the total count +// matching the filter (for pagination). +func (r *AuditEventRepository) List(ctx context.Context, f models.AuditEventFilter) ([]*models.AuditEvent, int, error) { + var conds []string + var args []interface{} + add := func(cond string, val interface{}) { + args = append(args, val) + conds = append(conds, fmt.Sprintf(cond, len(args))) + } + if f.From != nil { + add("created_at >= $%d", *f.From) + } + if f.To != nil { + add("created_at <= $%d", *f.To) + } + if f.UserEmail != "" { + add("user_email = $%d", f.UserEmail) + } + if f.Group != "" { + add("$%d = ANY(user_groups)", f.Group) + } + if f.ResourceType != "" { + add("resource_type = $%d", f.ResourceType) + } + if f.RequestID != "" { + add("request_id = $%d", f.RequestID) + } + where := "" + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } + + var total int + if err := r.pool.QueryRow(ctx, "SELECT count(*) FROM audit_events "+where, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count audit events: %w", err) + } + + limit := f.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + args = append(args, limit) + limitPos := len(args) + args = append(args, f.Offset) + offsetPos := len(args) + + rows, err := r.pool.Query(ctx, + `SELECT id, request_id, user_email, user_groups, resource_type, resource_id, resource_name, + source, action, prompt, response, tool_calls, status, error_type, error_msg, + duration_ms, ttfb_ms, created_at + FROM audit_events `+where+ + fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", limitPos, offsetPos), + args...) + if err != nil { + return nil, 0, fmt.Errorf("list audit events: %w", err) + } + defer rows.Close() + + var events []*models.AuditEvent + for rows.Next() { + var e models.AuditEvent + var reqID, resName, errType, errMsg *string + var toolCallsJSON []byte + if err := rows.Scan(&e.ID, &reqID, &e.UserEmail, &e.UserGroups, &e.ResourceType, &e.ResourceID, &resName, + &e.Source, &e.Action, &e.Prompt, &e.Response, &toolCallsJSON, &e.Status, &errType, &errMsg, + &e.DurationMs, &e.TTFBMs, &e.CreatedAt); err != nil { + return nil, 0, fmt.Errorf("scan audit event: %w", err) + } + e.RequestID = deref(reqID) + e.ResourceName = deref(resName) + e.ErrorType = deref(errType) + e.ErrorMsg = deref(errMsg) + if len(toolCallsJSON) > 0 { + _ = json.Unmarshal(toolCallsJSON, &e.ToolCalls) + } + events = append(events, &e) + } + return events, total, rows.Err() +} + +// Cleanup deletes audit events older than retentionDays. +func (r *AuditEventRepository) Cleanup(ctx context.Context, retentionDays int) (int64, error) { + tag, err := r.pool.Exec(ctx, + `DELETE FROM audit_events WHERE created_at < NOW() - ($1 || ' days')::interval`, + fmt.Sprintf("%d", retentionDays)) + if err != nil { + return 0, fmt.Errorf("cleanup audit events: %w", err) + } + return tag.RowsAffected(), nil +} + +func nullStr(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/api/internal/repository/repository.go b/api/internal/repository/repository.go index 0b2dd68..64234b2 100644 --- a/api/internal/repository/repository.go +++ b/api/internal/repository/repository.go @@ -49,6 +49,13 @@ type MCPServerRepository interface { ReplaceAPIKeyRules(ctx context.Context, serverID string, rules []models.MCPAPIKeyRule) error } +// AuditEventRepository manages detailed audit event persistence (prompt/response). +type AuditEventRepository interface { + Insert(ctx context.Context, event *models.AuditEvent) error + List(ctx context.Context, filter models.AuditEventFilter) ([]*models.AuditEvent, int, error) + Cleanup(ctx context.Context, retentionDays int) (int64, error) +} + // SkillRepository manages skill persistence type SkillRepository interface { Create(ctx context.Context, skill *models.Skill) error diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 88e24a6..15a6784 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -38,6 +38,7 @@ type AdminDeps struct { AgentRepo repository.AgentRepository MCPRepo repository.MCPServerRepository SkillRepo repository.SkillRepository + AuditEventRepo repository.AuditEventRepository UserRepo repository.UserRepository AuditRepo repository.AuditRepository LLMRepo repository.LLMModelRepository @@ -129,7 +130,7 @@ func SetupRoutes(cfg *config.Config, registry *agents.Registry, sessionStore sto // MCP server endpoint (exposes agents as MCP tools for Claude Code and other MCP clients) if cfg.MCPServer.Enabled { - mcpServerHandler := mcpserver.NewHandler(registry, mcpRegistry, sessionStore, adminDeps.UserService, adminDeps.GroupRepo, adminDeps.SkillRepo, oidcClient, cfg, logger, adminDeps.LangfuseTracer, adminDeps.OAuth2Manager, adminDeps.MCPRepo, llmRepo, adminDeps.SettingsService) + mcpServerHandler := mcpserver.NewHandler(registry, mcpRegistry, sessionStore, adminDeps.UserService, adminDeps.GroupRepo, adminDeps.SkillRepo, oidcClient, cfg, logger, adminDeps.LangfuseTracer, adminDeps.OAuth2Manager, adminDeps.MCPRepo, llmRepo, adminDeps.SettingsService, adminDeps.AuditEventRepo) // Public: OAuth2 Protected Resource Metadata (RFC 9728) // Serve at both root and path-based locations per RFC 9728 Section 3.1: @@ -436,6 +437,12 @@ func SetupRoutes(cfg *config.Config, registry *agents.Registry, sessionStore sto r.Put("/skills/{id}/permissions", adminSkillsHandler.UpdateSkillPermissions) } + // Admin audit log (detailed activity with prompt/response content) + if adminDeps.AuditEventRepo != nil { + adminAuditHandler := handlers.NewAdminAuditHandler(adminDeps.AuditEventRepo, logger) + r.Get("/audit", adminAuditHandler.ListAuditEvents) + } + // Admin MCP OAuth2 scope mappings if adminDeps.OAuth2Manager != nil { mcpOAuthHandler := handlers.NewMCPOAuthHandler(adminDeps.OAuth2Manager, adminDeps.MCPRepo, mcpRegistry, logger) diff --git a/api/internal/settings/settings.go b/api/internal/settings/settings.go index 516e385..458af08 100644 --- a/api/internal/settings/settings.go +++ b/api/internal/settings/settings.go @@ -41,6 +41,10 @@ const ( KeyGroupDebateTimeout = "group_debate_timeout_api" KeyGroupMaxTurnsAPI = "group_max_turns_api" KeyGroupMaxTurnsMCP = "group_max_turns_mcp" + + KeyObservabilityRetentionDays = "observability_retention_days" + KeyAuditRetentionDays = "audit_retention_days" + KeyAuditMaxContentChars = "audit_max_content_chars" ) // Defs is the registry of known settings, in admin-display order. Adding a @@ -56,6 +60,12 @@ var Defs = []Def{ Description: "Default cap on moderated-debate turns over the streaming API. A group's own max_turns overrides this.", Min: 1, Max: 50}, {Key: KeyGroupMaxTurnsMCP, Section: "Group debates", Label: "Max turns (MCP)", Type: TypeInt, Default: "3", Description: "Default cap for synchronous MCP group__ tools (kept lower to fit tool-call timeouts). A group's own max_turns overrides this.", Min: 1, Max: 50}, + {Key: KeyObservabilityRetentionDays, Section: "Observability", Label: "Metrics retention (days)", Type: TypeInt, Default: "30", + Description: "How many days of observability metrics (chat events) to keep before the cleanup job deletes them.", Min: 1, Max: 3650}, + {Key: KeyAuditRetentionDays, Section: "Audit", Label: "Audit retention (days)", Type: TypeInt, Default: "30", + Description: "How many days of audit events (prompts and responses) to keep before the cleanup job deletes them.", Min: 1, Max: 3650}, + {Key: KeyAuditMaxContentChars, Section: "Audit", Label: "Max content chars", Type: TypeInt, Default: "10000", + Description: "Maximum characters stored per prompt and per response in an audit event; longer content is truncated.", Min: 100, Max: 200000}, } var defByKey = func() map[string]Def { diff --git a/api/migrations/000036_audit_events.down.sql b/api/migrations/000036_audit_events.down.sql new file mode 100644 index 0000000..3713ba9 --- /dev/null +++ b/api/migrations/000036_audit_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_events; diff --git a/api/migrations/000036_audit_events.up.sql b/api/migrations/000036_audit_events.up.sql new file mode 100644 index 0000000..2db202a --- /dev/null +++ b/api/migrations/000036_audit_events.up.sql @@ -0,0 +1,28 @@ +-- Audit events: detailed per-interaction records including the prompt sent and +-- the response returned (truncated). Distinct from chat_events, which stores +-- only aggregate metrics with no content. +CREATE TABLE audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id VARCHAR(255), + user_email VARCHAR(255) NOT NULL, + user_groups TEXT[], + resource_type VARCHAR(20) NOT NULL, -- agent, mcp, skill, group + resource_id VARCHAR(255) NOT NULL, + resource_name VARCHAR(255), + source VARCHAR(20) NOT NULL DEFAULT 'web', -- web, mcp, slack + action VARCHAR(30) NOT NULL, -- chat, mcp_tool, skill_read, group_debate + prompt TEXT, + response TEXT, + tool_calls JSONB, + status VARCHAR(20) NOT NULL, -- ok, error + error_type VARCHAR(50), + error_msg TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + ttfb_ms INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_events_created ON audit_events (created_at DESC); +CREATE INDEX idx_audit_events_user ON audit_events (user_email, created_at DESC); +CREATE INDEX idx_audit_events_resource ON audit_events (resource_type, created_at DESC); +CREATE INDEX idx_audit_events_request ON audit_events (request_id); diff --git a/web/src/app/admin/audit/page.tsx b/web/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..e15a6d7 --- /dev/null +++ b/web/src/app/admin/audit/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useState, useCallback, Fragment } from "react"; +import type { AuditEvent } from "@/lib/types"; +import { getAuditEvents } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, RefreshCw } from "lucide-react"; +import { AdminTableSkeleton } from "@/components/admin/AdminTableSkeleton"; + +const RANGES: { label: string; hours: number }[] = [ + { label: "Last 24 hours", hours: 24 }, + { label: "Last 7 days", hours: 24 * 7 }, + { label: "Last 30 days", hours: 24 * 30 }, +]; + +const CATEGORIES = ["", "agent", "mcp", "skill", "group"]; +const PAGE_SIZE = 50; + +function fmtDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +export default function AdminAuditPage() { + const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + + const [rangeHours, setRangeHours] = useState(24); + const [user, setUser] = useState(""); + const [category, setCategory] = useState(""); + const [requestId, setRequestId] = useState(""); + const [offset, setOffset] = useState(0); + + const fetchEvents = useCallback(async () => { + setLoading(true); + try { + const from = new Date(Date.now() - rangeHours * 3600 * 1000).toISOString(); + const data = await getAuditEvents({ + from, + user, + resource_type: category, + request_id: requestId, + limit: String(PAGE_SIZE), + offset: String(offset), + }); + setEvents(data.events); + setTotal(data.total); + } catch { + setEvents([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [rangeHours, user, category, requestId, offset]); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + return ( +
+
+

Audit

+ +
+ + {/* Filters */} +
+
+ + +
+
+ + { setOffset(0); setUser(e.target.value); }} + /> +
+
+ + +
+
+ + { setOffset(0); setRequestId(e.target.value); }} + /> +
+
+ + {loading ? ( + + ) : ( +
+ + + + + + + + + + + {events.map((e) => { + const isOpen = expanded === e.id; + return ( + + setExpanded(isOpen ? null : e.id)} + > + + + + + + {isOpen && ( + + + + )} + + ); + })} + +
DateActionMemberCategory
{fmtDate(e.created_at)} +
+ + {e.resource_name || e.resource_id} + {e.status === "error" && ( + error + )} +
+
+ {e.action} · {e.duration_ms}ms · {e.source} +
+
{e.user_email} + {e.resource_type} +
+
+ {e.request_id &&
Request ID: {e.request_id}
} + {e.prompt && ( +
+
Prompt
+
{e.prompt}
+
+ )} + {e.response && ( +
+
Response
+
{e.response}
+
+ )} + {e.error_msg && ( +
+
Error
+
{e.error_msg}
+
+ )} +
+
+ {events.length === 0 && ( +
No audit events for this filter
+ )} +
+ )} + + {/* Pagination */} + {total > PAGE_SIZE && ( +
+ {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} +
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/components/admin/AdminNav.tsx b/web/src/components/admin/AdminNav.tsx index 34d02a6..193f6b8 100644 --- a/web/src/components/admin/AdminNav.tsx +++ b/web/src/components/admin/AdminNav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Bot, Server, Users, UsersRound, ArrowLeft, Brain, BarChart3, Settings, KeyRound, BookOpen } from "lucide-react"; +import { Bot, Server, Users, UsersRound, ArrowLeft, Brain, BarChart3, Settings, KeyRound, BookOpen, ScrollText } from "lucide-react"; import { cn } from "@/lib/utils"; const navItems = [ @@ -14,6 +14,7 @@ const navItems = [ { href: "/admin/llm", label: "LLM Models", icon: Brain }, { href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/observability", label: "Observability", icon: BarChart3 }, + { href: "/admin/audit", label: "Audit", icon: ScrollText }, { href: "/admin/settings", label: "General Configuration", icon: Settings }, ]; diff --git a/web/src/components/agents/SkillsSection.tsx b/web/src/components/agents/SkillsSection.tsx index dd91aaa..c7da5f2 100644 --- a/web/src/components/agents/SkillsSection.tsx +++ b/web/src/components/agents/SkillsSection.tsx @@ -5,6 +5,11 @@ import type { UserSkill, SkillDetail } from "@/lib/types"; import { getSkills, getSkillDetail } from "@/lib/api"; import { getEntityColor } from "@/lib/agent-colors"; import { MarkdownMessage } from "../chat/MarkdownMessage"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Dialog, DialogContent, @@ -12,13 +17,20 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { BookOpen } from "lucide-react"; +import { BookOpen, ChevronRight } from "lucide-react"; import { toast } from "sonner"; +// stripFrontmatter removes a leading YAML frontmatter block (--- ... ---) so the +// reader shows the instructions, not the metadata already shown in the header. +function stripFrontmatter(content: string): string { + return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""); +} + export function SkillsSection() { const [skills, setSkills] = useState([]); const [selected, setSelected] = useState(null); const [loadingId, setLoadingId] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); useEffect(() => { getSkills() @@ -45,43 +57,53 @@ export function SkillsSection() { return ( <>
-
- - Skills - -
- - {skills.map((skill) => ( - - ))} + + + + + + {skills.map((skill) => ( + + ))} + + !open && setSelected(null)}> - + {selected?.name} {selected?.description && ( - {selected.description} + {selected.description} )} -
- {selected && } +
+ {selected && }
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f2bbe64..31deb30 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,6 +9,7 @@ import type { AdminSkill, UserSkill, SkillDetail, + AuditEventsResponse, AdminUser, AppSetting, BasicAuthUser, @@ -390,6 +391,12 @@ export async function deleteAdminMCPServer(id: string): Promise { await fetchApi(`/api/admin/mcp/${id}`, { method: "DELETE" }); } +export async function getAuditEvents(params: Record): Promise { + const clean = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== "")); + const qs = new URLSearchParams(clean).toString(); + return fetchApi(`/api/admin/audit${qs ? `?${qs}` : ""}`); +} + export async function getSkills(): Promise { const data = await fetchApi<{ skills: UserSkill[] }>("/api/skills"); return data.skills || []; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e0cf87b..430635b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -250,6 +250,32 @@ export interface AdminMCPServer { api_key_rules?: MCPApiKeyRule[]; } +export interface AuditEvent { + id: string; + request_id?: string; + user_email: string; + user_groups?: string[]; + resource_type: string; + resource_id: string; + resource_name: string; + source: string; + action: string; + prompt: string; + response: string; + status: string; + error_type?: string; + error_msg?: string; + duration_ms: number; + created_at: string; +} + +export interface AuditEventsResponse { + events: AuditEvent[]; + total: number; + limit: number; + offset: number; +} + export interface UserSkill { id: string; name: string; From 999b7bbccc5d4e5c605673d5a7853dc64f30d159 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 24 Jul 2026 10:05:07 +0200 Subject: [PATCH 2/9] feat(audit): capture web surfaces (agent chat, MCP chat, group debates) Instrument the web/API surfaces with the same audit.RecordEvent helper used by the MCP facade: agent proxy chat (proxy.go), MCP chat single + multi (mcp.go), and group debate turns (group_chat.go). Audit repo injected into ProxyHandler and MCPHandler via setters (both already use a variadic chatEventRepo). --- api/internal/handlers/group_chat.go | 18 +++++++++ api/internal/handlers/instrumentation.go | 24 ++++++++++++ api/internal/handlers/mcp.go | 48 ++++++++++++++++++++++++ api/internal/handlers/proxy.go | 27 +++++++++++++ api/internal/server/routes.go | 2 + 5 files changed, 119 insertions(+) diff --git a/api/internal/handlers/group_chat.go b/api/internal/handlers/group_chat.go index 24d4640..36e3c2b 100644 --- a/api/internal/handlers/group_chat.go +++ b/api/internal/handlers/group_chat.go @@ -650,6 +650,24 @@ func (h *ProxyHandler) recordGroupTurnEvent(agent *models.Agent, sessionID, user MessageCount: messageCount, ToolCalls: toolCallInfos, }, h.logger) + + var auditResp string + if result != nil { + auditResp = proxy.TranscriptText(result) + } + recordAuditEvent(h.auditRepo, h.settings, &models.AuditEvent{ + UserEmail: userEmail, + ResourceType: models.AuditResourceAgent, + ResourceID: agent.ID, + ResourceName: agent.Name, + Source: models.AuditSourceWeb, + Action: models.AuditActionGroupDebate, + Response: auditResp, + Status: status, + ErrorType: errType, + ErrorMsg: errMsg, + DurationMs: durationMs, + }, h.logger) } // distinctRespondingSpeakers counts agents that produced a response, even when diff --git a/api/internal/handlers/instrumentation.go b/api/internal/handlers/instrumentation.go index 52dd1fb..fb19b46 100644 --- a/api/internal/handlers/instrumentation.go +++ b/api/internal/handlers/instrumentation.go @@ -4,12 +4,36 @@ import ( "context" "time" + "github.com/dfradehubs/agentgram-api/internal/audit" "github.com/dfradehubs/agentgram-api/internal/metrics" "github.com/dfradehubs/agentgram-api/internal/models" "github.com/dfradehubs/agentgram-api/internal/repository" + appsettings "github.com/dfradehubs/agentgram-api/internal/settings" "go.uber.org/zap" ) +// recordAuditEvent records a detailed audit event asynchronously, truncating +// content to the runtime-configured limit. No-op when auditing isn't wired. +func recordAuditEvent(repo repository.AuditEventRepository, settings *appsettings.Service, ev *models.AuditEvent, logger *zap.Logger) { + if repo == nil { + return + } + maxChars := 0 + if settings != nil { + maxChars = settings.Int(appsettings.KeyAuditMaxContentChars) + } + audit.RecordEvent(repo, ev, maxChars, logger) +} + +// lastMessageContent returns the content of the last message (the user's prompt +// for a single-turn request), or "" if there are none. +func lastMessageContent(msgs []models.ChatMessage) string { + if len(msgs) == 0 { + return "" + } + return msgs[len(msgs)-1].Content +} + // recordChatEvent inserts a ChatEvent into the repository asynchronously. // It also records Prometheus metrics if enabled. func recordChatEvent(repo repository.ChatEventRepository, event *models.ChatEvent, logger *zap.Logger) { diff --git a/api/internal/handlers/mcp.go b/api/internal/handlers/mcp.go index fda3133..5823f20 100644 --- a/api/internal/handlers/mcp.go +++ b/api/internal/handlers/mcp.go @@ -20,6 +20,7 @@ import ( "github.com/dfradehubs/agentgram-api/internal/middleware" "github.com/dfradehubs/agentgram-api/internal/models" "github.com/dfradehubs/agentgram-api/internal/repository" + appsettings "github.com/dfradehubs/agentgram-api/internal/settings" "github.com/dfradehubs/agentgram-api/internal/sessionnamer" "github.com/dfradehubs/agentgram-api/internal/store" "go.uber.org/zap" @@ -35,11 +36,20 @@ type MCPHandler struct { audit *audit.Logger langfuseTracer *lf.Tracer chatEventRepo repository.ChatEventRepository + auditRepo repository.AuditEventRepository + auditSettings *appsettings.Service oauth2Mgr *mcp.OAuth2Manager mcpRepo repository.MCPServerRepository logger *zap.Logger } +// SetAudit wires the audit-event repository and the settings service used for +// the content-truncation limit (optional; auditing is a no-op without a repo). +func (h *MCPHandler) SetAudit(repo repository.AuditEventRepository, settings *appsettings.Service) { + h.auditRepo = repo + h.auditSettings = settings +} + // NewMCPHandler creates a new MCP handler. maxRoundsFn resolves the max // LLM ↔ tool rounds per request (a runtime setting). func NewMCPHandler(llmRepo repository.LLMModelRepository, registry *mcp.Registry, sessionStore store.SessionStore, maxRoundsFn func() int, auditLogger *audit.Logger, logger *zap.Logger, lfTracer *lf.Tracer, oauth2Mgr *mcp.OAuth2Manager, mcpRepo repository.MCPServerRepository, chatEventRepo ...repository.ChatEventRepository) *MCPHandler { @@ -478,6 +488,25 @@ func (h *MCPHandler) Chat(w http.ResponseWriter, r *http.Request) { LLMModel: req.ModelID, } recordChatEvent(h.chatEventRepo, event, h.logger) + + var auditResp string + if runResult != nil { + auditResp = runResult.AssistantText + } + recordAuditEvent(h.auditRepo, h.auditSettings, &models.AuditEvent{ + UserEmail: userEmail, + ResourceType: models.AuditResourceMCP, + ResourceID: serverID, + ResourceName: server.Config.Name, + Source: models.AuditSourceWeb, + Action: models.AuditActionChat, + Prompt: lastMessageContent(req.Messages), + Response: auditResp, + Status: status, + ErrorType: errType, + ErrorMsg: errMsg, + DurationMs: durationMs, + }, h.logger) } // Async: generate a short LLM-based session name for new sessions @@ -695,6 +724,25 @@ func (h *MCPHandler) ChatMulti(w http.ResponseWriter, r *http.Request) { LLMModel: req.ModelID, } recordChatEvent(h.chatEventRepo, event, h.logger) + + var auditResp string + if runResult != nil { + auditResp = runResult.AssistantText + } + recordAuditEvent(h.auditRepo, h.auditSettings, &models.AuditEvent{ + UserEmail: userEmail, + ResourceType: models.AuditResourceMCP, + ResourceID: resourceID, + ResourceName: "Multi-MCP", + Source: models.AuditSourceWeb, + Action: models.AuditActionChat, + Prompt: lastMessageContent(req.Messages), + Response: auditResp, + Status: status, + ErrorType: errType, + ErrorMsg: errMsg, + DurationMs: durationMs, + }, h.logger) } // Async: generate a short LLM-based session name for new sessions diff --git a/api/internal/handlers/proxy.go b/api/internal/handlers/proxy.go index 5988908..8e902f0 100644 --- a/api/internal/handlers/proxy.go +++ b/api/internal/handlers/proxy.go @@ -53,10 +53,18 @@ type ProxyHandler struct { settings *appsettings.Service audit *audit.Logger chatEventRepo repository.ChatEventRepository + auditRepo repository.AuditEventRepository langfuseTracer *lf.Tracer logger *zap.Logger } +// SetAuditRepo wires the audit-event repository (optional; auditing is a no-op +// without it). Uses the handler's existing settings service for the truncation +// limit. +func (h *ProxyHandler) SetAuditRepo(repo repository.AuditEventRepository) { + h.auditRepo = repo +} + // NewProxyHandler creates a new proxy handler func NewProxyHandler(llmRepo repository.LLMModelRepository, registry *agents.Registry, userService *service.UserService, groupRepo repository.GroupRepository, sessionStore store.SessionStore, hub *pubsub.Hub, auditLogger *audit.Logger, logger *zap.Logger, settingsService *appsettings.Service, lfTracer *lf.Tracer, chatEventRepo ...repository.ChatEventRepository) *ProxyHandler { h := &ProxyHandler{ @@ -557,6 +565,25 @@ func (h *ProxyHandler) Chat(w http.ResponseWriter, r *http.Request) { SessionRotated: sessionRotated, } recordChatEvent(h.chatEventRepo, event, h.logger) + + var auditResp string + if result != nil { + auditResp = proxy.TranscriptText(result) + } + recordAuditEvent(h.auditRepo, h.settings, &models.AuditEvent{ + UserEmail: userEmail, + ResourceType: models.AuditResourceAgent, + ResourceID: agentID, + ResourceName: agent.Name, + Source: models.AuditSourceWeb, + Action: models.AuditActionChat, + Prompt: lastMessageContent(messagesToSend), + Response: auditResp, + Status: status, + ErrorType: errType, + ErrorMsg: errMsg, + DurationMs: durationMs, + }, h.logger) } // Audit log diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 15a6784..e69016b 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -93,6 +93,8 @@ func SetupRoutes(cfg *config.Config, registry *agents.Registry, sessionStore sto proxyHandler := handlers.NewProxyHandler(llmRepo, registry, adminDeps.UserService, adminDeps.GroupRepo, sessionStore, adminDeps.PubSubHub, auditLogger, logger, adminDeps.SettingsService, adminDeps.LangfuseTracer, adminDeps.ChatEventRepo) sessionsHandler := handlers.NewSessionsHandler(registry, sessionStore, adminDeps.GroupRepo, adminDeps.UserService, auditLogger, logger) mcpHandler := handlers.NewMCPHandler(llmRepo, mcpRegistry, sessionStore, func() int { return adminDeps.SettingsService.Int(settings.KeyMCPMaxToolRounds) }, auditLogger, logger, adminDeps.LangfuseTracer, adminDeps.OAuth2Manager, adminDeps.MCPRepo, adminDeps.ChatEventRepo) + proxyHandler.SetAuditRepo(adminDeps.AuditEventRepo) + mcpHandler.SetAudit(adminDeps.AuditEventRepo, adminDeps.SettingsService) chartHandler := handlers.NewChartHandler(llmRepo, adminDeps.LangfuseTracer, logger) // User handler From 9db70f8118bf95b6df2c08b1fecb3a357b137da8 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 24 Jul 2026 10:15:29 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix(audit):=20filter=20tweaks=20=E2=80=94?= =?UTF-8?q?=20remove=20unused=20request=5Fid,=20richer=20time=20ranges,=20?= =?UTF-8?q?Max=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the request_id filter (not populated yet) - time range options: 30m/1h/6h/12h/24h/7d - add a Max results selector (50/100/250/500); repo limit cap raised to 1000 --- .../repository/postgres/audit_event.go | 2 +- web/src/app/admin/audit/page.tsx | 56 ++++++++++--------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/api/internal/repository/postgres/audit_event.go b/api/internal/repository/postgres/audit_event.go index a3e74ee..41ad04b 100644 --- a/api/internal/repository/postgres/audit_event.go +++ b/api/internal/repository/postgres/audit_event.go @@ -77,7 +77,7 @@ func (r *AuditEventRepository) List(ctx context.Context, f models.AuditEventFilt } limit := f.Limit - if limit <= 0 || limit > 200 { + if limit <= 0 || limit > 1000 { limit = 50 } args = append(args, limit) diff --git a/web/src/app/admin/audit/page.tsx b/web/src/app/admin/audit/page.tsx index e15a6d7..15d4833 100644 --- a/web/src/app/admin/audit/page.tsx +++ b/web/src/app/admin/audit/page.tsx @@ -7,14 +7,17 @@ import { Button } from "@/components/ui/button"; import { ChevronRight, RefreshCw } from "lucide-react"; import { AdminTableSkeleton } from "@/components/admin/AdminTableSkeleton"; -const RANGES: { label: string; hours: number }[] = [ - { label: "Last 24 hours", hours: 24 }, - { label: "Last 7 days", hours: 24 * 7 }, - { label: "Last 30 days", hours: 24 * 30 }, +const RANGES: { label: string; minutes: number }[] = [ + { label: "Last 30 minutes", minutes: 30 }, + { label: "Last hour", minutes: 60 }, + { label: "Last 6 hours", minutes: 360 }, + { label: "Last 12 hours", minutes: 720 }, + { label: "Last 24 hours", minutes: 1440 }, + { label: "Last 7 days", minutes: 10080 }, ]; const CATEGORIES = ["", "agent", "mcp", "skill", "group"]; -const PAGE_SIZE = 50; +const MAX_OPTIONS = [50, 100, 250, 500]; function fmtDate(iso: string): string { const d = new Date(iso); @@ -27,22 +30,21 @@ export default function AdminAuditPage() { const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(null); - const [rangeHours, setRangeHours] = useState(24); + const [rangeMinutes, setRangeMinutes] = useState(1440); const [user, setUser] = useState(""); const [category, setCategory] = useState(""); - const [requestId, setRequestId] = useState(""); + const [maxResults, setMaxResults] = useState(50); const [offset, setOffset] = useState(0); const fetchEvents = useCallback(async () => { setLoading(true); try { - const from = new Date(Date.now() - rangeHours * 3600 * 1000).toISOString(); + const from = new Date(Date.now() - rangeMinutes * 60 * 1000).toISOString(); const data = await getAuditEvents({ from, user, resource_type: category, - request_id: requestId, - limit: String(PAGE_SIZE), + limit: String(maxResults), offset: String(offset), }); setEvents(data.events); @@ -53,7 +55,7 @@ export default function AdminAuditPage() { } finally { setLoading(false); } - }, [rangeHours, user, category, requestId, offset]); + }, [rangeMinutes, user, category, maxResults, offset]); useEffect(() => { fetchEvents(); @@ -75,10 +77,10 @@ export default function AdminAuditPage() {
@@ -100,14 +102,15 @@ export default function AdminAuditPage() { {CATEGORIES.map((c) => )}
-
- - { setOffset(0); setRequestId(e.target.value); }} - /> +
+ +
@@ -155,7 +158,6 @@ export default function AdminAuditPage() {
- {e.request_id &&
Request ID: {e.request_id}
} {e.prompt && (
Prompt
@@ -190,12 +192,12 @@ export default function AdminAuditPage() { )} {/* Pagination */} - {total > PAGE_SIZE && ( + {total > maxResults && (
- {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} + {offset + 1}–{Math.min(offset + maxResults, total)} of {total}
- - + +
)} From 6e069a5b53816de83fc48887ae1e864ba3867678 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 24 Jul 2026 10:23:26 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(audit):=20richer=20events=20=E2=80=94?= =?UTF-8?q?=20MCP=20client,=20session,=20tokens/model,=20tool=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 000037: add client, session_id, token_usage, llm_model columns - AuditEvent gains Client, SessionID, ToolCalls (name+args+result), TokenUsage, LLMModel - facade captures the MCP client (user-agent) and agent session_id - web surfaces capture session_id, tool calls with args/result, and (MCP chat) token usage + LLM model - audit UI shows client on the row and a metadata line + expandable tool calls in the detail view --- api/internal/handlers/group_chat.go | 4 ++ api/internal/handlers/instrumentation.go | 14 ++++++ api/internal/handlers/mcp.go | 16 +++++++ api/internal/handlers/proxy.go | 4 ++ api/internal/mcpserver/group.go | 2 +- api/internal/mcpserver/handler.go | 10 ++-- api/internal/models/audit_event.go | 48 ++++++++++++------- .../repository/postgres/audit_event.go | 32 ++++++++----- .../000037_audit_events_extra.down.sql | 5 ++ .../000037_audit_events_extra.up.sql | 8 ++++ web/src/app/admin/audit/page.tsx | 23 +++++++++ web/src/lib/types.ts | 11 +++++ 12 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 api/migrations/000037_audit_events_extra.down.sql create mode 100644 api/migrations/000037_audit_events_extra.up.sql diff --git a/api/internal/handlers/group_chat.go b/api/internal/handlers/group_chat.go index 36e3c2b..a2b715d 100644 --- a/api/internal/handlers/group_chat.go +++ b/api/internal/handlers/group_chat.go @@ -652,8 +652,10 @@ func (h *ProxyHandler) recordGroupTurnEvent(agent *models.Agent, sessionID, user }, h.logger) var auditResp string + var auditTools []models.AuditToolCall if result != nil { auditResp = proxy.TranscriptText(result) + auditTools = auditToolCalls(result.ToolCalls) } recordAuditEvent(h.auditRepo, h.settings, &models.AuditEvent{ UserEmail: userEmail, @@ -661,8 +663,10 @@ func (h *ProxyHandler) recordGroupTurnEvent(agent *models.Agent, sessionID, user ResourceID: agent.ID, ResourceName: agent.Name, Source: models.AuditSourceWeb, + SessionID: sessionID, Action: models.AuditActionGroupDebate, Response: auditResp, + ToolCalls: auditTools, Status: status, ErrorType: errType, ErrorMsg: errMsg, diff --git a/api/internal/handlers/instrumentation.go b/api/internal/handlers/instrumentation.go index fb19b46..a1cd4b9 100644 --- a/api/internal/handlers/instrumentation.go +++ b/api/internal/handlers/instrumentation.go @@ -7,11 +7,25 @@ import ( "github.com/dfradehubs/agentgram-api/internal/audit" "github.com/dfradehubs/agentgram-api/internal/metrics" "github.com/dfradehubs/agentgram-api/internal/models" + "github.com/dfradehubs/agentgram-api/internal/proxy" "github.com/dfradehubs/agentgram-api/internal/repository" appsettings "github.com/dfradehubs/agentgram-api/internal/settings" "go.uber.org/zap" ) +// auditToolCalls converts captured proxy tool calls (name + args + result) to +// the audit-event shape. +func auditToolCalls(tcs []proxy.CapturedToolCall) []models.AuditToolCall { + if len(tcs) == 0 { + return nil + } + out := make([]models.AuditToolCall, 0, len(tcs)) + for _, tc := range tcs { + out = append(out, models.AuditToolCall{Name: tc.Name, Arguments: tc.Args, Result: tc.Result}) + } + return out +} + // recordAuditEvent records a detailed audit event asynchronously, truncating // content to the runtime-configured limit. No-op when auditing isn't wired. func recordAuditEvent(repo repository.AuditEventRepository, settings *appsettings.Service, ev *models.AuditEvent, logger *zap.Logger) { diff --git a/api/internal/handlers/mcp.go b/api/internal/handlers/mcp.go index 5823f20..7ab498b 100644 --- a/api/internal/handlers/mcp.go +++ b/api/internal/handlers/mcp.go @@ -493,15 +493,23 @@ func (h *MCPHandler) Chat(w http.ResponseWriter, r *http.Request) { if runResult != nil { auditResp = runResult.AssistantText } + auditTools := make([]models.AuditToolCall, 0, len(toolCallInfos)) + for _, tc := range toolCallInfos { + auditTools = append(auditTools, models.AuditToolCall{Name: tc.Name}) + } recordAuditEvent(h.auditRepo, h.auditSettings, &models.AuditEvent{ UserEmail: userEmail, ResourceType: models.AuditResourceMCP, ResourceID: serverID, ResourceName: server.Config.Name, Source: models.AuditSourceWeb, + SessionID: sessionID, Action: models.AuditActionChat, Prompt: lastMessageContent(req.Messages), Response: auditResp, + ToolCalls: auditTools, + TokenUsage: tokenUsage, + LLMModel: req.ModelID, Status: status, ErrorType: errType, ErrorMsg: errMsg, @@ -729,15 +737,23 @@ func (h *MCPHandler) ChatMulti(w http.ResponseWriter, r *http.Request) { if runResult != nil { auditResp = runResult.AssistantText } + auditTools := make([]models.AuditToolCall, 0, len(toolCallInfos)) + for _, tc := range toolCallInfos { + auditTools = append(auditTools, models.AuditToolCall{Name: tc.Name}) + } recordAuditEvent(h.auditRepo, h.auditSettings, &models.AuditEvent{ UserEmail: userEmail, ResourceType: models.AuditResourceMCP, ResourceID: resourceID, ResourceName: "Multi-MCP", Source: models.AuditSourceWeb, + SessionID: sessionID, Action: models.AuditActionChat, Prompt: lastMessageContent(req.Messages), Response: auditResp, + ToolCalls: auditTools, + TokenUsage: tokenUsage, + LLMModel: req.ModelID, Status: status, ErrorType: errType, ErrorMsg: errMsg, diff --git a/api/internal/handlers/proxy.go b/api/internal/handlers/proxy.go index 8e902f0..cbed928 100644 --- a/api/internal/handlers/proxy.go +++ b/api/internal/handlers/proxy.go @@ -567,8 +567,10 @@ func (h *ProxyHandler) Chat(w http.ResponseWriter, r *http.Request) { recordChatEvent(h.chatEventRepo, event, h.logger) var auditResp string + var auditTools []models.AuditToolCall if result != nil { auditResp = proxy.TranscriptText(result) + auditTools = auditToolCalls(result.ToolCalls) } recordAuditEvent(h.auditRepo, h.settings, &models.AuditEvent{ UserEmail: userEmail, @@ -576,9 +578,11 @@ func (h *ProxyHandler) Chat(w http.ResponseWriter, r *http.Request) { ResourceID: agentID, ResourceName: agent.Name, Source: models.AuditSourceWeb, + SessionID: session.SessionID, Action: models.AuditActionChat, Prompt: lastMessageContent(messagesToSend), Response: auditResp, + ToolCalls: auditTools, Status: status, ErrorType: errType, ErrorMsg: errMsg, diff --git a/api/internal/mcpserver/group.go b/api/internal/mcpserver/group.go index c1f58cf..b7d1822 100644 --- a/api/internal/mcpserver/group.go +++ b/api/internal/mcpserver/group.go @@ -197,7 +197,7 @@ func (h *Handler) handleGroupToolCall(w http.ResponseWriter, r *http.Request, re h.recordAudit(&models.AuditEvent{ UserEmail: userEmail, UserGroups: userGroups, ResourceType: models.AuditResourceGroup, ResourceID: groupID, ResourceName: group.Name, - Source: models.AuditSourceMCP, Action: models.AuditActionGroupDebate, + Source: models.AuditSourceMCP, Client: r.UserAgent(), Action: models.AuditActionGroupDebate, Prompt: args.Question, Response: responseText, Status: groupAuditStatus, DurationMs: elapsedSeconds * 1000, }) diff --git a/api/internal/mcpserver/handler.go b/api/internal/mcpserver/handler.go index 95de23b..6825ab1 100644 --- a/api/internal/mcpserver/handler.go +++ b/api/internal/mcpserver/handler.go @@ -591,7 +591,8 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js h.recordAudit(&models.AuditEvent{ UserEmail: userEmail, UserGroups: userGroups, ResourceType: models.AuditResourceAgent, ResourceID: agentID, ResourceName: agent.Name, - Source: models.AuditSourceMCP, Action: models.AuditActionChat, + Source: models.AuditSourceMCP, Client: r.UserAgent(), SessionID: cr.sessionID, + Action: models.AuditActionChat, Prompt: args.Question, Response: cr.text, Status: "error", ErrorMsg: cr.err.Error(), DurationMs: int(time.Since(auditStart).Milliseconds()), @@ -624,7 +625,8 @@ func (h *Handler) handleToolsCall(w http.ResponseWriter, r *http.Request, req js h.recordAudit(&models.AuditEvent{ UserEmail: userEmail, UserGroups: userGroups, ResourceType: models.AuditResourceAgent, ResourceID: agentID, ResourceName: agent.Name, - Source: models.AuditSourceMCP, Action: models.AuditActionChat, + Source: models.AuditSourceMCP, Client: r.UserAgent(), SessionID: cr.sessionID, + Action: models.AuditActionChat, Prompt: args.Question, Response: cr.text, Status: "ok", DurationMs: int(time.Since(auditStart).Milliseconds()), }) @@ -672,6 +674,7 @@ func (h *Handler) handleSkillToolCall(w http.ResponseWriter, r *http.Request, re ResourceID: skill.ID, ResourceName: skill.Name, Source: models.AuditSourceMCP, + Client: r.UserAgent(), Action: models.AuditActionSkillRead, Response: skill.Content, Status: "ok", @@ -856,8 +859,9 @@ func (h *Handler) handleMCPToolCall(w http.ResponseWriter, r *http.Request, req h.recordAudit(&models.AuditEvent{ UserEmail: userEmail, UserGroups: userGroups, ResourceType: models.AuditResourceMCP, ResourceID: serverID, ResourceName: server.Config.Name, - Source: models.AuditSourceMCP, Action: models.AuditActionMCPTool, + Source: models.AuditSourceMCP, Client: r.UserAgent(), Action: models.AuditActionMCPTool, Prompt: fmt.Sprintf("%s %s", toolName, string(argsJSON)), Response: text, Status: auditStatus, + ToolCalls: []models.AuditToolCall{{Name: toolName, Arguments: string(argsJSON), Result: text}}, DurationMs: int(time.Since(auditStart).Milliseconds()), }) diff --git a/api/internal/models/audit_event.go b/api/internal/models/audit_event.go index b5db23b..d5d3c56 100644 --- a/api/internal/models/audit_event.go +++ b/api/internal/models/audit_event.go @@ -19,28 +19,40 @@ const ( AuditActionGroupDebate = "group_debate" ) +// AuditToolCall is a tool invocation captured in an audit event, with arguments +// and result when available (truncation is applied to prompt/response only). +type AuditToolCall struct { + Name string `json:"name"` + Arguments string `json:"arguments,omitempty"` + Result string `json:"result,omitempty"` +} + // AuditEvent is a detailed record of one user-facing interaction, including the // prompt sent and the response returned (truncated to a configurable limit). // Distinct from ChatEvent, which stores only aggregate metrics with no content. type AuditEvent struct { - ID string `json:"id"` - RequestID string `json:"request_id,omitempty"` - UserEmail string `json:"user_email"` - UserGroups []string `json:"user_groups,omitempty"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - ResourceName string `json:"resource_name"` - Source string `json:"source"` - Action string `json:"action"` - Prompt string `json:"prompt"` - Response string `json:"response"` - ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"` - Status string `json:"status"` - ErrorType string `json:"error_type,omitempty"` - ErrorMsg string `json:"error_msg,omitempty"` - DurationMs int `json:"duration_ms"` - TTFBMs *int `json:"ttfb_ms,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + RequestID string `json:"request_id,omitempty"` + UserEmail string `json:"user_email"` + UserGroups []string `json:"user_groups,omitempty"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name"` + Source string `json:"source"` + Client string `json:"client,omitempty"` // MCP client / user-agent that made the call + SessionID string `json:"session_id,omitempty"` // correlate events in one conversation + Action string `json:"action"` + Prompt string `json:"prompt"` + Response string `json:"response"` + ToolCalls []AuditToolCall `json:"tool_calls,omitempty"` + TokenUsage *TokenUsage `json:"token_usage,omitempty"` + LLMModel string `json:"llm_model,omitempty"` + Status string `json:"status"` + ErrorType string `json:"error_type,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` + DurationMs int `json:"duration_ms"` + TTFBMs *int `json:"ttfb_ms,omitempty"` + CreatedAt time.Time `json:"created_at"` } // AuditEventFilter narrows an audit-event list query. Zero values mean "no diff --git a/api/internal/repository/postgres/audit_event.go b/api/internal/repository/postgres/audit_event.go index 41ad04b..3a92cab 100644 --- a/api/internal/repository/postgres/audit_event.go +++ b/api/internal/repository/postgres/audit_event.go @@ -21,18 +21,22 @@ func NewAuditEventRepository(pool *pgxpool.Pool) *AuditEventRepository { } func (r *AuditEventRepository) Insert(ctx context.Context, e *models.AuditEvent) error { - var toolCallsJSON []byte + var toolCallsJSON, tokenUsageJSON []byte if len(e.ToolCalls) > 0 { toolCallsJSON, _ = json.Marshal(e.ToolCalls) } + if e.TokenUsage != nil { + tokenUsageJSON, _ = json.Marshal(e.TokenUsage) + } _, err := r.pool.Exec(ctx, `INSERT INTO audit_events (request_id, user_email, user_groups, resource_type, resource_id, resource_name, - source, action, prompt, response, tool_calls, status, error_type, error_msg, duration_ms, ttfb_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + source, client, session_id, action, prompt, response, tool_calls, token_usage, llm_model, + status, error_type, error_msg, duration_ms, ttfb_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, nullStr(e.RequestID), e.UserEmail, e.UserGroups, e.ResourceType, e.ResourceID, nullStr(e.ResourceName), - e.Source, e.Action, e.Prompt, e.Response, toolCallsJSON, e.Status, nullStr(e.ErrorType), nullStr(e.ErrorMsg), - e.DurationMs, e.TTFBMs) + e.Source, nullStr(e.Client), nullStr(e.SessionID), e.Action, e.Prompt, e.Response, toolCallsJSON, tokenUsageJSON, nullStr(e.LLMModel), + e.Status, nullStr(e.ErrorType), nullStr(e.ErrorMsg), e.DurationMs, e.TTFBMs) if err != nil { return fmt.Errorf("insert audit event: %w", err) } @@ -87,8 +91,8 @@ func (r *AuditEventRepository) List(ctx context.Context, f models.AuditEventFilt rows, err := r.pool.Query(ctx, `SELECT id, request_id, user_email, user_groups, resource_type, resource_id, resource_name, - source, action, prompt, response, tool_calls, status, error_type, error_msg, - duration_ms, ttfb_ms, created_at + source, client, session_id, action, prompt, response, tool_calls, token_usage, llm_model, + status, error_type, error_msg, duration_ms, ttfb_ms, created_at FROM audit_events `+where+ fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", limitPos, offsetPos), args...) @@ -100,20 +104,26 @@ func (r *AuditEventRepository) List(ctx context.Context, f models.AuditEventFilt var events []*models.AuditEvent for rows.Next() { var e models.AuditEvent - var reqID, resName, errType, errMsg *string - var toolCallsJSON []byte + var reqID, resName, client, sessionID, llmModel, errType, errMsg *string + var toolCallsJSON, tokenUsageJSON []byte if err := rows.Scan(&e.ID, &reqID, &e.UserEmail, &e.UserGroups, &e.ResourceType, &e.ResourceID, &resName, - &e.Source, &e.Action, &e.Prompt, &e.Response, &toolCallsJSON, &e.Status, &errType, &errMsg, - &e.DurationMs, &e.TTFBMs, &e.CreatedAt); err != nil { + &e.Source, &client, &sessionID, &e.Action, &e.Prompt, &e.Response, &toolCallsJSON, &tokenUsageJSON, &llmModel, + &e.Status, &errType, &errMsg, &e.DurationMs, &e.TTFBMs, &e.CreatedAt); err != nil { return nil, 0, fmt.Errorf("scan audit event: %w", err) } e.RequestID = deref(reqID) e.ResourceName = deref(resName) + e.Client = deref(client) + e.SessionID = deref(sessionID) + e.LLMModel = deref(llmModel) e.ErrorType = deref(errType) e.ErrorMsg = deref(errMsg) if len(toolCallsJSON) > 0 { _ = json.Unmarshal(toolCallsJSON, &e.ToolCalls) } + if len(tokenUsageJSON) > 0 { + _ = json.Unmarshal(tokenUsageJSON, &e.TokenUsage) + } events = append(events, &e) } return events, total, rows.Err() diff --git a/api/migrations/000037_audit_events_extra.down.sql b/api/migrations/000037_audit_events_extra.down.sql new file mode 100644 index 0000000..4c4e46b --- /dev/null +++ b/api/migrations/000037_audit_events_extra.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_audit_events_session; +ALTER TABLE audit_events DROP COLUMN IF EXISTS llm_model; +ALTER TABLE audit_events DROP COLUMN IF EXISTS token_usage; +ALTER TABLE audit_events DROP COLUMN IF EXISTS session_id; +ALTER TABLE audit_events DROP COLUMN IF EXISTS client; diff --git a/api/migrations/000037_audit_events_extra.up.sql b/api/migrations/000037_audit_events_extra.up.sql new file mode 100644 index 0000000..9646ff9 --- /dev/null +++ b/api/migrations/000037_audit_events_extra.up.sql @@ -0,0 +1,8 @@ +-- Richer audit event fields: MCP client/origin, session correlation, token usage +-- and LLM model. tool_calls (already JSONB) now stores name+arguments+result. +ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS client VARCHAR(255); +ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS session_id VARCHAR(255); +ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS token_usage JSONB; +ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS llm_model VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_audit_events_session ON audit_events (session_id); diff --git a/web/src/app/admin/audit/page.tsx b/web/src/app/admin/audit/page.tsx index 15d4833..03c9739 100644 --- a/web/src/app/admin/audit/page.tsx +++ b/web/src/app/admin/audit/page.tsx @@ -147,6 +147,7 @@ export default function AdminAuditPage() {
{e.action} · {e.duration_ms}ms · {e.source} + {e.client ? ` · ${e.client.length > 40 ? e.client.slice(0, 40) + "…" : e.client}` : ""}
{e.user_email} @@ -158,6 +159,28 @@ export default function AdminAuditPage() {
+
+ {e.client && Client: {e.client}} + {e.session_id && Session: {e.session_id}} + {e.llm_model && Model: {e.llm_model}} + {e.token_usage && e.token_usage.total > 0 && ( + Tokens: {e.token_usage.input}/{e.token_usage.output} ({e.token_usage.total}) + )} +
+ {e.tool_calls && e.tool_calls.length > 0 && ( +
+
Tool calls
+
+ {e.tool_calls.map((tc, i) => ( +
+ {tc.name} + {tc.arguments &&
args: {tc.arguments}
} + {tc.result &&
result: {tc.result}
} +
+ ))} +
+
+ )} {e.prompt && (
Prompt
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 430635b..6f9d18a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -250,6 +250,12 @@ export interface AdminMCPServer { api_key_rules?: MCPApiKeyRule[]; } +export interface AuditToolCall { + name: string; + arguments?: string; + result?: string; +} + export interface AuditEvent { id: string; request_id?: string; @@ -259,9 +265,14 @@ export interface AuditEvent { resource_id: string; resource_name: string; source: string; + client?: string; + session_id?: string; action: string; prompt: string; response: string; + tool_calls?: AuditToolCall[]; + token_usage?: { input: number; output: number; total: number }; + llm_model?: string; status: string; error_type?: string; error_msg?: string; From 06c7c937f0c0e3a145a0dd81808d6a3f0c66dc6f Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 24 Jul 2026 10:35:44 +0200 Subject: [PATCH 5/9] feat(audit): session column and filter - backend: filter audit events by session_id (?session=) - UI: Session column (click a session id to filter by it) + Session search box --- api/internal/handlers/admin_audit.go | 1 + api/internal/models/audit_event.go | 1 + .../repository/postgres/audit_event.go | 3 ++ web/src/app/admin/audit/page.tsx | 31 +++++++++++++++++-- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/api/internal/handlers/admin_audit.go b/api/internal/handlers/admin_audit.go index beb6387..a4bf433 100644 --- a/api/internal/handlers/admin_audit.go +++ b/api/internal/handlers/admin_audit.go @@ -31,6 +31,7 @@ func (h *AdminAuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Reque Group: q.Get("group"), ResourceType: q.Get("resource_type"), RequestID: q.Get("request_id"), + SessionID: q.Get("session"), } if v := q.Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { diff --git a/api/internal/models/audit_event.go b/api/internal/models/audit_event.go index d5d3c56..2e4dad7 100644 --- a/api/internal/models/audit_event.go +++ b/api/internal/models/audit_event.go @@ -64,6 +64,7 @@ type AuditEventFilter struct { Group string ResourceType string RequestID string + SessionID string Limit int Offset int } diff --git a/api/internal/repository/postgres/audit_event.go b/api/internal/repository/postgres/audit_event.go index 3a92cab..7dc43aa 100644 --- a/api/internal/repository/postgres/audit_event.go +++ b/api/internal/repository/postgres/audit_event.go @@ -70,6 +70,9 @@ func (r *AuditEventRepository) List(ctx context.Context, f models.AuditEventFilt if f.RequestID != "" { add("request_id = $%d", f.RequestID) } + if f.SessionID != "" { + add("session_id = $%d", f.SessionID) + } where := "" if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") diff --git a/web/src/app/admin/audit/page.tsx b/web/src/app/admin/audit/page.tsx index 03c9739..63d9d56 100644 --- a/web/src/app/admin/audit/page.tsx +++ b/web/src/app/admin/audit/page.tsx @@ -33,6 +33,7 @@ export default function AdminAuditPage() { const [rangeMinutes, setRangeMinutes] = useState(1440); const [user, setUser] = useState(""); const [category, setCategory] = useState(""); + const [session, setSession] = useState(""); const [maxResults, setMaxResults] = useState(50); const [offset, setOffset] = useState(0); @@ -44,6 +45,7 @@ export default function AdminAuditPage() { from, user, resource_type: category, + session, limit: String(maxResults), offset: String(offset), }); @@ -55,7 +57,7 @@ export default function AdminAuditPage() { } finally { setLoading(false); } - }, [rangeMinutes, user, category, maxResults, offset]); + }, [rangeMinutes, user, category, session, maxResults, offset]); useEffect(() => { fetchEvents(); @@ -102,6 +104,15 @@ export default function AdminAuditPage() { {CATEGORIES.map((c) => )}
+
+ + { setOffset(0); setSession(e.target.value); }} + /> +
@@ -114,7 +161,7 @@ export default function AdminAuditPage() { />
- +