Skip to main content

llm_assisted_api_debugging_lab/
evidence.rs

1//! Evidence model and collectors.
2//!
3//! Evidence is the only input the rules engine consumes. Cases and log
4//! lines are normalized into [`Evidence`] items here; `diagnose()` never
5//! reads a [`Case`] or a raw log line directly. That separation is what
6//! keeps the rules engine a pure function over evidence.
7//!
8//! Two collection paths exist:
9//!
10//! - [`collect_evidence`] takes a `Case` plus the contents of its log
11//!   file and produces the union, with cross-source dedup (see
12//!   [`is_redundant_with`] below).
13//! - [`parse_log`] takes only a log string and is used by the
14//!   `diagnose-log` subcommand for ad-hoc analysis when no JSON case is
15//!   available. The log markers it recognizes are documented in
16//!   `docs/architecture.md`.
17
18use crate::cases::Case;
19use serde::Serialize;
20
21/// A single normalized signal extracted from a request/response, env
22/// context, or a log line.
23///
24/// Variants are intentionally narrow: each one corresponds to a fact a
25/// support engineer would write in an escalation note. Inference belongs
26/// in `diagnose()`, not here. If you find yourself wanting to add a
27/// variant whose name is a hypothesis ("PossibleAuthMisconfig"), it
28/// belongs in `prose.toml` as a hypothesis string for an existing rule,
29/// not as new evidence.
30///
31/// `Serialize` so the JSON-envelope prompt renderer can emit each variant
32/// directly. The `#[serde(tag = "kind")]` attribute means each variant
33/// serializes as `{"kind": "VariantName", ...fields}`, which gives every
34/// variant a stable JSON discriminator without writing a hand-rolled
35/// serializer.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(tag = "kind")]
38pub enum Evidence {
39    /// Final HTTP status code observed on the response. Absent for
40    /// connection-layer failures (DNS, TLS, timeout) where no response
41    /// was received.
42    HttpStatus(u16),
43    /// A request header that was present. The `value` is masked (e.g.
44    /// `"***"` for `Authorization`) — we don't surface secret material in
45    /// rendered output.
46    HeaderPresent { name: String, value: Option<String> },
47    /// A request header that the rule was looking for but did not find.
48    /// Currently produced only for `Authorization` (used by the
49    /// `auth_missing` rule).
50    HeaderMissing { name: String },
51    /// The request body was modified by middleware between transmission
52    /// and verification. The webhook signature rule cares about this
53    /// because re-encoding even idempotent JSON changes the byte stream
54    /// that HMAC was computed over.
55    BodyMutatedBeforeVerification,
56    /// HMAC signature verification failed. Sourced from log markers
57    /// `reason=signature_mismatch` or `signature verification failed`.
58    SignatureMismatch,
59    /// Clock skew between the signature timestamp and the server clock,
60    /// expressed in absolute seconds (`observed.abs()`). Only emitted
61    /// when the magnitude exceeds `tolerance_secs`. Sign is dropped to
62    /// keep dedup simple — `|skew| > tol` is what the verifier checks.
63    ClockDriftSecs { observed: i64, tolerance_secs: u64 },
64    /// Server-supplied `Retry-After` value in seconds, parsed from the
65    /// response header.
66    RetryAfterSecs(u64),
67    /// Observed request rate vs the account's documented per-second
68    /// limit, sourced from log markers like `burst above limit
69    /// observed_rps=X limit_rps=Y`.
70    RateLimitObserved { observed_rps: u32, limit_rps: u32 },
71    /// DNS resolution failed for the given host. Both fields are required
72    /// when this is emitted from a log line — see the parser comments
73    /// for why an abort line without `host=` is intentionally skipped.
74    DnsResolutionFailed { host: String, message: String },
75    /// TLS handshake to the given peer failed before any HTTP request was
76    /// sent. Same parser-strictness contract as `DnsResolutionFailed`:
77    /// the marker substring without a `peer=` token is descriptive
78    /// prose, not a fresh observation.
79    TlsHandshakeFailed { peer: String, reason: String },
80    /// The client aborted the request because the upstream did not
81    /// respond inside the budget. Both fields together prove the abort
82    /// was on the client side (elapsed >= timeout).
83    ConnectionTimeout { elapsed_ms: u64, timeout_ms: u64 },
84    /// Server-side schema validation rejected the request. `field` is the
85    /// failing field name when the server identified one (most common);
86    /// `message` is the validation error string.
87    JsonValidationError {
88        field: Option<String>,
89        message: String,
90    },
91}
92
93/// Collect evidence from a [`Case`] and the contents of its log file.
94///
95/// Deterministic source order is preserved: context first (environmental
96/// facts the caller observed), then response, then request, then
97/// log-derived items. This ordering matters for two reasons:
98///
99/// 1. **Snapshot stability.** The renderers walk the vec in order, so
100///    consistent input order produces consistent output for `cargo insta`.
101/// 2. **Dedup priority.** When two sources describe the same fact (e.g.
102///    a DNS error appearing in both `case.context.dns_error` and the
103///    log's `error="..."` field), the *first*-pushed item wins because
104///    [`is_redundant_with`] treats the candidate as redundant against
105///    `existing`. Pushing context first means the more authoritative
106///    caller-side error string is kept.
107pub fn collect_evidence(case: &Case, log_text: &str) -> Vec<Evidence> {
108    let mut out = Vec::new();
109
110    // ---- Context evidence (environmental, from the caller's vantage) ----
111
112    // DNS failure means the connection never opened. We accept either
113    // `dns_resolved: false` alone (with placeholder strings) or that plus
114    // the more specific `dns_host` and `dns_error` fields.
115    if matches!(case.context.dns_resolved, Some(false)) {
116        let host = case
117            .context
118            .dns_host
119            .clone()
120            .unwrap_or_else(|| "<unknown host>".into());
121        let message = case
122            .context
123            .dns_error
124            .clone()
125            .unwrap_or_else(|| "name resolution failed".into());
126        out.push(Evidence::DnsResolutionFailed { host, message });
127    }
128
129    if matches!(case.context.tls_handshake_failed, Some(true)) {
130        let peer = case
131            .context
132            .tls_peer
133            .clone()
134            .unwrap_or_else(|| "<unknown peer>".into());
135        let reason = case
136            .context
137            .tls_failure_reason
138            .clone()
139            .unwrap_or_else(|| "tls handshake failed".into());
140        out.push(Evidence::TlsHandshakeFailed { peer, reason });
141    }
142
143    // No response received AND elapsed time crossed the configured
144    // timeout: the client gave up before the server replied. Both halves
145    // of the conjunction matter — `response.is_none()` alone could mean
146    // DNS or TLS failed (handled above), and `elapsed >= timeout` alone
147    // would mis-fire on a slow-but-completed request.
148    if case.response.is_none() {
149        if let (Some(elapsed), Some(timeout)) = (
150            case.context.elapsed_ms_before_abort,
151            case.request.timeout_ms,
152        ) {
153            if elapsed >= timeout {
154                out.push(Evidence::ConnectionTimeout {
155                    elapsed_ms: elapsed,
156                    timeout_ms: timeout,
157                });
158            }
159        }
160    }
161
162    // Clock drift only matters if the case is signature-bearing (a
163    // tolerance is configured) AND the magnitude exceeds it. We store the
164    // absolute value so dedup against the log-derived item works
165    // structurally regardless of which side observed the skew.
166    if let (Some(skew), Some(tol)) = (
167        case.context.client_clock_skew_secs,
168        case.context.signature_tolerance_secs,
169    ) {
170        if skew.unsigned_abs() > tol {
171            out.push(Evidence::ClockDriftSecs {
172                observed: skew.abs(),
173                tolerance_secs: tol,
174            });
175        }
176    }
177
178    if matches!(case.context.body_mutated_before_verification, Some(true)) {
179        out.push(Evidence::BodyMutatedBeforeVerification);
180    }
181
182    // ---- Response evidence (only if a response was received) ----
183    if let Some(resp) = &case.response {
184        out.push(Evidence::HttpStatus(resp.status));
185
186        // Retry-After is only meaningful when present and parseable as
187        // unsigned seconds. The HTTP spec also allows an HTTP-date form;
188        // we don't currently parse that because none of the fixtures use
189        // it and a real on-call would notice if a server started sending
190        // dates instead of ints.
191        if let Some(retry_after) = resp.headers.get("Retry-After") {
192            if let Ok(n) = retry_after.parse::<u64>() {
193                out.push(Evidence::RetryAfterSecs(n));
194            }
195        }
196
197        // Server's structured validation response is parsed for the
198        // failing field name, which the bad_payload rule surfaces in its
199        // likely-cause text via `{field}` substitution.
200        if let Some(err) = parse_validation_error_body(&resp.body_summary) {
201            out.push(err);
202        }
203    }
204
205    // ---- Request evidence ----
206    //
207    // We currently care about exactly one header: Authorization. The
208    // present/missing distinction drives the auth_missing rule. If we
209    // ever start checking other headers (Idempotency-Key, X-Signature),
210    // factor this into a small loop.
211    let auth_header = "Authorization";
212    if case.request.headers.contains_key(auth_header) {
213        out.push(Evidence::HeaderPresent {
214            name: auth_header.into(),
215            value: Some("***".into()),
216        });
217    } else {
218        out.push(Evidence::HeaderMissing {
219            name: auth_header.into(),
220        });
221    }
222
223    // ---- Log-derived evidence ----
224    //
225    // Dedup is structural for most variants (full equality via
226    // `out.contains`); for `JsonValidationError`, `DnsResolutionFailed`,
227    // and `TlsHandshakeFailed`, the dedup keys on a stable identifier
228    // (field name / host / peer) because the body parser and log parser
229    // emit the same fact with slightly different message text. See
230    // [`is_redundant_with`].
231    for ev in parse_log(log_text) {
232        if is_redundant_with(&out, &ev) {
233            continue;
234        }
235        out.push(ev);
236    }
237
238    out
239}
240
241/// Decide whether `candidate` is already represented in `existing`.
242///
243/// For most variants this is plain structural equality (`Vec::contains`).
244/// Three variants need a richer notion of "same fact":
245///
246/// - `JsonValidationError` — body parser and log parser typically produce
247///   the same field with different error messages. Showing both would
248///   misleadingly suggest two independent errors.
249/// - `DnsResolutionFailed` — context (caller-side) and log (service-side)
250///   both describe the same lookup; we want one rendered line per host.
251/// - `TlsHandshakeFailed` — same shape as DNS, by symmetry.
252///
253/// In all three cases the *first* item pushed wins, which is the
254/// context-derived one (see `collect_evidence`'s source order). That's
255/// the more authoritative source: it's the error string the caller's
256/// network stack actually saw.
257fn is_redundant_with(existing: &[Evidence], candidate: &Evidence) -> bool {
258    if existing.contains(candidate) {
259        return true;
260    }
261    match candidate {
262        Evidence::JsonValidationError { field, .. } => existing.iter().any(|e| {
263            matches!(
264                e,
265                Evidence::JsonValidationError { field: f, .. } if f == field
266            )
267        }),
268        Evidence::DnsResolutionFailed { host, .. } => existing.iter().any(|e| {
269            matches!(
270                e,
271                Evidence::DnsResolutionFailed { host: h, .. } if h == host
272            )
273        }),
274        Evidence::TlsHandshakeFailed { peer, .. } => existing.iter().any(|e| {
275            matches!(
276                e,
277                Evidence::TlsHandshakeFailed { peer: p, .. } if p == peer
278            )
279        }),
280        _ => false,
281    }
282}
283
284/// Parse a log buffer into evidence items by scanning for known markers.
285///
286/// The parser is deliberately substring-based, not regex-driven: the
287/// markers it recognizes are documented in `docs/architecture.md`, and
288/// adding a new marker should be a one-line change. The cost of that
289/// simplicity is that the parser does not understand log-line *structure*
290/// (level, component, timestamp) — it only checks whether specific
291/// substrings appear and pulls `key=value` pairs out of the surrounding
292/// text via [`extract_kv_str`].
293///
294/// This is also the public entry point for the `diagnose-log` subcommand,
295/// which accepts a bare log file with no JSON case fixture. Evidence
296/// extracted here is identical to what `collect_evidence` would extract
297/// from the same log; only the context-derived items (DNS state, clock
298/// skew, etc.) are missing.
299pub fn parse_log(log_text: &str) -> Vec<Evidence> {
300    let mut out = Vec::new();
301    for raw_line in log_text.lines() {
302        let line = raw_line.trim();
303        if line.is_empty() {
304            continue;
305        }
306
307        if line.contains("reason=signature_mismatch")
308            || line.contains("signature verification failed")
309        {
310            push_unique(&mut out, Evidence::SignatureMismatch);
311        }
312
313        if line.contains("body_modified=true") || line.contains("body_mutated=true") {
314            push_unique(&mut out, Evidence::BodyMutatedBeforeVerification);
315        }
316
317        if let (Some(observed), Some(tol)) = (
318            extract_kv_i64(line, "drift_secs"),
319            extract_kv_u64(line, "tolerance_secs"),
320        ) {
321            push_unique(
322                &mut out,
323                Evidence::ClockDriftSecs {
324                    observed: observed.abs(),
325                    tolerance_secs: tol,
326                },
327            );
328        }
329
330        if line.contains("schema validation failed") {
331            let field = extract_kv_str(line, "field");
332            push_unique(
333                &mut out,
334                Evidence::JsonValidationError {
335                    field,
336                    message: "schema validation failed".into(),
337                },
338            );
339        }
340
341        if line.contains("burst above limit") {
342            if let Some(retry) = extract_kv_u64(line, "retry_after_secs") {
343                push_unique(&mut out, Evidence::RetryAfterSecs(retry));
344            }
345            if let (Some(observed), Some(limit)) = (
346                extract_kv_u32(line, "observed_rps"),
347                extract_kv_u32(line, "limit_rps"),
348            ) {
349                push_unique(
350                    &mut out,
351                    Evidence::RateLimitObserved {
352                        observed_rps: observed,
353                        limit_rps: limit,
354                    },
355                );
356            }
357        }
358
359        // Only emit if the line carries a `host=` token. An abort line that
360        // mentions DNS resolution as descriptive prose (no `host=`) is not a
361        // fresh observation — emitting a `<unknown host>` placeholder would
362        // produce a phantom evidence line that the dedup keys cannot
363        // collapse against the real one.
364        if line.contains("name resolution failed") {
365            if let Some(host) = extract_kv_str(line, "host") {
366                let message =
367                    extract_kv_str(line, "error").unwrap_or_else(|| "no such host".into());
368                push_unique(&mut out, Evidence::DnsResolutionFailed { host, message });
369            }
370        }
371
372        // Same shape as the DNS rule above: an abort line carrying the
373        // marker substring as prose (e.g. `aborting request: tls handshake
374        // failed`) without a `peer=` token is not new evidence.
375        if line.contains("tls handshake failed") {
376            if let Some(peer) = extract_kv_str(line, "peer") {
377                let reason =
378                    extract_kv_str(line, "error").unwrap_or_else(|| "tls handshake failed".into());
379                push_unique(&mut out, Evidence::TlsHandshakeFailed { peer, reason });
380            }
381        }
382
383        if line.contains("upstream timeout") {
384            if let (Some(elapsed), Some(timeout)) = (
385                extract_kv_u64(line, "elapsed_ms"),
386                extract_kv_u64(line, "timeout_ms"),
387            ) {
388                push_unique(
389                    &mut out,
390                    Evidence::ConnectionTimeout {
391                        elapsed_ms: elapsed,
392                        timeout_ms: timeout,
393                    },
394                );
395            }
396        }
397    }
398    out
399}
400
401/// Push `ev` only if an exactly-equal item is not already present.
402///
403/// This is for *within-log* dedup (the same marker can appear on
404/// multiple lines, e.g. a verifier logging both DEBUG and WARN for the
405/// same signature mismatch). Cross-source dedup (context vs log) lives
406/// in [`is_redundant_with`] and uses richer keys.
407fn push_unique(out: &mut Vec<Evidence>, ev: Evidence) {
408    if !out.contains(&ev) {
409        out.push(ev);
410    }
411}
412
413/// Parse a server response body for a structured validation error.
414///
415/// Recognizes the shape:
416/// `{"error": {"code": "validation_failed", "field": "...", "message": "..."}}`
417///
418/// Returns `None` for any other body shape, including bodies that are not
419/// valid JSON at all. The caller (`collect_evidence`) treats `None` as
420/// "no evidence to add" rather than as a parse error — bodies like
421/// `{"error":"unauthorized"}` are perfectly normal, they just don't
422/// produce a `JsonValidationError`.
423fn parse_validation_error_body(body: &str) -> Option<Evidence> {
424    let value: serde_json::Value = serde_json::from_str(body).ok()?;
425    let err = value.get("error")?;
426    let code = err.get("code")?.as_str()?;
427    if code == "validation_failed" {
428        let field = err
429            .get("field")
430            .and_then(|v| v.as_str())
431            .map(|s| s.to_string());
432        let message = err
433            .get("message")
434            .and_then(|v| v.as_str())
435            .unwrap_or("validation failed")
436            .to_string();
437        Some(Evidence::JsonValidationError { field, message })
438    } else {
439        None
440    }
441}
442
443/// Extract `key=value` where the value is either a `"quoted string"` or an
444/// unquoted whitespace-delimited token.
445///
446/// The match requires a word boundary *before* the key (start of line or a
447/// whitespace character) so that `prefixed_key=...` does not match a search
448/// for `key`. The trailing `=` in the search needle implicitly bounds the
449/// key on the right side, so `keyword=...` does not match a search for
450/// `key` either. This is the only protection we have against substring
451/// collisions; it is intentionally simple, and its limits are documented in
452/// `docs/architecture.md`.
453fn extract_kv_str(line: &str, key: &str) -> Option<String> {
454    let needle = format!("{key}=");
455    let mut search_from = 0;
456    let start = loop {
457        let rel = line[search_from..].find(&needle)?;
458        let abs = search_from + rel;
459        let preceded_by_boundary = abs == 0
460            || line[..abs]
461                .chars()
462                .next_back()
463                .is_some_and(char::is_whitespace);
464        if preceded_by_boundary {
465            break abs + needle.len();
466        }
467        search_from = abs + 1;
468    };
469    let rest = &line[start..];
470    if let Some(stripped) = rest.strip_prefix('"') {
471        let end = stripped.find('"')?;
472        Some(stripped[..end].to_string())
473    } else {
474        let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
475        Some(rest[..end].to_string())
476    }
477}
478
479// Numeric variants of `extract_kv_str`. `Option<T>` is `None` when the
480// key is absent OR when the value fails to parse as the requested
481// integer type. Callers treat both cases as "no signal," so we don't
482// distinguish them. A `<T: FromStr>` generic would collapse these into
483// one function but at the cost of explicit type annotations at every
484// call site; three tiny helpers read more cleanly here.
485
486fn extract_kv_u64(line: &str, key: &str) -> Option<u64> {
487    extract_kv_str(line, key).and_then(|s| s.parse().ok())
488}
489
490fn extract_kv_u32(line: &str, key: &str) -> Option<u32> {
491    extract_kv_str(line, key).and_then(|s| s.parse().ok())
492}
493
494fn extract_kv_i64(line: &str, key: &str) -> Option<i64> {
495    extract_kv_str(line, key).and_then(|s| s.parse().ok())
496}
497
498#[cfg(test)]
499mod tests {
500    #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
501    use super::*;
502
503    #[test]
504    fn parse_log_extracts_signature_mismatch_and_drift() {
505        let log = "2026-05-11T08:04:40.005Z DEBUG webhook.verify msg=\"computing HMAC\" \
506                   drift_secs=360 tolerance_secs=300\n\
507                   2026-05-11T08:04:40.006Z WARN webhook.verify \
508                   msg=\"signature verification failed\" reason=signature_mismatch";
509        let ev = parse_log(log);
510        assert!(ev.contains(&Evidence::SignatureMismatch));
511        assert!(ev.contains(&Evidence::ClockDriftSecs {
512            observed: 360,
513            tolerance_secs: 300
514        }));
515    }
516
517    #[test]
518    fn parse_log_extracts_dns_failure() {
519        let log = "2026-05-11T08:08:20.140Z ERROR http.client \
520                   msg=\"name resolution failed\" host=api.exmaple.com error=\"no such host\"";
521        let ev = parse_log(log);
522        assert_eq!(
523            ev,
524            vec![Evidence::DnsResolutionFailed {
525                host: "api.exmaple.com".into(),
526                message: "no such host".into(),
527            }]
528        );
529    }
530
531    #[test]
532    fn parse_log_extracts_rate_limit_burst() {
533        let log = "2026-05-11T08:03:20.000Z WARN ratelimit msg=\"burst above limit\" \
534                   account=acct_*** observed_rps=112 limit_rps=100 retry_after_secs=12";
535        let ev = parse_log(log);
536        assert!(ev.contains(&Evidence::RetryAfterSecs(12)));
537        assert!(ev.contains(&Evidence::RateLimitObserved {
538            observed_rps: 112,
539            limit_rps: 100
540        }));
541    }
542
543    #[test]
544    fn parse_log_extracts_timeout() {
545        let log = "2026-05-11T08:06:45.012Z WARN http.client msg=\"upstream timeout\" \
546                   elapsed_ms=5012 timeout_ms=5000";
547        let ev = parse_log(log);
548        assert_eq!(
549            ev,
550            vec![Evidence::ConnectionTimeout {
551                elapsed_ms: 5012,
552                timeout_ms: 5000
553            }]
554        );
555    }
556
557    #[test]
558    fn parse_log_extracts_validation_error() {
559        let log = "2026-05-11T08:01:40.022Z WARN charges.handler \
560                   msg=\"schema validation failed\" field=amount expected=integer got=string";
561        let ev = parse_log(log);
562        assert_eq!(
563            ev,
564            vec![Evidence::JsonValidationError {
565                field: Some("amount".into()),
566                message: "schema validation failed".into(),
567            }]
568        );
569    }
570
571    // -- Negative coverage for the substring kv-extractor below.
572
573    #[test]
574    fn extract_kv_handles_quoted_value_with_spaces() {
575        // The trailing key after a quoted value must still parse correctly.
576        let line = "msg=\"value with spaces\" host=example.com";
577        assert_eq!(
578            extract_kv_str(line, "msg"),
579            Some("value with spaces".into())
580        );
581        assert_eq!(extract_kv_str(line, "host"), Some("example.com".into()));
582    }
583
584    #[test]
585    fn extract_kv_returns_none_for_absent_key() {
586        let line = "host=example.com retry_after_secs=5";
587        assert_eq!(extract_kv_str(line, "absent"), None);
588        assert_eq!(extract_kv_u64(line, "absent"), None);
589    }
590
591    #[test]
592    fn extract_kv_rejects_malformed_numerics() {
593        let line = "elapsed_ms=oops timeout_ms=not_a_number";
594        assert_eq!(extract_kv_u64(line, "elapsed_ms"), None);
595        assert_eq!(extract_kv_u64(line, "timeout_ms"), None);
596    }
597
598    #[test]
599    fn extract_kv_does_not_match_inside_a_longer_key() {
600        // Searching for `key` must not match `prefixed_key=`. This is the
601        // word-boundary guarantee in extract_kv_str.
602        let line = "prefixed_key=should_not_match key=found";
603        assert_eq!(extract_kv_str(line, "key"), Some("found".into()));
604    }
605
606    #[test]
607    fn parse_log_ignores_blank_and_unknown_lines() {
608        let log = "\n\n\
609                   2026-05-11T08:00:00.000Z INFO http.server msg=\"healthz ok\"\n\
610                   \n";
611        // No recognized markers: result is empty.
612        assert!(parse_log(log).is_empty());
613    }
614
615    #[test]
616    fn parse_log_does_not_emit_phantom_tls_for_abort_line() {
617        // The "aborting request: tls handshake failed" line carries the
618        // marker substring as prose, not as a fresh observation: it has no
619        // `peer=` token. The parser must skip it rather than emit a
620        // <unknown peer> placeholder that the dedup cannot collapse.
621        let log = "2026-05-11T08:11:40.142Z ERROR http.client msg=\"tls handshake failed\" peer=api.example.com error=\"certificate has expired\"\n\
622                   2026-05-11T08:11:40.156Z WARN  http.client msg=\"aborting request: tls handshake failed\" elapsed_ms=156";
623        let ev = parse_log(log);
624        assert_eq!(
625            ev,
626            vec![Evidence::TlsHandshakeFailed {
627                peer: "api.example.com".into(),
628                reason: "certificate has expired".into(),
629            }],
630            "abort line without `peer=` must not produce a second TlsHandshakeFailed"
631        );
632    }
633
634    #[test]
635    fn parse_log_does_not_emit_phantom_dns_for_abort_line() {
636        // Symmetric to the TLS test above: an abort line that happens to
637        // mention "name resolution failed" without a `host=` token must
638        // not produce a <unknown host> placeholder.
639        let log = "2026-05-11T08:08:20.140Z ERROR http.client msg=\"name resolution failed\" host=api.exmaple.com error=\"no such host\"\n\
640                   2026-05-11T08:08:20.142Z WARN  http.client msg=\"aborting request: name resolution failed\" elapsed_ms=142";
641        let ev = parse_log(log);
642        assert_eq!(
643            ev,
644            vec![Evidence::DnsResolutionFailed {
645                host: "api.exmaple.com".into(),
646                message: "no such host".into(),
647            }],
648            "abort line without `host=` must not produce a second DnsResolutionFailed"
649        );
650    }
651}