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    async fn execute(
105        &self,
106        ctx: Context,
107        _named_inputs: &HashMap<String, serde_json::Value>,
108    ) -> 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 {
123            context: ctx,
124            named_outputs: HashMap::new(),
125        })
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
133    use bytes::Bytes;
134
135    fn test_ctx(consumer: Option<&str>) -> Context {
136        let mut message = HashMap::new();
137        if let Some(name) = consumer {
138            message.insert("consumer.name".to_string(), serde_json::json!(name));
139        }
140        Context {
141            request: GatewayRequest {
142                method: "GET".to_string(),
143                path: "/api/users".to_string(),
144                host: "api.example.com".to_string(),
145                scheme: "http".to_string(),
146                headers: HashMap::new(),
147                query_params: HashMap::new(),
148                body: Bytes::new(),
149                remote_addr: "10.0.0.1:1234".to_string(),
150                protocol: Protocol::Http1,
151            },
152            response: GatewayResponse {
153                status_code: 200,
154                headers: HashMap::new(),
155                body: Bytes::new(),
156            },
157            message,
158            errors: Vec::new(),
159        }
160    }
161
162    fn plugin_with_metrics(metrics: Arc<GatewayMetrics>) -> PrometheusPlugin {
163        let resources = PluginResources::new(Some(metrics));
164        PrometheusPlugin::from_config(&HashMap::new(), &resources).unwrap()
165    }
166
167    #[test]
168    fn from_config_reads_prefer_name() {
169        let cfg: HashMap<String, serde_json::Value> =
170            serde_json::from_value(serde_json::json!({ "prefer_name": true })).unwrap();
171        let p = PrometheusPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
172        assert!(p.prefer_name);
173        // default false
174        let p = PrometheusPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
175        assert!(!p.prefer_name);
176    }
177
178    #[tokio::test]
179    async fn execute_bumps_consumer_counter() {
180        let metrics = Arc::new(GatewayMetrics::new());
181        let p = plugin_with_metrics(metrics.clone());
182
183        let out = p
184            .execute(test_ctx(Some("alice")), &HashMap::new())
185            .await
186            .unwrap();
187        // context passes through unchanged
188        assert_eq!(out.context.request.path, "/api/users");
189
190        assert_eq!(
191            metrics
192                .consumer_requests
193                .with_label_values(&["alice", "api.example.com"])
194                .get(),
195            1
196        );
197
198        // a second request for the same consumer increments again
199        p.execute(test_ctx(Some("alice")), &HashMap::new())
200            .await
201            .unwrap();
202        assert_eq!(
203            metrics
204                .consumer_requests
205                .with_label_values(&["alice", "api.example.com"])
206                .get(),
207            2
208        );
209    }
210
211    #[tokio::test]
212    async fn execute_defaults_to_anonymous() {
213        let metrics = Arc::new(GatewayMetrics::new());
214        let p = plugin_with_metrics(metrics.clone());
215
216        p.execute(test_ctx(None), &HashMap::new()).await.unwrap();
217        assert_eq!(
218            metrics
219                .consumer_requests
220                .with_label_values(&["anonymous", "api.example.com"])
221                .get(),
222            1
223        );
224    }
225
226    #[tokio::test]
227    async fn execute_is_noop_without_metrics() {
228        // resources.metrics == None must not panic and must pass ctx through.
229        let p = PrometheusPlugin::from_config(&HashMap::new(), &PluginResources::empty()).unwrap();
230        let out = p
231            .execute(test_ctx(Some("bob")), &HashMap::new())
232            .await
233            .unwrap();
234        assert_eq!(out.context.response.status_code, 200);
235    }
236}