Skip to main content

featherbit/plugins/native/
prometheus.rs

1//! The `prometheus` node — a thin parity node over featherbit's built-in
2//! Prometheus metrics.
3//!
4//! **featherbit already exposes Prometheus metrics out of the box.** The
5//! [`GatewayMetrics`](crate::metrics::GatewayMetrics) registry records
6//! per-route request counters and latency histograms plus per-node execution
7//! metrics; the graph engine and data-plane listener feed it on every request
8//! with no plugin required, and it is rendered at the Admin API's `/metrics`
9//! endpoint. So this node does **not** stand up metrics from scratch.
10//!
11//! It exists only to add a dimension APISIX's `prometheus` plugin tracks that
12//! featherbit's always-on core metrics do not: a **per-consumer request
13//! counter** (`gateway_consumer_requests_total`, labelled `consumer` and
14//! `route`). Drop it into a pipeline **after** the auth node that attaches the
15//! consumer (`key-auth`, `basic-auth`, ...); each execution bumps the counter
16//! for the request's consumer (`anonymous` when none is attached). It never
17//! mutates the context and never fails.
18//!
19//! ## Deviations from APISIX
20//! - APISIX's plugin wires up the entire Prometheus exporter and its full
21//!   metric set. In featherbit the core metrics are built-in and always on, so
22//!   this node is a thin add-on that only records the per-consumer counter.
23//! - APISIX's `prefer_name` toggles route *name* vs route *id* in labels.
24//!   featherbit has no route object on the context here, so the `route` label
25//!   uses the request `Host` (kept low-cardinality on purpose). `prefer_name`
26//!   is accepted for config compatibility but is otherwise inert.
27
28use async_trait::async_trait;
29use std::collections::HashMap;
30use std::sync::Arc;
31
32use crate::context::Context;
33use crate::metrics::GatewayMetrics;
34use crate::plugins::resources::PluginResources;
35use crate::plugins::{Plugin, PluginOutput, PluginResult};
36
37/// Records a per-consumer request counter against the shared
38/// [`GatewayMetrics`]; a no-op when metrics are disabled (unit tests).
39pub struct PrometheusPlugin {
40    /// Accepted for APISIX config compatibility; inert (see module docs).
41    prefer_name: bool,
42    /// Shared metrics handle from `PluginResources`; `None` disables recording.
43    metrics: Option<Arc<GatewayMetrics>>,
44}
45
46impl PrometheusPlugin {
47    /// Builds the plugin from node config.
48    ///
49    /// Config keys (all optional):
50    /// - `prefer_name` (bool, default `false`): accepted for parity with
51    ///   APISIX's `prometheus` plugin; inert in featherbit (the `route` label
52    ///   is always the request host — see module docs).
53    ///
54    /// ```yaml
55    /// type: prometheus
56    /// config:
57    ///   prefer_name: true
58    /// ```
59    pub fn from_config(
60        config: &HashMap<String, serde_json::Value>,
61        resources: &Arc<PluginResources>,
62    ) -> Result<Self, String> {
63        let prefer_name = config
64            .get("prefer_name")
65            .and_then(|v| v.as_bool())
66            .unwrap_or(false);
67
68        Ok(Self {
69            prefer_name,
70            metrics: resources.metrics.clone(),
71        })
72    }
73
74    /// The `consumer` label for this request: the consumer name attached by an
75    /// upstream auth node, or `anonymous` when none is present.
76    fn consumer_label(ctx: &Context) -> String {
77        ctx.message
78            .get("consumer.name")
79            .and_then(|v| v.as_str())
80            .filter(|s| !s.is_empty())
81            .unwrap_or("anonymous")
82            .to_string()
83    }
84
85    /// The `route` label for this request. featherbit has no route object on
86    /// the context here, so the request host is used (low-cardinality);
87    /// falls back to `unknown` when absent.
88    fn route_label(ctx: &Context) -> String {
89        let host = ctx.request.host.trim();
90        if host.is_empty() {
91            "unknown".to_string()
92        } else {
93            host.to_string()
94        }
95    }
96}
97
98#[async_trait]
99impl Plugin for PrometheusPlugin {
100    fn plugin_type(&self) -> &str {
101        "prometheus"
102    }
103
104    fn reads_response_body(&self) -> bool {
105        false
106    }
107
108    async fn execute(&self, ctx: Context) -> PluginResult {
109        if let Some(ref metrics) = self.metrics {
110            let consumer = Self::consumer_label(&ctx);
111            let route = Self::route_label(&ctx);
112            metrics
113                .consumer_requests
114                .with_label_values(&[&consumer, &route])
115                .inc();
116        }
117
118        // `prefer_name` is intentionally read-but-inert; touch it so the field
119        // is never flagged as dead while documenting its parity purpose.
120        let _ = self.prefer_name;
121
122        Ok(PluginOutput::success(ctx))
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
130    use bytes::Bytes;
131
132    fn test_ctx(consumer: Option<&str>) -> Context {
133        let mut message = HashMap::new();
134        if let Some(name) = consumer {
135            message.insert("consumer.name".to_string(), serde_json::json!(name));
136        }
137        Context {
138            request: GatewayRequest {
139                method: "GET".to_string(),
140                path: "/api/users".to_string(),
141                host: "api.example.com".to_string(),
142                scheme: "http".to_string(),
143                headers: HashMap::new(),
144                query_params: HashMap::new(),
145                body: Bytes::new(),
146                remote_addr: "10.0.0.1:1234".to_string(),
147                protocol: Protocol::Http1,
148            },
149            response: GatewayResponse {
150                status_code: 200,
151                headers: HashMap::new(),
152                body: Bytes::new(),
153                stream: None,
154            },
155            message,
156            errors: Vec::new(),
157        }
158    }
159
160    fn plugin_with_metrics(metrics: Arc<GatewayMetrics>) -> PrometheusPlugin {
161        let resources = PluginResources::new(Some(metrics));
162        PrometheusPlugin::from_config(&HashMap::new(), &resources).unwrap()
163    }
164
165    #[test]
166    fn from_config_reads_prefer_name() {
167        let cfg: HashMap<String, serde_json::Value> =
168            serde_json::from_value(serde_json::json!({ "prefer_name": true })).unwrap();
169        let p = PrometheusPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
170        assert!(p.prefer_name);
171        // default false
172        let p = PrometheusPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
173        assert!(!p.prefer_name);
174    }
175
176    #[tokio::test]
177    async fn execute_bumps_consumer_counter() {
178        let metrics = Arc::new(GatewayMetrics::new());
179        let p = plugin_with_metrics(metrics.clone());
180
181        let out = p.execute(test_ctx(Some("alice"))).await.unwrap();
182        // context passes through unchanged
183        assert_eq!(out.context.request.path, "/api/users");
184
185        assert_eq!(
186            metrics
187                .consumer_requests
188                .with_label_values(&["alice", "api.example.com"])
189                .get(),
190            1
191        );
192
193        // a second request for the same consumer increments again
194        p.execute(test_ctx(Some("alice"))).await.unwrap();
195        assert_eq!(
196            metrics
197                .consumer_requests
198                .with_label_values(&["alice", "api.example.com"])
199                .get(),
200            2
201        );
202    }
203
204    #[tokio::test]
205    async fn execute_defaults_to_anonymous() {
206        let metrics = Arc::new(GatewayMetrics::new());
207        let p = plugin_with_metrics(metrics.clone());
208
209        p.execute(test_ctx(None)).await.unwrap();
210        assert_eq!(
211            metrics
212                .consumer_requests
213                .with_label_values(&["anonymous", "api.example.com"])
214                .get(),
215            1
216        );
217    }
218
219    #[tokio::test]
220    async fn execute_is_noop_without_metrics() {
221        // resources.metrics == None must not panic and must pass ctx through.
222        let p = PrometheusPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
223        let out = p.execute(test_ctx(Some("bob"))).await.unwrap();
224        assert_eq!(out.context.response.status_code, 200);
225    }
226}