📈 feat(monitoring): upgrade to VictoriaMetrics + DuckDB analytics

- Replace Prometheus with VictoriaMetrics (2-year retention)
- Add DuckDB analytics module for business KPIs (unlimited retention)
- Add master overview dashboard combining all metrics
- Add business metrics dashboard for user growth tracking
- Add backup script for VictoriaMetrics snapshots and DuckDB
- Add ADR documentation for monitoring stack decision

Analytics API endpoints:
- GET /api/v1/analytics/health - Service health
- GET /api/v1/analytics/latest - Latest metrics snapshot
- GET /api/v1/analytics/growth - User growth over time
- GET /api/v1/analytics/monthly - Monthly aggregates
- POST /api/v1/analytics/snapshot - Manual snapshot trigger
This commit is contained in:
Till-JS 2026-01-28 12:38:04 +01:00
parent 2e7378710f
commit 9dfad0128a
17 changed files with 2901 additions and 18 deletions

View file

@ -45,3 +45,6 @@ coverage/
.cache/
tmp/
temp/
# DuckDB local data
data/

View file

@ -50,7 +50,8 @@
"rxjs": "^7.8.1",
"stripe": "^17.5.0",
"winston": "^3.17.0",
"zod": "^3.24.1"
"zod": "^3.24.1",
"duckdb-async": "^1.1.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",

View file

@ -0,0 +1,135 @@
import { Controller, Get, Post, Query, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { AnalyticsService } from './analytics.service';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
/**
* Health check endpoint
*/
@Get('health')
async getHealth() {
return this.analyticsService.getHealth();
}
/**
* Get latest metrics snapshot
*/
@Get('latest')
async getLatest() {
const metrics = await this.analyticsService.getLatestMetrics();
if (!metrics) {
return { message: 'No metrics recorded yet' };
}
return metrics;
}
/**
* Get user growth data
* @param days Number of days to look back (default: 90)
*/
@Get('growth')
async getGrowth(@Query('days') days?: string) {
const numDays = days ? parseInt(days, 10) : 90;
return this.analyticsService.getUserGrowth(numDays);
}
/**
* Get monthly aggregated metrics
* @param months Number of months to look back (default: 12)
*/
@Get('monthly')
async getMonthly(@Query('months') months?: string) {
const numMonths = months ? parseInt(months, 10) : 12;
return this.analyticsService.getMonthlyMetrics(numMonths);
}
/**
* Get metrics for a date range
* @param start Start date (YYYY-MM-DD)
* @param end End date (YYYY-MM-DD)
*/
@Get('range')
async getRange(@Query('start') start: string, @Query('end') end: string) {
if (!start || !end) {
return { error: 'Both start and end dates are required (YYYY-MM-DD format)' };
}
return this.analyticsService.getMetricsRange(start, end);
}
/**
* Trigger manual snapshot (for testing/recovery)
*/
@Post('snapshot')
async triggerSnapshot() {
await this.analyticsService.recordDailySnapshot();
return { message: 'Snapshot recorded successfully' };
}
/**
* Grafana JSON API compatible endpoint - query
* Used by Grafana Infinity datasource
*/
@Post('grafana/query')
async grafanaQuery(@Res() res: Response) {
// Return available targets
const latest = await this.analyticsService.getLatestMetrics();
const growth = await this.analyticsService.getUserGrowth(30);
res.status(HttpStatus.OK).json([
{
target: 'total_users',
datapoints: growth.map((g) => [g.total_users, new Date(g.date).getTime()]),
},
{
target: 'daily_growth',
datapoints: growth.map((g) => [g.growth ?? 0, new Date(g.date).getTime()]),
},
]);
}
/**
* Grafana JSON API compatible endpoint - search
* Returns available metrics
*/
@Post('grafana/search')
async grafanaSearch() {
return [
'total_users',
'verified_users',
'new_users_today',
'new_users_week',
'new_users_month',
'daily_growth',
];
}
/**
* Summary endpoint for dashboards
*/
@Get('summary')
async getSummary() {
const latest = await this.analyticsService.getLatestMetrics();
const monthly = await this.analyticsService.getMonthlyMetrics(2);
const health = await this.analyticsService.getHealth();
const currentMonth = monthly[monthly.length - 1];
const previousMonth = monthly[monthly.length - 2];
return {
current: latest,
trends: {
month_over_month_growth:
currentMonth && previousMonth
? ((currentMonth.total_users_eom - previousMonth.total_users_eom) /
previousMonth.total_users_eom) *
100
: null,
new_users_this_month: currentMonth?.new_users ?? 0,
},
health,
};
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { AnalyticsService } from './analytics.service';
import { AnalyticsController } from './analytics.controller';
@Module({
imports: [ScheduleModule.forRoot()],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View file

@ -0,0 +1,327 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Database } from 'duckdb-async';
import { sql } from 'drizzle-orm';
import { getDb } from '../db/connection';
import * as fs from 'fs';
import * as path from 'path';
export interface DailyMetrics {
date: string;
total_users: number;
verified_users: number;
new_users_today: number;
new_users_week: number;
new_users_month: number;
total_db_size_bytes: number | null;
recorded_at: string;
}
export interface GrowthData {
date: string;
total_users: number;
growth: number | null;
growth_percent: number | null;
}
export interface MonthlyMetrics {
month: string;
total_users_eom: number;
new_users: number;
growth_percent: number | null;
}
@Injectable()
export class AnalyticsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AnalyticsService.name);
private duckdb: Database | null = null;
private readonly dbPath: string;
private readonly databaseUrl: string;
constructor(private readonly configService: ConfigService) {
this.dbPath = this.configService.get<string>('DUCKDB_PATH', './data/metrics.duckdb');
this.databaseUrl = this.configService.get<string>('DATABASE_URL', '');
}
async onModuleInit(): Promise<void> {
try {
// Ensure the directory exists
const dbDir = path.dirname(this.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
this.logger.log(`Created DuckDB directory: ${dbDir}`);
}
this.duckdb = await Database.create(this.dbPath);
await this.initializeSchema();
this.logger.log(`DuckDB initialized at ${this.dbPath}`);
// Record initial snapshot if database is empty
const count = await this.getRecordCount();
if (count === 0) {
this.logger.log('No existing records found, recording initial snapshot...');
await this.recordDailySnapshot();
}
} catch (error) {
this.logger.error('Failed to initialize DuckDB', error);
}
}
async onModuleDestroy(): Promise<void> {
if (this.duckdb) {
await this.duckdb.close();
this.logger.log('DuckDB connection closed');
}
}
private async initializeSchema(): Promise<void> {
if (!this.duckdb) return;
await this.duckdb.run(`
CREATE TABLE IF NOT EXISTS daily_metrics (
date DATE PRIMARY KEY,
total_users INTEGER NOT NULL,
verified_users INTEGER NOT NULL,
new_users_today INTEGER NOT NULL,
new_users_week INTEGER NOT NULL,
new_users_month INTEGER NOT NULL,
total_db_size_bytes BIGINT,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
this.logger.log('DuckDB schema initialized');
}
private async getRecordCount(): Promise<number> {
if (!this.duckdb) return 0;
const result = await this.duckdb.all('SELECT COUNT(*) as count FROM daily_metrics');
return Number(result[0]?.count ?? 0);
}
/**
* Record daily snapshot - runs at midnight UTC
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async recordDailySnapshot(): Promise<void> {
if (!this.duckdb) {
this.logger.warn('DuckDB not initialized, skipping snapshot');
return;
}
try {
const today = new Date().toISOString().split('T')[0];
// Get user counts from PostgreSQL
const [totalUsers, verifiedUsers, newToday, newWeek, newMonth, dbSize] = await Promise.all([
this.countTotalUsers(),
this.countVerifiedUsers(),
this.countUsersCreatedSince(1),
this.countUsersCreatedSince(7),
this.countUsersCreatedSince(30),
this.getDatabaseSize(),
]);
// Insert or replace in DuckDB
await this.duckdb.run(
`
INSERT OR REPLACE INTO daily_metrics
(date, total_users, verified_users, new_users_today, new_users_week, new_users_month, total_db_size_bytes, recorded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`,
today,
totalUsers,
verifiedUsers,
newToday,
newWeek,
newMonth,
dbSize
);
this.logger.log(`Daily snapshot recorded for ${today}: ${totalUsers} total users`);
} catch (error) {
this.logger.error('Failed to record daily snapshot', error);
}
}
/**
* Get user growth over time
*/
async getUserGrowth(days: number = 90): Promise<GrowthData[]> {
if (!this.duckdb) return [];
const result = await this.duckdb.all(
`
SELECT
date::VARCHAR as date,
total_users,
total_users - LAG(total_users) OVER (ORDER BY date) as growth,
ROUND(((total_users::FLOAT - LAG(total_users) OVER (ORDER BY date)) /
NULLIF(LAG(total_users) OVER (ORDER BY date), 0)) * 100, 2) as growth_percent
FROM daily_metrics
WHERE date > CURRENT_DATE - INTERVAL '${days} days'
ORDER BY date
`
);
return result as GrowthData[];
}
/**
* Get monthly aggregated metrics
*/
async getMonthlyMetrics(months: number = 12): Promise<MonthlyMetrics[]> {
if (!this.duckdb) return [];
const result = await this.duckdb.all(
`
SELECT
strftime(date_trunc('month', date), '%Y-%m') as month,
MAX(total_users)::INTEGER as total_users_eom,
SUM(new_users_today)::INTEGER as new_users,
ROUND(((MAX(total_users)::FLOAT - MIN(total_users)) /
NULLIF(MIN(total_users), 0)) * 100, 2) as growth_percent
FROM daily_metrics
WHERE date > CURRENT_DATE - INTERVAL '${months} months'
GROUP BY date_trunc('month', date)
ORDER BY month
`
);
return result as MonthlyMetrics[];
}
/**
* Get latest metrics
*/
async getLatestMetrics(): Promise<DailyMetrics | null> {
if (!this.duckdb) return null;
const result = await this.duckdb.all(`
SELECT
date::VARCHAR as date,
total_users,
verified_users,
new_users_today,
new_users_week,
new_users_month,
total_db_size_bytes::INTEGER as total_db_size_bytes,
recorded_at::VARCHAR as recorded_at
FROM daily_metrics
ORDER BY date DESC
LIMIT 1
`);
return (result[0] as DailyMetrics) ?? null;
}
/**
* Get all metrics for a date range
*/
async getMetricsRange(startDate: string, endDate: string): Promise<DailyMetrics[]> {
if (!this.duckdb) return [];
const result = await this.duckdb.all(
`
SELECT
date::VARCHAR as date,
total_users,
verified_users,
new_users_today,
new_users_week,
new_users_month,
total_db_size_bytes::INTEGER as total_db_size_bytes,
recorded_at::VARCHAR as recorded_at
FROM daily_metrics
WHERE date BETWEEN ? AND ?
ORDER BY date
`,
startDate,
endDate
);
return result as DailyMetrics[];
}
/**
* Health check for the analytics service
*/
async getHealth(): Promise<{
status: string;
database_path: string;
database_size_bytes: number | null;
total_records: number;
latest_snapshot: string | null;
}> {
const recordCount = await this.getRecordCount();
const latest = await this.getLatestMetrics();
return {
status: this.duckdb ? 'healthy' : 'unhealthy',
database_path: this.dbPath,
database_size_bytes: null, // DuckDB doesn't expose this easily
total_records: recordCount,
latest_snapshot: latest?.date ?? null,
};
}
/**
* Export metrics to Parquet format (for archival)
*/
async exportToParquet(outputPath: string): Promise<void> {
if (!this.duckdb) {
throw new Error('DuckDB not initialized');
}
await this.duckdb.run(`COPY daily_metrics TO '${outputPath}' (FORMAT PARQUET)`);
this.logger.log(`Metrics exported to ${outputPath}`);
}
// ============================================
// PostgreSQL Query Helpers
// ============================================
private getPostgresDb() {
if (!this.databaseUrl) {
throw new Error('DATABASE_URL not configured');
}
return getDb(this.databaseUrl);
}
private async countTotalUsers(): Promise<number> {
const db = this.getPostgresDb();
const result = await db.execute(sql`SELECT COUNT(*) as count FROM auth.users`);
const row = result[0] as { count: string | number } | undefined;
return Number(row?.count ?? 0);
}
private async countVerifiedUsers(): Promise<number> {
const db = this.getPostgresDb();
const result = await db.execute(
sql`SELECT COUNT(*) as count FROM auth.users WHERE email_verified = true`
);
const row = result[0] as { count: string | number } | undefined;
return Number(row?.count ?? 0);
}
private async countUsersCreatedSince(days: number): Promise<number> {
const db = this.getPostgresDb();
const result = await db.execute(
sql`SELECT COUNT(*) as count FROM auth.users WHERE created_at > NOW() - INTERVAL '${sql.raw(days.toString())} days'`
);
const row = result[0] as { count: string | number } | undefined;
return Number(row?.count ?? 0);
}
private async getDatabaseSize(): Promise<number | null> {
try {
const db = this.getPostgresDb();
const result = await db.execute(sql`SELECT pg_database_size(current_database()) as size`);
const row = result[0] as { size: string | number } | undefined;
return Number(row?.size ?? 0);
} catch {
return null;
}
}
}

View file

@ -0,0 +1,3 @@
export * from './analytics.module';
export * from './analytics.service';
export * from './analytics.controller';

View file

@ -12,6 +12,7 @@ import { TagsModule } from './tags/tags.module';
import { AiModule } from './ai/ai.module';
import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics';
import { AnalyticsModule } from './analytics';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@Module({
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
},
]),
MetricsModule,
AnalyticsModule,
AiModule,
AuthModule,
CreditsModule,