template-saas/docs/02-especificaciones/ET-SAAS-016-analytics.md
Adrian Flores Cortes 114b81ba57
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions
[TASK-030/031] docs: Update OAuth and Analytics status to Completed/Implemented
- SAAS-015 OAuth: Specified -> Completed (already implemented)
- SAAS-016 Analytics: Specified -> Completed (already implemented)
- ET-SAAS-015: Proposed -> Implemented
- ET-SAAS-016: Proposed -> Implemented

Backend implementation verified:
- OAuth: 799 lines (controller + service)
- Analytics: 513 lines with full metrics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:35:57 -06:00

23 KiB

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

  • 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