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 (