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 /// Most-recent non-None session id observed on this thread. Used as a
20 /// fallback for between-request log sites that have no inherent session
21 /// (filesystem watcher invalidation, gitignore rebuilds triggered by
22 /// the OS, etc.) so their lines still carry a session tag.
23 ///
24 /// This is best-effort: in a multi-session setup the tag may attribute
25 /// project-wide events to whichever session was active most recently.
26 /// That's acceptable because the vast majority of AFT users have one
27 /// active session per project and the alternative is an untagged line.
28 static LAST_SESSION: RefCell<Option<String>> = const { RefCell::new(None) };
29}
30
31/// Set the current thread-local session id.
32///
33/// Call this at the start of a background thread that captured the session id
34/// from the parent request loop.
35pub fn set_session(session: Option<String>) {
36 if let Some(sid) = session.as_deref() {
37 LAST_SESSION.with(|s| {
38 *s.borrow_mut() = Some(sid.to_string());
39 });
40 }
41 CURRENT_SESSION.with(|s| {
42 *s.borrow_mut() = session;
43 });
44}
45
46struct SessionGuard(Option<String>);
47
48impl Drop for SessionGuard {
49 fn drop(&mut self) {
50 // Restore CURRENT_SESSION directly (don't go through set_session,
51 // which would also overwrite LAST_SESSION — the guard's job is to
52 // restore the previous CURRENT_SESSION value, not to alter the
53 // last-session fallback).
54 CURRENT_SESSION.with(|s| {
55 *s.borrow_mut() = self.0.take();
56 });
57 }
58}
59
60/// Run `f` with the given session id set on the current thread, restoring the
61/// previous value afterwards (RAII-style and panic-safe).
62///
63/// This is the primary entry point for the main request loop: wrap the
64/// dispatch call in `with_session(req.session_id.clone(), || { ... })`.
65pub fn with_session<T>(session: Option<String>, f: impl FnOnce() -> T) -> T {
66 let prev = current_session();
67 set_session(session);
68 let _guard = SessionGuard(prev);
69 f()
70}
71
72/// Return the current session id (e.g. `"abcd1234"`), or `None` if no session is set.
73pub fn current_session() -> Option<String> {
74 CURRENT_SESSION.with(|s| s.borrow().clone())
75}
76
77/// Return the current session id prefix string, e.g. `"[ses_abcd1234] "`,
78/// or an empty string if no session id has ever been observed on this thread.
79///
80/// Resolution order:
81/// 1. Current request's session (set via `with_session`).
82/// 2. Most-recent session observed on this thread (fallback for
83/// between-request log sites like watcher invalidation that have no
84/// inherent session context).
85///
86/// The stored session id may already carry the `ses_` prefix (OpenCode's
87/// real session IDs do); detect that and avoid double-prefixing.
88pub fn session_prefix() -> String {
89 let sid_opt = CURRENT_SESSION
90 .with(|s| s.borrow().clone())
91 .or_else(|| LAST_SESSION.with(|s| s.borrow().clone()));
92 match sid_opt.as_deref() {
93 Some(sid) if sid.starts_with("ses_") => format!("[{}] ", sid),
94 Some(sid) => format!("[ses_{}] ", sid),
95 None => String::new(),
96 }
97}
98
99/// Log at INFO level with the optional `[ses_xxx]` session tag.
100///
101/// Use this instead of `log::info!(...)` in per-request code paths.
102/// The macro automatically reads the thread-local session id and formats:
103///
104/// ```text
105/// With session: [aft] [ses_abcd1234] semantic index: rebuilding from scratch
106/// Without session: [aft] semantic index: rebuilding from scratch
107/// ```
108///
109/// The `[aft]` / `[aft-lsp]` outer prefix is added by env_logger based on the
110/// log target — do NOT inline it into the macro body, that produces a doubled
111/// `[aft-lsp] [aft]` prefix when LSP modules log.
112#[macro_export]
113macro_rules! slog_info {
114 ($($arg:tt)*) => {
115 log::info!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
116 };
117}
118
119/// Log at WARN level with the optional `[ses_xxx]` session tag.
120///
121/// See [`slog_info!`] for format details.
122#[macro_export]
123macro_rules! slog_warn {
124 ($($arg:tt)*) => {
125 log::warn!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
126 };
127}
128
129/// Log at ERROR level with the optional `[ses_xxx]` session tag.
130///
131/// See [`slog_info!`] for format details.
132#[macro_export]
133macro_rules! slog_error {
134 ($($arg:tt)*) => {
135 log::error!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
136 };
137}
138
139/// Log at DEBUG level with the optional `[ses_xxx]` session tag.
140///
141/// Use for verbose-but-useful diagnostics that should be silent by default and
142/// only surface when `RUST_LOG=aft=debug` (or similar) is set.
143///
144/// See [`slog_info!`] for format details.
145#[macro_export]
146macro_rules! slog_debug {
147 ($($arg:tt)*) => {
148 log::debug!("{}{}", $crate::log_ctx::session_prefix(), format!($($arg)*))
149 };
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 /// Reset both thread-locals so each test starts from a clean slate.
157 /// Rust runs tests on a thread pool — without this helper a previous
158 /// test in the same thread could leak its LAST_SESSION value into
159 /// the next one.
160 fn reset_session_state() {
161 CURRENT_SESSION.with(|s| {
162 *s.borrow_mut() = None;
163 });
164 LAST_SESSION.with(|s| {
165 *s.borrow_mut() = None;
166 });
167 }
168
169 #[test]
170 fn with_session_sets_and_clears() {
171 reset_session_state();
172 // Initially no current session
173 CURRENT_SESSION.with(|s| {
174 assert!(s.borrow().is_none());
175 });
176
177 // Set inside with_session
178 with_session(Some("test123".to_string()), || {
179 CURRENT_SESSION.with(|s| {
180 assert_eq!(s.borrow().as_deref(), Some("test123"));
181 });
182 });
183
184 // CURRENT_SESSION cleared after with_session
185 CURRENT_SESSION.with(|s| {
186 assert!(s.borrow().is_none());
187 });
188 }
189
190 #[test]
191 fn with_session_none_is_noop() {
192 reset_session_state();
193 with_session(None, || {
194 CURRENT_SESSION.with(|s| {
195 assert!(s.borrow().is_none());
196 });
197 });
198 }
199
200 #[test]
201 fn session_prefix_format() {
202 reset_session_state();
203 with_session(Some("abcd1234".to_string()), || {
204 assert_eq!(session_prefix(), "[ses_abcd1234] ");
205 });
206
207 // After exiting with_session, CURRENT is None but LAST_SESSION
208 // still carries "abcd1234" — the prefix falls back to it.
209 assert_eq!(session_prefix(), "[ses_abcd1234] ");
210
211 // Truly empty starting state: no prefix.
212 reset_session_state();
213 assert_eq!(session_prefix(), "");
214 }
215
216 #[test]
217 fn session_prefix_does_not_double_prefix_real_ids() {
218 reset_session_state();
219 // Real OpenCode session IDs already start with "ses_" — the
220 // formatter must not turn that into "ses_ses_xxx".
221 with_session(Some("ses_313660571ffeZTsf4koSJwk50Q".to_string()), || {
222 assert_eq!(session_prefix(), "[ses_313660571ffeZTsf4koSJwk50Q] ");
223 });
224 }
225
226 #[test]
227 fn set_session_direct() {
228 reset_session_state();
229 set_session(Some("direct".to_string()));
230 CURRENT_SESSION.with(|s| {
231 assert_eq!(s.borrow().as_deref(), Some("direct"));
232 });
233 set_session(None);
234 CURRENT_SESSION.with(|s| {
235 assert!(s.borrow().is_none());
236 });
237 // LAST_SESSION still remembers it for the fallback.
238 LAST_SESSION.with(|s| {
239 assert_eq!(s.borrow().as_deref(), Some("direct"));
240 });
241 }
242
243 /// Regression: between-request log sites (filesystem watcher
244 /// invalidation, gitignore rebuilds) run outside a `with_session`
245 /// scope but should still carry a session tag derived from the most
246 /// recent active session on this thread. Without the LAST_SESSION
247 /// fallback these lines appeared untagged in the plugin log.
248 #[test]
249 fn session_prefix_falls_back_to_last_session_after_with_session_exits() {
250 reset_session_state();
251
252 // Simulate a request handled on this thread.
253 with_session(Some("ses_first".to_string()), || {
254 assert_eq!(session_prefix(), "[ses_first] ");
255 });
256
257 // CURRENT_SESSION is now None, but a between-request log site
258 // should still get a tag from LAST_SESSION.
259 assert!(current_session().is_none());
260 assert_eq!(session_prefix(), "[ses_first] ");
261 }
262
263 #[test]
264 fn last_session_updates_with_each_with_session_call() {
265 reset_session_state();
266
267 with_session(Some("ses_first".to_string()), || {});
268 assert_eq!(session_prefix(), "[ses_first] ");
269
270 with_session(Some("ses_second".to_string()), || {});
271 // Newer session wins as the fallback.
272 assert_eq!(session_prefix(), "[ses_second] ");
273 }
274
275 #[test]
276 fn with_session_none_does_not_clear_last_session() {
277 reset_session_state();
278
279 with_session(Some("ses_real".to_string()), || {});
280 assert_eq!(session_prefix(), "[ses_real] ");
281
282 // A subsequent `with_session(None, ...)` call (which can happen
283 // for synthetic / system requests with no session_id) must not
284 // erase the fallback tag from earlier real traffic.
285 with_session(None, || {});
286 assert_eq!(session_prefix(), "[ses_real] ");
287 }
288}