feat: Prometheus support - GET /metrics - closes #2245 fix #2245

This commit is contained in:
devlikepro committed 2026-08-31 17:23:56 +07:00
1 parent 89543ab371
commit b7c4eac93b
12 files changed
+557 -1

No files matched your search

+14
View File
@@ -56,6 +56,20 @@ WAHA_LOG_LEVEL=info
# Don't print QR codes in logs
WAHA_PRINT_QR=False
# ======================
# ===== PROMETHEUS =====
# ======================
# Prometheus metrics endpoint - GET /metrics
# WAHA_PROMETHEUS_ENABLED=True
# WAHA_PROMETHEUS_PATH=/metrics
# WAHA_PROMETHEUS_METRIC_PREFIX=waha_
# WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS=0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30
# Events to count in waha_events_total ('*' for all) - the server actively processes listed events even with no webhooks
# WAHA_PROMETHEUS_TRACK_EVENTS=message.any
# Optional basic auth for the metrics endpoint (both must be set)
# WAHA_PROMETHEUS_USERNAME=admin
# WAHA_PROMETHEUS_PASSWORD=secret
# =========================
# ===== MEDIA STORAGE =====
# =========================
+1
View File
@@ -59,6 +59,7 @@
"@opentelemetry/instrumentation-pino": "^0.67.0",
"@opentelemetry/sdk-node": "^0.221.0",
"@opentelemetry/sdk-trace": "^2.10.0",
"@prometheus-io/client": "^0.16.1",
"@types/better-sqlite3": "^7.6.10",
"@types/lodash": "^4.14.194",
"@types/mustache": "^4.2.5",
+2
View File
@@ -5,6 +5,7 @@ import { DECORATORS } from '@nestjs/swagger/dist/constants';
import { GetAppsApiTags } from '@waha/apps/app_sdk/api/tags';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { DashboardConfigServiceCore } from '@waha/core/config/DashboardConfigServiceCore';
import { getPrometheusExcludePaths } from '@waha/modules/waha-prometheus/prometheus.config';
import { Logger } from 'nestjs-pino';
import { WhatsappConfigService } from '../config.service';
@@ -206,6 +207,7 @@ export class SwaggerConfiguratorCore {
'/jobs',
'/jobs/',
...config.getExcludedFullPaths(),
...getPrometheusExcludePaths(),
]);
const authFunction = BasicAuthFunction(username, password, exclude);
+9
View File
@@ -34,6 +34,11 @@ import { isPresenceAutoOnlineEnabled } from '@waha/modules/waha-maintain-online-
import { MaintainOnlineStatusModule } from '@waha/modules/waha-maintain-online-status/maintain-online-status.module';
import { isJidEngine } from '@waha/modules/waha-wid-jid/wid-jid.plugins';
import { MessageSourceModule } from '@waha/modules/waha-message-source/message-source.module';
import {
getPrometheusPath,
isPrometheusEnabled,
} from '@waha/modules/waha-prometheus/prometheus.config';
import { PrometheusModule } from '@waha/modules/waha-prometheus/prometheus.module';
import { WebhookModule } from '@waha/modules/waha-webhook/webhook.module';
import { SessionRuntimeInfoModule } from '@waha/modules/waha-session-runtime-info/session-runtime-info.module';
import { WidJIDModule } from '@waha/modules/waha-wid-jid/wid-jid.module';
@@ -99,6 +104,7 @@ export const IMPORTS_CORE = [
ignore: (req) => {
return (
req.url.startsWith('/ping') ||
req.url.startsWith(getPrometheusPath()) ||
req.url.startsWith('/dashboard/') ||
req.url.startsWith('/api/files/') ||
req.url.startsWith('/api/s3/') ||
@@ -161,6 +167,9 @@ export const IMPORTS_CORE = [
isPresenceAutoOnlineEnabled,
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(PrometheusModule, isPrometheusEnabled, {
debug: isDebugEnabled(),
}),
];
const IMPORTS_MEDIA = [
@@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { parseBool } from '@waha/helpers';
import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
import { EventWildUnmask } from '@waha/utils/events';
import * as Joi from 'joi';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
enum Env {
WAHA_PROMETHEUS_ENABLED = 'WAHA_PROMETHEUS_ENABLED',
WAHA_PROMETHEUS_PATH = 'WAHA_PROMETHEUS_PATH',
WAHA_PROMETHEUS_METRIC_PREFIX = 'WAHA_PROMETHEUS_METRIC_PREFIX',
WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS = 'WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS',
WAHA_PROMETHEUS_USERNAME = 'WAHA_PROMETHEUS_USERNAME',
WAHA_PROMETHEUS_PASSWORD = 'WAHA_PROMETHEUS_PASSWORD',
WAHA_PROMETHEUS_TRACK_EVENTS = 'WAHA_PROMETHEUS_TRACK_EVENTS',
}
const DEFAULT_PATH = '/metrics';
const DEFAULT_METRIC_PREFIX = 'waha_';
const DEFAULT_HTTP_DURATION_BUCKETS = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30,
];
export const PrometheusEnvSchema = Joi.object({
[Env.WAHA_PROMETHEUS_ENABLED]: Joi.boolean().truthy('1').falsy('0'),
[Env.WAHA_PROMETHEUS_PATH]: Joi.string().pattern(/^\//).allow(''),
[Env.WAHA_PROMETHEUS_METRIC_PREFIX]: Joi.string().allow(''),
[Env.WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS]: Joi.string()
.pattern(/^\d+(\.\d+)?(,\d+(\.\d+)?)*$/)
.allow(''),
[Env.WAHA_PROMETHEUS_USERNAME]: Joi.string().allow(''),
[Env.WAHA_PROMETHEUS_PASSWORD]: Joi.string().allow(''),
[Env.WAHA_PROMETHEUS_TRACK_EVENTS]: Joi.string().allow(''),
});
export function isPrometheusEnabled(env: NodeJS.ProcessEnv): boolean {
const value = env[Env.WAHA_PROMETHEUS_ENABLED];
if (!value) {
return false;
}
return parseBool(value);
}
export function getPrometheusPath(): string {
return process.env[Env.WAHA_PROMETHEUS_PATH] || DEFAULT_PATH;
}
export function getPrometheusExcludePaths(): string[] {
if (!isPrometheusEnabled(process.env)) {
return [];
}
return [getPrometheusPath()];
}
@Injectable()
export class PrometheusConfigService {
private eventUnmask = new EventWildUnmask(WAHAEvents, WAHAEventsWild);
constructor(
private configService: ConfigService,
@InjectPinoLogger(PrometheusConfigService.name)
private logger: PinoLogger,
) {}
get path(): string {
return getPrometheusPath();
}
get metricPrefix(): string {
return this.configService.get(
Env.WAHA_PROMETHEUS_METRIC_PREFIX,
DEFAULT_METRIC_PREFIX,
);
}
get httpDurationBuckets(): number[] {
const value = this.configService.get(
Env.WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS,
'',
);
if (!value) {
return DEFAULT_HTTP_DURATION_BUCKETS;
}
return value.split(',').map((bucket) => parseFloat(bucket));
}
get events(): WAHAEvents[] {
const value = this.configService.get(Env.WAHA_PROMETHEUS_TRACK_EVENTS, '');
if (!value) {
return [WAHAEvents.MESSAGE_ANY];
}
const names = value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const result = this.eventUnmask.unmask(names);
if (result.unknown.length > 0) {
throw new Error(
`${
Env.WAHA_PROMETHEUS_TRACK_EVENTS
} - unknown events '${result.unknown.join(', ')}', ` +
`expected '*' or: ${Object.values(WAHAEvents).join(', ')}`,
);
}
return result.events as WAHAEvents[];
}
get credentials(): [string, string] | null {
const username = this.configService.get(Env.WAHA_PROMETHEUS_USERNAME, '');
const password = this.configService.get(Env.WAHA_PROMETHEUS_PASSWORD, '');
if (!username && !password) {
return null;
}
if (!username || !password) {
this.logger.warn(
'Set up both WAHA_PROMETHEUS_USERNAME and WAHA_PROMETHEUS_PASSWORD ' +
'to enable metrics endpoint authentication.',
);
return null;
}
return [username, password];
}
}
@@ -0,0 +1,30 @@
import { Controller, Get, Header, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { getPrometheusPath } from '@waha/modules/waha-prometheus/prometheus.config';
import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics';
import { SessionMetricsCollector } from '@waha/modules/waha-prometheus/prometheus.sessions.collector';
import { Response } from 'express';
@Controller(getPrometheusPath())
@ApiTags('🔍 Observability')
export class PrometheusController {
constructor(
private metrics: WahaMetrics,
private collector: SessionMetricsCollector,
) {}
@Get()
@Header('Cache-Control', 'no-store')
@ApiOperation({
summary: 'Prometheus metrics',
description:
'Prometheus text exposition format. ' +
'Enable with WAHA_PROMETHEUS_ENABLED=True, configure with WAHA_PROMETHEUS_* environment variables.',
})
async get(@Res() response: Response): Promise<void> {
await this.collector.collect();
const body = await this.metrics.render();
response.set('Content-Type', this.metrics.contentType);
response.send(body);
}
}
@@ -0,0 +1,65 @@
import {
Injectable,
OnApplicationBootstrap,
OnModuleDestroy,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PrometheusConfigService } from '@waha/modules/waha-prometheus/prometheus.config';
import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { Subscription } from 'rxjs';
@Injectable()
export class EventMetricsSubscriber
implements OnApplicationBootstrap, OnModuleDestroy
{
private subscriptions: Subscription[] = [];
constructor(
private moduleRef: ModuleRef,
private metrics: WahaMetrics,
private config: PrometheusConfigService,
) {}
onApplicationBootstrap(): void {
const manager = this.moduleRef.get(SessionManager, { strict: false });
for (const eventName of this.config.events) {
this.subscriptions.push(
manager.getSessionEvent('*', eventName).subscribe((event) => {
const session = event?.session;
if (!session) {
return;
}
this.metrics.incrementEvent(session, eventName);
const fromMe = event?.payload?.fromMe;
if (
eventName === WAHAEvents.MESSAGE_ANY &&
typeof fromMe === 'boolean'
) {
this.metrics.incrementMessage(session, fromMe);
}
}),
);
}
this.subscriptions.push(
manager
.getSessionEvent('*', WAHAEvents.SESSION_STATUS)
.subscribe((event) => {
const session = event?.session;
if (!session) {
return;
}
const timestampMs = event.timestamp ?? Date.now();
this.metrics.observeSessionStatusChange(session, timestampMs);
}),
);
}
onModuleDestroy(): void {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
}
this.subscriptions = [];
}
}
@@ -0,0 +1,30 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { PrometheusConfigService } from '@waha/modules/waha-prometheus/prometheus.config';
import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class HttpMetricsMiddleware implements NestMiddleware {
constructor(
private metrics: WahaMetrics,
private config: PrometheusConfigService,
) {}
use(req: Request, res: Response, next: NextFunction): void {
const pathname = (req.originalUrl || req.url || '').split('?')[0];
if (pathname.startsWith(this.config.path)) {
next();
return;
}
const start = process.hrtime.bigint();
res.on('finish', () => {
const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9;
this.metrics.observeHttpRequest(
req.method,
res.statusCode,
durationSeconds,
);
});
next();
}
}
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { PrometheusConfigService } from '@waha/modules/waha-prometheus/prometheus.config';
import { VERSION } from '@waha/version';
import {
collectDefaultMetrics,
Counter,
Gauge,
Histogram,
Registry,
} from '@prometheus-io/client';
export interface SessionCountRow {
name: string;
status: string;
engine: string;
activityTimestampMs: number | null;
}
@Injectable()
export class WahaMetrics {
readonly registry: Registry;
private readonly httpRequests: Counter<'method' | 'status'>;
private readonly httpDuration: Histogram<'method' | 'status'>;
private readonly sessions: Gauge<'status' | 'engine'>;
private readonly sessionStatus: Gauge<'session' | 'status' | 'engine'>;
private readonly sessionActivity: Gauge<'session'>;
private readonly sessionStatusChange: Gauge<'session'>;
private readonly messages: Counter<'session' | 'fromMe'>;
private readonly events: Counter<'session' | 'event'>;
constructor(config: PrometheusConfigService) {
this.registry = new Registry();
// always keep the worker label, even if it's empty (set an empty string, otherwise it renders as worker="null")
this.registry.setDefaultLabels({ worker: VERSION.worker.id || '' });
const prefix = config.metricPrefix;
collectDefaultMetrics({ register: this.registry, prefix: prefix });
const up = new Gauge({
name: `${prefix}up`,
help: '1 if the WAHA process is serving Prometheus metrics',
registers: [this.registry],
});
up.set(1);
const info = new Gauge({
name: `${prefix}info`,
help: 'WAHA build information',
labelNames: ['version', 'tier', 'engine', 'platform'],
registers: [this.registry],
});
info.set(
{
version: VERSION.version,
tier: VERSION.tier,
engine: VERSION.engine,
platform: VERSION.platform,
},
1,
);
this.httpRequests = new Counter({
name: `${prefix}http_requests_total`,
help: 'API HTTP requests handled by WAHA',
labelNames: ['method', 'status'],
registers: [this.registry],
});
this.httpDuration = new Histogram({
name: `${prefix}http_request_duration_seconds`,
help: 'API HTTP request duration in seconds',
labelNames: ['method', 'status'],
buckets: config.httpDurationBuckets,
registers: [this.registry],
});
this.sessions = new Gauge({
name: `${prefix}sessions`,
help: 'WhatsApp sessions by status and engine',
labelNames: ['status', 'engine'],
registers: [this.registry],
});
this.sessionStatus = new Gauge({
name: `${prefix}session_status`,
help: 'Current status per session (1 = session is in this status)',
labelNames: ['session', 'status', 'engine'],
registers: [this.registry],
});
this.sessionActivity = new Gauge({
name: `${prefix}session_activity_timestamp_seconds`,
help: 'Unix timestamp of the last session activity',
labelNames: ['session'],
registers: [this.registry],
});
this.sessionStatusChange = new Gauge({
name: `${prefix}session_status_change_timestamp_seconds`,
help: 'Unix timestamp of the last session status change',
labelNames: ['session'],
registers: [this.registry],
});
this.messages = new Counter({
name: `${prefix}messages_total`,
help: 'WhatsApp messages observed by WAHA',
labelNames: ['session', 'fromMe'],
registers: [this.registry],
});
this.events = new Counter({
name: `${prefix}events_total`,
help: 'WAHA events observed by WAHA (WAHA_PROMETHEUS_TRACK_EVENTS)',
labelNames: ['session', 'event'],
registers: [this.registry],
});
}
get contentType(): string {
return this.registry.contentType;
}
observeHttpRequest(
method: string,
statusCode: number,
durationSeconds: number,
): void {
const labels = {
method: method.toUpperCase(),
status: String(statusCode),
};
this.httpRequests.inc(labels);
this.httpDuration.observe(labels, durationSeconds);
}
incrementMessage(session: string, fromMe: boolean): void {
this.messages.inc({ session: session, fromMe: String(fromMe) });
}
incrementEvent(session: string, event: string): void {
this.events.inc({ session: session, event: event });
}
observeSessionStatusChange(session: string, timestampMs: number): void {
this.sessionStatusChange.set({ session: session }, timestampMs / 1000);
}
setSessionCounts(rows: SessionCountRow[]): void {
this.sessions.reset();
this.sessionStatus.reset();
this.sessionActivity.reset();
for (const row of rows) {
this.sessions.inc({ status: row.status, engine: row.engine });
this.sessionStatus.set(
{ session: row.name, status: row.status, engine: row.engine },
1,
);
if (row.activityTimestampMs) {
this.sessionActivity.set(
{ session: row.name },
row.activityTimestampMs / 1000,
);
}
}
}
render(): Promise<string> {
return this.registry.metrics();
}
}
@@ -0,0 +1,48 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import {
PrometheusConfigService,
PrometheusEnvSchema,
} from '@waha/modules/waha-prometheus/prometheus.config';
import { PrometheusController } from '@waha/modules/waha-prometheus/prometheus.controller';
import { EventMetricsSubscriber } from '@waha/modules/waha-prometheus/prometheus.events.subscriber';
import { HttpMetricsMiddleware } from '@waha/modules/waha-prometheus/prometheus.http.middleware';
import { WahaMetrics } from '@waha/modules/waha-prometheus/prometheus.metrics';
import { SessionMetricsCollector } from '@waha/modules/waha-prometheus/prometheus.sessions.collector';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema: PrometheusEnvSchema,
}),
],
providers: [
PrometheusConfigService,
WahaMetrics,
HttpMetricsMiddleware,
SessionMetricsCollector,
EventMetricsSubscriber,
],
controllers: [PrometheusController],
})
/**
* Prometheus metrics endpoint (WAHA_PROMETHEUS_ENABLED=True).
* Serves GET /metrics (WAHA_PROMETHEUS_PATH) with process, HTTP, session and message metrics;
* optional basic auth via WAHA_PROMETHEUS_USERNAME and WAHA_PROMETHEUS_PASSWORD.
*/
export class PrometheusModule implements NestModule {
constructor(private config: PrometheusConfigService) {}
configure(consumer: MiddlewareConsumer) {
const credentials = this.config.credentials;
if (credentials) {
const username = credentials[0];
const password = credentials[1];
consumer
.apply(BasicAuthFunction(username, password))
.forRoutes(this.config.path);
}
consumer.apply(HttpMetricsMiddleware).forRoutes('api', 'mcp');
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { SessionManager } from '@waha/core/abc/manager.abc';
import {
SessionCountRow,
WahaMetrics,
} from '@waha/modules/waha-prometheus/prometheus.metrics';
import { getEngineName } from '@waha/version';
@Injectable()
export class SessionMetricsCollector {
private manager: SessionManager | null = null;
constructor(
private moduleRef: ModuleRef,
private metrics: WahaMetrics,
) {}
private getManager(): SessionManager {
if (!this.manager) {
this.manager = this.moduleRef.get(SessionManager, { strict: false });
}
return this.manager;
}
async collect(): Promise<void> {
const manager = this.getManager();
const sessions = await manager.getSessions(true);
const rows: SessionCountRow[] = [];
for (const info of sessions) {
let engine: string;
try {
engine = String(manager.getSession(info.name).engine);
} catch {
engine = String(getEngineName());
}
rows.push({
name: info.name,
status: String(info.status),
engine: engine,
activityTimestampMs: info.timestamps?.activity ?? null,
});
}
this.metrics.setSessionCounts(rows);
}
}
+28 -1
View File
@@ -3205,7 +3205,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.1":
"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.1":
version: 1.9.1
resolution: "@opentelemetry/api@npm:1.9.1"
checksum: 10/b26032739d3c54ca99b5a2920844a1fbd4c3ee383cacbb0915e8c706a2626fe91e96feaa6e893397abe0545dc8d0a765b220aa18a31b1773176eeaf3a225e10e
@@ -3710,6 +3710,16 @@ __metadata:
languageName: node
linkType: hard
"@prometheus-io/client@npm:^0.16.1":
version: 0.16.1
resolution: "@prometheus-io/client@npm:0.16.1"
dependencies:
"@opentelemetry/api": "npm:^1.4.0"
tdigest: "npm:^0.1.1"
checksum: 10/6e8cdf0fbafc8506e3b5fb32815504fdfcf702e06b0d5d3d345a41ecf7e726e9a8e23b536eded80fdfb2073e14baee1622819129802104984c9fc179d1787048
languageName: node
linkType: hard
"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/aspromise@npm:1.1.2"
@@ -6093,6 +6103,13 @@ __metadata:
languageName: node
linkType: hard
"bintrees@npm:1.0.2":
version: 1.0.2
resolution: "bintrees@npm:1.0.2"
checksum: 10/071896cea5ea5413316c8436e95799444c208630d5c539edd8a7089fc272fc5d3634aa4a2e4847b28350dda1796162e14a34a0eda53108cc5b3c2ff6a036c1fa
languageName: node
linkType: hard
"bl@npm:^4.0.3, bl@npm:^4.1.0":
version: 4.1.0
resolution: "bl@npm:4.1.0"
@@ -13980,6 +13997,15 @@ __metadata:
languageName: node
linkType: hard
"tdigest@npm:^0.1.1":
version: 0.1.3
resolution: "tdigest@npm:0.1.3"
dependencies:
bintrees: "npm:1.0.2"
checksum: 10/c4a0fe3469ddc1eea56938c26215b5bd690efb93ef583872c09baf81646c15acb077d9253e72e39e2655ed140410ca901bb75b77a7d29fcecf77a2efde91e52b
languageName: node
linkType: hard
"terser-webpack-plugin@npm:^5.3.11":
version: 5.3.14
resolution: "terser-webpack-plugin@npm:5.3.14"
@@ -14679,6 +14705,7 @@ __metadata:
"@opentelemetry/instrumentation-pino": "npm:^0.67.0"
"@opentelemetry/sdk-node": "npm:^0.221.0"
"@opentelemetry/sdk-trace": "npm:^2.10.0"
"@prometheus-io/client": "npm:^0.16.1"
"@types/better-sqlite3": "npm:^7.6.10"
"@types/express": "npm:^4.17.3"
"@types/jest": "npm:26.0.10"