obsidian/wiki/shared-patterns/structlog-setup.md
2026-05-15 10:37:25 +01:00

193 lines
6.7 KiB
Markdown

---
title: structlog Setup — Oliver Standard
aliases:
- structlog pattern
- structured logging
tags:
- python
- structlog
- shared-pattern
- oliver
sources:
- cc-dashboard/src/main.py
- cc-dashboard/src/middleware/logging.py
- ac-tool/backend/server/app.py
- ac-helper/backend/server/app.py
created: 2026-05-15
updated: 2026-05-15
---
## Key Takeaways
- Two distinct `structlog.configure()` styles coexist across Oliver projects: a **modern** one (cc-dashboard, FastAPI) using `make_filtering_bound_logger` + `PrintLoggerFactory`, and a **stdlib-bridge** one (ac-tool / ac-helper, Quart) using `stdlib.LoggerFactory` + `stdlib.BoundLogger`.
- The modern FastAPI pattern switches renderer based on a `LOG_FORMAT` env var (`json``JSONRenderer`, anything else → `ConsoleRenderer` with colors).
- Request-scoped context (`request_id`, `method`, `path`) is injected via `structlog.contextvars` in a Starlette middleware and cleaned up in `finally`.
- `cache_logger_on_first_use=True` is set in all projects — mandatory for production performance.
- `structlog.configure()` is called inside the FastAPI `lifespan` function, not at module import time.
## Standard Setup
### Modern pattern (cc-dashboard / FastAPI)
From `cc-dashboard/src/main.py` — the canonical Oliver FastAPI setup:
```python
import logging
import structlog
def _configure_logging() -> None:
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
]
if settings.LOG_FORMAT == "json":
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors=True)
structlog.configure(
processors=shared_processors + [renderer],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
```
Called in the lifespan:
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
_configure_logging()
# ... other startup
yield
# ... shutdown
app = FastAPI(lifespan=lifespan)
```
### Stdlib-bridge pattern (ac-tool / ac-helper / Quart)
From `ac-tool/backend/server/app.py` and `ac-helper/backend/server/app.py` (identical):
```python
import structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="ISO"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logger = structlog.get_logger(__name__)
```
This pattern always outputs JSON (no dev/prod switch). The `format_exc_info` processor formats exception tracebacks into the JSON log entry.
## Usage Pattern
Getting a logger and using it in FastAPI:
```python
import structlog
# Module level — fine with cache_logger_on_first_use=True
log = structlog.get_logger()
# In a route or service
log.info("work_item_fetched", work_item_id=work_item_id, project=project)
log.warning("rate_limit_approaching", remaining=remaining, endpoint=url)
log.error("ado_api_error", status=resp.status_code, url=url)
```
Request context middleware (from `cc-dashboard/src/middleware/logging.py`):
```python
import time
import uuid
import structlog
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
log = structlog.get_logger()
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
request_id = str(uuid.uuid4())[:8]
start = time.perf_counter()
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
path=request.url.path,
)
try:
response = await call_next(request)
duration = round((time.perf_counter() - start) * 1000, 1)
log.info("request", status=response.status_code, duration_ms=duration)
return response
finally:
structlog.contextvars.unbind_contextvars("request_id", "method", "path")
```
Register in app:
```python
app.add_middleware(LoggingMiddleware)
```
## JSON vs Console Output
Controlled by `settings.LOG_FORMAT` in cc-dashboard:
| `LOG_FORMAT` value | Renderer | Use |
|-------------------|----------|-----|
| `"json"` | `structlog.processors.JSONRenderer()` | Production (Docker, GCP Logging) |
| anything else (e.g. `"text"`) | `structlog.dev.ConsoleRenderer(colors=True)` | Local dev |
Typical `.env` entries:
```env
# dev
LOG_FORMAT=text
# prod / docker-compose.prod.yml
LOG_FORMAT=json
```
The Quart projects (ac-tool, ac-helper) always use `JSONRenderer` — no switch; they were designed for server-only deployment from the start.
## Gotchas
**1. `_configure_logging()` must be called before any logger is used.**
If `structlog.get_logger()` is called at module import time and `structlog.configure()` runs later (e.g. on first request), the logger uses structlog defaults (no processors, no renderer). Always call `configure()` in `lifespan` startup, before importing routers that create module-level loggers. cc-dashboard works around this by delaying imports of routers until after configure is called.
**2. `contextvars.unbind_contextvars` must be in `finally`.**
If an unhandled exception escapes `call_next`, the bound context (`request_id`, etc.) leaks into subsequent requests on the same event-loop task. The `finally` block in `LoggingMiddleware` ensures cleanup even on 500 errors.
**3. `make_filtering_bound_logger(logging.INFO)` vs `stdlib.BoundLogger`.**
The modern pattern uses `make_filtering_bound_logger` which compiles level checks at bind time — faster. The stdlib-bridge pattern uses `stdlib.BoundLogger` which integrates with Python's `logging` module (needed for third-party libraries that use `logging.getLogger`). Don't mix them in the same app.
**4. `cache_logger_on_first_use=True` is required in production.**
Without it, structlog rebuilds the processor chain on every log call. With it, the chain is compiled after the first call. This flag must be set; it's present in all Oliver projects.
## Related
- [[wiki/shared-patterns/httpx-async-client]]
- [[wiki/tech-patterns/fastapi-python-docker]]
- [[wiki/tech-patterns/cost-tracker-integration]]