feat(stats): add user statistics to Prometheus metrics and Grafana

- Add user metrics to mana-core-auth MetricsService:
  - auth_users_total: Total registered users
  - auth_users_verified: Email-verified users
  - auth_users_created_today/this_week/this_month
- Create Grafana user-statistics dashboard with:
  - User overview stats (total, verified, verification rate, new today)
  - Registration period breakdown (today/week/month)
  - User growth trends over time
- Enhance telegram-stats-bot /users command:
  - Add yesterday comparison with trends
  - Add week-over-week comparison
  - Add mini bar chart for last 7 days registration
- Include user stats in daily Telegram report
This commit is contained in:
Till-JS 2026-01-26 10:53:57 +01:00
parent 9fedb7cfdd
commit 0cd2bc858a
6 changed files with 798 additions and 13 deletions

View file

@ -1,15 +1,28 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as client from 'prom-client';
import { count, eq, gte, and, isNull, sql } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { users } from '../db/schema';
@Injectable()
export class MetricsService implements OnModuleInit {
export class MetricsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MetricsService.name);
private readonly register: client.Registry;
private updateInterval: NodeJS.Timeout | null = null;
// HTTP metrics
readonly httpRequestsTotal: client.Counter<string>;
readonly httpRequestDuration: client.Histogram<string>;
constructor() {
// User metrics
readonly usersTotal: client.Gauge<string>;
readonly usersVerified: client.Gauge<string>;
readonly usersCreatedToday: client.Gauge<string>;
readonly usersCreatedThisWeek: client.Gauge<string>;
readonly usersCreatedThisMonth: client.Gauge<string>;
constructor(private readonly configService: ConfigService) {
this.register = new client.Registry();
// Add default metrics (CPU, memory, event loop, etc.)
@ -34,10 +47,113 @@ export class MetricsService implements OnModuleInit {
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [this.register],
});
// User statistics gauges
this.usersTotal = new client.Gauge({
name: 'auth_users_total',
help: 'Total number of registered users',
registers: [this.register],
});
this.usersVerified = new client.Gauge({
name: 'auth_users_verified',
help: 'Number of email-verified users',
registers: [this.register],
});
this.usersCreatedToday = new client.Gauge({
name: 'auth_users_created_today',
help: 'Number of users created today',
registers: [this.register],
});
this.usersCreatedThisWeek = new client.Gauge({
name: 'auth_users_created_this_week',
help: 'Number of users created this week',
registers: [this.register],
});
this.usersCreatedThisMonth = new client.Gauge({
name: 'auth_users_created_this_month',
help: 'Number of users created this month',
registers: [this.register],
});
}
onModuleInit() {
// Metrics are ready
async onModuleInit() {
// Update user metrics immediately and then every 60 seconds
await this.updateUserMetrics();
this.updateInterval = setInterval(() => this.updateUserMetrics(), 60000);
}
onModuleDestroy() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
private async updateUserMetrics() {
const databaseUrl = this.configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
this.logger.warn('DATABASE_URL not configured, user metrics unavailable');
return;
}
try {
const db = getDb(databaseUrl);
const now = new Date();
// Start of today (midnight)
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
// Start of week (Monday)
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
// Start of month
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Query all metrics in parallel
const [totalResult, verifiedResult, todayResult, weekResult, monthResult] = await Promise.all(
[
// Total users
db.select({ count: count() }).from(users).where(isNull(users.deletedAt)),
// Verified users
db
.select({ count: count() })
.from(users)
.where(and(isNull(users.deletedAt), eq(users.emailVerified, true))),
// Users created today
db
.select({ count: count() })
.from(users)
.where(and(isNull(users.deletedAt), gte(users.createdAt, startOfToday))),
// Users created this week
db
.select({ count: count() })
.from(users)
.where(and(isNull(users.deletedAt), gte(users.createdAt, startOfWeek))),
// Users created this month
db
.select({ count: count() })
.from(users)
.where(and(isNull(users.deletedAt), gte(users.createdAt, startOfMonth))),
]
);
this.usersTotal.set(totalResult[0].count);
this.usersVerified.set(verifiedResult[0].count);
this.usersCreatedToday.set(todayResult[0].count);
this.usersCreatedThisWeek.set(weekResult[0].count);
this.usersCreatedThisMonth.set(monthResult[0].count);
} catch (error) {
this.logger.error('Failed to update user metrics:', error);
}
}
async getMetrics(): Promise<string> {

View file

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { UmamiModule } from '../umami/umami.module';
import { UsersModule } from '../users/users.module';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [UmamiModule],
imports: [UmamiModule, UsersModule],
providers: [AnalyticsService],
exports: [AnalyticsService],
})

View file

@ -1,17 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { UmamiService, UmamiStats } from '../umami/umami.service';
import { UsersService, UserStats } from '../users/users.service';
import {
formatDailyReport,
formatWeeklyReport,
formatRealtimeReport,
formatStatsOverview,
formatUsersReportCompact,
} from './formatters';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(private readonly umamiService: UmamiService) {}
constructor(
private readonly umamiService: UmamiService,
private readonly usersService: UsersService
) {}
private getStartOfDay(date: Date = new Date()): Date {
const start = new Date(date);
@ -75,7 +80,15 @@ export class AnalyticsService {
async generateDailyReport(): Promise<string> {
try {
const stats = await this.getTodayStats();
return formatDailyReport(stats, new Date());
let report = formatDailyReport(stats, new Date());
// Add user stats to daily report
const userStats = await this.usersService.getUserStats();
if (userStats) {
report += formatUsersReportCompact(userStats);
}
return report;
} catch (error) {
this.logger.error('Failed to generate daily report:', error);
return '❌ Fehler beim Erstellen des Daily Reports';

View file

@ -203,27 +203,103 @@ Verfügbare Befehle:
Weekly: Jeden Montag um 9:00`;
}
export interface DailyRegistration {
date: string;
count: number;
}
export interface UserStats {
totalUsers: number;
verifiedUsers: number;
todayNewUsers: number;
yesterdayNewUsers: number;
weekNewUsers: number;
lastWeekNewUsers: number;
monthNewUsers: number;
dailyRegistrations: DailyRegistration[];
}
function createMiniBarChart(dailyRegistrations: DailyRegistration[]): string[] {
if (dailyRegistrations.length === 0) return [];
const maxCount = Math.max(...dailyRegistrations.map((d) => d.count), 1);
const barChars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
// Fill in missing days and sort
const last7Days: DailyRegistration[] = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const found = dailyRegistrations.find((d) => d.date === dateStr);
last7Days.push({ date: dateStr, count: found?.count || 0 });
}
const bars = last7Days.map((d) => {
const index = Math.floor((d.count / maxCount) * (barChars.length - 1));
return barChars[Math.max(0, index)];
});
const dayLabels = last7Days.map((d) => {
const date = new Date(d.date);
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][date.getDay()];
});
return [`<code>${bars.join('')}</code>`, `<code>${dayLabels.join('')}</code>`];
}
export function formatUsersReport(stats: UserStats): string {
const verificationRate =
stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0;
// Calculate trends
const dailyTrend =
stats.yesterdayNewUsers > 0
? ((stats.todayNewUsers - stats.yesterdayNewUsers) / stats.yesterdayNewUsers) * 100
: stats.todayNewUsers > 0
? 100
: 0;
const weeklyTrend =
stats.lastWeekNewUsers > 0
? ((stats.weekNewUsers - stats.lastWeekNewUsers) / stats.lastWeekNewUsers) * 100
: stats.weekNewUsers > 0
? 100
: 0;
const lines: string[] = [
'👥 <b>ManaCore User Statistics</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`👤 <b>Gesamt:</b> ${formatNumber(stats.totalUsers)}`,
`✅ <b>Verifiziert:</b> ${formatNumber(stats.verifiedUsers)}`,
'<b>📊 Übersicht</b>',
` 👤 Gesamt: <b>${formatNumber(stats.totalUsers)}</b>`,
` ✅ Verifiziert: ${formatNumber(stats.verifiedUsers)} (${verificationRate}%)`,
'',
'<b>📊 Neue Registrierungen:</b>',
` Heute: +${formatNumber(stats.todayNewUsers)}`,
` Diese Woche: +${formatNumber(stats.weekNewUsers)}`,
'<b>📈 Neue Registrierungen</b>',
` Heute: <b>+${formatNumber(stats.todayNewUsers)}</b> ${formatChangeEmoji(dailyTrend)}`,
` Gestern: +${formatNumber(stats.yesterdayNewUsers)}`,
` Diese Woche: +${formatNumber(stats.weekNewUsers)} ${formatChange(weeklyTrend)} ${formatChangeEmoji(weeklyTrend)}`,
` Dieser Monat: +${formatNumber(stats.monthNewUsers)}`,
];
// Add mini bar chart for last 7 days
if (stats.dailyRegistrations.length > 0) {
lines.push('');
lines.push('<b>📅 Letzte 7 Tage</b>');
lines.push(...createMiniBarChart(stats.dailyRegistrations));
}
return lines.join('\n');
}
export function formatUsersReportCompact(stats: UserStats): string {
const verificationRate =
stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0;
return [
'',
'<b>👥 Registrierte User</b>',
` Gesamt: <b>${formatNumber(stats.totalUsers)}</b> (${verificationRate}% verifiziert)`,
` Heute: +${formatNumber(stats.todayNewUsers)} | Woche: +${formatNumber(stats.weekNewUsers)} | Monat: +${formatNumber(stats.monthNewUsers)}`,
].join('\n');
}

View file

@ -8,6 +8,14 @@ export interface UserStats {
todayNewUsers: number;
weekNewUsers: number;
monthNewUsers: number;
yesterdayNewUsers: number;
lastWeekNewUsers: number;
dailyRegistrations: DailyRegistration[];
}
export interface DailyRegistration {
date: string;
count: number;
}
@Injectable()
@ -43,31 +51,60 @@ export class UsersService implements OnModuleInit {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday);
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Main stats query
const [result] = await this.sql`
SELECT
COUNT(*) as total_users,
COUNT(*) FILTER (WHERE email_verified = true) as verified_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfToday.toISOString()}) as today_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfYesterday.toISOString()} AND created_at < ${startOfToday.toISOString()}) as yesterday_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfWeek.toISOString()}) as week_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfLastWeek.toISOString()} AND created_at < ${startOfWeek.toISOString()}) as last_week_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfMonth.toISOString()}) as month_new_users
FROM auth.users
WHERE deleted_at IS NULL
`;
// Get daily registrations for last 7 days
const dailyStats = await this.sql`
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM auth.users
WHERE deleted_at IS NULL
AND created_at >= ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()}
GROUP BY DATE(created_at)
ORDER BY date DESC
`;
const dailyRegistrations: DailyRegistration[] = dailyStats.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
count: Number(row.count),
}));
return {
totalUsers: Number(result.total_users),
verifiedUsers: Number(result.verified_users),
todayNewUsers: Number(result.today_new_users),
yesterdayNewUsers: Number(result.yesterday_new_users),
weekNewUsers: Number(result.week_new_users),
lastWeekNewUsers: Number(result.last_week_new_users),
monthNewUsers: Number(result.month_new_users),
dailyRegistrations,
};
} catch (error) {
this.logger.error('Failed to fetch user stats:', error);