193 lines
6.7 KiB
Markdown
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]]
|