- Add redis-exporter container to docker-compose (oliver006/redis_exporter, 32MB) - Add Redis scrape target to Prometheus config - Add 4 Redis alert rules: RedisDown, HighMemory, HighConnections, RejectedConnections - Document Step 19b (Sentry Error Tracking) in Hetzner deployment guide - Document Step 19c (Redis Monitoring) in Hetzner deployment guide - Update resource budget and port reference tables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
63 KiB
Analytics Dependency Status: ❌ NOT FIXED ─
The MetricsProvider pattern exists in contracts/metrics.py, but admin_stores.py still has hard imports:
File: app/modules/tenancy/routes/api/admin_stores.py ┌──────┬────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐ │ Line │ Import │ Used In │ ├──────┼────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤ │ 20 │ from app.modules.analytics.services.stats_service import stats_service │ get_store_statistics_endpoint() (line 110) │ ├──────┼────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤ │ 23 │ from app.modules.analytics.schemas import StoreStatsResponse │ Return type (line 104) │ └──────┴────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────┘
All Core → Optional Dependencies
- tenancy → marketplace
File: app/modules/tenancy/models/init.py:22 from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob
- Purpose: SQLAlchemy needs this to resolve User.marketplace_import_jobs and Store.marketplace_import_jobs relationships
File: app/modules/tenancy/services/store_service.py ┌──────┬─────────────────────────────────────┬───────────────────────────────────────────────────────────────────────┐ │ Line │ Import │ Functions Using It │ ├──────┼─────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤ │ 19 │ MarketplaceProductNotFoundException │ add_product_to_catalog() :463, _get_product_by_id_or_raise() :571 │ ├──────┼─────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤ │ 26 │ MarketplaceProduct model │ _get_product_by_id_or_raise() :565-566, add_product_to_catalog() :466 │ └──────┴─────────────────────────────────────┴───────────────────────────────────────────────────────────────────────┘
- tenancy → catalog
File: app/modules/tenancy/services/store_service.py ┌──────┬───────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐ │ Line │ Import │ Functions Using It │ ├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤ │ 18 │ ProductAlreadyExistsException │ add_product_to_catalog() :472 │ ├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤ │ 27 │ Product model │ add_product_to_catalog() :477, get_products() :533, _product_in_catalog() :579 │ ├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤ │ 30 │ ProductCreate schema │ add_product_to_catalog() parameter type :447 │ └──────┴───────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
- billing → catalog
File: app/modules/billing/services/subscription_service.py:48 ┌───────────────────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐ │ Function │ Lines │ What It Does │ ├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ get_usage() │ 371-376 │ db.query(func.count(Product.id)).filter(Product.store_id == store_id) │ ├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ get_usage_summary() │ 420-425 │ Same product count query │ ├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ can_add_product() │ 514-519 │ Same product count query │ ├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ check_product_limit() │ 532-537 │ Same product count query │ └───────────────────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘ File: app/modules/billing/services/admin_subscription_service.py:30 ┌───────────────────────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐ │ Function │ Lines │ What It Does │ ├───────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤ │ get_store_usage_counts() │ 212-217 │ db.query(func.count(Product.id)).filter(Product.store_id == store_id) │ └───────────────────────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘ File: app/modules/billing/services/capacity_forecast_service.py:19 ┌──────────────────────────┬──────┬───────────────────────────────────────────┐ │ Function │ Line │ What It Does │ ├──────────────────────────┼──────┼───────────────────────────────────────────┤ │ capture_daily_snapshot() │ 91 │ db.query(func.count(Product.id)).scalar() │ └──────────────────────────┴──────┴───────────────────────────────────────────┘
Summary: What to Move Where? ┌──────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────┐ │ Dependency │ Recommendation │ ├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ │ admin_stores.py → analytics │ Use the existing MetricsProvider pattern via stats_aggregator │ ├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ │ store_service.py → marketplace/catalog │ Move add_product_to_catalog() and get_products() to catalog module - these are product operations │ ├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ │ tenancy/models/init.py → marketplace │ Remove MarketplaceImportJob import, move relationships to marketplace module │ ├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ │ billing/* → catalog (Product count) │ Create ProductCountProvider protocol in contracts │ └──────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────┘ Would you like me to start working on any of these?
Modular Architecture Analysis
Executive Summary
This analysis answers two questions:
- Are all modules self-contained and respecting architecture rules?
- Would the app still run if non-core modules are removed?
Key Findings:
- ✅ Route registration is fully auto-discovered (no hardcoded module routes)
- ✅ Middleware has no module-specific dependencies
- ✅ API routers use dynamic discovery pattern
- ❌ CRITICAL: Core modules have hard dependencies on optional modules - the app would CRASH if optional modules are removed
Part 1: Architecture Rule Compliance
What's Working Well ┌─────────────────────────┬────────┬────────────────────────────────────────────────┐ │ Aspect │ Status │ Details │ ├─────────────────────────┼────────┼────────────────────────────────────────────────┤ │ Module auto-discovery │ ✅ │ All modules discovered via definition.py │ ├─────────────────────────┼────────┼────────────────────────────────────────────────┤ │ Route auto-registration │ ✅ │ Admin, store, storefront routes fully dynamic │ ├─────────────────────────┼────────┼────────────────────────────────────────────────┤ │ Middleware stack │ ✅ │ No hardcoded module dependencies │ ├─────────────────────────┼────────┼────────────────────────────────────────────────┤ │ Static file mounting │ ✅ │ Dynamic scanning of module directories │ ├─────────────────────────┼────────┼────────────────────────────────────────────────┤ │ Module enablement │ ✅ │ Controlled via PlatformModule.is_enabled │ └─────────────────────────┴────────┴────────────────────────────────────────────────┘ Previous Issues Fixed (This Session) ┌───────────────────────────────────┬──────────┬─────────────────────────────────────────────────────────────┐ │ Issue │ Status │ Fix Applied │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ Module definition variable names │ ✅ Fixed │ catalog, checkout, cart renamed to module │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ Router attachment functions │ ✅ Fixed │ Added get_module_with_routers() functions │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ Billing exceptions location │ ✅ Fixed │ Moved to exceptions.py with backwards-compat aliases │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ StoreEmailSettingsService DI │ ✅ Fixed │ Changed to db-as-parameter pattern │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ StoreDomainService exception bug │ ✅ Fixed │ Added proper re-raise for DomainVerificationFailedException │ ├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤ │ StoreTeamService exception bug │ ✅ Fixed │ Fixed CannotRemoveOwnerException arguments │ └───────────────────────────────────┴──────────┴─────────────────────────────────────────────────────────────┘
Part 2: Critical Architecture Violations
❌ Core → Optional Module Dependencies
The app would NOT run if optional modules are removed. Core modules have hard imports from optional modules:
tenancy (core) → optional modules
tenancy/models/init.py:22 → from app.modules.marketplace.models import MarketplaceImportJob
tenancy/services/admin_service.py:36,40 → from app.modules.marketplace.models import MarketplaceImportJob → from app.modules.marketplace.schemas import MarketplaceImportJobResponse
tenancy/services/store_service.py:18,19,26,27,30 → from app.modules.catalog.exceptions import ProductAlreadyExistsException → from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException → from app.modules.marketplace.models import MarketplaceProduct → from app.modules.catalog.models import Product → from app.modules.catalog.schemas import ProductCreate
tenancy/services/store_team_service.py:34 → from app.modules.billing.exceptions import TierLimitExceededException
tenancy/routes/api/admin_stores.py:20,23,348,399 → from app.modules.analytics.services.stats_service import stats_service → from app.modules.analytics.schemas import StoreStatsResponse → from app.modules.marketplace.services import letzshop_export_service
tenancy/routes/api/admin_platform_users.py:18 → from app.modules.analytics.services.stats_service import stats_service
core (core) → analytics (optional)
core/routes/api/admin_dashboard.py:14,16 → from app.modules.analytics.services.stats_service import stats_service → from app.modules.analytics.schemas import StoreStatsResponse, ...
core/routes/api/store_dashboard.py:17,20 → from app.modules.analytics.services.stats_service import stats_service → from app.modules.analytics.schemas import StoreStatsResponse, ...
cms (core) → optional modules
cms/models/init.py:14, cms/models/media.py:9, cms/services/media_service.py:31 → from app.modules.catalog.models import ProductMedia
cms/routes/pages/platform.py:17 → from app.modules.billing.models import TIER_LIMITS, TierCode
cms/services/store_email_settings_service.py:33 → from app.modules.billing.models import StoreSubscription, TierCode
customers (core) → orders (optional)
customers/services/customer_service.py:332,368 → from app.modules.orders.models import Order # (lazy import but still hard dep)
Part 3: Module Categorization (UPDATED 2026-02-03)
Core Modules (8) - Always enabled ┌───────────┬───────────────────────────────────────────────────┬──────────────────────────────┐ │ Module │ Purpose │ Depends on Optional? │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ contracts │ Cross-module Protocol interfaces │ No │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ core │ Dashboard, settings, profile │ ❌ YES (analytics) │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ cms │ Content pages, media, themes │ ❌ YES (catalog) │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ customers │ Customer database, profiles │ ❌ YES (orders) │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ tenancy │ Platforms, merchants, stores, users │ ❌ YES (marketplace,catalog, │ │ │ │ analytics) │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ billing │ Subscriptions, tier limits, invoices │ No (depends on payments, │ │ │ │ which is also core) │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ payments │ Payment gateways (Stripe, PayPal) │ No │ ├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤ │ messaging │ Email, notifications, templates │ No │ └───────────┴───────────────────────────────────────────────────┴──────────────────────────────┘
Why billing, payments, messaging are core (decided 2026-02-03):
- billing: Tier limits affect team size, product limits, email providers - pervasive
- payments: Required by billing for subscription payment processing
- messaging: Email required for registration, password reset, team invitations
Optional Modules (8) - Can be disabled per platform ┌─────────────┬────────────────────────────┬───────────────────────────────────┐ │ Module │ Purpose │ Dependencies │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ analytics │ Reports, dashboards │ (standalone) │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ cart │ Shopping cart │ inventory │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ catalog │ Product catalog │ inventory │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ checkout │ Order placement │ cart, orders, customers │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ inventory │ Stock management │ (standalone) │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ loyalty │ Loyalty programs │ customers │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ marketplace │ Letzshop integration │ inventory │ ├─────────────┼────────────────────────────┼───────────────────────────────────┤ │ orders │ Order management │ (standalone) │ └─────────────┴────────────────────────────┴───────────────────────────────────┘
Internal Modules (2) - Admin-only ┌────────────┬─────────────────────────────────┐ │ Module │ Purpose │ ├────────────┼─────────────────────────────────┤ │ dev-tools │ Component library, icon browser │ ├────────────┼─────────────────────────────────┤ │ monitoring │ Logs, tasks, health checks │ └────────────┴─────────────────────────────────┘
Part 4: Answer to User Questions
Q1: Are all modules self-contained and respecting architecture rules?
NO. While the route registration and middleware are properly decoupled, the core modules have hard import dependencies on optional modules. This violates the documented rule that "core modules cannot depend on optional modules."
Specific violations:
- tenancy depends on: marketplace, billing, catalog, analytics
- core depends on: analytics
- cms depends on: catalog, billing
- customers depends on: orders
Q2: Would the app still run if non-core modules are removed?
NO. The app would crash immediately on import with ImportError or ModuleNotFoundError because:
- tenancy/models/init.py imports MarketplaceImportJob at module load time
- core/routes/api/admin_dashboard.py imports stats_service at module load time
- cms/models/init.py imports ProductMedia at module load time
Even if using lazy imports (like in customers/services/customer_service.py), the code paths that use these imports would still fail at runtime.
Part 5: Contracts Pattern Deep Dive (Option A)
How the Existing Contracts Module Works
The app/modules/contracts/ module already implements Protocol-based interfaces:
app/modules/contracts/ ├── init.py # Exports all protocols ├── base.py # ServiceProtocol, CRUDServiceProtocol ├── cms.py # ContentServiceProtocol, MediaServiceProtocol └── definition.py # Module definition (is_core=True)
Key Pattern:
1. Protocol definition in contracts (no implementation)
@runtime_checkable class ContentServiceProtocol(Protocol): def get_page_for_store(self, db: Session, ...) -> object | None: ...
2. Implementation in the module itself
class ContentPageService: # Implements the protocol implicitly (duck typing) def get_page_for_store(self, db: Session, ...) -> ContentPage | None: # actual implementation
3. Usage in other modules (depends on protocol, not implementation)
class OrderService: def init(self, content: ContentServiceProtocol | None = None): self._content = content
@property
def content(self) -> ContentServiceProtocol:
if self._content is None:
from app.modules.cms.services import content_page_service # Lazy load
self._content = content_page_service
return self._content
Part 6: Metrics Provider Pattern (Proposed)
Design Goal
Each module provides its own metrics/stats. The analytics module aggregates them but doesn't implement them.
Step 1: Define MetricsProvider Protocol
File: app/modules/contracts/metrics.py from typing import Protocol, runtime_checkable, TYPE_CHECKING from dataclasses import dataclass
if TYPE_CHECKING: from sqlalchemy.orm import Session
@dataclass class MetricValue: """Standard metric value with metadata.""" key: str # e.g., "orders.total_count" value: int | float | str label: str # Human-readable label category: str # Grouping category icon: str | None = None # Optional UI icon
@runtime_checkable class MetricsProviderProtocol(Protocol): """ Protocol for modules that provide metrics/statistics.
Each module implements this to expose its own metrics.
The analytics module discovers and aggregates all providers.
"""
@property
def metrics_category(self) -> str:
"""Category name for this provider's metrics (e.g., 'orders', 'inventory')."""
...
def get_store_metrics(
self,
db: "Session",
store_id: int,
date_from: "datetime | None" = None,
date_to: "datetime | None" = None,
) -> list[MetricValue]:
"""Get metrics for a specific store."""
...
def get_platform_metrics(
self,
db: "Session",
platform_id: int,
date_from: "datetime | None" = None,
date_to: "datetime | None" = None,
) -> list[MetricValue]:
"""Get metrics aggregated for a platform."""
...
Step 2: Each Module Implements the Protocol
File: app/modules/orders/services/order_metrics.py from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue from sqlalchemy.orm import Session
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, date_from=None, date_to=None
) -> list[MetricValue]:
from app.modules.orders.models import Order
query = db.query(Order).filter(Order.store_id == store_id)
if date_from:
query = query.filter(Order.created_at >= date_from)
if date_to:
query = query.filter(Order.created_at <= date_to)
total_orders = query.count()
total_revenue = query.with_entities(func.sum(Order.total)).scalar() or 0
return [
MetricValue(
key="orders.total_count",
value=total_orders,
label="Total Orders",
category="orders",
icon="shopping-cart"
),
MetricValue(
key="orders.total_revenue",
value=float(total_revenue),
label="Total Revenue",
category="orders",
icon="currency-euro"
),
]
def get_platform_metrics(self, db: Session, platform_id: int, **kwargs):
# Aggregate across all stores in platform
...
Singleton instance
order_metrics_provider = OrderMetricsProvider()
Step 3: Register Provider in Module Definition
File: app/modules/orders/definition.py from app.modules.base import ModuleDefinition
def _get_metrics_provider(): """Lazy load metrics provider 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", # ... existing config ... metrics_provider=_get_metrics_provider, # NEW: Optional callable )
Step 4: Analytics Module Discovers All Providers
File: app/modules/analytics/services/stats_aggregator.py from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue from app.modules.registry import MODULES from sqlalchemy.orm import Session
class StatsAggregatorService: """Aggregates metrics from all module providers."""
def _get_enabled_providers(self, db: Session, platform_id: int) -> list[MetricsProviderProtocol]:
"""Get metrics providers from enabled modules."""
from app.modules.service import module_service
providers = []
for module in MODULES.values():
# Check if module is enabled for this platform
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
# Check if module has a metrics provider
if hasattr(module, 'metrics_provider') and module.metrics_provider:
provider = module.metrics_provider() # Call the lazy getter
if provider:
providers.append(provider)
return providers
def get_store_stats(
self, db: Session, store_id: int, platform_id: int, **kwargs
) -> dict[str, list[MetricValue]]:
"""Get all metrics for a store, grouped by category."""
providers = self._get_enabled_providers(db, platform_id)
result = {}
for provider in providers:
metrics = provider.get_store_metrics(db, store_id, **kwargs)
result[provider.metrics_category] = metrics
return result
stats_aggregator = StatsAggregatorService()
Step 5: Dashboard Uses Protocol, Not Implementation
File: app/modules/core/routes/api/store_dashboard.py from fastapi import APIRouter, Depends from sqlalchemy.orm import Session
from app.core.database import get_db from app.api.deps import get_current_store_api from models.schema.auth import UserContext
router = APIRouter(prefix="/dashboard")
@router.get("/stats") def get_dashboard_stats( current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Get aggregated stats for store dashboard.""" # Lazy import - only fails if analytics module removed AND this endpoint called from app.modules.analytics.services.stats_aggregator import stats_aggregator
return stats_aggregator.get_store_stats(
db=db,
store_id=current_user.token_store_id,
platform_id=current_user.platform_id,
)
Part 7: Creating a New Module with Metrics
Checklist for New Module with Metrics Support
- Create module structure: app/modules/my_module/ ├── init.py ├── definition.py # Include metrics_provider ├── models/ ├── services/ │ ├── init.py │ └── my_metrics.py # Implement MetricsProviderProtocol ├── routes/ └── schemas/
- Implement metrics provider:
app/modules/my_module/services/my_metrics.py
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
class MyModuleMetricsProvider: @property def metrics_category(self) -> str: return "my_module"
def get_store_metrics(self, db, store_id, **kwargs):
return [
MetricValue(key="my_module.count", value=42, label="My Count", category="my_module")
]
def get_platform_metrics(self, db, platform_id, **kwargs):
return []
my_module_metrics = MyModuleMetricsProvider() 3. Register in definition:
app/modules/my_module/definition.py
def _get_metrics_provider(): from app.modules.my_module.services.my_metrics import my_module_metrics return my_module_metrics
my_module = ModuleDefinition( code="my_module", metrics_provider=_get_metrics_provider, # ... ) 4. Metrics appear automatically in dashboards when module is enabled
Part 8: Benefits of This Architecture ┌───────────────────────────┬───────────────────────┬──────────────────────────────────┐ │ Aspect │ Before │ After │ ├───────────────────────────┼───────────────────────┼──────────────────────────────────┤ │ Core depends on analytics │ ❌ Hard import │ ✅ Optional lazy import │ ├───────────────────────────┼───────────────────────┼──────────────────────────────────┤ │ Adding new metrics │ Edit analytics module │ Just add provider to your module │ ├───────────────────────────┼───────────────────────┼──────────────────────────────────┤ │ Module isolation │ ❌ Coupled │ ✅ Truly independent │ ├───────────────────────────┼───────────────────────┼──────────────────────────────────┤ │ Testing │ Hard (need analytics) │ Easy (mock protocol) │ ├───────────────────────────┼───────────────────────┼──────────────────────────────────┤ │ Disable analytics module │ ❌ App crashes │ ✅ Dashboard shows "no stats" │ └───────────────────────────┴───────────────────────┴──────────────────────────────────┘
Part 9: Core Module Metrics & Dashboard Architecture
Key Questions Answered
Q1: How do core modules implement MetricsProviderProtocol?
Core modules implement MetricsProviderProtocol the same way as optional modules. The difference is they're always enabled.
tenancy module metrics:
app/modules/tenancy/services/tenancy_metrics.py
class TenancyMetricsProvider: @property def metrics_category(self) -> str: return "tenancy"
def get_store_metrics(self, db, store_id, **kwargs):
# Store-specific: team members count, domains count
from app.modules.tenancy.models import StoreUser, StoreDomain
team_count = db.query(StoreUser).filter(
StoreUser.store_id == store_id, StoreUser.is_active == True
).count()
domains_count = db.query(StoreDomain).filter(
StoreDomain.store_id == store_id
).count()
return [
MetricValue(key="tenancy.team_members", value=team_count,
label="Team Members", category="tenancy", icon="users"),
MetricValue(key="tenancy.domains", value=domains_count,
label="Custom Domains", category="tenancy", icon="globe"),
]
def get_platform_metrics(self, db, platform_id, **kwargs):
# Platform-wide: total stores, total users, active stores
from app.modules.tenancy.models import Store, User
total_stores = db.query(Store).filter(
Store.platform_id == platform_id
).count()
active_stores = db.query(Store).filter(
Store.platform_id == platform_id, Store.is_active == True
).count()
return [
MetricValue(key="tenancy.total_stores", value=total_stores,
label="Total Stores", category="tenancy", icon="store"),
MetricValue(key="tenancy.active_stores", value=active_stores,
label="Active Stores", category="tenancy", icon="check-circle"),
]
customers module metrics:
app/modules/customers/services/customer_metrics.py
class CustomerMetricsProvider: @property def metrics_category(self) -> str: return "customers"
def get_store_metrics(self, db, store_id, date_from=None, date_to=None):
from app.modules.customers.models import Customer
query = db.query(Customer).filter(Customer.store_id == store_id)
total = query.count()
# New customers in period
if date_from:
new_query = query.filter(Customer.created_at >= date_from)
if date_to:
new_query = new_query.filter(Customer.created_at <= date_to)
new_count = new_query.count()
else:
new_count = 0
return [
MetricValue(key="customers.total", value=total,
label="Total Customers", category="customers", icon="users"),
MetricValue(key="customers.new", value=new_count,
label="New Customers", category="customers", icon="user-plus"),
]
def get_platform_metrics(self, db, platform_id, **kwargs):
# Platform admin sees aggregated customer stats
...
Q2: Should analytics be part of core?
Recommendation: Split analytics into two parts: ┌────────────────────────┬─────────────────────────────┬─────────────────────────────────────────────────────┐ │ Component │ Location │ Purpose │ ├────────────────────────┼─────────────────────────────┼─────────────────────────────────────────────────────┤ │ StatsAggregatorService │ core module │ Basic metric discovery & aggregation for dashboards │ ├────────────────────────┼─────────────────────────────┼─────────────────────────────────────────────────────┤ │ Advanced Analytics │ analytics module (optional) │ Reports, charts, trends, exports, historical data │ └────────────────────────┴─────────────────────────────┴─────────────────────────────────────────────────────┘ Why this split works:
- Dashboards always work (aggregator in core)
- Advanced analytics features can be disabled per platform
- No circular dependencies
- Clear separation of concerns
Implementation: app/modules/core/services/ ├── stats_aggregator.py # Discovers & aggregates MetricsProviders (CORE) └── ...
app/modules/analytics/services/ ├── reports_service.py # Advanced reports (OPTIONAL) ├── trends_service.py # Historical trends (OPTIONAL) └── export_service.py # Data exports (OPTIONAL)
StatsAggregatorService in core:
app/modules/core/services/stats_aggregator.py
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue from app.modules.registry import MODULES
class StatsAggregatorService: """ Core service that discovers and aggregates metrics from all modules. Lives in core module so dashboards always work. """
def _get_providers(self, db, platform_id) -> list[MetricsProviderProtocol]:
"""Get all metrics providers from enabled modules."""
from app.modules.service import module_service
providers = []
for module in MODULES.values():
# Always include core modules, check others
if not module.is_core:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
if hasattr(module, 'metrics_provider') and module.metrics_provider:
provider = module.metrics_provider()
if provider:
providers.append(provider)
return providers
def get_store_dashboard_stats(self, db, store_id, platform_id, **kwargs):
"""For store dashboard - single store metrics."""
providers = self._get_providers(db, platform_id)
return {p.metrics_category: p.get_store_metrics(db, store_id, **kwargs)
for p in providers}
def get_admin_dashboard_stats(self, db, platform_id, **kwargs):
"""For admin dashboard - platform-wide metrics."""
providers = self._get_providers(db, platform_id)
return {p.metrics_category: p.get_platform_metrics(db, platform_id, **kwargs)
for p in providers}
stats_aggregator = StatsAggregatorService()
Q3: Should this be used by both admin and store dashboards?
YES. The protocol has two methods for this exact purpose: ┌───────────────────────────────────┬──────────────────┬───────────────────────────────┐ │ Method │ Used By │ Data Scope │ ├───────────────────────────────────┼──────────────────┼───────────────────────────────┤ │ get_store_metrics(store_id) │ Store Dashboard │ Single store's data │ ├───────────────────────────────────┼──────────────────┼───────────────────────────────┤ │ get_platform_metrics(platform_id) │ Admin Dashboard │ Aggregated across all stores │ └───────────────────────────────────┴──────────────────┴───────────────────────────────┘ Store Dashboard:
app/modules/core/routes/api/store_dashboard.py
@router.get("/stats") def get_store_stats( current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): from app.modules.core.services.stats_aggregator import stats_aggregator
return stats_aggregator.get_store_dashboard_stats(
db=db,
store_id=current_user.token_store_id,
platform_id=current_user.platform_id,
)
Admin Dashboard:
app/modules/core/routes/api/admin_dashboard.py
@router.get("/stats") def get_platform_stats( current_user: UserContext = Depends(get_current_platform_admin_api), db: Session = Depends(get_db), ): from app.modules.core.services.stats_aggregator import stats_aggregator
return stats_aggregator.get_admin_dashboard_stats(
db=db,
platform_id=current_user.platform_id,
)
Module Categorization Update (UPDATED 2026-02-03)
With this architecture, module categorization becomes: ┌───────────┬──────────┬─────────────────┬───────────────────────────────────────┐ │ Module │ Type │ Has Metrics? │ Metrics Examples │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ core │ Core │ ✅ + Aggregator │ System stats, aggregator service │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ tenancy │ Core │ ✅ │ Stores, users, team members, domains │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ customers │ Core │ ✅ │ Customer counts, new customers │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ cms │ Core │ ✅ │ Pages, media items │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ contracts │ Core │ ❌ │ Just protocols, no data │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ billing │ Core │ ✅ │ Subscription stats, tier usage │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ payments │ Core │ ✅ │ Transaction stats │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ messaging │ Core │ ✅ │ Email stats, notification counts │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ orders │ Optional │ ✅ │ Order counts, revenue │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ inventory │ Optional │ ✅ │ Stock levels, low stock │ ├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤ │ analytics │ Optional │ ❌ │ Advanced reports (uses aggregator) │ └───────────┴──────────┴─────────────────┴───────────────────────────────────────┘
Part 10: Implementation Steps
Phase 1: Create Metrics Contract (contracts module)
- Create app/modules/contracts/metrics.py with:
- MetricValue dataclass
- MetricsProviderProtocol
- Add metrics_provider: Callable | None field to ModuleDefinition base class
- Export from contracts/init.py
Phase 2: Create Stats Aggregator (core module)
- Create app/modules/core/services/stats_aggregator.py
- StatsAggregatorService that discovers all metrics providers
- get_store_dashboard_stats() method
- get_admin_dashboard_stats() method
- Register in core module exports
Phase 3: Add Metrics Providers to Core Modules
- tenancy → tenancy_metrics.py
- Store count, user count, team members, domains
- customers → customer_metrics.py
- Customer count, new customers, active customers
- cms → cms_metrics.py
- Page count, media count
Phase 4: Add Metrics Providers to Optional Modules
Priority order:
- orders → order_metrics.py - order counts, revenue
- inventory → inventory_metrics.py - stock levels, low stock alerts
- billing → billing_metrics.py - subscription stats
- marketplace → marketplace_metrics.py - import stats
Phase 5: Update Dashboard Routes
- Update core/routes/api/store_dashboard.py to use aggregator
- Update core/routes/api/admin_dashboard.py to use aggregator
- Remove direct imports from analytics module
- Handle graceful degradation when no metrics available
Phase 6: Refactor Analytics Module (Optional Features)
- Keep advanced features in analytics module:
- reports_service.py - detailed reports
- trends_service.py - historical trends
- export_service.py - data exports
- Analytics module can use stats_aggregator for base data
- Remove duplicated metric calculations from analytics
Phase 7: Update Tests
- Add unit tests for StatsAggregatorService
- Add unit tests for each metrics provider
- Update dashboard API tests
- Test with analytics module disabled
Verification Commands
Check module discovery
python -c "from app.modules.registry import MODULES; print(list(MODULES.keys()))"
Test metrics provider discovery
python -c " from app.modules.registry import MODULES for m in MODULES.values(): if hasattr(m, 'metrics_provider') and m.metrics_provider: print(f'{m.code}: has metrics provider') "
Test stats aggregator (after implementation)
python -c " from app.modules.core.services.stats_aggregator import stats_aggregator from app.core.database import SessionLocal db = SessionLocal() try: # Test with platform_id=1 stats = stats_aggregator.get_admin_dashboard_stats(db, platform_id=1) print('Categories:', list(stats.keys())) finally: db.close() "
Run metrics provider tests
pytest tests/unit/services/test_*_metrics.py -v
Verify no core→optional imports remain
grep -r "from app.modules.analytics" app/modules/core/ app/modules/tenancy/ || echo "Clean!"
Summary: What Changes
Before (Current State)
core/routes/admin_dashboard.py └── imports stats_service from analytics module ❌ └── analytics calculates ALL metrics internally
After (New Architecture)
core/routes/admin_dashboard.py └── imports stats_aggregator from core module ✅ └── discovers MetricsProviders from ALL modules ├── tenancy_metrics (core) ├── customer_metrics (core) ├── order_metrics (optional, if enabled) ├── inventory_metrics (optional, if enabled) └── ...
analytics module (optional) └── Advanced features: reports, trends, exports └── can use stats_aggregator for base data
Key Benefits
- Dashboards always work - aggregator is in core
- Each module owns its metrics - no cross-module coupling
- Optional modules truly optional - can be removed without breaking app
- Easy to add new metrics - just implement protocol in your module
- Both dashboards supported - store (per-store) and admin (platform-wide)