Skip to main content

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}