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

This commit is contained in:
Carlos Escalante
2026-07-14 22:55:33 -06:00
parent 8ceed0ab2e
commit 3d0547743f
5 changed files with 112 additions and 2 deletions

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,