From fa2e59896520ddf199add86a7c543fba4f9f3adb Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Jul 2026 16:52:35 +0800 Subject: [PATCH 1/2] fix(telemetry): stop clipping the per-attempt error message at 256 chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `error_message` an operator reads on the /logs page is truncated before it is ever stored: `attempt_error_message` caps at 256 chars, a bound copied from `sanitize_tag` — which exists to bound short provider tags (`branded_provider`, `pk_label`), not error text. An `UpstreamStatus` message is already bounded by the bridge at `MAX_UPSTREAM_ERROR_MESSAGE_BYTES` (1 KiB), so the 256-char cap was a second, tighter truncation applied on top of a bound that had already made the string safe to store. Real upstream errors exceed it: Azure's content-management-policy message is ~260 chars on its own, so what reached telemetry ended mid-URL at "...read our documentation: https://go.m" — dropping exactly the part that says what to do about it. Raise the cap to 2048 chars, above the bridge's 1 KiB byte budget, so the bridge's bound is the only one that fires and the operator sees the whole message the bridge kept. The cap stays as a backstop for variants carrying an unbounded string (e.g. `Config`). Fixes api7/AISIX-Cloud#1065 --- crates/aisix-proxy/src/attempt.rs | 86 +++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/aisix-proxy/src/attempt.rs b/crates/aisix-proxy/src/attempt.rs index 6459e94d..e4748f44 100644 --- a/crates/aisix-proxy/src/attempt.rs +++ b/crates/aisix-proxy/src/attempt.rs @@ -145,13 +145,24 @@ pub(crate) fn routing_error_class(err: &BridgeError) -> &'static str { } } -/// Short, control-char-stripped error string for the per-attempt -/// `error_message` telemetry field (#655). Capped like `sanitize_tag`. +/// Upper bound on the per-attempt `error_message` telemetry field. +/// +/// Sized as a backstop, not as the real limit: an `UpstreamStatus` +/// message is already bounded to [`aisix_gateway::MAX_UPSTREAM_ERROR_MESSAGE_BYTES`] +/// (1 KiB) by the bridge, so a cap above that byte budget leaves the +/// bridge's bound as the only one that ever fires and the operator sees +/// the whole message the bridge kept. A tighter cap silently clipped it +/// a second time (AISIX-Cloud#1065). +const MAX_ATTEMPT_ERROR_MESSAGE_CHARS: usize = 2048; + +/// Control-char-stripped error string for the per-attempt +/// `error_message` telemetry field (#655), capped at +/// [`MAX_ATTEMPT_ERROR_MESSAGE_CHARS`]. pub(crate) fn attempt_error_message(err: &BridgeError) -> String { err.to_string() .chars() .filter(|c| !c.is_control()) - .take(256) + .take(MAX_ATTEMPT_ERROR_MESSAGE_CHARS) .collect() } @@ -173,3 +184,72 @@ pub(crate) fn attempt_error_from_proxy(err: &ProxyError) -> (String, String) { pub(crate) fn ms_since(started: Instant) -> u32 { started.elapsed().as_millis().min(u32::MAX as u128) as u32 } + +#[cfg(test)] +mod tests { + use super::*; + use aisix_gateway::{UpstreamWire, MAX_UPSTREAM_ERROR_MESSAGE_BYTES}; + + fn upstream_status(message: &str) -> BridgeError { + BridgeError::UpstreamStatus { + status: 400, + message: message.to_string(), + parsed: None, + wire: UpstreamWire::OpenAI, + retry_after: None, + } + } + + /// AISIX-Cloud#1065: a real upstream error long enough to matter + /// must survive into telemetry whole. Azure's content-management + /// policy message is ~260 chars — the old 256-char cap clipped its + /// tail (the doc link that says what to actually do about it). + #[test] + fn long_upstream_message_is_not_clipped() { + let upstream = "The response was filtered due to the prompt triggering \ + Azure OpenAI's content management policy. Please modify your prompt \ + and retry. To learn more about our content filtering policies please \ + read our documentation: https://go.microsoft.com/fwlink/?linkid=2198766"; + assert!( + upstream.len() > 256, + "fixture must exceed the old cap to be a regression test" + ); + + let got = attempt_error_message(&upstream_status(upstream)); + + assert!( + got.ends_with("https://go.microsoft.com/fwlink/?linkid=2198766"), + "message tail was clipped: {got}" + ); + assert!(got.contains(upstream), "message body was altered: {got}"); + } + + /// The cap sits above the bridge's own byte bound, so anything the + /// bridge already truncated passes through untouched — the bridge + /// stays the single limit that fires. + #[test] + fn cap_clears_the_bridge_message_bound() { + let bridge_capped = "x".repeat(MAX_UPSTREAM_ERROR_MESSAGE_BYTES); + let got = attempt_error_message(&upstream_status(&bridge_capped)); + assert!( + got.contains(&bridge_capped), + "a bridge-bounded message must reach telemetry whole" + ); + } + + /// The cap is still a backstop: a bridge variant carrying an + /// unbounded string (`Config`, here) can't write unbounded telemetry. + #[test] + fn pathological_message_still_hits_the_backstop() { + let got = attempt_error_message(&BridgeError::Config("y".repeat(9000))); + assert_eq!(got.chars().count(), MAX_ATTEMPT_ERROR_MESSAGE_CHARS); + } + + /// Control characters stay stripped — a multi-line upstream body + /// must not break the single-string telemetry field. + #[test] + fn control_chars_are_stripped() { + let got = attempt_error_message(&upstream_status("line one\nline\ttwo")); + assert!(got.ends_with("line onelinetwo"), "got: {got}"); + } +} From d81d27850bee752b727b90e8497b4c6632cf0800 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 17 Jul 2026 18:31:33 +0800 Subject: [PATCH 2/2] test(telemetry): drop the brand from the long-message fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression fixture quoted an upstream vendor's refusal prose verbatim, doc URL and all, inside a provider-neutral crate. Only its LENGTH was load-bearing — the brand was decorative, which is what AGENTS.md §7 rules out for shipped artifacts. Keep the shape that makes the fixture worth having (prose ending in a URL, so what must survive is the END of a realistically long message) and write it generically. Still fails on the old 256-char cap. --- crates/aisix-proxy/src/attempt.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/aisix-proxy/src/attempt.rs b/crates/aisix-proxy/src/attempt.rs index e4748f44..9c14440f 100644 --- a/crates/aisix-proxy/src/attempt.rs +++ b/crates/aisix-proxy/src/attempt.rs @@ -200,16 +200,20 @@ mod tests { } } - /// AISIX-Cloud#1065: a real upstream error long enough to matter - /// must survive into telemetry whole. Azure's content-management - /// policy message is ~260 chars — the old 256-char cap clipped its - /// tail (the doc link that says what to actually do about it). + /// AISIX-Cloud#1065: an upstream error long enough to matter must + /// survive into telemetry whole. A content-filter refusal — the + /// shape that provoked the issue — runs past 256 chars, and the old + /// cap clipped its tail, which is exactly where the actionable part + /// (the link explaining the policy) sits. Hence a fixture that is + /// prose ending in a URL, not a run of filler: what has to survive + /// is the END of a realistically long message. #[test] fn long_upstream_message_is_not_clipped() { - let upstream = "The response was filtered due to the prompt triggering \ - Azure OpenAI's content management policy. Please modify your prompt \ - and retry. To learn more about our content filtering policies please \ - read our documentation: https://go.microsoft.com/fwlink/?linkid=2198766"; + let upstream = "The response was filtered because the prompt triggered \ + the provider's content management policy. Please modify your prompt \ + and retry. To learn more about the content filtering policies that \ + apply here, read the documentation at \ + https://upstream.example/docs/content-filtering"; assert!( upstream.len() > 256, "fixture must exceed the old cap to be a regression test" @@ -218,7 +222,7 @@ mod tests { let got = attempt_error_message(&upstream_status(upstream)); assert!( - got.ends_with("https://go.microsoft.com/fwlink/?linkid=2198766"), + got.ends_with("https://upstream.example/docs/content-filtering"), "message tail was clipped: {got}" ); assert!(got.contains(upstream), "message body was altered: {got}");