Skip to main content

rama/telemetry/tracing/apple/oslog/
mod.rs

1//! A [`tracing_subscriber`] layer for Apple unified logging.
2//!
3//! [`OsLogLayer`] writes tracing events to an Apple `os_log_t`. By default,
4//! spans are represented as signpost intervals for inspection in Instruments.
5//! Each interval measures the span's lifetime from creation until final close,
6//! rather than only the time during which the span is entered. Span context in
7//! event messages remains optional and is disabled by default.
8//!
9//! The layer checks whether Apple logging is enabled for an event's mapped
10//! level before it visits or formats that event. This check is deliberately
11//! performed inside [`Layer::on_event`]: returning `false` from
12//! [`Layer::enabled`] would disable the event for every other layer in the
13//! subscriber stack as well.
14//!
15//! # Example
16//!
17//! ```no_run
18//! use rama::telemetry::tracing::{
19//!     self,
20//!     apple::oslog::{OsLogLayer, Privacy},
21//!     subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _},
22//! };
23//!
24//! let oslog = OsLogLayer::new("com.example.proxy", "network")?
25//!     .with_privacy(Privacy::Public)
26//!     .with_span_context(true);
27//!
28//! tracing::subscriber::registry().with(oslog).try_init()?;
29//! # Ok::<(), Box<dyn std::error::Error>>(())
30//! ```
31
32use ahash::HashMap;
33use rama_core::telemetry::tracing::{
34    Event, Level, Metadata, Subscriber,
35    field::{Field, Visit},
36    span::{Attributes, Id, Record},
37};
38use rama_utils::octets::kib;
39use std::{
40    ffi::{CStr, CString, NulError, c_void},
41    fmt::{self, Write as _},
42    ptr::NonNull,
43    sync::{
44        Arc,
45        atomic::{AtomicU64, Ordering},
46    },
47};
48use tracing_subscriber::{
49    layer::{Context, Layer},
50    registry::LookupSpan,
51};
52
53const DEFAULT_MAX_MESSAGE_BYTES: usize = kib(1);
54const MIN_MESSAGE_BYTES: usize = 3;
55const OS_SIGNPOST_ID_NULL: u64 = 0;
56const OS_SIGNPOST_ID_INVALID: u64 = u64::MAX;
57
58static NEXT_LAYER_ID: AtomicU64 = AtomicU64::new(1);
59
60/// Privacy applied to the event or signpost's dynamic text.
61///
62/// Apple normally treats dynamic strings as private. Use [`Self::Public`] only
63/// when the formatted tracing message and all of its fields are known not to
64/// contain user or secret data.
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
66#[repr(u8)]
67pub enum Privacy {
68    /// Redact the dynamic text in persisted logs.
69    #[default]
70    Private = 0,
71    /// Store the dynamic text without redaction.
72    Public = 1,
73    /// Store the target and event message publicly while keeping structured
74    /// fields and span fields private.
75    ///
76    /// Callers must ensure the tracing `message` itself contains no user or
77    /// secret data. Prefer structured fields for values that need redaction.
78    PublicMessagePrivateFields = 2,
79}
80
81/// Controls whether tracing spans are exported as Apple signposts.
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
83pub enum SpanMode {
84    /// Do not emit signpost intervals.
85    ///
86    /// This avoids the per-span runtime-enabled check and is useful for
87    /// applications that create spans at particularly high volume.
88    Disabled,
89    /// Emit a signpost interval from span creation until span close.
90    ///
91    /// This represents the full lifetime of a tracing span, which can include
92    /// idle time and multiple enter/exit cycles, rather than only active time.
93    ///
94    /// Apple signposts require a static interval name, so Rama uses the fixed
95    /// name `tracing-span` and writes the tracing target, span name, and fields
96    /// into the signpost's dynamic message.
97    #[default]
98    Signposts,
99}
100
101/// Native Apple unified-log types.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103#[repr(u8)]
104pub enum OsLogType {
105    /// Persisted notice/default message.
106    Default = 0x00,
107    /// Informational message, normally held in memory only.
108    Info = 0x01,
109    /// Debug message, captured only when debug logging is enabled.
110    Debug = 0x02,
111    /// Process-level error.
112    Error = 0x10,
113    /// System-level or multi-process fault.
114    Fault = 0x11,
115}
116
117/// Maps tracing levels to Apple unified-log types.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct LevelMap {
120    trace: OsLogType,
121    debug: OsLogType,
122    info: OsLogType,
123    warn: OsLogType,
124    error: OsLogType,
125}
126
127impl LevelMap {
128    /// Create a custom level map.
129    pub const fn new(
130        trace: OsLogType,
131        debug: OsLogType,
132        info: OsLogType,
133        warn: OsLogType,
134        error: OsLogType,
135    ) -> Self {
136        Self {
137            trace,
138            debug,
139            info,
140            warn,
141            error,
142        }
143    }
144
145    /// Match the semantics of Apple's Swift `Logger` convenience methods.
146    ///
147    /// Trace and debug share Apple's debug type, warning and error share its
148    /// error type, and fault is never selected implicitly.
149    pub const fn apple() -> Self {
150        Self::new(
151            OsLogType::Debug,
152            OsLogType::Debug,
153            OsLogType::Info,
154            OsLogType::Error,
155            OsLogType::Error,
156        )
157    }
158
159    /// Persist tracing `INFO` events while keeping ordinary errors below fault.
160    ///
161    /// This is useful for rare lifecycle events that must survive for later
162    /// `log show` inspection.
163    pub const fn persistent_info() -> Self {
164        Self::new(
165            OsLogType::Debug,
166            OsLogType::Info,
167            OsLogType::Default,
168            OsLogType::Error,
169            OsLogType::Error,
170        )
171    }
172
173    /// Preserve the level mapping used by `tracing-oslog` 0.3.
174    ///
175    /// In particular, every tracing error becomes an Apple fault. Prefer
176    /// [`Self::apple`] or [`Self::persistent_info`] for new integrations.
177    pub const fn tracing_oslog_compatible() -> Self {
178        Self::new(
179            OsLogType::Debug,
180            OsLogType::Info,
181            OsLogType::Default,
182            OsLogType::Error,
183            OsLogType::Fault,
184        )
185    }
186
187    const fn get(self, level: Level) -> OsLogType {
188        match level {
189            Level::TRACE => self.trace,
190            Level::DEBUG => self.debug,
191            Level::INFO => self.info,
192            Level::WARN => self.warn,
193            Level::ERROR => self.error,
194        }
195    }
196}
197
198impl Default for LevelMap {
199    fn default() -> Self {
200        Self::apple()
201    }
202}
203
204/// Failure to create an Apple unified-log layer.
205#[derive(Debug)]
206pub enum OsLogError {
207    /// The subsystem contained an interior NUL byte.
208    InvalidSubsystem(NulError),
209    /// The category contained an interior NUL byte.
210    InvalidCategory(NulError),
211    /// Apple unexpectedly returned a null log handle.
212    CreateFailed,
213}
214
215impl fmt::Display for OsLogError {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        match self {
218            Self::InvalidSubsystem(_) => f.write_str("os_log subsystem contains a NUL byte"),
219            Self::InvalidCategory(_) => f.write_str("os_log category contains a NUL byte"),
220            Self::CreateFailed => f.write_str("Apple os_log_create returned a null handle"),
221        }
222    }
223}
224
225impl std::error::Error for OsLogError {
226    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
227        match self {
228            Self::InvalidSubsystem(err) | Self::InvalidCategory(err) => Some(err),
229            Self::CreateFailed => None,
230        }
231    }
232}
233
234/// A composable [`tracing_subscriber::Layer`] that writes to Apple unified
235/// logging.
236pub struct OsLogLayer {
237    log: Arc<LogHandle>,
238    layer_id: u64,
239    privacy: Privacy,
240    span_mode: SpanMode,
241    level_map: LevelMap,
242    include_target: bool,
243    include_span_context: bool,
244    max_message_bytes: usize,
245}
246
247impl OsLogLayer {
248    /// Create a layer for one fixed Apple subsystem/category pair.
249    ///
250    /// Apple caches these pairs for the lifetime of the process, so callers
251    /// should create a small, fixed set rather than derive categories from
252    /// request or span data.
253    pub fn new(subsystem: impl AsRef<str>, category: impl AsRef<str>) -> Result<Self, OsLogError> {
254        let subsystem = CString::new(subsystem.as_ref()).map_err(OsLogError::InvalidSubsystem)?;
255        let category = CString::new(category.as_ref()).map_err(OsLogError::InvalidCategory)?;
256
257        // SAFETY: [`ffi`] contract: both arguments are `CString`s that outlive
258        // this call. os/log.h: `os_log_create` returns a +1 `os_log_t`.
259        let raw = unsafe { ffi::rama_apple_oslog_create(subsystem.as_ptr(), category.as_ptr()) };
260        let raw = NonNull::new(raw).ok_or(OsLogError::CreateFailed)?;
261
262        Ok(Self {
263            // Establishes the `LogHandle` field invariant: `raw` is the +1
264            // reference just returned by `os_log_create`, owned only here.
265            log: Arc::new(LogHandle(raw)),
266            layer_id: next_layer_id(),
267            privacy: Privacy::default(),
268            span_mode: SpanMode::default(),
269            level_map: LevelMap::default(),
270            include_target: true,
271            include_span_context: false,
272            max_message_bytes: DEFAULT_MAX_MESSAGE_BYTES,
273        })
274    }
275
276    rama_utils::macros::generate_set_and_with! {
277        /// Set the privacy applied to all dynamic text emitted by this layer.
278        pub fn privacy(mut self, privacy: Privacy) -> Self {
279            self.privacy = privacy;
280            self
281        }
282    }
283
284    rama_utils::macros::generate_set_and_with! {
285        /// Configure native signpost export for spans.
286        ///
287        /// Defaults to [`SpanMode::Signposts`].
288        pub fn span_mode(mut self, span_mode: SpanMode) -> Self {
289            self.span_mode = span_mode;
290            self
291        }
292    }
293
294    rama_utils::macros::generate_set_and_with! {
295        /// Set the tracing-to-Apple level mapping.
296        pub fn level_map(mut self, level_map: LevelMap) -> Self {
297            self.level_map = level_map;
298            self
299        }
300    }
301
302    rama_utils::macros::generate_set_and_with! {
303        /// Include or omit the tracing target in event and signpost messages.
304        pub fn target(mut self, include_target: bool) -> Self {
305            self.include_target = include_target;
306            self
307        }
308    }
309
310    rama_utils::macros::generate_set_and_with! {
311        /// Include or omit the event's explicit/contextual span path and fields.
312        pub fn span_context(mut self, include_span_context: bool) -> Self {
313            self.include_span_context = include_span_context;
314            self
315        }
316    }
317
318    rama_utils::macros::generate_set_and_with! {
319        /// Bound the formatted dynamic payload.
320        ///
321        /// Apple caps persisted dynamic content at roughly 1 KiB. Values
322        /// below three bytes are raised to three so truncation can be represented
323        /// by `...`.
324        pub fn max_message_bytes(mut self, max_message_bytes: usize) -> Self {
325            self.max_message_bytes = if max_message_bytes < MIN_MESSAGE_BYTES {
326                MIN_MESSAGE_BYTES
327            } else {
328                max_message_bytes
329            };
330            self
331        }
332    }
333
334    fn format_span(&self, state: &SpanState) -> CString {
335        let mut output = BoundedString::new(self.max_message_bytes);
336        if self.include_target {
337            _ = write!(output, "[{}] ", state.metadata.target());
338        }
339        output.push_str(state.metadata.name());
340        if !state.fields.is_empty() {
341            output.push_str(" ");
342            output.push_bounded(&state.fields);
343        }
344        output.into_c_message()
345    }
346
347    fn format_span_split(&self, state: &SpanState) -> (CString, CString) {
348        let mut public = BoundedString::new(self.max_message_bytes);
349        if self.include_target {
350            _ = write!(public, "[{}] ", state.metadata.target());
351        }
352        public.push_str(state.metadata.name());
353
354        let mut private = BoundedString::new(self.max_message_bytes);
355        private.push_bounded(&state.fields);
356        (public.into_c_message(), private.into_c_message())
357    }
358
359    fn format_event<S>(&self, event: &Event<'_>, ctx: &Context<'_, S>) -> CString
360    where
361        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
362    {
363        let mut message = BoundedString::new(self.max_message_bytes);
364        let mut fields = BoundedString::new(self.max_message_bytes);
365        event.record(&mut FieldVisitor::event(&mut message, &mut fields));
366
367        let mut output = BoundedString::new(self.max_message_bytes);
368        if self.include_target {
369            _ = write!(output, "[{}] ", event.metadata().target());
370        }
371
372        if !message.is_empty() {
373            output.push_bounded(&message);
374        }
375        if !fields.is_empty() {
376            if !message.is_empty() {
377                output.push_str(" ");
378            }
379            output.push_bounded(&fields);
380        }
381
382        if self.include_span_context {
383            self.append_span_context(event, ctx, &mut output);
384        }
385
386        output.into_c_message()
387    }
388
389    fn format_event_split<S>(&self, event: &Event<'_>, ctx: &Context<'_, S>) -> (CString, CString)
390    where
391        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
392    {
393        let mut message = BoundedString::new(self.max_message_bytes);
394        let mut fields = BoundedString::new(self.max_message_bytes);
395        event.record(&mut FieldVisitor::event(&mut message, &mut fields));
396
397        let mut public = BoundedString::new(self.max_message_bytes);
398        if self.include_target {
399            _ = write!(public, "[{}] ", event.metadata().target());
400        }
401        public.push_bounded(&message);
402
403        let mut private = BoundedString::new(self.max_message_bytes);
404        private.push_bounded(&fields);
405        if self.include_span_context {
406            self.append_span_context(event, ctx, &mut private);
407        }
408
409        (public.into_c_message(), private.into_c_message())
410    }
411
412    fn append_span_context<S>(
413        &self,
414        event: &Event<'_>,
415        ctx: &Context<'_, S>,
416        output: &mut BoundedString,
417    ) where
418        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
419    {
420        let mut wrote_span = false;
421        if let Some(scope) = ctx.event_scope(event) {
422            for span in scope.from_root() {
423                let extensions = span.extensions();
424                let Some(states) = extensions.get::<SpanStates>() else {
425                    continue;
426                };
427                let Some(state) = states.0.get(&self.layer_id) else {
428                    continue;
429                };
430
431                if !wrote_span {
432                    if !output.is_empty() {
433                        output.push_str(" ");
434                    }
435                    output.push_str("spans=[");
436                    wrote_span = true;
437                } else {
438                    output.push_str(" > ");
439                }
440
441                output.push_str(state.metadata.name());
442                if !state.fields.is_empty() {
443                    output.push_str("{");
444                    output.push_bounded(&state.fields);
445                    output.push_str("}");
446                }
447            }
448        }
449        if wrote_span {
450            output.push_str("]");
451        }
452    }
453}
454
455impl Clone for OsLogLayer {
456    fn clone(&self) -> Self {
457        Self {
458            log: Arc::clone(&self.log),
459            layer_id: next_layer_id(),
460            privacy: self.privacy,
461            span_mode: self.span_mode,
462            level_map: self.level_map,
463            include_target: self.include_target,
464            include_span_context: self.include_span_context,
465            max_message_bytes: self.max_message_bytes,
466        }
467    }
468}
469
470impl fmt::Debug for OsLogLayer {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        f.debug_struct("OsLogLayer")
473            .field("layer_id", &self.layer_id)
474            .field("privacy", &self.privacy)
475            .field("span_mode", &self.span_mode)
476            .field("level_map", &self.level_map)
477            .field("include_target", &self.include_target)
478            .field("include_span_context", &self.include_span_context)
479            .field("max_message_bytes", &self.max_message_bytes)
480            .finish_non_exhaustive()
481    }
482}
483
484impl<S> Layer<S> for OsLogLayer
485where
486    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
487{
488    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
489        let signpost_enabled = self.span_mode == SpanMode::Signposts && self.log.signpost_enabled();
490        if !self.include_span_context && !signpost_enabled {
491            return;
492        }
493
494        let Some(span) = ctx.span(id) else {
495            return;
496        };
497
498        let mut fields = BoundedString::new(self.max_message_bytes);
499        attrs.record(&mut FieldVisitor::fields(&mut fields));
500
501        let signpost_id = if signpost_enabled {
502            let signpost_id = self.log.signpost_id_generate();
503            if is_valid_signpost_id(signpost_id) {
504                Some(signpost_id)
505            } else {
506                None
507            }
508        } else {
509            None
510        };
511
512        let state = SpanState {
513            metadata: attrs.metadata(),
514            fields,
515            signpost_id,
516        };
517
518        if let Some(signpost_id) = signpost_id {
519            if self.privacy == Privacy::PublicMessagePrivateFields {
520                let (public, private) = self.format_span_split(&state);
521                self.log
522                    .signpost_begin_split(signpost_id, &public, &private);
523            } else {
524                let message = self.format_span(&state);
525                self.log.signpost_begin(signpost_id, &message, self.privacy);
526            }
527        }
528
529        let mut extensions = span.extensions_mut();
530        if let Some(states) = extensions.get_mut::<SpanStates>() {
531            states.0.insert(self.layer_id, state);
532        } else {
533            let mut states = HashMap::default();
534            states.insert(self.layer_id, state);
535            extensions.insert(SpanStates(states));
536        }
537    }
538
539    fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
540        let Some(span) = ctx.span(id) else {
541            return;
542        };
543        let mut extensions = span.extensions_mut();
544        let Some(states) = extensions.get_mut::<SpanStates>() else {
545            return;
546        };
547        let Some(state) = states.0.get_mut(&self.layer_id) else {
548            return;
549        };
550
551        values.record(&mut FieldVisitor::fields(&mut state.fields));
552    }
553
554    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
555        let os_type = self.level_map.get(*event.metadata().level());
556        if !self.log.enabled(os_type) {
557            return;
558        }
559
560        if self.privacy == Privacy::PublicMessagePrivateFields {
561            let (public, private) = self.format_event_split(event, &ctx);
562            self.log.emit_split(os_type, &public, &private);
563        } else {
564            let message = self.format_event(event, &ctx);
565            self.log.emit(os_type, &message, self.privacy);
566        }
567    }
568
569    fn on_close(&self, id: Id, ctx: Context<'_, S>) {
570        let Some(span) = ctx.span(&id) else {
571            return;
572        };
573        let mut extensions = span.extensions_mut();
574        let Some(states) = extensions.get_mut::<SpanStates>() else {
575            return;
576        };
577        let Some(state) = states.0.remove(&self.layer_id) else {
578            return;
579        };
580
581        if let Some(signpost_id) = state.signpost_id {
582            if self.privacy == Privacy::PublicMessagePrivateFields {
583                let (public, private) = self.format_span_split(&state);
584                self.log.signpost_end_split(signpost_id, &public, &private);
585            } else {
586                let message = self.format_span(&state);
587                self.log.signpost_end(signpost_id, &message, self.privacy);
588            }
589        }
590    }
591}
592
593struct LogHandle(
594    // Safety invariant: a +1 `os_log_t` reference returned by `os_log_create`,
595    // owned by this handle and released only by its `Drop`.
596    NonNull<c_void>,
597);
598
599// SAFETY: `Send` and `Sync` require that the handle may be used and released
600// from any thread, concurrently. os/log.h documents `os_log_t` objects as
601// entries of a process-global collection that are never deallocated, and
602// Apple's `os` Swift overlay declares `OSLog` and `Logger` `Sendable`, which is
603// Apple's statement that the logging calls on them are thread-safe. The Rust
604// side holds no mutable state.
605unsafe impl Send for LogHandle {}
606// SAFETY: see the `Send` proof; every method takes `&self` and only forwards
607// the immutable handle to the os_log runtime.
608unsafe impl Sync for LogHandle {}
609
610impl LogHandle {
611    fn enabled(&self, os_type: OsLogType) -> bool {
612        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
613        // invariant) and `OsLogType` discriminants are `os_log_type_t` values.
614        unsafe { ffi::rama_apple_oslog_enabled(self.0.as_ptr(), os_type as u8) != 0 }
615    }
616
617    fn emit(&self, os_type: OsLogType, message: &CStr, privacy: Privacy) {
618        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
619        // invariant); `message` is a `&CStr`, so NUL-terminated and borrowed
620        // for this synchronous call.
621        unsafe {
622            ffi::rama_apple_oslog_emit(
623                self.0.as_ptr(),
624                os_type as u8,
625                message.as_ptr(),
626                privacy as u8,
627            );
628        }
629    }
630
631    fn emit_split(&self, os_type: OsLogType, public: &CStr, private: &CStr) {
632        if private.is_empty() {
633            self.emit(os_type, public, Privacy::Public);
634            return;
635        }
636        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
637        // invariant); both texts are `&CStr` borrowed for this call.
638        unsafe {
639            ffi::rama_apple_oslog_emit_split(
640                self.0.as_ptr(),
641                os_type as u8,
642                public.as_ptr(),
643                private.as_ptr(),
644            );
645        }
646    }
647
648    fn signpost_enabled(&self) -> bool {
649        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
650        // invariant); the shim availability-checks the signpost API.
651        unsafe { ffi::rama_apple_oslog_signpost_enabled(self.0.as_ptr()) != 0 }
652    }
653
654    fn signpost_id_generate(&self) -> u64 {
655        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
656        // invariant); the shim availability-checks the signpost API.
657        unsafe { ffi::rama_apple_oslog_signpost_id_generate(self.0.as_ptr()) }
658    }
659
660    fn signpost_begin(&self, signpost_id: u64, message: &CStr, privacy: Privacy) {
661        debug_assert!(is_valid_signpost_id(signpost_id));
662        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
663        // invariant); `message` is a `&CStr` borrowed for this call.
664        unsafe {
665            ffi::rama_apple_oslog_signpost_begin(
666                self.0.as_ptr(),
667                signpost_id,
668                message.as_ptr(),
669                privacy as u8,
670            );
671        }
672    }
673
674    fn signpost_end(&self, signpost_id: u64, message: &CStr, privacy: Privacy) {
675        debug_assert!(is_valid_signpost_id(signpost_id));
676        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
677        // invariant); `message` is a `&CStr` borrowed for this call.
678        unsafe {
679            ffi::rama_apple_oslog_signpost_end(
680                self.0.as_ptr(),
681                signpost_id,
682                message.as_ptr(),
683                privacy as u8,
684            );
685        }
686    }
687
688    fn signpost_begin_split(&self, signpost_id: u64, public: &CStr, private: &CStr) {
689        debug_assert!(is_valid_signpost_id(signpost_id));
690        if private.is_empty() {
691            self.signpost_begin(signpost_id, public, Privacy::Public);
692            return;
693        }
694        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
695        // invariant); both texts are `&CStr` borrowed for this call.
696        unsafe {
697            ffi::rama_apple_oslog_signpost_begin_split(
698                self.0.as_ptr(),
699                signpost_id,
700                public.as_ptr(),
701                private.as_ptr(),
702            );
703        }
704    }
705
706    fn signpost_end_split(&self, signpost_id: u64, public: &CStr, private: &CStr) {
707        debug_assert!(is_valid_signpost_id(signpost_id));
708        if private.is_empty() {
709            self.signpost_end(signpost_id, public, Privacy::Public);
710            return;
711        }
712        // SAFETY: [`ffi`] contract: `self.0` is a live `os_log_t` (field
713        // invariant); both texts are `&CStr` borrowed for this call.
714        unsafe {
715            ffi::rama_apple_oslog_signpost_end_split(
716                self.0.as_ptr(),
717                signpost_id,
718                public.as_ptr(),
719                private.as_ptr(),
720            );
721        }
722    }
723}
724
725impl Drop for LogHandle {
726    fn drop(&mut self) {
727        // SAFETY: [`ffi`] contract for `rama_apple_oslog_release`: by the field
728        // invariant `self.0` is this handle's own +1 reference, and `Drop` runs
729        // once, so it is released exactly once and never used afterwards.
730        unsafe { ffi::rama_apple_oslog_release(self.0.as_ptr()) };
731    }
732}
733
734struct SpanStates(HashMap<u64, SpanState>);
735
736struct SpanState {
737    metadata: &'static Metadata<'static>,
738    fields: BoundedString,
739    signpost_id: Option<u64>,
740}
741
742struct FieldVisitor<'a> {
743    message: Option<&'a mut BoundedString>,
744    fields: &'a mut BoundedString,
745}
746
747impl<'a> FieldVisitor<'a> {
748    fn event(message: &'a mut BoundedString, fields: &'a mut BoundedString) -> Self {
749        Self {
750            message: Some(message),
751            fields,
752        }
753    }
754
755    fn fields(fields: &'a mut BoundedString) -> Self {
756        Self {
757            message: None,
758            fields,
759        }
760    }
761
762    fn record_value(&mut self, field: &Field, write_value: impl FnOnce(&mut BoundedString, bool)) {
763        if field.name() == "message"
764            && let Some(message) = self.message.as_deref_mut()
765        {
766            write_value(message, true);
767            return;
768        }
769
770        if !self.fields.is_empty() {
771            self.fields.push_str(" ");
772        }
773        self.fields.push_str(field.name());
774        self.fields.push_str("=");
775        write_value(self.fields, false);
776    }
777}
778
779impl Visit for FieldVisitor<'_> {
780    fn record_f64(&mut self, field: &Field, value: f64) {
781        self.record_value(field, |output, _| _ = write!(output, "{value}"));
782    }
783
784    fn record_i64(&mut self, field: &Field, value: i64) {
785        self.record_value(field, |output, _| _ = write!(output, "{value}"));
786    }
787
788    fn record_u64(&mut self, field: &Field, value: u64) {
789        self.record_value(field, |output, _| _ = write!(output, "{value}"));
790    }
791
792    fn record_i128(&mut self, field: &Field, value: i128) {
793        self.record_value(field, |output, _| _ = write!(output, "{value}"));
794    }
795
796    fn record_u128(&mut self, field: &Field, value: u128) {
797        self.record_value(field, |output, _| _ = write!(output, "{value}"));
798    }
799
800    fn record_bool(&mut self, field: &Field, value: bool) {
801        self.record_value(field, |output, _| _ = write!(output, "{value}"));
802    }
803
804    fn record_str(&mut self, field: &Field, value: &str) {
805        self.record_value(field, |output, is_message| {
806            if is_message {
807                output.push_str(value);
808            } else {
809                _ = write!(output, "{value:?}");
810            }
811        });
812    }
813
814    fn record_bytes(&mut self, field: &Field, value: &[u8]) {
815        self.record_value(field, |output, _| _ = write!(output, "{value:?}"));
816    }
817
818    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
819        self.record_value(field, |output, _| _ = write!(output, "{value}"));
820    }
821
822    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
823        self.record_value(field, |output, _| _ = write!(output, "{value:?}"));
824    }
825}
826
827#[derive(Debug)]
828struct BoundedString {
829    value: String,
830    max_bytes: usize,
831    truncated: bool,
832}
833
834impl BoundedString {
835    fn new(max_bytes: usize) -> Self {
836        Self {
837            value: String::with_capacity(max_bytes.min(256)),
838            max_bytes,
839            truncated: false,
840        }
841    }
842
843    fn is_empty(&self) -> bool {
844        self.value.is_empty()
845    }
846
847    fn as_str(&self) -> &str {
848        &self.value
849    }
850
851    fn push_str(&mut self, value: &str) {
852        _ = self.write_str(value);
853    }
854
855    fn push_bounded(&mut self, value: &Self) {
856        self.push_str(value.as_str());
857        self.truncated |= value.truncated;
858    }
859
860    fn into_c_message(mut self) -> CString {
861        if self.truncated {
862            let keep = self.max_bytes.saturating_sub(MIN_MESSAGE_BYTES);
863            truncate_utf8(&mut self.value, keep);
864            self.value.push_str("...");
865        }
866
867        if self.value.contains('\0') {
868            let mut escaped = Self::new(self.max_bytes);
869            for part in self.value.split_inclusive('\0') {
870                if let Some(without_nul) = part.strip_suffix('\0') {
871                    escaped.push_str(without_nul);
872                    escaped.push_str("\\0");
873                } else {
874                    escaped.push_str(part);
875                }
876            }
877            return escaped.into_c_message();
878        }
879
880        // NUL bytes were escaped above, so the empty fallback is never taken.
881        CString::new(self.value).unwrap_or_default()
882    }
883}
884
885impl fmt::Write for BoundedString {
886    fn write_str(&mut self, value: &str) -> fmt::Result {
887        let remaining = self.max_bytes.saturating_sub(self.value.len());
888        if value.len() <= remaining {
889            self.value.push_str(value);
890            return Ok(());
891        }
892
893        let mut end = remaining;
894        while end > 0 && !value.is_char_boundary(end) {
895            end -= 1;
896        }
897        self.value.push_str(&value[..end]);
898        self.truncated = true;
899        Ok(())
900    }
901}
902
903fn truncate_utf8(value: &mut String, max_bytes: usize) {
904    if value.len() <= max_bytes {
905        return;
906    }
907
908    let mut end = max_bytes;
909    while end > 0 && !value.is_char_boundary(end) {
910        end -= 1;
911    }
912    value.truncate(end);
913}
914
915fn next_layer_id() -> u64 {
916    NEXT_LAYER_ID.fetch_add(1, Ordering::Relaxed)
917}
918
919const fn is_valid_signpost_id(signpost_id: u64) -> bool {
920    signpost_id != OS_SIGNPOST_ID_NULL && signpost_id != OS_SIGNPOST_ID_INVALID
921}
922
923/// Declarations of the C shim in `shim.c` (signatures match `shim.h`).
924///
925/// # Safety
926///
927/// Callers of every function here must ensure that:
928///
929/// - `log` is a live `os_log_t` (a value returned by `rama_apple_oslog_create`
930///   and not yet passed to `rama_apple_oslog_release`);
931///   `rama_apple_oslog_release` consumes that reference, so `log` must not be
932///   used again afterwards;
933/// - `subsystem`, `category`, `message`, `public_message` and `private_fields`
934///   are non-null and point to NUL-terminated strings readable until the call
935///   returns.
936///
937/// The shim passes text only as `%s` arguments of constant format strings,
938/// never as a format string, and availability-checks the signpost API.
939mod ffi {
940    use std::ffi::{c_char, c_void};
941
942    unsafe extern "C" {
943        pub(super) fn rama_apple_oslog_create(
944            subsystem: *const c_char,
945            category: *const c_char,
946        ) -> *mut c_void;
947        pub(super) fn rama_apple_oslog_release(log: *mut c_void);
948        pub(super) fn rama_apple_oslog_enabled(log: *mut c_void, os_type: u8) -> u8;
949        pub(super) fn rama_apple_oslog_emit(
950            log: *mut c_void,
951            os_type: u8,
952            message: *const c_char,
953            is_public: u8,
954        );
955        pub(super) fn rama_apple_oslog_emit_split(
956            log: *mut c_void,
957            os_type: u8,
958            public_message: *const c_char,
959            private_fields: *const c_char,
960        );
961
962        pub(super) fn rama_apple_oslog_signpost_enabled(log: *mut c_void) -> u8;
963        pub(super) fn rama_apple_oslog_signpost_id_generate(log: *mut c_void) -> u64;
964        pub(super) fn rama_apple_oslog_signpost_begin(
965            log: *mut c_void,
966            signpost_id: u64,
967            message: *const c_char,
968            is_public: u8,
969        );
970        pub(super) fn rama_apple_oslog_signpost_end(
971            log: *mut c_void,
972            signpost_id: u64,
973            message: *const c_char,
974            is_public: u8,
975        );
976        pub(super) fn rama_apple_oslog_signpost_begin_split(
977            log: *mut c_void,
978            signpost_id: u64,
979            public_message: *const c_char,
980            private_fields: *const c_char,
981        );
982        pub(super) fn rama_apple_oslog_signpost_end_split(
983            log: *mut c_void,
984            signpost_id: u64,
985            public_message: *const c_char,
986            private_fields: *const c_char,
987        );
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::telemetry::tracing::{self, subscriber::layer::SubscriberExt as _};
995    use std::assert_matches;
996    use std::sync::RwLock;
997
998    struct FormattingCapture {
999        formatter: OsLogLayer,
1000        events: Arc<RwLock<Vec<String>>>,
1001    }
1002
1003    impl<S> Layer<S> for FormattingCapture
1004    where
1005        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
1006    {
1007        fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
1008            <OsLogLayer as Layer<S>>::on_new_span(&self.formatter, attrs, id, ctx);
1009        }
1010
1011        fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
1012            <OsLogLayer as Layer<S>>::on_record(&self.formatter, id, values, ctx);
1013        }
1014
1015        fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
1016            let message = if self.formatter.privacy == Privacy::PublicMessagePrivateFields {
1017                let (public, private) = self.formatter.format_event_split(event, &ctx);
1018                format!(
1019                    "{} |private| {}",
1020                    public.to_str().unwrap(),
1021                    private.to_str().unwrap()
1022                )
1023            } else {
1024                let message = self.formatter.format_event(event, &ctx);
1025                message.into_string().unwrap()
1026            };
1027            self.events.write().unwrap().push(message);
1028        }
1029
1030        fn on_close(&self, id: Id, ctx: Context<'_, S>) {
1031            <OsLogLayer as Layer<S>>::on_close(&self.formatter, id, ctx);
1032        }
1033    }
1034
1035    #[test]
1036    fn signposts_are_enabled_by_default() {
1037        assert_eq!(SpanMode::default(), SpanMode::Signposts);
1038    }
1039
1040    #[test]
1041    fn level_maps_are_explicit_about_faults() {
1042        assert_eq!(LevelMap::apple().get(Level::ERROR), OsLogType::Error);
1043        assert_eq!(
1044            LevelMap::persistent_info().get(Level::INFO),
1045            OsLogType::Default
1046        );
1047        assert_eq!(
1048            LevelMap::tracing_oslog_compatible().get(Level::ERROR),
1049            OsLogType::Fault
1050        );
1051    }
1052
1053    #[test]
1054    fn bounded_message_is_utf8_safe_and_nul_free() {
1055        let mut message = BoundedString::new(8);
1056        message.push_str("ééééé");
1057        let message = message.into_c_message();
1058
1059        message.to_str().unwrap();
1060        assert!(message.as_bytes().len() <= 8);
1061
1062        let mut message = BoundedString::new(16);
1063        message.push_str("left\0right");
1064        let message = message.into_c_message();
1065        assert_eq!(message.as_bytes(), b"left\\0right");
1066
1067        let mut message = BoundedString::new(4);
1068        message.push_str("\0\0\0\0");
1069        let message = message.into_c_message();
1070        assert!(!message.as_bytes().is_empty());
1071        assert!(!message.as_bytes().contains(&0));
1072    }
1073
1074    #[test]
1075    fn invalid_subsystem_and_category_are_errors() {
1076        assert_matches!(
1077            OsLogLayer::new("bad\0subsystem", "category"),
1078            Err(OsLogError::InvalidSubsystem(_)),
1079        );
1080        assert_matches!(
1081            OsLogLayer::new("com.example", "bad\0category"),
1082            Err(OsLogError::InvalidCategory(_)),
1083        );
1084    }
1085
1086    #[test]
1087    fn explicit_parents_records_and_multiple_layers_do_not_panic() {
1088        let first = OsLogLayer::new("org.plabayo.rama.test", "first")
1089            .unwrap()
1090            .with_privacy(Privacy::Public)
1091            .with_level_map(LevelMap::persistent_info())
1092            .with_span_mode(SpanMode::Signposts)
1093            .with_span_context(true);
1094        let second = first.clone().with_target(false);
1095        let subscriber = tracing::subscriber::registry().with(first).with(second);
1096        let dispatch = tracing::Dispatch::new(subscriber);
1097
1098        tracing::dispatcher::with_default(&dispatch, || {
1099            let span = tracing::info_span!("request", request.id = tracing::field::Empty);
1100            span.record("request.id", 42_u64);
1101            tracing::info!(parent: &span, answer = 42, "explicit parent");
1102            tracing::info!(parent: None, "explicit root");
1103        });
1104    }
1105
1106    #[test]
1107    fn event_formatting_uses_explicit_scope_and_late_records() {
1108        let events = Arc::new(RwLock::new(Vec::new()));
1109        let formatter = OsLogLayer::new("org.plabayo.rama.test", "format")
1110            .unwrap()
1111            .with_target(false)
1112            .with_span_context(true);
1113        let capture = FormattingCapture {
1114            formatter,
1115            events: Arc::clone(&events),
1116        };
1117        let dispatch = tracing::Dispatch::new(tracing::subscriber::registry().with(capture));
1118
1119        tracing::dispatcher::with_default(&dispatch, || {
1120            let span = tracing::info_span!("request", request.id = tracing::field::Empty);
1121            span.record("request.id", 42_u64);
1122            tracing::info!(parent: &span, answer = 42, "explicit parent");
1123            tracing::info!(parent: None, "explicit root");
1124        });
1125
1126        let events = events.read().unwrap();
1127        assert_eq!(events.len(), 2);
1128        assert!(events[0].contains("explicit parent answer=42"));
1129        assert!(events[0].contains("spans=[request{request.id=42}]"));
1130        assert_eq!(events[1], "explicit root");
1131    }
1132
1133    #[test]
1134    fn split_privacy_keeps_fields_out_of_public_message() {
1135        let events = Arc::new(RwLock::new(Vec::new()));
1136        let formatter = OsLogLayer::new("org.plabayo.rama.test", "format")
1137            .unwrap()
1138            .with_privacy(Privacy::PublicMessagePrivateFields)
1139            .with_target(false)
1140            .with_span_context(true);
1141        let capture = FormattingCapture {
1142            formatter,
1143            events: Arc::clone(&events),
1144        };
1145        let dispatch = tracing::Dispatch::new(tracing::subscriber::registry().with(capture));
1146
1147        tracing::dispatcher::with_default(&dispatch, || {
1148            let span = tracing::info_span!("request", user = "private-user");
1149            tracing::info!(parent: &span, endpoint = "/private", "request finished");
1150        });
1151
1152        let events = events.read().unwrap();
1153        assert!(events[0].starts_with("request finished |private| "));
1154        assert!(
1155            !events[0]
1156                .split(" |private| ")
1157                .next()
1158                .unwrap()
1159                .contains("private")
1160        );
1161        assert!(events[0].contains("endpoint=\"/private\""));
1162        assert!(events[0].contains("user=\"private-user\""));
1163    }
1164}