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")]
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 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 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 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#[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#[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 }
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 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 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 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}