Skip to main content

netget/server/prometheus/
mod.rs

1//! Prometheus exporter: an HTTP `/metrics` endpoint whose metrics the model invents.
2//!
3//! A real Prometheus server (or `promtool`, or anything else that reads the exposition format)
4//! scrapes `GET /metrics`; NetGet raises one `prometheus_scrape` event, and the model answers
5//! with `send_metrics` — structured families, never exposition text. NetGet renders the text
6//! (format 0.0.4, or OpenMetrics 1.0.0 when the scraper prefers it) in [`exposition`], so the
7//! model cannot produce a body a scraper would reject.
8//!
9//! `GET /` is a static HTML page linking to `/metrics`, like every exporter's. `HEAD /metrics`
10//! answers the headers without asking the model. Anything else is a 404 or 405.
11//!
12//! See `src/server/prometheus/CLAUDE.md`.
13
14pub mod actions;
15/// Rendering and validation. Public so it can be tested directly against `promtool`.
16pub mod exposition;
17
18use std::convert::Infallible;
19use std::net::SocketAddr;
20use std::sync::Arc;
21
22use bytes::Bytes;
23use http_body_util::{BodyExt, Full};
24use hyper::body::Incoming;
25use hyper::header::{HeaderValue, ACCEPT, ALLOW, CONTENT_TYPE, RETRY_AFTER, USER_AGENT};
26use hyper::server::conn::http1;
27use hyper::service::service_fn;
28use hyper::{Method, Request, Response, StatusCode};
29use hyper_util::rt::TokioIo;
30use serde_json::{json, Value};
31use tokio::sync::mpsc;
32use tracing::{debug, error, info};
33
34use crate::llm::action_helper::call_llm;
35use crate::llm::ollama_client::OllamaClient;
36use crate::protocol::Event;
37use crate::server::connection::ConnectionId;
38use crate::state::app_state::AppState;
39use crate::state::ServerId;
40use crate::{console_debug, console_error, console_info};
41
42use exposition::MetricFamilies;
43
44/// Largest request body this server will buffer, in bytes.
45///
46/// A scrape is a `GET` and carries no body, so no legitimate request comes near this. hyper's
47/// `Incoming` has no limit of its own, and this server performs no authentication, so without
48/// a bound one unauthenticated `POST` could make it buffer without limit. The body is read and
49/// discarded before routing, which is what makes the bound apply to every path rather than
50/// only to the ones that would have used it.
51pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024;
52
53/// How long to wait for a peer's first byte after it connects.
54///
55/// HTTP is client-speaks-first, so a peer that has connected and sent nothing has not begun a
56/// request. Enforced with `TcpStream::peek` before hyper sees the socket, so a deadline never
57/// runs inside a model round-trip (hyper keeps polling the connection while a request is being
58/// answered, so a read deadline would).
59const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
60
61/// How long a connection may do nothing at all between requests.
62///
63/// Bounds silence, not work: `ConnectionActivity` reports a connection with a request in flight
64/// as not idle, so a slow model or a `manual` rule parked for a human never closes the
65/// connection its answer belongs to. Prometheus keeps one connection per target and scrapes on
66/// an interval that defaults to a minute, so two minutes keeps that connection alive across
67/// scrapes at the default interval; a longer interval simply reconnects.
68const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
69
70/// Concurrent connections this server admits.
71const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
72
73/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
74const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
75    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
76
77/// The static landing page every exporter serves at `/`.
78const INDEX_HTML: &str = "<html>\n<head><title>NetGet Prometheus Exporter</title></head>\n\
79<body>\n<h1>NetGet Prometheus Exporter</h1>\n<p><a href=\"/metrics\">Metrics</a></p>\n</body>\n\
80</html>\n";
81
82/// Prometheus exporter server.
83pub struct PrometheusServer;
84
85impl PrometheusServer {
86    /// Bind, then spawn the accept loop.
87    ///
88    /// The listener is bound before the task is spawned so a bind failure reaches the caller
89    /// and `server_startup` records `ServerStatus::Error`. The accept-loop handle is registered
90    /// so `stop_server` can abort it and release the port.
91    pub async fn spawn_with_llm_actions(
92        listen_addr: SocketAddr,
93        llm_client: OllamaClient,
94        app_state: Arc<AppState>,
95        status_tx: mpsc::UnboundedSender<String>,
96        server_id: ServerId,
97    ) -> anyhow::Result<SocketAddr> {
98        let listener =
99            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
100        let local_addr = listener.local_addr()?;
101
102        console_info!(
103            status_tx,
104            "Prometheus exporter listening on http://{}/metrics",
105            local_addr
106        );
107
108        let protocol = Arc::new(actions::PrometheusProtocol::new());
109        let task_registrar = app_state.clone();
110        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
111        let accept_handle = tokio::spawn(async move {
112            loop {
113                match crate::server::accept_bounded::accept_bounded(
114                    &listener,
115                    &limiter,
116                    CONNECTION_CAP_REFUSAL,
117                    "Prometheus",
118                    Some(&status_tx),
119                )
120                .await
121                {
122                    Ok((stream, remote_addr, permit)) => {
123                        let connection_id =
124                            ConnectionId::new(app_state.get_next_unified_id().await);
125                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
126                        info!(
127                            "Prometheus connection {} from {}",
128                            connection_id, remote_addr
129                        );
130
131                        use crate::state::server::{
132                            ConnectionState as ServerConnectionState, ConnectionStatus,
133                            ProtocolConnectionInfo,
134                        };
135                        let now = crate::utils::clock::Instant::now();
136                        app_state
137                            .add_connection_to_server(
138                                server_id,
139                                ServerConnectionState {
140                                    id: connection_id,
141                                    remote_addr,
142                                    local_addr: local_addr_conn,
143                                    bytes_sent: 0,
144                                    bytes_received: 0,
145                                    packets_sent: 0,
146                                    packets_received: 0,
147                                    last_activity: now,
148                                    status: ConnectionStatus::Active,
149                                    status_changed_at: now,
150                                    protocol_info: ProtocolConnectionInfo::empty(),
151                                },
152                            )
153                            .await;
154                        let _ = status_tx.send("__UPDATE_UI__".to_string());
155
156                        let ctx = RequestContext {
157                            llm_client: llm_client.clone(),
158                            app_state: app_state.clone(),
159                            status_tx: status_tx.clone(),
160                            protocol: protocol.clone(),
161                            server_id,
162                            connection_id,
163                        };
164                        let app_state_for_close = app_state.clone();
165                        let status_for_close = status_tx.clone();
166
167                        let task_owner = app_state.clone();
168                        task_owner
169                            .spawn_server_task(server_id, async move {
170                                // Held for the life of the connection: dropping it early frees
171                                // the slot while the peer is still here.
172                                let _permit = permit;
173
174                                // First-byte bound on the raw socket, before hyper sees it.
175                                // `peek` does not consume, so the request line is still there.
176                                match tokio::time::timeout(
177                                    FIRST_BYTE_READ_TIMEOUT,
178                                    stream.peek(&mut [0u8; 1]),
179                                )
180                                .await
181                                {
182                                    Ok(Ok(n)) if n > 0 => {
183                                        serve_connection(TokioIo::new(stream), ctx).await;
184                                    }
185                                    Ok(_) => {}
186                                    Err(_) => {
187                                        debug!(
188                                            "Prometheus peer {} sent nothing for {}s; closing \
189                                             before the request line",
190                                            remote_addr,
191                                            FIRST_BYTE_READ_TIMEOUT.as_secs()
192                                        );
193                                    }
194                                }
195
196                                app_state_for_close
197                                    .close_connection_on_server(server_id, connection_id)
198                                    .await;
199                                let _ = status_for_close.send(format!(
200                                    "[INFO] Prometheus connection {connection_id} closed"
201                                ));
202                                let _ = status_for_close.send("__UPDATE_UI__".to_string());
203                            })
204                            .await;
205                    }
206                    Err(e) => {
207                        console_error!(status_tx, "Failed to accept Prometheus connection: {}", e);
208                        break;
209                    }
210                }
211            }
212        });
213
214        task_registrar
215            .register_server_task(server_id, accept_handle)
216            .await;
217
218        Ok(local_addr)
219    }
220}
221
222/// Per-connection dependencies, cloned into each hyper service call.
223#[derive(Clone)]
224struct RequestContext {
225    llm_client: OllamaClient,
226    app_state: Arc<AppState>,
227    status_tx: mpsc::UnboundedSender<String>,
228    protocol: Arc<actions::PrometheusProtocol>,
229    server_id: ServerId,
230    connection_id: ConnectionId,
231}
232
233async fn serve_connection<T>(io: TokioIo<T>, ctx: RequestContext)
234where
235    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
236{
237    let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
238    let activity_for_service = Arc::clone(&activity);
239
240    let service = service_fn(move |req: Request<Incoming>| {
241        let ctx = ctx.clone();
242        let activity = Arc::clone(&activity_for_service);
243        async move {
244            let _busy = activity.busy();
245            Ok::<_, Infallible>(handle_request(req, ctx).await)
246        }
247    });
248
249    let conn = http1::Builder::new().serve_connection(io, service);
250    tokio::pin!(conn);
251    tokio::select! {
252        result = &mut conn => {
253            if let Err(err) = result {
254                debug!("Prometheus connection ended: {:?}", err);
255            }
256        }
257        _ = crate::server::accept_bounded::watch_idle(
258            Arc::clone(&activity),
259            IDLE_BETWEEN_REQUESTS_TIMEOUT,
260        ) => {
261            debug!(
262                "Prometheus connection idle for {}s; closing",
263                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
264            );
265        }
266    }
267}
268
269fn text_response(
270    status: StatusCode,
271    content_type: &'static str,
272    body: String,
273) -> Response<Full<Bytes>> {
274    let mut response = Response::new(Full::new(Bytes::from(body)));
275    *response.status_mut() = status;
276    response
277        .headers_mut()
278        .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
279    response
280}
281
282/// A plain-text error. Prometheus records the status line as the scrape error and never reads
283/// the body as metrics, so a refusal can never be mistaken for an empty exposition.
284fn error_response(status: StatusCode, message: &'static str) -> Response<Full<Bytes>> {
285    text_response(status, "text/plain; charset=utf-8", format!("{message}\n"))
286}
287
288async fn handle_request(req: Request<Incoming>, ctx: RequestContext) -> Response<Full<Bytes>> {
289    let (parts, body) = req.into_parts();
290    let method = parts.method.clone();
291    let path = parts.uri.path().to_string();
292    let header = |name| {
293        parts
294            .headers
295            .get(name)
296            .and_then(|v: &HeaderValue| v.to_str().ok())
297            .map(str::to_string)
298    };
299    let accept = header(ACCEPT);
300    let user_agent = header(USER_AGENT).unwrap_or_default();
301    let scrape_timeout = header(hyper::header::HeaderName::from_static(
302        "x-prometheus-scrape-timeout-seconds",
303    ));
304
305    debug!("Prometheus {} {}", method, path);
306    let _ = ctx
307        .status_tx
308        .send(format!("[DEBUG] Prometheus {method} {path}"));
309
310    // Bounded, and read before routing so the bound holds on every path. A body over the cap
311    // is refused without the model being asked anything.
312    let body_len = match http_body_util::Limited::new(body, MAX_REQUEST_BODY_BYTES)
313        .collect()
314        .await
315    {
316        Ok(collected) => collected.to_bytes().len(),
317        Err(e) => {
318            error!(
319                "Prometheus {} {}: decision=fail_closed_body_rejected (limit {} bytes): {}",
320                method, path, MAX_REQUEST_BODY_BYTES, e
321            );
322            return error_response(
323                StatusCode::PAYLOAD_TOO_LARGE,
324                "netget: request body is larger than this exporter accepts",
325            );
326        }
327    };
328    ctx.app_state
329        .update_connection_stats(
330            ctx.server_id,
331            ctx.connection_id,
332            Some(body_len as u64),
333            None,
334            Some(1),
335            None,
336        )
337        .await;
338
339    match (path.as_str(), &method) {
340        ("/", &Method::GET) | ("/", &Method::HEAD) => {
341            return text_response(
342                StatusCode::OK,
343                "text/html; charset=utf-8",
344                INDEX_HTML.to_string(),
345            );
346        }
347        ("/metrics", &Method::GET) => {}
348        ("/metrics", &Method::HEAD) => {
349            let format = exposition::negotiate(accept.as_deref());
350            return text_response(StatusCode::OK, format.content_type(), String::new());
351        }
352        ("/metrics", _) | ("/", _) => {
353            let mut response = error_response(
354                StatusCode::METHOD_NOT_ALLOWED,
355                "netget: only GET and HEAD are served here",
356            );
357            response
358                .headers_mut()
359                .insert(ALLOW, HeaderValue::from_static("GET, HEAD"));
360            return response;
361        }
362        _ => {
363            return error_response(StatusCode::NOT_FOUND, "404 page not found");
364        }
365    }
366
367    let format = exposition::negotiate(accept.as_deref());
368    let mut data = json!({
369        "path": path,
370        "accept": accept.clone().unwrap_or_default(),
371        "user_agent": user_agent,
372        "format": format.as_str(),
373    });
374    if let Some(timeout) = scrape_timeout
375        .as_deref()
376        .and_then(|t| t.parse::<f64>().ok())
377    {
378        data["scrape_timeout_seconds"] = json!(timeout);
379    }
380
381    console_debug!(
382        ctx.status_tx,
383        "Calling LLM for Prometheus scrape of {}",
384        path
385    );
386    let event = Event::new(&actions::PROMETHEUS_SCRAPE_EVENT, data);
387    let llm_result = call_llm(
388        &ctx.llm_client,
389        &ctx.app_state,
390        ctx.server_id,
391        Some(ctx.connection_id),
392        &event,
393        ctx.protocol.as_ref(),
394    )
395    .await;
396
397    match llm_result {
398        Ok(execution) => {
399            let failure_summary = execution.failure_summary();
400            for result in execution.protocol_results {
401                let crate::llm::ActionResult::Custom { name, data } = result else {
402                    continue;
403                };
404                match name.as_str() {
405                    "send_metrics" => {
406                        let Some(families) = data
407                            .get("metrics")
408                            .and_then(|m| MetricFamilies::parse(m).ok())
409                        else {
410                            // The executor already validated this; failing here means the two
411                            // disagree, which must not become a half-rendered body.
412                            error!(
413                                "Prometheus {}: decision=fail_closed_invalid_exposition \
414                                 (executor output did not re-validate)",
415                                path
416                            );
417                            return error_response(
418                                StatusCode::INTERNAL_SERVER_ERROR,
419                                "netget: the handler's metrics were refused as invalid",
420                            );
421                        };
422                        let body = families.render(format);
423                        info!(
424                            "Prometheus {}: decision=model_answer format={} families={} samples={}",
425                            path,
426                            format.as_str(),
427                            families.family_count(),
428                            families.sample_count()
429                        );
430                        ctx.app_state
431                            .update_connection_stats(
432                                ctx.server_id,
433                                ctx.connection_id,
434                                None,
435                                Some(body.len() as u64),
436                                None,
437                                Some(1),
438                            )
439                            .await;
440                        return text_response(StatusCode::OK, format.content_type(), body);
441                    }
442                    "send_scrape_error" => {
443                        let status = data
444                            .get("status")
445                            .and_then(Value::as_u64)
446                            .and_then(|s| u16::try_from(s).ok())
447                            .and_then(|s| StatusCode::from_u16(s).ok())
448                            .filter(|s| s.is_client_error() || s.is_server_error())
449                            .unwrap_or(StatusCode::SERVICE_UNAVAILABLE);
450                        let message = data
451                            .get("message")
452                            .and_then(Value::as_str)
453                            .unwrap_or("scrape refused")
454                            .to_string();
455                        info!(
456                            "Prometheus {}: decision=model_reject status={}",
457                            path,
458                            status.as_u16()
459                        );
460                        return text_response(
461                            status,
462                            "text/plain; charset=utf-8",
463                            format!("{}\n", crate::utils::sanitize::line_field(&message)),
464                        );
465                    }
466                    other => debug!("Prometheus: ignoring non-exporter action '{}'", other),
467                }
468            }
469            // Refuse rather than serve an empty exposition: an empty 200 is a claim that the
470            // target has no metrics, and "the model said nothing" must not read as that.
471            match failure_summary {
472                None => error!(
473                    "Prometheus {}: decision=fail_closed_no_action (no send_metrics)",
474                    path
475                ),
476                Some(summary) => error!(
477                    "Prometheus {}: decision=fail_closed_invalid_exposition ({})",
478                    path, summary
479                ),
480            }
481            console_error!(
482                ctx.status_tx,
483                "Prometheus: the handler returned no usable metrics for {}",
484                path
485            );
486            error_response(
487                StatusCode::INTERNAL_SERVER_ERROR,
488                "netget: the handler returned no usable metrics",
489            )
490        }
491        Err(e) => {
492            // The peer gets a category; the log gets the error.
493            let failure = crate::utils::WireFailure::classify(&e);
494            error!(
495                "Prometheus {}: decision=fail_closed_llm_error category={:?} error={}",
496                path, failure, e
497            );
498            console_error!(ctx.status_tx, "Prometheus LLM call failed: {}", e);
499            match failure {
500                crate::utils::WireFailure::Overloaded => {
501                    let mut response =
502                        error_response(StatusCode::SERVICE_UNAVAILABLE, failure.prefixed_text());
503                    response
504                        .headers_mut()
505                        .insert(RETRY_AFTER, HeaderValue::from_static("5"));
506                    response
507                }
508                crate::utils::WireFailure::Unavailable => {
509                    error_response(StatusCode::INTERNAL_SERVER_ERROR, failure.prefixed_text())
510                }
511            }
512        }
513    }
514}