From 4be6e22b5f2d31a581d9e3260398a1aa47869fd0 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Fri, 17 Jul 2026 23:47:31 +0200 Subject: [PATCH] feat(webui): surface user, client IP and user agent in API traces (#10886, #10887) (#10907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Ettore Di Giacinto --- core/http/middleware/trace.go | 8 ++++ .../http/react-ui/e2e/traces-metadata.spec.js | 44 +++++++++++++++++++ core/http/react-ui/src/pages/Traces.jsx | 24 +++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 core/http/react-ui/e2e/traces-metadata.spec.js diff --git a/core/http/middleware/trace.go b/core/http/middleware/trace.go index 9a3da8dea..92baf18a4 100644 --- a/core/http/middleware/trace.go +++ b/core/http/middleware/trace.go @@ -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(), diff --git a/core/http/react-ui/e2e/traces-metadata.spec.js b/core/http/react-ui/e2e/traces-metadata.spec.js new file mode 100644 index 000000000..9e8fdbe70 --- /dev/null +++ b/core/http/react-ui/e2e/traces-metadata.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/src/pages/Traces.jsx b/core/http/react-ui/src/pages/Traces.jsx index bc4cb6d18..92c39c7cd 100644 --- a/core/http/react-ui/src/pages/Traces.jsx +++ b/core/http/react-ui/src/pages/Traces.jsx @@ -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 (
+ {meta.length > 0 && ( +
+ {meta.map(([label, value]) => ( + + {label} + {value} + + ))} +
+ )} {trace.error && (
(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() { {sortableTh('method', 'Method')} {sortableTh('path', 'Path')} + {sortableTh('user', 'User')} {sortableTh('status', 'Status')} Result @@ -626,6 +647,7 @@ export default function Traces() { {trace.request?.method || '-'} {trace.request?.path || '-'} + {trace.user_name || trace.user_id || '-'} {trace.response?.status || '-'} {trace.error @@ -635,7 +657,7 @@ export default function Traces() { {expandedRow === i && ( - +