- ET-SAAS-015-oauth.md: Changed Estado from Propuesto to Implementado - ET-SAAS-016-analytics.md: Changed Estado from Propuesto to Implementado Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
808 lines
23 KiB
Markdown
808 lines
23 KiB
Markdown
---
|
|
id: "ET-SAAS-016"
|
|
title: "Especificacion Tecnica Analytics Dashboard"
|
|
type: "TechnicalSpec"
|
|
status: "Implemented"
|
|
priority: "P2"
|
|
module: "analytics"
|
|
version: "1.0.0"
|
|
created_date: "2026-01-24"
|
|
updated_date: "2026-01-24"
|
|
story_points: 8
|
|
---
|
|
|
|
# ET-SAAS-016: Especificacion Tecnica - Analytics Dashboard
|
|
|
|
## Metadata
|
|
- **Codigo:** ET-SAAS-016
|
|
- **Modulo:** Analytics
|
|
- **Version:** 1.0.0
|
|
- **Estado:** Implementado
|
|
- **Fecha:** 2026-01-24
|
|
- **Basado en:** Metabase patterns, SaaS analytics best practices
|
|
|
|
---
|
|
|
|
## 1. Resumen Ejecutivo
|
|
|
|
### 1.1 Estado Actual
|
|
|
|
No existe modulo de analytics implementado. Los datos para metricas existen en:
|
|
|
|
| Fuente | Datos Disponibles | Accesibilidad |
|
|
|--------|-------------------|---------------|
|
|
| users.users | Total usuarios, fechas creacion | Directa |
|
|
| billing.subscriptions | MRR, planes activos | Directa |
|
|
| billing.invoices | Revenue por periodo | Directa |
|
|
| auth.sessions | Usuarios activos (login) | Indirecta |
|
|
| audit.audit_logs | Acciones por usuario | Directa |
|
|
| storage.files | Uso de storage | Directa |
|
|
| ai.ai_usage | Tokens AI consumidos | Directa |
|
|
|
|
### 1.2 Propuesta v1.0
|
|
|
|
Dashboard de analytics con:
|
|
|
|
- **KPIs en tiempo real**: Usuarios, MRR, uso de recursos
|
|
- **Tendencias temporales**: Graficos de 7d, 30d, 90d, 1y
|
|
- **Breakdowns**: Por plan, por tenant, por recurso
|
|
- **Cache inteligente**: Metricas pre-calculadas
|
|
- **Superadmin view**: Metricas globales agregadas
|
|
|
|
---
|
|
|
|
## 2. Metricas Definidas
|
|
|
|
### 2.1 Metricas de Usuarios
|
|
|
|
| ID | Metrica | Formula | Granularidad |
|
|
|----|---------|---------|--------------|
|
|
| U1 | total_users | COUNT(users) | Snapshot |
|
|
| U2 | active_users_7d | Users con login en 7 dias | Rolling |
|
|
| U3 | active_users_30d | Users con login en 30 dias | Rolling |
|
|
| U4 | new_users | Users creados en periodo | Period |
|
|
| U5 | churned_users | Users inactivos > 30 dias | Period |
|
|
| U6 | churn_rate | churned / total * 100 | Period |
|
|
| U7 | dau | Users activos hoy | Daily |
|
|
| U8 | mau | Users activos 30d | Monthly |
|
|
|
|
### 2.2 Metricas de Billing
|
|
|
|
| ID | Metrica | Formula | Granularidad |
|
|
|----|---------|---------|--------------|
|
|
| B1 | mrr | SUM(subscription.price) WHERE active | Snapshot |
|
|
| B2 | arr | MRR * 12 | Calculated |
|
|
| B3 | revenue_period | SUM(invoices.amount) WHERE paid | Period |
|
|
| B4 | arpu | revenue / users | Period |
|
|
| B5 | subscriptions_active | COUNT(active subs) | Snapshot |
|
|
| B6 | subscriptions_new | Subs creadas en periodo | Period |
|
|
| B7 | subscriptions_churned | Subs canceladas en periodo | Period |
|
|
| B8 | ltv | ARPU * avg_lifetime | Calculated |
|
|
|
|
### 2.3 Metricas de Uso
|
|
|
|
| ID | Metrica | Formula | Granularidad |
|
|
|----|---------|---------|--------------|
|
|
| R1 | api_calls | COUNT(requests) | Period |
|
|
| R2 | api_calls_by_endpoint | GROUP BY endpoint | Period |
|
|
| R3 | storage_used_gb | SUM(file_size) / 1GB | Snapshot |
|
|
| R4 | storage_by_type | GROUP BY mime_type | Snapshot |
|
|
| R5 | ai_tokens | SUM(tokens_used) | Period |
|
|
| R6 | ai_tokens_by_model | GROUP BY model | Period |
|
|
| R7 | notifications_sent | COUNT(sent) | Period |
|
|
| R8 | webhooks_delivered | COUNT(delivered) | Period |
|
|
|
|
---
|
|
|
|
## 3. Arquitectura
|
|
|
|
### 3.1 Diagrama de Componentes
|
|
|
|
```
|
|
+------------------+ +-------------------+ +------------------+
|
|
| Data Sources | | MetricsCalculator | | MetricsCache |
|
|
| (users, billing |---->| Service |---->| (Redis/DB) |
|
|
| audit, etc) | +-------------------+ +------------------+
|
|
+------------------+ | |
|
|
v v
|
|
+-------------------+ +-------------------+
|
|
| AnalyticsService |<----| Scheduled Jobs |
|
|
| (API layer) | | (hourly/daily) |
|
|
+-------------------+ +-------------------+
|
|
|
|
|
v
|
|
+-------------------+
|
|
| AnalyticsController|
|
|
| (REST endpoints) |
|
|
+-------------------+
|
|
|
|
|
v
|
|
+-------------------+
|
|
| Frontend Dashboard|
|
|
| (React + Recharts)|
|
|
+-------------------+
|
|
```
|
|
|
|
### 3.2 Estrategia de Cache
|
|
|
|
| Tipo Metrica | Calculo | Cache TTL | Storage |
|
|
|--------------|---------|-----------|---------|
|
|
| Snapshots | Cada hora | 1 hora | Redis |
|
|
| Daily | Medianoche | 24 horas | PostgreSQL |
|
|
| Weekly | Domingo | 7 dias | PostgreSQL |
|
|
| Monthly | Dia 1 | 30 dias | PostgreSQL |
|
|
| Real-time | On-demand | 5 min | Redis |
|
|
|
|
---
|
|
|
|
## 4. Modelo de Datos
|
|
|
|
### 4.1 Schema: analytics
|
|
|
|
```sql
|
|
-- Crear schema
|
|
CREATE SCHEMA IF NOT EXISTS analytics;
|
|
|
|
-- Enum para tipos de metrica
|
|
CREATE TYPE analytics.metric_type AS ENUM (
|
|
'users_total',
|
|
'users_active',
|
|
'users_new',
|
|
'users_churned',
|
|
'mrr',
|
|
'arr',
|
|
'revenue',
|
|
'arpu',
|
|
'subscriptions_active',
|
|
'api_calls',
|
|
'storage_used',
|
|
'ai_tokens',
|
|
'notifications',
|
|
'webhooks'
|
|
);
|
|
|
|
-- Enum para periodos
|
|
CREATE TYPE analytics.period_type AS ENUM (
|
|
'hourly',
|
|
'daily',
|
|
'weekly',
|
|
'monthly',
|
|
'yearly'
|
|
);
|
|
```
|
|
|
|
### 4.2 Tabla: metrics_cache
|
|
|
|
```sql
|
|
CREATE TABLE analytics.metrics_cache (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Metric identification
|
|
metric_type analytics.metric_type NOT NULL,
|
|
period_type analytics.period_type NOT NULL,
|
|
period_start TIMESTAMPTZ NOT NULL,
|
|
period_end TIMESTAMPTZ NOT NULL,
|
|
|
|
-- Value
|
|
value NUMERIC NOT NULL,
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
-- Timestamps
|
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
-- Constraints
|
|
UNIQUE(tenant_id, metric_type, period_type, period_start)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_metrics_cache_tenant ON analytics.metrics_cache(tenant_id);
|
|
CREATE INDEX idx_metrics_cache_type ON analytics.metrics_cache(metric_type);
|
|
CREATE INDEX idx_metrics_cache_period ON analytics.metrics_cache(period_start DESC);
|
|
|
|
-- RLS
|
|
ALTER TABLE analytics.metrics_cache ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY metrics_cache_tenant_isolation ON analytics.metrics_cache
|
|
USING (
|
|
tenant_id = current_setting('app.current_tenant_id', true)::UUID
|
|
OR current_setting('app.is_superadmin', true)::BOOLEAN = true
|
|
);
|
|
|
|
COMMENT ON TABLE analytics.metrics_cache IS 'Cache de metricas pre-calculadas por tenant';
|
|
```
|
|
|
|
### 4.3 Tabla: usage_events
|
|
|
|
```sql
|
|
CREATE TABLE analytics.usage_events (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL,
|
|
user_id UUID,
|
|
|
|
-- Event info
|
|
event_type VARCHAR(50) NOT NULL,
|
|
event_data JSONB DEFAULT '{}',
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Partitioning por mes
|
|
CREATE INDEX idx_usage_events_tenant_type
|
|
ON analytics.usage_events(tenant_id, event_type, created_at);
|
|
|
|
-- Auto-purge eventos > 90 dias
|
|
CREATE OR REPLACE FUNCTION analytics.purge_old_events()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
DELETE FROM analytics.usage_events
|
|
WHERE created_at < NOW() - INTERVAL '90 days';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON TABLE analytics.usage_events IS 'Eventos de uso para tracking detallado';
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Implementacion Backend
|
|
|
|
### 5.1 Estructura de Archivos
|
|
|
|
```
|
|
backend/src/modules/analytics/
|
|
├── analytics.module.ts
|
|
├── controllers/
|
|
│ └── analytics.controller.ts
|
|
├── services/
|
|
│ ├── analytics.service.ts
|
|
│ ├── metrics-calculator.service.ts
|
|
│ └── metrics-cache.service.ts
|
|
├── entities/
|
|
│ ├── metrics-cache.entity.ts
|
|
│ └── usage-event.entity.ts
|
|
├── dto/
|
|
│ ├── analytics-summary.dto.ts
|
|
│ ├── user-metrics.dto.ts
|
|
│ ├── billing-metrics.dto.ts
|
|
│ └── usage-metrics.dto.ts
|
|
└── jobs/
|
|
├── calculate-hourly-metrics.job.ts
|
|
└── calculate-daily-metrics.job.ts
|
|
```
|
|
|
|
### 5.2 Service: MetricsCalculatorService
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class MetricsCalculatorService {
|
|
constructor(
|
|
@InjectRepository(User) private usersRepo: Repository<User>,
|
|
@InjectRepository(Subscription) private subsRepo: Repository<Subscription>,
|
|
@InjectRepository(Invoice) private invoicesRepo: Repository<Invoice>,
|
|
@InjectRepository(Session) private sessionsRepo: Repository<Session>,
|
|
@InjectRepository(AuditLog) private auditRepo: Repository<AuditLog>,
|
|
@InjectRepository(File) private filesRepo: Repository<File>,
|
|
@InjectRepository(AiUsage) private aiUsageRepo: Repository<AiUsage>,
|
|
) {}
|
|
|
|
async calculateUserMetrics(
|
|
tenantId: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<UserMetrics> {
|
|
const [
|
|
totalUsers,
|
|
activeUsers7d,
|
|
activeUsers30d,
|
|
newUsers,
|
|
] = await Promise.all([
|
|
this.usersRepo.count({ where: { tenantId } }),
|
|
this.getActiveUsers(tenantId, 7),
|
|
this.getActiveUsers(tenantId, 30),
|
|
this.usersRepo.count({
|
|
where: {
|
|
tenantId,
|
|
createdAt: Between(startDate, endDate),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const churnedUsers = await this.getChurnedUsers(tenantId, 30);
|
|
const churnRate = totalUsers > 0 ? (churnedUsers / totalUsers) * 100 : 0;
|
|
|
|
return {
|
|
total: totalUsers,
|
|
active7d: activeUsers7d,
|
|
active30d: activeUsers30d,
|
|
new: newUsers,
|
|
churned: churnedUsers,
|
|
churnRate: Math.round(churnRate * 100) / 100,
|
|
};
|
|
}
|
|
|
|
async calculateBillingMetrics(
|
|
tenantId: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<BillingMetrics> {
|
|
const activeSubscriptions = await this.subsRepo.find({
|
|
where: { tenantId, status: 'active' },
|
|
relations: ['plan'],
|
|
});
|
|
|
|
const mrr = activeSubscriptions.reduce(
|
|
(sum, sub) => sum + sub.plan.priceMonthly,
|
|
0,
|
|
);
|
|
|
|
const revenue = await this.invoicesRepo
|
|
.createQueryBuilder('i')
|
|
.select('SUM(i.amount)', 'total')
|
|
.where('i.tenant_id = :tenantId', { tenantId })
|
|
.andWhere('i.status = :status', { status: 'paid' })
|
|
.andWhere('i.paid_at BETWEEN :start AND :end', {
|
|
start: startDate,
|
|
end: endDate,
|
|
})
|
|
.getRawOne();
|
|
|
|
const totalUsers = await this.usersRepo.count({ where: { tenantId } });
|
|
const arpu = totalUsers > 0 ? revenue.total / totalUsers : 0;
|
|
|
|
return {
|
|
mrr,
|
|
arr: mrr * 12,
|
|
revenue: revenue.total || 0,
|
|
arpu: Math.round(arpu * 100) / 100,
|
|
subscriptionsActive: activeSubscriptions.length,
|
|
};
|
|
}
|
|
|
|
async calculateUsageMetrics(
|
|
tenantId: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<UsageMetrics> {
|
|
const [storageUsed, aiTokens] = await Promise.all([
|
|
this.filesRepo
|
|
.createQueryBuilder('f')
|
|
.select('SUM(f.size)', 'total')
|
|
.where('f.tenant_id = :tenantId', { tenantId })
|
|
.getRawOne(),
|
|
this.aiUsageRepo
|
|
.createQueryBuilder('a')
|
|
.select('SUM(a.tokens_used)', 'total')
|
|
.where('a.tenant_id = :tenantId', { tenantId })
|
|
.andWhere('a.created_at BETWEEN :start AND :end', {
|
|
start: startDate,
|
|
end: endDate,
|
|
})
|
|
.getRawOne(),
|
|
]);
|
|
|
|
return {
|
|
storageUsedGb: Math.round((storageUsed.total || 0) / 1024 / 1024 / 1024 * 100) / 100,
|
|
aiTokens: aiTokens.total || 0,
|
|
};
|
|
}
|
|
|
|
private async getActiveUsers(tenantId: string, days: number): Promise<number> {
|
|
const since = new Date();
|
|
since.setDate(since.getDate() - days);
|
|
|
|
return this.sessionsRepo
|
|
.createQueryBuilder('s')
|
|
.select('COUNT(DISTINCT s.user_id)', 'count')
|
|
.where('s.tenant_id = :tenantId', { tenantId })
|
|
.andWhere('s.created_at > :since', { since })
|
|
.getRawOne()
|
|
.then((r) => parseInt(r.count, 10));
|
|
}
|
|
|
|
private async getChurnedUsers(tenantId: string, inactiveDays: number): Promise<number> {
|
|
const since = new Date();
|
|
since.setDate(since.getDate() - inactiveDays);
|
|
|
|
// Users with no session in last N days
|
|
return this.usersRepo
|
|
.createQueryBuilder('u')
|
|
.where('u.tenant_id = :tenantId', { tenantId })
|
|
.andWhere(
|
|
`NOT EXISTS (
|
|
SELECT 1 FROM auth.sessions s
|
|
WHERE s.user_id = u.id AND s.created_at > :since
|
|
)`,
|
|
{ since },
|
|
)
|
|
.getCount();
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.3 Service: AnalyticsService
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class AnalyticsService {
|
|
constructor(
|
|
private readonly metricsCalculator: MetricsCalculatorService,
|
|
private readonly metricsCache: MetricsCacheService,
|
|
) {}
|
|
|
|
async getSummary(
|
|
tenantId: string,
|
|
period: '7d' | '30d' | '90d' | '1y',
|
|
): Promise<AnalyticsSummary> {
|
|
const { startDate, endDate } = this.getPeriodDates(period);
|
|
|
|
// Try cache first
|
|
const cached = await this.metricsCache.get(tenantId, 'summary', period);
|
|
if (cached && !this.isStale(cached)) {
|
|
return cached.value;
|
|
}
|
|
|
|
// Calculate fresh
|
|
const [users, billing, usage] = await Promise.all([
|
|
this.metricsCalculator.calculateUserMetrics(tenantId, startDate, endDate),
|
|
this.metricsCalculator.calculateBillingMetrics(tenantId, startDate, endDate),
|
|
this.metricsCalculator.calculateUsageMetrics(tenantId, startDate, endDate),
|
|
]);
|
|
|
|
// Calculate trends
|
|
const previousPeriod = this.getPreviousPeriodDates(period);
|
|
const [prevUsers, prevBilling] = await Promise.all([
|
|
this.metricsCalculator.calculateUserMetrics(tenantId, previousPeriod.startDate, previousPeriod.endDate),
|
|
this.metricsCalculator.calculateBillingMetrics(tenantId, previousPeriod.startDate, previousPeriod.endDate),
|
|
]);
|
|
|
|
const trends = {
|
|
usersGrowth: this.calculateGrowth(users.total, prevUsers.total),
|
|
mrrGrowth: this.calculateGrowth(billing.mrr, prevBilling.mrr),
|
|
};
|
|
|
|
const summary = { period, users, billing, usage, trends };
|
|
|
|
// Cache result
|
|
await this.metricsCache.set(tenantId, 'summary', period, summary);
|
|
|
|
return summary;
|
|
}
|
|
|
|
async getTimeseries(
|
|
tenantId: string,
|
|
metric: string,
|
|
period: '7d' | '30d' | '90d' | '1y',
|
|
granularity: 'daily' | 'weekly' | 'monthly',
|
|
): Promise<TimeseriesData[]> {
|
|
const { startDate, endDate } = this.getPeriodDates(period);
|
|
const intervals = this.generateIntervals(startDate, endDate, granularity);
|
|
|
|
const data = await Promise.all(
|
|
intervals.map(async (interval) => {
|
|
const value = await this.getMetricForInterval(
|
|
tenantId,
|
|
metric,
|
|
interval.start,
|
|
interval.end,
|
|
);
|
|
return {
|
|
date: interval.start.toISOString().split('T')[0],
|
|
value,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
private getPeriodDates(period: string): { startDate: Date; endDate: Date } {
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
|
|
switch (period) {
|
|
case '7d':
|
|
startDate.setDate(startDate.getDate() - 7);
|
|
break;
|
|
case '30d':
|
|
startDate.setDate(startDate.getDate() - 30);
|
|
break;
|
|
case '90d':
|
|
startDate.setDate(startDate.getDate() - 90);
|
|
break;
|
|
case '1y':
|
|
startDate.setFullYear(startDate.getFullYear() - 1);
|
|
break;
|
|
}
|
|
|
|
return { startDate, endDate };
|
|
}
|
|
|
|
private calculateGrowth(current: number, previous: number): number {
|
|
if (previous === 0) return current > 0 ? 100 : 0;
|
|
return Math.round(((current - previous) / previous) * 100 * 10) / 10;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.4 Controller: AnalyticsController
|
|
|
|
```typescript
|
|
@Controller('analytics')
|
|
@ApiTags('Analytics')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class AnalyticsController {
|
|
constructor(private readonly analyticsService: AnalyticsService) {}
|
|
|
|
@Get('summary')
|
|
@ApiOperation({ summary: 'Get analytics summary' })
|
|
async getSummary(
|
|
@GetTenant() tenantId: string,
|
|
@Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d',
|
|
): Promise<AnalyticsSummary> {
|
|
return this.analyticsService.getSummary(tenantId, period);
|
|
}
|
|
|
|
@Get('users')
|
|
@ApiOperation({ summary: 'Get user metrics' })
|
|
async getUserMetrics(
|
|
@GetTenant() tenantId: string,
|
|
@Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d',
|
|
@Query('granularity') granularity: 'daily' | 'weekly' | 'monthly' = 'daily',
|
|
): Promise<UserMetricsResponse> {
|
|
return this.analyticsService.getUserMetrics(tenantId, period, granularity);
|
|
}
|
|
|
|
@Get('billing')
|
|
@ApiOperation({ summary: 'Get billing metrics' })
|
|
async getBillingMetrics(
|
|
@GetTenant() tenantId: string,
|
|
@Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d',
|
|
): Promise<BillingMetricsResponse> {
|
|
return this.analyticsService.getBillingMetrics(tenantId, period);
|
|
}
|
|
|
|
@Get('usage')
|
|
@ApiOperation({ summary: 'Get usage metrics' })
|
|
async getUsageMetrics(
|
|
@GetTenant() tenantId: string,
|
|
@Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d',
|
|
): Promise<UsageMetricsResponse> {
|
|
return this.analyticsService.getUsageMetrics(tenantId, period);
|
|
}
|
|
|
|
@Get('trends')
|
|
@ApiOperation({ summary: 'Get trends data' })
|
|
async getTrends(
|
|
@GetTenant() tenantId: string,
|
|
@Query('metrics') metrics: string,
|
|
@Query('period') period: '30d' | '90d' | '1y' = '30d',
|
|
): Promise<TrendsResponse> {
|
|
const metricsList = metrics.split(',');
|
|
return this.analyticsService.getTrends(tenantId, metricsList, period);
|
|
}
|
|
|
|
@Get('export')
|
|
@ApiOperation({ summary: 'Export analytics data' })
|
|
async exportData(
|
|
@GetTenant() tenantId: string,
|
|
@Query('period') period: '7d' | '30d' | '90d' | '1y' = '30d',
|
|
@Query('format') format: 'json' | 'csv' = 'json',
|
|
@Res() res: Response,
|
|
): Promise<void> {
|
|
const data = await this.analyticsService.exportData(tenantId, period);
|
|
|
|
if (format === 'csv') {
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename=analytics.csv');
|
|
res.send(this.convertToCsv(data));
|
|
} else {
|
|
res.json(data);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.5 Scheduled Jobs
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class CalculateMetricsJob {
|
|
constructor(
|
|
private readonly metricsCalculator: MetricsCalculatorService,
|
|
private readonly metricsCache: MetricsCacheService,
|
|
private readonly tenantsService: TenantsService,
|
|
) {}
|
|
|
|
@Cron('0 * * * *') // Every hour
|
|
async calculateHourlyMetrics(): Promise<void> {
|
|
const tenants = await this.tenantsService.findAll();
|
|
|
|
for (const tenant of tenants) {
|
|
await this.calculateAndCacheMetrics(tenant.id, 'hourly');
|
|
}
|
|
}
|
|
|
|
@Cron('0 0 * * *') // Every day at midnight
|
|
async calculateDailyMetrics(): Promise<void> {
|
|
const tenants = await this.tenantsService.findAll();
|
|
|
|
for (const tenant of tenants) {
|
|
await this.calculateAndCacheMetrics(tenant.id, 'daily');
|
|
}
|
|
}
|
|
|
|
private async calculateAndCacheMetrics(
|
|
tenantId: string,
|
|
periodType: 'hourly' | 'daily',
|
|
): Promise<void> {
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
|
|
if (periodType === 'hourly') {
|
|
startDate.setHours(startDate.getHours() - 1);
|
|
} else {
|
|
startDate.setDate(startDate.getDate() - 1);
|
|
}
|
|
|
|
const metrics = await this.metricsCalculator.calculateAllMetrics(
|
|
tenantId,
|
|
startDate,
|
|
endDate,
|
|
);
|
|
|
|
await this.metricsCache.store(tenantId, periodType, startDate, metrics);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Implementacion Frontend
|
|
|
|
### 6.1 Dashboard Component
|
|
|
|
```tsx
|
|
export const AnalyticsDashboard: React.FC = () => {
|
|
const [period, setPeriod] = useState<'7d' | '30d' | '90d' | '1y'>('30d');
|
|
const { data: summary, isLoading } = useAnalyticsSummary(period);
|
|
const { data: trends } = useAnalyticsTrends(['users', 'mrr'], period);
|
|
|
|
if (isLoading) return <DashboardSkeleton />;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Period Selector */}
|
|
<div className="flex justify-between items-center">
|
|
<h1 className="text-2xl font-bold">Analytics</h1>
|
|
<PeriodSelector value={period} onChange={setPeriod} />
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<MetricCard
|
|
title="Total Users"
|
|
value={summary.users.total}
|
|
change={summary.trends.usersGrowth}
|
|
icon={<UsersIcon />}
|
|
/>
|
|
<MetricCard
|
|
title="MRR"
|
|
value={formatCurrency(summary.billing.mrr)}
|
|
change={summary.trends.mrrGrowth}
|
|
icon={<DollarIcon />}
|
|
/>
|
|
<MetricCard
|
|
title="API Calls"
|
|
value={formatNumber(summary.usage.apiCalls)}
|
|
icon={<ApiIcon />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Trend Chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Trends</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<TrendChart data={trends} height={300} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Usage Breakdown */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<UsageBreakdownCard
|
|
title="Users by Plan"
|
|
data={summary.users.byPlan}
|
|
/>
|
|
<UsageBreakdownCard
|
|
title="Storage by Type"
|
|
data={summary.usage.storageByType}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 6.2 TrendChart Component
|
|
|
|
```tsx
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
|
|
interface TrendChartProps {
|
|
data: TimeseriesData[];
|
|
height?: number;
|
|
}
|
|
|
|
export const TrendChart: React.FC<TrendChartProps> = ({ data, height = 300 }) => {
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<LineChart data={data}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="date" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="value"
|
|
stroke="#3B82F6"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 6.3 Hooks
|
|
|
|
```typescript
|
|
export const useAnalyticsSummary = (period: string) => {
|
|
return useQuery({
|
|
queryKey: ['analytics', 'summary', period],
|
|
queryFn: () => analyticsApi.getSummary(period),
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
};
|
|
|
|
export const useAnalyticsTrends = (metrics: string[], period: string) => {
|
|
return useQuery({
|
|
queryKey: ['analytics', 'trends', metrics, period],
|
|
queryFn: () => analyticsApi.getTrends(metrics.join(','), period),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Criterios de Aceptacion
|
|
|
|
- [ ] Dashboard muestra KPIs de usuarios, billing, uso
|
|
- [ ] Selector de periodo funciona (7d, 30d, 90d, 1y)
|
|
- [ ] Graficos de tendencias renderizan con Recharts
|
|
- [ ] Cache de metricas reduce queries a BD
|
|
- [ ] Jobs programados calculan metricas hourly/daily
|
|
- [ ] Superadmin puede ver metricas globales
|
|
- [ ] Exportacion de datos funciona (JSON, CSV)
|
|
- [ ] Performance < 500ms para dashboard load
|
|
- [ ] Tests unitarios con cobertura >70%
|
|
|
|
---
|
|
|
|
## 8. Riesgos y Mitigaciones
|
|
|
|
| Riesgo | Probabilidad | Impacto | Mitigacion |
|
|
|--------|--------------|---------|------------|
|
|
| Queries lentas | Alta | Alto | Indexes + cache + jobs |
|
|
| Cache stale | Media | Medio | TTLs cortos + invalidation |
|
|
| Datos incorrectos | Baja | Alto | Tests + validacion cruzada |
|
|
| Alto uso CPU | Media | Medio | Jobs en horarios off-peak |
|
|
|
|
---
|
|
|
|
*ET-SAAS-016 v1.0.0 - Template SaaS*
|