Files
orion/docs/architecture/metrics-provider-pattern.md
Samir Boulahtit 272b62fbd3 docs: update documentation for platform-aware storefront routing
Update 8 documentation files to reflect new URL scheme:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (root path = storefront)
- Rename DEFAULT_PLATFORM_CODE to MAIN_PLATFORM_CODE
- Replace hardcoded platform_id=1 with dynamic values
- Update route examples, middleware descriptions, code samples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:56:26 +01:00

480 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 from require_platform dependency │
│ platform = Depends(require_platform) # Raises 400 if missing │
│ platform_id = platform.id │
│ │
│ # Or from JWT for API routes │
│ platform_id = current_user.token_platform_id │
└─────────────────────────────────────────────────────────────────────┘
```
## 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),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
store_id = current_user.token_store_id
platform_id = platform.id
# 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