# 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 Store Dashboard) │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ StatsAggregatorService │ │ (app/modules/core/services/stats_aggregator.py) │ │ │ │ • Discovers MetricsProviders from all enabled modules │ │ • Calls get_store_metrics() or get_platform_metrics() │ │ • Returns categorized metrics dict │ └─────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────┼───────────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │tenancy_metrics│ │ order_metrics │ │catalog_metrics│ │ (enabled) │ │ (enabled) │ │ (disabled) │ └───────┬───────┘ └───────┬───────┘ └───────────────┘ │ │ │ ▼ ▼ × (skipped) ┌───────────────┐ ┌───────────────┐ │ store_count │ │ total_orders │ │ user_count │ │ total_revenue │ └───────────────┘ └───────────────┘ │ │ └───────────┬───────────┘ ▼ ┌─────────────────────────────────┐ │ Categorized Metrics │ │ {"tenancy": [...], "orders": [...]} │ └─────────────────────────────────┘ ``` ## Problem Solved Before this pattern, dashboard routes had **hard imports** from optional modules: ```python # 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_store_stats(db, store_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: ```python # 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): ```python @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: ```python 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_store_metrics( self, db: Session, store_id: int, context: MetricsContext | None = None, ) -> list[MetricValue]: """Get metrics for a specific store (store 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: ```python # app/modules/core/services/stats_aggregator.py class StatsAggregatorService: def get_store_dashboard_stats( self, db: Session, store_id: int, platform_id: int, context: MetricsContext | None = None, ) -> dict[str, list[MetricValue]]: """Get all metrics for a store, grouped by category.""" providers = self._get_enabled_providers(db, platform_id) return { p.metrics_category: p.get_store_metrics(db, store_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 ```python # 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_store_metrics( self, db: Session, store_id: int, context: MetricsContext | None = None, ) -> list[MetricValue]: """Get order metrics for a specific store.""" from app.modules.orders.models import Order try: total_orders = ( db.query(Order) .filter(Order.store_id == store_id) .count() ) total_revenue = ( db.query(func.sum(Order.total_amount)) .filter(Order.store_id == store_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 store 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 StorePlatform try: # IMPORTANT: Use StorePlatform junction table for multi-platform support store_ids = ( db.query(StorePlatform.store_id) .filter( StorePlatform.platform_id == platform_id, StorePlatform.is_active == True, ) .subquery() ) total_orders = ( db.query(Order) .filter(Order.store_id.in_(store_ids)) .count() ) return [ MetricValue( key="orders.total", value=total_orders, label="Total Orders", category="orders", icon="shopping-cart", description="Total orders across all stores", ), ] 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 ```python # 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 ### StorePlatform Junction Table Stores can belong to multiple platforms. When querying platform-level metrics, **always use the StorePlatform junction table**: ```python # CORRECT: Using StorePlatform junction table from app.modules.tenancy.models import StorePlatform store_ids = ( db.query(StorePlatform.store_id) .filter( StorePlatform.platform_id == platform_id, StorePlatform.is_active == True, ) .subquery() ) total_orders = ( db.query(Order) .filter(Order.store_id.in_(store_ids)) .count() ) # WRONG: Store.platform_id does not exist! # store_ids = db.query(Store.id).filter(Store.platform_id == platform_id) ``` ### Platform Context Flow Platform context flows through middleware and JWT tokens: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Request Flow │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ PlatformContextMiddleware │ │ Sets: request.state.platform (Platform object) │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ StoreContextMiddleware │ │ Sets: request.state.store (Store 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` | store 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 ### Store Dashboard ```python # app/modules/core/routes/api/store_dashboard.py @router.get("/stats", response_model=StoreDashboardStatsResponse) def get_store_dashboard_stats( request: Request, current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): store_id = current_user.token_store_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_store_dashboard_stats( db=db, store_id=store_id, platform_id=platform_id, ) # Extract and return formatted response ... ``` ### Admin Dashboard ```python # 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: ```python try: metrics = provider.get_store_metrics(db, store_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 `StorePlatform` junction 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 `Store.platform_id` exists (it doesn't!) - Let exceptions propagate from metric providers - Create hard dependencies between core and optional modules ## Related Documentation - [Module System Architecture](module-system.md) - Module structure and auto-discovery - [Multi-Tenant Architecture](multi-tenant.md) - Platform/store/merchant hierarchy - [Middleware](middleware.md) - Request context flow - [User Context Pattern](user-context-pattern.md) - JWT token context