mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 09:26:42 +02:00
feat: add monitoring dashboard (Prometheus + Grafana + Umami + Admin)
Phase 1: Infrastructure - Add docker/prometheus/prometheus.yml with scrape configs for all services - Add docker/grafana/provisioning for auto-configured datasources - Add docker/grafana/dashboards (system-overview, backends-docker) - Update docker-compose.macmini.yml with monitoring services: - prometheus, grafana, node-exporter, cadvisor - postgres-exporter, redis-exporter, umami - Add grafana.mana.how and analytics.mana.how to Caddyfile Phase 2: Backend Metrics - Create packages/shared-nestjs-metrics with: - MetricsModule (auto /metrics endpoint) - MetricsService (Counter, Histogram, Gauge helpers) - MetricsMiddleware (auto HTTP request tracking) Phase 3: Umami Web Analytics - Add Umami tracking scripts to all landing pages - Add Umami tracking scripts to all web apps - Create scripts/mac-mini/setup-umami-db.sh Phase 4: Admin Dashboard (ManaCore Web) - Add admin routes: /admin, /admin/users, /admin/system - Create StatCard, QuickLinks, UserTable components - Add Admin link to navigation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ad7a84feef
commit
6d86a08d63
36 changed files with 2779 additions and 559 deletions
39
packages/shared-nestjs-metrics/package.json
Normal file
39
packages/shared-nestjs-metrics/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@manacore/shared-nestjs-metrics",
|
||||
"version": "1.0.0",
|
||||
"description": "Prometheus metrics module for NestJS backends",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"nestjs",
|
||||
"metrics",
|
||||
"prometheus",
|
||||
"monitoring",
|
||||
"manacore"
|
||||
],
|
||||
"author": "Mana Core Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
62
packages/shared-nestjs-metrics/src/index.ts
Normal file
62
packages/shared-nestjs-metrics/src/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @manacore/shared-nestjs-metrics
|
||||
*
|
||||
* Prometheus metrics module for NestJS backends.
|
||||
* Automatically tracks HTTP requests, duration, and provides custom metrics.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
*
|
||||
* @Module({
|
||||
* imports: [
|
||||
* MetricsModule.register({
|
||||
* prefix: 'myapp_',
|
||||
* defaultLabels: { app: 'my-backend' },
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* The module automatically:
|
||||
* - Exposes a `/metrics` endpoint for Prometheus scraping
|
||||
* - Tracks HTTP request count, duration, and in-flight requests
|
||||
* - Collects default Node.js metrics (CPU, memory, event loop)
|
||||
*
|
||||
* Custom metrics can be created via the MetricsService:
|
||||
* ```typescript
|
||||
* @Injectable()
|
||||
* export class MyService {
|
||||
* private readonly aiRequestCounter: Counter;
|
||||
*
|
||||
* constructor(private readonly metricsService: MetricsService) {
|
||||
* this.aiRequestCounter = metricsService.createCounter(
|
||||
* 'ai_requests_total',
|
||||
* 'Total AI requests',
|
||||
* ['model']
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* async processAI(model: string) {
|
||||
* this.aiRequestCounter.inc({ model });
|
||||
* // ... process
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Module
|
||||
export { MetricsModule, MetricsModuleOptions } from './metrics.module';
|
||||
|
||||
// Service
|
||||
export { MetricsService, MetricsServiceOptions } from './metrics.service';
|
||||
|
||||
// Middleware
|
||||
export { MetricsMiddleware } from './metrics.middleware';
|
||||
|
||||
// Controller
|
||||
export { MetricsController } from './metrics.controller';
|
||||
|
||||
// Re-export prom-client types for convenience
|
||||
export { Counter, Histogram, Gauge, Summary } from 'prom-client';
|
||||
15
packages/shared-nestjs-metrics/src/metrics.controller.ts
Normal file
15
packages/shared-nestjs-metrics/src/metrics.controller.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Controller()
|
||||
export class MetricsController {
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
@Get('metrics')
|
||||
async getMetrics(@Res() res: Response): Promise<void> {
|
||||
const metrics = await this.metricsService.getMetrics();
|
||||
res.set('Content-Type', this.metricsService.getContentType());
|
||||
res.send(metrics);
|
||||
}
|
||||
}
|
||||
35
packages/shared-nestjs-metrics/src/metrics.middleware.ts
Normal file
35
packages/shared-nestjs-metrics/src/metrics.middleware.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsMiddleware implements NestMiddleware {
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
// Skip metrics endpoint itself to avoid recursion
|
||||
if (req.path === '/metrics') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const method = req.method;
|
||||
|
||||
// Track in-flight requests
|
||||
this.metricsService.incrementInFlight(method);
|
||||
|
||||
// Hook into response finish
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
const status = res.statusCode;
|
||||
const path = req.route?.path || req.path;
|
||||
|
||||
// Record metrics
|
||||
this.metricsService.incrementHttpRequests(method, path, status);
|
||||
this.metricsService.observeHttpDuration(method, path, status, duration);
|
||||
this.metricsService.decrementInFlight(method);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
79
packages/shared-nestjs-metrics/src/metrics.module.ts
Normal file
79
packages/shared-nestjs-metrics/src/metrics.module.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Module, DynamicModule, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { MetricsService, MetricsServiceOptions } from './metrics.service';
|
||||
import { MetricsMiddleware } from './metrics.middleware';
|
||||
import { MetricsController } from './metrics.controller';
|
||||
|
||||
export interface MetricsModuleOptions extends MetricsServiceOptions {
|
||||
/**
|
||||
* Path for metrics endpoint (default: '/metrics')
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Paths to exclude from metrics collection
|
||||
*/
|
||||
excludePaths?: string[];
|
||||
|
||||
/**
|
||||
* Whether to register the metrics endpoint controller (default: true)
|
||||
*/
|
||||
registerController?: boolean;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class MetricsModule implements NestModule {
|
||||
private static options: MetricsModuleOptions = {};
|
||||
|
||||
/**
|
||||
* Register the metrics module with options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
*
|
||||
* @Module({
|
||||
* imports: [
|
||||
* MetricsModule.register({
|
||||
* prefix: 'myapp_',
|
||||
* defaultLabels: { app: 'my-backend' },
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*/
|
||||
static register(options: MetricsModuleOptions = {}): DynamicModule {
|
||||
MetricsModule.options = options;
|
||||
|
||||
const providers = [
|
||||
{
|
||||
provide: MetricsService,
|
||||
useFactory: () => new MetricsService(options),
|
||||
},
|
||||
MetricsMiddleware,
|
||||
];
|
||||
|
||||
const controllers = options.registerController !== false ? [MetricsController] : [];
|
||||
|
||||
return {
|
||||
module: MetricsModule,
|
||||
controllers,
|
||||
providers,
|
||||
exports: [MetricsService],
|
||||
global: true,
|
||||
};
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
const excludePaths = MetricsModule.options.excludePaths || [];
|
||||
const metricsPath = MetricsModule.options.path || '/metrics';
|
||||
|
||||
// Always exclude the metrics endpoint itself
|
||||
const allExcludePaths = [...excludePaths, metricsPath];
|
||||
|
||||
consumer
|
||||
.apply(MetricsMiddleware)
|
||||
.exclude(...allExcludePaths)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
173
packages/shared-nestjs-metrics/src/metrics.service.ts
Normal file
173
packages/shared-nestjs-metrics/src/metrics.service.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics, register } from 'prom-client';
|
||||
|
||||
export interface MetricsServiceOptions {
|
||||
prefix?: string;
|
||||
defaultLabels?: Record<string, string>;
|
||||
collectDefaultMetrics?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService implements OnModuleInit {
|
||||
private readonly registry: Registry;
|
||||
private readonly httpRequestsTotal: Counter;
|
||||
private readonly httpRequestDuration: Histogram;
|
||||
private readonly httpRequestsInFlight: Gauge;
|
||||
|
||||
constructor(private readonly options: MetricsServiceOptions = {}) {
|
||||
this.registry = register;
|
||||
|
||||
const prefix = options.prefix || '';
|
||||
|
||||
// Set default labels if provided
|
||||
if (options.defaultLabels) {
|
||||
this.registry.setDefaultLabels(options.defaultLabels);
|
||||
}
|
||||
|
||||
// HTTP Request Counter
|
||||
this.httpRequestsTotal = new Counter({
|
||||
name: `${prefix}http_requests_total`,
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
registers: [this.registry],
|
||||
});
|
||||
|
||||
// HTTP Request Duration Histogram
|
||||
this.httpRequestDuration = new Histogram({
|
||||
name: `${prefix}http_request_duration_seconds`,
|
||||
help: 'HTTP request duration in seconds',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
||||
registers: [this.registry],
|
||||
});
|
||||
|
||||
// HTTP Requests In Flight
|
||||
this.httpRequestsInFlight = new Gauge({
|
||||
name: `${prefix}http_requests_in_flight`,
|
||||
help: 'Number of HTTP requests currently being processed',
|
||||
labelNames: ['method'],
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
// Collect default Node.js metrics (CPU, memory, event loop, etc.)
|
||||
if (this.options.collectDefaultMetrics !== false) {
|
||||
collectDefaultMetrics({
|
||||
register: this.registry,
|
||||
prefix: this.options.prefix || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment HTTP request counter
|
||||
*/
|
||||
incrementHttpRequests(method: string, path: string, status: number): void {
|
||||
this.httpRequestsTotal.inc({
|
||||
method,
|
||||
path: this.normalizePath(path),
|
||||
status: String(status),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe HTTP request duration
|
||||
*/
|
||||
observeHttpDuration(method: string, path: string, status: number, durationMs: number): void {
|
||||
this.httpRequestDuration.observe(
|
||||
{
|
||||
method,
|
||||
path: this.normalizePath(path),
|
||||
status: String(status),
|
||||
},
|
||||
durationMs / 1000 // Convert to seconds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment in-flight requests
|
||||
*/
|
||||
incrementInFlight(method: string): void {
|
||||
this.httpRequestsInFlight.inc({ method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement in-flight requests
|
||||
*/
|
||||
decrementInFlight(method: string): void {
|
||||
this.httpRequestsInFlight.dec({ method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics as Prometheus text format
|
||||
*/
|
||||
async getMetrics(): Promise<string> {
|
||||
return this.registry.metrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type for metrics endpoint
|
||||
*/
|
||||
getContentType(): string {
|
||||
return this.registry.contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom counter
|
||||
*/
|
||||
createCounter(name: string, help: string, labelNames: string[] = []): Counter {
|
||||
return new Counter({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom histogram
|
||||
*/
|
||||
createHistogram(
|
||||
name: string,
|
||||
help: string,
|
||||
labelNames: string[] = [],
|
||||
buckets?: number[]
|
||||
): Histogram {
|
||||
return new Histogram({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
buckets,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom gauge
|
||||
*/
|
||||
createGauge(name: string, help: string, labelNames: string[] = []): Gauge {
|
||||
return new Gauge({
|
||||
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
|
||||
help,
|
||||
labelNames,
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path to prevent high cardinality
|
||||
* Replaces UUIDs and numeric IDs with placeholders
|
||||
*/
|
||||
private normalizePath(path: string): string {
|
||||
return (
|
||||
path
|
||||
// Replace UUIDs
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
// Remove query strings
|
||||
.split('?')[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
22
packages/shared-nestjs-metrics/tsconfig.json
Normal file
22
packages/shared-nestjs-metrics/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2021"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue