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
9fn validate_pyroscope_endpoint(endpoint: &str) -> Result<(), Box<dyn Error>> {
13 use url::Url;
14
15 if endpoint.starts_with("unix://") {
17 return Ok(());
18 }
19
20 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
22 let url = Url::parse(endpoint)?;
23
24 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#[derive(Debug, Clone, Default)]
59pub(crate) struct ProfilingIdentity {
60 pub host_name: Option<String>,
62 pub deployment_environment: Option<String>,
64 pub service_version: Option<String>,
66}
67
68#[cfg(feature = "profiling-bridge-pyroscope-rs")]
69impl ProfilingIdentity {
70 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
94pub struct ProfilingHandle {
97 #[cfg(feature = "profiling-bridge-pyroscope-rs")]
99 agent: Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
100 #[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#[cfg(feature = "profiling-bridge-pyroscope-rs")]
130static PROFILING_TAG_FNS: OnceLock<(BoxedTagFn, BoxedTagFn)> = OnceLock::new();
131
132#[cfg(feature = "profiling-bridge-pyroscope-rs")]
137static PROFILING_STARTED: OnceLock<()> = OnceLock::new();
138
139#[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_pyroscope_endpoint(pyroscope_endpoint)?;
159
160 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#[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 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#[cfg(feature = "profiling-memory-jemalloc")]
278#[doc(hidden)]
279#[derive(Debug)]
280pub enum SamplingActivation {
281 Activated,
283 Unavailable(String),
285 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 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#[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#[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 }
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 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 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 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}