Skip to main content

sentinel_core/ingest/
pg_stat.rs

1//! Ingestion for `PostgreSQL` `pg_stat_statements` data.
2//!
3//! Parses CSV or JSON exports of `pg_stat_statements` into a `PgStatReport`
4//! with top-N rankings by total execution time, call count, and mean execution time.
5//!
6//! Unlike trace-based ingestion, `pg_stat_statements` has no `trace_id`, it provides
7//! a complementary view of SQL hotspots at the database level.
8
9use crate::detect::Finding;
10use crate::normalize::sql::normalize_sql;
11use serde::{Deserialize, Serialize};
12
13/// A single entry from `pg_stat_statements`.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct PgStatEntry {
16    /// Original query text (as normalized by `PostgreSQL`).
17    pub query: String,
18    /// Template after `perf-sentinel` SQL normalization.
19    pub normalized_template: String,
20    /// Number of times the query was executed.
21    pub calls: u64,
22    /// Total execution time in milliseconds.
23    pub total_exec_time_ms: f64,
24    /// Mean execution time in milliseconds.
25    pub mean_exec_time_ms: f64,
26    /// Total rows returned or affected.
27    pub rows: u64,
28    /// Number of shared buffer hits.
29    pub shared_blks_hit: u64,
30    /// Number of shared buffer reads (cache misses).
31    pub shared_blks_read: u64,
32    /// Whether this template was also seen in trace-based findings.
33    #[serde(default)]
34    pub seen_in_traces: bool,
35}
36
37/// A ranking of `pg_stat_statements` entries by a specific criterion.
38#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct PgStatRanking {
40    /// Label describing the ranking criterion (e.g., "top by `total_exec_time`").
41    pub label: String,
42    /// Entries sorted by the criterion, limited to `top_n`.
43    pub entries: Vec<PgStatEntry>,
44}
45
46/// Report produced from `pg_stat_statements` analysis.
47#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct PgStatReport {
49    /// Total number of entries parsed.
50    pub total_entries: usize,
51    /// Number of top entries per ranking.
52    pub top_n: usize,
53    /// Rankings in a stable order: by `total_exec_time`, by `calls`,
54    /// by `mean_exec_time`, by `shared_blks_total` (cache hits + reads).
55    /// Consumers that index by position (e.g., the HTML dashboard's
56    /// `pg_stat` sub-switcher) rely on this ordering not changing. New
57    /// rankings are appended, existing indices are never reassigned.
58    pub rankings: Vec<PgStatRanking>,
59}
60
61/// Detected input format.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PgStatFormat {
64    Csv,
65    Json,
66}
67
68/// Errors that can occur during `pg_stat_statements` parsing.
69///
70/// `#[non_exhaustive]` for SemVer-minor variant additions.
71#[derive(Debug, thiserror::Error)]
72#[non_exhaustive]
73pub enum PgStatError {
74    #[error("payload too large: {size} bytes exceeds maximum of {max} bytes")]
75    PayloadTooLarge { size: usize, max: usize },
76    #[error("CSV parse error at line {line}: {detail}")]
77    CsvParse { line: usize, detail: String },
78    #[error("JSON parse error: {0}")]
79    JsonParse(String),
80    #[error("missing required column: {0}")]
81    MissingColumn(String),
82    #[error("empty input")]
83    EmptyInput,
84    #[cfg(any(feature = "daemon", feature = "tempo"))]
85    #[error("Prometheus request failed: {0}")]
86    PrometheusRequest(String),
87    #[cfg(any(feature = "daemon", feature = "tempo"))]
88    #[error("Prometheus response parse error: {0}")]
89    PrometheusFormat(String),
90}
91
92/// Raw JSON entry matching common `pg_stat_statements` export formats.
93#[derive(Deserialize)]
94struct RawJsonEntry {
95    query: String,
96    calls: u64,
97    #[serde(alias = "total_exec_time")]
98    total_exec_time_ms: f64,
99    #[serde(alias = "mean_exec_time")]
100    mean_exec_time_ms: f64,
101    #[serde(default)]
102    rows: u64,
103    #[serde(default)]
104    shared_blks_hit: u64,
105    #[serde(default)]
106    shared_blks_read: u64,
107}
108
109/// Detect whether the input is CSV or JSON.
110///
111/// Peeks at the first non-whitespace byte: `[` or `{` indicates JSON,
112/// otherwise CSV. Returns `Csv` as fallback for empty input; the caller
113/// should validate non-emptiness separately.
114#[must_use]
115pub fn detect_pg_stat_format(raw: &[u8]) -> PgStatFormat {
116    let trimmed = raw.iter().position(|&b| !b.is_ascii_whitespace());
117    match trimmed.map(|i| raw[i]) {
118        Some(b'[' | b'{') => PgStatFormat::Json,
119        _ => PgStatFormat::Csv,
120    }
121}
122
123/// Parse `pg_stat_statements` data from raw bytes.
124///
125/// Auto-detects CSV vs JSON format. Normalizes each query through
126/// the SQL normalizer for consistency with trace-based analysis.
127///
128/// # Errors
129///
130/// Returns an error if the payload exceeds `max_size`, the input is empty,
131/// or parsing fails.
132pub fn parse_pg_stat(raw: &[u8], max_size: usize) -> Result<Vec<PgStatEntry>, PgStatError> {
133    if raw.len() > max_size {
134        return Err(PgStatError::PayloadTooLarge {
135            size: raw.len(),
136            max: max_size,
137        });
138    }
139    if raw.is_empty() || raw.iter().all(|&b| b.is_ascii_whitespace()) {
140        return Err(PgStatError::EmptyInput);
141    }
142
143    let text = std::str::from_utf8(raw).map_err(|e| PgStatError::CsvParse {
144        line: 0,
145        detail: format!("invalid UTF-8: {e}"),
146    })?;
147
148    match detect_pg_stat_format(raw) {
149        PgStatFormat::Csv => parse_csv(text),
150        PgStatFormat::Json => parse_json(text),
151    }
152}
153
154/// Generate rankings from parsed entries.
155///
156/// Produces four rankings in a stable order: by total execution time,
157/// by call count, by mean execution time, by total shared buffer
158/// blocks touched (`shared_blks_read + shared_blks_hit`). Each ranking
159/// contains at most `top_n` entries.
160///
161/// Uses index-based sorting so the full `entries` slice is never
162/// cloned during the sort. Each ranking still clones its own `top_n`
163/// entries because `PgStatRanking.entries: Vec<PgStatEntry>` is owned
164/// data on the public Serialize surface. Four rankings times `top_n`
165/// (defaults to 100) gives about 400 small-struct clones per call,
166/// which is acceptable because `pg_stat` ingestion is one-shot (CLI
167/// batch or daemon-load path), never on the per-event hot path.
168/// If `top_n` grows past a few thousand or the call is moved into a
169/// hot path, switch to an `Arc<PgStatEntry>` refcount shared across
170/// rankings to reclaim the duplicate allocations.
171///
172/// Downstream consumers (the HTML dashboard's `pg_stat` sub-switcher
173/// in particular) rely on the rankings appearing at the documented
174/// positions, new rankings are always appended and existing indices
175/// never reassign.
176#[must_use]
177pub fn rank_pg_stat(entries: &[PgStatEntry], top_n: usize) -> PgStatReport {
178    let total_entries = entries.len();
179
180    let top_n_by =
181        |cmp: fn(&PgStatEntry, &PgStatEntry) -> std::cmp::Ordering, label: &str| -> PgStatRanking {
182            let mut indices: Vec<usize> = (0..entries.len()).collect();
183            indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b]));
184            indices.truncate(top_n);
185            PgStatRanking {
186                label: label.to_string(),
187                entries: indices.iter().map(|&i| entries[i].clone()).collect(),
188            }
189        };
190
191    let by_total_time = top_n_by(
192        |a, b| {
193            b.total_exec_time_ms
194                .partial_cmp(&a.total_exec_time_ms)
195                .unwrap_or(std::cmp::Ordering::Equal)
196        },
197        "top by total_exec_time",
198    );
199
200    let by_calls = top_n_by(|a, b| b.calls.cmp(&a.calls), "top by calls");
201
202    let by_mean_time = top_n_by(
203        |a, b| {
204            b.mean_exec_time_ms
205                .partial_cmp(&a.mean_exec_time_ms)
206                .unwrap_or(std::cmp::Ordering::Equal)
207        },
208        "top by mean_exec_time",
209    );
210
211    // Total shared buffer blocks touched = hits + reads. Highest first
212    // identifies queries that move the most data through the cache
213    // regardless of whether they hit or miss, which correlates with
214    // memory pressure better than raw call count.
215    let by_io_blocks = top_n_by(
216        |a, b| {
217            let bt = b.shared_blks_read.saturating_add(b.shared_blks_hit);
218            let at = a.shared_blks_read.saturating_add(a.shared_blks_hit);
219            bt.cmp(&at)
220        },
221        "top by shared_blks_total",
222    );
223
224    PgStatReport {
225        total_entries,
226        top_n,
227        rankings: vec![by_total_time, by_calls, by_mean_time, by_io_blocks],
228    }
229}
230
231/// Cross-reference `pg_stat_statements` entries with trace-based findings.
232///
233/// Marks entries whose `normalized_template` matches any finding's pattern template.
234pub fn cross_reference(entries: &mut [PgStatEntry], findings: &[Finding]) {
235    let templates: std::collections::HashSet<&str> = findings
236        .iter()
237        .map(|f| f.pattern.template.as_str())
238        .collect();
239
240    for entry in entries {
241        if templates.contains(entry.normalized_template.as_str()) {
242            entry.seen_in_traces = true;
243        }
244    }
245}
246
247// ---------------------------------------------------------------------------
248// CSV parsing (RFC 4180 subset)
249// ---------------------------------------------------------------------------
250
251const MAX_CSV_ROWS: usize = 1_000_000;
252
253fn parse_csv(text: &str) -> Result<Vec<PgStatEntry>, PgStatError> {
254    let mut lines = text.lines();
255
256    let header_line = lines.next().ok_or(PgStatError::EmptyInput)?;
257    let headers = parse_csv_row(header_line);
258    let col = |name: &str| -> Result<usize, PgStatError> {
259        headers
260            .iter()
261            .position(|h| h.eq_ignore_ascii_case(name))
262            .ok_or_else(|| PgStatError::MissingColumn(name.to_string()))
263    };
264
265    let query_idx = col("query")?;
266    let calls_idx = col("calls")?;
267    let total_time_idx = col("total_exec_time")?;
268    let mean_time_idx = col("mean_exec_time")?;
269    let rows_idx = col("rows").ok();
270    let hit_idx = col("shared_blks_hit").ok();
271    let read_idx = col("shared_blks_read").ok();
272
273    // Estimate row count from byte length (~100 bytes per row), capped at 100k entries
274    let estimated = (text.len() / 100).min(100_000);
275    let mut entries = Vec::with_capacity(estimated);
276    for (line_num, line) in lines.enumerate() {
277        if entries.len() >= MAX_CSV_ROWS {
278            return Err(PgStatError::CsvParse {
279                line: line_num + 2,
280                detail: format!("CSV exceeds maximum of {MAX_CSV_ROWS} rows"),
281            });
282        }
283        let line = line.trim();
284        if line.is_empty() {
285            continue;
286        }
287        let fields = parse_csv_row(line);
288        let line_num = line_num + 2; // 1-indexed, header is line 1
289
290        let query = fields.get(query_idx).cloned().unwrap_or_default();
291        let calls = parse_u64(&fields, calls_idx, line_num, "calls")?;
292        let total_exec_time_ms = parse_f64(&fields, total_time_idx, line_num, "total_exec_time")?;
293        let mean_exec_time_ms = parse_f64(&fields, mean_time_idx, line_num, "mean_exec_time")?;
294        let rows = rows_idx.map_or(Ok(0), |i| parse_u64(&fields, i, line_num, "rows"))?;
295        let shared_blks_hit = hit_idx.map_or(Ok(0), |i| {
296            parse_u64(&fields, i, line_num, "shared_blks_hit")
297        })?;
298        let shared_blks_read = read_idx.map_or(Ok(0), |i| {
299            parse_u64(&fields, i, line_num, "shared_blks_read")
300        })?;
301
302        let normalized = normalize_sql(&query);
303
304        entries.push(PgStatEntry {
305            query,
306            normalized_template: normalized.template,
307            calls,
308            total_exec_time_ms,
309            mean_exec_time_ms,
310            rows,
311            shared_blks_hit,
312            shared_blks_read,
313            seen_in_traces: false,
314        });
315    }
316
317    if entries.is_empty() {
318        return Err(PgStatError::EmptyInput);
319    }
320    Ok(entries)
321}
322
323/// Parse a single CSV row, handling double-quoted fields.
324///
325/// Iterates over chars (not bytes) to correctly handle multi-byte UTF-8 content
326/// in query strings. Only ASCII delimiters (`"` and `,`) receive special treatment.
327/// `pub(crate)`: shared with the `mysql_stat` CSV parser.
328pub(crate) fn parse_csv_row(line: &str) -> Vec<String> {
329    let mut fields = Vec::with_capacity(8);
330    let mut current = String::new();
331    let mut in_quotes = false;
332    let mut chars = line.chars().peekable();
333
334    while let Some(c) = chars.next() {
335        if in_quotes {
336            if c == '"' {
337                if chars.peek() == Some(&'"') {
338                    // Escaped quote
339                    current.push('"');
340                    chars.next();
341                } else {
342                    // End of quoted field
343                    in_quotes = false;
344                }
345            } else {
346                current.push(c);
347            }
348        } else if c == '"' {
349            in_quotes = true;
350        } else if c == ',' {
351            fields.push(std::mem::take(&mut current));
352        } else {
353            current.push(c);
354        }
355    }
356    fields.push(current);
357    fields
358}
359
360fn parse_u64(
361    fields: &[String],
362    idx: usize,
363    line: usize,
364    col_name: &str,
365) -> Result<u64, PgStatError> {
366    let val = fields.get(idx).map_or("", String::as_str).trim();
367    val.parse::<u64>().map_err(|_| PgStatError::CsvParse {
368        line,
369        detail: format!("cannot parse '{val}' as integer for column {col_name}"),
370    })
371}
372
373fn parse_f64(
374    fields: &[String],
375    idx: usize,
376    line: usize,
377    col_name: &str,
378) -> Result<f64, PgStatError> {
379    let val = fields.get(idx).map_or("", String::as_str).trim();
380    val.parse::<f64>().map_err(|_| PgStatError::CsvParse {
381        line,
382        detail: format!("cannot parse '{val}' as float for column {col_name}"),
383    })
384}
385
386// ---------------------------------------------------------------------------
387// JSON parsing
388// ---------------------------------------------------------------------------
389
390fn parse_json(text: &str) -> Result<Vec<PgStatEntry>, PgStatError> {
391    let raw_entries: Vec<RawJsonEntry> =
392        serde_json::from_str(text).map_err(|e| PgStatError::JsonParse(e.to_string()))?;
393
394    if raw_entries.is_empty() {
395        return Err(PgStatError::EmptyInput);
396    }
397    if raw_entries.len() > MAX_CSV_ROWS {
398        return Err(PgStatError::JsonParse(format!(
399            "JSON array exceeds maximum of {MAX_CSV_ROWS} entries (got {})",
400            raw_entries.len()
401        )));
402    }
403
404    let entries = raw_entries
405        .into_iter()
406        .map(|raw| {
407            let normalized = normalize_sql(&raw.query);
408            PgStatEntry {
409                query: raw.query,
410                normalized_template: normalized.template,
411                calls: raw.calls,
412                total_exec_time_ms: raw.total_exec_time_ms,
413                mean_exec_time_ms: raw.mean_exec_time_ms,
414                rows: raw.rows,
415                shared_blks_hit: raw.shared_blks_hit,
416                shared_blks_read: raw.shared_blks_read,
417                seen_in_traces: false,
418            }
419        })
420        .collect();
421
422    Ok(entries)
423}
424
425// ── Prometheus scrape path ─────────────────────────────────────────
426
427/// Fetch `pg_stat_statements` data from a Prometheus endpoint.
428///
429/// Queries the Prometheus HTTP API for `pg_stat_statements_seconds_total`
430/// metrics exposed by `postgres_exporter`, converts them to
431/// [`PgStatEntry`] structs, and normalizes SQL templates.
432///
433/// When `auth_header` is `Some`, the `"Name: Value"` string is parsed
434/// once via [`crate::ingest::auth_header::AuthHeader::parse`] and the
435/// resulting header is attached to the outbound request. Required for
436/// Grafana Cloud, Grafana Mimir and any Prometheus ingress enforcing
437/// bearer/basic auth.
438///
439/// # Errors
440///
441/// Returns [`PgStatError::PrometheusRequest`] on transport errors,
442/// invalid auth headers, or auth-over-cleartext warnings, and
443/// [`PgStatError::PrometheusFormat`] if the response cannot be parsed.
444#[cfg(any(feature = "daemon", feature = "tempo"))]
445pub async fn fetch_from_prometheus(
446    endpoint: &str,
447    top_n: usize,
448    auth_header: Option<&str>,
449) -> Result<Vec<PgStatEntry>, PgStatError> {
450    use crate::ingest::auth_header::AuthHeader;
451
452    // Validate the endpoint URL before issuing the request. Consistent with
453    // the Scaphandre and cloud energy scrapers, we reject malformed URLs,
454    // non-http(s) schemes, and credentials in the authority.
455    validate_prometheus_endpoint(endpoint)?;
456
457    // Parse the optional auth header once. Reuse the existing
458    // PrometheusRequest variant for the error path, same shape as the
459    // URL parse failure above.
460    let parsed_auth = auth_header
461        .map(AuthHeader::parse)
462        .transpose()
463        .map_err(|msg| PgStatError::PrometheusRequest(format!("invalid auth header: {msg}")))?;
464    if parsed_auth.is_some() && endpoint.starts_with("http://") {
465        tracing::warn!(
466            "Sending auth header over cleartext HTTP, prefer https:// to avoid credential leak"
467        );
468    }
469
470    let client = crate::http_client::build_client();
471    // PromQL query. The parentheses and underscores are safe for URL
472    // query strings, so we only need to encode the comma.
473    let query = format!("topk({top_n}%2C%20pg_stat_statements_seconds_total)");
474    let url = format!("{endpoint}/api/v1/query?query={query}");
475    let uri: crate::http_client::Uri = url
476        .parse()
477        .map_err(|e| PgStatError::PrometheusRequest(format!("invalid URL: {e}")))?;
478
479    let timeout = std::time::Duration::from_secs(30);
480    let body = crate::http_client::fetch_get(
481        &client,
482        &uri,
483        "perf-sentinel/pg-stat",
484        timeout,
485        parsed_auth.as_ref(),
486    )
487    .await
488    .map_err(|e| {
489        // Redact the endpoint before surfacing the transport error, so
490        // credentials accidentally embedded in the URL never leak to
491        // stdout/stderr.
492        PgStatError::PrometheusRequest(format!(
493            "{e} (endpoint: {})",
494            crate::http_client::redact_endpoint(&uri)
495        ))
496    })?;
497
498    parse_prometheus_response(&body)
499}
500
501/// Validate a user-supplied Prometheus endpoint string.
502///
503/// Rejects URLs that:
504/// - fail to parse as a hyper `Uri`
505/// - have a scheme other than `http` or `https`
506/// - carry userinfo (credentials in the authority, e.g. `user:pass@host`)
507///   since credentials must flow via env vars or a `.pgpass`-style file
508#[cfg(any(feature = "daemon", feature = "tempo"))]
509fn validate_prometheus_endpoint(endpoint: &str) -> Result<(), PgStatError> {
510    if endpoint.bytes().any(|b| b < 0x20 || b == 0x7f) {
511        return Err(PgStatError::PrometheusRequest(
512            "endpoint must not contain ASCII control characters".to_string(),
513        ));
514    }
515    let uri: crate::http_client::Uri = endpoint
516        .parse()
517        .map_err(|e| PgStatError::PrometheusRequest(format!("invalid endpoint URL: {e}")))?;
518
519    match uri.scheme_str() {
520        Some("http" | "https") => {}
521        Some(other) => {
522            return Err(PgStatError::PrometheusRequest(format!(
523                "unsupported scheme `{other}`, only http and https are accepted"
524            )));
525        }
526        None => {
527            return Err(PgStatError::PrometheusRequest(
528                "endpoint URL must include a scheme (http:// or https://)".to_string(),
529            ));
530        }
531    }
532
533    // Check for userinfo. `hyper::Uri::authority()` returns the full
534    // `[user[:pass]@]host[:port]` string; if it contains `@`, credentials
535    // are embedded.
536    if let Some(authority) = uri.authority()
537        && authority.as_str().contains('@')
538    {
539        return Err(PgStatError::PrometheusRequest(
540            "credentials in the URL are not accepted; use env vars instead".to_string(),
541        ));
542    }
543
544    Ok(())
545}
546
547/// Parse a Prometheus instant query response into `PgStatEntry` structs.
548#[cfg(any(feature = "daemon", feature = "tempo"))]
549fn parse_prometheus_response(body: &[u8]) -> Result<Vec<PgStatEntry>, PgStatError> {
550    let json: serde_json::Value = serde_json::from_slice(body)
551        .map_err(|e| PgStatError::PrometheusFormat(format!("invalid JSON: {e}")))?;
552
553    let results = json
554        .get("data")
555        .and_then(|d| d.get("result"))
556        .and_then(|r| r.as_array())
557        .ok_or_else(|| PgStatError::PrometheusFormat("missing data.result array".to_string()))?;
558
559    let mut entries = Vec::with_capacity(results.len());
560    for result in results {
561        let metric = result.get("metric").unwrap_or(&serde_json::Value::Null);
562        let query_text = metric
563            .get("query")
564            .or_else(|| metric.get("queryid"))
565            .and_then(|v| v.as_str())
566            .unwrap_or("unknown")
567            .to_string();
568
569        // value is [timestamp, "string_value"]
570        let value = result
571            .get("value")
572            .and_then(|v| v.as_array())
573            .and_then(|arr| arr.get(1))
574            .and_then(|v| v.as_str())
575            .and_then(|s| s.parse::<f64>().ok())
576            .unwrap_or(0.0);
577
578        let total_exec_time_ms = value * 1000.0; // seconds to ms
579
580        // Prometheus label values are always strings. `.as_str() + parse` is
581        // the correct path; the previous `.as_u64().map(|_| "")` branch was
582        // dead code that silently produced 0 for non-string values.
583        let calls = metric
584            .get("calls")
585            .and_then(serde_json::Value::as_str)
586            .and_then(|s| s.parse::<u64>().ok())
587            .unwrap_or(0);
588
589        #[allow(clippy::cast_precision_loss)]
590        let mean_exec_time_ms = if calls > 0 {
591            total_exec_time_ms / (calls as f64)
592        } else {
593            total_exec_time_ms
594        };
595
596        let normalized = normalize_sql(&query_text);
597
598        entries.push(PgStatEntry {
599            query: query_text,
600            normalized_template: normalized.template,
601            calls,
602            total_exec_time_ms,
603            mean_exec_time_ms,
604            rows: 0,
605            shared_blks_hit: 0,
606            shared_blks_read: 0,
607            seen_in_traces: false,
608        });
609    }
610
611    Ok(entries)
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use core::assert_matches;
618
619    fn sample_csv() -> &'static str {
620        "query,calls,total_exec_time,mean_exec_time,rows,shared_blks_hit,shared_blks_read\n\
621         SELECT * FROM order_item WHERE order_id = 42,1500,4500.50,3.000,1500,12000,150\n\
622         \"SELECT * FROM orders WHERE id = 1 AND status = 'active'\",800,2400.00,3.000,800,6400,80\n\
623         INSERT INTO audit_log VALUES (1),200,600.00,3.000,200,0,200\n\
624         SELECT count(*) FROM order_item,50,250.00,5.000,50,500,10"
625    }
626
627    fn sample_json() -> &'static str {
628        r#"[
629            {
630                "query": "SELECT * FROM order_item WHERE order_id = 42",
631                "calls": 1500,
632                "total_exec_time_ms": 4500.50,
633                "mean_exec_time_ms": 3.0,
634                "rows": 1500,
635                "shared_blks_hit": 12000,
636                "shared_blks_read": 150
637            },
638            {
639                "query": "SELECT * FROM orders WHERE id = 1 AND status = 'active'",
640                "calls": 800,
641                "total_exec_time_ms": 2400.0,
642                "mean_exec_time_ms": 3.0,
643                "rows": 800,
644                "shared_blks_hit": 6400,
645                "shared_blks_read": 80
646            },
647            {
648                "query": "INSERT INTO audit_log VALUES (1)",
649                "calls": 200,
650                "total_exec_time_ms": 600.0,
651                "mean_exec_time_ms": 3.0,
652                "rows": 200,
653                "shared_blks_hit": 0,
654                "shared_blks_read": 200
655            },
656            {
657                "query": "SELECT count(*) FROM order_item",
658                "calls": 50,
659                "total_exec_time_ms": 250.0,
660                "mean_exec_time_ms": 5.0,
661                "rows": 50,
662                "shared_blks_hit": 500,
663                "shared_blks_read": 10
664            }
665        ]"#
666    }
667
668    // -- Format detection --
669
670    #[test]
671    fn detect_format_csv() {
672        assert_eq!(
673            detect_pg_stat_format(b"query,calls,total_exec_time"),
674            PgStatFormat::Csv
675        );
676    }
677
678    #[test]
679    fn detect_format_json_array() {
680        assert_eq!(
681            detect_pg_stat_format(b"[{\"query\": \"SELECT 1\"}]"),
682            PgStatFormat::Json
683        );
684    }
685
686    #[test]
687    fn detect_format_json_with_whitespace() {
688        assert_eq!(
689            detect_pg_stat_format(b"  \n  [{\"query\": \"SELECT 1\"}]"),
690            PgStatFormat::Json
691        );
692    }
693
694    #[test]
695    fn detect_format_empty_defaults_csv() {
696        assert_eq!(detect_pg_stat_format(b""), PgStatFormat::Csv);
697    }
698
699    // -- CSV parsing --
700
701    #[test]
702    fn parse_csv_basic() {
703        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
704        assert_eq!(entries.len(), 4);
705        assert_eq!(entries[0].calls, 1500);
706        assert!((entries[0].total_exec_time_ms - 4500.50).abs() < f64::EPSILON);
707        assert_eq!(entries[0].rows, 1500);
708        assert_eq!(entries[0].shared_blks_hit, 12000);
709    }
710
711    #[test]
712    fn parse_csv_quoted_field() {
713        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
714        // Second entry has a quoted query with comma-free content but single quotes
715        assert!(entries[1].query.contains("status = 'active'"));
716    }
717
718    #[test]
719    fn parse_csv_normalization_applied() {
720        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
721        // order_id = 42 -> order_id = ?
722        assert_eq!(
723            entries[0].normalized_template,
724            "SELECT * FROM order_item WHERE order_id = ?"
725        );
726    }
727
728    #[test]
729    fn parse_csv_empty_input() {
730        let result = parse_pg_stat(b"", 1_048_576);
731        assert_matches!(result, Err(PgStatError::EmptyInput));
732    }
733
734    #[test]
735    fn parse_csv_whitespace_only() {
736        let result = parse_pg_stat(b"  \n  \n  ", 1_048_576);
737        assert_matches!(result, Err(PgStatError::EmptyInput));
738    }
739
740    #[test]
741    fn parse_csv_header_only() {
742        let result = parse_pg_stat(b"query,calls,total_exec_time,mean_exec_time\n", 1_048_576);
743        assert_matches!(result, Err(PgStatError::EmptyInput));
744    }
745
746    #[test]
747    fn parse_csv_missing_column() {
748        let result = parse_pg_stat(b"query,calls\nSELECT 1,100", 1_048_576);
749        assert_matches!(result, Err(PgStatError::MissingColumn(_)));
750    }
751
752    #[test]
753    fn parse_csv_oversized_payload() {
754        let result = parse_pg_stat(sample_csv().as_bytes(), 10);
755        assert_matches!(result, Err(PgStatError::PayloadTooLarge { .. }));
756    }
757
758    #[test]
759    fn parse_csv_escaped_quotes() {
760        let csv = "query,calls,total_exec_time,mean_exec_time\n\
761                   \"SELECT * FROM t WHERE name = \"\"O'Brien\"\"\",100,500.0,5.0";
762        let entries = parse_pg_stat(csv.as_bytes(), 1_048_576).unwrap();
763        assert!(entries[0].query.contains("O'Brien"));
764    }
765
766    // -- JSON parsing --
767
768    #[test]
769    fn parse_json_basic() {
770        let entries = parse_pg_stat(sample_json().as_bytes(), 1_048_576).unwrap();
771        assert_eq!(entries.len(), 4);
772        assert_eq!(entries[0].calls, 1500);
773    }
774
775    #[test]
776    fn parse_json_normalization_applied() {
777        let entries = parse_pg_stat(sample_json().as_bytes(), 1_048_576).unwrap();
778        assert_eq!(
779            entries[0].normalized_template,
780            "SELECT * FROM order_item WHERE order_id = ?"
781        );
782    }
783
784    #[test]
785    fn parse_json_empty_array() {
786        let result = parse_pg_stat(b"[]", 1_048_576);
787        assert_matches!(result, Err(PgStatError::EmptyInput));
788    }
789
790    #[test]
791    fn parse_json_invalid() {
792        let result = parse_pg_stat(b"[{invalid json}]", 1_048_576);
793        assert_matches!(result, Err(PgStatError::JsonParse(_)));
794    }
795
796    #[test]
797    fn parse_json_field_alias() {
798        // pg_stat_statements uses total_exec_time without _ms suffix
799        let json = r#"[{
800            "query": "SELECT 1",
801            "calls": 10,
802            "total_exec_time": 100.0,
803            "mean_exec_time": 10.0,
804            "rows": 10
805        }]"#;
806        let entries = parse_pg_stat(json.as_bytes(), 1_048_576).unwrap();
807        assert!((entries[0].total_exec_time_ms - 100.0).abs() < f64::EPSILON);
808    }
809
810    // -- Ranking --
811
812    #[test]
813    fn rank_by_total_time() {
814        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
815        let report = rank_pg_stat(&entries, 2);
816        assert_eq!(report.total_entries, 4);
817        assert_eq!(report.top_n, 2);
818        let by_time = &report.rankings[0];
819        assert_eq!(by_time.label, "top by total_exec_time");
820        assert_eq!(by_time.entries.len(), 2);
821        // First should be highest total_exec_time
822        assert!(by_time.entries[0].total_exec_time_ms >= by_time.entries[1].total_exec_time_ms);
823    }
824
825    #[test]
826    fn rank_by_calls() {
827        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
828        let report = rank_pg_stat(&entries, 10);
829        let by_calls = &report.rankings[1];
830        assert_eq!(by_calls.label, "top by calls");
831        assert!(by_calls.entries[0].calls >= by_calls.entries[1].calls);
832    }
833
834    #[test]
835    fn rank_by_mean_time() {
836        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
837        let report = rank_pg_stat(&entries, 10);
838        let by_mean = &report.rankings[2];
839        assert_eq!(by_mean.label, "top by mean_exec_time");
840        assert!(by_mean.entries[0].mean_exec_time_ms >= by_mean.entries[1].mean_exec_time_ms);
841    }
842
843    #[test]
844    fn rank_top_n_limits_output() {
845        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
846        let report = rank_pg_stat(&entries, 1);
847        for ranking in &report.rankings {
848            assert_eq!(ranking.entries.len(), 1);
849        }
850    }
851
852    #[test]
853    fn rank_pg_stat_emits_four_rankings_in_stable_order() {
854        let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
855        let report = rank_pg_stat(&entries, 10);
856        assert_eq!(report.rankings.len(), 4, "exactly 4 rankings expected");
857        assert_eq!(report.rankings[0].label, "top by total_exec_time");
858        assert_eq!(report.rankings[1].label, "top by calls");
859        assert_eq!(report.rankings[2].label, "top by mean_exec_time");
860        assert_eq!(report.rankings[3].label, "top by shared_blks_total");
861
862        // by_io_blocks ranking: first entry has the highest hits+reads
863        // sum among all parsed entries.
864        let by_io = &report.rankings[3];
865        let expected_top_sum = entries
866            .iter()
867            .map(|e| e.shared_blks_read.saturating_add(e.shared_blks_hit))
868            .max()
869            .unwrap();
870        let actual_top_sum = by_io.entries[0]
871            .shared_blks_read
872            .saturating_add(by_io.entries[0].shared_blks_hit);
873        assert_eq!(
874            actual_top_sum, expected_top_sum,
875            "by_io_blocks top must be the entry with max hits+reads"
876        );
877    }
878
879    #[test]
880    fn rank_empty_entries() {
881        let report = rank_pg_stat(&[], 10);
882        assert_eq!(report.total_entries, 0);
883        for ranking in &report.rankings {
884            assert!(ranking.entries.is_empty());
885        }
886    }
887
888    // -- Cross-reference --
889
890    use crate::detect::test_finding_with_template as make_finding;
891
892    #[test]
893    fn cross_reference_marks_matching_templates() {
894        let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
895        let findings = vec![make_finding("SELECT * FROM order_item WHERE order_id = ?")];
896        cross_reference(&mut entries, &findings);
897        assert!(entries[0].seen_in_traces);
898        assert!(!entries[1].seen_in_traces);
899    }
900
901    #[test]
902    fn cross_reference_no_matches() {
903        let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
904        let findings = vec![make_finding("SELECT * FROM nonexistent WHERE id = ?")];
905        cross_reference(&mut entries, &findings);
906        assert!(entries.iter().all(|e| !e.seen_in_traces));
907    }
908
909    #[test]
910    fn cross_reference_empty_findings() {
911        let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
912        cross_reference(&mut entries, &[]);
913        assert!(entries.iter().all(|e| !e.seen_in_traces));
914    }
915
916    // -- CSV row parsing edge cases --
917
918    #[test]
919    fn csv_row_with_embedded_comma() {
920        let row = r#""SELECT a, b FROM t",100,500.0,5.0"#;
921        let fields = parse_csv_row(row);
922        assert_eq!(fields[0], "SELECT a, b FROM t");
923        assert_eq!(fields[1], "100");
924    }
925
926    #[test]
927    fn csv_row_simple() {
928        let row = "a,b,c,d";
929        let fields = parse_csv_row(row);
930        assert_eq!(fields, vec!["a", "b", "c", "d"]);
931    }
932
933    #[test]
934    fn csv_row_with_utf8_content() {
935        let row = "\"SELECT * FROM café WHERE naïve = 'résumé'\",100,500.0,5.0";
936        let fields = parse_csv_row(row);
937        assert_eq!(fields[0], "SELECT * FROM café WHERE naïve = 'résumé'");
938    }
939
940    #[test]
941    fn parse_invalid_utf8_returns_error() {
942        let data: &[u8] = &[0xFF, 0xFE, 0x00, 0x01];
943        let result = parse_pg_stat(data, 1_048_576);
944        assert_matches!(result, Err(PgStatError::CsvParse { line: 0, .. }));
945    }
946
947    #[test]
948    fn parse_csv_invalid_number_returns_error() {
949        let csv = "query,calls,total_exec_time,mean_exec_time\nSELECT 1,abc,500.0,5.0";
950        let result = parse_pg_stat(csv.as_bytes(), 1_048_576);
951        assert_matches!(result, Err(PgStatError::CsvParse { line: 2, .. }));
952    }
953
954    // -- Prometheus response parsing --
955
956    #[cfg(any(feature = "daemon", feature = "tempo"))]
957    #[test]
958    fn parse_prometheus_response_basic() {
959        let json = br#"{
960            "status": "success",
961            "data": {
962                "resultType": "vector",
963                "result": [
964                    {
965                        "metric": {
966                            "__name__": "pg_stat_statements_seconds_total",
967                            "query": "SELECT * FROM orders WHERE id = $1"
968                        },
969                        "value": [1720000000, "4.5"]
970                    },
971                    {
972                        "metric": {
973                            "__name__": "pg_stat_statements_seconds_total",
974                            "query": "INSERT INTO audit_log VALUES ($1)"
975                        },
976                        "value": [1720000000, "1.2"]
977                    }
978                ]
979            }
980        }"#;
981
982        let entries = parse_prometheus_response(json).unwrap();
983        assert_eq!(entries.len(), 2);
984        assert!((entries[0].total_exec_time_ms - 4500.0).abs() < f64::EPSILON);
985        assert!((entries[1].total_exec_time_ms - 1200.0).abs() < f64::EPSILON);
986        // Templates should be normalized.
987        assert!(entries[0].normalized_template.contains('?'));
988    }
989
990    #[cfg(any(feature = "daemon", feature = "tempo"))]
991    #[test]
992    fn parse_prometheus_response_empty_result() {
993        let json = br#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
994        let entries = parse_prometheus_response(json).unwrap();
995        assert!(entries.is_empty());
996    }
997
998    #[cfg(any(feature = "daemon", feature = "tempo"))]
999    #[test]
1000    fn parse_prometheus_response_invalid_json() {
1001        let result = parse_prometheus_response(b"not json");
1002        assert_matches!(result, Err(PgStatError::PrometheusFormat(_)));
1003    }
1004
1005    // -- Prometheus endpoint URL validation --
1006
1007    #[cfg(any(feature = "daemon", feature = "tempo"))]
1008    #[test]
1009    fn validate_endpoint_accepts_http_and_https() {
1010        assert!(validate_prometheus_endpoint("http://prometheus:9090").is_ok());
1011        assert!(validate_prometheus_endpoint("https://prometheus.example.com").is_ok());
1012        assert!(validate_prometheus_endpoint("http://127.0.0.1:9090").is_ok());
1013    }
1014
1015    #[cfg(any(feature = "daemon", feature = "tempo"))]
1016    #[test]
1017    fn validate_endpoint_rejects_malformed_url() {
1018        let result = validate_prometheus_endpoint("not a url");
1019        assert_matches!(result, Err(PgStatError::PrometheusRequest(_)));
1020    }
1021
1022    #[cfg(any(feature = "daemon", feature = "tempo"))]
1023    #[test]
1024    fn validate_endpoint_rejects_userinfo() {
1025        let result = validate_prometheus_endpoint("http://user:pass@prometheus:9090");
1026        assert!(
1027            matches!(result, Err(PgStatError::PrometheusRequest(msg)) if msg.contains("credentials")),
1028            "must reject userinfo in URL"
1029        );
1030    }
1031
1032    #[cfg(any(feature = "daemon", feature = "tempo"))]
1033    #[test]
1034    fn validate_endpoint_rejects_non_http_scheme() {
1035        let result = validate_prometheus_endpoint("ftp://prometheus:9090");
1036        assert!(
1037            matches!(result, Err(PgStatError::PrometheusRequest(msg)) if msg.contains("scheme")),
1038            "must reject non-http(s) schemes"
1039        );
1040    }
1041
1042    #[cfg(any(feature = "daemon", feature = "tempo"))]
1043    #[tokio::test]
1044    async fn fetch_from_prometheus_sends_auth_header_on_wire() {
1045        let body = r#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
1046        let response = crate::test_helpers::http_200_text("application/json", body);
1047        let (endpoint, mut rx, server) = crate::test_helpers::spawn_capture_server(response).await;
1048
1049        let entries = fetch_from_prometheus(&endpoint, 5, Some("Authorization: Bearer topsecret"))
1050            .await
1051            .expect("fetch_from_prometheus must succeed");
1052        assert!(entries.is_empty());
1053
1054        let captured = rx.recv().await.expect("captured request");
1055        let text = std::str::from_utf8(&captured).expect("utf8");
1056        assert!(
1057            text.contains("authorization: Bearer topsecret")
1058                || text.contains("Authorization: Bearer topsecret"),
1059            "auth header missing from request, got:\n{text}"
1060        );
1061        server.await.expect("server join");
1062    }
1063
1064    #[cfg(any(feature = "daemon", feature = "tempo"))]
1065    #[tokio::test]
1066    async fn fetch_from_prometheus_rejects_invalid_auth_header() {
1067        let err = fetch_from_prometheus("http://prometheus.local:9090", 5, Some("NoColonHere"))
1068            .await
1069            .expect_err("malformed auth header must be rejected");
1070        match err {
1071            PgStatError::PrometheusRequest(msg) => {
1072                assert!(
1073                    msg.contains("invalid auth header"),
1074                    "error message should flag the auth header parse failure, got: {msg}"
1075                );
1076            }
1077            other => panic!("expected PrometheusRequest, got {other:?}"),
1078        }
1079    }
1080}