refactor: reorganize into monorepo with separate subprojects
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s

Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 10:37:47 +01:00
co-authored by Claude Opus 4.5
parent f4e8552298
commit 3b58fa4f8b
121 changed files with 2034 additions and 284 deletions
+238
View File
@@ -0,0 +1,238 @@
"""
Centralized logging with temporal benchmarking.
Provides:
- @logged() decorator for automatic function timing
- trace_span() context manager for manual instrumentation
- Trace ID correlation across nested calls
- Configurable slow/warn thresholds
"""
import asyncio
import functools
import logging
import sys
import time
from collections.abc import Callable
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from pathlib import Path
from uuid import uuid4
# === Trace Context ===
@dataclass
class TraceSpan:
"""Represents a timed execution span."""
name: str
trace_id: str
parent_id: str | None = None
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
start_time: float = field(default_factory=time.perf_counter)
end_time: float | None = None
@property
def duration_ms(self) -> float:
"""Get duration in milliseconds."""
end = self.end_time or time.perf_counter()
return (end - self.start_time) * 1000
_current_span: ContextVar[TraceSpan | None] = ContextVar('current_span', default=None)
_trace_id: ContextVar[str | None] = ContextVar('trace_id', default=None)
def get_current_trace_id() -> str | None:
"""Get current trace ID for log correlation."""
return _trace_id.get()
def get_current_span() -> TraceSpan | None:
"""Get current trace span."""
return _current_span.get()
# === Setup ===
def setup_logging(log_level: str = "INFO") -> None:
"""Configure application logging."""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(log_dir / "webber.log", encoding="utf-8")
]
)
# Quiet noisy libraries
for name in ["httpx", "httpcore", "uvicorn.access", "uvicorn.error"]:
logging.getLogger(name).setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance."""
return logging.getLogger(name)
# === Decorator ===
def logged(
logger: logging.Logger | None = None,
slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0,
include_args: bool = False,
):
"""
Decorator for automatic function logging with temporal benchmarking.
Args:
logger: Logger instance (defaults to module logger)
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
include_args: Include function arguments in log (careful with sensitive data)
Usage:
@logged()
async def my_function(): ...
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
def critical_path(): ...
"""
def decorator(func: Callable):
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__module__)
func_name = f"{func.__module__}.{func.__qualname__}"
def _create_span() -> TraceSpan:
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
return TraceSpan(
name=func_name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
def _log_completion(span: TraceSpan, error: Exception | None = None):
span.end_time = time.perf_counter()
duration = span.duration_ms
tid = span.trace_id[:8]
if error:
logger.error(
f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}",
exc_info=True
)
elif duration >= warn_threshold_ms:
logger.warning(
f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)"
)
elif duration >= slow_threshold_ms:
logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
else:
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
if include_args:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
else:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = await func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
if include_args:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
else:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
return decorator
# === Context Manager ===
class trace_span:
"""
Context manager for manual span creation.
Usage:
with trace_span("database_query"):
result = db.execute(query)
async with trace_span("llm_call"):
response = await agent.run(prompt)
"""
def __init__(self, name: str, logger: logging.Logger | None = None):
self.name = name
self.logger = logger or logging.getLogger(__name__)
self.span: TraceSpan | None = None
self.token: Token[TraceSpan | None] | None = None
def __enter__(self) -> TraceSpan:
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
self.span = TraceSpan(
name=self.name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
self.token = _current_span.set(self.span)
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
return self.span
def __exit__(self, exc_type, exc_val, exc_tb):
if self.span:
self.span.end_time = time.perf_counter()
duration = self.span.duration_ms
tid = self.span.trace_id[:8]
if exc_val:
self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
else:
self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
if self.token:
_current_span.reset(self.token)
return False
async def __aenter__(self) -> TraceSpan:
return self.__enter__()
async def __aexit__(self, exc_type, exc_val, exc_tb):
return self.__exit__(exc_type, exc_val, exc_tb)