Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Escalante
3d0547743f Log client details for API and agent requests
All checks were successful
Deploy to VPS / test (push) Successful in 1m42s
Deploy to VPS / deploy (push) Successful in 14s
2026-07-14 22:55:50 -06:00
5 changed files with 112 additions and 2 deletions

View File

@@ -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 ###

View File

@@ -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),
)

View File

@@ -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;

View File

@@ -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,

View File

@@ -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