From 83778cfb0d6eb0be063b1cb12dfa08a6cbe1121f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 3 Aug 2026 18:03:26 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Prometheus=20/metrics=20endpoint=20?= =?UTF-8?q?=E2=80=94=20Phase=205=20->=206/8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metrics.v: GET /metrics au format OpenMetrics (gauges + counters) - server.v: route /metrics, Stats.http_polls counter, increment in poll loop - metrics_test.v: test du endpoint metrics - ROADMAP: Phase 5 6/8 (Prometheus done) --- docs/ROADMAP.md | 19 ++++++------- metrics.v | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ metrics_test.v | 28 +++++++++++++++++++ server.v | 7 ++++- 4 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 metrics.v create mode 100644 metrics_test.v diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e099bc3..841f6e0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -106,7 +106,7 @@ Objectif : un seul binaire, une seule commande pour installer/désinstaller le s **Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). ✅ -## Phase 5 : Futures idées — v0.5.0+ (4/8) +## Phase 5 : Futures idées — v0.5.0+ (6/8) - [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints - [x] Filtres avancés — Expressions conditionnelles par source @@ -116,20 +116,17 @@ Objectif : un seul binaire, une seule commande pour installer/désinstaller le s - match_source: "docker" match_name: "test-*" action: drop - - match_source: "gitea" - action: set_priority - value: "5" ``` - - Actions: `drop`, `set_priority:N`, `add_tag:X` - - Wildcard `*` supporté dans `match_name` - [x] Notifications structurées — Support des actions Ntfy (boutons click) - Champ `click_url` pour ouvrir une URL au clic - Champ `actions` pour boutons d'action (view, broadcast, http) -- [ ] Intégration Prometheus — Métriques exposées au format OpenMetrics +- [x] Intégration Prometheus — Métriques exposées au format OpenMetrics + - Endpoint `GET /metrics` : gauges (uptime, poll_state, silence) + counters (notifications, errors, http_polls) + - Format compatible Prometheus/Grafana - [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON - ```yaml - outgoing: - webhooks: +- [ ] Support multi-utilisateurs — ACLs par webhook_path +- [x] Fichier d'état — Persistance de l'état up/down des health checks +- [ ] Plugin system — Sources customisables via dll/.so - url: "https://hooks.slack.com/..." format: slack - url: "https://discord.com/api/webhooks/..." @@ -169,4 +166,4 @@ Ces features ont été ajoutées en cours de route : | v0.3.0 | ✅ Terminé | Robustesse, HMAC, retry, graceful shutdown, SIGHUP reload, tests | | v0.4.0 | ✅ Terminé | Templates, silence, grouping, HMAC global, CI/CD | | v0.4.5 | ✅ Terminé | Déploiement multi-plateforme : --install-service, Windows, systemd, openrc, Docker, install scripts | -| v0.5.0+ | 🚧 4/8 | Dashboard, filtres avancés, actions Ntfy, outgoing webhooks, state file — manque Prometheus, multi-user, plugins | +| v0.5.0+ | 🚧 6/8 | Dashboard, filtres, actions Ntfy, Prometheus, outgoing webhooks, state file — manque multi-user, plugins | diff --git a/metrics.v b/metrics.v new file mode 100644 index 0000000..43cc9be --- /dev/null +++ b/metrics.v @@ -0,0 +1,71 @@ +module main + +import net.http +import time + +// metrics_response returns a Prometheus / OpenMetrics formatted response. +fn (mut app App) metrics_response() http.Response { + mut lines := []string{} + + // HELP/TYPE headers + lines << '# HELP ntfy_bridge_uptime_seconds Seconds since the bridge started.' + lines << '# TYPE ntfy_bridge_uptime_seconds gauge' + lines << 'ntfy_bridge_uptime_seconds ${app.uptime_seconds()}' + + lines << '# HELP ntfy_bridge_notifications_total Total notifications sent (all sources).' + lines << '# TYPE ntfy_bridge_notifications_total counter' + lines << 'ntfy_bridge_notifications_total ${app.stats.total_notifications}' + + lines << '# HELP ntfy_bridge_errors_total Total publish errors.' + lines << '# TYPE ntfy_bridge_errors_total counter' + lines << 'ntfy_bridge_errors_total ${app.stats.errors}' + + lines << '# HELP ntfy_bridge_notifications_by_source Notifications by source type.' + lines << '# TYPE ntfy_bridge_notifications_by_source counter' + for source, count in app.stats.by_source { + lines << 'ntfy_bridge_notifications_by_source{source="${source}"} ${count}' + } + + lines << '# HELP ntfy_bridge_poll_up Health check polling status (1=up, 0=down).' + lines << '# TYPE ntfy_bridge_poll_up gauge' + for url, is_up in app.poll_state { + val := if is_up { '1' } else { '0' } + safe_url := url.replace('"', '\\"') + lines << 'ntfy_bridge_poll_up{url="${safe_url}"} ${val}' + } + + lines << '# HELP ntfy_bridge_http_polls_total Total HTTP poll checks performed.' + lines << '# TYPE ntfy_bridge_http_polls_total counter' + lines << 'ntfy_bridge_http_polls_total ${app.stats.http_polls}' + + lines << '# HELP ntfy_bridge_silence_active Whether notification silence is active.' + lines << '# TYPE ntfy_bridge_silence_active gauge' + now := time.now() + silence_val := if now.unix() < app.silence_until.unix() { '1' } else { '0' } + lines << 'ntfy_bridge_silence_active ${silence_val}' + + lines << '# HELP ntfy_bridge_silence_remaining_seconds Seconds remaining in current silence.' + lines << '# TYPE ntfy_bridge_silence_remaining_seconds gauge' + remaining := if time.now() < app.silence_until { + (app.silence_until - time.now()) / time.second + } else { + 0 + } + lines << 'ntfy_bridge_silence_remaining_seconds ${remaining}' + + // EOF + lines << '# EOF' + + return http.Response{ + status_code: 200 + header: http.Header{} + body: lines.join('\n') + '\n' + } +} + +// uptime_seconds returns the number of seconds since the server started. +fn (app App) uptime_seconds() i64 { + now := time.now() + diff := now.unix() - app.start_time.unix() + return i64(diff) +} diff --git a/metrics_test.v b/metrics_test.v new file mode 100644 index 0000000..a87e149 --- /dev/null +++ b/metrics_test.v @@ -0,0 +1,28 @@ +module main + +fn test_metrics_keys() { + mut app := App{ + logger: new_logger(.info, false) + cfg: Config{ + filters: FilterConfig{} + outgoing: OutgoingConfig{} + state: StateConfig{} + } + poll_state: map[string]bool{} + } + app.poll_state['http://example.com'] = true + app.poll_state['http://down.com'] = false + app.stats.total_notifications = 42 + app.stats.errors = 3 + app.stats.by_source['docker'] = 10 + + resp := app.metrics_response() + body := resp.body + + assert body.contains('ntfy_bridge_uptime_seconds') + assert body.contains('ntfy_bridge_notifications_total 42') + assert body.contains('ntfy_bridge_errors_total 3') + assert body.contains('ntfy_bridge_silence_active') + assert body.contains('ntfy_bridge_http_polls_total') + assert body.contains('# EOF') +} diff --git a/server.v b/server.v index 4133f41..dd7c825 100644 --- a/server.v +++ b/server.v @@ -15,9 +15,10 @@ pub struct Stats { pub mut: total_notifications int errors int + http_polls int // v0.5: total HTTP poll checks by_source map[string]int + start_time time.Time } - pub struct App { pub mut: cfg Config @@ -190,6 +191,9 @@ pub fn (mut app App) handle(req http.Request) http.Response { if req.url == '/api/silence' { return app.silence_status_response() } + if req.url == '/metrics' { + return app.metrics_response() + } } .post { if req.url.starts_with('/api/silence') { @@ -409,6 +413,7 @@ fn (mut app App) http_poll_loop() { } for src in app.cfg.sources.http_poll { for check in src.checks { + app.stats.http_polls++ app.run_http_check(check) } }