Structured logging implementation based on
structlogwith JSON output for Grafana Loki.
Services (structlog JSON) → stdout → Docker → Promtail → Loki → Grafana
- Loki (port 3100) — log aggregation, 7-day retention
- Promtail — scrapes Docker container logs, ships to Loki
- Grafana (port 3000) — dashboards, log viewer, alerting
All services output LOG_FORMAT=json in Docker. Promtail reads log files
non-destructively (docker logs still works).
BaseMessage(shared/contracts/base.py) auto-generatescorrelation_idper message- All consumers bind it via
bind_message_context()on message receipt - All API clients inject
X-Correlation-IDheader in outgoing requests - API middleware binds it on receipt
- Result: filter by
correlation_idin Grafana → see full request flow across all services
infra/
├── loki.yml # Loki config (TSDB, 7-day retention)
├── promtail.yml # Scrape Docker container logs
└── grafana/
├── datasources.yml # Auto-provision Loki datasource
├── dashboards.yml # Dashboard provisioning config
└── dashboards/service-logs.json # Pre-built "Service Logs" dashboard
Self-hosted Langfuse v3 provides LLM call tracing for all agents.
Setup: Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY env vars (empty = disabled). All 4 LangGraph consumers (PO, architect, engineering, deploy) auto-attach a CallbackHandler via src/tracing.py. Zero changes to agent/graph code.
Trace enrichment: Each trace includes user_id, project_id, agent_type metadata for filtering.
Infrastructure: langfuse-web (UI, port 3002), langfuse-worker (background processor), clickhouse (analytics), minio (S3-compatible media storage). Separate langfuse PostgreSQL database.
Access: Via admin-frontend links or directly at http://localhost:3002.
from shared.log_config import setup_logging
import structlog
# Initialize at service startup
setup_logging(service_name="my_service")
# Get logger and use it
logger = structlog.get_logger()
logger.info("event_name", key1="value1", key2=123)| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
Logging level: DEBUG, INFO, WARNING, ERROR |
LOG_FORMAT |
console |
Output format: json (production) or console (dev) |
SERVICE_NAME |
unknown |
Service name added to all logs |
Console format (development):
2025-12-26T12:00:00 [info] event_name key1=value1 key2=123 service=api
JSON format (production):
{
"timestamp": "2025-12-26T12:00:00.123456+02:00",
"level": "info",
"service": "api",
"event": "event_name",
"key1": "value1",
"key2": 123
}import structlog
logger = structlog.get_logger()
# Info log with context
logger.info("creating_project", project_id="proj_123", user_id=456)
# Warning log
logger.warning("rate_limit_approaching", current=90, limit=100)
# Error log with exception
try:
do_something()
except Exception as e:
logger.error("operation_failed",
error=str(e),
error_type=type(e).__name__,
exc_info=True) # Includes stack traceUse contextvars to bind context that persists across function calls:
import structlog
# Bind context for all subsequent logs in this request
structlog.contextvars.bind_contextvars(
correlation_id="msg_123_1735167345",
user_id=123
)
# All logs now include correlation_id and user_id
logger.info("step_one") # Has correlation_id, user_id
logger.info("step_two") # Has correlation_id, user_id
# Clear context when done
structlog.contextvars.unbind_contextvars("correlation_id", "user_id")Use the @log_node_execution decorator for automatic node tracking:
from nodes.base import log_node_execution
import structlog
logger = structlog.get_logger()
@log_node_execution("my_node")
async def my_node(state: dict) -> dict:
# Logs "node_start" automatically
logger.info("doing_work", item_count=len(items))
# Logs "node_complete" with duration on success
# Logs "node_failed" with error on exception
return {"key": "value"}| Event | Level | Description | Context Fields |
|---|---|---|---|
http_request |
info | HTTP request completed | method, path, status_code, duration_ms |
creating_project |
info | Project creation started | project_id, name |
project_updated |
info | Project updated | project_id, status |
project_patched |
info | Project patched | project_id, status |
project_creation_failed_duplicate |
warning | Duplicate project ID | project_id |
openrouter_fetching_models |
info | Fetching models from OpenRouter | — |
openrouter_models_cached |
info | Models cached | model_count |
openrouter_fetch_failed |
error | OpenRouter API failed | error, error_type |
| Event | Level | Description | Context Fields |
|---|---|---|---|
message_received |
info | Message from Telegram | chat_id, message_length |
node_start |
info | Node execution started | node |
node_complete |
info | Node execution completed | node, duration_ms, state_updates |
node_failed |
error | Node execution failed | node, duration_ms, error, error_type |
spawning_developer_worker |
info | Developer worker spawn | repo_name |
spawning_factory_worker |
info | Factory worker spawn | repo |
service_deployment_record_created |
info | Deployment recorded | service_name |
unknown_tool_called |
warning | Unknown tool requested | tool_name |
conversation_history_cleared |
info | Thread history cleared | — |
| Event | Level | Description | Context Fields |
|---|---|---|---|
password_reset_triggered |
info | Password reset started | server_handle, server_id |
password_reset_completed |
info | Password reset done | server_handle |
password_reset_timeout |
error | Password reset timeout | error |
os_reinstall_start |
info | OS reinstall started | server_handle, server_id |
reinstall_task_created |
info | Reinstall task queued | task_id |
os_reinstall_completed |
info | OS reinstall done | server_handle |
reinstall_timeout |
error | Reinstall timeout | error |
ssh_access_ok |
info | SSH connection success | server_handle |
ssh_access_failed |
info | SSH connection failed | server_handle |
service_redeployment_start |
info | Redeployment started | server_handle |
services_found_for_redeployment |
info | Services to redeploy | server_handle, count |
service_redeployed |
info | Service redeployed | service_name |
service_redeploy_failed |
error | Redeployment failed | service_name, error |
ansible_stderr |
warning | Ansible stderr output | output |
ansible_playbook_timeout |
error | Ansible timeout | playbook, timeout |
| Event | Level | Description | Context Fields |
|---|---|---|---|
scheduler_started |
info | Scheduler started | — |
scheduler_shutdown_requested |
info | Shutdown signal | — |
health_check_start |
info | Health check started | servers_count |
server_healthy |
debug | Server is healthy | server_handle |
incident_recovery_triggered |
info | Recovery triggered | server_handle |
github_sync_start |
info | GitHub sync started | org_name |
github_repos_fetched |
info | Repos fetched | org_name, repo_count |
server_sync_worker_started |
info | Server sync started | — |
server_reappeared |
info | Server back online | server_ip |
server_missing_from_time4vps |
warning | Server not in provider | server_ip |
server_details_sync_start |
info | Details sync started | — |
server_details_sync_complete |
info | Details sync done | updated_count |
server_pending_setup_trigger |
info | Setup triggered | server_handle |
| Event | Level | Description | Context Fields |
|---|---|---|---|
telegram_bot_starting |
info | Bot starting | — |
message_received |
info | Telegram message received | user_id, chat_id, correlation_id |
message_published |
info | Published to Redis | stream |
sending_message |
info | Sending response | chat_id, reply_to_message_id |
message_sent |
info | Response sent | chat_id |
invalid_outgoing_message |
warning | Invalid message format | payload |
| Event | Level | Description | Context Fields |
|---|---|---|---|
spawn_request_received |
info | Spawn request | request_id, repo, branch |
docker_container_creating |
info | Container creating | request_id, image |
docker_container_created |
info | Container created | request_id, container_id |
worker_execution_complete |
info | Worker finished | request_id, exit_code, duration_sec |
spawn_result_published |
info | Result published | request_id, channel |
# All logs in JSON
docker compose logs -f api | jq
# Filter by event
docker compose logs langgraph | jq 'select(.event=="node_start")'
# Filter by level
docker compose logs | jq 'select(.level=="error")'
# Filter by service
docker compose logs | jq 'select(.service=="scheduler")'
# Trace by correlation_id
docker compose logs | jq 'select(.correlation_id=="msg_123_1735167345")'
# Filter by node
docker compose logs langgraph | jq 'select(.node=="developer")'
# Find slow operations
docker compose logs | jq 'select(.duration_ms > 1000)'
# Errors with stack traces
docker compose logs | jq 'select(.level=="error") | {event, error, error_type}'# All logs from service
{service="langgraph"}
# Specific node
{service="langgraph"} | json | node="developer"
# Trace request
{job="docker"} | json | correlation_id="msg_123_1735167345"
# Errors in last hour
{job="docker"} | json | level="error"
# Slow operations (>1s)
{job="docker"} | json | duration_ms > 1000
# Count events by type
sum by (event) (count_over_time({service="api"} | json [1h]))
# Use snake_case event names
logger.info("user_created", user_id=123)
# Include relevant context
logger.info("deployment_complete",
server_handle="main-1",
duration_sec=45.2,
services_count=3)
# Log errors with full context
logger.error("api_call_failed",
url=url,
status_code=response.status_code,
error=response.text[:200],
exc_info=True)# Don't use f-strings for dynamic content
logger.info(f"Created user {user_id}") # BAD
# Don't log sensitive data
logger.info("login", password=password) # BAD
# Don't use generic event names
logger.info("done") # BAD
logger.info("error") # BAD- Check
LOG_LEVEL- set toDEBUGfor more output - Verify
setup_logging()is called before any logging
- Ensure
LOG_FORMAT=jsonis set - Check for print() statements mixed with logs
- Verify
bind_contextvars()is called before logging - Check that context is bound in the correct async context
- Avoid logging large objects (truncate if needed)
- Use
DEBUGlevel for high-frequency logs - Set
LOG_LEVEL=INFOin production