Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on
## What you get

- **Overview dashboard** — KPI cards for sessions, unique users, total cost, and token counts (30-day window), with per-user filter across all charts
- **Sessions** — live table of every Claude Code session with model, duration, cost, and status (OK / ERROR)
- **Sessions** — live table of every Claude Code session with user, model, duration, cost, and status (OK / ERROR); search by user and click any user to filter the table to their sessions
- **History** — time-series and daily-activity heatmaps for sessions and token spend over time
- **Costs** — cumulative spend chart + breakdown table by model
- **Tools** — call counts, average duration, and error rate per tool (`Bash`, `Read`, `Edit`, …)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface OverviewResponse {

export interface SessionItem {
session_id: string
user_id: string
first_seen: string
last_seen: string
model: string
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/pages/Sessions.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,26 @@
color: var(--color-accent);
}

.userLink {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--color-accent);
font: inherit;
text-align: left;
}

.userLink:hover {
text-decoration: underline;
}

.anonUser {
color: var(--color-text-2);
font-style: italic;
}

.pagination {
display: flex;
align-items: center;
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/pages/Sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@ export default function Sessions() {
})
}

function applyUserFilter(uid: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('user_id', uid)
return next
})
setPage(1)
}

const filtered = useMemo(() => {
if (!data) return []
const needle = search.toLowerCase()
return data.items.filter(
(s) =>
(search === '' || s.session_id.toLowerCase().includes(search.toLowerCase())) &&
(search === '' || (s.user_id ?? '').toLowerCase().includes(needle)) &&
(statusFilter === 'all' || s.status === statusFilter),
)
}, [data, search, statusFilter])
Expand All @@ -47,7 +57,7 @@ export default function Sessions() {
<div className={styles.filterBar}>
<input
className={styles.searchInput}
placeholder="Search by session ID…"
placeholder="Search by user…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
/>
Expand Down Expand Up @@ -88,6 +98,23 @@ export default function Sessions() {
</Link>
),
},
{
key: 'user_id',
label: 'User',
sortable: true,
render: (v) =>
v ? (
<button
className={styles.userLink}
onClick={(e) => { e.stopPropagation(); applyUserFilter(String(v)) }}
title={`Filter by ${v}`}
>
{String(v)}
</button>
) : (
<span className={styles.anonUser}>anonymous</span>
),
},
{
key: 'first_seen',
label: 'Started',
Expand Down
6 changes: 4 additions & 2 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) {

type sessionItem struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
Model string `json:"model"`
Expand Down Expand Up @@ -368,7 +369,8 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) {
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COUNT(*) FILTER (WHERE tool_name IS NOT NULL AND tool_name <> ''),
MAX(CASE WHEN status_code = 2 THEN 1 ELSE 0 END)
MAX(CASE WHEN status_code = 2 THEN 1 ELSE 0 END),
COALESCE(MAX(user_id), '')
FROM spans
WHERE session_id IS NOT NULL%s
GROUP BY session_id
Expand All @@ -394,7 +396,7 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) {
var firstSeen, lastSeen time.Time
var hasError int
if err := rows.Scan(&s.SessionID, &firstSeen, &lastSeen, &s.Model,
&s.CostUSD, &s.InputTokens, &s.OutputTokens, &s.ToolCalls, &hasError); err != nil {
&s.CostUSD, &s.InputTokens, &s.OutputTokens, &s.ToolCalls, &hasError, &s.UserID); err != nil {
continue
}
s.FirstSeen = firstSeen.UTC().Format(time.RFC3339)
Expand Down
22 changes: 22 additions & 0 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ func TestSessions(t *testing.T) {
}
},
},
{
name: "surfaces user_id",
seed: func(t *testing.T, db *storage.DB) {
now := time.Now()
insertSpan(t, db, storage.Span{
TraceID: "t1", SpanID: "s1", Name: "llm", SessionID: "sess1",
UserID: "alice", StartTime: now, EndTime: now.Add(time.Second),
})
},
path: "/api/v1/sessions",
wantStatus: http.StatusOK,
checkBody: func(t *testing.T, body map[string]any) {
items, ok := body["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("want 1 item, got %v", body["items"])
}
item := items[0].(map[string]any)
if item["user_id"] != "alice" {
t.Errorf("want user_id=alice, got %v", item["user_id"])
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down