Skip to main content

velesdb_memory/
logging.rs

1//! Per-request observability, gated by `VELESDB_MEMORY_LOG` (#1780).
2//!
3//! The daemon used to emit nothing per request: no `tracing` subscriber was
4//! ever installed, so both this crate's events and everything rmcp already
5//! emits about session lifecycles (idle timeouts, dead channels — exactly
6//! the signals #1727 needed) were discarded. #1727 was then diagnosed twice
7//! on a wrong cause, and settling it took a throwaway HTTP probe written
8//! outside the repository.
9//!
10//! This module is the deliberately narrow fix: one env var, silent by
11//! default.
12//!
13//! - `VELESDB_MEMORY_LOG` unset (or blank) installs **no subscriber at
14//!   all** — the daemon behaves byte-for-byte as before.
15//! - Set, its value is a standard `EnvFilter` directive list (e.g. `info`
16//!   or `info,rmcp=debug`), rendered to **stderr only**: on the stdio
17//!   transport stdout carries the MCP protocol itself, and one log byte
18//!   there would corrupt the stream. The HTTP daemon's stderr is already
19//!   captured by launchd (`~/Library/Logs/velesdb-memory/daemon.err.log`),
20//!   so a log line lands where an operator already looks.
21//!
22//! What gets traced lives at the call sites (`http::trace_mcp_http`,
23//! `mcp`'s `call_tool`): tool name, session id, verdict, duration — never
24//! an argument, a payload, or fact content (`tests/daemon_logging.rs`
25//! proves that with canaries). This module also owns the vocabulary those
26//! two events share — the absent-session placeholder, the duration helper —
27//! so the pair cannot drift apart.
28
29use std::time::Instant;
30
31/// The env var that turns logging on. Named (rather than `RUST_LOG`) so an
32/// ambient `RUST_LOG` in a developer's shell cannot make the daemon
33/// talkative by accident — enabling logs here is an explicit, per-daemon
34/// decision.
35pub const LOG_ENV_VAR: &str = "VELESDB_MEMORY_LOG";
36
37/// The filter an operator should run to diagnose a session incident (#1727):
38/// this crate's per-request events, plus rmcp's session-lifecycle signals —
39/// and **no client content, ever**, which is the property that makes it safe
40/// to leave on in a deployed daemon (`scripts/install-memory-daemon.sh`
41/// wires exactly this string into the launchd plist; a test below refuses
42/// drift). Directive by directive:
43///
44/// - `info` — this crate's own per-request events (transport and tool).
45/// - `rmcp::service=error` — NOT `warn` or the bare default: at `warn`,
46///   rmcp's `response error` event dumps the whole `ErrorData`, and several
47///   `MemoryError` messages quote client input verbatim (an invalid filter's
48///   field name, the full offending JSON value). The #1780 review proved
49///   that leak in execution; `tests/daemon_logging.rs` pins it with
50///   error-path canaries. At `info` the same target also dumps
51///   notifications. `error` keeps only content-free faults.
52/// - `rmcp::transport::worker=debug` — carries `WorkerQuitReason`, including
53///   the idle-timeout that is THE #1727 signal (a session whose worker died
54///   of inactivity while the session stayed in the table).
55/// - `rmcp::transport::streamable_http_server=debug` — session/channel
56///   lifecycle (open, close, dead channel), all content-free at that level.
57///
58/// Broader rmcp verbosity DUMPS REQUEST CONTENT: `rmcp::service` logs every
59/// request's full arguments — fact text included — at `debug`, and the
60/// transport tower logs whole messages at `trace`. So `rmcp=debug` is NOT a
61/// harmless step up from this preset; it is the payload firehose, acceptable
62/// only for deliberate wire debugging on data that may land in a log file.
63/// `tests/daemon_logging.rs` captures under THIS preset and asserts canaries
64/// (fact content on the happy path, client input on the error path) never
65/// reach the log — if a dependency upgrade (e.g. rmcp 3.x, #1789) moves a
66/// dump to a level this preset admits, those tests go red before the leak
67/// ships.
68pub const INCIDENT_PRESET: &str = "info,rmcp::service=error,rmcp::transport::worker=debug,rmcp::transport::streamable_http_server=debug";
69
70/// Read [`LOG_ENV_VAR`] and install the stderr subscriber it asks for.
71/// Unset or blank installs nothing — see the module docs.
72///
73/// # Errors
74/// A value that does not parse as `EnvFilter` directives, or a subscriber
75/// already installed for this process. Both abort startup rather than run
76/// the daemon with logging silently different from what the operator asked
77/// for — same posture as the config file (`crate::config`): a daemon
78/// quietly running on defaults the operator believes they overrode is worse
79/// than a loud failure at boot.
80pub fn init_from_env() -> Result<(), String> {
81    match filter_from_raw(std::env::var(LOG_ENV_VAR).ok().as_deref())? {
82        None => Ok(()),
83        Some(filter) => install(filter),
84    }
85}
86
87/// The parsing half of [`init_from_env`], taking the raw value instead of
88/// reading it — same testability idiom as `http::keep_alive_from_raw`
89/// (process-wide env vars are shared mutable state under a parallel test
90/// runner).
91///
92/// `None` and blank mean "no logging requested" and yield `Ok(None)`; any
93/// other value must be a valid `EnvFilter` directive list.
94///
95/// # Errors
96/// A set, non-blank value that `EnvFilter` refuses, with the exact
97/// directive text and the var's name in the message.
98fn filter_from_raw(raw: Option<&str>) -> Result<Option<tracing_subscriber::EnvFilter>, String> {
99    let Some(directives) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
100        return Ok(None);
101    };
102    tracing_subscriber::EnvFilter::try_new(directives)
103        .map(Some)
104        .map_err(|err| {
105            format!(
106                "{LOG_ENV_VAR}='{directives}' is not a valid filter ({err}) — use EnvFilter \
107                 directives, e.g. 'info' or 'info,rmcp=debug', or unset it for silence"
108            )
109        })
110}
111
112/// Install the stderr `fmt` subscriber filtered by `filter`.
113fn install(filter: tracing_subscriber::EnvFilter) -> Result<(), String> {
114    let subscriber = tracing_subscriber::fmt()
115        .with_env_filter(filter)
116        .with_writer(std::io::stderr)
117        .with_ansi(false)
118        .finish();
119    tracing::subscriber::set_global_default(subscriber)
120        .map_err(|err| format!("{LOG_ENV_VAR}: cannot install the log subscriber: {err}"))
121}
122
123/// What the `session` field carries when a request has none (stdio, or an
124/// `initialize` that hasn't been assigned one yet). A stable placeholder
125/// rather than an omitted field, so `grep session=` matches every event.
126pub(crate) const NO_SESSION: &str = "-";
127
128/// Milliseconds since `started`, saturating instead of panicking — shared by
129/// the transport- and tool-level trace events so the two report durations
130/// that are comparable by construction.
131pub(crate) fn elapsed_millis(started: Instant) -> u64 {
132    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
133}
134
135#[cfg(test)]
136#[path = "logging_tests.rs"]
137mod tests;