otel_bootstrap/profiling.rs
1#![cfg(feature = "profiling")]
2
3use std::error::Error;
4use std::sync::OnceLock;
5
6/// Validate that a pyroscope endpoint targets only loopback (per ADR platform/0203 AC1).
7/// Allowed: 127.0.0.1, ::1, localhost, unix socket paths.
8/// Rejects routable addresses to prevent unauthenticated plaintext profile data leaving the pod.
9fn validate_pyroscope_endpoint(endpoint: &str) -> Result<(), Box<dyn Error>> {
10 use url::Url;
11
12 // Unix socket paths are allowed
13 if endpoint.starts_with("unix://") {
14 return Ok(());
15 }
16
17 // HTTP/HTTPS endpoints must target loopback
18 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
19 let url = Url::parse(endpoint)?;
20
21 // Reject endpoints with userinfo (user:pass@host) to prevent redirect attacks
22 if !url.username().is_empty() || url.password().is_some() {
23 return Err(format!(
24 "pyroscope endpoint must not contain userinfo; got: {endpoint} (ADR platform/0203 AC1)"
25 ).into());
26 }
27
28 let host = url.host_str().unwrap_or("");
29
30 match host {
31 "127.0.0.1" | "::1" | "[::1]" | "localhost" => Ok(()),
32 _ => Err(format!(
33 "pyroscope endpoint must target loopback (127.0.0.1, ::1, localhost, or unix socket); \
34 got: {endpoint} (ADR platform/0203 AC1)"
35 ).into()),
36 }
37 } else {
38 Err(
39 format!("pyroscope endpoint must be http://, https://, or unix://; got: {endpoint}")
40 .into(),
41 )
42 }
43}
44
45/// Identity attached to every profile this process uploads.
46///
47/// Pyroscope stores a profile series per tag set. Without these, every replica
48/// of a service collapses into one unlabelled series: you cannot tell two pods
49/// apart, cannot follow one pod across a restart, and cannot line a profile up
50/// against the logs and metrics for the same instance.
51///
52/// Field names deliberately match the resource attributes exported on logs and
53/// traces (`host_name`, `deployment_environment`, `service_version`) so the
54/// same value joins across all three signals without translation.
55#[derive(Debug, Clone, Default)]
56pub(crate) struct ProfilingIdentity {
57 /// Host name — the pod name under Kubernetes.
58 pub host_name: Option<String>,
59 /// Deployment environment, e.g. `prod`.
60 pub deployment_environment: Option<String>,
61 /// Service version.
62 pub service_version: Option<String>,
63}
64
65#[cfg(feature = "profiling-bridge-pyroscope-rs")]
66impl ProfilingIdentity {
67 /// Flatten to the `(key, value)` pairs the pyroscope builder takes.
68 ///
69 /// Absent fields are omitted rather than emitted empty: an empty tag value
70 /// still forks the series, which is the precise problem this exists to
71 /// avoid.
72 fn tag_pairs(&self) -> Vec<(&'static str, &str)> {
73 let mut pairs = Vec::new();
74 if let Some(host) = self.host_name.as_deref().filter(|s| !s.is_empty()) {
75 pairs.push(("host_name", host));
76 }
77 if let Some(env) = self
78 .deployment_environment
79 .as_deref()
80 .filter(|s| !s.is_empty())
81 {
82 pairs.push(("deployment_environment", env));
83 }
84 if let Some(version) = self.service_version.as_deref().filter(|s| !s.is_empty()) {
85 pairs.push(("service_version", version));
86 }
87 pairs
88 }
89}
90
91/// Profiling bridge handle. Owns the active profiling agents and ensures
92/// graceful shutdown on drop.
93pub struct ProfilingHandle {
94 /// CPU profiler (`pprof` backend).
95 #[cfg(feature = "profiling-bridge-pyroscope-rs")]
96 agent: Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
97 /// Heap profiler (jemalloc backend).
98 ///
99 /// A separate agent because `PyroscopeAgentBuilder` takes exactly one
100 /// backend, and the two sample different things: `pprof` samples on-CPU
101 /// time, jemalloc samples allocations. A process stalled off-CPU produces
102 /// an empty CPU profile while still allocating, so the heap agent is the
103 /// one that has anything to say in that case.
104 #[cfg(feature = "profiling-memory-jemalloc")]
105 memory_agent: Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
106}
107
108#[cfg(feature = "profiling-bridge-pyroscope-rs")]
109impl Drop for ProfilingHandle {
110 fn drop(&mut self) {
111 if let Some(agent) = self.agent.take() {
112 let _ = agent.stop();
113 }
114 #[cfg(feature = "profiling-memory-jemalloc")]
115 if let Some(agent) = self.memory_agent.take() {
116 let _ = agent.stop();
117 }
118 }
119}
120
121/// Guards against starting more than one profiling agent per process.
122/// The `pprof` backend keeps a single process-wide profiler guard, so a
123/// second concurrent agent would fail to start; subsequent calls are
124/// treated as no-ops rather than errors.
125#[cfg(feature = "profiling-bridge-pyroscope-rs")]
126static PROFILING_STARTED: OnceLock<()> = OnceLock::new();
127
128/// Start the pyroscope profiling bridge.
129///
130/// The bridge pushes profiles over plain HTTP/loopback to a local SPIFFE-terminating
131/// sidecar (or an already-mTLS'd endpoint reachable without client-side TLS material).
132/// pyroscope-rs hardcodes its own HTTP client internally with no hook
133/// for custom TLS/identity, so in-process mTLS is not possible; the sidecar carries
134/// the workload identity upstream.
135///
136/// **Temporary exception** (Tracks #40): This bridge is a sunset-bound interim implementation
137/// pending a native Rust OTLP profiles exporter. See ADR platform/0202 and issue #40.
138#[cfg(feature = "profiling-bridge-pyroscope-rs")]
139pub(crate) fn start_pyroscope_bridge(
140 service_name: &str,
141 pyroscope_endpoint: &str,
142 identity: &ProfilingIdentity,
143) -> Result<Option<ProfilingHandle>, Box<dyn Error>> {
144 use pyroscope::backend::{BackendConfig, PprofConfig, pprof_backend};
145
146 // Validate endpoint targets loopback only (ADR platform/0203 AC1)
147 validate_pyroscope_endpoint(pyroscope_endpoint)?;
148
149 // The `pprof` backend holds a single process-wide profiler guard, so the
150 // bridge starts at most once; ignore subsequent start attempts.
151 if PROFILING_STARTED.set(()).is_err() {
152 return Ok(None);
153 }
154
155 let tags = identity.tag_pairs();
156
157 let agent = pyroscope::pyroscope::PyroscopeAgentBuilder::new(
158 pyroscope_endpoint,
159 service_name,
160 100,
161 "pyroscope-rs",
162 env!("CARGO_PKG_VERSION"),
163 pprof_backend(PprofConfig { sample_rate: 100 }, BackendConfig::default()),
164 )
165 .tags(tags.clone())
166 .build()?
167 .start()?;
168
169 // Deliberately NOT calling `agent.tag_wrapper()`. See [`ProfilingTagLayer`]:
170 // every tag call rebuilds and clears the whole profile, which both leaked
171 // memory and emptied the profiles it was meant to enrich.
172
173 Ok(Some(ProfilingHandle {
174 agent: Some(agent),
175 #[cfg(feature = "profiling-memory-jemalloc")]
176 memory_agent: start_memory_agent(service_name, pyroscope_endpoint, &tags)?,
177 }))
178}
179
180/// Start the jemalloc heap-profiling agent.
181///
182/// Returns `Ok(None)` — never an error — when heap profiling is unavailable.
183/// The backend needs the process to use jemalloc as its global allocator and
184/// to have been built with profiling support; neither is visible at compile
185/// time, and a binary that merely links this feature must still boot normally
186/// without it. Losing heap profiles is an observability regression, not a
187/// reason to fail service startup.
188///
189/// ## Arm inactive, activate here
190///
191/// Consumers should set `_RJEM_MALLOC_CONF=prof:true,prof_active:false` and
192/// let this function turn sampling on. **Do not set `prof_active:true`.**
193///
194/// On x86_64 static musl, arming profiling at process start segfaults before
195/// `main` runs. Isolated on a real service image, same host, only the env var
196/// differing:
197///
198/// ```text
199/// prof:true,prof_active:true -> exit 139 (SIGSEGV)
200/// prof:true,prof_active:true,lg_prof_sample:30 -> exit 139 (SIGSEGV)
201/// prof:true,prof_active:false -> runs clean
202/// ```
203///
204/// `lg_prof_sample:30` samples roughly once per gigabyte and the probe never
205/// allocated near that, so the fault is in activation itself rather than in
206/// walking a sampled allocation's backtrace. Activating from here instead runs
207/// after the runtime is fully initialised.
208///
209/// Activation failure is non-fatal for the same reason as everything else in
210/// this path: CPU profiling continues, and the service boots.
211/// Turn jemalloc sampling on, if the consumer armed `prof` but left it inactive.
212///
213/// Split out of [`start_memory_agent`] so it can be exercised directly by the
214/// `heap-probe` binary: this is the whole of what runs before any Pyroscope
215/// endpoint is involved, and it is where both shipped profiling defects lived.
216///
217/// The outer `Result` is `Err` when the call panicked rather than failed —
218/// reading the mallctl panics rather than erroring when jemalloc is not the
219/// process allocator.
220///
221/// ## Why not `blocking_lock`
222///
223/// `PROF_CTL` is a `tokio::sync::Mutex`, and callers reach this from inside a
224/// runtime — `with_profiling()` runs during service bootstrap. `blocking_lock`
225/// panics with "Cannot block the current thread from within a runtime", which
226/// 2.12.0 shipped: the panic was caught, heap profiling silently never armed,
227/// and the service looked healthy. `try_lock` is correct rather than merely
228/// panic-free, because activation happens once at startup with nothing else
229/// holding the lock; there is no contention to wait out.
230#[cfg(feature = "profiling-memory-jemalloc")]
231#[doc(hidden)]
232pub fn activate_jemalloc_sampling() -> SamplingActivation {
233 let caught = std::panic::catch_unwind(|| match jemalloc_pprof::PROF_CTL.as_ref() {
234 None => Err("jemalloc profiling not compiled into this binary".to_owned()),
235 Some(ctl) => {
236 let Ok(mut guard) = ctl.try_lock() else {
237 return Err(
238 "jemalloc profiling control is held elsewhere; sampling not activated"
239 .to_owned(),
240 );
241 };
242 if guard.activated() {
243 // Already active — the consumer set prof_active:true. It works
244 // on some targets, so this is not an error, but it is the
245 // configuration that crashes on x86_64 musl, and a process
246 // that reaches here has already survived it.
247 return Ok(());
248 }
249 guard.activate().map_err(|e| e.to_string())
250 }
251 });
252 match caught {
253 Ok(Ok(())) => SamplingActivation::Activated,
254 Ok(Err(e)) => SamplingActivation::Unavailable(e),
255 Err(_) => SamplingActivation::Panicked,
256 }
257}
258
259/// Outcome of [`activate_jemalloc_sampling`].
260///
261/// `Panicked` is a distinct variant rather than folded into `Unavailable`
262/// because the two call for different responses: `Unavailable` is a
263/// configuration the operator can correct, while `Panicked` means the process
264/// is not the one this code assumes it is running in.
265#[cfg(feature = "profiling-memory-jemalloc")]
266#[doc(hidden)]
267#[derive(Debug)]
268pub enum SamplingActivation {
269 /// Sampling is on.
270 Activated,
271 /// Sampling could not be turned on, with the reason.
272 Unavailable(String),
273 /// Reading the mallctl panicked — jemalloc is not this process's allocator.
274 Panicked,
275}
276
277#[cfg(feature = "profiling-memory-jemalloc")]
278fn start_memory_agent(
279 service_name: &str,
280 pyroscope_endpoint: &str,
281 tags: &[(&'static str, &str)],
282) -> Result<
283 Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
284 Box<dyn Error>,
285> {
286 use pyroscope::backend::jemalloc::jemalloc_backend;
287
288 match activate_jemalloc_sampling() {
289 SamplingActivation::Activated => {}
290 SamplingActivation::Unavailable(e) => {
291 tracing::warn!(
292 error = %e,
293 "jemalloc heap profiling unavailable — continuing without it; \
294 set _RJEM_MALLOC_CONF=prof:true,prof_active:false and use jemalloc \
295 as the global allocator"
296 );
297 return Ok(None);
298 }
299 SamplingActivation::Panicked => {
300 tracing::warn!(
301 "jemalloc heap profiling unavailable — this process is not using \
302 jemalloc as its global allocator; continuing without it"
303 );
304 return Ok(None);
305 }
306 }
307
308 // `catch_unwind`, not just error handling, because the failure is a panic.
309 // `jemalloc_pprof`'s `JemallocProfCtl::get` reads the `opt.prof` mallctl
310 // and `unwrap()`s it; when the process is not actually using jemalloc that
311 // read fails and the unwrap panics rather than returning an error we could
312 // match on. A binary that merely compiles this feature — every test binary
313 // in a consuming workspace, for one — links jemalloc_pprof without
314 // installing the allocator, so this is the normal case, not an edge one.
315 //
316 // Nothing here is left half-initialised by the unwind: the closure owns the
317 // backend and the partially-built agent, and both are dropped with it.
318 let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
319 pyroscope::pyroscope::PyroscopeAgentBuilder::new(
320 pyroscope_endpoint,
321 service_name,
322 100,
323 "pyroscope-rs",
324 env!("CARGO_PKG_VERSION"),
325 jemalloc_backend(),
326 )
327 .tags(tags.to_vec())
328 .build()
329 }));
330
331 let agent = match built {
332 Ok(Ok(agent)) => agent,
333 Ok(Err(e)) => {
334 tracing::warn!(
335 error = %e,
336 "jemalloc heap profiling unavailable — continuing without it; \
337 check the global allocator is jemalloc and prof:true,prof_active:true is set"
338 );
339 return Ok(None);
340 }
341 Err(_) => {
342 tracing::warn!(
343 "jemalloc heap profiling unavailable — this process is not using \
344 jemalloc as its global allocator; continuing without it"
345 );
346 return Ok(None);
347 }
348 };
349
350 match agent.start() {
351 Ok(running) => {
352 tracing::info!("jemalloc heap profiling started");
353 Ok(Some(running))
354 }
355 Err(e) => {
356 tracing::warn!(error = %e, "jemalloc heap profiling failed to start — continuing without it");
357 Ok(None)
358 }
359 }
360}
361
362/// No-op bridge for when profiling is enabled but the pyroscope feature is not.
363#[cfg(all(feature = "profiling", not(feature = "profiling-bridge-pyroscope-rs")))]
364pub(crate) fn start_pyroscope_bridge(
365 _service_name: &str,
366 _pyroscope_endpoint: &str,
367 _identity: &ProfilingIdentity,
368) -> Result<Option<ProfilingHandle>, Box<dyn Error>> {
369 Ok(None)
370}
371
372/// Inert. Was: a tracing layer that tagged the running pyroscope agent with
373/// `trace_id`/`span_id` on every span enter and exit.
374///
375/// # Why this does nothing
376///
377/// Per-span tagging is not implementable against the `pprof` backend, and
378/// enabling it was strictly worse than having no correlation at all. In
379/// pyroscope-rs the backend's own comment calls `dump_report` a *"workaround
380/// for pprof-rs to interrupt the profiler"*, and both `Backend::add_tag` and
381/// `Backend::remove_tag` call it unconditionally. One `dump_report` symbolises
382/// every entry currently in the collector — `backtrace::resolve` per frame,
383/// building a fresh `Vec<Vec<Symbol>>` with a `String` and a `PathBuf` per
384/// symbol — and then clears the collector.
385///
386/// So each span enter cost two full profile rebuilds, and each exit two more.
387/// Measured on a static-musl build at the shipped 100 Hz sample rate, driving
388/// spans from two threads:
389///
390/// ```text
391/// t=10s dumps=868726 clears=868725 sessions=1 collector_entries=0
392/// ```
393///
394/// ~87,000 profile rebuilds per second against one 10-second upload. Two
395/// consequences, and the second is why this is not merely a tuning problem:
396///
397/// 1. The allocation churn grew RSS without bound. Over 90 minutes on static
398/// musl the leak was 7.4 MiB/h with this layer and 1.0 MiB/h without, which
399/// matches the 6.5 MiB/h observed on brefwiz-spiffe in production, where it
400/// OOM-killed both replicas roughly every 5.5 hours against a 512Mi cgroup.
401/// 2. `collector_entries=0` at *every* upload: the collector was cleared far
402/// faster than the 100 Hz sampler could fill it, so the profiles this layer
403/// existed to enrich were arriving essentially empty. The correlation
404/// feature destroyed the very data it annotated.
405///
406/// Sampling itself is not implicated — with tagging off, 63,254 samples over
407/// 420 seconds moved RSS by 0.03 MiB/h.
408///
409/// # What replaces it
410///
411/// Nothing, on this backend: there is no bounded form. The cost is per tag
412/// call, so sampling spans or tagging only roots still buys full profile
413/// rebuilds at a fraction of the span rate, and each rebuild still truncates
414/// the sample window. Trace/profile correlation returns with the native OTLP
415/// profiles exporter this bridge is already sunset-bound against.
416///
417/// The type is kept, registered, and doing nothing so the subscriber stack and
418/// the public surface are unchanged; it is removed in the next major.
419#[cfg(feature = "profiling-bridge-pyroscope-rs")]
420#[deprecated(
421 since = "2.15.0",
422 note = "inert: per-span pyroscope tagging leaked memory and emptied profiles; \
423 correlation returns with the OTLP profiles exporter"
424)]
425pub struct ProfilingTagLayer;
426
427#[cfg(feature = "profiling-bridge-pyroscope-rs")]
428#[allow(deprecated)]
429impl<S> tracing_subscriber::Layer<S> for ProfilingTagLayer where
430 S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>
431{
432}
433
434#[cfg(all(test, feature = "profiling-bridge-pyroscope-rs"))]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn start_bridge_with_nonexistent_server() {
440 let result = start_pyroscope_bridge(
441 "test-svc",
442 "http://localhost:4040",
443 &ProfilingIdentity::default(),
444 );
445 assert!(
446 result.is_ok(),
447 "pyroscope agent start() is lazy and does not eagerly connect"
448 );
449 if let Ok(Some(_handle)) = result {
450 // Bridge is active
451 }
452 }
453
454 #[test]
455 fn start_bridge_multiple_times_ignores_second() {
456 let result1 = start_pyroscope_bridge(
457 "test-svc-1",
458 "http://localhost:4040",
459 &ProfilingIdentity::default(),
460 );
461 assert!(result1.is_ok());
462 let result2 = start_pyroscope_bridge(
463 "test-svc-2",
464 "http://localhost:4041",
465 &ProfilingIdentity::default(),
466 );
467 assert!(result2.is_ok());
468 // Second call is a no-op: the `pprof` backend only supports one
469 // process-wide profiler guard, so the bridge returns `Ok(None)`.
470 assert!(result2.unwrap().is_none());
471 }
472
473 #[test]
474 fn validate_endpoint_accepts_loopback_ipv4() {
475 assert!(validate_pyroscope_endpoint("http://127.0.0.1:4040").is_ok());
476 }
477
478 #[test]
479 fn validate_endpoint_accepts_loopback_ipv6() {
480 // IPv6 literals in a URL authority must be bracketed (RFC 3986 §3.2.2).
481 assert!(validate_pyroscope_endpoint("http://[::1]:4040").is_ok());
482 }
483
484 #[test]
485 fn validate_endpoint_accepts_localhost() {
486 assert!(validate_pyroscope_endpoint("http://localhost:4040").is_ok());
487 }
488
489 #[test]
490 fn validate_endpoint_accepts_https_loopback() {
491 assert!(validate_pyroscope_endpoint("https://127.0.0.1:4040").is_ok());
492 }
493
494 #[test]
495 fn validate_endpoint_rejects_routable_ipv4() {
496 assert!(validate_pyroscope_endpoint("http://10.0.0.1:4040").is_err());
497 }
498
499 #[test]
500 fn validate_endpoint_rejects_userinfo_bypass() {
501 // Userinfo bypass: attacker tries to use loopback as userinfo but target evil.com
502 assert!(validate_pyroscope_endpoint("http://127.0.0.1:4040@evil.com/").is_err());
503 }
504
505 #[test]
506 fn validate_endpoint_rejects_userinfo_with_password() {
507 assert!(validate_pyroscope_endpoint("http://user:pass@localhost:4040").is_err());
508 }
509
510 #[test]
511 fn validate_endpoint_rejects_unix_socket_check() {
512 assert!(validate_pyroscope_endpoint("unix:///var/run/profiling.sock").is_ok());
513 }
514}
515
516#[cfg(all(
517 test,
518 feature = "profiling",
519 not(feature = "profiling-bridge-pyroscope-rs")
520))]
521mod tests_no_bridge {
522 use super::*;
523
524 #[test]
525 fn start_bridge_returns_none() {
526 let result = start_pyroscope_bridge(
527 "test-svc",
528 "http://localhost:4040",
529 &ProfilingIdentity::default(),
530 );
531 assert!(result.is_ok());
532 if let Ok(handle) = result {
533 assert!(handle.is_none());
534 }
535 }
536}