Skip to content

Commit bf21b93

Browse files
committed
feat: migrate from stdlib logging to structlog with structured context
Replace plain `import logging` / `logging.getLogger(__name__)` across 41 backend files with structlog for structured, context-rich logging. Foundation: - Add structlog>=24.0 dependency - New logging_config.py: processor pipeline with dev (pretty) / prod (JSON) renderers - New ASGI middleware binding request_id + X-Request-Id header per request - EDICTUM_LOG_LEVEL and EDICTUM_LOG_FORMAT settings in config.py - tenant_id/auth_type/agent_id/user_id auto-bound after auth resolves - worker=<name> context bound in background workers New security-relevant logging wired in 12 files: - routes/auth.py: login success/failure, rate limit hits - auth/csrf.py: CSRF rejections with method/path/client_ip - routes/discord.py: signature failures, tenant mismatch (S3), approval decisions - routes/stream.py: agent SSE connect/disconnect, env scope rejection - routes/notifications.py: channel create/update/delete audit trail - security/body_limit.py: 413 rejections (header + streaming phases) - notifications/webhook.py: HTTP delivery failures (domain only) - notifications/email.py: SMTP success/failure - ai/agent_loop.py: tool call limit reached - ai/openai_compat.py + anthropic.py: JSON decode errors in tool calls - services/fleet_coverage_service.py: fallback from console to local matchers Dead code removed: unused logger boilerplate from ollama.py, coverage_matching.py
1 parent e6443c3 commit bf21b93

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+615
-286
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies = [
2424
"email-validator>=2.0",
2525
"aiosmtplib>=2.0,<3.0",
2626
"edictum[yaml]>=0.11.3",
27+
"structlog>=24.0",
2728
]
2829

2930
[project.optional-dependencies]

src/edictum_server/ai/agent_loop.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@
1616
from __future__ import annotations
1717

1818
import json
19-
import logging
2019
import time
2120
from collections.abc import AsyncIterator
2221
from typing import Any
2322

23+
import structlog
24+
2425
from edictum_server.ai.base import AIProvider, StreamEvent, ToolCallChunk
2526
from edictum_server.ai.tools import ToolContext, execute_tool, get_tool_definitions
2627

27-
logger = logging.getLogger(__name__)
28+
logger = structlog.get_logger(__name__)
2829

2930
# Safety limits to prevent runaway tool calling.
3031
_DEFAULT_MAX_ITERATIONS = 5
@@ -138,6 +139,12 @@ async def run_agent_loop(
138139
for tc in tool_calls:
139140
total_tool_calls += 1
140141
if total_tool_calls > max_tool_calls:
142+
logger.warning(
143+
"tool_call_limit_reached",
144+
tool_name=tc.name,
145+
iteration=_iteration,
146+
max_tool_calls=max_tool_calls,
147+
)
141148
result = {"error": f"Tool call limit reached ({max_tool_calls})"}
142149
duration_ms = 0
143150
else:

src/edictum_server/ai/anthropic.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import json
6-
import logging
76
from collections.abc import AsyncIterator
87
from typing import Any
98

9+
import structlog
10+
1011
from edictum_server.ai.base import (
1112
AIProvider,
1213
AiUsageResult,
@@ -15,15 +16,15 @@
1516
ToolDefinition,
1617
)
1718

18-
logger = logging.getLogger(__name__)
19-
2019
try:
2120
import anthropic # type: ignore[import-not-found]
2221

2322
_HAS_ANTHROPIC = True
2423
except ImportError:
2524
_HAS_ANTHROPIC = False
2625

26+
logger = structlog.get_logger(__name__)
27+
2728
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
2829

2930

@@ -141,6 +142,13 @@ async def stream_with_tools(
141142
try:
142143
arguments = json.loads(raw_json) if raw_json else {}
143144
except json.JSONDecodeError:
145+
logger.warning(
146+
"tool_call_json_decode_error",
147+
provider="anthropic",
148+
model=self._model,
149+
tool_name=current_tool_name,
150+
raw_length=len(raw_json),
151+
)
144152
arguments = {"_raw": raw_json}
145153
yield StreamEvent(
146154
tool_call=ToolCallChunk(

src/edictum_server/ai/ollama.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22

33
from __future__ import annotations
44

5-
import logging
65
from collections.abc import AsyncIterator
76

87
from edictum_server.ai.base import AIProvider, AiUsageResult
98

10-
logger = logging.getLogger(__name__)
11-
129
try:
1310
import ollama as _ollama_lib # type: ignore[import-not-found]
1411

src/edictum_server/ai/openai_compat.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import json
6-
import logging
76
from collections.abc import AsyncIterator
87
from typing import Any
98

9+
import structlog
10+
1011
from edictum_server.ai.base import (
1112
AIProvider,
1213
AiUsageResult,
@@ -15,15 +16,15 @@
1516
ToolDefinition,
1617
)
1718

18-
logger = logging.getLogger(__name__)
19-
2019
try:
2120
import openai # type: ignore[import-not-found]
2221

2322
_HAS_OPENAI = True
2423
except ImportError:
2524
_HAS_OPENAI = False
2625

26+
logger = structlog.get_logger(__name__)
27+
2728
DEFAULT_OPENAI_MODEL = "gpt-5-mini"
2829
DEFAULT_OPENROUTER_MODEL = "qwen/qwen3-4b:free"
2930
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
@@ -187,6 +188,13 @@ async def stream_with_tools(
187188
try:
188189
arguments = json.loads(raw_args) if raw_args else {}
189190
except json.JSONDecodeError:
191+
logger.warning(
192+
"tool_call_json_decode_error",
193+
provider=self._provider_name,
194+
model=self._model,
195+
tool_name=buf["name"],
196+
raw_length=len(raw_args),
197+
)
190198
arguments = {"_raw": raw_args}
191199
yield StreamEvent(
192200
tool_call=ToolCallChunk(

src/edictum_server/ai/pricing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
from __future__ import annotations
88

99
import asyncio
10-
import logging
1110
import time
1211
from decimal import Decimal
1312

1413
import httpx
14+
import structlog
1515

16-
logger = logging.getLogger(__name__)
16+
logger = structlog.get_logger(__name__)
1717

1818
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
1919
_CACHE_TTL_SECONDS = 3600 # 1 hour

src/edictum_server/ai/resources.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@
1717

1818
from __future__ import annotations
1919

20-
import logging
2120
import uuid
2221
from datetime import UTC, datetime, timedelta
2322
from importlib import resources as importlib_resources
2423

24+
import structlog
2525
import yaml
2626
from sqlalchemy import func, select
2727
from sqlalchemy.ext.asyncio import AsyncSession
2828

29-
logger = logging.getLogger(__name__)
29+
logger = structlog.get_logger(__name__)
3030

3131
# Cache for static template content (loaded once, never changes at runtime).
3232
_templates_cache: dict[str, str] = {}

src/edictum_server/ai/tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@
2020
from __future__ import annotations
2121

2222
import asyncio
23-
import logging
2423
import uuid
2524
from collections.abc import Awaitable, Callable
2625
from dataclasses import dataclass
2726
from typing import Any
2827

28+
import structlog
2929
import yaml
3030

3131
from edictum_server.ai.base import ToolDefinition
3232

33-
logger = logging.getLogger(__name__)
33+
logger = structlog.get_logger(__name__)
3434

3535
_TOOL_TIMEOUT_SECONDS = 5.0
3636

src/edictum_server/auth/csrf.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@
1212

1313
from __future__ import annotations
1414

15-
import logging
16-
15+
import structlog
1716
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
1817
from starlette.requests import Request
1918
from starlette.responses import JSONResponse, Response
2019

21-
logger = logging.getLogger(__name__)
20+
logger = structlog.get_logger(__name__)
2221

2322
_MUTATING_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
2423

@@ -37,9 +36,7 @@
3736
class CSRFMiddleware(BaseHTTPMiddleware):
3837
"""Require ``X-Requested-With`` on cookie-auth mutating requests."""
3938

40-
async def dispatch(
41-
self, request: Request, call_next: RequestResponseEndpoint
42-
) -> Response:
39+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
4340
if request.method not in _MUTATING_METHODS:
4441
return await call_next(request)
4542

@@ -62,6 +59,8 @@ async def dispatch(
6259

6360
# Cookie-auth mutating request — require custom header
6461
if not request.headers.get("x-requested-with"):
62+
client_ip = request.client.host if request.client else "unknown"
63+
logger.warning("csrf_rejected", method=request.method, path=path, client_ip=client_ip)
6564
from edictum_server.security.headers import _HEADERS as _SECURITY_HEADERS
6665

6766
resp = JSONResponse(

src/edictum_server/auth/dependencies.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from __future__ import annotations
44

5-
import logging
65
import uuid
76
from dataclasses import dataclass
87
from typing import Literal
98

9+
import structlog
1010
from fastapi import Depends, Header, HTTPException, Request, status
1111
from sqlalchemy import select
1212
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +16,7 @@
1616
from edictum_server.db.engine import get_db
1717
from edictum_server.db.models import ApiKey
1818

19-
logger = logging.getLogger(__name__)
19+
logger = structlog.get_logger(__name__)
2020

2121

2222
@dataclass(frozen=True, slots=True)
@@ -81,28 +81,41 @@ async def require_api_key(
8181
detail="Invalid or revoked API key.",
8282
)
8383

84-
return AuthContext(
84+
ctx = AuthContext(
8585
tenant_id=api_key.tenant_id,
8686
auth_type="api_key",
8787
env=api_key.env,
8888
agent_id=x_edictum_agent_id,
8989
api_key_prefix=api_key.key_prefix,
9090
)
91+
structlog.contextvars.bind_contextvars(
92+
tenant_id=str(ctx.tenant_id),
93+
auth_type="api_key",
94+
agent_id=x_edictum_agent_id or "unknown",
95+
env=api_key.env,
96+
)
97+
return ctx
9198

9299

93100
async def require_dashboard_auth(
94101
request: Request,
95102
) -> AuthContext:
96103
"""Authenticate a dashboard (human) request via session cookie."""
97104
auth_provider = request.app.state.auth_provider
98-
ctx: DashboardAuthContext = await auth_provider.authenticate(request)
99-
return AuthContext(
100-
tenant_id=ctx.tenant_id,
105+
dash_ctx: DashboardAuthContext = await auth_provider.authenticate(request)
106+
auth = AuthContext(
107+
tenant_id=dash_ctx.tenant_id,
101108
auth_type="dashboard",
102-
user_id=str(ctx.user_id),
103-
email=ctx.email,
104-
is_admin=ctx.is_admin,
109+
user_id=str(dash_ctx.user_id),
110+
email=dash_ctx.email,
111+
is_admin=dash_ctx.is_admin,
105112
)
113+
structlog.contextvars.bind_contextvars(
114+
tenant_id=str(auth.tenant_id),
115+
auth_type="dashboard",
116+
user_id=str(dash_ctx.user_id),
117+
)
118+
return auth
106119

107120

108121
async def require_admin(

0 commit comments

Comments
 (0)