# 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
