| id |
title |
type |
status |
priority |
module |
version |
created_date |
updated_date |
story_points |
| ET-SAAS-016 |
Especificacion Tecnica Analytics Dashboard |
TechnicalSpec |
Implemented |
P2 |
analytics |
1.0.0 |
2026-01-24 |
2026-01-24 |
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
-- 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
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
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
@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
@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
@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
@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
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
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
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
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