mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
📈 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:
parent
2e7378710f
commit
9dfad0128a
17 changed files with 2901 additions and 18 deletions
3
services/mana-core-auth/.gitignore
vendored
3
services/mana-core-auth/.gitignore
vendored
|
|
@ -45,3 +45,6 @@ coverage/
|
|||
.cache/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# DuckDB local data
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
135
services/mana-core-auth/src/analytics/analytics.controller.ts
Normal file
135
services/mana-core-auth/src/analytics/analytics.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
12
services/mana-core-auth/src/analytics/analytics.module.ts
Normal file
12
services/mana-core-auth/src/analytics/analytics.module.ts
Normal 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 {}
|
||||
327
services/mana-core-auth/src/analytics/analytics.service.ts
Normal file
327
services/mana-core-auth/src/analytics/analytics.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
services/mana-core-auth/src/analytics/index.ts
Normal file
3
services/mana-core-auth/src/analytics/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './analytics.module';
|
||||
export * from './analytics.service';
|
||||
export * from './analytics.controller';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue