feat: initial FastAPI boilerplate setup
Set up Webber - multi-agent AI development system with: - Domain-based project structure (src/domains/, src/shared/) - BaseController pattern with lazy router instantiation - Pydantic Settings configuration with env file support - Logger decorator with temporal benchmarking and trace IDs - UserProvider singleton for request-scoped context - Custom exception hierarchy - Health endpoints (/, /health) Dependencies (CVE checked 2026-01-09): - FastAPI 0.128.0, Starlette 0.50.0, Uvicorn 0.40.0 - Pydantic 2.12.4, PydanticAI 1.40.0 - All packages at latest safe versions Placeholder domains for future implementation: - agents/ (explore, plan, task) - tools/ (file, shell, search) - auth/ (tatlock integration) Port: 8086 (per CONTAINERS.md allocation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Authentication utilities.
|
||||
|
||||
Provides API key validation and integration with external auth services.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.context import User, user_provider
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# API key header scheme
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def validate_api_key(api_key: str) -> Optional[User]:
|
||||
"""
|
||||
Validate API key and return User if valid.
|
||||
|
||||
TODO: Integrate with tatlock-ui/core-api for real validation.
|
||||
Currently accepts any non-empty key for development.
|
||||
"""
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Development mode: accept any key
|
||||
if settings.debug:
|
||||
logger.debug(f"Debug mode: accepting API key {api_key[:8]}...")
|
||||
return User(
|
||||
id="dev-user",
|
||||
email="dev@localhost",
|
||||
api_key=api_key,
|
||||
tenant_id="dev-tenant",
|
||||
roles=["admin"],
|
||||
)
|
||||
|
||||
# TODO: Production mode - validate against tatlock API
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# response = await client.get(
|
||||
# f"{settings.tatlock_api_url}/auth/validate",
|
||||
# headers={"X-API-Key": api_key}
|
||||
# )
|
||||
# if response.status_code == 200:
|
||||
# data = response.json()
|
||||
# return User(**data)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_api_key(
|
||||
api_key: Optional[str] = Depends(api_key_header),
|
||||
) -> Optional[str]:
|
||||
"""FastAPI dependency to extract API key from header."""
|
||||
return api_key
|
||||
|
||||
|
||||
async def get_current_user_dep(
|
||||
api_key: Optional[str] = Depends(get_api_key),
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
FastAPI dependency to get current user from API key.
|
||||
|
||||
Returns None if no valid API key provided.
|
||||
"""
|
||||
if not api_key:
|
||||
return None
|
||||
return await validate_api_key(api_key)
|
||||
|
||||
|
||||
async def require_auth(
|
||||
user: Optional[User] = Depends(get_current_user_dep),
|
||||
) -> User:
|
||||
"""
|
||||
FastAPI dependency that requires authentication.
|
||||
|
||||
Raises 401 if no valid API key provided.
|
||||
"""
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or missing API key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(
|
||||
user: User = Depends(require_auth),
|
||||
) -> User:
|
||||
"""
|
||||
FastAPI dependency that requires admin role.
|
||||
|
||||
Raises 403 if user is not admin.
|
||||
"""
|
||||
if "admin" not in user.roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Base classes for controllers and schemas.
|
||||
|
||||
All domain controllers and Pydantic models should inherit from these.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseController(ABC):
|
||||
"""
|
||||
Base controller with lazy router instantiation.
|
||||
|
||||
All domain controllers inherit from this and implement create_router().
|
||||
|
||||
Usage:
|
||||
class UsersController(BaseController):
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/users", tags=["Users"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.get("/")
|
||||
async def list_users():
|
||||
return []
|
||||
|
||||
return router
|
||||
|
||||
users_controller = UsersController()
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str, tags: list[str]):
|
||||
self.prefix = prefix
|
||||
self.tags = tags
|
||||
self._router = None
|
||||
|
||||
@abstractmethod
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the FastAPI router with all routes."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def router(self) -> APIRouter:
|
||||
"""Lazy router instantiation."""
|
||||
if self._router is None:
|
||||
self._router = self.create_router()
|
||||
return self._router
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""
|
||||
Base Pydantic model with standardized configuration.
|
||||
|
||||
All domain schemas should inherit from this.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
strict=False,
|
||||
populate_by_name=True,
|
||||
use_enum_values=True,
|
||||
validate_assignment=True,
|
||||
json_encoders={
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
)
|
||||
|
||||
def dict_without_none(self) -> dict[str, Any]:
|
||||
"""Return model as dict, excluding None values."""
|
||||
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Application configuration via Pydantic Settings.
|
||||
|
||||
All settings loaded from environment variables or .env file.
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Load version from pyproject.toml."""
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
__version__ = _get_version()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
# Application
|
||||
app_name: str = "Webber"
|
||||
app_version: str = __version__
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8086
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
||||
ollama_url: str = "http://192.168.86.149:11434"
|
||||
ollama_agent_model: str = "mistral-nemo-large:latest"
|
||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||
|
||||
# Auth - Tatlock integration
|
||||
tatlock_api_url: Optional[str] = "http://192.168.86.149:8000"
|
||||
internal_api_key: Optional[str] = None
|
||||
|
||||
# Tool execution
|
||||
tool_timeout_seconds: int = 120
|
||||
sandbox_enabled: bool = True
|
||||
allowed_paths: list[str] = []
|
||||
|
||||
# Sessions
|
||||
session_ttl_hours: int = 24
|
||||
max_context_tokens: int = 128000
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Request context management.
|
||||
|
||||
Provides UserProvider singleton for request-scoped user context.
|
||||
Set once per request in middleware, accessible everywhere without
|
||||
passing user through function parameters.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Authenticated user context."""
|
||||
id: str
|
||||
email: str
|
||||
api_key: str
|
||||
tenant_id: Optional[str] = None
|
||||
roles: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
|
||||
|
||||
|
||||
class UserProvider:
|
||||
"""
|
||||
Singleton for request-scoped user context.
|
||||
|
||||
Set once per request in middleware, accessible everywhere without
|
||||
passing user through function parameters.
|
||||
|
||||
Usage in middleware:
|
||||
user = await validate_api_key(request)
|
||||
user_provider.set_user(user)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
|
||||
Usage anywhere:
|
||||
from src.shared.context import get_current_user, require_user
|
||||
|
||||
user = get_current_user() # Returns None if not authenticated
|
||||
user = require_user() # Raises if not authenticated
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def set_user(self, user: User) -> None:
|
||||
"""Set current user for this request context."""
|
||||
_current_user.set(user)
|
||||
|
||||
def get_user(self) -> Optional[User]:
|
||||
"""Get current user (may be None)."""
|
||||
return _current_user.get()
|
||||
|
||||
def clear_user(self) -> None:
|
||||
"""Clear current user (call in finally block)."""
|
||||
_current_user.set(None)
|
||||
|
||||
@property
|
||||
def current_user(self) -> Optional[User]:
|
||||
"""Property access to current user."""
|
||||
return self.get_user()
|
||||
|
||||
|
||||
# Global singleton
|
||||
user_provider = UserProvider()
|
||||
|
||||
|
||||
def get_current_user() -> Optional[User]:
|
||||
"""Get current user or None."""
|
||||
return user_provider.get_user()
|
||||
|
||||
|
||||
def require_user() -> User:
|
||||
"""Get current user or raise ValueError."""
|
||||
user = user_provider.get_user()
|
||||
if user is None:
|
||||
raise ValueError("No authenticated user in context")
|
||||
return user
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Custom exception hierarchy.
|
||||
|
||||
All application exceptions inherit from AppException.
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""Base exception for application errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to JSON-serializable dict."""
|
||||
return {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
"""Resource not found."""
|
||||
|
||||
def __init__(self, resource: str, identifier: Any):
|
||||
super().__init__(
|
||||
message=f"{resource} not found: {identifier}",
|
||||
status_code=404,
|
||||
details={"resource": resource, "identifier": str(identifier)},
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(AppException):
|
||||
"""Input validation failed."""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=422,
|
||||
details={"field": field} if field else {},
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationError(AppException):
|
||||
"""Authentication required or failed."""
|
||||
|
||||
def __init__(self, message: str = "Authentication required"):
|
||||
super().__init__(message=message, status_code=401)
|
||||
|
||||
|
||||
class AuthorizationError(AppException):
|
||||
"""Permission denied."""
|
||||
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(message=message, status_code=403)
|
||||
|
||||
|
||||
class ConflictError(AppException):
|
||||
"""Resource conflict (e.g., duplicate)."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message=message, status_code=409)
|
||||
|
||||
|
||||
class RateLimitError(AppException):
|
||||
"""Rate limit exceeded."""
|
||||
|
||||
def __init__(self, retry_after: int = 60):
|
||||
super().__init__(
|
||||
message="Rate limit exceeded",
|
||||
status_code=429,
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
|
||||
|
||||
class ServiceUnavailableError(AppException):
|
||||
"""External service unavailable."""
|
||||
|
||||
def __init__(self, service: str, message: Optional[str] = None):
|
||||
super().__init__(
|
||||
message=message or f"Service unavailable: {service}",
|
||||
status_code=503,
|
||||
details={"service": service},
|
||||
)
|
||||
|
||||
|
||||
class TimeoutError(AppException):
|
||||
"""Operation timed out."""
|
||||
|
||||
def __init__(self, operation: str, timeout_seconds: float):
|
||||
super().__init__(
|
||||
message=f"Operation timed out: {operation}",
|
||||
status_code=504,
|
||||
details={"operation": operation, "timeout_seconds": timeout_seconds},
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
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 functools
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
# === Trace Context ===
|
||||
|
||||
@dataclass
|
||||
class TraceSpan:
|
||||
"""Represents a timed execution span."""
|
||||
name: str
|
||||
trace_id: str
|
||||
parent_id: Optional[str] = None
|
||||
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
|
||||
start_time: float = field(default_factory=time.perf_counter)
|
||||
end_time: Optional[float] = 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[Optional[TraceSpan]] = ContextVar('current_span', default=None)
|
||||
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
|
||||
|
||||
|
||||
def get_current_trace_id() -> Optional[str]:
|
||||
"""Get current trace ID for log correlation."""
|
||||
return _trace_id.get()
|
||||
|
||||
|
||||
def get_current_span() -> Optional[TraceSpan]:
|
||||
"""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,
|
||||
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):
|
||||
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):
|
||||
self.name = name
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.span: Optional[TraceSpan] = None
|
||||
self.token = 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)
|
||||
Reference in New Issue
Block a user