1use crate::llm::actions::{
10 protocol_trait::{ActionResult, Protocol, Server},
11 ActionDefinition, Parameter,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::state::app_state::AppState;
16use anyhow::{Context, Result};
17use serde_json::json;
18use std::sync::LazyLock;
19
20use super::exposition::MetricFamilies;
21
22pub static PROMETHEUS_SCRAPE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
24 EventType::new(
25 "prometheus_scrape",
26 "A Prometheus server (or promtool, or any metrics collector) scraped /metrics. Answer \
27 with the metrics this target exposes right now. Counters only ever go up between \
28 scrapes; keep values consistent with earlier answers if memory holds them.",
29 example_metrics_action(),
30 )
31 .with_actions(vec![send_metrics_action(), send_scrape_error_action()])
32});
33
34pub struct PrometheusProtocol;
36
37impl PrometheusProtocol {
38 pub fn new() -> Self {
39 Self
40 }
41}
42
43impl Default for PrometheusProtocol {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49impl Protocol for PrometheusProtocol {
50 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
51 Vec::new()
52 }
53
54 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
55 vec![send_metrics_action(), send_scrape_error_action()]
56 }
57
58 fn protocol_name(&self) -> &'static str {
59 "Prometheus"
60 }
61
62 fn get_event_types(&self) -> Vec<EventType> {
63 vec![PROMETHEUS_SCRAPE_EVENT.clone()]
64 }
65
66 fn stack_name(&self) -> &'static str {
67 "ETH>IP>TCP>HTTP>PROMETHEUS"
68 }
69
70 fn keywords(&self) -> Vec<&'static str> {
71 vec![
73 "prometheus",
74 "exporter",
75 "node_exporter",
76 "/metrics",
77 "openmetrics",
78 ]
79 }
80
81 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
82 use crate::protocol::metadata::{
83 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
84 };
85
86 ProtocolMetadataV2::builder()
87 .state(DevelopmentState::Beta)
94 .well_known_port(9100)
95 .privilege_requirement(PrivilegeRequirement::None)
96 .implementation(
97 "hyper HTTP/1.1. GET /metrics raises prometheus_scrape; the model answers with \
98 structured families and NetGet renders the exposition itself (text format \
99 0.0.4, or OpenMetrics 1.0.0 when the Accept header prefers it) - names \
100 validated, label values and help escaped, counters suffixed _total, histogram \
101 buckets sorted with a +Inf bucket and _count guaranteed. GET / is a static \
102 HTML link page; HEAD /metrics answers headers without the model.",
103 )
104 .llm_control(
105 "Every scrape: which metric families exist, their types, help text, labels and \
106 values (send_metrics), or a refusal (send_scrape_error). NetGet stores no \
107 values between scrapes; a static handler serves a fixed set with no LLM call.",
108 )
109 .e2e_testing(
110 "tests/server/prometheus/real_client_test.rs - promtool check metrics (the \
111 Prometheus project's own parser and linter) reads bodies NetGet served and \
112 must exit 0; a deliberately broken exposition is the negative control that \
113 proves promtool rejects. A real prometheus binary scrapes NetGet in both \
114 negotiated formats and its query API must report up == 1 and the served \
115 sample. Both binaries HARD FAIL when absent. e2e_test.rs covers the mocked \
116 model path; exposition_test.rs the renderer.",
117 )
118 .notes(
119 "Exposition formats: text 0.0.4 and OpenMetrics 1.0.0 (negotiated from \
120 Accept). Not implemented: protobuf exposition, native histograms, exemplars, \
121 _created series, # UNIT lines, gzip, UTF-8 metric names (legacy names only), \
122 federation's match[] filtering, and any authentication. Invalid families from \
123 the model are refused by the executor (the reason is in the log and the \
124 access log) and the scrape is answered 500, never a partial body.",
125 )
126 .max_inbound_bytes(super::MAX_REQUEST_BODY_BYTES)
127 .answers_on_failure()
131 .request_only("Prometheus scraping is HTTP request/response; a server cannot send a peer anything unprompted")
132 .build()
133 }
134
135 fn description(&self) -> &'static str {
136 "Prometheus exporter (/metrics) whose metrics the model invents"
137 }
138
139 fn example_prompt(&self) -> &'static str {
140 "Be a Prometheus exporter on port 9100 for a web server: request counts by status code, \
141 an in-flight gauge and a request latency histogram"
142 }
143
144 fn group_name(&self) -> &'static str {
145 "AI & API"
146 }
147
148 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
149 use crate::llm::actions::StartupExamples;
150
151 StartupExamples::new(
152 json!({
153 "type": "open_server",
154 "port": 9100,
155 "base_stack": "prometheus",
156 "instruction": "Prometheus exporter for a web server. Expose \
157 http_requests_total by method and status (GET 200 grows by \
158 about 50 per scrape, 500s are rare), an in-flight requests \
159 gauge between 0 and 10, and a request latency histogram in \
160 seconds."
161 }),
162 json!({
163 "type": "open_server",
164 "port": 9100,
165 "base_stack": "prometheus",
166 "event_handlers": [{
167 "event_pattern": "prometheus_scrape",
168 "handler": {
169 "type": "script",
170 "language": "python",
171 "code": "import json, sys, time\nevent = json.load(sys.stdin)['event']\nfmt = event.get('format', 'text')\nprint(json.dumps({'actions': [{'type': 'send_metrics', 'metrics': [{'name': 'app_time_seconds', 'type': 'gauge', 'help': 'Seconds since the epoch at scrape time', 'samples': [{'value': int(time.time())}]}, {'name': 'app_scrapes_total', 'type': 'counter', 'help': 'Scrapes served, by negotiated format', 'samples': [{'labels': {'format': fmt}, 'value': 1}]}]}]}))"
172 }
173 }]
174 }),
175 json!({
176 "type": "open_server",
177 "port": 9100,
178 "base_stack": "prometheus",
179 "event_handlers": [{
180 "event_pattern": "prometheus_scrape",
181 "handler": {
182 "type": "static",
183 "actions": [example_metrics_action()]
184 }
185 }]
186 }),
187 )
188 }
189}
190
191impl Server for PrometheusProtocol {
192 fn spawn(
193 &self,
194 ctx: crate::protocol::SpawnContext,
195 ) -> std::pin::Pin<
196 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
197 > {
198 Box::pin(async move {
199 super::PrometheusServer::spawn_with_llm_actions(
200 ctx.legacy_listen_addr(),
201 ctx.llm_client,
202 ctx.state,
203 ctx.status_tx,
204 ctx.server_id,
205 )
206 .await
207 })
208 }
209
210 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
211 let action_type = action
212 .get("type")
213 .and_then(|v| v.as_str())
214 .context("Missing 'type' field in action")?;
215
216 match action_type {
217 "send_metrics" => {
218 let metrics = action
219 .get("metrics")
220 .context("send_metrics is missing the required 'metrics' array")?;
221 let families = MetricFamilies::parse(metrics)
224 .map_err(|reason| anyhow::anyhow!("send_metrics refused: {reason}"))?;
225 Ok(ActionResult::Custom {
226 name: "send_metrics".to_string(),
227 data: json!({
228 "metrics": metrics,
229 "families": families.family_count(),
230 "samples": families.sample_count(),
231 }),
232 })
233 }
234 "send_scrape_error" => {
235 let status = action.get("status").and_then(|v| v.as_u64()).unwrap_or(503);
236 if !(400..600).contains(&status) {
237 return Err(anyhow::anyhow!(
238 "send_scrape_error 'status' must be a 4xx or 5xx HTTP status, got {status}"
239 ));
240 }
241 let message = action
242 .get("message")
243 .and_then(|v| v.as_str())
244 .unwrap_or("scrape refused")
245 .to_string();
246 Ok(ActionResult::Custom {
247 name: "send_scrape_error".to_string(),
248 data: json!({"status": status, "message": message}),
249 })
250 }
251 other => Err(anyhow::anyhow!("Unknown Prometheus action: {other}")),
252 }
253 }
254}
255
256fn example_metrics_action() -> serde_json::Value {
257 json!({
258 "type": "send_metrics",
259 "metrics": [
260 {
261 "name": "http_requests_total",
262 "type": "counter",
263 "help": "HTTP requests served, by method and status code.",
264 "samples": [
265 {"labels": {"method": "GET", "code": "200"}, "value": 1027},
266 {"labels": {"method": "GET", "code": "500"}, "value": 3}
267 ]
268 },
269 {
270 "name": "http_requests_in_flight",
271 "type": "gauge",
272 "help": "Requests currently being served.",
273 "samples": [{"value": 4}]
274 },
275 {
276 "name": "http_request_duration_seconds",
277 "type": "histogram",
278 "help": "Request latency.",
279 "samples": [
280 {"suffix": "_bucket", "labels": {"le": "0.1"}, "value": 900},
281 {"suffix": "_bucket", "labels": {"le": "0.5"}, "value": 1010},
282 {"suffix": "_bucket", "labels": {"le": "+Inf"}, "value": 1030},
283 {"suffix": "_sum", "value": 81.4},
284 {"suffix": "_count", "value": 1030}
285 ]
286 }
287 ]
288 })
289}
290
291fn send_metrics_action() -> ActionDefinition {
292 ActionDefinition {
293 name: "send_metrics".to_string(),
294 description:
295 "Answer a scrape with this target's metrics. Give structured families; NetGet \
296 writes the exposition text, escapes it, appends _total to counters, sorts histogram \
297 buckets and adds the +Inf bucket and _count if you leave them out. An invalid name \
298 or a malformed histogram is refused and the scrape fails, so keep names to \
299 letters, digits, underscores and colons."
300 .to_string(),
301 parameters: vec![Parameter {
302 name: "metrics".to_string(),
303 type_hint: "array".to_string(),
304 description: "Metric families: [{\"name\": \"http_requests_total\", \"type\": \
305 counter|gauge|histogram|summary|untyped, \"help\": \"one line of text\", \
306 \"samples\": [{\"labels\": {\"k\": \"v\"}, \"value\": number or \"NaN\"/\"+Inf\", \
307 \"suffix\": \"_bucket\"|\"_sum\"|\"_count\"|\"_total\"|\"\" , \
308 \"timestamp_ms\": optional integer}]}]. A histogram's _bucket samples carry an \
309 \"le\" label (upper bound, or \"+Inf\") and are cumulative; a summary's \
310 quantile samples carry a \"quantile\" label between 0 and 1 and no suffix. \
311 Gauges and untyped samples have no suffix."
312 .to_string(),
313 required: true,
314 }],
315 example: example_metrics_action(),
316 log_template: Some(
317 LogTemplate::new()
318 .with_info("-> Prometheus metrics ({families} families, {samples} samples)")
319 .with_debug("Prometheus send_metrics: {families} families {samples} samples"),
320 ),
321 }
322}
323
324fn send_scrape_error_action() -> ActionDefinition {
325 ActionDefinition {
326 name: "send_scrape_error".to_string(),
327 description: "Fail this scrape on purpose - the target is down, overloaded or refuses the \
328 scraper. Prometheus marks the target up=0 and shows the message as the scrape \
329 error. Structurally distinct from send_metrics, so a refusal is never read as an \
330 empty target."
331 .to_string(),
332 parameters: vec![
333 Parameter {
334 name: "status".to_string(),
335 type_hint: "number".to_string(),
336 description: "HTTP status, 4xx or 5xx (default 503)".to_string(),
337 required: false,
338 },
339 Parameter {
340 name: "message".to_string(),
341 type_hint: "string".to_string(),
342 description: "One line of text explaining the failure".to_string(),
343 required: false,
344 },
345 ],
346 example: json!({
347 "type": "send_scrape_error",
348 "status": 503,
349 "message": "exporter is warming up"
350 }),
351 log_template: Some(
352 LogTemplate::new()
353 .with_info("-> Prometheus scrape refused ({status})")
354 .with_debug("Prometheus send_scrape_error: {status} {message}"),
355 ),
356 }
357}