Skip to main content

scirs2_core/profiling/
tracy.rs

1//! Tracy-API-compatible profiler integration.
2//!
3//! Enable the `tracy` cargo feature to activate a **Pure Rust** trace-event
4//! recorder that captures spans, frame marks, and log messages into a
5//! process-global, in-memory event log and can export them to the
6//! [Chrome Trace Event Format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
7//! (also understood by [Perfetto](https://ui.perfetto.dev) and
8//! `chrome://tracing`) via [`TracyClient::export_chrome_trace`].
9//!
10//! This backend deliberately does **not** link the upstream C++ Tracy
11//! client: it is built entirely from `std` plus the two dependencies
12//! `scirs2-core` already carries unconditionally (`once_cell`,
13//! `parking_lot`), so enabling the `tracy` feature never triggers a C/C++
14//! compilation step. Without the feature, all types and functions compile
15//! to zero-cost no-ops with no external dependencies, exactly as before.
16//!
17//! # Important characteristics
18//!
19//! * The process-global event buffer (`TRACE_EVENTS`) grows **unbounded**
20//!   for the lifetime of the process while the `tracy` feature is enabled.
21//!   This backend is intended for bounded profiling sessions (start the
22//!   client, do some work, export, stop the process) — not for
23//!   long-running services that never restart. A ring-buffer/size-cap
24//!   scheme would be a reasonable follow-up if long-lived usage is needed.
25//! * Thread identifiers (`tid` in the exported JSON) are small
26//!   process-local monotonic integers assigned the first time a thread
27//!   touches the profiler. They are **not** OS thread ids, and will not
28//!   match what tools like `ps -T` or an external tracer report.
29//!
30//! # Usage
31//!
32//! ```rust,no_run
33//! use scirs2_core::profiling::tracy::TracyClient;
34//!
35//! let client = TracyClient::new();
36//! if client.is_active() {
37//!     client.message("profiling enabled");
38//! }
39//! {
40//!     let _span = client.span("my_operation");
41//!     // work here
42//! } // span ends on drop
43//!
44//! // Export the recorded events (valid even when the `tracy` feature is
45//! // disabled -- it just writes an empty-but-valid trace document).
46//! let _ = client.export_chrome_trace(std::env::temp_dir().join("trace.json"));
47//! ```
48
49#[cfg(feature = "tracy")]
50use std::cell::Cell;
51#[cfg(feature = "tracy")]
52use std::sync::atomic::{AtomicU64, Ordering};
53#[cfg(feature = "tracy")]
54use std::time::Instant;
55
56#[cfg(feature = "tracy")]
57use once_cell::sync::Lazy;
58#[cfg(feature = "tracy")]
59use parking_lot::Mutex;
60
61// ---------------------------------------------------------------------------
62// Process-global trace state (only compiled when `tracy` is enabled)
63// ---------------------------------------------------------------------------
64
65/// A single Chrome Trace Event Format event.
66///
67/// See the [Chrome Trace Event Format
68/// spec](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
69/// for the meaning of each field.
70#[cfg(feature = "tracy")]
71#[derive(Debug, Clone)]
72struct TraceEvent {
73    /// Event name shown in the timeline.
74    name: String,
75    /// Event category (e.g. "zone", "frame", "log").
76    category: &'static str,
77    /// Event phase: `'X'` for complete (duration) events, `'i'` for instants.
78    ph: char,
79    /// Start timestamp in microseconds, relative to process start.
80    ts_micros: f64,
81    /// Duration in microseconds, only present for complete (`'X'`) events.
82    dur_micros: Option<f64>,
83    /// Process-local, process-lifetime-stable thread id (NOT the OS tid).
84    tid: u64,
85    /// OS process id.
86    pid: u32,
87    /// Optional free-form text payload (used by [`TracyClient::message`]).
88    arg: Option<String>,
89}
90
91/// Monotonic epoch captured the first time the profiler is touched. All
92/// event timestamps are recorded relative to this instant.
93#[cfg(feature = "tracy")]
94static PROCESS_START: Lazy<Instant> = Lazy::new(Instant::now);
95
96/// Process-global, mutex-protected event log.
97///
98/// Deliberately unbounded: see the module-level documentation for the
99/// implications of long-running processes.
100#[cfg(feature = "tracy")]
101static TRACE_EVENTS: Lazy<Mutex<Vec<TraceEvent>>> = Lazy::new(|| Mutex::new(Vec::new()));
102
103/// Counter used to hand out small monotonic per-thread ids.
104#[cfg(feature = "tracy")]
105static NEXT_TID: AtomicU64 = AtomicU64::new(0);
106
107#[cfg(feature = "tracy")]
108thread_local! {
109    /// Cached process-local thread id for the current OS thread, assigned
110    /// lazily on first use.
111    static THREAD_TID: Cell<Option<u64>> = const { Cell::new(None) };
112}
113
114/// Returns a small, process-local, monotonically-assigned integer id for
115/// the calling thread. Stable for the lifetime of the thread, but **not**
116/// the OS thread id (`std::thread::ThreadId` has no stable integer
117/// accessor, so we roll our own).
118#[cfg(feature = "tracy")]
119fn current_tid() -> u64 {
120    THREAD_TID.with(|cell| {
121        if let Some(tid) = cell.get() {
122            tid
123        } else {
124            let tid = NEXT_TID.fetch_add(1, Ordering::Relaxed);
125            cell.set(Some(tid));
126            tid
127        }
128    })
129}
130
131/// Records a single event into the process-global trace buffer.
132#[cfg(feature = "tracy")]
133fn record_event(event: TraceEvent) {
134    TRACE_EVENTS.lock().push(event);
135}
136
137/// Escapes a string for embedding in a JSON string literal.
138///
139/// Escapes backslash, double quote, and control characters (`< 0x20`),
140/// using the standard short escapes for `\n`, `\r`, and `\t`, and
141/// `\u00XX` for everything else in that range.
142#[cfg(feature = "tracy")]
143fn json_escape(s: &str) -> String {
144    let mut out = String::with_capacity(s.len() + 2);
145    for c in s.chars() {
146        match c {
147            '\\' => out.push_str("\\\\"),
148            '"' => out.push_str("\\\""),
149            '\n' => out.push_str("\\n"),
150            '\r' => out.push_str("\\r"),
151            '\t' => out.push_str("\\t"),
152            c if (c as u32) < 0x20 => {
153                out.push_str(&format!("\\u{:04x}", c as u32));
154            }
155            c => out.push(c),
156        }
157    }
158    out
159}
160
161/// Serializes a single [`TraceEvent`] as a JSON object (no trailing comma).
162#[cfg(feature = "tracy")]
163fn serialize_event(event: &TraceEvent) -> String {
164    let mut obj = String::new();
165    obj.push('{');
166    obj.push_str(&format!("\"name\":\"{}\",", json_escape(&event.name)));
167    obj.push_str(&format!("\"cat\":\"{}\",", json_escape(event.category)));
168    obj.push_str(&format!("\"ph\":\"{}\",", event.ph));
169    obj.push_str(&format!("\"ts\":{},", event.ts_micros));
170    if let Some(dur) = event.dur_micros {
171        obj.push_str(&format!("\"dur\":{dur},"));
172    }
173    obj.push_str(&format!("\"pid\":{},", event.pid));
174    obj.push_str(&format!("\"tid\":{}", event.tid));
175    if event.ph == 'i' {
176        obj.push_str(",\"s\":\"g\"");
177    }
178    if let Some(arg) = &event.arg {
179        obj.push_str(&format!(
180            ",\"args\":{{\"message\":\"{}\"}}",
181            json_escape(arg)
182        ));
183    }
184    obj.push('}');
185    obj
186}
187
188/// Serializes the full Chrome Trace Event Format document for the given
189/// events (a `traceEvents` array plus the `displayTimeUnit` field).
190#[cfg(feature = "tracy")]
191fn serialize_document(events: &[TraceEvent]) -> String {
192    let mut doc = String::from("{\"traceEvents\":[");
193    for (i, event) in events.iter().enumerate() {
194        if i > 0 {
195            doc.push(',');
196        }
197        doc.push_str(&serialize_event(event));
198    }
199    doc.push_str("],\"displayTimeUnit\":\"ns\"}");
200    doc
201}
202
203/// Empty-but-valid Chrome Trace Event Format document, used when the
204/// `tracy` feature is disabled (or, trivially, when no events have been
205/// recorded yet).
206const EMPTY_TRACE_DOCUMENT: &str = "{\"traceEvents\":[],\"displayTimeUnit\":\"ns\"}";
207
208// ---------------------------------------------------------------------------
209// Tracy span RAII guard
210// ---------------------------------------------------------------------------
211
212/// A profiling span that is emitted to the trace-event log when dropped.
213///
214/// Obtain one via [`TracyClient::span`].
215pub struct TracySpan {
216    #[cfg(feature = "tracy")]
217    name: String,
218    #[cfg(feature = "tracy")]
219    start: Instant,
220    #[cfg(not(feature = "tracy"))]
221    _phantom: (),
222}
223
224#[cfg(feature = "tracy")]
225impl Drop for TracySpan {
226    fn drop(&mut self) {
227        let dur = self.start.elapsed();
228        let ts_micros = self.start.duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
229        record_event(TraceEvent {
230            name: std::mem::take(&mut self.name),
231            category: "zone",
232            ph: 'X',
233            ts_micros,
234            dur_micros: Some(dur.as_secs_f64() * 1_000_000.0),
235            tid: current_tid(),
236            pid: std::process::id(),
237            arg: None,
238        });
239    }
240}
241
242// ---------------------------------------------------------------------------
243// TracyClient
244// ---------------------------------------------------------------------------
245
246/// Handle to the (Pure Rust) trace-event profiler client.
247///
248/// Construct once at application start with [`TracyClient::new`] and keep
249/// the handle alive for the duration of profiling.  All methods are safe
250/// no-ops when the `tracy` feature is not enabled.
251pub struct TracyClient {
252    active: bool,
253}
254
255impl TracyClient {
256    /// Initialise the profiler client.
257    ///
258    /// When the `tracy` feature is enabled this forces initialisation of
259    /// the process-global trace-event buffer and epoch clock. When the
260    /// feature is absent this is a pure no-op constructor.
261    pub fn new() -> Self {
262        #[cfg(feature = "tracy")]
263        {
264            // Force initialisation of the global statics, matching the old
265            // "starts the underlying runtime" semantics.
266            Lazy::force(&PROCESS_START);
267            Lazy::force(&TRACE_EVENTS);
268            TracyClient { active: true }
269        }
270        #[cfg(not(feature = "tracy"))]
271        {
272            TracyClient { active: false }
273        }
274    }
275
276    /// Returns `true` when profiling is active (i.e. the `tracy` feature
277    /// is enabled and the client was started successfully).
278    #[inline]
279    pub fn is_active(&self) -> bool {
280        self.active
281    }
282
283    /// Begin a named profiling zone.  The returned [`TracySpan`] ends the
284    /// zone (and records a complete `'X'` trace event) when dropped.
285    ///
286    /// When the `tracy` feature is disabled this is a zero-cost no-op.
287    #[inline]
288    pub fn span(&self, name: &str) -> TracySpan {
289        #[cfg(feature = "tracy")]
290        {
291            TracySpan {
292                name: name.to_owned(),
293                start: Instant::now(),
294            }
295        }
296        #[cfg(not(feature = "tracy"))]
297        {
298            let _ = name;
299            TracySpan { _phantom: () }
300        }
301    }
302
303    /// Mark a named frame boundary.
304    ///
305    /// Recorded as an instant (`'i'`) trace event with global scope. When
306    /// the `tracy` feature is disabled this is a no-op.
307    #[inline]
308    pub fn frame_mark(&self, name: &str) {
309        #[cfg(feature = "tracy")]
310        {
311            let ts_micros =
312                Instant::now().duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
313            record_event(TraceEvent {
314                name: format!("frame: {name}"),
315                category: "frame",
316                ph: 'i',
317                ts_micros,
318                dur_micros: None,
319                tid: current_tid(),
320                pid: std::process::id(),
321                arg: None,
322            });
323        }
324        #[cfg(not(feature = "tracy"))]
325        let _ = name;
326    }
327
328    /// Emit a free-form message to the trace-event log.
329    ///
330    /// Recorded as an instant (`'i'`) trace event carrying the message as
331    /// an argument payload. When the `tracy` feature is disabled this is
332    /// a no-op.
333    #[inline]
334    pub fn message(&self, msg: &str) {
335        #[cfg(feature = "tracy")]
336        {
337            let ts_micros =
338                Instant::now().duration_since(*PROCESS_START).as_secs_f64() * 1_000_000.0;
339            record_event(TraceEvent {
340                name: "message".to_owned(),
341                category: "log",
342                ph: 'i',
343                ts_micros,
344                dur_micros: None,
345                tid: current_tid(),
346                pid: std::process::id(),
347                arg: Some(msg.to_owned()),
348            });
349        }
350        #[cfg(not(feature = "tracy"))]
351        let _ = msg;
352    }
353
354    /// Export all recorded trace events as a [Chrome Trace Event
355    /// Format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
356    /// JSON document, viewable at <https://ui.perfetto.dev> or
357    /// `chrome://tracing`.
358    ///
359    /// This method is always available and always safe to call: when the
360    /// `tracy` feature is disabled it writes the valid-but-empty document
361    /// `{"traceEvents":[],"displayTimeUnit":"ns"}` rather than being
362    /// conditionally compiled out, so callers never need feature guards
363    /// around the export call itself.
364    pub fn export_chrome_trace(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
365        use std::io::Write;
366
367        #[cfg(feature = "tracy")]
368        let document = {
369            let events = TRACE_EVENTS.lock().clone();
370            serialize_document(&events)
371        };
372        #[cfg(not(feature = "tracy"))]
373        let document = EMPTY_TRACE_DOCUMENT.to_owned();
374
375        let mut file = std::fs::File::create(path)?;
376        file.write_all(document.as_bytes())?;
377        Ok(())
378    }
379}
380
381impl Default for TracyClient {
382    fn default() -> Self {
383        Self::new()
384    }
385}
386
387// ---------------------------------------------------------------------------
388// Macro convenience
389// ---------------------------------------------------------------------------
390
391/// Create a Tracy span in the current scope.
392///
393/// The span ends when the binding goes out of scope.
394///
395/// # Examples
396///
397/// ```rust,no_run
398/// use scirs2_core::profiling::tracy::TracyClient;
399/// use scirs2_core::tracy_span;
400///
401/// let client = TracyClient::new();
402/// tracy_span!(client, "my_operation");
403/// // work here — span ends at end of block
404/// ```
405#[macro_export]
406macro_rules! tracy_span {
407    ($client:expr, $name:expr) => {
408        let _tracy_span_guard = $client.span($name);
409    };
410}
411
412// ---------------------------------------------------------------------------
413// Tests
414// ---------------------------------------------------------------------------
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_tracy_client_default_features() {
422        // Must succeed regardless of whether the `tracy` feature is enabled.
423        let client = TracyClient::new();
424
425        // Without the `tracy` feature (default), the client should be inactive.
426        #[cfg(not(feature = "tracy"))]
427        assert!(
428            !client.is_active(),
429            "TracyClient should be inactive without the tracy feature"
430        );
431
432        // With the `tracy` feature active, the client should report active.
433        #[cfg(feature = "tracy")]
434        assert!(
435            client.is_active(),
436            "TracyClient should be active with the tracy feature"
437        );
438    }
439
440    #[test]
441    fn test_tracy_span_drop() {
442        let client = TracyClient::new();
443        {
444            let _span = client.span("test_span_drop");
445            // span is live here
446        }
447        // span dropped — no panic
448    }
449
450    #[test]
451    fn test_tracy_frame_mark() {
452        let client = TracyClient::new();
453        // Must not panic regardless of feature flag.
454        client.frame_mark("test_frame");
455    }
456
457    #[test]
458    fn test_tracy_message() {
459        let client = TracyClient::new();
460        // Must not panic regardless of feature flag.
461        client.message("test message from tracy integration test");
462    }
463
464    #[test]
465    fn test_tracy_default_impl() {
466        let client = TracyClient::default();
467        // Default should produce the same result as new().
468        #[cfg(not(feature = "tracy"))]
469        assert!(!client.is_active());
470    }
471
472    #[test]
473    fn test_tracy_span_macro() {
474        let client = TracyClient::new();
475        tracy_span!(client, "macro_test_span");
476        // No panic = success
477    }
478
479    #[test]
480    fn test_export_chrome_trace_produces_valid_json_shape() {
481        let client = TracyClient::new();
482        client.message("export shape test");
483        let path = std::env::temp_dir().join(format!(
484            "scirs2_core_tracy_export_shape_{}.json",
485            std::process::id()
486        ));
487
488        client
489            .export_chrome_trace(&path)
490            .expect("export_chrome_trace should succeed");
491
492        let contents = std::fs::read_to_string(&path).expect("exported file should be readable");
493        let _ = std::fs::remove_file(&path);
494
495        assert!(
496            contents.starts_with("{\"traceEvents\":["),
497            "exported document should start with the traceEvents array: {contents}"
498        );
499        assert!(
500            contents.ends_with("],\"displayTimeUnit\":\"ns\"}"),
501            "exported document should end with the displayTimeUnit field: {contents}"
502        );
503    }
504
505    #[cfg(feature = "tracy")]
506    #[test]
507    fn test_export_chrome_trace_records_span_event() {
508        // Use a uniquely-named span so this test is robust to other tests
509        // (and doctests / concurrent test threads) sharing the same
510        // process-global event buffer.
511        let client = TracyClient::new();
512        let marker = format!(
513            "unique_span_marker_{}_{}",
514            std::process::id(),
515            current_tid()
516        );
517        {
518            let _span = client.span(&marker);
519        }
520
521        let path = std::env::temp_dir().join(format!(
522            "scirs2_core_tracy_export_span_{}_{}.json",
523            std::process::id(),
524            current_tid()
525        ));
526        client
527            .export_chrome_trace(&path)
528            .expect("export_chrome_trace should succeed");
529        let contents = std::fs::read_to_string(&path).expect("exported file should be readable");
530        let _ = std::fs::remove_file(&path);
531
532        assert!(
533            contents.contains(&marker),
534            "exported document should contain the span's name: {contents}"
535        );
536        assert!(
537            contents.contains("\"ph\":\"X\""),
538            "exported document should contain a complete-event phase marker: {contents}"
539        );
540    }
541}