perf: lazy-load log content, populate user_id, keyset-paginate admin logs

Admin logs list no longer joins prompt/completion content for every row —
content is fetched per-row only when a log is expanded in the dashboard,
since a table of chat-length blobs was slow to serialize even at low row
counts.

request_logs.user_id (previously unpopulated despite an index existing on
it) is now set from the owning key on every insert, including synthetic
dashboard keys, and log queries filter on it directly instead of a
virtual_keys subquery. Pagination switches from OFFSET to a (created_at, id)
keyset cursor so paging deep into the log stays cheap. Adds indexes on
provider_name/key_prefix used by the admin filters.
This commit is contained in:
2026-07-01 16:16:49 +02:00
parent 904c7fcea6
commit 58d9c0a9c0
9 changed files with 166 additions and 19 deletions

View File

@@ -56,15 +56,19 @@ export const api = {
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
},
logs: {
list: (params: { provider?: string; key?: string; status?: string; since?: string } = {}) => {
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string } = {}) => {
const qs = new URLSearchParams()
if (params.provider) qs.set('provider', params.provider)
if (params.key) qs.set('key', params.key)
if (params.status) qs.set('status', params.status)
if (params.since) qs.set('since', params.since)
if (params.before) qs.set('before', params.before)
if (params.beforeId) qs.set('before_id', params.beforeId)
const s = qs.toString()
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
},
content: (id: string) =>
req<{ Messages: RequestLog['Messages']; Completion: RequestLog['Completion'] }>(`/logs/${id}/content`),
},
stats: {
keys: (since?: string) => {

View File

@@ -15,7 +15,10 @@ function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function ContentDrawer({ log }: { log: RequestLog }) {
function ContentDrawer({ log, loading }: { log: RequestLog; loading: boolean }) {
if (loading) {
return <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>Loading…</div>
}
if (!log.Messages && !log.Completion) {
return <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>No content logged</div>
}
@@ -53,21 +56,46 @@ function ContentDrawer({ log }: { log: RequestLog }) {
)
}
const PAGE_SIZE = 100
export default function Logs() {
const [searchParams, setSearchParams] = useSearchParams()
const [logs, setLogs] = useState<RequestLog[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [rangeIdx, setRangeIdx] = useState(1)
const [search, setSearch] = useState(() => searchParams.get('key') ?? '')
const [expanded, setExpanded] = useState<string | null>(null)
const [content, setContent] = useState<Record<string, Pick<RequestLog, 'Messages' | 'Completion'>>>({})
const [contentLoading, setContentLoading] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
.then(r => setLogs(r ?? []))
.then(r => {
setLogs(r ?? [])
setHasMore((r ?? []).length === PAGE_SIZE)
})
.finally(() => setLoading(false))
}, [rangeIdx])
function loadMore() {
const last = logs[logs.length - 1]
if (!last) return
setLoadingMore(true)
api.logs.list({
since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(),
before: last.CreatedAt,
beforeId: last.ID,
})
.then(r => {
setLogs(prev => [...prev, ...(r ?? [])])
setHasMore((r ?? []).length === PAGE_SIZE)
})
.finally(() => setLoadingMore(false))
}
// Sync search → URL param so links from ApiKeys page work.
useEffect(() => {
if (search) setSearchParams({ key: search }, { replace: true })
@@ -82,6 +110,20 @@ export default function Logs() {
l.RequestID.includes(search)
)
function toggleExpand(id: string) {
if (expanded === id) {
setExpanded(null)
return
}
setExpanded(id)
if (!content[id]) {
setContentLoading(id)
api.logs.content(id)
.then(c => setContent(prev => ({ ...prev, [id]: c })))
.finally(() => setContentLoading(null))
}
}
const cols = '80px 1fr 110px 80px 90px 72px 58px 22px'
return (
@@ -122,7 +164,7 @@ export default function Logs() {
return (
<div key={l.ID} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div
onClick={() => setExpanded(isOpen ? null : l.ID)}
onClick={() => toggleExpand(l.ID)}
style={{ display: 'grid', gridTemplateColumns: cols, padding: '9px 16px', alignItems: 'center', cursor: 'pointer' }}
>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{fmtTime(l.CreatedAt)}</span>
@@ -142,10 +184,24 @@ export default function Logs() {
<polyline points="2,4 6,8 10,4"/>
</svg>
</div>
{isOpen && <ContentDrawer log={l} />}
{isOpen && (
<ContentDrawer
log={content[l.ID] ? { ...l, ...content[l.ID] } : l}
loading={contentLoading === l.ID}
/>
)}
</div>
)
})}
{!loading && hasMore && (
<div style={{ padding: '12px 16px', textAlign: 'center' as const }}>
<button onClick={loadMore} disabled={loadingMore} style={{
padding: '6px 16px', borderRadius: 6, fontSize: 12, fontWeight: 500, cursor: loadingMore ? 'default' : 'pointer',
border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: `${D}0.5)`,
}}>{loadingMore ? 'Loading…' : 'Load more'}</button>
</div>
)}
</div>
</div>
)

View File

@@ -304,6 +304,15 @@ func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
p.Since = t
}
}
// Cursor for "load more": the CreatedAt/ID of the last row already shown.
if before := q.Get("before"); before != "" {
if t, err := time.Parse(time.RFC3339Nano, before); err == nil {
if id, err := uuid.Parse(q.Get("before_id")); err == nil {
p.Before = t
p.BeforeID = id
}
}
}
rows, err := a.Store.ListRequestLogs(r.Context(), p)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -315,6 +324,25 @@ func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
func (a *API) adminGetLogContent(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid log id")
return
}
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
}
messages, completion, err := a.Store.GetLogContent(r.Context(), id, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"Messages": messages, "Completion": completion})
}
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
since := time.Now().Add(-24 * time.Hour)
if s := r.URL.Query().Get("since"); s != "" {

View File

@@ -99,6 +99,7 @@ func (a *API) Mount(r chi.Router) {
r.Post("/keys", a.adminCreateKey)
r.Delete("/keys/{id}", a.adminRevokeKey)
r.Get("/logs", a.adminListLogs)
r.Get("/logs/{id}/content", a.adminGetLogContent)
r.Get("/stats/keys", a.adminKeyStats)
// The following routes require admin group membership.

View File

@@ -178,6 +178,7 @@ func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.
}
if key != nil {
rec.KeyPrefix = key.KeyPrefix
rec.UserID = key.UserID
// Only set VirtualKeyID for real keys — synthetic dashboard keys have a nil UUID.
if key.ID != uuid.Nil {
rec.VirtualKeyID = &key.ID

View File

@@ -78,6 +78,9 @@ func (a *API) requireAuth(next http.Handler) http.Handler {
func (a *API) dashboardKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
synthetic := &store.VirtualKey{AllowAllModels: true, KeyPrefix: "dashboard"}
if au := userFrom(r.Context()); au != nil {
synthetic.UserID = &au.User.ID
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyCtxKey, synthetic)))
})
}

View File

@@ -3,15 +3,18 @@ package store
import (
"context"
"encoding/json"
"errors"
"net/netip"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// RequestLog is one row to append to request_logs (billing source of truth).
type RequestLog struct {
VirtualKeyID *uuid.UUID
UserID *uuid.UUID // owner of the key used (set even for synthetic dashboard keys)
ProviderModelID *uuid.UUID
ProviderID *uuid.UUID
KeyPrefix string
@@ -36,14 +39,14 @@ func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) (uuid.UUID,
var id uuid.UUID
err := s.pool.QueryRow(ctx, `
INSERT INTO request_logs (
virtual_key_id, provider_model_id, provider_id,
virtual_key_id, user_id, provider_model_id, provider_id,
key_prefix, model_name, provider_name,
status_code, streamed, finish_reason, error,
prompt_tokens, completion_tokens, cached_tokens, total_tokens,
cost, latency_ms, ttft_ms, request_id, client_ip
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
RETURNING id`,
e.VirtualKeyID, e.ProviderModelID, e.ProviderID,
e.VirtualKeyID, e.UserID, e.ProviderModelID, e.ProviderID,
nullString(e.KeyPrefix), nullString(e.ModelName), nullString(e.ProviderName),
e.StatusCode, e.Streamed, nullString(e.FinishReason), nullString(e.Error),
e.PromptTokens, e.CompletionTokens, e.CachedTokens, e.TotalTokens,
@@ -97,10 +100,18 @@ type ListLogsParams struct {
StatusLt int // 0 = no upper bound
Since time.Time
Limit int
Offset int
// Before/BeforeID page backwards from a cursor: only rows strictly older
// than (Before, BeforeID) in the (created_at DESC, id DESC) ordering are
// returned. Pass the last row of the previous page. Zero value = first page.
// Keyset pagination instead of OFFSET so paging deep into the log stays
// O(limit) rather than re-scanning and discarding every skipped row.
Before time.Time
BeforeID uuid.UUID
}
// ListRequestLogs returns recent request logs for the admin view, with content joined in.
// ListRequestLogs returns recent request logs for the admin view. Content
// (prompt/completion) is fetched separately per row on demand — joining it
// here would pull every logged conversation's full text on every page load.
func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]RequestLogListRow, error) {
if p.Limit <= 0 {
p.Limit = 50
@@ -109,24 +120,29 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
if !p.Since.IsZero() {
since = &p.Since
}
var before *time.Time
var beforeID *uuid.UUID
if !p.Before.IsZero() {
before = &p.Before
beforeID = &p.BeforeID
}
rows, err := s.pool.Query(ctx, `
SELECT rl.id::text, rl.created_at, COALESCE(rl.request_id,''),
COALESCE(rl.key_prefix,''), COALESCE(rl.model_name,''), COALESCE(rl.provider_name,''),
rl.status_code, rl.streamed, COALESCE(rl.error,''),
rl.prompt_tokens, rl.completion_tokens, rl.total_tokens,
rl.cost, rl.latency_ms, rl.ttft_ms,
rlc.messages, rlc.completion
rl.cost, rl.latency_ms, rl.ttft_ms
FROM request_logs rl
LEFT JOIN request_log_content rlc ON rlc.request_log_id = rl.id
WHERE ($1 = '' OR rl.provider_name = $1)
AND ($2 = '' OR rl.key_prefix = $2)
AND ($3::uuid IS NULL OR rl.virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $3))
AND ($3::uuid IS NULL OR rl.user_id = $3)
AND ($4 = 0 OR rl.status_code >= $4)
AND ($5 = 0 OR rl.status_code < $5)
AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
ORDER BY rl.created_at DESC
LIMIT $7 OFFSET $8`,
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
AND ($7::timestamptz IS NULL OR (rl.created_at, rl.id) < ($7, $8))
ORDER BY rl.created_at DESC, rl.id DESC
LIMIT $9`,
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, before, beforeID, p.Limit,
)
if err != nil {
return nil, err
@@ -141,7 +157,6 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
&r.StatusCode, &r.Streamed, &r.Error,
&r.PromptTokens, &r.CompletionTokens, &r.TotalTokens,
&r.Cost, &r.LatencyMS, &r.TTFTMs,
&r.Messages, &r.Completion,
); err != nil {
return nil, err
}
@@ -150,6 +165,30 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
return out, rows.Err()
}
// GetLogContent returns the prompt messages and completion text for a single
// request log row, or (nil, nil, nil) if none was recorded or the log is not
// visible to userID (non-nil userID restricts to that user's own logs, same
// scoping as ListRequestLogs).
func (s *Store) GetLogContent(ctx context.Context, logID uuid.UUID, userID *uuid.UUID) (*json.RawMessage, *string, error) {
var messages *json.RawMessage
var completion *string
err := s.pool.QueryRow(ctx, `
SELECT rlc.messages, rlc.completion
FROM request_log_content rlc
JOIN request_logs rl ON rl.id = rlc.request_log_id
WHERE rlc.request_log_id = $1
AND ($2::uuid IS NULL OR rl.user_id = $2)`,
logID, userID,
).Scan(&messages, &completion)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil
}
if err != nil {
return nil, nil, err
}
return messages, completion, nil
}
// KeyStat is per-virtual-key aggregate stats for a time window.
type KeyStat struct {
KeyPrefix string `json:"key_prefix"`
@@ -174,7 +213,7 @@ func (s *Store) KeyStats(ctx context.Context, since time.Time, userID *uuid.UUID
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int
FROM request_logs
WHERE created_at >= $1
AND ($2::uuid IS NULL OR virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $2))
AND ($2::uuid IS NULL OR user_id = $2)
GROUP BY key_prefix
ORDER BY SUM(total_tokens) DESC`,
since, userID,

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_request_logs_key_prefix;
DROP INDEX IF EXISTS idx_request_logs_provider_name;

View File

@@ -0,0 +1,13 @@
-- Backfill user_id on existing rows so the direct column (already indexed
-- via idx_request_logs_user) matches historical logs written before the app
-- started populating it from the owning key.
UPDATE request_logs rl
SET user_id = vk.user_id
FROM virtual_keys vk
WHERE rl.virtual_key_id = vk.id
AND rl.user_id IS NULL;
-- Support the admin logs list's provider/key filters without a full scan
-- once the table grows past a handful of rows.
CREATE INDEX idx_request_logs_provider_name ON request_logs (provider_name, created_at DESC);
CREATE INDEX idx_request_logs_key_prefix ON request_logs (key_prefix, created_at DESC);