feat: faster
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser,
|
||||
CreateProviderParams, CreateKeyParams, ModelOverride,
|
||||
CreateProviderParams, CreateKeyParams, ModelOverride, LogsSummary, ModelStat,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/admin'
|
||||
@@ -56,7 +56,7 @@ export const api = {
|
||||
req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }),
|
||||
},
|
||||
logs: {
|
||||
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string } = {}) => {
|
||||
list: (params: { provider?: string; key?: string; status?: string; since?: string; before?: string; beforeId?: string; limit?: number; q?: string } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.provider) qs.set('provider', params.provider)
|
||||
if (params.key) qs.set('key', params.key)
|
||||
@@ -64,6 +64,8 @@ export const api = {
|
||||
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)
|
||||
if (params.limit) qs.set('limit', String(params.limit))
|
||||
if (params.q) qs.set('q', params.q)
|
||||
const s = qs.toString()
|
||||
return req<RequestLog[]>('/logs' + (s ? '?' + s : ''))
|
||||
},
|
||||
@@ -75,6 +77,24 @@ export const api = {
|
||||
const qs = since ? '?since=' + encodeURIComponent(since) : ''
|
||||
return req<KeyStat[]>('/stats/keys' + qs)
|
||||
},
|
||||
summary: (since?: string) => {
|
||||
const qs = since ? '?since=' + encodeURIComponent(since) : ''
|
||||
return req<LogsSummary>('/stats/summary' + qs)
|
||||
},
|
||||
models: (since?: string, limit?: number) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (since) qs.set('since', since)
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
const s = qs.toString()
|
||||
return req<ModelStat[]>('/stats/models' + (s ? '?' + s : ''))
|
||||
},
|
||||
series: (since?: string, buckets?: number) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (since) qs.set('since', since)
|
||||
if (buckets) qs.set('buckets', String(buckets))
|
||||
const s = qs.toString()
|
||||
return req<number[]>('/stats/series' + (s ? '?' + s : ''))
|
||||
},
|
||||
},
|
||||
me: () => req<CurrentUser>('/me', undefined, BASE_API),
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@ export interface KeyStat {
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface LogsSummary {
|
||||
requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
avg_latency_ms: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface ModelStat {
|
||||
model_name: string
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string
|
||||
email: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { RequestLog, KeyStat } from '../lib/types'
|
||||
import type { KeyStat, LogsSummary, ModelStat } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
@@ -32,8 +32,12 @@ function SparkLine({ data, color = '#7c3aed' }: { data: number[]; color?: string
|
||||
)
|
||||
}
|
||||
|
||||
const BUCKETS = 24
|
||||
|
||||
export default function Analytics() {
|
||||
const [logs, setLogs] = useState<RequestLog[]>([])
|
||||
const [summary, setSummary] = useState<LogsSummary | null>(null)
|
||||
const [topModels, setTopModels] = useState<ModelStat[]>([])
|
||||
const [reqByBucket, setReqByBucket] = useState<number[]>(Array(BUCKETS).fill(0))
|
||||
const [keyStats, setKeyStats] = useState<KeyStat[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rangeIdx, setRangeIdx] = useState(0)
|
||||
@@ -42,40 +46,28 @@ export default function Analytics() {
|
||||
setLoading(true)
|
||||
const since = new Date(Date.now() - RANGES[rangeIdx].ms).toISOString()
|
||||
Promise.all([
|
||||
api.logs.list({ since }),
|
||||
api.stats.summary(since),
|
||||
api.stats.models(since, 6),
|
||||
api.stats.series(since, BUCKETS),
|
||||
api.stats.keys(since),
|
||||
])
|
||||
.then(([l, k]) => { setLogs(l ?? []); setKeyStats(k ?? []) })
|
||||
.then(([s, m, series, k]) => {
|
||||
setSummary(s)
|
||||
setTopModels(m ?? [])
|
||||
setReqByBucket(series ?? Array(BUCKETS).fill(0))
|
||||
setKeyStats(k ?? [])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [rangeIdx])
|
||||
|
||||
// Build hourly buckets
|
||||
const buckets = 24
|
||||
const bucketMs = RANGES[rangeIdx].ms / buckets
|
||||
const now = Date.now()
|
||||
const reqByBucket = Array(buckets).fill(0)
|
||||
logs.forEach(l => {
|
||||
const age = now - new Date(l.CreatedAt).getTime()
|
||||
const idx = Math.floor(age / bucketMs)
|
||||
if (idx >= 0 && idx < buckets) reqByBucket[buckets - 1 - idx]++
|
||||
})
|
||||
|
||||
const totalReqs = logs.length
|
||||
const totalTokens = logs.reduce((s, l) => s + l.TotalTokens, 0)
|
||||
const totalCost = logs.reduce((s, l) => s + l.Cost, 0)
|
||||
const avgLatency = logs.length ? Math.round(logs.reduce((s, l) => s + l.LatencyMS, 0) / logs.length) : 0
|
||||
|
||||
// Top models by token usage
|
||||
const byModel: Record<string, number> = {}
|
||||
logs.forEach(l => { byModel[l.ModelName] = (byModel[l.ModelName] ?? 0) + l.TotalTokens })
|
||||
const topModels = Object.entries(byModel).sort((a, b) => b[1] - a[1]).slice(0, 6)
|
||||
const maxTokens = topModels[0]?.[1] ?? 1
|
||||
const totalTokens = summary?.total_tokens ?? 0
|
||||
const maxTokens = topModels[0]?.total_tokens ?? 1
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total requests', value: totalReqs.toLocaleString() },
|
||||
{ label: 'Total requests', value: (summary?.requests ?? 0).toLocaleString() },
|
||||
{ label: 'Total tokens', value: totalTokens > 1e6 ? `${(totalTokens/1e6).toFixed(1)}M` : totalTokens.toLocaleString() },
|
||||
{ label: 'Total cost', value: `$${totalCost.toFixed(4)}` },
|
||||
{ label: 'Avg latency', value: `${avgLatency}ms` },
|
||||
{ label: 'Total cost', value: `$${(summary?.total_cost ?? 0).toFixed(4)}` },
|
||||
{ label: 'Avg latency', value: `${summary?.avg_latency_ms ?? 0}ms` },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -123,14 +115,14 @@ export default function Analytics() {
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 2 }}>Top Models</div>
|
||||
<div style={{ fontSize: 11.5, color: `${D}0.35)`, marginBottom: 18 }}>By token usage</div>
|
||||
{topModels.length === 0 && <div style={{ color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
|
||||
{topModels.map(([name, tokens]) => (
|
||||
<div key={name} style={{ marginBottom: 14 }}>
|
||||
{topModels.map(({ model_name, total_tokens }) => (
|
||||
<div key={model_name} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.62)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{name}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{tokens > 1e6 ? `${(tokens/1e6).toFixed(1)}M` : tokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.62)`, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{model_name}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: `${D}0.38)` }}>{total_tokens > 1e6 ? `${(total_tokens/1e6).toFixed(1)}M` : total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 3, background: 'rgba(255,255,255,0.06)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', background: '#7c3aed', borderRadius: 2, width: `${(tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
|
||||
<div style={{ height: '100%', background: '#7c3aed', borderRadius: 2, width: `${(total_tokens / maxTokens) * 100}%`, transition: 'width 0.3s' }}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -66,19 +66,26 @@ export default function Logs() {
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [rangeIdx, setRangeIdx] = useState(1)
|
||||
const [search, setSearch] = useState(() => searchParams.get('key') ?? '')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(search)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [content, setContent] = useState<Record<string, Pick<RequestLog, 'Messages' | 'Completion'>>>({})
|
||||
const [contentLoading, setContentLoading] = useState<string | null>(null)
|
||||
|
||||
// Debounce the search box so typing doesn't fire a request per keystroke.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() })
|
||||
api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(), q: debouncedSearch || undefined })
|
||||
.then(r => {
|
||||
setLogs(r ?? [])
|
||||
setHasMore((r ?? []).length === PAGE_SIZE)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [rangeIdx])
|
||||
}, [rangeIdx, debouncedSearch])
|
||||
|
||||
function loadMore() {
|
||||
const last = logs[logs.length - 1]
|
||||
@@ -88,6 +95,7 @@ export default function Logs() {
|
||||
since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString(),
|
||||
before: last.CreatedAt,
|
||||
beforeId: last.ID,
|
||||
q: debouncedSearch || undefined,
|
||||
})
|
||||
.then(r => {
|
||||
setLogs(prev => [...prev, ...(r ?? [])])
|
||||
@@ -102,13 +110,7 @@ export default function Logs() {
|
||||
else setSearchParams({}, { replace: true })
|
||||
}, [search])
|
||||
|
||||
const visible = logs.filter(l =>
|
||||
!search ||
|
||||
l.ModelName.includes(search) ||
|
||||
l.ProviderName.includes(search) ||
|
||||
l.KeyPrefix.includes(search) ||
|
||||
l.RequestID.includes(search)
|
||||
)
|
||||
const visible = logs
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expanded === id) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import type { Provider, RequestLog } from '../lib/types'
|
||||
import type { Provider, RequestLog, LogsSummary } from '../lib/types'
|
||||
|
||||
const D = 'rgba(237,237,240,'
|
||||
|
||||
@@ -27,18 +27,23 @@ function ago(iso: string) {
|
||||
export default function Overview() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [logs, setLogs] = useState<RequestLog[]>([])
|
||||
const [summary, setSummary] = useState<LogsSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.providers.list(), api.logs.list({ since: new Date(Date.now() - 86400000).toISOString() })])
|
||||
.then(([p, l]) => { setProviders(p ?? []); setLogs((l ?? []).slice(0, 8)) })
|
||||
const since = new Date(Date.now() - 86400000).toISOString()
|
||||
Promise.all([
|
||||
api.providers.list(),
|
||||
api.logs.list({ since, limit: 8 }),
|
||||
api.stats.summary(since),
|
||||
])
|
||||
.then(([p, l, s]) => { setProviders(p ?? []); setLogs(l ?? []); setSummary(s) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const todayLogs = logs
|
||||
const totalReqs = todayLogs.length
|
||||
const totalCost = todayLogs.reduce((s, l) => s + l.Cost, 0)
|
||||
const errRate = totalReqs ? Math.round(todayLogs.filter(l => l.StatusCode >= 400).length / totalReqs * 100) : 0
|
||||
const totalReqs = summary?.requests ?? 0
|
||||
const totalCost = summary?.total_cost ?? 0
|
||||
const errRate = totalReqs ? Math.round((summary?.errors ?? 0) / totalReqs * 100) : 0
|
||||
|
||||
const stats = [
|
||||
{ label: 'Providers', value: providers.length, delta: '' },
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -282,17 +283,18 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
// Admins see all logs; regular users see only their own keys' logs.
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil && !au.IsAdmin {
|
||||
userID = &au.User.ID
|
||||
limit := 100
|
||||
if l := q.Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
p := store.ListLogsParams{
|
||||
Provider: q.Get("provider"),
|
||||
KeyPrefix: q.Get("key"),
|
||||
UserID: userID,
|
||||
Limit: 100,
|
||||
UserID: scopedUserID(r),
|
||||
Query: q.Get("q"),
|
||||
Limit: limit,
|
||||
}
|
||||
if s := q.Get("status"); s == "error" {
|
||||
p.StatusGte = 400
|
||||
@@ -343,19 +345,29 @@ func (a *API) adminGetLogContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"Messages": messages, "Completion": completion})
|
||||
}
|
||||
|
||||
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
|
||||
// sinceParam parses the "since" query param (RFC3339), defaulting to 24h ago.
|
||||
func sinceParam(r *http.Request) time.Time {
|
||||
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
|
||||
}
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// scopedUserID returns the current user's ID for non-admins, or nil for
|
||||
// admins (who see stats across all users).
|
||||
func scopedUserID(r *http.Request) *uuid.UUID {
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil && !au.IsAdmin {
|
||||
userID = &au.User.ID
|
||||
return &au.User.ID
|
||||
}
|
||||
stats, err := a.Store.KeyStats(r.Context(), since, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := a.Store.KeyStats(r.Context(), sinceParam(r), scopedUserID(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -366,6 +378,48 @@ func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (a *API) adminStatsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := a.Store.GetLogsSummary(r.Context(), sinceParam(r), scopedUserID(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (a *API) adminStatsModels(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 6
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
models, err := a.Store.TopModels(r.Context(), sinceParam(r), scopedUserID(r), limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if models == nil {
|
||||
models = []store.ModelStat{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, models)
|
||||
}
|
||||
|
||||
func (a *API) adminStatsSeries(w http.ResponseWriter, r *http.Request) {
|
||||
buckets := 24
|
||||
if b := r.URL.Query().Get("buckets"); b != "" {
|
||||
if n, err := strconv.Atoi(b); err == nil && n > 0 {
|
||||
buckets = n
|
||||
}
|
||||
}
|
||||
series, err := a.Store.RequestVolumeSeries(r.Context(), sinceParam(r), scopedUserID(r), buckets)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, series)
|
||||
}
|
||||
|
||||
// --- catalog ---
|
||||
|
||||
func (a *API) adminRefreshCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -101,6 +101,9 @@ func (a *API) Mount(r chi.Router) {
|
||||
r.Get("/logs", a.adminListLogs)
|
||||
r.Get("/logs/{id}/content", a.adminGetLogContent)
|
||||
r.Get("/stats/keys", a.adminKeyStats)
|
||||
r.Get("/stats/summary", a.adminStatsSummary)
|
||||
r.Get("/stats/models", a.adminStatsModels)
|
||||
r.Get("/stats/series", a.adminStatsSeries)
|
||||
|
||||
// The following routes require admin group membership.
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -99,7 +99,10 @@ type ListLogsParams struct {
|
||||
StatusGte int // 0 = all; e.g. 500 filters ≥500
|
||||
StatusLt int // 0 = no upper bound
|
||||
Since time.Time
|
||||
Limit int
|
||||
// Query is a free-text search matched (case-insensitively, substring) against
|
||||
// model_name, provider_name, key_prefix, and request_id. Empty = no filter.
|
||||
Query string
|
||||
Limit 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.
|
||||
@@ -140,9 +143,13 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
|
||||
AND ($5 = 0 OR rl.status_code < $5)
|
||||
AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
|
||||
AND ($7::timestamptz IS NULL OR (rl.created_at, rl.id) < ($7, $8))
|
||||
AND ($9 = '' OR rl.model_name ILIKE '%' || $9 || '%'
|
||||
OR rl.provider_name ILIKE '%' || $9 || '%'
|
||||
OR rl.key_prefix ILIKE '%' || $9 || '%'
|
||||
OR rl.request_id ILIKE '%' || $9 || '%')
|
||||
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,
|
||||
LIMIT $10`,
|
||||
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, before, beforeID, p.Query, p.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,6 +240,106 @@ func (s *Store) KeyStats(ctx context.Context, since time.Time, userID *uuid.UUID
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LogsSummary is aggregate totals for a time window (dashboard stat cards).
|
||||
type LogsSummary struct {
|
||||
Requests int `json:"requests"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
AvgLatencyMS int `json:"avg_latency_ms"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// GetLogsSummary computes aggregate totals since the given time in a single
|
||||
// query, instead of shipping raw rows to the client to sum in JS.
|
||||
func (s *Store) GetLogsSummary(ctx context.Context, since time.Time, userID *uuid.UUID) (LogsSummary, error) {
|
||||
var out LogsSummary
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)::int,
|
||||
COALESCE(SUM(total_tokens),0)::bigint,
|
||||
COALESCE(SUM(cost),0),
|
||||
COALESCE(AVG(latency_ms),0)::int,
|
||||
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 user_id = $2)`,
|
||||
since, userID,
|
||||
).Scan(&out.Requests, &out.TotalTokens, &out.TotalCost, &out.AvgLatencyMS, &out.Errors)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ModelStat is token usage for one model address, for the "top models" chart.
|
||||
type ModelStat struct {
|
||||
ModelName string `json:"model_name"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// TopModels returns the top models by token usage since the given time.
|
||||
func (s *Store) TopModels(ctx context.Context, since time.Time, userID *uuid.UUID, limit int) ([]ModelStat, error) {
|
||||
if limit <= 0 {
|
||||
limit = 6
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT COALESCE(model_name,''), COALESCE(SUM(total_tokens),0)::bigint
|
||||
FROM request_logs
|
||||
WHERE created_at >= $1
|
||||
AND ($2::uuid IS NULL OR user_id = $2)
|
||||
GROUP BY model_name
|
||||
ORDER BY SUM(total_tokens) DESC
|
||||
LIMIT $3`,
|
||||
since, userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ModelStat
|
||||
for rows.Next() {
|
||||
var m ModelStat
|
||||
if err := rows.Scan(&m.ModelName, &m.TotalTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RequestVolumeSeries buckets request counts evenly between since and now
|
||||
// into `buckets` buckets (oldest first), for the dashboard sparkline.
|
||||
func (s *Store) RequestVolumeSeries(ctx context.Context, since time.Time, userID *uuid.UUID, buckets int) ([]int, error) {
|
||||
if buckets <= 0 {
|
||||
buckets = 24
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT width_bucket(extract(epoch from rl.created_at), extract(epoch from $1::timestamptz), extract(epoch from now()), $3) AS bucket,
|
||||
COUNT(*)::int
|
||||
FROM request_logs rl
|
||||
WHERE rl.created_at >= $1
|
||||
AND ($2::uuid IS NULL OR rl.user_id = $2)
|
||||
GROUP BY bucket`,
|
||||
since, userID, buckets,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int, buckets)
|
||||
for rows.Next() {
|
||||
var bucket, count int
|
||||
if err := rows.Scan(&bucket, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// width_bucket returns 1..buckets for in-range values; clamp the rare
|
||||
// edge case (e.g. a row created exactly at `now()`) into the last bucket.
|
||||
if bucket < 1 {
|
||||
bucket = 1
|
||||
} else if bucket > buckets {
|
||||
bucket = buckets
|
||||
}
|
||||
out[bucket-1] += count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullInet(s string) any {
|
||||
if a, err := netip.ParseAddr(s); err == nil {
|
||||
return a
|
||||
|
||||
Reference in New Issue
Block a user