Skip to content

Commit 5b3c5d3

Browse files
committed
Implement /pprof/symbol
foundations includes a feature for memory profiling, which is very useful. Unfortunately, that feature does not natively work with the `jeprof` CLI tool because of the lack of a /pprof/symbol endpoint. Since many Rust binaries are built with PIE, trying to post-hoc symbolize with jeprof/pprof is quite challenging -- especially if running on a remote system that does not have debug symbols available. This commit implements /pprof/symbol, following the golang implementation (https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/net/http/pprof/pprof.go;l=197) as a guide. The actual symbolization is performed with the `backtrace` crate, which was previously an indirect dependency and now becomes a direct dependency.
1 parent 85d0c8c commit 5b3c5d3

6 files changed

Lines changed: 154 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ check-cfg = [
3737

3838
[workspace.dependencies]
3939
anyhow = "1.0.75"
40+
backtrace = "0.3"
4041
foundations = { version = "5.5.3", path = "./foundations" }
4142
foundations-macros = { version = "=5.5.3", path = "./foundations-macros", default-features = false }
4243
bindgen = { version = "0.72", default-features = false }

foundations/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ metrics = [
152152

153153
# Enables memory profiling features (require `jemalloc` feature to be enabled)
154154
memory-profiling = [
155+
"dep:backtrace",
155156
"dep:once_cell",
156157
"dep:tikv-jemalloc-ctl",
157158
"dep:tempfile",
@@ -194,6 +195,7 @@ workspace = true
194195

195196
[dependencies]
196197
anyhow = { workspace = true, features = ["backtrace", "std"] }
198+
backtrace = { workspace = true, optional = true }
197199
foundations-macros = { workspace = true, optional = true, default-features = false }
198200
cf-rustracing = { workspace = true, optional = true }
199201
cf-rustracing-jaeger = { workspace = true, optional = true }
@@ -264,6 +266,7 @@ neli = { workspace = true, optional = true }
264266
neli-proc-macros = { workspace = true, optional = true }
265267

266268
[dev-dependencies]
269+
backtrace = { workspace = true }
267270
reqwest = { workspace = true }
268271
serde = { workspace = true, features = ["rc"] }
269272
tempfile = { workspace = true }

foundations/src/telemetry/server/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ use tokio::sync::watch;
2121

2222
mod router;
2323

24+
#[cfg(feature = "memory-profiling")]
25+
mod pprof_symbol;
26+
2427
use router::Router;
2528

2629
enum TelemetryStream {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use crate::Result;
2+
use crate::telemetry::reexports::http_body_util::BodyExt;
3+
use hyper::body::Incoming;
4+
use hyper::{Method, Request};
5+
use std::fmt::Write;
6+
7+
/// Resolves program counter addresses to symbol names.
8+
///
9+
/// This implements the pprof symbol resolution protocol used by `jeprof` and
10+
/// other pprof-compatible tools. The input is read from the POST body (or GET
11+
/// query string) as `+`-separated hex addresses (with optional `0x` prefix).
12+
/// The output is a text response with a `num_symbols` header followed by one
13+
/// line per resolved symbol in the format `0x<addr>\t<name>`.
14+
pub(super) async fn pprof_symbol(req: Request<Incoming>) -> Result<String> {
15+
let mut buf = String::new();
16+
17+
// Always emit num_symbols header. The value doesn't matter to pprof tools
18+
// as long as it is > 0, which signals that symbol information is available.
19+
// This is also what Go does: https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/net/http/pprof/pprof.go;l=197
20+
writeln!(buf, "num_symbols: 1")?;
21+
22+
let input = if req.method() == Method::POST {
23+
let body = req.into_body().collect().await?.to_bytes();
24+
String::from_utf8(body.to_vec())?
25+
} else {
26+
req.uri().query().unwrap_or_default().to_string()
27+
};
28+
29+
for token in input.split('+') {
30+
let hex_str = token
31+
.trim()
32+
.strip_prefix("0x")
33+
.or_else(|| token.trim().strip_prefix("0X"))
34+
.unwrap_or(token.trim());
35+
36+
let Ok(addr) = u64::from_str_radix(hex_str, 16) else {
37+
continue;
38+
};
39+
40+
backtrace::resolve(addr as usize as *mut std::ffi::c_void, |symbol| {
41+
if let Some(name) = symbol.name() {
42+
let _ = writeln!(buf, "{:#x}\t{}", addr, name);
43+
}
44+
});
45+
}
46+
47+
Ok(buf)
48+
}

foundations/src/telemetry/server/router.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#[cfg(all(target_os = "linux", feature = "memory-profiling"))]
22
use super::memory_profiling;
3+
#[cfg(feature = "memory-profiling")]
4+
use super::pprof_symbol;
35
#[cfg(feature = "metrics")]
46
use crate::telemetry::metrics;
57
use crate::telemetry::reexports::http_body_util::{BodyExt, Empty, Full, combinators::BoxBody};
@@ -109,6 +111,21 @@ impl RouteMap {
109111
}),
110112
});
111113

114+
#[cfg(feature = "memory-profiling")]
115+
self.set(TelemetryServerRoute {
116+
path: "/pprof/symbol".into(),
117+
methods: vec![Method::GET, Method::POST],
118+
handler: Box::new(|req, _| {
119+
async move {
120+
into_response(
121+
"text/plain; charset=utf-8",
122+
pprof_symbol::pprof_symbol(req).await,
123+
)
124+
}
125+
.boxed()
126+
}),
127+
});
128+
112129
#[cfg(feature = "tracing")]
113130
self.set(TelemetryServerRoute {
114131
path: "/debug/traces".into(),

foundations/tests/telemetry_server.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,33 @@ use foundations::telemetry::settings::MemoryProfilerSettings;
1818
#[cfg(target_os = "linux")]
1919
use foundations::telemetry::MemoryProfiler;
2020

21+
/// Captures its own instruction pointer via [`backtrace::trace`] and returns it
22+
/// along with the symbol name that the `backtrace` crate resolves it to. This
23+
/// gives us a (pc, expected_name) pair that the `/pprof/symbol` endpoint must
24+
/// also be able to resolve, since it uses the same crate.
25+
#[inline(never)]
26+
fn capture_self_pc() -> (usize, String) {
27+
let mut result = None;
28+
backtrace::trace(|frame| {
29+
if result.is_some() {
30+
return false;
31+
}
32+
let ip = frame.ip() as usize;
33+
backtrace::resolve(ip as *mut std::ffi::c_void, |symbol| {
34+
if result.is_none()
35+
&& let Some(name) = symbol.name()
36+
{
37+
let name = name.to_string();
38+
if name.contains("capture_self_pc") {
39+
result = Some((ip, name));
40+
}
41+
}
42+
});
43+
result.is_none()
44+
});
45+
result.expect("should find capture_self_pc frame")
46+
}
47+
2148
#[tokio::test]
2249
async fn telemetry_server() {
2350
let server_addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 1337));
@@ -121,6 +148,61 @@ async fn telemetry_server() {
121148
.contains("Allocated")
122149
);
123150

151+
// Capture a real PC and the symbol the backtrace crate resolves it to, so
152+
// we can verify both GET and POST resolve to the same name.
153+
let (known_pc, expected_symbol) = capture_self_pc();
154+
let addr_hex = format!("{:#x}", known_pc);
155+
156+
// Test symbol resolution via GET (addresses in query string), GET without
157+
// addresses (just checks availability), and POST (addresses in body).
158+
let symbol_requests: Vec<(&str, String)> = vec![
159+
(
160+
"GET",
161+
format!("http://{server_addr}/pprof/symbol?{addr_hex}"),
162+
),
163+
(
164+
"GET (no addr)",
165+
format!("http://{server_addr}/pprof/symbol"),
166+
),
167+
("POST", format!("http://{server_addr}/pprof/symbol")),
168+
];
169+
170+
for (method, url) in &symbol_requests {
171+
let res = if *method == "POST" {
172+
reqwest::Client::new()
173+
.post(url)
174+
.body(addr_hex.clone())
175+
.send()
176+
.await
177+
} else {
178+
reqwest::get(url).await
179+
}
180+
.unwrap()
181+
.text()
182+
.await
183+
.unwrap();
184+
185+
eprintln!("pprof/symbol {method}: url={url}, expecting='{expected_symbol}'");
186+
eprintln!("pprof/symbol {method}: response:\n{res}");
187+
188+
assert!(
189+
res.contains("num_symbols: 1"),
190+
"pprof/symbol {method} should contain 'num_symbols: 1', got: {res}"
191+
);
192+
193+
if !method.contains("no addr") {
194+
assert!(
195+
res.contains(&expected_symbol),
196+
"pprof/symbol {method} should resolve to '{expected_symbol}', got: {res}"
197+
);
198+
let known_pc_str = format!("{:#x}", known_pc);
199+
assert!(
200+
res.contains(&known_pc_str),
201+
"pprof/symbol {method} should resolve to '{known_pc_str}', got: {res}"
202+
);
203+
}
204+
}
205+
124206
let telemetry_ctx = TelemetryContext::current();
125207
let _scope = telemetry_ctx.scope();
126208

0 commit comments

Comments
 (0)