Loading...
# UI, Query API, and Validation Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add Sessions/Run detail views with supporting query API and schema validation at ingest. **Architecture:** Three layers - validation in shared `internal/event` package, new query methods in `internal/store/postgres`, frontend as static files served by web-ui with History API routing. **Tech Stack:** Go 1.21+, chi router, pgx, Vanilla JS (no build step) --- ## Task 1: Add Event Validation Add a `Validate` function to the event package for schema validation. **Files:** - Create: `internal/event/validate.go` - Create: `internal/event/validate_test.go` **Step 1: Write the test file** ```go // internal/event/validate_test.go package event import ( "encoding/json" "testing" ) func TestValidate_ValidEvent(t *testing.T) { raw := `{ "schema": {"name": "agentmon.event", "version": 1}, "event": { "id": "e1", "type": "run.start", "ts": "2026-01-17T00:00:00Z", "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} } }` var m map[string]any _ = json.Unmarshal([]byte(raw), &m) err := Validate(m) if err != nil { t.Fatalf("expected no error, got %v", err) } } func TestValidate_MissingEventID(t *testing.T) { raw := `{ "schema": {"name": "agentmon.event", "version": 1}, "event": { "type": "run.start", "ts": "2026-01-17T00:00:00Z", "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} } }` var m map[string]any _ = json.Unmarshal([]byte(raw), &m) err := Validate(m) if err == nil { t.Fatal("expected error for missing event.id") } } func TestValidate_InvalidType(t *testing.T) { raw := `{ "schema": {"name": "agentmon.event", "version": 1}, "event": { "id": "e1", "type": "invalid.type", "ts": "2026-01-17T00:00:00Z", "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} } }` var m map[string]any _ = json.Unmarshal([]byte(raw), &m) err := Validate(m) if err == nil { t.Fatal("expected error for invalid type") } } func TestValidate_WrongSchemaName(t *testing.T) { raw := `{ "schema": {"name": "wrong.schema", "version": 1}, "event": { "id": "e1", "type": "run.start", "ts": "2026-01-17T00:00:00Z", "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} } }` var m map[string]any _ = json.Unmarshal([]byte(raw), &m) err := Validate(m) if err == nil { t.Fatal("expected error for wrong schema name") } } ``` **Step 2: Run test to verify it fails** Run: `cd ~/lab/agentmon && go test ./internal/event/... -v` Expected: FAIL (Validate undefined) **Step 3: Write the validation implementation** ```go // internal/event/validate.go package event import ( "errors" "fmt" ) var validTypes = map[string]bool{ "session.start": true, "session.end": true, "run.start": true, "run.end": true, "span.start": true, "span.end": true, "error": true, "metric.snapshot": true, } type ValidationError struct { Field string Message string } func (e ValidationError) Error() string { return fmt.Sprintf("%s: %s", e.Field, e.Message) } func Validate(m map[string]any) error { // Check schema schema, ok := m["schema"].(map[string]any) if !ok { return ValidationError{Field: "schema", Message: "missing or invalid"} } if name, _ := schema["name"].(string); name != "agentmon.event" { return ValidationError{Field: "schema.name", Message: "must be 'agentmon.event'"} } if ver, _ := schema["version"].(float64); ver != 1 { return ValidationError{Field: "schema.version", Message: "must be 1"} } // Check event event, ok := m["event"].(map[string]any) if !ok { return ValidationError{Field: "event", Message: "missing or invalid"} } if id, _ := event["id"].(string); id == "" { return ValidationError{Field: "event.id", Message: "required"} } eventType, _ := event["type"].(string) if eventType == "" { return ValidationError{Field: "event.type", Message: "required"} } if !validTypes[eventType] { return ValidationError{Field: "event.type", Message: fmt.Sprintf("invalid type '%s'", eventType)} } if event["ts"] == nil { return ValidationError{Field: "event.ts", Message: "required"} } // Check source source, ok := event["source"].(map[string]any) if !ok { return ValidationError{Field: "event.source", Message: "missing or invalid"} } if fw, _ := source["framework"].(string); fw == "" { return ValidationError{Field: "event.source.framework", Message: "required"} } if cid, _ := source["client_id"].(string); cid == "" { return ValidationError{Field: "event.source.client_id", Message: "required"} } if host, _ := source["host"].(string); host == "" { return ValidationError{Field: "event.source.host", Message: "required"} } return nil } func IsValidationError(err error) bool { var ve ValidationError return errors.As(err, &ve) } ``` **Step 4: Run test to verify it passes** Run: `cd ~/lab/agentmon && go test ./internal/event/... -v` Expected: PASS **Step 5: Commit** ```bash git add internal/event/validate.go internal/event/validate_test.go git commit -m "feat: add event validation" ``` --- ## Task 2: Wire Validation into Ingest Gateway **Files:** - Modify: `cmd/ingest-gateway/main.go` **Step 1: Update HTTP handler to validate** In `cmd/ingest-gateway/main.go`, update the POST handler: ```go // Add import at top: // "agentmon/internal/event" // Replace the POST /v1/events handler (around line 39): r.Post("/v1/events", func(w http.ResponseWriter, r *http.Request) { var events []json.RawMessage if err := json.NewDecoder(r.Body).Decode(&events); err != nil { httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_json"}) return } accepted := 0 rejected := 0 var errors []map[string]any for _, raw := range events { if len(raw) == 0 { rejected++ continue } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { rejected++ errors = append(errors, map[string]any{"error": "invalid_json"}) continue } if err := event.Validate(m); err != nil { rejected++ if ve, ok := err.(event.ValidationError); ok { errors = append(errors, map[string]any{"error": "validation_failed", "field": ve.Field, "message": ve.Message}) } continue } if err := pub.Publish(r.Context(), raw); err != nil { rejected++ continue } accepted++ } resp := map[string]any{"accepted": accepted, "rejected": rejected} if len(errors) > 0 { resp["errors"] = errors } httpx.WriteJSON(w, http.StatusAccepted, resp) }) ``` **Step 2: Update WebSocket handler to validate** Replace the wsHandler function body's message processing (around line 86): ```go var m map[string]any if err := json.Unmarshal(msg, &m); err != nil { _ = conn.WriteJSON(map[string]any{"error": "invalid_json"}) continue } if err := event.Validate(m); err != nil { if ve, ok := err.(event.ValidationError); ok { _ = conn.WriteJSON(map[string]any{"error": "validation_failed", "field": ve.Field, "message": ve.Message}) } else { _ = conn.WriteJSON(map[string]any{"error": "validation_failed"}) } continue } if err := pub.Publish(r.Context(), msg); err != nil { _ = conn.WriteJSON(map[string]any{"error": "publish_failed"}) continue } _ = conn.WriteJSON(map[string]any{"ack": map[string]any{"up_to_seq": nil}}) ``` **Step 3: Build to verify** Run: `cd ~/lab/agentmon && go build ./cmd/ingest-gateway` Expected: Build succeeds **Step 4: Commit** ```bash git add cmd/ingest-gateway/main.go git commit -m "feat: add validation to ingest gateway" ``` --- ## Task 3: Add Sessions Query **Files:** - Create: `internal/store/postgres/sessions.go` **Step 1: Create the sessions query file** ```go // internal/store/postgres/sessions.go package postgres import ( "context" "fmt" "strings" "time" ) type SessionRow struct { SessionID string `json:"session_id"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at,omitempty"` Framework string `json:"framework"` Host string `json:"host"` RunCount int `json:"run_count"` } type SessionsFilter struct { From *time.Time To *time.Time Framework string Host string Limit int Cursor *time.Time // cursor is the last started_at seen } func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow, *time.Time, error) { if f.Limit <= 0 { f.Limit = 50 } if f.Limit > 200 { f.Limit = 200 } // Build query dynamically var conditions []string var args []any argN := 1 // Default time range: last 24h if f.From == nil { t := time.Now().Add(-24 * time.Hour) f.From = &t } conditions = append(conditions, fmt.Sprintf("ts >= $%d", argN)) args = append(args, *f.From) argN++ if f.To != nil { conditions = append(conditions, fmt.Sprintf("ts <= $%d", argN)) args = append(args, *f.To) argN++ } if f.Framework != "" { conditions = append(conditions, fmt.Sprintf("source_framework = $%d", argN)) args = append(args, f.Framework) argN++ } if f.Host != "" { conditions = append(conditions, fmt.Sprintf("payload->'event'->'source'->>'host' = $%d", argN)) args = append(args, f.Host) argN++ } if f.Cursor != nil { conditions = append(conditions, fmt.Sprintf("ts < $%d", argN)) args = append(args, *f.Cursor) argN++ } where := strings.Join(conditions, " AND ") query := fmt.Sprintf(` SELECT session_id, MIN(ts) as started_at, MAX(ts) as ended_at, MAX(source_framework) as framework, MAX(payload->'event'->'source'->>'host') as host, COUNT(DISTINCT run_id) as run_count FROM events WHERE session_id IS NOT NULL AND %s GROUP BY session_id ORDER BY started_at DESC LIMIT $%d `, where, argN) args = append(args, f.Limit+1) // fetch one extra to detect next page rows, err := d.sql.QueryContext(ctx, query, args...) if err != nil { return nil, nil, err } defer rows.Close() var out []SessionRow for rows.Next() { var r SessionRow var host *string if err := rows.Scan(&r.SessionID, &r.StartedAt, &r.EndedAt, &r.Framework, &host, &r.RunCount); err != nil { return nil, nil, err } if host != nil { r.Host = *host } out = append(out, r) } var nextCursor *time.Time if len(out) > f.Limit { out = out[:f.Limit] nextCursor = &out[len(out)-1].StartedAt } return out, nextCursor, rows.Err() } ``` **Step 2: Build to verify** Run: `cd ~/lab/agentmon && go build ./...` Expected: Build succeeds **Step 3: Commit** ```bash git add internal/store/postgres/sessions.go git commit -m "feat: add sessions list query" ``` --- ## Task 4: Add Session Detail Query (Runs) **Files:** - Create: `internal/store/postgres/runs.go` **Step 1: Create the runs query file** ```go // internal/store/postgres/runs.go package postgres import ( "context" "time" ) type RunRow struct { RunID string `json:"run_id"` SessionID string `json:"session_id"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at,omitempty"` Status string `json:"status"` SpanCount int `json:"span_count"` } type SessionDetail struct { SessionID string `json:"session_id"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at,omitempty"` Framework string `json:"framework"` Host string `json:"host"` } func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*SessionDetail, []RunRow, error) { // Get session info sessionQuery := ` SELECT session_id, MIN(ts) as started_at, MAX(ts) as ended_at, MAX(source_framework) as framework, MAX(payload->'event'->'source'->>'host') as host FROM events WHERE session_id = $1 GROUP BY session_id ` var session SessionDetail var host *string err := d.sql.QueryRowContext(ctx, sessionQuery, sessionID).Scan( &session.SessionID, &session.StartedAt, &session.EndedAt, &session.Framework, &host, ) if err != nil { return nil, nil, err } if host != nil { session.Host = *host } // Get runs runsQuery := ` SELECT run_id, session_id, MIN(ts) as started_at, MAX(ts) as ended_at, CASE WHEN bool_or(type = 'error' OR payload->'payload'->>'status' = 'error') THEN 'error' ELSE 'success' END as status, COUNT(DISTINCT span_id) as span_count FROM events WHERE session_id = $1 AND run_id IS NOT NULL GROUP BY run_id, session_id ORDER BY started_at ASC ` rows, err := d.sql.QueryContext(ctx, runsQuery, sessionID) if err != nil { return nil, nil, err } defer rows.Close() var runs []RunRow for rows.Next() { var r RunRow if err := rows.Scan(&r.RunID, &r.SessionID, &r.StartedAt, &r.EndedAt, &r.Status, &r.SpanCount); err != nil { return nil, nil, err } runs = append(runs, r) } return &session, runs, rows.Err() } ``` **Step 2: Build to verify** Run: `cd ~/lab/agentmon && go build ./...` Expected: Build succeeds **Step 3: Commit** ```bash git add internal/store/postgres/runs.go git commit -m "feat: add session detail with runs query" ``` --- ## Task 5: Add Run Detail Query (Spans) **Files:** - Modify: `internal/store/postgres/runs.go` **Step 1: Add GetRunWithSpans function** Append to `internal/store/postgres/runs.go`: ```go type SpanRow struct { SpanID string `json:"span_id"` Name string `json:"name"` Kind string `json:"kind"` StartedAt time.Time `json:"started_at"` Duration *int64 `json:"duration_ms,omitempty"` Status string `json:"status"` Payload json.RawMessage `json:"payload"` } type RunDetail struct { RunID string `json:"run_id"` SessionID string `json:"session_id"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at,omitempty"` Status string `json:"status"` } func (d *DB) GetRunWithSpans(ctx context.Context, runID string) (*RunDetail, []SpanRow, error) { // Get run info runQuery := ` SELECT run_id, session_id, MIN(ts) as started_at, MAX(ts) as ended_at, CASE WHEN bool_or(type = 'error' OR payload->'payload'->>'status' = 'error') THEN 'error' ELSE 'success' END as status FROM events WHERE run_id = $1 GROUP BY run_id, session_id ` var run RunDetail err := d.sql.QueryRowContext(ctx, runQuery, runID).Scan( &run.RunID, &run.SessionID, &run.StartedAt, &run.EndedAt, &run.Status, ) if err != nil { return nil, nil, err } // Get spans spansQuery := ` SELECT span_id, COALESCE(payload->'attributes'->>'name', payload->'event'->>'type', type) as name, COALESCE(payload->'attributes'->>'span_kind', 'unknown') as kind, ts as started_at, (payload->'payload'->>'duration_ms')::bigint as duration_ms, CASE WHEN type = 'error' OR payload->'payload'->>'status' = 'error' THEN 'error' ELSE 'success' END as status, payload FROM events WHERE run_id = $1 AND span_id IS NOT NULL ORDER BY ts ASC ` rows, err := d.sql.QueryContext(ctx, spansQuery, runID) if err != nil { return nil, nil, err } defer rows.Close() var spans []SpanRow for rows.Next() { var s SpanRow if err := rows.Scan(&s.SpanID, &s.Name, &s.Kind, &s.StartedAt, &s.Duration, &s.Status, &s.Payload); err != nil { return nil, nil, err } spans = append(spans, s) } return &run, spans, rows.Err() } ``` **Step 2: Add import for encoding/json at top of file** Add `"encoding/json"` to the imports. **Step 3: Build to verify** Run: `cd ~/lab/agentmon && go build ./...` Expected: Build succeeds **Step 4: Commit** ```bash git add internal/store/postgres/runs.go git commit -m "feat: add run detail with spans query" ``` --- ## Task 6: Add Query API Endpoints **Files:** - Modify: `cmd/query-api/main.go` **Step 1: Add sessions endpoint** Add the new routes after the existing `/v1/events` handler. Full updated main.go: ```go package main import ( "database/sql" "log" "net/http" "os" "strconv" "time" "agentmon/internal/httpx" "agentmon/internal/store/postgres" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) func main() { addr := envDefault("AGENTMON_QUERY_ADDR", ":8081") dsn := os.Getenv("DATABASE_URL") if dsn == "" { log.Fatalf("DATABASE_URL is required") } db, err := postgres.Open(dsn) if err != nil { log.Fatalf("failed to open DB: %v", err) } defer func() { _ = db.Close() }() r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) r.Get("/v1/events", func(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) events, err := db.ListRecentEvents(r.Context(), limit) if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"events": events}) }) r.Get("/v1/sessions", func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() f := postgres.SessionsFilter{ Framework: q.Get("framework"), Host: q.Get("host"), } if v := q.Get("limit"); v != "" { f.Limit, _ = strconv.Atoi(v) } if v := q.Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { f.From = &t } else if t, err := time.Parse("2006-01-02", 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 } else if t, err := time.Parse("2006-01-02", v); err == nil { end := t.Add(24*time.Hour - time.Nanosecond) f.To = &end } } if v := q.Get("cursor"); v != "" { if t, err := time.Parse(time.RFC3339Nano, v); err == nil { f.Cursor = &t } } sessions, nextCursor, err := db.ListSessions(r.Context(), f) if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } resp := map[string]any{"sessions": sessions} if nextCursor != nil { resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano) } httpx.WriteJSON(w, http.StatusOK, resp) }) r.Get("/v1/sessions/{sessionID}", func(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "sessionID") session, runs, err := db.GetSessionWithRuns(r.Context(), sessionID) if err == sql.ErrNoRows { httpx.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) return } if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"session": session, "runs": runs}) }) r.Get("/v1/runs/{runID}", func(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") run, spans, err := db.GetRunWithSpans(r.Context(), runID) if err == sql.ErrNoRows { httpx.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) return } if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"run": run, "spans": spans}) }) log.Printf("query-api listening on %s", addr) log.Fatal(http.ListenAndServe(addr, r)) } func envDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } ``` **Step 2: Build to verify** Run: `cd ~/lab/agentmon && go build ./cmd/query-api` Expected: Build succeeds **Step 3: Commit** ```bash git add cmd/query-api/main.go git commit -m "feat: add sessions and runs endpoints to query-api" ``` --- ## Task 7: Create Static File Structure for Web UI **Files:** - Create: `cmd/web-ui/static/index.html` - Create: `cmd/web-ui/static/style.css` - Create: `cmd/web-ui/static/app.js` **Step 1: Create index.html** ```html
Loading...
Page not found
'; } } function navigate(path) { history.pushState(null, '', path); route(); } window.addEventListener('popstate', route); // API helpers async function api(path) { const resp = await fetch('/api' + path); if (!resp.ok) throw new Error('API error'); return resp.json(); } function relativeTime(ts) { const now = Date.now(); const then = new Date(ts).getTime(); const diff = now - then; if (diff < 60000) return 'just now'; if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; return Math.floor(diff / 86400000) + 'd ago'; } function formatDuration(ms) { if (!ms) return '-'; if (ms < 1000) return ms + 'ms'; if (ms < 60000) return (ms / 1000).toFixed(1) + 's'; return (ms / 60000).toFixed(1) + 'm'; } function statusIcon(status) { if (status === 'success') return '✓'; if (status === 'error') return '✗'; return '●'; } // Sessions list let sessionsState = { sessions: [], cursor: null, filters: {} }; async function renderSessions() { app.innerHTML = `| Session | Framework | Host | Runs | Time |
|---|
| Run ID | Status | Spans | Duration | Started |
|---|---|---|---|---|
| ${r.run_id.substring(0, 12)}... | ${statusIcon(r.status)} ${r.status} | ${r.span_count} | ${dur} | ${new Date(r.started_at).toLocaleTimeString()} |
| No runs | ||||
| Name | Kind | Status | Duration |
|---|---|---|---|
| ${sp.name} | ${sp.kind} | ${statusIcon(sp.status)} | ${formatDuration(sp.duration_ms)} |
| No spans | |||