Skip to main content

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}