chore(logging): stream backend logs to console + instrument dispatch/notify
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
@@ -14,6 +15,8 @@ from app.models.org import Employee
|
|||||||
from app.models.work import Task, TaskComment
|
from app.models.work import Task, TaskComment
|
||||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.work")
|
||||||
|
|
||||||
router = APIRouter(tags=["work"])
|
router = APIRouter(tags=["work"])
|
||||||
|
|
||||||
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
||||||
@@ -110,16 +113,27 @@ def dispatch_task(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||||
):
|
):
|
||||||
|
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"dispatch_task: loaded",
|
||||||
|
extra={
|
||||||
|
"task_id": getattr(task, "id", None),
|
||||||
|
"assignee_employee_id": getattr(task, "assignee_employee_id", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if task.assignee_employee_id is None:
|
if task.assignee_employee_id is None:
|
||||||
raise HTTPException(status_code=400, detail="Task has no assignee")
|
raise HTTPException(status_code=400, detail="Task has no assignee")
|
||||||
|
|
||||||
_validate_task_assignee(session, task.assignee_employee_id)
|
_validate_task_assignee(session, task.assignee_employee_id)
|
||||||
|
|
||||||
if OpenClawClient.from_env() is None:
|
client = OpenClawClient.from_env()
|
||||||
|
if client is None:
|
||||||
|
logger.warning("dispatch_task: missing OpenClaw env")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
|
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
|
||||||
@@ -143,6 +157,7 @@ def update_task(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||||
):
|
):
|
||||||
|
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
@@ -242,6 +257,7 @@ def delete_task(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||||
):
|
):
|
||||||
|
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|||||||
54
backend/app/core/logging.py
Normal file
54
backend/app/core/logging.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _level() -> str:
|
||||||
|
return (os.environ.get("LOG_LEVEL") or os.environ.get("UVICORN_LOG_LEVEL") or "INFO").upper()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""Configure app logging to stream to stdout.
|
||||||
|
|
||||||
|
Uvicorn already logs requests, but we want our app/integrations logs to be visible
|
||||||
|
in the same console stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
level = getattr(logging, _level(), logging.INFO)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
# Avoid duplicate handlers (e.g., when autoreload imports twice)
|
||||||
|
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(level)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
# Make common noisy loggers respect our level
|
||||||
|
for name in [
|
||||||
|
"uvicorn",
|
||||||
|
"uvicorn.error",
|
||||||
|
"uvicorn.access",
|
||||||
|
"sqlalchemy.engine",
|
||||||
|
"httpx",
|
||||||
|
"requests",
|
||||||
|
]:
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def log_kv(logger: logging.Logger, msg: str, **kv: Any) -> None:
|
||||||
|
# Lightweight key-value logging without requiring JSON logging.
|
||||||
|
if kv:
|
||||||
|
suffix = " ".join(f"{k}={v!r}" for k, v in kv.items())
|
||||||
|
logger.info(f"{msg} | {suffix}")
|
||||||
|
else:
|
||||||
|
logger.info(msg)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ from app.models.org import Employee
|
|||||||
from app.models.projects import ProjectMember
|
from app.models.projects import ProjectMember
|
||||||
from app.models.work import Task, TaskComment
|
from app.models.work import Task, TaskComment
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.notify")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class NotifyContext:
|
class NotifyContext:
|
||||||
@@ -143,15 +146,36 @@ def build_message(ctx: NotifyContext, recipient: Employee) -> str:
|
|||||||
|
|
||||||
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
||||||
client = OpenClawClient.from_env()
|
client = OpenClawClient.from_env()
|
||||||
|
logger.info(
|
||||||
|
"notify_openclaw: start",
|
||||||
|
extra={
|
||||||
|
"event": ctx.event,
|
||||||
|
"task_id": getattr(ctx.task, "id", None),
|
||||||
|
"actor": ctx.actor_employee_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
if client is None:
|
if client is None:
|
||||||
|
logger.warning("notify_openclaw: skipped (missing OpenClaw env)")
|
||||||
return
|
return
|
||||||
|
|
||||||
recipient_ids = resolve_recipients(session, ctx)
|
recipient_ids = resolve_recipients(session, ctx)
|
||||||
|
logger.info(
|
||||||
|
"notify_openclaw: recipients resolved", extra={"recipient_ids": sorted(recipient_ids)}
|
||||||
|
)
|
||||||
recipients = _employees_with_session_keys(session, recipient_ids)
|
recipients = _employees_with_session_keys(session, recipient_ids)
|
||||||
if not recipients:
|
if not recipients:
|
||||||
|
logger.info("notify_openclaw: no recipients with session keys")
|
||||||
return
|
return
|
||||||
|
|
||||||
for e in recipients:
|
for e in recipients:
|
||||||
|
logger.info(
|
||||||
|
"notify_openclaw: sending",
|
||||||
|
extra={
|
||||||
|
"to_employee_id": getattr(e, "id", None),
|
||||||
|
"session_key": getattr(e, "openclaw_session_key", None),
|
||||||
|
"event": ctx.event,
|
||||||
|
},
|
||||||
|
)
|
||||||
sk = getattr(e, "openclaw_session_key", None)
|
sk = getattr(e, "openclaw_session_key", None)
|
||||||
if not sk:
|
if not sk:
|
||||||
continue
|
continue
|
||||||
@@ -164,5 +188,6 @@ def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
|||||||
timeout_s=3.0,
|
timeout_s=3.0,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("notify_openclaw: sessions_send failed")
|
||||||
# best-effort; never break Mission Control writes
|
# best-effort; never break Mission Control writes
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.openclaw")
|
||||||
|
|
||||||
|
|
||||||
class OpenClawClient:
|
class OpenClawClient:
|
||||||
def __init__(self, base_url: str, token: str):
|
def __init__(self, base_url: str, token: str):
|
||||||
@@ -28,6 +31,9 @@ class OpenClawClient:
|
|||||||
timeout_s: float = 5.0,
|
timeout_s: float = 5.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {"tool": tool, "args": args}
|
payload: dict[str, Any] = {"tool": tool, "args": args}
|
||||||
|
logger.info(
|
||||||
|
"openclaw.tools_invoke", extra={"tool": tool, "has_session_key": bool(session_key)}
|
||||||
|
)
|
||||||
if session_key is not None:
|
if session_key is not None:
|
||||||
payload["sessionKey"] = session_key
|
payload["sessionKey"] = session_key
|
||||||
|
|
||||||
@@ -38,4 +44,5 @@ class OpenClawClient:
|
|||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
logger.info("openclaw.tools_invoke: ok", extra={"tool": tool, "status": r.status_code})
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ from app.api.org import router as org_router
|
|||||||
from app.api.projects import router as projects_router
|
from app.api.projects import router as projects_router
|
||||||
from app.api.work import router as work_router
|
from app.api.work import router as work_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import configure_logging
|
||||||
from app.db.session import init_db
|
from app.db.session import init_db
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
|
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
|
||||||
|
|
||||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
|
|||||||
Reference in New Issue
Block a user