diff --git a/backend/app/api/work.py b/backend/app/api/work.py index f3979d3b..4b54a536 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from datetime import datetime 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.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate +logger = logging.getLogger("app.work") + router = APIRouter(tags=["work"]) ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"} @@ -110,16 +113,27 @@ def dispatch_task( session: Session = Depends(get_session), 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) if not task: 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: raise HTTPException(status_code=400, detail="Task has no assignee") _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( status_code=503, detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)", @@ -143,6 +157,7 @@ def update_task( session: Session = Depends(get_session), 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) if not task: raise HTTPException(status_code=404, detail="Task not found") @@ -242,6 +257,7 @@ def delete_task( session: Session = Depends(get_session), 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) if not task: raise HTTPException(status_code=404, detail="Task not found") diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 00000000..4513bb0e --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/integrations/notify.py b/backend/app/integrations/notify.py index 5bcd3072..fc04735d 100644 --- a/backend/app/integrations/notify.py +++ b/backend/app/integrations/notify.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from dataclasses import dataclass from typing import Iterable @@ -10,6 +11,8 @@ from app.models.org import Employee from app.models.projects import ProjectMember from app.models.work import Task, TaskComment +logger = logging.getLogger("app.notify") + @dataclass(frozen=True) class NotifyContext: @@ -143,15 +146,36 @@ def build_message(ctx: NotifyContext, recipient: Employee) -> str: def notify_openclaw(session: Session, ctx: NotifyContext) -> None: 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: + logger.warning("notify_openclaw: skipped (missing OpenClaw env)") return 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) if not recipients: + logger.info("notify_openclaw: no recipients with session keys") return 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) if not sk: continue @@ -164,5 +188,6 @@ def notify_openclaw(session: Session, ctx: NotifyContext) -> None: timeout_s=3.0, ) except Exception: + logger.exception("notify_openclaw: sessions_send failed") # best-effort; never break Mission Control writes continue diff --git a/backend/app/integrations/openclaw.py b/backend/app/integrations/openclaw.py index b5301123..780533df 100644 --- a/backend/app/integrations/openclaw.py +++ b/backend/app/integrations/openclaw.py @@ -1,10 +1,13 @@ from __future__ import annotations +import logging import os from typing import Any import requests +logger = logging.getLogger("app.openclaw") + class OpenClawClient: def __init__(self, base_url: str, token: str): @@ -28,6 +31,9 @@ class OpenClawClient: timeout_s: float = 5.0, ) -> dict[str, Any]: 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: payload["sessionKey"] = session_key @@ -38,4 +44,5 @@ class OpenClawClient: timeout=timeout_s, ) r.raise_for_status() + logger.info("openclaw.tools_invoke: ok", extra={"tool": tool, "status": r.status_code}) return r.json() diff --git a/backend/app/main.py b/backend/app/main.py index f383bdf3..4442bd6c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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.work import router as work_router from app.core.config import settings +from app.core.logging import configure_logging from app.db.session import init_db +configure_logging() + app = FastAPI(title="OpenClaw Agency API", version="0.3.0") origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]