Skip to main content

netget/server/prometheus/
actions.rs

1//! Prometheus exporter actions.
2//!
3//! One event, two actions, all structured. The model never writes exposition text: it names
4//! families, types, help strings, labels and values, and [`super::exposition`] validates and
5//! renders them. That is the whole point of the protocol here — a scraper rejects a target over
6//! one malformed line, so the part a model is worst at (byte-exact text framing, escaping,
7//! suffix conventions) is the part NetGet owns.
8
9use 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
22/// `GET /metrics` — a scraper wants the target's current metrics.
23pub 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
34/// Prometheus exporter protocol handler.
35pub 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        // Specific on purpose: "metrics" alone collides with every monitoring-flavoured prompt.
72        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            // Beta on evidence: tests/server/prometheus/real_client_test.rs drives the
88            // Prometheus project's own promtool (parse + lint, with a negative control) and a
89            // real prometheus scraping in both negotiated formats and answering PromQL, and
90            // both binaries hard-fail when absent. Not Stable: one project's tools are the only
91            // peers (promtool and prometheus share the same parser code), there is no fuzz
92            // target and no pcap-oracle test, and protobuf/native histograms are absent.
93            .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            // LLM failure: 503 + Retry-After when the backend is saturated, 500 otherwise, each
128            // with a fixed text/plain category. Prometheus records the status as the scrape
129            // error and never reads the body as metrics.
130            .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                // Validate here, so a bad name is an action failure the log and the access log
222                // record against the model's answer, not a surprise at render time.
223                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}