Entropic 2.3.8
Local-first agentic inference engine
Loading...
Searching...
No Matches
logging.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
9#include <spdlog/sinks/basic_file_sink.h>
10#include <spdlog/sinks/stdout_color_sinks.h>
11#include <spdlog/sinks/sink.h>
12#include <algorithm>
13#include <array>
14#include <atomic>
15#include <filesystem>
16#include <mutex>
17#include <unordered_map>
18#include <unordered_set>
19
20namespace entropic::log {
21
22static std::once_flag s_init_flag;
23static std::shared_ptr<spdlog::sinks::stderr_color_sink_mt> s_sink;
24
25// gh#59 (v2.3.1): per-handle log dispatcher. Pre-v2.3.1, setup_session()
26// called add_file_sink() which mutated EVERY registered logger globally
27// — so two handles with different log_dirs would each see the other's
28// log lines fan out into their session.log. The dispatcher solves this:
29// a single sink installed once on the default logger consults a
30// thread_local current_handle_id (set via HandleLogScope at API entry)
31// and dispatches to per-handle file sinks. Logs emitted with no
32// handle_id context are silently dropped from the file (they still hit
33// the shared stderr console attached separately).
34
36static thread_local int t_current_handle_id = 0;
37
39class HandleAwareSink final : public spdlog::sinks::sink {
40public:
43 void log(const spdlog::details::log_msg& msg) override {
44 std::shared_ptr<spdlog::sinks::sink> target;
45 int id = t_current_handle_id;
46 {
47 std::lock_guard lk(mu_);
48 auto it = sinks_.find(id);
49 if (it != sinks_.end()) { target = it->second; }
50 }
51 if (target) { target->log(msg); }
52 }
53
55 void flush() override {
56 std::vector<std::shared_ptr<spdlog::sinks::sink>> all;
57 {
58 std::lock_guard lk(mu_);
59 all.reserve(sinks_.size());
60 for (auto& [_, s] : sinks_) { all.push_back(s); }
61 }
62 for (auto& s : all) { s->flush(); }
63 }
64
66 void set_pattern(const std::string& pattern) override {
67 std::lock_guard lk(mu_);
68 pattern_ = pattern;
69 for (auto& [_, s] : sinks_) { s->set_pattern(pattern); }
70 }
71
73 void set_formatter(std::unique_ptr<spdlog::formatter> f) override {
74 // Forward only the pattern shape; the per-handle file sinks
75 // need their own formatter instances since unique_ptr can't be
76 // shared. spdlog uses set_pattern under the hood for typical
77 // use, so this branch is rarely taken.
78 std::lock_guard lk(mu_);
79 if (!sinks_.empty()) {
80 // Apply the same pattern by cloning via set_pattern when
81 // possible; if formatter is a custom one, only the first
82 // sink gets it. Pragmatic — the codebase always uses
83 // set_pattern via set_default_logger's pattern.
84 auto it = sinks_.begin();
85 it->second->set_formatter(std::move(f));
86 }
87 }
88
90 void register_sink(int id, std::shared_ptr<spdlog::sinks::sink> s) {
91 std::lock_guard lk(mu_);
92 sinks_[id] = std::move(s);
93 if (!pattern_.empty()) { sinks_[id]->set_pattern(pattern_); }
94 }
95
97 void unregister_sink(int id) {
98 std::lock_guard lk(mu_);
99 sinks_.erase(id);
100 }
101
104 std::vector<int> registered_ids() const {
105 std::lock_guard lk(mu_);
106 std::vector<int> ids;
107 ids.reserve(sinks_.size());
108 for (auto& [id, _] : sinks_) { ids.push_back(id); }
109 return ids;
110 }
111
112private:
113 mutable std::mutex mu_;
114 std::unordered_map<int, std::shared_ptr<spdlog::sinks::sink>> sinks_;
115 std::string pattern_;
116};
117
118static std::shared_ptr<HandleAwareSink> s_dispatcher;
119
120// gh#59 follow-up (v2.3.7): once the console sink is disabled
121// (set_console_enabled(false)), this flag makes get() strip s_sink
122// from every logger it creates afterward. Without it, loggers built
123// lazily during a turn re-inherit s_sink by copying the default
124// logger's sink list, leaking engine output to fd 2 — which is the
125// TUI's paint stream.
126static std::atomic<bool> s_console_disabled{false};
127
128// gh#58 (v2.2.5): the v2.2.5 sink-dedup workaround. Kept around for
129// backward compatibility with `setup_session()` callers that don't go
130// through `register_handle_log()` yet (e.g. early-init / test fixtures
131// without a handle id). The dispatcher path (v2.3.1) is preferred.
132static std::mutex s_session_paths_mu;
133static std::unordered_set<std::string> s_session_paths;
134
145void init(spdlog::level::level_enum level) {
146 std::call_once(s_init_flag, [level]() {
147 s_sink = std::make_shared<spdlog::sinks::stderr_color_sink_mt>();
148 s_sink->set_level(level);
149 // gh#59 (v2.3.1): install the per-handle dispatcher alongside
150 // stderr on the default logger. Subsystems' loggers (created
151 // via get()) inherit both sinks. The dispatcher routes file
152 // writes based on thread_local current handle_id; stderr is
153 // shared across handles (process-global console is acceptable
154 // since operators reading stderr already see everything).
155 s_dispatcher = std::make_shared<HandleAwareSink>();
156 s_dispatcher->set_level(level);
157 auto root = std::make_shared<spdlog::logger>(
158 "entropic",
159 spdlog::sinks_init_list{s_sink, s_dispatcher});
160 spdlog::set_default_logger(root);
161 spdlog::set_level(level);
162 spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v");
163 });
164}
165
179void add_file_sink(const std::filesystem::path& path) {
180 std::filesystem::create_directories(path.parent_path());
181 auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(
182 path.string(), true); // truncate = true → fresh file each run
183 file_sink->set_level(spdlog::level::trace);
184 file_sink->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v");
185
186 // Add to default logger (inherited by future get() calls)
187 spdlog::default_logger()->sinks().push_back(file_sink);
188
189 // Add to every already-registered logger
190 spdlog::apply_all([&file_sink](std::shared_ptr<spdlog::logger> l) {
191 l->sinks().push_back(file_sink);
192 });
193
194 // Flush on every message — session logs must survive crashes/early exits
195 spdlog::flush_on(spdlog::level::trace);
196}
197
211std::shared_ptr<spdlog::logger> get(const std::string& name) {
212 auto logger = spdlog::get(name);
213 if (logger) {
214 return logger;
215 }
216 if (!s_sink) {
217 init(spdlog::level::info);
218 }
219 auto default_logger = spdlog::default_logger();
220 auto new_logger = std::make_shared<spdlog::logger>(
221 name, default_logger->sinks().begin(),
222 default_logger->sinks().end());
223 // gh#59 follow-up (v2.3.7): if the console was disabled, the
224 // default logger may still carry s_sink (or a copy raced in);
225 // strip it so this lazily-created logger doesn't leak to stderr.
226 if (s_console_disabled.load() && s_sink) {
227 auto& sinks = new_logger->sinks();
228 sinks.erase(
229 std::remove(sinks.begin(), sinks.end(), s_sink),
230 sinks.end());
231 }
232 new_logger->set_level(default_logger->level());
233 spdlog::register_logger(new_logger);
234 return new_logger;
235}
236
243void set_console_enabled(bool enabled) {
244 if (enabled) {
245 return; // default state; nothing installed/removed here
246 }
247 s_console_disabled.store(true);
248 if (!s_sink) {
249 return;
250 }
251 // Strip s_sink from the default logger so future get() copies a
252 // clean list, and from every already-registered logger.
253 auto strip = [](std::shared_ptr<spdlog::logger> l) {
254 auto& sinks = l->sinks();
255 sinks.erase(
256 std::remove(sinks.begin(), sinks.end(), s_sink),
257 sinks.end());
258 };
259 if (auto dl = spdlog::default_logger()) {
260 strip(dl);
261 }
262 spdlog::apply_all(strip);
263}
264
285static void remove_console_sink() {
286 if (!s_sink) { return; }
287 spdlog::apply_all([](std::shared_ptr<spdlog::logger> l) {
288 auto& sinks = l->sinks();
289 sinks.erase(
290 std::remove(sinks.begin(), sinks.end(), s_sink),
291 sinks.end());
292 });
293}
294
305void setup_session(const std::filesystem::path& log_dir) {
306 // gh#67 (v2.3.2): historically this function attached a file sink
307 // to the global spdlog logger tree and removed the stderr sink so
308 // a single-handle process routed cleanly to session.log. After
309 // v2.3.1 (gh#59) introduced per-handle routing via the
310 // HandleAwareSink dispatcher, calling `register_handle_log()` from
311 // configure_dir + configure_from_file already attaches the
312 // handle's session.log file as a per-handle sink. If setup_session
313 // ALSO does the legacy global add_file_sink path, every log line
314 // ends up written twice — once via the dispatcher, once via the
315 // duplicated global sink. That's the v2.3.1 doubled-line + SEGV
316 // regression.
317 //
318 // The function stays in the public ABI because external callers
319 // (TUI launchers, test harnesses) may call it directly. It now
320 // does only the side-effect-free part: truncate session_model.log
321 // so a fresh session starts clean. File-sink wiring is owned by
322 // `register_handle_log()` exclusively.
323 if (log_dir.empty()) { return; }
324 auto model_file = log_dir / "session_model.log";
325 std::error_code ec;
326 std::filesystem::create_directories(model_file.parent_path(), ec);
327 FILE* fp = fopen(model_file.string().c_str(), "w");
328 if (fp) { fclose(fp); }
329}
330
331// ── Content escaping ──────────────────────────────────────────
332
346void escape_ansi(const std::string& text, size_t& i,
347 std::string& result)
348{
349 result += "[ESC";
350 if (i + 1 >= text.size() || text[i + 1] != '[') {
351 result += ']';
352 return;
353 }
354 ++i; // skip '['
355 while (i + 1 < text.size() && text[i + 1] != 'm') {
356 ++i;
357 result += text[i];
358 }
359 if (i + 1 < text.size()) { ++i; } // skip 'm'
360 result += ']';
361}
362
369static const std::array<const char*, 256>& escape_table() {
370 static const auto tbl = []() {
371 std::array<const char*, 256> t{};
372 t[static_cast<unsigned char>('\n')] = "\\n";
373 t[static_cast<unsigned char>('\r')] = "\\r";
374 t[static_cast<unsigned char>('\t')] = "\\t";
375 t[static_cast<unsigned char>('{')] = "{{";
376 t[static_cast<unsigned char>('}')] = "}}";
377 return t;
378 }();
379 return tbl;
380}
381
390bool escape_char(char c, std::string& result) {
391 const char* esc = escape_table()[static_cast<unsigned char>(c)];
392 if (!esc) { return false; }
393 result += esc;
394 return true;
395}
396
409std::string escape_content(const std::string& text) {
410 std::string result;
411 result.reserve(text.size() + text.size() / 8);
412 for (size_t i = 0; i < text.size(); ++i) {
413 if (escape_char(text[i], result)) { continue; }
414 if (text[i] == '\033') { escape_ansi(text, i, result); }
415 else { result += text[i]; }
416 }
417 return result;
418}
419
420// ── Formatting helpers ────────────────────────────────────────
421
432 const std::shared_ptr<spdlog::logger>& logger,
433 spdlog::level::level_enum level,
434 const std::string& label,
435 const std::string& text)
436{
437 logger->log(level, "{}: {}", label, escape_content(text));
438}
439
449 const std::shared_ptr<spdlog::logger>& logger,
450 const std::string& label,
451 double ms)
452{
453 logger->info("{}: {:.1f}ms", label, ms);
454}
455
465 const std::shared_ptr<spdlog::logger>& logger,
466 int count,
467 double time_ms)
468{
469 double tok_s = (time_ms > 0.0)
470 ? (static_cast<double>(count) / time_ms * 1000.0) : 0.0;
471 logger->info("{} tokens, {:.1f}ms, {:.1f} tok/s",
472 count, time_ms, tok_s);
473}
474
485 const std::shared_ptr<spdlog::logger>& logger,
486 const std::string& label,
487 const std::string& key,
488 const std::string& value)
489{
490 logger->info("{}: {} -> {}", label, key, value);
491}
492
493// ── gh#59 (v2.3.1): per-handle dispatcher API ─────────────────
494
501 int handle_id, const std::filesystem::path& log_dir)
502{
503 if (handle_id == 0 || log_dir.empty()) { return; }
504 if (!s_dispatcher) { init(spdlog::level::info); }
505
506 auto log_file = log_dir / "session.log";
507 std::filesystem::create_directories(log_file.parent_path());
508 auto file_sink =
509 std::make_shared<spdlog::sinks::basic_file_sink_mt>(
510 log_file.string(), /*truncate=*/true);
511 file_sink->set_level(spdlog::level::trace);
512 file_sink->set_pattern(
513 "[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v");
514 s_dispatcher->register_sink(handle_id, file_sink);
515
516 // Match the v2.2.5 setup_session behavior: truncate
517 // session_model.log too so a fresh session starts clean.
518 auto model_file = log_dir / "session_model.log";
519 FILE* fp = fopen(model_file.string().c_str(), "w");
520 if (fp) { fclose(fp); }
521
522 spdlog::flush_on(spdlog::level::trace);
523}
524
530void unregister_handle_log(int handle_id) {
531 if (!s_dispatcher || handle_id == 0) { return; }
532 s_dispatcher->unregister_sink(handle_id);
533}
534
541 return t_current_handle_id;
542}
543
550 : prev_id_(t_current_handle_id) {
551 t_current_handle_id = handle_id;
552}
553
562
563} // namespace entropic::log
Per-handle file sink dispatcher (installed on default logger).
Definition logging.cpp:39
void set_pattern(const std::string &pattern) override
Apply pattern to all registered sinks.
Definition logging.cpp:66
void flush() override
Flush every registered handle sink.
Definition logging.cpp:55
void unregister_sink(int id)
Remove a handle id's sink registration.
Definition logging.cpp:97
void register_sink(int id, std::shared_ptr< spdlog::sinks::sink > s)
Register/replace the sink for a handle id.
Definition logging.cpp:90
std::vector< int > registered_ids() const
Snapshot of registered ids (tests + diagnostics).
Definition logging.cpp:104
void set_formatter(std::unique_ptr< spdlog::formatter > f) override
Spdlog's set_formatter shim.
Definition logging.cpp:73
void log(const spdlog::details::log_msg &msg) override
Route message to the current thread's handle file sink.
Definition logging.cpp:43
~HandleLogScope()
Exit scope; restores previous handle_id.
Definition logging.cpp:559
HandleLogScope(int handle_id)
Enter scope; sets thread current handle_id.
Definition logging.cpp:549
static thread_local int t_current_handle_id
Thread-local current handle id (0 = no scope active).
Definition logging.cpp:36
bool escape_char(char c, std::string &result)
Append escaped form of c if special, else return false.
Definition logging.cpp:390
void escape_ansi(const std::string &text, size_t &i, std::string &result)
Consume an ANSI CSI sequence starting at the ESC byte.
Definition logging.cpp:346
static const std::array< const char *, 256 > & escape_table()
256-entry table mapping bytes to escape strings (nullptr = passthrough).
Definition logging.cpp:369
static void remove_console_sink()
Set up session logging for a project directory.
Definition logging.cpp:285
spdlog initialization and logger access.
ENTROPIC_EXPORT std::string escape_content(const std::string &text)
Escape content for safe spdlog formatting.
Definition logging.cpp:409
ENTROPIC_EXPORT void add_file_sink(const std::filesystem::path &path)
Add a file sink to all loggers (truncated per run).
Definition logging.cpp:179
ENTROPIC_EXPORT int current_handle_id()
gh#59 (v2.3.1): query the current thread's handle_id.
Definition logging.cpp:540
ENTROPIC_EXPORT void log_tokens(const std::shared_ptr< spdlog::logger > &logger, int count, double time_ms)
Log token count with throughput.
Definition logging.cpp:464
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
ENTROPIC_EXPORT void init(spdlog::level::level_enum level=spdlog::level::info)
Initialize the logging subsystem.
Definition logging.cpp:145
ENTROPIC_EXPORT void set_console_enabled(bool enabled)
Enable or disable the stderr console sink process-wide.
Definition logging.cpp:243
ENTROPIC_EXPORT void register_handle_log(int handle_id, const std::filesystem::path &log_dir)
gh#59 (v2.3.1): register a per-handle session.log file.
Definition logging.cpp:500
ENTROPIC_EXPORT void log_content(const std::shared_ptr< spdlog::logger > &logger, spdlog::level::level_enum level, const std::string &label, const std::string &text)
Log full content with escaping applied.
Definition logging.cpp:431
ENTROPIC_EXPORT void setup_session(const std::filesystem::path &log_dir)
Set up session logging for a project directory.
Definition logging.cpp:305
ENTROPIC_EXPORT void log_decision(const std::shared_ptr< spdlog::logger > &logger, const std::string &label, const std::string &key, const std::string &value)
Log a decision/routing event.
Definition logging.cpp:484
ENTROPIC_EXPORT void unregister_handle_log(int handle_id)
gh#59 (v2.3.1): release a handle's session.log file sink.
Definition logging.cpp:530
ENTROPIC_EXPORT void log_timing(const std::shared_ptr< spdlog::logger > &logger, const std::string &label, double ms)
Log a timing measurement.
Definition logging.cpp:448