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#[cfg(feature = "profiling-memory-jemalloc")]
224fn start_memory_agent(
225    service_name: &str,
226    pyroscope_endpoint: &str,
227    tags: &[(&'static str, &str)],
228) -> Result<
229    Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
230    Box<dyn Error>,
231> {
232    use pyroscope::backend::jemalloc::jemalloc_backend;
233
234    // Turn sampling on now, if the consumer armed prof but left it inactive.
235    // Wrapped for the same reason as the agent construction below: reading the
236    // mallctl panics rather than erroring when jemalloc is not the allocator.
237    let activation = std::panic::catch_unwind(|| match jemalloc_pprof::PROF_CTL.as_ref() {
238        None => Err("jemalloc profiling not compiled into this binary".to_owned()),
239        Some(ctl) => {
240            let mut guard = ctl.blocking_lock();
241            if guard.activated() {
242                // Already active — the consumer set prof_active:true. It works
243                // on some targets, so this is not an error, but it is the
244                // configuration that crashes on x86_64 musl, and a process
245                // that reaches here has already survived it.
246                return Ok(());
247            }
248            guard.activate().map_err(|e| e.to_string())
249        }
250    });
251    match activation {
252        Ok(Ok(())) => {}
253        Ok(Err(e)) => {
254            tracing::warn!(
255                error = %e,
256                "jemalloc heap profiling unavailable — continuing without it; \
257                 set _RJEM_MALLOC_CONF=prof:true,prof_active:false and use jemalloc \
258                 as the global allocator"
259            );
260            return Ok(None);
261        }
262        Err(_) => {
263            tracing::warn!(
264                "jemalloc heap profiling unavailable — this process is not using \
265                 jemalloc as its global allocator; continuing without it"
266            );
267            return Ok(None);
268        }
269    }
270
271    // `catch_unwind`, not just error handling, because the failure is a panic.
272    // `jemalloc_pprof`'s `JemallocProfCtl::get` reads the `opt.prof` mallctl
273    // and `unwrap()`s it; when the process is not actually using jemalloc that
274    // read fails and the unwrap panics rather than returning an error we could
275    // match on. A binary that merely compiles this feature — every test binary
276    // in a consuming workspace, for one — links jemalloc_pprof without
277    // installing the allocator, so this is the normal case, not an edge one.
278    //
279    // Nothing here is left half-initialised by the unwind: the closure owns the
280    // backend and the partially-built agent, and both are dropped with it.
281    let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
282        pyroscope::pyroscope::PyroscopeAgentBuilder::new(
283            pyroscope_endpoint,
284            service_name,
285            100,
286            "pyroscope-rs",
287            env!("CARGO_PKG_VERSION"),
288            jemalloc_backend(),
289        )
290        .tags(tags.to_vec())
291        .build()
292    }));
293
294    let agent = match built {
295        Ok(Ok(agent)) => agent,
296        Ok(Err(e)) => {
297            tracing::warn!(
298                error = %e,
299                "jemalloc heap profiling unavailable — continuing without it; \
300                 check the global allocator is jemalloc and prof:true,prof_active:true is set"
301            );
302            return Ok(None);
303        }
304        Err(_) => {
305            tracing::warn!(
306                "jemalloc heap profiling unavailable — this process is not using \
307                 jemalloc as its global allocator; continuing without it"
308            );
309            return Ok(None);
310        }
311    };
312
313    match agent.start() {
314        Ok(running) => {
315            tracing::info!("jemalloc heap profiling started");
316            Ok(Some(running))
317        }
318        Err(e) => {
319            tracing::warn!(error = %e, "jemalloc heap profiling failed to start — continuing without it");
320            Ok(None)
321        }
322    }
323}
324
325/// No-op bridge for when profiling is enabled but the pyroscope feature is not.
326#[cfg(all(feature = "profiling", not(feature = "profiling-bridge-pyroscope-rs")))]
327pub(crate) fn start_pyroscope_bridge(
328    _service_name: &str,
329    _pyroscope_endpoint: &str,
330    _identity: &ProfilingIdentity,
331) -> Result<Option<ProfilingHandle>, Box<dyn Error>> {
332    Ok(None)
333}
334
335/// Tracing layer that tags active span enter/exit with trace_id and span_id
336/// in the running pyroscope agent.
337#[cfg(feature = "profiling-bridge-pyroscope-rs")]
338pub struct ProfilingTagLayer;
339
340#[cfg(feature = "profiling-bridge-pyroscope-rs")]
341impl<S> tracing_subscriber::Layer<S> for ProfilingTagLayer
342where
343    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
344{
345    fn on_enter(&self, _id: &tracing::span::Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
346        if let Some((add_tag, _)) = PROFILING_TAG_FNS.get() {
347            let cx = opentelemetry::Context::current();
348            let span_ref = cx.span();
349            let span_context = span_ref.span_context();
350            if span_context.is_valid() {
351                let trace_id = span_context.trace_id();
352                let span_id = span_context.span_id();
353                let _ = add_tag("trace_id".to_string(), format!("{trace_id:x}"));
354                let _ = add_tag("span_id".to_string(), format!("{span_id:x}"));
355            }
356        }
357    }
358
359    fn on_exit(&self, _id: &tracing::span::Id, _ctx: tracing_subscriber::layer::Context<'_, S>) {
360        if let Some((_, remove_tag)) = PROFILING_TAG_FNS.get() {
361            let cx = opentelemetry::Context::current();
362            let span_ref = cx.span();
363            let span_context = span_ref.span_context();
364            if span_context.is_valid() {
365                let trace_id = span_context.trace_id();
366                let span_id = span_context.span_id();
367                let _ = remove_tag("trace_id".to_string(), format!("{trace_id:x}"));
368                let _ = remove_tag("span_id".to_string(), format!("{span_id:x}"));
369            }
370        }
371    }
372}
373
374#[cfg(all(test, feature = "profiling-bridge-pyroscope-rs"))]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn start_bridge_with_nonexistent_server() {
380        let result = start_pyroscope_bridge(
381            "test-svc",
382            "http://localhost:4040",
383            &ProfilingIdentity::default(),
384        );
385        assert!(
386            result.is_ok(),
387            "pyroscope agent start() is lazy and does not eagerly connect"
388        );
389        if let Ok(Some(_handle)) = result {
390            // Bridge is active
391        }
392    }
393
394    #[test]
395    fn start_bridge_multiple_times_ignores_second() {
396        let result1 = start_pyroscope_bridge(
397            "test-svc-1",
398            "http://localhost:4040",
399            &ProfilingIdentity::default(),
400        );
401        assert!(result1.is_ok());
402        let result2 = start_pyroscope_bridge(
403            "test-svc-2",
404            "http://localhost:4041",
405            &ProfilingIdentity::default(),
406        );
407        assert!(result2.is_ok());
408        // Second call is a no-op: the `pprof` backend only supports one
409        // process-wide profiler guard, so the bridge returns `Ok(None)`.
410        assert!(result2.unwrap().is_none());
411    }
412
413    #[test]
414    fn validate_endpoint_accepts_loopback_ipv4() {
415        assert!(validate_pyroscope_endpoint("http://127.0.0.1:4040").is_ok());
416    }
417
418    #[test]
419    fn validate_endpoint_accepts_loopback_ipv6() {
420        // IPv6 literals in a URL authority must be bracketed (RFC 3986 §3.2.2).
421        assert!(validate_pyroscope_endpoint("http://[::1]:4040").is_ok());
422    }
423
424    #[test]
425    fn validate_endpoint_accepts_localhost() {
426        assert!(validate_pyroscope_endpoint("http://localhost:4040").is_ok());
427    }
428
429    #[test]
430    fn validate_endpoint_accepts_https_loopback() {
431        assert!(validate_pyroscope_endpoint("https://127.0.0.1:4040").is_ok());
432    }
433
434    #[test]
435    fn validate_endpoint_rejects_routable_ipv4() {
436        assert!(validate_pyroscope_endpoint("http://10.0.0.1:4040").is_err());
437    }
438
439    #[test]
440    fn validate_endpoint_rejects_userinfo_bypass() {
441        // Userinfo bypass: attacker tries to use loopback as userinfo but target evil.com
442        assert!(validate_pyroscope_endpoint("http://127.0.0.1:4040@evil.com/").is_err());
443    }
444
445    #[test]
446    fn validate_endpoint_rejects_userinfo_with_password() {
447        assert!(validate_pyroscope_endpoint("http://user:pass@localhost:4040").is_err());
448    }
449
450    #[test]
451    fn validate_endpoint_rejects_unix_socket_check() {
452        assert!(validate_pyroscope_endpoint("unix:///var/run/profiling.sock").is_ok());
453    }
454}
455
456#[cfg(all(
457    test,
458    feature = "profiling",
459    not(feature = "profiling-bridge-pyroscope-rs")
460))]
461mod tests_no_bridge {
462    use super::*;
463
464    #[test]
465    fn start_bridge_returns_none() {
466        let result = start_pyroscope_bridge(
467            "test-svc",
468            "http://localhost:4040",
469            &ProfilingIdentity::default(),
470        );
471        assert!(result.is_ok());
472        if let Ok(handle) = result {
473            assert!(handle.is_none());
474        }
475    }
476}