2026-03-21 12:57:09 +08:00
|
|
|
use std::collections::BTreeMap;
|
2026-09-04 03:45:52 +08:00
|
|
|
use std::fmt;
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
|
|
|
|
use crate::ExecutionError;
|
|
|
|
|
|
2026-08-14 09:28:07 +08:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
pub struct ExecutionResponseObservation {
|
|
|
|
|
pub request_started_at_unix_ms: u64,
|
|
|
|
|
pub response_headers_observed_at_unix_ms: u64,
|
|
|
|
|
pub request_order_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
pub struct ExecutionTelemetry {
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub ttfb_ms: Option<u64>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub elapsed_ms: Option<u64>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub upstream_bytes: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
#[derive(Clone, Serialize, Deserialize, PartialEq)]
|
2026-03-21 12:57:09 +08:00
|
|
|
pub struct ResponseBody {
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub json_body: Option<Value>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub body_bytes_b64: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
impl fmt::Debug for ResponseBody {
|
|
|
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
formatter
|
|
|
|
|
.debug_struct("ResponseBody")
|
|
|
|
|
.field("has_json_body", &self.json_body.is_some())
|
|
|
|
|
.field(
|
|
|
|
|
"json_body_bytes",
|
|
|
|
|
&self
|
|
|
|
|
.json_body
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|body| serde_json::to_vec(body).ok().map(|bytes| bytes.len())),
|
|
|
|
|
)
|
|
|
|
|
.field(
|
|
|
|
|
"body_bytes_b64_len",
|
|
|
|
|
&self.body_bytes_b64.as_ref().map(String::len),
|
|
|
|
|
)
|
|
|
|
|
.finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Serialize, Deserialize, PartialEq)]
|
2026-03-21 12:57:09 +08:00
|
|
|
pub struct ExecutionResult {
|
|
|
|
|
pub request_id: String,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub candidate_id: Option<String>,
|
|
|
|
|
pub status_code: u16,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub headers: BTreeMap<String, String>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
2026-08-14 09:28:07 +08:00
|
|
|
pub response_observation: Option<ExecutionResponseObservation>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
2026-03-21 12:57:09 +08:00
|
|
|
pub body: Option<ResponseBody>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub telemetry: Option<ExecutionTelemetry>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub error: Option<ExecutionError>,
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
|
|
|
|
|
impl fmt::Debug for ExecutionResult {
|
|
|
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
let mut debug = formatter.debug_struct("ExecutionResult");
|
|
|
|
|
debug
|
|
|
|
|
.field("request_id", &self.request_id)
|
|
|
|
|
.field("candidate_id", &self.candidate_id)
|
|
|
|
|
.field("status_code", &self.status_code)
|
|
|
|
|
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
|
|
|
|
|
.field("response_observation", &self.response_observation)
|
|
|
|
|
.field("body", &self.body)
|
|
|
|
|
.field("telemetry", &self.telemetry)
|
|
|
|
|
.field("has_error", &self.error.is_some());
|
|
|
|
|
if let Some(error) = self.error.as_ref() {
|
|
|
|
|
// ExecutionError::message can contain an upstream response or URL.
|
|
|
|
|
debug
|
|
|
|
|
.field("error_kind", &error.kind)
|
|
|
|
|
.field("error_phase", &error.phase)
|
|
|
|
|
.field("error_upstream_status", &error.upstream_status)
|
|
|
|
|
.field("error_retryable", &error.retryable)
|
|
|
|
|
.field("error_failover_recommended", &error.failover_recommended);
|
|
|
|
|
}
|
|
|
|
|
debug.finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{ExecutionResult, ResponseBody};
|
|
|
|
|
use crate::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn debug_does_not_render_response_headers_body_or_error_message() {
|
|
|
|
|
let result = ExecutionResult {
|
|
|
|
|
request_id: "request-1".into(),
|
|
|
|
|
candidate_id: None,
|
|
|
|
|
status_code: 401,
|
|
|
|
|
headers: BTreeMap::from([(
|
|
|
|
|
"set-cookie".into(),
|
|
|
|
|
"session=response-header-secret".into(),
|
|
|
|
|
)]),
|
|
|
|
|
response_observation: None,
|
|
|
|
|
body: Some(ResponseBody {
|
|
|
|
|
json_body: Some(serde_json::json!({"access_token": "response-body-secret"})),
|
|
|
|
|
body_bytes_b64: None,
|
|
|
|
|
}),
|
|
|
|
|
telemetry: None,
|
|
|
|
|
error: Some(ExecutionError {
|
|
|
|
|
kind: ExecutionErrorKind::Upstream4xx,
|
|
|
|
|
phase: ExecutionPhase::Finalize,
|
|
|
|
|
message: "upstream detail error-secret".into(),
|
|
|
|
|
upstream_status: Some(401),
|
|
|
|
|
retryable: false,
|
|
|
|
|
failover_recommended: false,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let debug = format!("{result:?}");
|
|
|
|
|
for secret in [
|
|
|
|
|
"response-header-secret",
|
|
|
|
|
"response-body-secret",
|
|
|
|
|
"error-secret",
|
|
|
|
|
] {
|
|
|
|
|
assert!(!debug.contains(secret), "debug leaked {secret}: {debug}");
|
|
|
|
|
}
|
|
|
|
|
assert!(debug.contains("header_names"));
|
|
|
|
|
assert!(debug.contains("has_error"));
|
|
|
|
|
}
|
|
|
|
|
}
|