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
57 changes: 49 additions & 8 deletions core/http/auth/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,37 @@ type TotalsEntry struct {
Requests int64 `json:"requests"`
}

// KeyTotal is the per-key roll-up returned by sources endpoints.
// KeyTotal is the per-key roll-up returned by sources endpoints. UserID and
// UserName are snapshotted from the UsageRecord so revoked-and-deleted keys
// still carry their owner attribution in admin views.
type KeyTotal struct {
APIKeyID string `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
LastUsed time.Time `json:"last_used"`
}

// UserSourceTotal is a per-(user, source) roll-up for sources that don't carry
// a named API key identity (web, legacy). It exists so admin views can show
// which user generated each block of Web UI / legacy traffic; the per-apikey
// breakdown for source=apikey already lives in KeyTotal.
type UserSourceTotal struct {
Source string `json:"source"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
}

// SourceTotals summarises a per-source breakdown.
type SourceTotals struct {
BySource map[string]TotalsEntry `json:"by_source"`
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
GrandTotal TotalsEntry `json:"grand_total"`
BySource map[string]TotalsEntry `json:"by_source"`
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
ByUserSource []UserSourceTotal `json:"by_user_source,omitempty"` // populated only when includeLegacy=true
GrandTotal TotalsEntry `json:"grand_total"`
}

const maxKeyTotals = 200
Expand Down Expand Up @@ -275,9 +292,10 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,

byKeyQ := db.Model(&UsageRecord{}).
Select("COALESCE(api_key_id, '') as api_key_id, api_key_name, "+
"user_id, user_name, "+
"SUM(total_tokens) as tokens, COUNT(*) as requests, MAX(created_at) as last_used").
Where("api_key_id IS NOT NULL AND api_key_id <> ''").
Group("api_key_id, api_key_name").
Group("api_key_id, api_key_name, user_id, user_name").
Order("tokens DESC").
Limit(maxKeyTotals)
byKeyQ = applyFilters(byKeyQ, userID, apiKeyID, since, includeLegacy)
Expand All @@ -294,15 +312,17 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
out := make([]KeyTotal, 0)
for rows.Next() {
var (
apiKeyID, apiKeyName, lastUsedRaw string
tokens, requests int64
apiKeyID, apiKeyName, userIDCol, userName, lastUsedRaw string
tokens, requests int64
)
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &userIDCol, &userName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
continue
}
out = append(out, KeyTotal{
APIKeyID: apiKeyID,
APIKeyName: apiKeyName,
UserID: userIDCol,
UserName: userName,
Tokens: tokens,
Requests: requests,
LastUsed: parseLastUsedString(lastUsedRaw),
Expand All @@ -314,6 +334,27 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
totals.ByKey = out
}

// by_user_source: only populated for admin callers (includeLegacy=true) so
// they can attribute Web UI / legacy traffic to specific users. Per-apikey
// rows already carry user info via KeyTotal above, so this query only
// covers source != apikey.
if includeLegacy {
byUserSourceQ := db.Model(&UsageRecord{}).
Select("source, user_id, user_name, "+
"SUM(total_tokens) as tokens, COUNT(*) as requests").
Where("source <> ?", UsageSourceAPIKey).
Group("source, user_id, user_name").
Order("tokens DESC")
byUserSourceQ = applyFilters(byUserSourceQ, userID, apiKeyID, since, includeLegacy)

var byUserSourceRows []UserSourceTotal
if scanErr := byUserSourceQ.Scan(&byUserSourceRows).Error; scanErr != nil {
xlog.Warn("computeSourceTotals: by-user-source Scan failed", "error", scanErr)
} else {
totals.ByUserSource = byUserSourceRows
}
}

return totals
}

Expand Down
81 changes: 81 additions & 0 deletions core/http/auth/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,5 +349,86 @@ var _ = Describe("Usage", func() {
Expect(totals.ByKey).To(HaveLen(200))
Expect(totals.ByKey[0].Tokens > totals.ByKey[199].Tokens).To(BeTrue())
})

// insertNamed records a row with explicit user_id, user_name, source,
// and optional api key snapshot. Used by the user-attribution tests
// below which the older insert helper can't express.
insertNamed := func(db *gorm.DB, userID, userName, source, keyID, keyName string, tokens int64) {
rec := &auth.UsageRecord{
UserID: userID,
UserName: userName,
Source: source,
Model: "gpt-4",
TotalTokens: tokens,
CreatedAt: time.Now(),
}
if keyID != "" {
rec.APIKeyID = &keyID
rec.APIKeyName = keyName
}
Expect(auth.RecordUsage(db, rec)).To(Succeed())
}

It("attributes each KeyTotal to its owner user", func() {
db := testDB()
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 100)
insertNamed(db, "bob", "Bob", auth.UsageSourceAPIKey, "k2", "lap", 50)

_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
Expect(err).ToNot(HaveOccurred())
Expect(totals.ByKey).To(HaveLen(2))

byID := map[string]auth.KeyTotal{}
for _, k := range totals.ByKey {
byID[k.APIKeyID] = k
}
Expect(byID["k1"].UserID).To(Equal("alice"))
Expect(byID["k1"].UserName).To(Equal("Alice"))
Expect(byID["k2"].UserID).To(Equal("bob"))
Expect(byID["k2"].UserName).To(Equal("Bob"))
})

It("breaks Web UI and legacy traffic out per user in by_user_source for admin", func() {
db := testDB()
// Alice and Bob both have Web UI traffic; a synthetic legacy user
// also contributes. ByUserSource should expose one row per
// (source, user) pair, never for source=apikey.
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)
insertNamed(db, "bob", "Bob", auth.UsageSourceWeb, "", "", 70)
insertNamed(db, "legacy-api-key", "API Key User", auth.UsageSourceLegacy, "", "", 10)
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 5)

_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
Expect(err).ToNot(HaveOccurred())
Expect(totals.ByUserSource).ToNot(BeEmpty())

for _, r := range totals.ByUserSource {
Expect(r.Source).ToNot(Equal(auth.UsageSourceAPIKey))
}

webByUser := map[string]int64{}
legacyByUser := map[string]int64{}
for _, r := range totals.ByUserSource {
switch r.Source {
case auth.UsageSourceWeb:
webByUser[r.UserID] = r.Tokens
case auth.UsageSourceLegacy:
legacyByUser[r.UserID] = r.Tokens
}
}
Expect(webByUser["alice"]).To(Equal(int64(30)))
Expect(webByUser["bob"]).To(Equal(int64(70)))
Expect(legacyByUser["legacy-api-key"]).To(Equal(int64(10)))
})

It("does NOT populate by_user_source in the non-admin path", func() {
db := testDB()
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)

_, totals, err := auth.GetUserUsageBySource(db, "alice", "month")
Expect(err).ToNot(HaveOccurred())
// Non-admin path uses includeLegacy=false, so by_user_source stays nil.
Expect(totals.ByUserSource).To(BeNil())
})
})
})
1 change: 1 addition & 0 deletions core/http/react-ui/public/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"sortRequests": "Requests",
"sortLastUsed": "Last used",
"sortName": "Name",
"sortUser": "User",
"webUI": "Web UI",
"legacy": "Legacy",
"revoked": "revoked",
Expand Down
1 change: 1 addition & 0 deletions core/http/react-ui/src/pages/Usage/SourcesTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export default function SourcesTab({ period, adminUserId }) {
sortKey={sortKey}
setSortKey={setSortKey}
existingKeyIds={existingKeyIds}
showUserColumn={isAdmin}
/>
</div>

Expand Down
64 changes: 53 additions & 11 deletions core/http/react-ui/src/pages/Usage/SourcesTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const SORT_FNS = {
requests: (a, b) => (b.requests || 0) - (a.requests || 0),
last_used: (a, b) => new Date(b.last_used || 0).getTime() - new Date(a.last_used || 0).getTime(),
name: (a, b) => (a.name || '').localeCompare(b.name || ''),
user: (a, b) => (a.userName || '').localeCompare(b.userName || ''),
}

function formatTokens(n) {
Expand Down Expand Up @@ -41,6 +42,8 @@ function formatRelative(iso) {
// when the parent hasn't yet learned which keys exist. Null suppresses the
// revoked badge entirely so live keys aren't dimmed during the fetch or
// after a failure.
// showUserColumn: render the User column. Admin views set this true so the
// reader can attribute each key (and each Web UI row) to its owner.
export default function SourcesTable({
totals,
selectedKey,
Expand All @@ -50,6 +53,7 @@ export default function SourcesTable({
sortKey,
setSortKey,
existingKeyIds = null,
showUserColumn = false,
}) {
const { t } = useTranslation('admin')

Expand All @@ -58,39 +62,70 @@ export default function SourcesTable({
kind: 'apikey',
id: k.api_key_id,
name: k.api_key_name || k.api_key_id,
userID: k.user_id || '',
userName: k.user_name || '',
prefix: '',
tokens: k.tokens,
requests: k.requests,
last_used: k.last_used,
revoked: existingKeyIds != null && !existingKeyIds.has(k.api_key_id),
}))
const web = totals?.by_source?.web
? [{

// Pseudo-rows for sources that don't have a named key identity.
// In admin view (showUserColumn=true), prefer the per-user breakdown
// from totals.by_user_source so each user's Web UI / legacy traffic
// gets its own row. Otherwise fall back to the global by_source aggregate.
let unkeyed = []
if (showUserColumn && Array.isArray(totals?.by_user_source) && totals.by_user_source.length > 0) {
unkeyed = totals.by_user_source.map((r) => ({
kind: r.source,
id: r.source + ':' + (r.user_id || ''),
name: r.source === 'legacy' ? t('usage.sources.legacy') : t('usage.sources.webUI'),
userID: r.user_id || '',
userName: r.user_name || '',
prefix: '-',
tokens: r.tokens,
requests: r.requests,
}))
} else {
if (totals?.by_source?.web) {
unkeyed.push({
kind: 'web',
id: 'web',
name: t('usage.sources.webUI'),
userID: '',
userName: '',
prefix: '-',
tokens: totals.by_source.web.tokens,
requests: totals.by_source.web.requests,
}]
: []
const leg = totals?.by_source?.legacy
? [{
})
}
if (totals?.by_source?.legacy) {
unkeyed.push({
kind: 'legacy',
id: 'legacy',
name: t('usage.sources.legacy'),
userID: '',
userName: '',
prefix: '-',
tokens: totals.by_source.legacy.tokens,
requests: totals.by_source.legacy.requests,
}]
: []
return [...named, ...web, ...leg]
}, [totals, existingKeyIds, t])
})
}
}

return [...named, ...unkeyed]
}, [totals, existingKeyIds, showUserColumn, t])

const filtered = useMemo(() => {
const q = (search || '').trim().toLowerCase()
const list = q
? rows.filter((r) => (r.name || '').toLowerCase().includes(q) || (r.prefix || '').toLowerCase().includes(q))
? rows.filter((r) =>
(r.name || '').toLowerCase().includes(q) ||
(r.prefix || '').toLowerCase().includes(q) ||
(r.userName || '').toLowerCase().includes(q) ||
(r.userID || '').toLowerCase().includes(q)
)
: rows
return [...list].sort(SORT_FNS[sortKey] || SORT_FNS.tokens)
}, [rows, search, sortKey])
Expand Down Expand Up @@ -134,6 +169,7 @@ export default function SourcesTable({
<option value="requests">{t('usage.sources.sortRequests')}</option>
<option value="last_used">{t('usage.sources.sortLastUsed')}</option>
<option value="name">{t('usage.sources.sortName')}</option>
{showUserColumn && <option value="user">{t('usage.sources.sortUser')}</option>}
</select>
</label>
</div>
Expand All @@ -143,6 +179,7 @@ export default function SourcesTable({
<thead>
<tr>
<th>{t('usage.sources.sortName')}</th>
{showUserColumn && <th style={{ width: 180 }}>{t('usage.sources.sortUser')}</th>}
<th style={{ width: 110 }}>Prefix</th>
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortRequests')}</th>
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortTokens')}</th>
Expand Down Expand Up @@ -182,6 +219,11 @@ export default function SourcesTable({
)}
</span>
</td>
{showUserColumn && (
<td style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem' }}>
{r.userName || r.userID || '-'}
</td>
)}
<td style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>{r.prefix || '-'}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{Number(r.requests || 0).toLocaleString()}
Expand Down
Loading