Skip to main content

otel_bootstrap/
profiling.rs

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