taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit b2cc6de2b7551860d1c864df316c408df3708a14
parent f477d8ba1a4552bdbfd88c98e38313edd0af805d
Author: Antoine A <>
Date:   Thu, 10 Sep 2026 11:20:37 +0200

common: add Taler Observability API

Diffstat:
MCargo.toml | 9++++++++-
Madapters/taler-cyclos/Cargo.toml | 1+
Madapters/taler-cyclos/cyclos.conf | 18+++++++++++++++++-
Madapters/taler-cyclos/src/api.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Madapters/taler-cyclos/src/config.rs | 3+++
Madapters/taler-cyclos/src/lib.rs | 5++++-
Madapters/taler-cyclos/src/main.rs | 5++++-
Madapters/taler-magnet-bank/Cargo.toml | 1+
Madapters/taler-magnet-bank/magnet-bank.conf | 17++++++++++++++++-
Madapters/taler-magnet-bank/src/api.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Madapters/taler-magnet-bank/src/config.rs | 3+++
Madapters/taler-magnet-bank/src/lib.rs | 5++++-
Madapters/taler-magnet-bank/src/main.rs | 5++++-
Madapters/taler-wise/Cargo.toml | 1+
Madapters/taler-wise/src/api.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Madapters/taler-wise/src/config.rs | 2++
Madapters/taler-wise/src/main.rs | 6+++++-
Madapters/taler-wise/src/worker.rs | 7++++---
Madapters/taler-wise/wise.conf | 16++++++++++++++++
Mcommon/taler-api/Cargo.toml | 1+
Mcommon/taler-api/src/api.rs | 8+++++++-
Acommon/taler-api/src/api/observability.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-api/src/constants.rs | 1+
Mcommon/taler-api/src/test.rs | 31++++++++++++++++++++++++-------
Mcommon/taler-api/src/test/api.rs | 28++++++++++++++++++++++++++++
Mcommon/taler-build/build.rs | 9+++++----
Mcommon/taler-common/src/api.rs | 1+
Acommon/taler-common/src/api/observability.rs | 23+++++++++++++++++++++++
Mdebian/etc/taler-cyclos/conf.d/cyclos-httpd.conf | 4++++
Mdebian/etc/taler-cyclos/secrets/cyclos-httpd.secret.conf | 4++++
Mdebian/etc/taler-magnet-bank/conf.d/magnet-bank-httpd.conf | 5+++++
Mdebian/etc/taler-magnet-bank/secrets/magnet-bank-httpd.secret.conf | 4++++
Mdebian/etc/taler-wise/conf.d/wise-httpd.conf | 4++++
Mdebian/etc/taler-wise/secrets/wise-httpd.secret.conf | 5+++++
34 files changed, 450 insertions(+), 38 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -59,9 +59,16 @@ owo-colors = "4.2.3" aws-lc-rs = "1.15" compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } -hyper-rustls = { version = "0.27", features = ["aws-lc-rs", "http1", "http2", "rustls-platform-verifier", "tls12"], default-features = false } +hyper-rustls = { version = "0.27", features = [ + "aws-lc-rs", + "http1", + "http2", + "rustls-platform-verifier", + "tls12", +], default-features = false } rand = { version = "0.10" } regex = { version = "1" } rustls = "0.23" http = "1.4" +prometheus-client = { version = "0.25" } diff --git a/adapters/taler-cyclos/Cargo.toml b/adapters/taler-cyclos/Cargo.toml @@ -33,6 +33,7 @@ owo-colors.workspace = true failure-injection.workspace = true hyper.workspace = true url.workspace = true +prometheus-client.workspace = true [dev-dependencies] taler-test-utils.workspace = true \ No newline at end of file diff --git a/adapters/taler-cyclos/cyclos.conf b/adapters/taler-cyclos/cyclos.conf @@ -75,7 +75,23 @@ AUTH_METHOD = bearer # USERNAME = # Password for basic authentication scheme -# PASSWORD = +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + +[cyclos-httpd-observability-api] +# Whether to serve the Observability API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = # Token for bearer authentication scheme TOKEN = diff --git a/adapters/taler-cyclos/src/api.rs b/adapters/taler-cyclos/src/api.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA + Copyright (C) 2025-2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -16,8 +16,13 @@ use compact_str::CompactString; use jiff::Timestamp; +use prometheus_client::{metrics::gauge::Gauge, registry::Registry}; +use sqlx::PgPool; use taler_api::{ - api::{TalerApi, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway}, + api::{ + TalerApi, observability::Observability, prepared::PreparedTransfer, revenue::Revenue, + wire::WireGateway, + }, error::{ApiResult, failure_code}, subject::{IncomingKey, fmt_in_subject}, }; @@ -55,6 +60,31 @@ pub struct CyclosApi { pub out_channel: Sender<i64>, pub taler_out_channel: Sender<i64>, pub root: CompactString, + metrics: Metrics, + registry: Registry, +} + +#[derive(Default)] +struct Metrics { + db_access: Gauge, +} + +impl Metrics { + pub fn registry(&self) -> Registry { + let mut registry = Registry::default(); + + registry.register( + "db_access", + "Whether the last database metrics refresh succeeded", + self.db_access.clone(), + ); + registry + } + + pub async fn sync(&self, db: &PgPool) { + let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok(); + self.db_access.set(if test { 1 } else { 0 }); + } } impl CyclosApi { @@ -68,6 +98,9 @@ impl CyclosApi { let taler_in_channel = Sender::new(0); let out_channel = Sender::new(0); let taler_out_channel = Sender::new(0); + + let metrics = Metrics::default(); + let tmp = Self { pool: pool.clone(), payto, @@ -77,6 +110,8 @@ impl CyclosApi { taler_in_channel: taler_in_channel.clone(), out_channel: out_channel.clone(), taler_out_channel: taler_out_channel.clone(), + registry: metrics.registry(), + metrics, }; tokio::spawn(db::notification_listener( pool, @@ -327,6 +362,13 @@ impl PreparedTransfer for CyclosApi { } } +impl Observability for CyclosApi { + async fn metrics(&self) -> ApiResult<&Registry> { + self.metrics.sync(&self.pool).await; + Ok(&self.registry) + } +} + #[cfg(test)] mod test { use std::sync::{ @@ -346,6 +388,7 @@ mod test { use taler_common::{ api::{ EddsaPublicKey, + observability::Config, prepared::PreparedTransferConfig, revenue::RevenueConfig, wire::{TransferState, WireConfig}, @@ -393,7 +436,8 @@ mod test { let server = Router::new() .wire_gateway(api.clone(), AuthMethod::None) .prepared_transfer(api.clone()) - .revenue(api, AuthMethod::None) + .revenue(api.clone(), AuthMethod::None) + .observability(api, AuthMethod::None) .finalize(); (server, pool) @@ -414,6 +458,14 @@ mod test { .get("/taler-revenue/config") .await .assert_ok_json::<RevenueConfig>(); + server + .get(format!("/taler-observability/config")) + .await + .assert_ok_json::<Config>(); + server + .get(format!("/taler-observability/metrics")) + .await + .assert_ok(); } #[tokio::test] diff --git a/adapters/taler-cyclos/src/config.rs b/adapters/taler-cyclos/src/config.rs @@ -104,6 +104,7 @@ pub struct ServeCfg { pub serve: Serve, pub wire_gateway: Option<ApiCfg>, pub revenue: Option<ApiCfg>, + pub observability: Option<ApiCfg>, } impl ServeCfg { @@ -117,6 +118,7 @@ impl ServeCfg { let wire_gateway = ApiCfg::parse(cfg.section("cyclos-httpd-wire-gateway-api"))?; let revenue = ApiCfg::parse(cfg.section("cyclos-httpd-revenue-api"))?; + let observability = ApiCfg::parse(cfg.section("cyclos-httpd-observability-api"))?; Ok(Self { payto, @@ -125,6 +127,7 @@ impl ServeCfg { serve, wire_gateway, revenue, + observability, }) } } diff --git a/adapters/taler-cyclos/src/lib.rs b/adapters/taler-cyclos/src/lib.rs @@ -41,7 +41,10 @@ pub async fn run_serve(cfg: &Config, pool: PgPool) -> anyhow::Result<()> { router = router.wire_gateway(api.clone(), cfg.auth.method()); } if let Some(cfg) = cfg.revenue { - router = router.revenue(api, cfg.auth.method()); + router = router.revenue(api.clone(), cfg.auth.method()); + } + if let Some(cfg) = cfg.observability { + router = router.observability(api, cfg.auth.method()); } router.serve(&cfg.serve, None).await?; Ok(()) diff --git a/adapters/taler-cyclos/src/main.rs b/adapters/taler-cyclos/src/main.rs @@ -95,7 +95,10 @@ async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { Command::Serve { check } => { if check { let cfg = ServeCfg::parse(cfg)?; - if cfg.revenue.is_none() && cfg.wire_gateway.is_none() { + if cfg.revenue.is_none() + && cfg.wire_gateway.is_none() + && cfg.observability.is_none() + { std::process::exit(1); } } else { diff --git a/adapters/taler-magnet-bank/Cargo.toml b/adapters/taler-magnet-bank/Cargo.toml @@ -39,6 +39,7 @@ hyper.workspace = true url.workspace = true aws-lc-rs.workspace = true compact_str.workspace = true +prometheus-client.workspace = true [dev-dependencies] taler-test-utils.workspace = true \ No newline at end of file diff --git a/adapters/taler-magnet-bank/magnet-bank.conf b/adapters/taler-magnet-bank/magnet-bank.conf @@ -76,11 +76,26 @@ AUTH_METHOD = bearer # USERNAME = # Password for basic authentication scheme -# PASSWORD = +# PASSWORD = # Token for bearer authentication scheme TOKEN = +[magnet-bank-httpd-observability-api] +# Whether to serve the Observability API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = [magnet-bankdb-postgres] # DB connection string diff --git a/adapters/taler-magnet-bank/src/api.rs b/adapters/taler-magnet-bank/src/api.rs @@ -15,8 +15,13 @@ */ use jiff::Timestamp; +use prometheus_client::{metrics::gauge::Gauge, registry::Registry}; +use sqlx::PgPool; use taler_api::{ - api::{TalerApi, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway}, + api::{ + TalerApi, observability::Observability, prepared::PreparedTransfer, revenue::Revenue, + wire::WireGateway, + }, error::{ApiResult, failure_code}, subject::{IncomingKey, fmt_in_subject}, }; @@ -53,6 +58,31 @@ pub struct MagnetApi { pub taler_in_channel: Sender<i64>, pub out_channel: Sender<i64>, pub taler_out_channel: Sender<i64>, + metrics: Metrics, + registry: Registry, +} + +#[derive(Default)] +struct Metrics { + db_access: Gauge, +} + +impl Metrics { + pub fn registry(&self) -> Registry { + let mut registry = Registry::default(); + + registry.register( + "db_access", + "Whether the last database metrics refresh succeeded", + self.db_access.clone(), + ); + registry + } + + pub async fn sync(&self, db: &PgPool) { + let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok(); + self.db_access.set(if test { 1 } else { 0 }); + } } impl MagnetApi { @@ -61,6 +91,9 @@ impl MagnetApi { let taler_in_channel = Sender::new(0); let out_channel = Sender::new(0); let taler_out_channel = Sender::new(0); + + let metrics = Metrics::default(); + let tmp = Self { pool: pool.clone(), payto, @@ -68,6 +101,8 @@ impl MagnetApi { taler_in_channel: taler_in_channel.clone(), out_channel: out_channel.clone(), taler_out_channel: taler_out_channel.clone(), + registry: metrics.registry(), + metrics, }; tokio::spawn(db::notification_listener( pool, @@ -299,6 +334,13 @@ impl PreparedTransfer for MagnetApi { } } +impl Observability for MagnetApi { + async fn metrics(&self) -> ApiResult<&Registry> { + self.metrics.sync(&self.pool).await; + Ok(&self.registry) + } +} + #[cfg(test)] mod test { @@ -318,6 +360,7 @@ mod test { use taler_common::{ api::{ EddsaPublicKey, + observability::Config, prepared::PreparedTransferConfig, revenue::RevenueConfig, wire::{TransferState, WireConfig}, @@ -361,7 +404,8 @@ mod test { let server = Router::new() .wire_gateway(api.clone(), AuthMethod::None) .prepared_transfer(api.clone()) - .revenue(api, AuthMethod::None) + .revenue(api.clone(), AuthMethod::None) + .observability(api, AuthMethod::None) .finalize(); (server, pool) @@ -382,6 +426,14 @@ mod test { .get("/taler-revenue/config") .await .assert_ok_json::<RevenueConfig>(); + server + .get(format!("/taler-observability/config")) + .await + .assert_ok_json::<Config>(); + server + .get(format!("/taler-observability/metrics")) + .await + .assert_ok(); } #[tokio::test] diff --git a/adapters/taler-magnet-bank/src/config.rs b/adapters/taler-magnet-bank/src/config.rs @@ -47,6 +47,7 @@ pub struct ServeCfg { pub serve: Serve, pub wire_gateway: Option<ApiCfg>, pub revenue: Option<ApiCfg>, + pub observability: Option<ApiCfg>, } impl ServeCfg { @@ -59,12 +60,14 @@ impl ServeCfg { let wire_gateway = ApiCfg::parse(cfg.section("magnet-bank-httpd-wire-gateway-api"))?; let revenue = ApiCfg::parse(cfg.section("magnet-bank-httpd-revenue-api"))?; + let observability = ApiCfg::parse(cfg.section("cyclos-httpd-observability-api"))?; Ok(Self { payto, serve, wire_gateway, revenue, + observability, }) } } diff --git a/adapters/taler-magnet-bank/src/lib.rs b/adapters/taler-magnet-bank/src/lib.rs @@ -45,7 +45,10 @@ pub async fn run_serve(cfg: &Config, pool: PgPool) -> anyhow::Result<()> { router = router.wire_gateway(api.clone(), cfg.auth.method()); } if let Some(cfg) = cfg.revenue { - router = router.revenue(api, cfg.auth.method()); + router = router.revenue(api.clone(), cfg.auth.method()); + } + if let Some(cfg) = cfg.observability { + router = router.observability(api, cfg.auth.method()); } router.serve(&cfg.serve, None).await?; Ok(()) diff --git a/adapters/taler-magnet-bank/src/main.rs b/adapters/taler-magnet-bank/src/main.rs @@ -95,7 +95,10 @@ async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { Command::Serve { check } => { if check { let cfg = ServeCfg::parse(cfg)?; - if cfg.revenue.is_none() && cfg.wire_gateway.is_none() { + if cfg.revenue.is_none() + && cfg.wire_gateway.is_none() + && cfg.observability.is_none() + { std::process::exit(1); } } else { diff --git a/adapters/taler-wise/Cargo.toml b/adapters/taler-wise/Cargo.toml @@ -29,6 +29,7 @@ anyhow.workspace = true tokio.workspace = true taler-test-utils.workspace = true regex.workspace = true +prometheus-client.workspace = true uuid = { version = "1.24.0", features = ["serde"] } futures-util = { version = "0.3", default-features = false, features = ["alloc"] } diff --git a/adapters/taler-wise/src/api.rs b/adapters/taler-wise/src/api.rs @@ -18,9 +18,12 @@ use std::{collections::BTreeMap, sync::Arc}; use compact_str::format_compact; use jiff::Timestamp; +use prometheus_client::{metrics::gauge::Gauge, registry::Registry}; +use sqlx::PgPool; use taler_api::{ api::{ - TalerApi, TalerRouter as _, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway, + TalerApi, TalerRouter as _, observability::Observability, prepared::PreparedTransfer, + revenue::Revenue, wire::WireGateway, }, config::ApiCfg, error::{ApiResult, failure_code, not_implemented}, @@ -59,16 +62,22 @@ pub async fn start( balances: Vec<WiseBalance>, wire_gateway: &Option<ApiCfg>, revenue: &Option<ApiCfg>, + observability: &Option<ApiCfg>, ) -> Router { let apis: Vec<_> = balances .into_iter() - .map(|b| WiseBalanceApi { - id: b.id, - currency: b.currency, - pool: pool.clone(), - payto: b.payto.full(name), - in_channel: Sender::new(0), - taler_in_channel: Sender::new(0), + .map(|b| { + let metrics = Metrics::default(); + WiseBalanceApi { + id: b.id, + currency: b.currency, + pool: pool.clone(), + payto: b.payto.full(name), + in_channel: Sender::new(0), + taler_in_channel: Sender::new(0), + registry: metrics.registry(), + metrics, + } }) .collect(); let in_channels = Arc::new(BTreeMap::from_iter( @@ -94,6 +103,9 @@ pub async fn start( if let Some(cfg) = revenue { balance_router = balance_router.revenue(api.clone(), cfg.auth.method()); } + if let Some(cfg) = observability { + balance_router = balance_router.observability(api.clone(), cfg.auth.method()); + } main_router = main_router.nest(&format!("/balances/{}", api.id), balance_router); } main_router @@ -106,6 +118,31 @@ pub struct WiseBalanceApi { pub payto: FullWisePayto, pub in_channel: Sender<i64>, pub taler_in_channel: Sender<i64>, + metrics: Metrics, + registry: Registry, +} + +#[derive(Default)] +struct Metrics { + db_access: Gauge, +} + +impl Metrics { + pub fn registry(&self) -> Registry { + let mut registry = Registry::default(); + + registry.register( + "db_access", + "Whether the last database metrics refresh succeeded", + self.db_access.clone(), + ); + registry + } + + pub async fn sync(&self, db: &PgPool) { + let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok(); + self.db_access.set(if test { 1 } else { 0 }); + } } impl TalerApi for WiseBalanceApi { @@ -296,6 +333,13 @@ impl PreparedTransfer for WiseBalanceApi { } } +impl Observability for WiseBalanceApi { + async fn metrics(&self) -> ApiResult<&Registry> { + self.metrics.sync(&self.pool).await; + Ok(&self.registry) + } +} + #[cfg(test)] mod test { @@ -311,8 +355,8 @@ mod test { }; use taler_common::{ api::{ - EddsaPublicKey, prepared::PreparedTransferConfig, revenue::RevenueConfig, - wire::WireConfig, + EddsaPublicKey, observability::Config, prepared::PreparedTransferConfig, + revenue::RevenueConfig, wire::WireConfig, }, types::{ amount::{Currency, amount}, @@ -366,6 +410,9 @@ mod test { &Some(ApiCfg { auth: AuthCfg::None, }), + &Some(ApiCfg { + auth: AuthCfg::None, + }), ) .await .finalize(); @@ -389,6 +436,14 @@ mod test { .get(format!("/balances/{id}/taler-revenue/config")) .await .assert_ok_json::<RevenueConfig>(); + server + .get(format!("/balances/{id}/taler-observability/config")) + .await + .assert_ok_json::<Config>(); + server + .get(format!("/balances/{id}/taler-observability/metrics")) + .await + .assert_ok(); } } diff --git a/adapters/taler-wise/src/config.rs b/adapters/taler-wise/src/config.rs @@ -108,6 +108,7 @@ pub struct ServeCfg { pub balances: Vec<WiseBalance>, pub wire_gateway: Option<ApiCfg>, pub revenue: Option<ApiCfg>, + pub observability: Option<ApiCfg>, } impl ServeCfg { @@ -118,6 +119,7 @@ impl ServeCfg { serve: Serve::parse(&cfg.section("wise-httpd"))?, wire_gateway: ApiCfg::parse(cfg.section("wise-httpd-wire-gateway-api"))?, revenue: ApiCfg::parse(cfg.section("wise-httpd-revenue-api"))?, + observability: ApiCfg::parse(cfg.section("wise-httpd-observability-api"))?, balances: balances(cfg)?, }) } diff --git a/adapters/taler-wise/src/main.rs b/adapters/taler-wise/src/main.rs @@ -75,7 +75,10 @@ async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { Command::Serve { check } => { if check { let cfg = ServeCfg::parse(cfg)?; - if cfg.revenue.is_none() && cfg.wire_gateway.is_none() { + if cfg.revenue.is_none() + && cfg.wire_gateway.is_none() + && cfg.observability.is_none() + { std::process::exit(1); } } else { @@ -87,6 +90,7 @@ async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { cfg.balances, &cfg.wire_gateway, &cfg.revenue, + &cfg.observability, ) .await .serve(&cfg.serve, None) diff --git a/adapters/taler-wise/src/worker.rs b/adapters/taler-wise/src/worker.rs @@ -17,7 +17,6 @@ use std::{sync::LazyLock, time::Duration}; use futures_util::future::{join_all, try_join_all}; - use http_client::ApiErr; use jiff::Timestamp; use regex::Regex; @@ -201,11 +200,13 @@ async fn sync_balance( #[cfg(test)] mod tests { - use super::*; - use futures_util::FutureExt; use std::cell::Cell; + + use futures_util::FutureExt; use tokio::time::Instant; + use super::*; + fn unavailable() -> WorkerError { sqlx::Error::Io(std::io::ErrorKind::ConnectionRefused.into()).into() } diff --git a/adapters/taler-wise/wise.conf b/adapters/taler-wise/wise.conf @@ -81,6 +81,22 @@ AUTH_METHOD = bearer # Token for bearer authentication scheme TOKEN = +[wise-httpd-observability-api] +# Whether to serve the Observability API +ENABLED = NO + +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = bearer + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +TOKEN = + [wisedb-postgres] # DB connection string CONFIG = postgres:///taler-wise diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -33,6 +33,7 @@ jiff.workspace = true aws-lc-rs.workspace = true compact_str.workspace = true regex.workspace = true +prometheus-client.workspace = true [dev-dependencies] taler-test-utils.workspace = true diff --git a/common/taler-api/src/api.rs b/common/taler-api/src/api.rs @@ -42,11 +42,12 @@ use wire::WireGateway; use crate::{ Listener, Serve, - api::prepared::PreparedTransfer, + api::{observability::Observability, prepared::PreparedTransfer}, auth::{AuthMethod, AuthMiddlewareState}, error::{ApiResult, LoggedError, failure, failure_code}, }; +pub mod observability; pub mod prepared; pub mod revenue; pub mod wire; @@ -93,6 +94,7 @@ pub trait TalerRouter { fn wire_gateway<T: WireGateway>(self, api: Arc<T>, auth: AuthMethod) -> Self; fn prepared_transfer<T: PreparedTransfer>(self, api: Arc<T>) -> Self; fn revenue<T: Revenue>(self, api: Arc<T>, auth: AuthMethod) -> Self; + fn observability<T: Observability>(self, api: Arc<T>, auth: AuthMethod) -> Self; fn finalize(self) -> Self; fn serve( self, @@ -114,6 +116,10 @@ impl TalerRouter for Router { self.nest("/taler-revenue", revenue::router(api, auth)) } + fn observability<T: Observability>(self, api: Arc<T>, auth: AuthMethod) -> Self { + self.nest("/taler-observability", observability::router(api, auth)) + } + fn finalize(self) -> Router { self.method_not_allowed_fallback(async || failure_code(ErrorCode::GENERIC_METHOD_INVALID)) .fallback(async || failure_code(ErrorCode::GENERIC_ENDPOINT_UNKNOWN)) diff --git a/common/taler-api/src/api/observability.rs b/common/taler-api/src/api/observability.rs @@ -0,0 +1,67 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::sync::Arc; + +use axum::{ + Json, Router, extract::State, http::header::CONTENT_TYPE, response::IntoResponse as _, + routing::get, +}; +use prometheus_client::{encoding::text::encode, registry::Registry}; +use taler_common::api::observability::Config; + +use crate::{ + api::{RouterUtils as _, TalerApi}, + auth::AuthMethod, + constants::OBSERVABILITY_API_VERSION, + error::ApiResult, +}; + +pub trait Observability: TalerApi { + fn metrics(&self) -> impl std::future::Future<Output = ApiResult<&Registry>> + Send; +} + +pub fn router<I: Observability>(state: Arc<I>, auth: AuthMethod) -> Router { + Router::new() + .route( + "/metrics", + get(async |State(state): State<Arc<I>>| { + let registry = state.metrics().await?; + let mut buffer = String::new(); + encode(&mut buffer, registry).unwrap(); + ApiResult::Ok(( + [( + CONTENT_TYPE, + "application/openmetrics-text; version=1.0.0; charset=utf-8", + )], + buffer, + )) + }), + ) + .auth(auth, "taler-obervability") + .route( + "/config", + get(async |State(state): State<Arc<I>>| { + Json(Config { + name: (), + version: OBSERVABILITY_API_VERSION, + implementation: Some(state.implementation()), + }) + .into_response() + }), + ) + .with_state(state) +} diff --git a/common/taler-api/src/constants.rs b/common/taler-api/src/constants.rs @@ -19,4 +19,5 @@ use taler_common::api::LibtoolVersion; pub const WIRE_GATEWAY_API_VERSION: LibtoolVersion = LibtoolVersion::new(5, 0, 0); pub const PREPARED_TRANSFER_API_VERSION: LibtoolVersion = LibtoolVersion::new(1, 0, 0); pub const REVENUE_API_VERSION: LibtoolVersion = LibtoolVersion::new(1, 1, 1); +pub const OBSERVABILITY_API_VERSION: LibtoolVersion = LibtoolVersion::new(0, 0, 0); pub const MAX_BODY_LENGTH: usize = 4 * 1024; // 4kB diff --git a/common/taler-api/src/test.rs b/common/taler-api/src/test.rs @@ -1,3 +1,19 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + use std::{ str::FromStr, sync::{Arc, LazyLock}, @@ -58,13 +74,13 @@ static UNKNOWN: LazyLock<PaytoURI> = fn test_api(pool: PgPool, currency: Currency) -> Router { let outgoing_channel = Sender::new(0); let incoming_channel = Sender::new(0); - let wg = TestApi { + let wg = TestApi::new( currency, - pool: pool.clone(), - payto: PAYTO.clone(), - outgoing_channel: outgoing_channel.clone(), - incoming_channel: incoming_channel.clone(), - }; + pool.clone(), + outgoing_channel.clone(), + incoming_channel.clone(), + PAYTO.clone(), + ); tokio::spawn(notification_listener( pool, outgoing_channel, @@ -74,7 +90,8 @@ fn test_api(pool: PgPool, currency: Currency) -> Router { Router::new() .wire_gateway(state.clone(), AuthMethod::None) .prepared_transfer(state.clone()) - .revenue(state, AuthMethod::None) + .revenue(state.clone(), AuthMethod::None) + .observability(state, AuthMethod::None) } async fn setup() -> (Router, PgPool) { diff --git a/common/taler-api/src/test/api.rs b/common/taler-api/src/test/api.rs @@ -15,6 +15,7 @@ */ use jiff::Timestamp; +use prometheus_client::registry::Registry; use sqlx::PgPool; use taler_common::{ api::{ @@ -36,6 +37,7 @@ use tokio::sync::watch::Sender; use crate::{ api::{ TalerApi, + observability::Observability, prepared::{PreparedTransfer, simple_subject}, revenue::Revenue, wire::WireGateway, @@ -51,6 +53,26 @@ pub struct TestApi { pub outgoing_channel: Sender<i64>, pub incoming_channel: Sender<i64>, pub payto: FullIbanPayto, + registry: Registry, +} + +impl TestApi { + pub fn new( + currency: Currency, + pool: PgPool, + outgoing_channel: Sender<i64>, + incoming_channel: Sender<i64>, + payto: FullIbanPayto, + ) -> Self { + Self { + currency, + pool, + outgoing_channel, + incoming_channel, + payto, + registry: Registry::default(), + } + } } impl TalerApi for TestApi { @@ -240,3 +262,9 @@ impl PreparedTransfer for TestApi { Ok(db::transfer_unregister(&self.pool, &req).await?) } } + +impl Observability for TestApi { + async fn metrics(&self) -> ApiResult<&Registry> { + Ok(&self.registry) + } +} diff --git a/common/taler-build/build.rs b/common/taler-build/build.rs @@ -14,10 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; fn git(root: &Path, args: &[&str]) -> Result<String, String> { let output = Command::new("git") diff --git a/common/taler-common/src/api.rs b/common/taler-common/src/api.rs @@ -25,6 +25,7 @@ use serde_json::value::RawValue; use crate::{encoding::base32::Base32Error, types::base32::Base32}; +pub mod observability; pub mod params; pub mod prepared; pub mod revenue; diff --git a/common/taler-common/src/api/observability.rs b/common/taler-common/src/api/observability.rs @@ -0,0 +1,23 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use serde::{Deserialize, Serialize}; +use taler_macros::api_config; + +/// <https://docs.taler.net/core/api-observability.html#tsref-type-Config> +#[api_config("taler-observability")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config<'a> {} diff --git a/debian/etc/taler-cyclos/conf.d/cyclos-httpd.conf b/debian/etc/taler-cyclos/conf.d/cyclos-httpd.conf @@ -10,3 +10,7 @@ SERVE = systemd [cyclos-httpd-revenue-api] # ENABLED = YES @inline-secret@ cyclos-httpd-revenue-api ../secrets/cyclos-httpd.secret.conf + +[cyclos-httpd-observability-api] +# ENABLED = YES +@inline-secret@ cyclos-httpd-observability-api ../secrets/cyclos-httpd.secret.conf diff --git a/debian/etc/taler-cyclos/secrets/cyclos-httpd.secret.conf b/debian/etc/taler-cyclos/secrets/cyclos-httpd.secret.conf @@ -5,3 +5,7 @@ [cyclos-httpd-revenue-api] # AUTH_METHOD = bearer # TOKEN = + +[cyclos-httpd-observability-api] +# AUTH_METHOD = bearer +# TOKEN = diff --git a/debian/etc/taler-magnet-bank/conf.d/magnet-bank-httpd.conf b/debian/etc/taler-magnet-bank/conf.d/magnet-bank-httpd.conf @@ -10,3 +10,8 @@ SERVE = systemd [magnet-bank-httpd-revenue-api] # ENABLED = YES @inline-secret@ magnet-bank-httpd-revenue-api ../secrets/magnet-bank-httpd.secret.conf + +[magnet-bank-httpd-observability-api] +# ENABLED = YES +@inline-secret@ magnet-bank-httpd-observability-api ../secrets/magnet-bank-httpd.secret.conf + diff --git a/debian/etc/taler-magnet-bank/secrets/magnet-bank-httpd.secret.conf b/debian/etc/taler-magnet-bank/secrets/magnet-bank-httpd.secret.conf @@ -5,3 +5,7 @@ [magnet-bank-httpd-revenue-api] # AUTH_METHOD = bearer # TOKEN = + +[magnet-bank-httpd-observability-api] +# AUTH_METHOD = bearer +# TOKEN = diff --git a/debian/etc/taler-wise/conf.d/wise-httpd.conf b/debian/etc/taler-wise/conf.d/wise-httpd.conf @@ -10,3 +10,7 @@ SERVE = systemd [wise-httpd-revenue-api] # ENABLED = YES @inline-secret@ wise-httpd-revenue-api ../secrets/wise-httpd.secret.conf + +[wise-httpd-observability-api] +# ENABLED = YES +@inline-secret@ wise-httpd-observability-api ../secrets/wise-httpd.secret.conf diff --git a/debian/etc/taler-wise/secrets/wise-httpd.secret.conf b/debian/etc/taler-wise/secrets/wise-httpd.secret.conf @@ -5,3 +5,7 @@ [wise-httpd-revenue-api] # AUTH_METHOD = bearer # TOKEN = + +[wise-httpd-observability-api] +# AUTH_METHOD = bearer +# TOKEN = +\ No newline at end of file