Add proxy tunnel diagnostics and default logging

This commit is contained in:
fawney19
2026-05-15 19:43:59 +08:00
parent 1503986d40
commit 87e44479cc
17 changed files with 258 additions and 60 deletions

View File

@@ -53,6 +53,7 @@ pub const DEFAULT_REDIRECT_REPLAY_BUDGET_BYTES: usize = 5_242_880;
pub const DEFAULT_REDIRECT_REPLAY_BUDGET_HUMAN: &str = "5M";
pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 7;
pub const DEFAULT_LOG_MAX_FILES: usize = 30;
pub const DEFAULT_LOG_DIR: &str = "logs";
pub const DEFAULT_TUNNEL_RECONNECT_BASE_MS: u64 = 50;
pub const DEFAULT_TUNNEL_RECONNECT_MAX_MS: u64 = 250;
pub const DEFAULT_TUNNEL_PING_INTERVAL_MS: u64 = 10_000;
@@ -487,12 +488,12 @@ pub struct Config {
long,
env = "AETHER_PROXY_LOG_DESTINATION",
value_enum,
default_value = "stdout"
default_value = "both"
)]
pub log_destination: ProxyLogDestinationArg,
/// Log directory when file logging is enabled
#[arg(long, env = "AETHER_PROXY_LOG_DIR")]
#[arg(long, env = "AETHER_PROXY_LOG_DIR", default_value = DEFAULT_LOG_DIR)]
pub log_dir: Option<String>,
/// Log rotation schedule for file logging
@@ -1359,6 +1360,36 @@ mod tests {
);
}
#[test]
fn proxy_logs_default_to_rotating_file_and_stdout() {
let config = Config::parse_from([
"aether-proxy",
"--aether-url",
"https://example.com",
"--management-token",
"ae_test",
"--node-name",
"proxy-test",
]);
assert_eq!(config.log_destination, ProxyLogDestinationArg::Both);
assert_eq!(config.log_dir.as_deref(), Some(DEFAULT_LOG_DIR));
assert_eq!(config.log_rotation, ProxyLogRotationArg::Daily);
assert_eq!(config.log_retention_days, DEFAULT_LOG_RETENTION_DAYS);
let runtime = config
.service_runtime_config()
.expect("default file logging should be valid");
assert_eq!(runtime.observability.log_destination, LogDestination::Both);
let file_logging = runtime
.observability
.file_logging
.expect("file logging should be enabled by default");
assert_eq!(file_logging.dir, std::path::PathBuf::from(DEFAULT_LOG_DIR));
assert_eq!(file_logging.rotation, LogRotation::Daily);
assert_eq!(file_logging.retention_days, DEFAULT_LOG_RETENTION_DAYS);
}
#[test]
fn config_file_load_accepts_server_scoped_upstream_proxy_url() {
let cfg = parse_config_file_content(

View File

@@ -171,7 +171,7 @@ impl App {
Field {
label: "Save Logs to File",
key: "save_logs_to_file",
value: "false".into(),
value: "true".into(),
kind: FieldKind::Bool,
required: false,
help: "Write pretty .log files with daily rotation and 7-day retention",

View File

@@ -97,6 +97,7 @@ const TUNNEL_ERROR_MESSAGE_MAX_CHARS: usize = 320;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TunnelErrorEvent {
pub timestamp_unix_secs: u64,
pub timestamp_unix_ms: u64,
pub category: String,
pub message: String,
pub severity: String,
@@ -240,8 +241,10 @@ impl TunnelMetrics {
let message = normalize_error_field(message, TUNNEL_ERROR_MESSAGE_MAX_CHARS, "n/a");
let diagnostic = classify_tunnel_error(category.as_str(), message.as_str());
let timestamp_unix_ms = now_unix_ms();
let event = TunnelErrorEvent {
timestamp_unix_secs: now_unix_secs(),
timestamp_unix_secs: timestamp_unix_ms / 1_000,
timestamp_unix_ms,
category,
message,
severity: diagnostic.severity.to_string(),
@@ -298,9 +301,13 @@ impl TunnelMetrics {
}
fn now_unix_secs() -> u64 {
now_unix_ms() / 1_000
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}

View File

@@ -8,7 +8,7 @@ use tokio::sync::watch;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
use crate::egress_proxy::{connect_target_via_proxy, ProxyConnectOptions, UpstreamProxyConfig};
use crate::state::{AppState, ServerContext};
@@ -224,9 +224,30 @@ pub async fn connect_and_run(
let _ = tokio::time::timeout(Duration::from_secs(35), writer_handle).await;
}
server
.tunnel_metrics
.record_disconnect(connected_at.elapsed());
let connected_for = connected_at.elapsed();
match &outcome {
Ok(TunnelOutcome::Shutdown) => info!(
conn = conn_idx,
connected_duration_ms = connected_for.as_millis() as u64,
close_reason = "shutdown",
"tunnel session ending"
),
Ok(TunnelOutcome::Disconnected) => info!(
conn = conn_idx,
connected_duration_ms = connected_for.as_millis() as u64,
close_reason = "disconnected",
"tunnel session ending"
),
Err(error) => warn!(
conn = conn_idx,
connected_duration_ms = connected_for.as_millis() as u64,
close_reason = "error",
error = %error,
"tunnel session ending"
),
}
server.tunnel_metrics.record_disconnect(connected_for);
debug!("tunnel disconnected");
outcome
@@ -250,14 +271,26 @@ fn spawn_drain_signal(
}
debug!(conn = conn_idx, "sending GOAWAY for tunnel drain");
let _ = tokio::time::timeout(
match tokio::time::timeout(
Duration::from_millis(250),
frame_tx.send(super::protocol::Frame::control(
super::protocol::MsgType::GoAway,
bytes::Bytes::new(),
)),
)
.await;
.await
{
Ok(Ok(())) => info!(conn = conn_idx, "sent GOAWAY for tunnel drain"),
Ok(Err(error)) => warn!(
conn = conn_idx,
error = ?error,
"failed to queue GOAWAY for tunnel drain"
),
Err(_) => warn!(
conn = conn_idx,
"timed out queueing GOAWAY for tunnel drain"
),
}
})
}

View File

@@ -536,6 +536,10 @@ mod tests {
.pointer("/proxy_metadata/recent_tunnel_errors/0")
.and_then(serde_json::Value::as_object)
.expect("recent tunnel error should be reported");
assert!(recent_error
.get("timestamp_unix_ms")
.and_then(serde_json::Value::as_u64)
.is_some());
assert_eq!(
recent_error
.get("component")

View File

@@ -179,10 +179,20 @@ async fn write_frame<S>(sink: &mut S, frame: Frame, tunnel_metrics: Option<&Tunn
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
{
let stream_id = frame.stream_id;
let msg_type = frame.msg_type;
let flags = frame.flags;
let data = frame.encode();
let wire_len = data.len().max(HEADER_SIZE);
if let Err(e) = sink.send(Message::Binary(data.into())).await {
error!(error = %e, "failed to write frame to WebSocket");
error!(
stream_id = stream_id,
msg_type = ?msg_type,
flags = flags,
wire_len = wire_len,
error = %e,
"failed to write frame to WebSocket"
);
if let Some(metrics) = tunnel_metrics {
metrics.record_error("ws_write_error", &e.to_string());
}