Performance Monitoring
The PerformanceMonitor port, in-memory adapter, and resilient production adapters.
Related: 14-observability/02-metrics-patterns.md · 14-observability/01-logging-standards.md
Framework implementation: For FastAPI see 18-framework-implementations/fastapi/11-monitoring-and-performance.md.
Why an Abstract Interface
Monitoring backends change — CloudWatch today, Datadog or Prometheus tomorrow. Application code that calls a specific SDK directly cannot be tested without credentials and cannot be swapped without touching every call site.
The PerformanceMonitor port decouples metric recording from the monitoring backend. Application and middleware code records metrics through the interface; the concrete adapter handles delivery to whatever backend is configured.
The PerformanceMonitor Port
from abc import ABC, abstractmethod
class PerformanceMonitor(ABC):
@abstractmethod
async def record_request(
self,
endpoint: str,
method: str,
status_code: int,
duration_ms: float,
) -> None:
"""Record a completed HTTP request with its outcome and duration."""
@abstractmethod
async def record_error(
self,
endpoint: str,
error_type: str,
) -> None:
"""Record an application error at a given endpoint."""
async def startup(self) -> None:
"""Called at application startup. Override to initialise connections."""
async def shutdown(self) -> None:
"""Called at application shutdown. Override to flush buffered metrics."""The interface lives in the application layer (application/monitoring/performance.py). Infrastructure adapters implement it. Application and middleware code never imports a concrete adapter.
Adapters
| Adapter | Purpose |
|---|---|
| In-memory | Tests — collects calls in lists, zero external dependencies |
| CloudWatch | Production on AWS — batches metrics, circuit breaker included |
| No-op | Production services that don't require metrics yet |
The adapter is selected by settings.performance_monitor_type and wired at startup via a factory.
In-Memory Adapter (Testing)
The in-memory adapter is used in all tests. It satisfies the interface without network calls:
class InMemoryPerformanceMonitor(PerformanceMonitor):
def __init__(self):
self.requests: list[dict] = []
self.errors: list[dict] = []
async def record_request(self, endpoint, method, status_code, duration_ms):
self.requests.append({
"endpoint": endpoint, "method": method,
"status_code": status_code, "duration_ms": duration_ms,
})
async def record_error(self, endpoint, error_type):
self.errors.append({"endpoint": endpoint, "error_type": error_type})Tests assert on monitor.requests and monitor.errors directly — no mocking needed.
Where Metrics Are Recorded
Metrics are recorded in middleware, not in route handlers or application code. Middleware intercepts every request, measures wall-clock duration, and calls monitor.record_request() after the response is produced.
This keeps application handlers free of observability concerns. They perform their domain operation; middleware wraps and measures it.
Resilience Considerations
Production adapters that make external network calls must not allow monitoring failures to affect the service:
- Circuit breaker: Stop attempting delivery after repeated failures; reset after a cooldown period. Monitoring is non-critical — a broken metric pipeline must never cascade.
- Batching: Buffer metrics and flush in batches to reduce API call volume and cost.
- Graceful shutdown: Flush buffered metrics on shutdown to avoid data loss.
Application code never handles monitoring failures — the adapter absorbs them silently (and may log a warning).
Rules
PerformanceMonitoris defined in the application layer — never in infrastructure.- Metrics are recorded in middleware — application handlers do not call the monitor.
- The in-memory adapter is used in tests — never mock the interface.
- Production adapters must not propagate exceptions to callers.
- Startup/shutdown hooks initialise and flush adapters with external connections.
- The monitoring adapter failing must not fail the request — observability is non-critical infrastructure.
Framework implementations
Abstract reference: 14-observability/
Monitoring uses the same adapter pattern as events and caching. The application layer defines an abstract interface. Infrastructure provides in-memory (development/testing) and CloudWatch (production) adapters.
Interface (application/monitoring/performance.py)
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager
from typing import AsyncIterator
class PerformanceMonitor(ABC):
@asynccontextmanager
@abstractmethod
async def time_operation(
self, operation_name: str, context: dict | None = None
) -> AsyncIterator[None]: ...
@abstractmethod
async def record_metric(
self, metric_name: str, value: float, unit: str = "None",
context: dict | None = None
) -> None: ...
@abstractmethod
async def record_counter(
self, counter_name: str, increment: int = 1,
context: dict | None = None
) -> None: ...
async def start(self) -> None:
pass
async def shutdown(self) -> None:
passAdapters
In-Memory (infrastructure/monitoring/adapters/inmemory.py)
import time
from contextlib import asynccontextmanager
from application.monitoring.performance import PerformanceMonitor
class InMemoryPerformanceMonitor(PerformanceMonitor):
def __init__(self):
self.metrics: list[dict] = []
self.counters: dict[str, int] = {}
@asynccontextmanager
async def time_operation(self, operation_name: str, context=None):
start = time.perf_counter()
try:
yield
finally:
duration_ms = (time.perf_counter() - start) * 1000
self.metrics.append({
"name": operation_name,
"value": duration_ms,
"unit": "Milliseconds",
"context": context or {},
})
async def record_metric(self, metric_name, value, unit="None", context=None):
self.metrics.append({"name": metric_name, "value": value, "unit": unit})
async def record_counter(self, counter_name, increment=1, context=None):
self.counters[counter_name] = self.counters.get(counter_name, 0) + incrementCloudWatch (infrastructure/monitoring/adapters/cloudwatch.py)
Key features:
- Batches metrics (default 20) before flushing to CloudWatch to reduce API calls
- Background flush task runs every 60 seconds
- Circuit breaker disables CloudWatch on credential or persistent network errors
import asyncio
import time
import logging
import boto3
from contextlib import asynccontextmanager
from application.monitoring.performance import PerformanceMonitor
logger = logging.getLogger(__name__)
class CloudWatchPerformanceMonitor(PerformanceMonitor):
def __init__(
self,
namespace: str,
region: str,
batch_size: int = 20,
flush_interval: float = 60.0,
):
self._namespace = namespace
self._client = boto3.client("cloudwatch", region_name=region)
self._batch_size = batch_size
self._flush_interval = flush_interval
self._buffer: list[dict] = []
self._circuit_open = False
self._circuit_reset_at: float = 0.0
self._flush_task: asyncio.Task | None = None
@asynccontextmanager
async def time_operation(self, operation_name, context=None):
start = time.perf_counter()
try:
yield
finally:
duration_ms = (time.perf_counter() - start) * 1000
await self.record_metric(operation_name, duration_ms, "Milliseconds", context)
async def record_metric(self, metric_name, value, unit="None", context=None):
if self._circuit_open and time.time() < self._circuit_reset_at:
return
self._circuit_open = False
dimensions = [{"Name": k, "Value": str(v)} for k, v in (context or {}).items()]
self._buffer.append({
"MetricName": metric_name,
"Value": value,
"Unit": unit,
"Dimensions": dimensions,
})
if len(self._buffer) >= self._batch_size:
await self._flush()
async def record_counter(self, counter_name, increment=1, context=None):
await self.record_metric(counter_name, float(increment), "Count", context)
async def _flush(self) -> None:
if not self._buffer:
return
batch = self._buffer[:self._batch_size]
self._buffer = self._buffer[self._batch_size:]
try:
await asyncio.to_thread(
self._client.put_metric_data,
Namespace=self._namespace,
MetricData=batch,
)
except self._client.exceptions.NoCredentialsError:
logger.warning("cloudwatch.no_credentials — disabling for 5 minutes")
self._circuit_open = True
self._circuit_reset_at = time.time() + 300
except Exception as exc:
logger.warning("cloudwatch.flush_failed", extra={"error": str(exc)})
async def start(self) -> None:
self._flush_task = asyncio.create_task(self._periodic_flush())
async def shutdown(self) -> None:
if self._flush_task:
self._flush_task.cancel()
await self._flush() # Final flush on shutdown
async def _periodic_flush(self) -> None:
while True:
await asyncio.sleep(self._flush_interval)
await self._flush()Factory (infrastructure/monitoring/factories.py)
from config.settings import Settings
from application.monitoring.performance import PerformanceMonitor
from .adapters.inmemory import InMemoryPerformanceMonitor
from .adapters.cloudwatch import CloudWatchPerformanceMonitor
def build_performance_monitor(settings: Settings) -> PerformanceMonitor:
match settings.monitoring_adapter:
case "inmemory":
return InMemoryPerformanceMonitor()
case "cloudwatch":
return CloudWatchPerformanceMonitor(
namespace=settings.monitoring_namespace,
region=settings.aws_region,
batch_size=settings.monitoring_batch_size,
flush_interval=settings.monitoring_flush_interval,
)
case _:
raise ValueError(f"Unknown monitoring adapter: {settings.monitoring_adapter}")Usage in Application / Infrastructure
# In a repository (timing database operations):
class SQLAlchemyAccountRepository(AccountRepository):
def __init__(self, monitor: PerformanceMonitor):
self._monitor = monitor
async def find_by_id(self, session, account_id):
async with self._monitor.time_operation(
"db.account.find_by_id",
context={"account_id": str(account_id)},
):
result = await session.execute(...)
return account_to_domain(result.scalar_one_or_none())
# In an application handler (timing a full use case):
async def execute(self, command):
async with self._monitor.time_operation("handler.create_account"):
async with self._uow as session:
...
await self._monitor.record_counter("accounts.created")Lifecycle
Register start() and shutdown() in api/lifespan.py:
@asynccontextmanager
async def lifespan(app):
monitor = get_performance_monitor()
await monitor.start() # Starts background flush task (CloudWatch)
yield
await monitor.shutdown() # Final flush, cancel background taskRules
- Use
time_operationfor latency measurements — it's always a context manager, never manualstart/stop. - Metric names are dot-separated:
db.account.find_by_id,handler.create_account,messages.received. - The circuit breaker in the CloudWatch adapter prevents cascading failures when AWS credentials are missing or the service is unavailable.
- The in-memory adapter's
metricsandcountersare inspectable in tests — assert on them to verify monitoring calls.