mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 08:08:48 +02:00
Log client details for API and agent requests
This commit is contained in:
@@ -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 ###
|
||||||
@@ -41,7 +41,14 @@ def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def create_chat_run(
|
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:
|
) -> int:
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
@@ -50,6 +57,9 @@ def create_chat_run(
|
|||||||
agui_run_id=agui_run_id,
|
agui_run_id=agui_run_id,
|
||||||
requested_thread_id=thread_id,
|
requested_thread_id=thread_id,
|
||||||
model=settings.AGENT_MODEL,
|
model=settings.AGENT_MODEL,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
user_prompt=_last_user_prompt(messages),
|
user_prompt=_last_user_prompt(messages),
|
||||||
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
|
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,41 @@ from app.logging import configure_structured_logging, log_event
|
|||||||
|
|
||||||
|
|
||||||
AGENT_PATH = "/api/v1/agent/agui"
|
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:
|
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_id = uuid.uuid4().hex
|
||||||
request.state.request_id = request_id
|
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()
|
started = perf_counter()
|
||||||
status_code = 500
|
status_code = 500
|
||||||
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
|
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,
|
status_code=status_code,
|
||||||
duration_ms=round((perf_counter() - started) * 1000, 1),
|
duration_ms=round((perf_counter() - started) * 1000, 1),
|
||||||
error_type=error_type,
|
error_type=error_type,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
browser=browser,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -253,6 +295,9 @@ async def agent_auth_and_session(request: Request, call_next):
|
|||||||
has_user_message=any(
|
has_user_message=any(
|
||||||
message.get("role") == "user" for message in body.get("messages") or []
|
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
|
# The BFF removes its `method: agent/run` envelope before
|
||||||
# forwarding to MAF. A non-empty user message is the reliable
|
# 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"),
|
agui_run_id=body.get("runId"),
|
||||||
thread_id=body.get("threadId") or body.get("thread_id"),
|
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||||
messages=body.get("messages") or [],
|
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:
|
except Exception as exc:
|
||||||
# Parsing/repair must not prevent the agent endpoint from serving;
|
# Parsing/repair must not prevent the agent endpoint from serving;
|
||||||
|
|||||||
@@ -585,6 +585,9 @@ class ChatRunLog(SQLModel, table=True):
|
|||||||
requested_thread_id: Optional[str] = Field(default=None, index=True)
|
requested_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
response_thread_id: Optional[str] = Field(default=None, index=True)
|
response_thread_id: Optional[str] = Field(default=None, index=True)
|
||||||
model: str
|
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))
|
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
tool_calls: list = Field(
|
tool_calls: list = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine
|
|||||||
agui_run_id="run-1",
|
agui_run_id="run-1",
|
||||||
thread_id="main",
|
thread_id="main",
|
||||||
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
|
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)
|
token = chat_audit.set_current_chat_run(run_id)
|
||||||
try:
|
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.agui_run_id == "run-1"
|
||||||
assert row.response_thread_id == "resp_123"
|
assert row.response_thread_id == "resp_123"
|
||||||
assert row.user_prompt == "¿Cuál es mi saldo?"
|
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.assistant_response == "Tu saldo es ₡1."
|
||||||
assert row.tool_calls == [
|
assert row.tool_calls == [
|
||||||
{
|
{
|
||||||
@@ -107,7 +113,10 @@ def test_api_access_log_never_contains_request_body(monkeypatch):
|
|||||||
"path": "/api/auth/login",
|
"path": "/api/auth/login",
|
||||||
"raw_path": b"/api/auth/login",
|
"raw_path": b"/api/auth/login",
|
||||||
"query_string": b"",
|
"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",
|
"scheme": "http",
|
||||||
"server": ("testserver", 80),
|
"server": ("testserver", 80),
|
||||||
"client": ("testclient", 1234),
|
"client": ("testclient", 1234),
|
||||||
@@ -123,5 +132,8 @@ def test_api_access_log_never_contains_request_body(monkeypatch):
|
|||||||
fields = logged[0][1]
|
fields = logged[0][1]
|
||||||
assert fields["path"] == "/api/auth/login"
|
assert fields["path"] == "/api/auth/login"
|
||||||
assert fields["status_code"] == 201
|
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 "password" not in fields
|
||||||
assert "body" not in fields
|
assert "body" not in fields
|
||||||
|
|||||||
Reference in New Issue
Block a user