This commit introduces a protocol-based metrics architecture that allows each module to provide its own statistics for dashboards without creating cross-module dependencies. Key changes: - Add MetricsProviderProtocol and MetricValue dataclass in contracts module - Add StatsAggregatorService in core module that discovers and aggregates metrics from all enabled modules - Implement metrics providers for all modules: - tenancy: vendor/user counts, team members, domains - customers: customer counts - cms: pages, media files - catalog: products - inventory: stock levels - orders: order counts, revenue - marketplace: import jobs, staging products - Update dashboard routes to use StatsAggregator instead of direct imports - Fix VendorPlatform junction table usage (Vendor.platform_id doesn't exist) - Add comprehensive documentation for the pattern This architecture ensures: - Dashboards always work (aggregator in core) - Each module owns its metrics (no cross-module coupling) - Optional modules are truly optional (can be removed without breaking app) - Multi-platform vendors are properly supported via VendorPlatform table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
Metrics Provider Pattern
The metrics provider pattern enables modules to provide their own statistics for dashboards without creating cross-module dependencies. This is a key architectural pattern that ensures the platform remains modular and extensible.
Overview
┌─────────────────────────────────────────────────────────────────────┐
│ Dashboard Request │
│ (Admin Dashboard or Vendor Dashboard) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ StatsAggregatorService │
│ (app/modules/core/services/stats_aggregator.py) │
│ │
│ • Discovers MetricsProviders from all enabled modules │
│ • Calls get_vendor_metrics() or get_platform_metrics() │
│ • Returns categorized metrics dict │
└─────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│tenancy_metrics│ │ order_metrics │ │catalog_metrics│
│ (enabled) │ │ (enabled) │ │ (disabled) │
└───────┬───────┘ └───────┬───────┘ └───────────────┘
│ │ │
▼ ▼ × (skipped)
┌───────────────┐ ┌───────────────┐
│ vendor_count │ │ total_orders │
│ user_count │ │ total_revenue │
└───────────────┘ └───────────────┘
│ │
└───────────┬───────────┘
▼
┌─────────────────────────────────┐
│ Categorized Metrics │
│ {"tenancy": [...], "orders": [...]} │
└─────────────────────────────────┘
Problem Solved
Before this pattern, dashboard routes had hard imports from optional modules:
# BAD: Core module with hard dependency on optional module
from app.modules.analytics.services import stats_service # What if disabled?
from app.modules.marketplace.models import MarketplaceImportJob # What if disabled?
stats = stats_service.get_vendor_stats(db, vendor_id) # App crashes!
This violated the architecture rule: Core modules cannot depend on optional modules.
Solution: Protocol-Based Metrics
Each module implements the MetricsProviderProtocol and registers it in its definition.py. The StatsAggregatorService in core discovers and aggregates metrics from all enabled modules.
Key Components
1. MetricValue Dataclass
Standard structure for metric values:
# app/modules/contracts/metrics.py
from dataclasses import dataclass
@dataclass
class MetricValue:
key: str # Unique identifier (e.g., "orders.total")
value: int | float | str # The metric value
label: str # Human-readable label
category: str # Grouping category (module name)
icon: str | None = None # Optional UI icon
description: str | None = None # Tooltip description
unit: str | None = None # Unit (%, EUR, items)
trend: str | None = None # "up", "down", "stable"
trend_value: float | None = None # Percentage change
2. MetricsContext Dataclass
Context for metric queries (date ranges, options):
@dataclass
class MetricsContext:
date_from: datetime | None = None
date_to: datetime | None = None
include_trends: bool = False
period: str = "30d" # Default period for calculations
3. MetricsProviderProtocol
Protocol that modules implement:
from typing import Protocol, runtime_checkable
@runtime_checkable
class MetricsProviderProtocol(Protocol):
@property
def metrics_category(self) -> str:
"""Category name for this provider's metrics (e.g., 'orders')."""
...
def get_vendor_metrics(
self,
db: Session,
vendor_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""Get metrics for a specific vendor (vendor dashboard)."""
...
def get_platform_metrics(
self,
db: Session,
platform_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""Get metrics aggregated for a platform (admin dashboard)."""
...
4. StatsAggregatorService
Central service in core that discovers and aggregates metrics:
# app/modules/core/services/stats_aggregator.py
class StatsAggregatorService:
def get_vendor_dashboard_stats(
self,
db: Session,
vendor_id: int,
platform_id: int,
context: MetricsContext | None = None,
) -> dict[str, list[MetricValue]]:
"""Get all metrics for a vendor, grouped by category."""
providers = self._get_enabled_providers(db, platform_id)
return {
p.metrics_category: p.get_vendor_metrics(db, vendor_id, context)
for p in providers
}
def get_admin_dashboard_stats(
self,
db: Session,
platform_id: int,
context: MetricsContext | None = None,
) -> dict[str, list[MetricValue]]:
"""Get all metrics for admin dashboard, grouped by category."""
providers = self._get_enabled_providers(db, platform_id)
return {
p.metrics_category: p.get_platform_metrics(db, platform_id, context)
for p in providers
}
Implementing a Metrics Provider
Step 1: Create the Metrics Provider Class
# app/modules/orders/services/order_metrics.py
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.contracts.metrics import (
MetricValue,
MetricsContext,
MetricsProviderProtocol,
)
logger = logging.getLogger(__name__)
class OrderMetricsProvider:
"""Metrics provider for orders module."""
@property
def metrics_category(self) -> str:
return "orders"
def get_vendor_metrics(
self,
db: Session,
vendor_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""Get order metrics for a specific vendor."""
from app.modules.orders.models import Order
try:
total_orders = (
db.query(Order)
.filter(Order.vendor_id == vendor_id)
.count()
)
total_revenue = (
db.query(func.sum(Order.total_amount))
.filter(Order.vendor_id == vendor_id)
.scalar() or 0
)
return [
MetricValue(
key="orders.total",
value=total_orders,
label="Total Orders",
category="orders",
icon="shopping-cart",
description="Total orders received",
),
MetricValue(
key="orders.total_revenue",
value=float(total_revenue),
label="Total Revenue",
category="orders",
icon="currency-euro",
unit="EUR",
description="Total revenue from orders",
),
]
except Exception as e:
logger.warning(f"Failed to get order vendor metrics: {e}")
return []
def get_platform_metrics(
self,
db: Session,
platform_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""Get order metrics aggregated for a platform."""
from app.modules.orders.models import Order
from app.modules.tenancy.models import VendorPlatform
try:
# IMPORTANT: Use VendorPlatform junction table for multi-platform support
vendor_ids = (
db.query(VendorPlatform.vendor_id)
.filter(
VendorPlatform.platform_id == platform_id,
VendorPlatform.is_active == True,
)
.subquery()
)
total_orders = (
db.query(Order)
.filter(Order.vendor_id.in_(vendor_ids))
.count()
)
return [
MetricValue(
key="orders.total",
value=total_orders,
label="Total Orders",
category="orders",
icon="shopping-cart",
description="Total orders across all vendors",
),
]
except Exception as e:
logger.warning(f"Failed to get order platform metrics: {e}")
return []
# Singleton instance
order_metrics_provider = OrderMetricsProvider()
Step 2: Register in Module Definition
# app/modules/orders/definition.py
from app.modules.base import ModuleDefinition
def _get_metrics_provider():
"""Lazy import to avoid circular imports."""
from app.modules.orders.services.order_metrics import order_metrics_provider
return order_metrics_provider
orders_module = ModuleDefinition(
code="orders",
name="Order Management",
# ... other config ...
# Register the metrics provider
metrics_provider=_get_metrics_provider,
)
Step 3: Metrics Appear Automatically
When the module is enabled, its metrics automatically appear in dashboards.
Multi-Platform Architecture
VendorPlatform Junction Table
Vendors can belong to multiple platforms. When querying platform-level metrics, always use the VendorPlatform junction table:
# CORRECT: Using VendorPlatform junction table
from app.modules.tenancy.models import VendorPlatform
vendor_ids = (
db.query(VendorPlatform.vendor_id)
.filter(
VendorPlatform.platform_id == platform_id,
VendorPlatform.is_active == True,
)
.subquery()
)
total_orders = (
db.query(Order)
.filter(Order.vendor_id.in_(vendor_ids))
.count()
)
# WRONG: Vendor.platform_id does not exist!
# vendor_ids = db.query(Vendor.id).filter(Vendor.platform_id == platform_id)
Platform Context Flow
Platform context flows through middleware and JWT tokens:
┌─────────────────────────────────────────────────────────────────────┐
│ Request Flow │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PlatformContextMiddleware │
│ Sets: request.state.platform (Platform object) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ VendorContextMiddleware │
│ Sets: request.state.vendor (Vendor object) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler (Dashboard) │
│ │
│ # Get platform_id from middleware or JWT token │
│ platform = getattr(request.state, "platform", None) │
│ platform_id = platform.id if platform else 1 │
│ │
│ # Or from JWT for API routes │
│ platform_id = current_user.token_platform_id or 1 │
└─────────────────────────────────────────────────────────────────────┘
Available Metrics Providers
| Module | Category | Metrics Provided |
|---|---|---|
| tenancy | tenancy |
vendor counts, user counts, team members, domains |
| customers | customers |
customer counts, new customers |
| cms | cms |
pages, media files, themes |
| catalog | catalog |
products, active products, featured |
| inventory | inventory |
stock levels, low stock, out of stock |
| orders | orders |
order counts, revenue, average order value |
| marketplace | marketplace |
import jobs, staging products, success rate |
Dashboard Routes
Vendor Dashboard
# app/modules/core/routes/api/vendor_dashboard.py
@router.get("/stats", response_model=VendorDashboardStatsResponse)
def get_vendor_dashboard_stats(
request: Request,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
vendor_id = current_user.token_vendor_id
# Get platform from middleware
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
# Get aggregated metrics from all enabled modules
metrics = stats_aggregator.get_vendor_dashboard_stats(
db=db,
vendor_id=vendor_id,
platform_id=platform_id,
)
# Extract and return formatted response
...
Admin Dashboard
# app/modules/core/routes/api/admin_dashboard.py
@router.get("/stats", response_model=StatsResponse)
def get_comprehensive_stats(
request: Request,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
# Get platform_id with fallback logic
platform_id = _get_platform_id(request, current_admin)
# Get aggregated metrics from all enabled modules
metrics = stats_aggregator.get_admin_dashboard_stats(
db=db,
platform_id=platform_id,
)
# Extract and return formatted response
...
Benefits
| Aspect | Before | After |
|---|---|---|
| Core depends on optional | Hard import (crashes) | Protocol-based (graceful) |
| Adding new metrics | Edit analytics module | Just add provider to your module |
| Module isolation | Coupled | Truly independent |
| Testing | Hard (need all modules) | Easy (mock protocol) |
| Disable module | App crashes | Dashboard shows partial data |
Error Handling
Metrics providers are wrapped in try/except to prevent one failing module from breaking the entire dashboard:
try:
metrics = provider.get_vendor_metrics(db, vendor_id, context)
except Exception as e:
logger.warning(f"Failed to get {provider.metrics_category} metrics: {e}")
metrics = [] # Continue with empty metrics for this module
Best Practices
Do
- Use lazy imports inside metric methods to avoid circular imports
- Always use
VendorPlatformjunction table for platform-level queries - Return empty list on error, don't raise exceptions
- Log warnings for debugging but don't crash
- Include helpful descriptions and icons for UI
Don't
- Import from optional modules at the top of core module files
- Assume
Vendor.platform_idexists (it doesn't!) - Let exceptions propagate from metric providers
- Create hard dependencies between core and optional modules
Related Documentation
- Module System Architecture - Module structure and auto-discovery
- Multi-Tenant Architecture - Platform/vendor/company hierarchy
- Middleware - Request context flow
- User Context Pattern - JWT token context