1pub mod actions;
15pub 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
44pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024;
52
53const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
60
61const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
69
70const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
72
73const 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
77const 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
82pub struct PrometheusServer;
84
85impl PrometheusServer {
86 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 let _permit = permit;
173
174 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#[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
282fn 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 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 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 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 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}