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}