From 3d0547743f32cb6fc4deae022a0a5287bd5bfb52 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Tue, 14 Jul 2026 22:55:33 -0600 Subject: [PATCH] Log client details for API and agent requests --- ...86104b4ff6ae_record_chat_client_details.py | 37 ++++++++++++++ backend/app/agent/chat_audit.py | 12 ++++- backend/app/main.py | 48 +++++++++++++++++++ backend/app/models/models.py | 3 ++ backend/tests/test_chat_audit.py | 14 +++++- 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/86104b4ff6ae_record_chat_client_details.py diff --git a/backend/alembic/versions/86104b4ff6ae_record_chat_client_details.py b/backend/alembic/versions/86104b4ff6ae_record_chat_client_details.py new file mode 100644 index 0000000..6c6ff05 --- /dev/null +++ b/backend/alembic/versions/86104b4ff6ae_record_chat_client_details.py @@ -0,0 +1,37 @@ +"""record chat client details + +Revision ID: 86104b4ff6ae +Revises: 10f4a4f71964 +Create Date: 2026-07-14 22:54:55.519380 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '86104b4ff6ae' +down_revision: Union[str, Sequence[str], None] = '10f4a4f71964' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True)) + op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True)) + op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chatrunlog', 'browser') + op.drop_column('chatrunlog', 'user_agent') + op.drop_column('chatrunlog', 'client_ip') + # ### end Alembic commands ### diff --git a/backend/app/agent/chat_audit.py b/backend/app/agent/chat_audit.py index b0be7ac..bcb8776 100644 --- a/backend/app/agent/chat_audit.py +++ b/backend/app/agent/chat_audit.py @@ -41,7 +41,14 @@ def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None: def create_chat_run( - *, request_id: str, agui_run_id: str | None, thread_id: str | None, messages: list[dict[str, Any]] + *, + request_id: str, + agui_run_id: str | None, + thread_id: str | None, + messages: list[dict[str, Any]], + client_ip: str | None = None, + user_agent: str | None = None, + browser: str | None = None, ) -> int: now = utcnow() with Session(engine) as session: @@ -50,6 +57,9 @@ def create_chat_run( agui_run_id=agui_run_id, requested_thread_id=thread_id, model=settings.AGENT_MODEL, + client_ip=client_ip, + user_agent=user_agent, + browser=browser, user_prompt=_last_user_prompt(messages), expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS), ) diff --git a/backend/app/main.py b/backend/app/main.py index fde290a..5f41fec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -38,6 +38,41 @@ from app.logging import configure_structured_logging, log_event AGENT_PATH = "/api/v1/agent/agui" +MAX_USER_AGENT_LENGTH = 512 + + +def _request_client_details(request: Request) -> tuple[str, str | None, str | None]: + """Return the browser client details propagated through the frontend BFF. + + Production nginx-proxy supplies X-Real-IP and the frontend forwards it to + FastAPI. For direct local development, fall back to X-Forwarded-For and + then the socket peer. The BFF is the only production path to the backend, + which is not publicly exposed. + """ + client_ip = request.headers.get("x-real-ip", "").strip() + if not client_ip: + forwarded = request.headers.get("x-forwarded-for", "") + client_ip = forwarded.split(",", 1)[0].strip() + if not client_ip: + client_ip = request.client.host if request.client else "unknown" + + user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None + if not user_agent: + return client_ip, None, None + ua = user_agent.lower() + if "edg/" in ua or "edgios/" in ua: + browser = "Edge" + elif "opr/" in ua or "opera" in ua: + browser = "Opera" + elif "firefox/" in ua or "fxios/" in ua: + browser = "Firefox" + elif "chrome/" in ua or "crios/" in ua: + browser = "Chrome" + elif "safari/" in ua: + browser = "Safari" + else: + browser = "Other" + return client_ip, user_agent, browser def _pair_orphan_tool_calls(messages: list) -> list: @@ -151,6 +186,10 @@ async def log_api_request(request: Request, call_next): """ request_id = uuid.uuid4().hex request.state.request_id = request_id + client_ip, user_agent, browser = _request_client_details(request) + request.state.client_ip = client_ip + request.state.user_agent = user_agent + request.state.browser = browser started = perf_counter() status_code = 500 is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health" @@ -167,6 +206,9 @@ async def log_api_request(request: Request, call_next): status_code=status_code, duration_ms=round((perf_counter() - started) * 1000, 1), error_type=error_type, + client_ip=client_ip, + user_agent=user_agent, + browser=browser, ) try: @@ -253,6 +295,9 @@ async def agent_auth_and_session(request: Request, call_next): has_user_message=any( message.get("role") == "user" for message in body.get("messages") or [] ), + client_ip=request.state.client_ip, + user_agent=request.state.user_agent, + browser=request.state.browser, ) # The BFF removes its `method: agent/run` envelope before # forwarding to MAF. A non-empty user message is the reliable @@ -263,6 +308,9 @@ async def agent_auth_and_session(request: Request, call_next): agui_run_id=body.get("runId"), thread_id=body.get("threadId") or body.get("thread_id"), messages=body.get("messages") or [], + client_ip=request.state.client_ip, + user_agent=request.state.user_agent, + browser=request.state.browser, ) except Exception as exc: # Parsing/repair must not prevent the agent endpoint from serving; diff --git a/backend/app/models/models.py b/backend/app/models/models.py index ffd321d..a77f9db 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -585,6 +585,9 @@ class ChatRunLog(SQLModel, table=True): requested_thread_id: Optional[str] = Field(default=None, index=True) response_thread_id: Optional[str] = Field(default=None, index=True) model: str + client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) + user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) + browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) tool_calls: list = Field( default_factory=list, diff --git a/backend/tests/test_chat_audit.py b/backend/tests/test_chat_audit.py index c52ed9f..9543702 100644 --- a/backend/tests/test_chat_audit.py +++ b/backend/tests/test_chat_audit.py @@ -28,6 +28,9 @@ def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine agui_run_id="run-1", thread_id="main", messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}], + client_ip="203.0.113.7", + user_agent="Mozilla/5.0 TestBrowser/1.0", + browser="TestBrowser", ) token = chat_audit.set_current_chat_run(run_id) try: @@ -65,6 +68,9 @@ def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine assert row.agui_run_id == "run-1" assert row.response_thread_id == "resp_123" assert row.user_prompt == "¿Cuál es mi saldo?" + assert row.client_ip == "203.0.113.7" + assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0" + assert row.browser == "TestBrowser" assert row.assistant_response == "Tu saldo es ₡1." assert row.tool_calls == [ { @@ -107,7 +113,10 @@ def test_api_access_log_never_contains_request_body(monkeypatch): "path": "/api/auth/login", "raw_path": b"/api/auth/login", "query_string": b"", - "headers": [], + "headers": [ + (b"x-real-ip", b"203.0.113.9"), + (b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"), + ], "scheme": "http", "server": ("testserver", 80), "client": ("testclient", 1234), @@ -123,5 +132,8 @@ def test_api_access_log_never_contains_request_body(monkeypatch): fields = logged[0][1] assert fields["path"] == "/api/auth/login" assert fields["status_code"] == 201 + assert fields["client_ip"] == "203.0.113.9" + assert fields["browser"] == "Chrome" + assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36" assert "password" not in fields assert "body" not in fields