feat(proxy): add request IDs, tracing spans, and observability hooks

Generate UUID v4 request IDs per request, create manual tracing spans
(Pingora types don't impl Debug), record Prometheus metrics for
detection decisions and request totals, and forward X-Request-Id to
both upstream requests and downstream responses.

Signed-off-by: Sienna Meridian Satterwhite <sienna@sunbeam.pt>
This commit is contained in:
2026-03-10 23:38:20 +00:00
parent 1ae185b5a5
commit 0fd10110ff
3 changed files with 283 additions and 6 deletions

205
Cargo.lock generated
View File

@@ -1038,6 +1038,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1176,10 +1182,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 5.3.0",
"wasip2", "wasip2",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "getset" name = "getset"
version = "0.1.6" version = "0.1.6"
@@ -1252,6 +1271,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -1260,7 +1288,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash", "foldhash 0.2.0",
] ]
[[package]] [[package]]
@@ -1501,6 +1529,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -1546,6 +1580,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -1794,6 +1830,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.183"
@@ -2697,6 +2739,16 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.117",
]
[[package]] [[package]]
name = "proc-macro-error" name = "proc-macro-error"
version = "1.0.4" version = "1.0.4"
@@ -2824,6 +2876,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@@ -3459,6 +3517,7 @@ dependencies = [
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]
@@ -3976,6 +4035,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -4012,6 +4077,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -4058,6 +4134,15 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.114"
@@ -4117,6 +4202,40 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"semver",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.91"
@@ -4331,6 +4450,88 @@ name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck 0.5.0",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.13.0",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"indexmap 2.13.0",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"

View File

@@ -52,6 +52,9 @@ dns-lookup = "2"
# Prometheus metrics # Prometheus metrics
prometheus = "0.13" prometheus = "0.13"
# Request IDs
uuid = { version = "1", features = ["v4"] }
# Rustls crypto provider — must be installed before any TLS init # Rustls crypto provider — must be installed before any TLS init
rustls = { version = "0.23", features = ["aws-lc-rs"] } rustls = { version = "0.23", features = ["aws-lc-rs"] }

View File

@@ -2,6 +2,7 @@ use crate::acme::AcmeRoutes;
use crate::config::RouteConfig; use crate::config::RouteConfig;
use crate::ddos::detector::DDoSDetector; use crate::ddos::detector::DDoSDetector;
use crate::ddos::model::DDoSAction; use crate::ddos::model::DDoSAction;
use crate::metrics;
use crate::rate_limit::key; use crate::rate_limit::key;
use crate::rate_limit::limiter::{RateLimitResult, RateLimiter}; use crate::rate_limit::limiter::{RateLimitResult, RateLimiter};
use crate::scanner::allowlist::BotAllowlist; use crate::scanner::allowlist::BotAllowlist;
@@ -20,7 +21,6 @@ use std::time::Instant;
pub struct SunbeamProxy { pub struct SunbeamProxy {
pub routes: Vec<RouteConfig>, pub routes: Vec<RouteConfig>,
/// Per-challenge route table populated by the Ingress watcher. /// Per-challenge route table populated by the Ingress watcher.
/// Maps `/.well-known/acme-challenge/<token>` → solver service address.
pub acme_routes: AcmeRoutes, pub acme_routes: AcmeRoutes,
/// Optional KNN-based DDoS detector. /// Optional KNN-based DDoS detector.
pub ddos_detector: Option<Arc<DDoSDetector>>, pub ddos_detector: Option<Arc<DDoSDetector>>,
@@ -35,6 +35,10 @@ pub struct SunbeamProxy {
pub struct RequestCtx { pub struct RequestCtx {
pub route: Option<RouteConfig>, pub route: Option<RouteConfig>,
pub start_time: Instant, pub start_time: Instant,
/// Unique request identifier (UUID v4).
pub request_id: String,
/// Tracing span for this request.
pub span: tracing::Span,
/// Resolved solver backend address for this ACME challenge, if applicable. /// Resolved solver backend address for this ACME challenge, if applicable.
pub acme_backend: Option<String>, pub acme_backend: Option<String>,
/// Path prefix to strip before forwarding to the upstream (e.g. "/kratos"). /// Path prefix to strip before forwarding to the upstream (e.g. "/kratos").
@@ -91,7 +95,7 @@ fn extract_client_ip(session: &Session) -> Option<IpAddr> {
} }
/// Strip the scheme prefix from a backend URL like `http://host:port`. /// Strip the scheme prefix from a backend URL like `http://host:port`.
fn backend_addr(backend: &str) -> &str { pub fn backend_addr(backend: &str) -> &str {
backend backend
.trim_start_matches("https://") .trim_start_matches("https://")
.trim_start_matches("http://") .trim_start_matches("http://")
@@ -110,9 +114,12 @@ impl ProxyHttp for SunbeamProxy {
type CTX = RequestCtx; type CTX = RequestCtx;
fn new_ctx(&self) -> RequestCtx { fn new_ctx(&self) -> RequestCtx {
let request_id = uuid::Uuid::new_v4().to_string();
RequestCtx { RequestCtx {
route: None, route: None,
start_time: Instant::now(), start_time: Instant::now(),
request_id,
span: tracing::Span::none(),
acme_backend: None, acme_backend: None,
downstream_scheme: "https", downstream_scheme: "https",
strip_prefix: None, strip_prefix: None,
@@ -130,6 +137,19 @@ impl ProxyHttp for SunbeamProxy {
{ {
ctx.downstream_scheme = if is_plain_http(session) { "http" } else { "https" }; ctx.downstream_scheme = if is_plain_http(session) { "http" } else { "https" };
// Create the request-scoped tracing span.
let method = session.req_header().method.to_string();
let host = extract_host(session);
let path = session.req_header().uri.path().to_string();
ctx.span = tracing::info_span!("request",
request_id = %ctx.request_id,
method = %method,
host = %host,
path = %path,
);
metrics::ACTIVE_CONNECTIONS.inc();
if is_plain_http(session) { if is_plain_http(session) {
let path = session.req_header().uri.path().to_string(); let path = session.req_header().uri.path().to_string();
@@ -157,7 +177,6 @@ impl ProxyHttp for SunbeamProxy {
} }
// All other plain-HTTP traffic. // All other plain-HTTP traffic.
let host = extract_host(session);
let prefix = host.split('.').next().unwrap_or(""); let prefix = host.split('.').next().unwrap_or("");
// Routes that explicitly opt out of HTTPS enforcement pass through. // Routes that explicitly opt out of HTTPS enforcement pass through.
@@ -242,6 +261,8 @@ impl ProxyHttp for SunbeamProxy {
"pipeline" "pipeline"
); );
metrics::DDOS_DECISIONS.with_label_values(&[decision]).inc();
if matches!(ddos_action, DDoSAction::Block) { if matches!(ddos_action, DDoSAction::Block) {
let mut resp = ResponseHeader::build(429, None)?; let mut resp = ResponseHeader::build(429, None)?;
resp.insert_header("Retry-After", "60")?; resp.insert_header("Retry-After", "60")?;
@@ -325,6 +346,10 @@ impl ProxyHttp for SunbeamProxy {
"pipeline" "pipeline"
); );
metrics::SCANNER_DECISIONS
.with_label_values(&[decision, &reason])
.inc();
if decision == "block" { if decision == "block" {
let mut resp = ResponseHeader::build(403, None)?; let mut resp = ResponseHeader::build(403, None)?;
resp.insert_header("Content-Length", "0")?; resp.insert_header("Content-Length", "0")?;
@@ -363,6 +388,10 @@ impl ProxyHttp for SunbeamProxy {
"pipeline" "pipeline"
); );
metrics::RATE_LIMIT_DECISIONS
.with_label_values(&[decision])
.inc();
if let RateLimitResult::Reject { retry_after } = rl_result { if let RateLimitResult::Reject { retry_after } = rl_result {
let mut resp = ResponseHeader::build(429, None)?; let mut resp = ResponseHeader::build(429, None)?;
resp.insert_header("Retry-After", retry_after.to_string())?; resp.insert_header("Retry-After", retry_after.to_string())?;
@@ -491,6 +520,15 @@ impl ProxyHttp for SunbeamProxy {
) )
})?; })?;
// Forward X-Request-Id to upstream.
upstream_req.insert_header("x-request-id", &ctx.request_id).map_err(|e| {
pingora_core::Error::because(
pingora_core::ErrorType::InternalError,
"failed to insert x-request-id",
e,
)
})?;
if ctx.route.as_ref().map(|r| r.websocket).unwrap_or(false) { if ctx.route.as_ref().map(|r| r.websocket).unwrap_or(false) {
for name in &[CONNECTION, UPGRADE] { for name in &[CONNECTION, UPGRADE] {
if let Some(val) = session.req_header().headers.get(name.clone()) { if let Some(val) = session.req_header().headers.get(name.clone()) {
@@ -535,6 +573,20 @@ impl ProxyHttp for SunbeamProxy {
Ok(()) Ok(())
} }
/// Add X-Request-Id response header so clients can correlate.
async fn upstream_response_filter(
&self,
_session: &mut Session,
upstream_response: &mut ResponseHeader,
ctx: &mut RequestCtx,
) -> Result<()>
where
Self::CTX: Send + Sync,
{
let _ = upstream_response.insert_header("x-request-id", &ctx.request_id);
Ok(())
}
/// Emit a structured JSON audit log line for every request. /// Emit a structured JSON audit log line for every request.
async fn logging( async fn logging(
&self, &self,
@@ -544,10 +596,15 @@ impl ProxyHttp for SunbeamProxy {
) where ) where
Self::CTX: Send + Sync, Self::CTX: Send + Sync,
{ {
metrics::ACTIVE_CONNECTIONS.dec();
let status = session let status = session
.response_written() .response_written()
.map_or(0, |r| r.status.as_u16()); .map_or(0, |r| r.status.as_u16());
let duration_ms = ctx.start_time.elapsed().as_millis() as u64; let duration_ms = ctx.start_time.elapsed().as_millis() as u64;
let duration_secs = ctx.start_time.elapsed().as_secs_f64();
let method_str = session.req_header().method.to_string();
let host = extract_host(session);
let backend = ctx let backend = ctx
.route .route
.as_ref() .as_ref()
@@ -563,6 +620,12 @@ impl ProxyHttp for SunbeamProxy {
}); });
let error_str = error.map(|e| e.to_string()); let error_str = error.map(|e| e.to_string());
// Record Prometheus metrics.
metrics::REQUESTS_TOTAL
.with_label_values(&[&method_str, &host, &status.to_string(), backend])
.inc();
metrics::REQUEST_DURATION.observe(duration_secs);
let content_length: u64 = session let content_length: u64 = session
.req_header() .req_header()
.headers .headers
@@ -609,8 +672,9 @@ impl ProxyHttp for SunbeamProxy {
tracing::info!( tracing::info!(
target = "audit", target = "audit",
request_id = %ctx.request_id,
method = %session.req_header().method, method = %session.req_header().method,
host = %extract_host(session), host = %host,
path = %session.req_header().uri.path(), path = %session.req_header().uri.path(),
query, query,
client_ip, client_ip,
@@ -678,6 +742,8 @@ mod tests {
let ctx = RequestCtx { let ctx = RequestCtx {
route: None, route: None,
start_time: Instant::now(), start_time: Instant::now(),
request_id: "1".to_string(),
span: tracing::Span::none(),
acme_backend: None, acme_backend: None,
strip_prefix: None, strip_prefix: None,
downstream_scheme: "https", downstream_scheme: "https",
@@ -705,4 +771,11 @@ mod tests {
// Content-Length must survive the strip. // Content-Length must survive the strip.
assert!(req.headers.get("content-length").is_some()); assert!(req.headers.get("content-length").is_some());
} }
#[test]
fn test_request_id_is_uuid_v4() {
let id = uuid::Uuid::new_v4().to_string();
assert_eq!(id.len(), 36);
assert!(uuid::Uuid::parse_str(&id).is_ok());
}
} }