feat: content logging and per-key analytics

- Migration 000003: request_log_content table (messages jsonb, completion text)
- InsertRequestLog now returns the new row's ID so content can be linked
- Chat handler captures prompt messages and completion text (streaming accumulates deltas)
- ListRequestLogs left-joins content; logs API passes key filter through
- KeyStats query aggregates requests/tokens/cost/errors per key prefix
- GET /api/admin/stats/keys endpoint
- Logs page: expandable rows show prompt + completion inline
- Analytics page: per-key usage table below the chart
This commit is contained in:
2026-06-29 00:01:25 +02:00
parent 43963b255e
commit d7bd1cced0
11 changed files with 344 additions and 90 deletions

View File

@@ -1,5 +1,5 @@
import type {
Provider, VirtualKey, AdminModel, RequestLog,
Provider, VirtualKey, AdminModel, RequestLog, KeyStat,
CreateProviderParams, CreateKeyParams, ModelOverride,
} from './types'
@@ -55,13 +55,20 @@ export const api = {
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
},
logs: {
list: (params: { provider?: string; status?: string; since?: string } = {}) => {
list: (params: { provider?: string; key?: string; status?: string; since?: 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)
const s = qs.toString()
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
},
},
stats: {
keys: (since?: string) => {
const qs = since ? '?since=' + encodeURIComponent(since) : ''
return req<KeyStat[]>('/stats/keys' + qs)
},
},
}

View File

@@ -50,6 +50,19 @@ export interface RequestLog {
Cost: number
LatencyMS: number
TTFTMs: number | null
// Content (null when not yet logged or not available)
Messages: Array<{ role: string; content: string }> | null
Completion: string | null
}
export interface KeyStat {
key_prefix: string
requests: number
prompt_tokens: number
completion_tokens: number
total_tokens: number
cost: number
errors: number
}
export interface CatalogEntry {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { api } from '../lib/api'
import type { RequestLog } from '../lib/types'
import type { RequestLog, KeyStat } from '../lib/types'
const D = 'rgba(237,237,240,'
@@ -34,13 +34,18 @@ function SparkLine({ data, color = '#7c3aed' }: { data: number[]; color?: string
export default function Analytics() {
const [logs, setLogs] = useState<RequestLog[]>([])
const [keyStats, setKeyStats] = useState<KeyStat[]>([])
const [loading, setLoading] = useState(true)
const [rangeIdx, setRangeIdx] = useState(0)
useEffect(() => {
setLoading(true)
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
.then(r => setLogs(r ?? []))
const since = new Date(Date.now() - RANGES[rangeIdx].ms).toISOString()
Promise.all([
api.logs.list({ since }),
api.stats.keys(since),
])
.then(([l, k]) => { setLogs(l ?? []); setKeyStats(k ?? []) })
.finally(() => setLoading(false))
}, [rangeIdx])
@@ -131,6 +136,33 @@ export default function Analytics() {
))}
</div>
</div>
{/* Per-key stats */}
<div style={{ marginTop: 14, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Usage by API Key</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
{['Key','Requests','Tokens','Cost','Errors','Err %'].map(h => (
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
))}
</div>
{keyStats.length === 0 && !loading && <div style={{ padding: '18px 20px', color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
{keyStats.map(k => (
<div key={k.key_prefix} style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd' }}>{k.key_prefix}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>{k.requests.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>
{k.total_tokens > 1e6 ? `${(k.total_tokens/1e6).toFixed(1)}M` : k.total_tokens.toLocaleString()}
</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>${k.cost.toFixed(4)}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>{k.errors}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>
{k.requests ? `${Math.round(k.errors/k.requests*100)}%` : '—'}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -10,14 +10,46 @@ const RANGES = [
{ label: '7d', ms: 604800000 },
]
function StatusBadge({ code }: { code: number }) {
const ok = code < 400
return <span style={{ fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600, color: ok ? '#4ade80' : '#f87171', background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', padding: '2px 7px', borderRadius: 4 }}>{code}</span>
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function fmt(iso: string) {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
function ContentDrawer({ log }: { log: RequestLog }) {
if (!log.Messages && !log.Completion) {
return <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>No content logged</div>
}
return (
<div style={{ padding: '14px 18px', background: 'rgba(0,0,0,0.25)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{log.Messages && log.Messages.length > 0 && (
<div style={{ marginBottom: log.Completion ? 14 : 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Prompt</div>
<div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
{log.Messages.map((m, i) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1,
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : m.role === 'assistant' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.06)',
color: m.role === 'user' ? '#a78bfa' : m.role === 'assistant' ? '#4ade80' : `${D}0.45)`,
}}>{m.role}</span>
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}
</span>
</div>
))}
</div>
</div>
)}
{log.Completion && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Completion</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1, background: 'rgba(34,197,94,0.1)', color: '#4ade80' }}>assistant</span>
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{log.Completion}</span>
</div>
</div>
)}
</div>
)
}
export default function Logs() {
@@ -25,64 +57,87 @@ export default function Logs() {
const [loading, setLoading] = useState(true)
const [rangeIdx, setRangeIdx] = useState(1)
const [search, setSearch] = useState('')
const [expanded, setExpanded] = useState<string | null>(null)
function load(idx = rangeIdx) {
useEffect(() => {
setLoading(true)
api.logs.list({ since: new Date(Date.now() - RANGES[idx].ms).toISOString() })
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
.then(r => setLogs(r ?? []))
.finally(() => setLoading(false))
}
}, [rangeIdx])
useEffect(() => { load() }, [rangeIdx])
const visible = logs.filter(l =>
!search ||
l.ModelName.includes(search) ||
l.ProviderName.includes(search) ||
l.KeyPrefix.includes(search) ||
l.RequestID.includes(search)
)
const visible = search
? logs.filter(l => l.ModelName.includes(search) || l.ProviderName.includes(search) || l.RequestID.includes(search))
: logs
const cols = '86px 106px 1fr 100px 72px 72px 80px 60px'
const heads = ['Time','Request ID','Model','Provider','In tok.','Out tok.','Latency','Status']
const cols = '80px 1fr 110px 80px 90px 72px 58px 22px'
return (
<div className="animate-fade-up">
{/* Filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{RANGES.map((r, i) => (
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
padding: '6px 13px', borderRadius: 6, fontSize: 12.5, cursor: 'pointer', border: 'none',
background: i === rangeIdx ? '#7c3aed' : '#17171c',
color: i === rangeIdx ? 'white' : `${D}0.5)`,
}}>{r.label}</button>
))}
<div style={{ flex: 1, minWidth: 160, display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '6px 11px' }}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="rgba(237,237,240,0.28)" strokeWidth="1.9" strokeLinecap="round">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 2, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 6, padding: 3 }}>
{RANGES.map((r, i) => (
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
padding: '4px 13px', borderRadius: 4, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: 'none',
background: i === rangeIdx ? '#7c3aed' : 'transparent',
color: i === rangeIdx ? 'white' : `${D}0.42)`,
}}>{r.label}</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '7px 11px', flex: 1, maxWidth: 320 }}>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke={`${D}0.28)`} strokeWidth="2" strokeLinecap="round">
<circle cx="6.5" cy="6.5" r="4.5"/><line x1="10.5" y1="10.5" x2="14" y2="14"/>
</svg>
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Filter by model, request ID…"
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }}
/>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Filter by model, provider, key…"
style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }}/>
</div>
<span style={{ fontSize: 11.5, color: `${D}0.28)`, whiteSpace: 'nowrap' }}>{visible.length} results</span>
<span style={{ marginLeft: 'auto', fontSize: 12, color: `${D}0.3)` }}>{visible.length} entries</span>
</div>
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{heads.map(h => <span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>)}
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{['Time','Model','Provider','Key','Tokens','Latency','Status',''].map(h => (
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
))}
</div>
{loading && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>Loading…</div>}
{!loading && visible.length === 0 && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>No logs found</div>}
{visible.map(l => (
<div key={l.ID} style={{ display: 'grid', gridTemplateColumns: cols, padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.03)', alignItems: 'center' }}>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.32)` }}>{fmt(l.CreatedAt)}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.4)` }}>{l.RequestID.slice(0,10) || l.ID.slice(0,8)}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.PromptTokens.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.CompletionTokens.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
<StatusBadge code={l.StatusCode} />
</div>
))}
{loading && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>Loading…</div>}
{!loading && visible.length === 0 && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>No requests yet</div>}
{visible.map(l => {
const isOpen = expanded === l.ID
const ok = l.StatusCode < 400
return (
<div key={l.ID} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div
onClick={() => setExpanded(isOpen ? null : 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>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{l.KeyPrefix || '—'}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.TotalTokens.toLocaleString()}</span>
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
<span style={{
fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600,
color: ok ? '#4ade80' : '#f87171',
background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
padding: '2px 6px', borderRadius: 4, width: 'fit-content',
}}>{l.StatusCode}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke={`${D}0.3)`} strokeWidth="1.5" strokeLinecap="round"
style={{ transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s', justifySelf: 'center' }}>
<polyline points="2,4 6,8 10,4"/>
</svg>
</div>
{isOpen && <ContentDrawer log={l} />}
</div>
)
})}
</div>
</div>
)

View File

@@ -240,8 +240,9 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
p := store.ListLogsParams{
Provider: q.Get("provider"),
Limit: 100,
Provider: q.Get("provider"),
KeyPrefix: q.Get("key"),
Limit: 100,
}
if s := q.Get("status"); s == "error" {
p.StatusGte = 400
@@ -264,6 +265,24 @@ func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
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 != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
since = t
}
}
stats, err := a.Store.KeyStats(r.Context(), since)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if stats == nil {
stats = []store.KeyStat{}
}
writeJSON(w, http.StatusOK, stats)
}
// --- catalog ---
func (a *API) adminRefreshCatalog(w http.ResponseWriter, r *http.Request) {

View File

@@ -74,6 +74,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("/stats/keys", a.adminKeyStats)
r.Get("/models", a.adminListModels)
r.Patch("/models/{id}", a.adminUpdateModel)
r.Get("/settings", a.adminGetSettings)

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -9,6 +10,7 @@ import (
"maps"
"net"
"net/http"
"strings"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
@@ -21,9 +23,6 @@ import (
const maxRequestBody = 10 << 20 // 10 MiB
// chatCompletions is the OpenAI-compatible proxy: resolve the model address via
// the catalog, enforce the key allowlist, dispatch to the provider, relay the
// response (streaming or not), and log usage asynchronously.
func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody))
if err != nil {
@@ -41,7 +40,7 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
}
key := keyFrom(r.Context())
address := req.Model // the client-facing "<provider>/<slug>" (or "any/<slug>")
address := req.Model
entry, err := a.Cache.Resolve(address)
if err != nil {
@@ -72,13 +71,15 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
// Route to the upstream model and apply admin parameter overrides.
// Snapshot the messages before we overwrite req.Model below.
messages := marshalMessages(req.Payload["messages"])
req.Model = entry.Slug
applyOverrides(req.Payload, entry.ParamOverrides)
start := time.Now()
if req.Stream {
a.streamChat(w, r, cc, req, entry, key, address, start)
a.streamChat(w, r, cc, req, entry, key, address, messages, start)
return
}
@@ -92,10 +93,11 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil)
completion := extractCompletion(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, messages, completion)
}
func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, start time.Time) {
func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, messages json.RawMessage, start time.Time) {
stream, err := cc.ChatCompletionStream(r.Context(), req)
if err != nil {
var apiErr *providers.APIError
@@ -103,7 +105,7 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.StatusCode)
_, _ = w.Write(apiErr.Body)
a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, "upstream error", start, nil)
a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, "upstream error", start, nil, messages, "")
return
}
writeError(w, http.StatusBadGateway, err.Error())
@@ -118,18 +120,21 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
w.WriteHeader(http.StatusOK)
var ttft *int
var sb strings.Builder
for {
ev, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
break // mid-stream failure; status already sent
break
}
if ttft == nil {
ms := int(time.Since(start).Milliseconds())
ttft = &ms
}
// Accumulate assistant content from delta chunks.
sb.WriteString(extractDelta(ev.Data))
_, _ = fmt.Fprintf(w, "data: %s\n\n", ev.Data)
if flusher != nil {
flusher.Flush()
@@ -142,17 +147,17 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch
usage := stream.Usage()
a.Limiter.RecordTokens(entry.Address, usage.TotalTokens)
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft)
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft, messages, sb.String())
}
func applyOverrides(payload, overrides map[string]any) {
maps.Copy(payload, overrides)
}
func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int) {
func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int, messages json.RawMessage, completion string) {
entryID := entry.ID
providerID := entry.ProviderID
log := store.RequestLog{
rec := store.RequestLog{
ProviderModelID: &entryID,
ProviderID: &providerID,
ProviderName: entry.ProviderName,
@@ -171,16 +176,20 @@ func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.
ClientIP: clientIP(r),
}
if key != nil {
log.VirtualKeyID = &key.ID
log.KeyPrefix = key.KeyPrefix
rec.VirtualKeyID = &key.ID
rec.KeyPrefix = key.KeyPrefix
}
// Async, detached from the request lifecycle so billing never blocks it.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.Store.InsertRequestLog(ctx, log); err != nil {
id, err := a.Store.InsertRequestLog(ctx, rec)
if err != nil {
slog.Warn("failed to write request log", "err", err)
return
}
if err := a.Store.InsertLogContent(ctx, id, messages, completion); err != nil {
slog.Warn("failed to write request log content", "err", err)
}
if key != nil {
_ = a.Store.TouchVirtualKey(ctx, key.ID)
@@ -188,6 +197,48 @@ func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.
}()
}
// marshalMessages extracts the messages array from the request payload as raw JSON.
func marshalMessages(v any) json.RawMessage {
if v == nil {
return json.RawMessage("[]")
}
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage("[]")
}
return b
}
// extractCompletion pulls the first choice's message content from a non-streaming response body.
func extractCompletion(body []byte) string {
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &resp); err != nil || len(resp.Choices) == 0 {
return ""
}
return resp.Choices[0].Message.Content
}
// extractDelta pulls the assistant content delta from a streaming SSE data chunk.
func extractDelta(data []byte) string {
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 {
return ""
}
return chunk.Choices[0].Delta.Content
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {

View File

@@ -73,7 +73,7 @@ func (a *API) embeddings(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, nil, "")
}
// ptrInt dereferences an *int, returning 0 (no limit) when nil.

View File

@@ -2,6 +2,7 @@ package store
import (
"context"
"encoding/json"
"net/netip"
"time"
@@ -31,20 +32,36 @@ type RequestLog struct {
ClientIP string
}
func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) error {
_, err := s.pool.Exec(ctx, `
func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) (uuid.UUID, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx, `
INSERT INTO request_logs (
virtual_key_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)
RETURNING id`,
e.VirtualKeyID, 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,
e.Cost, e.LatencyMS, e.TTFTMs, nullString(e.RequestID), nullInet(e.ClientIP),
).Scan(&id)
return id, err
}
// InsertLogContent saves the prompt messages and completion text for a log row.
func (s *Store) InsertLogContent(ctx context.Context, logID uuid.UUID, messages json.RawMessage, completion string) error {
if len(messages) == 0 {
messages = json.RawMessage("[]")
}
_, err := s.pool.Exec(ctx, `
INSERT INTO request_log_content (request_log_id, messages, completion)
VALUES ($1, $2, $3)
ON CONFLICT (request_log_id) DO UPDATE SET messages = EXCLUDED.messages, completion = EXCLUDED.completion`,
logID, messages, completion,
)
return err
}
@@ -66,11 +83,15 @@ type RequestLogListRow struct {
Cost float64
LatencyMS int
TTFTMs *int
// Content fields — null when content logging is disabled or unavailable.
Messages *json.RawMessage `json:"Messages,omitempty"`
Completion *string `json:"Completion,omitempty"`
}
// ListLogsParams filters the request_logs query.
type ListLogsParams struct {
Provider string // empty = all
KeyPrefix string // empty = all
StatusGte int // 0 = all; e.g. 500 filters ≥500
StatusLt int // 0 = no upper bound
Since time.Time
@@ -78,7 +99,7 @@ type ListLogsParams struct {
Offset int
}
// ListRequestLogs returns recent request logs for the admin view.
// ListRequestLogs returns recent request logs for the admin view, with content joined in.
func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]RequestLogListRow, error) {
if p.Limit <= 0 {
p.Limit = 50
@@ -88,19 +109,22 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
since = &p.Since
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, created_at, COALESCE(request_id,''),
COALESCE(key_prefix,''), COALESCE(model_name,''), COALESCE(provider_name,''),
status_code, streamed, COALESCE(error,''),
prompt_tokens, completion_tokens, total_tokens,
cost, latency_ms, ttft_ms
FROM request_logs
WHERE ($1 = '' OR provider_name = $1)
AND ($2 = 0 OR status_code >= $2)
AND ($3 = 0 OR status_code < $3)
AND ($4::timestamptz IS NULL OR created_at >= $4)
ORDER BY created_at DESC
LIMIT $5 OFFSET $6`,
p.Provider, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
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
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 = 0 OR rl.status_code >= $3)
AND ($4 = 0 OR rl.status_code < $4)
AND ($5::timestamptz IS NULL OR rl.created_at >= $5)
ORDER BY rl.created_at DESC
LIMIT $6 OFFSET $7`,
p.Provider, p.KeyPrefix, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
)
if err != nil {
return nil, err
@@ -115,6 +139,7 @@ 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
}
@@ -123,6 +148,48 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
return out, rows.Err()
}
// KeyStat is per-virtual-key aggregate stats for a time window.
type KeyStat struct {
KeyPrefix string `json:"key_prefix"`
Requests int `json:"requests"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Cost float64 `json:"cost"`
Errors int `json:"errors"`
}
// KeyStats returns per-key aggregate stats since the given time.
func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT COALESCE(key_prefix, '(no key)'),
COUNT(*)::int,
SUM(prompt_tokens)::int,
SUM(completion_tokens)::int,
SUM(total_tokens)::int,
SUM(cost),
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int
FROM request_logs
WHERE created_at >= $1
GROUP BY key_prefix
ORDER BY SUM(total_tokens) DESC`,
since,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []KeyStat
for rows.Next() {
var s KeyStat
if err := rows.Scan(&s.KeyPrefix, &s.Requests, &s.PromptTokens, &s.CompletionTokens, &s.TotalTokens, &s.Cost, &s.Errors); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func nullInet(s string) any {
if a, err := netip.ParseAddr(s); err == nil {
return a

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS request_log_content;

View File

@@ -0,0 +1,8 @@
-- Full prompt/completion content stored separately from the metadata log so
-- large payloads don't bloat the hot request_logs table.
CREATE TABLE request_log_content (
request_log_id uuid PRIMARY KEY REFERENCES request_logs (id) ON DELETE CASCADE,
messages jsonb NOT NULL DEFAULT '[]', -- the full messages array sent upstream
completion text NOT NULL DEFAULT '' -- the assistant turn text from the response
);
CREATE INDEX idx_rlc_log ON request_log_content (request_log_id);