--- 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:** Propuesto - **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, @InjectRepository(Subscription) private subsRepo: Repository, @InjectRepository(Invoice) private invoicesRepo: Repository, @InjectRepository(Session) private sessionsRepo: Repository, @InjectRepository(AuditLog) private auditRepo: Repository, @InjectRepository(File) private filesRepo: Repository, @InjectRepository(AiUsage) private aiUsageRepo: Repository, ) {} async calculateUserMetrics( tenantId: string, startDate: Date, endDate: Date, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 ; return (
{/* Period Selector */}

Analytics

{/* KPI Cards */}
} /> } /> } />
{/* Trend Chart */} Trends {/* Usage Breakdown */}
); }; ``` ### 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 = ({ data, height = 300 }) => { return ( ); }; ``` ### 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*