aft/log_ctx.rs
1//! Thread-local session context for log lines.
2//!
3//! AFT runs a single-threaded request loop. Each incoming request carries a
4//! `session_id` that identifies the OpenCode/Pi session. By storing it in a
5//! thread-local we can automatically prepend `[ses_xxx]` to every `slog_*`
6//! log macro call without threading the session id through every function
7//! signature.
8//!
9//! Background threads spawned during request handling (search-index pre-warm,
10//! semantic-index build) **must** capture the session id before spawning and
11//! re-install it on the new thread via [`set_session`] or [`with_session`].
12
13use std::cell::RefCell;
14
15thread_local! {
16 /// Current session id for log tagging. `None` means "no session context".
17 static CURRENT_SESSION: RefCell<Option<String>> = const { RefCell::new(None) };
18}
19
20/// Set the current thread-local session id.
21///
22/// Call this at the start of a background thread that captured the session id
23/// from the parent request loop.
24pub fn set_session(session: Option<String>) {
25 CURRENT_SESSION.with(|s| {
26 *s.borrow_mut() = session;
27 });
28}
29
30struct SessionGuard(Option<String>);
31
32impl Drop for SessionGuard {
33 fn drop(&mut self) {
34 set_session(self.0.take());
35 }
36}
37
38/// Run `f` with the given session id set on the current thread, restoring the
39/// previous value afterwards (RAII-style and panic-safe).
40///
41/// This is the primary entry point for the main request loop: wrap the
42/// dispatch call in `with_session(req.session_id.clone(), || { ... })`.
43pub fn with_session<T>(session: Option<String>, f: impl FnOnce() -> T) -> T {
44 let prev = current_session();
45 set_session(session);
46 let _guard = SessionGuard(prev);
47 f()
48}
49
50/// Return the current session id (e.g. `"abcd1234"`), or `None` if no session is set.
51pub fn current_session() -> Option<String> {
52 CURRENT_SESSION.with(|s| s.borrow().clone())
53}
54
55/// Return the current session id prefix string, e.g. `"[ses_abcd1234] "`,
56/// or an empty string if no session is set.
57///
58/// The stored session id may already carry the `ses_` prefix (OpenCode's
59/// real session IDs do); detect that and avoid double-prefixing.
60pub fn session_prefix() -> String {
61 CURRENT_SESSION.with(|s| match s.borrow().as_deref() {
62 Some(sid) if sid.starts_with("ses_") => format!("[{}] ", sid),
63 Some(sid) => format!("[ses_{}] ", sid),
64 None => String::new(),
65 })
66}
67
68/// Log at INFO level with the optional `[ses_xxx]` session tag.
69///
70/// Use this instead of `log::info!(...)` in per-request code paths.
71/// The macro automatically reads the thread-local session id and formats:
72///
73/// ```text
74/// With session: [aft] [ses_abcd1234] semantic index: rebuilding from scratch
75/// Without session: [aft] semantic index: rebuilding from scratch
76/// ```
77///
78/// The `[aft]` / `[aft-lsp]` outer prefix is added by env_logger based on the
79/// log target — do NOT inline it into the macro body, that produces a doubled
80/// `[aft-lsp] [aft]` prefix when LSP modules log.
81#[macro_export]
82macro_rules! slog_info {
83 ($($arg:tt)*) => {
84 log::info!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
85 };
86}
87
88/// Log at WARN level with the optional `[ses_xxx]` session tag.
89///
90/// See [`slog_info!`] for format details.
91#[macro_export]
92macro_rules! slog_warn {
93 ($($arg:tt)*) => {
94 log::warn!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
95 };
96}
97
98/// Log at ERROR level with the optional `[ses_xxx]` session tag.
99///
100/// See [`slog_info!`] for format details.
101#[macro_export]
102macro_rules! slog_error {
103 ($($arg:tt)*) => {
104 log::error!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
105 };
106}
107
108/// Log at DEBUG level with the optional `[ses_xxx]` session tag.
109///
110/// Use for verbose-but-useful diagnostics that should be silent by default and
111/// only surface when `RUST_LOG=aft=debug` (or similar) is set.
112///
113/// See [`slog_info!`] for format details.
114#[macro_export]
115macro_rules! slog_debug {
116 ($($arg:tt)*) => {
117 log::debug!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
118 };
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn with_session_sets_and_clears() {
127 // Initially no session
128 CURRENT_SESSION.with(|s| {
129 assert!(s.borrow().is_none());
130 });
131
132 // Set inside with_session
133 with_session(Some("test123".to_string()), || {
134 CURRENT_SESSION.with(|s| {
135 assert_eq!(s.borrow().as_deref(), Some("test123"));
136 });
137 });
138
139 // Cleared after with_session
140 CURRENT_SESSION.with(|s| {
141 assert!(s.borrow().is_none());
142 });
143 }
144
145 #[test]
146 fn with_session_none_is_noop() {
147 with_session(None, || {
148 CURRENT_SESSION.with(|s| {
149 assert!(s.borrow().is_none());
150 });
151 });
152 }
153
154 #[test]
155 fn session_prefix_format() {
156 with_session(Some("abcd1234".to_string()), || {
157 assert_eq!(session_prefix(), "[ses_abcd1234] ");
158 });
159
160 // Without session
161 assert_eq!(session_prefix(), "");
162 }
163
164 #[test]
165 fn session_prefix_does_not_double_prefix_real_ids() {
166 // Real OpenCode session IDs already start with "ses_" — the
167 // formatter must not turn that into "ses_ses_xxx".
168 with_session(Some("ses_313660571ffeZTsf4koSJwk50Q".to_string()), || {
169 assert_eq!(session_prefix(), "[ses_313660571ffeZTsf4koSJwk50Q] ");
170 });
171 }
172
173 #[test]
174 fn set_session_direct() {
175 set_session(Some("direct".to_string()));
176 CURRENT_SESSION.with(|s| {
177 assert_eq!(s.borrow().as_deref(), Some("direct"));
178 });
179 set_session(None);
180 CURRENT_SESSION.with(|s| {
181 assert!(s.borrow().is_none());
182 });
183 }
184}