From 78376bdd831d12f656794c31623cb726a340b7a8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 20 May 2026 17:35:56 -0700 Subject: [PATCH] feat(query): include session totals and stable framework names --- README.md | 14 +++++++ cmd/query-api/main.go | 9 +++++ internal/store/postgres/runs.go | 2 +- internal/store/postgres/sessions.go | 58 ++++++++++++++++++++++++++++- internal/store/postgres/stats.go | 2 +- 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8762c5c..bf95da0 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,20 @@ The `hooks/gemini/` directory contains a TypeScript handler for Gemini CLI telem Sample Gemini hook configuration lives in [hooks/gemini/hooks.json](/home/will/lab/agentmon/hooks/gemini/hooks.json). Install the handler from that directory so the `agentmon-gemini-handler` binary is available, then point Gemini CLI at the sample hook config and set `AGENTMON_INGEST_URL` to your ingest gateway. +## Hermes Hook + +The `hooks/hermes/` directory contains a TypeScript handler for Hermes Agent shell-hook telemetry. The current integration maps Hermes hook events into agentmon's session/run/span model: + +- `on_session_start` maps to `session.start` +- `pre_llm_call` maps to `run.start` +- `post_llm_call` maps to `run.end` +- `pre_tool_call` maps to `span.start` +- `post_tool_call` maps to `span.end` +- `post_api_request` maps usage payloads to `metric.snapshot` +- `on_session_finalize` maps to `session.end` + +Sample Hermes hook configuration lives in [hooks/hermes/hooks.yaml](/home/will/lab/agentmon/hooks/hermes/hooks.yaml). Install the handler from that directory so the `agentmon-hermes-handler` binary is available, then merge the sample `hooks:` block into `~/.hermes/config.yaml` and set `AGENTMON_INGEST_URL` to your ingest gateway. + ## Go SDK Emit events from Go applications: diff --git a/cmd/query-api/main.go b/cmd/query-api/main.go index 13d3fd7..004a1b3 100644 --- a/cmd/query-api/main.go +++ b/cmd/query-api/main.go @@ -207,6 +207,15 @@ func main() { if nextCursor != nil { resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano) } + + // Include total count on the first page (no cursor) so the UI can show "X of Y" + if f.Cursor == nil { + total, err := db.CountSessions(r.Context(), f) + if err == nil { + resp["total"] = total + } + } + httpx.WriteJSON(w, http.StatusOK, resp) }) diff --git a/internal/store/postgres/runs.go b/internal/store/postgres/runs.go index 5b49832..e005739 100644 --- a/internal/store/postgres/runs.go +++ b/internal/store/postgres/runs.go @@ -33,7 +33,7 @@ func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*Session session_id, MIN(ts) as started_at, MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at, - MAX(source_framework) as framework, + COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework, MAX(payload->'event'->'source'->>'host') as host FROM events WHERE session_id = $1 diff --git a/internal/store/postgres/sessions.go b/internal/store/postgres/sessions.go index 37b02da..d017006 100644 --- a/internal/store/postgres/sessions.go +++ b/internal/store/postgres/sessions.go @@ -89,7 +89,7 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow, session_id, MIN(ts) as started_at, MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at, - MAX(source_framework) as framework, + COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework, MAX(client_id) as client_id, MAX(payload->'event'->'source'->>'host') as host, COUNT(DISTINCT run_id) as run_count @@ -136,3 +136,59 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow, return out, nextCursor, rows.Err() } + +// CountSessions returns the total number of sessions matching the filter (without limit or cursor). +func (d *DB) CountSessions(ctx context.Context, f SessionsFilter) (int, error) { + var innerConditions []string + var outerConditions []string + var args []any + argN := 1 + + if f.From == nil { + t := time.Now().Add(-24 * time.Hour) + f.From = &t + } + innerConditions = append(innerConditions, fmt.Sprintf("ts >= $%d", argN)) + args = append(args, *f.From) + argN++ + + if f.To != nil { + innerConditions = append(innerConditions, fmt.Sprintf("ts <= $%d", argN)) + args = append(args, *f.To) + argN++ + } + + if f.Framework != "" { + innerConditions = append(innerConditions, fmt.Sprintf("source_framework = $%d", argN)) + args = append(args, f.Framework) + argN++ + } + + if f.Host != "" { + outerConditions = append(outerConditions, fmt.Sprintf("host = $%d", argN)) + args = append(args, f.Host) + argN++ + } + + innerWhere := strings.Join(innerConditions, " AND ") + outerWhere := "" + if len(outerConditions) > 0 { + outerWhere = "WHERE " + strings.Join(outerConditions, " AND ") + } + + query := fmt.Sprintf(` + WITH session_groups AS ( + SELECT + session_id, + MAX(payload->'event'->'source'->>'host') as host + FROM events + WHERE session_id IS NOT NULL AND %s + GROUP BY session_id + ) + SELECT COUNT(*) FROM session_groups %s + `, innerWhere, outerWhere) + + var count int + err := d.sql.QueryRowContext(ctx, query, args...).Scan(&count) + return count, err +} diff --git a/internal/store/postgres/stats.go b/internal/store/postgres/stats.go index a8de0d9..3474fc6 100644 --- a/internal/store/postgres/stats.go +++ b/internal/store/postgres/stats.go @@ -51,7 +51,7 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) { WITH session_groups AS ( SELECT session_id, - COALESCE(MAX(source_framework), 'unknown') AS framework, + COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') AS framework, MAX(ts) AS last_event_at, BOOL_OR(type = 'session.start') AS has_start, BOOL_OR(type = 'session.end') AS has_end