feat(webui): surface user, client IP and user agent in API traces (#10886, #10887) (#10907)

The Operate → Traces "API Traces" panel already recorded who made each
request (user_id/user_name) but never showed it, and did not capture the
caller's network identity at all. Operators asked to see the requesting
user (#10886) and the client IP + user agent (#10887) so a trace can be
attributed to who/what issued it.

Backend: add ClientIP and UserAgent to APIExchange and populate them from
echo's c.RealIP() (honours X-Forwarded-For / X-Real-IP behind a trusted
proxy) and the request's User-Agent header. Both are omitempty and the
/api/traces swagger response is map[string]any, so this is additive.

UI: add a sortable "User" column to the API traces table and a metadata
block (User / Client IP / User Agent) at the top of the expanded row
detail. Fields render only when present, so older buffered traces and
unauthenticated/local requests degrade cleanly.

Adds an e2e spec covering the new column value and the expanded metadata.


Assisted-by: Claude:opus-4.8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
localai-org-maint-bot
2026-07-17 23:47:31 +02:00
committed by GitHub
parent bf484c5181
commit 4be6e22b5f
3 changed files with 75 additions and 1 deletions

View File

@@ -43,6 +43,12 @@ type APIExchange struct {
Error string `json:"error,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
// ClientIP is the caller's address as resolved by echo (honours
// X-Forwarded-For / X-Real-IP behind a trusted proxy), and UserAgent
// is the raw User-Agent header. Both are surfaced in the admin Traces
// UI so an operator can tell who/what issued each request.
ClientIP string `json:"client_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
var traceBuffer *circularbuffer.Queue[APIExchange]
@@ -221,6 +227,8 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
exchange := APIExchange{
Timestamp: startTime,
Duration: time.Since(startTime),
ClientIP: c.RealIP(),
UserAgent: c.Request().UserAgent(),
Request: APIExchangeRequest{
Method: c.Request().Method,
Path: c.Path(),

View File

@@ -0,0 +1,44 @@
import { test, expect } from './coverage-fixtures.js'
// Pin the API-trace metadata surface (issues #10886 / #10887): the API
// traces table exposes the requesting user in a dedicated column, and the
// expanded row detail lists User, Client IP and User Agent so an operator
// can tell who/what issued each request.
test.describe('Traces - API request metadata', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
request: { method: 'POST', path: '/v1/chat/completions' },
response: { status: 200 },
user_id: 'user-123',
user_name: 'alice',
client_ip: '203.0.113.7',
user_agent: 'curl/8.4.0',
},
]),
})
})
await page.route('**/api/backend-traces', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.goto('/app/traces')
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
})
test('shows the User column value in the API traces table', async ({ page }) => {
await expect(page.locator('th', { hasText: 'User' })).toBeVisible()
await expect(page.locator('td', { hasText: 'alice' }).first()).toBeVisible()
})
test('expands the row to reveal Client IP and User Agent', async ({ page }) => {
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
await expect(page.locator('text=Client IP').first()).toBeVisible()
await expect(page.locator('text=203.0.113.7').first()).toBeVisible()
await expect(page.locator('text=User Agent').first()).toBeVisible()
await expect(page.locator('text=curl/8.4.0').first()).toBeVisible()
})
})

View File

@@ -304,8 +304,27 @@ function BackendTraceDetail({ trace }) {
// Expanded detail for an API trace row
function ApiTraceDetail({ trace }) {
const user = trace.user_name || trace.user_id
const meta = [
['User', user],
['Client IP', trace.client_ip],
['User Agent', trace.user_agent],
].filter(([, v]) => v)
return (
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-secondary)', borderBottom: '1px solid var(--color-border)' }}>
{meta.length > 0 && (
<div style={{
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.25rem var(--spacing-md)',
alignItems: 'baseline', marginBottom: 'var(--spacing-md)', fontSize: '0.8125rem',
}}>
{meta.map(([label, value]) => (
<React.Fragment key={label}>
<span style={{ fontWeight: 600, color: 'var(--color-text-secondary)' }}>{label}</span>
<span style={{ fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>{value}</span>
</React.Fragment>
))}
</div>
)}
{trace.error && (
<div style={{
background: 'var(--color-error-light)', border: '1px solid var(--color-error-border)',
@@ -360,6 +379,7 @@ export default function Traces() {
const TRACE_SORT = {
method: (a, b) => (a.request?.method || '').localeCompare(b.request?.method || ''),
path: (a, b) => (a.request?.path || '').localeCompare(b.request?.path || ''),
user: (a, b) => (a.user_name || a.user_id || '').localeCompare(b.user_name || b.user_id || ''),
status: (a, b) => (a.response?.status || 0) - (b.response?.status || 0),
type: (a, b) => (a.type || '').localeCompare(b.type || ''),
time: (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
@@ -615,6 +635,7 @@ export default function Traces() {
<th style={{ width: '30px' }}></th>
{sortableTh('method', 'Method')}
{sortableTh('path', 'Path')}
{sortableTh('user', 'User')}
{sortableTh('status', 'Status')}
<th style={{ width: '40px' }}>Result</th>
</tr>
@@ -626,6 +647,7 @@ export default function Traces() {
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={trace.user_name || trace.user_id || ''}>{trace.user_name || trace.user_id || '-'}</td>
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
<td style={{ textAlign: 'center' }}>
{trace.error
@@ -635,7 +657,7 @@ export default function Traces() {
</tr>
{expandedRow === i && (
<tr>
<td colSpan="5" style={{ padding: 0 }}>
<td colSpan="6" style={{ padding: 0 }}>
<ApiTraceDetail trace={trace} />
</td>
</tr>