Skip to content

Commit 4be6e22

Browse files
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>
1 parent bf484c5 commit 4be6e22

3 files changed

Lines changed: 75 additions & 1 deletion

File tree

core/http/middleware/trace.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ type APIExchange struct {
4343
Error string `json:"error,omitempty"`
4444
UserID string `json:"user_id,omitempty"`
4545
UserName string `json:"user_name,omitempty"`
46+
// ClientIP is the caller's address as resolved by echo (honours
47+
// X-Forwarded-For / X-Real-IP behind a trusted proxy), and UserAgent
48+
// is the raw User-Agent header. Both are surfaced in the admin Traces
49+
// UI so an operator can tell who/what issued each request.
50+
ClientIP string `json:"client_ip,omitempty"`
51+
UserAgent string `json:"user_agent,omitempty"`
4652
}
4753

4854
var traceBuffer *circularbuffer.Queue[APIExchange]
@@ -221,6 +227,8 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
221227
exchange := APIExchange{
222228
Timestamp: startTime,
223229
Duration: time.Since(startTime),
230+
ClientIP: c.RealIP(),
231+
UserAgent: c.Request().UserAgent(),
224232
Request: APIExchangeRequest{
225233
Method: c.Request().Method,
226234
Path: c.Path(),
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { test, expect } from './coverage-fixtures.js'
2+
3+
// Pin the API-trace metadata surface (issues #10886 / #10887): the API
4+
// traces table exposes the requesting user in a dedicated column, and the
5+
// expanded row detail lists User, Client IP and User Agent so an operator
6+
// can tell who/what issued each request.
7+
test.describe('Traces - API request metadata', () => {
8+
test.beforeEach(async ({ page }) => {
9+
await page.route('**/api/traces', (route) => {
10+
route.fulfill({
11+
contentType: 'application/json',
12+
body: JSON.stringify([
13+
{
14+
request: { method: 'POST', path: '/v1/chat/completions' },
15+
response: { status: 200 },
16+
user_id: 'user-123',
17+
user_name: 'alice',
18+
client_ip: '203.0.113.7',
19+
user_agent: 'curl/8.4.0',
20+
},
21+
]),
22+
})
23+
})
24+
await page.route('**/api/backend-traces', (route) => {
25+
route.fulfill({ contentType: 'application/json', body: '[]' })
26+
})
27+
await page.goto('/app/traces')
28+
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
29+
})
30+
31+
test('shows the User column value in the API traces table', async ({ page }) => {
32+
await expect(page.locator('th', { hasText: 'User' })).toBeVisible()
33+
await expect(page.locator('td', { hasText: 'alice' }).first()).toBeVisible()
34+
})
35+
36+
test('expands the row to reveal Client IP and User Agent', async ({ page }) => {
37+
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
38+
39+
await expect(page.locator('text=Client IP').first()).toBeVisible()
40+
await expect(page.locator('text=203.0.113.7').first()).toBeVisible()
41+
await expect(page.locator('text=User Agent').first()).toBeVisible()
42+
await expect(page.locator('text=curl/8.4.0').first()).toBeVisible()
43+
})
44+
})

core/http/react-ui/src/pages/Traces.jsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,27 @@ function BackendTraceDetail({ trace }) {
304304

305305
// Expanded detail for an API trace row
306306
function ApiTraceDetail({ trace }) {
307+
const user = trace.user_name || trace.user_id
308+
const meta = [
309+
['User', user],
310+
['Client IP', trace.client_ip],
311+
['User Agent', trace.user_agent],
312+
].filter(([, v]) => v)
307313
return (
308314
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-secondary)', borderBottom: '1px solid var(--color-border)' }}>
315+
{meta.length > 0 && (
316+
<div style={{
317+
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.25rem var(--spacing-md)',
318+
alignItems: 'baseline', marginBottom: 'var(--spacing-md)', fontSize: '0.8125rem',
319+
}}>
320+
{meta.map(([label, value]) => (
321+
<React.Fragment key={label}>
322+
<span style={{ fontWeight: 600, color: 'var(--color-text-secondary)' }}>{label}</span>
323+
<span style={{ fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>{value}</span>
324+
</React.Fragment>
325+
))}
326+
</div>
327+
)}
309328
{trace.error && (
310329
<div style={{
311330
background: 'var(--color-error-light)', border: '1px solid var(--color-error-border)',
@@ -360,6 +379,7 @@ export default function Traces() {
360379
const TRACE_SORT = {
361380
method: (a, b) => (a.request?.method || '').localeCompare(b.request?.method || ''),
362381
path: (a, b) => (a.request?.path || '').localeCompare(b.request?.path || ''),
382+
user: (a, b) => (a.user_name || a.user_id || '').localeCompare(b.user_name || b.user_id || ''),
363383
status: (a, b) => (a.response?.status || 0) - (b.response?.status || 0),
364384
type: (a, b) => (a.type || '').localeCompare(b.type || ''),
365385
time: (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
@@ -615,6 +635,7 @@ export default function Traces() {
615635
<th style={{ width: '30px' }}></th>
616636
{sortableTh('method', 'Method')}
617637
{sortableTh('path', 'Path')}
638+
{sortableTh('user', 'User')}
618639
{sortableTh('status', 'Status')}
619640
<th style={{ width: '40px' }}>Result</th>
620641
</tr>
@@ -626,6 +647,7 @@ export default function Traces() {
626647
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
627648
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
628649
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
650+
<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>
629651
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
630652
<td style={{ textAlign: 'center' }}>
631653
{trace.error
@@ -635,7 +657,7 @@ export default function Traces() {
635657
</tr>
636658
{expandedRow === i && (
637659
<tr>
638-
<td colSpan="5" style={{ padding: 0 }}>
660+
<td colSpan="6" style={{ padding: 0 }}>
639661
<ApiTraceDetail trace={trace} />
640662
</td>
641663
</tr>

0 commit comments

Comments
 (0)