Skip to main content

auth_cloudflare/
observability.rs

1//! Observability - opt-in, local-first structured event logging and
2//! secret-safe protocol redaction.
3//!
4//! Feedback 03 "Observability" contract: no invasive telemetry by default.
5//! This module is **disabled by default**: [`EventLog::from_env`] returns a
6//! no-op log unless `AUTH_CLOUDFLARE_OBSERVABILITY=1`. When enabled, events
7//! are appended as one JSON object per line (JSONL) to a local file - never
8//! shipped anywhere, never read back by this crate.
9//!
10//! # Privacy invariants (binding)
11//!
12//! - The standard event log **never** stores prompt content or full tool
13//!   output. [`Event`] carries only request metadata (model id, counts,
14//!   latency, cost estimate, trace id) - no user text, no secrets.
15//! - [`redact`] is the shared scrubber for any text that might reach a
16//!   developer log: it removes `Authorization` headers, `Bearer` tokens,
17//!   `cfut_`/`cfwt_` token prefixes, `cookie` values, and
18//!   `ENV_VAR=value`-style substrings.
19//! - [`debug_protocol_enabled`] gates an **explicit developer-only** mode
20//!   (`AUTH_CLOUDFLARE_DEBUG_PROTOCOL=1`). That mode only enables *sanitized*
21//!   protocol traces - every trace must still pass through [`redact`] so
22//!   `Authorization`, `Bearer` tokens, cookies, env-var values, known token
23//!   prefixes, and private file contents never reach the log.
24
25use std::io;
26use std::path::{Path, PathBuf};
27
28/// Enables the local event log when set to the exact value `"1"`.
29pub const OBSERVABILITY_ENV: &str = "AUTH_CLOUDFLARE_OBSERVABILITY";
30/// Overrides the event-log file path when set.
31pub const EVENT_LOG_ENV: &str = "AUTH_CLOUDFLARE_EVENT_LOG";
32/// Enables sanitized protocol traces when set to the exact value `"1"`.
33pub const DEBUG_PROTOCOL_ENV: &str = "AUTH_CLOUDFLARE_DEBUG_PROTOCOL";
34/// Default event-log file name under the `~/.hermes/auth-cloudflare/` root.
35const DEFAULT_EVENT_LOG_FILE: &str = "events.jsonl";
36
37/// One structured observability event, serialized as a single snake_case
38/// JSON line.
39///
40/// Deliberately metadata-only: it has no field for prompt content or tool
41/// output, so a plain [`Event`] can never carry user text to disk.
42#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub struct Event {
45	/// RFC 3339 UTC timestamp, e.g. `2026-09-10T12:00:00Z`.
46	pub timestamp: String,
47	/// Event kind, e.g. `chat_completion`, `catalog_fetch`.
48	pub event: String,
49	/// Model identifier, e.g. `@cf/deepseek-ai/deepseek-v4-flash-0731`.
50	pub model_id: String,
51	/// Request surface, e.g. `chat_completions`.
52	pub request_kind: String,
53	/// Whether the request used streaming.
54	pub stream: bool,
55	/// Final status, e.g. an HTTP status code string.
56	pub status: String,
57	/// Total request latency in milliseconds.
58	pub latency_ms: u64,
59	/// Reported input/prompt tokens.
60	pub input_tokens: u64,
61	/// Reported output/completion tokens.
62	pub output_tokens: u64,
63	/// Estimated request cost in USD.
64	pub estimated_cost_usd: f64,
65	/// Number of tool calls issued during the request.
66	pub tool_call_count: u64,
67	/// Cache disposition, e.g. `miss`, `hit`, or empty when unknown.
68	pub cache_status: String,
69	/// Opaque correlation id tying related events together.
70	pub trace_id: String,
71}
72
73/// Local JSONL event log. Disabled (no-op) unless explicitly enabled.
74///
75/// Construct with [`EventLog::from_env`] for the documented opt-in behavior,
76/// or with [`EventLog::new`] / [`EventLog::disabled`] to pin a specific
77/// state (tests, adapters).
78#[derive(Debug, Clone)]
79pub struct EventLog {
80	/// `None` = disabled; `Some(path)` = append events to this JSONL file.
81	path: Option<PathBuf>,
82}
83
84impl EventLog {
85	/// A disabled log: [`Self::record`] is a no-op that writes nothing.
86	pub fn disabled() -> Self {
87		Self { path: None }
88	}
89
90	/// An enabled log pinned to a specific JSONL file path.
91	pub fn new(path: impl Into<PathBuf>) -> Self {
92		Self { path: Some(path.into()) }
93	}
94
95	/// Resolve from the environment.
96	///
97	/// Enabled iff `AUTH_CLOUDFLARE_OBSERVABILITY` is exactly `"1"`. The log
98	/// path is `AUTH_CLOUDFLARE_EVENT_LOG` when set, otherwise
99	/// `~/.hermes/auth-cloudflare/events.jsonl` (respecting `HERMES_HOME`).
100	/// Any other value - including unset - yields a disabled no-op log.
101	pub fn from_env() -> Self {
102		if env_flag_is_one(OBSERVABILITY_ENV) {
103			let path = std::env::var(EVENT_LOG_ENV)
104				.ok()
105				.map(|v| v.trim().to_string())
106				.filter(|v| !v.is_empty())
107				.map(PathBuf::from)
108				.unwrap_or_else(default_log_path);
109			Self::new(path)
110		} else {
111			Self::disabled()
112		}
113	}
114
115	/// True when events will actually be written.
116	pub fn is_enabled(&self) -> bool {
117		self.path.is_some()
118	}
119
120	/// The log file path, when enabled.
121	pub fn path(&self) -> Option<&Path> {
122		self.path.as_deref()
123	}
124
125	/// Append one event as a single JSON line.
126	///
127	/// No-op (returns `Ok(())`) when disabled. When enabled, the parent
128	/// directory is created if needed, the file is opened in append mode,
129	/// and the JSON object plus trailing newline is written in a single
130	/// `write_all`. The file is created user-private (mode `0o600` on Unix).
131	pub fn record(&self, event: Event) -> Result<(), io::Error> {
132		let Some(path) = &self.path else {
133			return Ok(());
134		};
135		use std::io::Write;
136		let mut line = serde_json::to_string(&event).map_err(io::Error::other)?;
137		line.push('\n');
138		if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
139			std::fs::create_dir_all(parent)?;
140		}
141		let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
142		file.write_all(line.as_bytes())?;
143		// 0o600 - user-private operational metadata.
144		#[cfg(unix)]
145		{
146			use std::os::unix::fs::PermissionsExt;
147			let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
148		}
149		Ok(())
150	}
151}
152
153/// True when `AUTH_CLOUDFLARE_DEBUG_PROTOCOL` is exactly `"1"` - the
154/// developer-only gate for *sanitized* protocol traces. Any other value
155/// (including `"true"`, `"0"`, or unset) is `false`.
156pub fn debug_protocol_enabled() -> bool {
157	env_flag_is_one(DEBUG_PROTOCOL_ENV)
158}
159
160/// Scrub secret-bearing text for a developer log or protocol trace.
161///
162/// Removes (case-insensitively):
163/// - `Authorization: <value>` headers - including the `Authorization` key
164///   itself - and a bare `Authorization` word;
165/// - `Bearer <token>` credential values (the `Bearer` keyword is kept, the
166///   token is replaced);
167/// - standalone `cfut_*` / `cfwt_*` token values;
168/// - `cookie` header/attribute values;
169/// - `ENV_VAR=value`-style assignments (the value is replaced).
170///
171/// The caller is responsible for routing prompt content and tool output
172/// through the debug-protocol path only when [`debug_protocol_enabled`] is
173/// true - and even then through this function.
174pub fn redact(text: &str) -> String {
175	let mut out = redact_authorization(text);
176	out = redact_bearer(&out);
177	out = redact_known_tokens(&out);
178	out = redact_cookie(&out);
179	redact_env_values(&out)
180}
181
182/// `AUTH_CLOUDFLARE_*` flag comparison: only the exact string `"1"` counts.
183fn env_flag_is_one(name: &str) -> bool {
184	std::env::var(name).map(|v| v == "1").unwrap_or(false)
185}
186
187/// Default log path: `$HERMES_HOME/auth-cloudflare/events.jsonl`, with
188/// `HERMES_HOME` falling back to `~/.hermes` - matching `crate::config`.
189fn default_log_path() -> PathBuf {
190	hermes_home().join("auth-cloudflare").join(DEFAULT_EVENT_LOG_FILE)
191}
192
193/// Resolve the Hermes config root: `$HERMES_HOME` or `~/.hermes`.
194fn hermes_home() -> PathBuf {
195	std::env::var(crate::cache::HERMES_HOME_ENV)
196		.ok()
197		.map(|v| v.trim().to_string())
198		.filter(|v| !v.is_empty())
199		.map(PathBuf::from)
200		.unwrap_or_else(|| {
201			std::env::var("HOME")
202				.ok()
203				.map(PathBuf::from)
204				.unwrap_or_else(|| PathBuf::from("~"))
205				.join(".hermes")
206		})
207}
208
209/// Byte length of the UTF-8 sequence starting at `b` (all scanned input is
210/// ASCII; this keeps slicing safe on the rare multibyte byte).
211fn utf8_len(b: u8) -> usize {
212	if b < 0x80 {
213		1
214	} else if b >> 5 == 0b110 {
215		2
216	} else if b >> 4 == 0b1110 {
217		3
218	} else if b >> 3 == 0b11110 {
219		4
220	} else {
221		1
222	}
223}
224
225/// True for a continuation byte of a `cfut_`/`cfwt_` token.
226fn is_token_char(b: u8) -> bool {
227	b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
228}
229
230fn is_ident_start(b: u8) -> bool {
231	b.is_ascii_alphabetic() || b == b'_'
232}
233
234fn is_ident_char(b: u8) -> bool {
235	b.is_ascii_alphanumeric() || b == b'_'
236}
237
238/// Redact `Authorization` headers (key + value) and the bare word.
239fn redact_authorization(text: &str) -> String {
240	let bytes = text.as_bytes();
241	let needle = b"Authorization";
242	let mut out = String::with_capacity(text.len());
243	let mut i = 0;
244	while i < bytes.len() {
245		if i + needle.len() <= bytes.len() && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle) {
246			let j = i + needle.len();
247			let mut k = j;
248			while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
249				k += 1;
250			}
251			if k < bytes.len() && bytes[k] == b':' {
252				// Header form: consume the value to end of line.
253				k += 1;
254				while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
255					k += 1;
256				}
257				while k < bytes.len() && bytes[k] != b'\n' && bytes[k] != b'\r' {
258					k += 1;
259				}
260				out.push_str("<redacted>");
261				i = k;
262			} else {
263				// Bare word: drop the key itself.
264				out.push_str("<redacted>");
265				i = j;
266			}
267		} else {
268			let ch_len = utf8_len(bytes[i]);
269			out.push_str(&text[i..i + ch_len]);
270			i += ch_len;
271		}
272	}
273	out
274}
275
276/// Redact `Bearer <token>` credential values (keeps the keyword).
277fn redact_bearer(text: &str) -> String {
278	let bytes = text.as_bytes();
279	let needle = b"Bearer";
280	let mut out = String::with_capacity(text.len());
281	let mut i = 0;
282	while i < bytes.len() {
283		if i + needle.len() <= bytes.len() && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle) {
284			let j = i + needle.len();
285			let mut k = j;
286			while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
287				k += 1;
288			}
289			let tok_start = k;
290			while k < bytes.len()
291				&& bytes[k] < 0x80
292				&& !bytes[k].is_ascii_whitespace()
293				&& bytes[k] != b','
294				&& bytes[k] != b';'
295				&& bytes[k] != b'"'
296			{
297				k += 1;
298			}
299			if k > tok_start {
300				out.push_str(&text[i..tok_start]); // "Bearer" + whitespace
301				out.push_str("<redacted>");
302				i = k;
303			} else {
304				let ch_len = utf8_len(bytes[i]);
305				out.push_str(&text[i..i + ch_len]);
306				i += ch_len;
307			}
308		} else {
309			let ch_len = utf8_len(bytes[i]);
310			out.push_str(&text[i..i + ch_len]);
311			i += ch_len;
312		}
313	}
314	out
315}
316
317/// Redact standalone `cfut_*` / `cfwt_*` token values.
318fn redact_known_tokens(text: &str) -> String {
319	const PREFIXES: [&[u8]; 2] = [b"cfut_", b"cfwt_"];
320	let bytes = text.as_bytes();
321	let mut out = String::with_capacity(text.len());
322	let mut i = 0;
323	while i < bytes.len() {
324		let mut matched = false;
325		for prefix in PREFIXES {
326			if i + prefix.len() <= bytes.len() && &bytes[i..i + prefix.len()] == prefix {
327				let mut j = i + prefix.len();
328				while j < bytes.len() && is_token_char(bytes[j]) {
329					j += 1;
330				}
331				out.push_str("<redacted>");
332				i = j;
333				matched = true;
334				break;
335			}
336		}
337		if !matched {
338			let ch_len = utf8_len(bytes[i]);
339			out.push_str(&text[i..i + ch_len]);
340			i += ch_len;
341		}
342	}
343	out
344}
345
346/// Redact `cookie` header/attribute values (keeps the key).
347fn redact_cookie(text: &str) -> String {
348	let bytes = text.as_bytes();
349	let needle = b"cookie";
350	let mut out = String::with_capacity(text.len());
351	let mut i = 0;
352	while i < bytes.len() {
353		if i + needle.len() <= bytes.len() && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle) {
354			let mut k = i + needle.len();
355			while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
356				k += 1;
357			}
358			if k < bytes.len() && (bytes[k] == b':' || bytes[k] == b'=') {
359				k += 1;
360				while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
361					k += 1;
362				}
363				let val_start = k;
364				while k < bytes.len()
365					&& bytes[k] < 0x80
366					&& !bytes[k].is_ascii_whitespace()
367					&& bytes[k] != b','
368					&& bytes[k] != b';'
369					&& bytes[k] != b'"'
370				{
371					k += 1;
372				}
373				if k > val_start {
374					out.push_str(&text[i..val_start]);
375					out.push_str("<redacted>");
376					i = k;
377					continue;
378				}
379			}
380			let ch_len = utf8_len(bytes[i]);
381			out.push_str(&text[i..i + ch_len]);
382			i += ch_len;
383		} else {
384			let ch_len = utf8_len(bytes[i]);
385			out.push_str(&text[i..i + ch_len]);
386			i += ch_len;
387		}
388	}
389	out
390}
391
392/// Redact `ENV_VAR=value`-style assignments (keeps the name and `=`).
393fn redact_env_values(text: &str) -> String {
394	let bytes = text.as_bytes();
395	let mut out = String::with_capacity(text.len());
396	let mut i = 0;
397	while i < bytes.len() {
398		if is_ident_start(bytes[i]) && (i == 0 || !is_ident_char(bytes[i - 1])) {
399			let mut j = i;
400			while j < bytes.len() && is_ident_char(bytes[j]) {
401				j += 1;
402			}
403			if j < bytes.len() && bytes[j] == b'=' {
404				let mut k = j + 1;
405				while k < bytes.len()
406					&& bytes[k] < 0x80
407					&& !bytes[k].is_ascii_whitespace()
408					&& bytes[k] != b','
409					&& bytes[k] != b';'
410				{
411					k += 1;
412				}
413				if k > j + 1 {
414					out.push_str(&text[i..j]);
415					out.push('=');
416					out.push_str("<redacted>");
417					i = k;
418					continue;
419				}
420			}
421		}
422		let ch_len = utf8_len(bytes[i]);
423		out.push_str(&text[i..i + ch_len]);
424		i += ch_len;
425	}
426	out
427}
428
429#[cfg(test)]
430mod tests {
431	use super::*;
432
433	/// Every env var this module reads, saved/restored for isolation.
434	const ALL_VARS: &[&str] = &[
435		OBSERVABILITY_ENV,
436		EVENT_LOG_ENV,
437		DEBUG_PROTOCOL_ENV,
438		crate::cache::HERMES_HOME_ENV,
439		"HOME",
440	];
441
442	/// `std::env` is process-global and tests run in parallel - serialize
443	/// env mutation through a static mutex and restore prior values after.
444	static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
445
446	fn with_env<F, R>(vars: &[(&str, Option<&str>)], f: F) -> R
447	where
448		F: FnOnce() -> R,
449	{
450		let _guard = ENV_LOCK.lock().unwrap();
451		let saved: Vec<(String, Option<String>)> =
452			ALL_VARS.iter().map(|k| ((*k).to_string(), std::env::var(k).ok())).collect();
453		for key in ALL_VARS {
454			std::env::remove_var(key);
455		}
456		for (key, value) in vars {
457			match value {
458				Some(value) => std::env::set_var(key, value),
459				None => std::env::remove_var(key),
460			}
461		}
462		let result = f();
463		for (key, value) in saved {
464			match value {
465				Some(value) => std::env::set_var(&key, value),
466				None => std::env::remove_var(&key),
467			}
468		}
469		result
470	}
471
472	/// Unique scratch dir per test - tests run in parallel.
473	fn scratch_dir(name: &str) -> PathBuf {
474		std::env::temp_dir().join(format!("auth-cloudflare-observability-test-{}-{name}", std::process::id()))
475	}
476
477	fn sample_event() -> Event {
478		Event {
479			timestamp: "2026-09-10T12:00:00Z".to_string(),
480			event: "chat_completion".to_string(),
481			model_id: crate::DEFAULT_MODEL.to_string(),
482			request_kind: "chat_completions".to_string(),
483			stream: false,
484			status: "200".to_string(),
485			latency_ms: 1234,
486			input_tokens: 100,
487			output_tokens: 50,
488			estimated_cost_usd: 0.0042,
489			tool_call_count: 2,
490			cache_status: "miss".to_string(),
491			trace_id: "trace-0001".to_string(),
492		}
493	}
494
495	#[test]
496	fn default_disabled_creates_no_file() {
497		let dir = scratch_dir("default-disabled");
498		let log_path = dir.join("events.jsonl");
499		with_env(&[(EVENT_LOG_ENV, Some(log_path.to_str().unwrap()))], || {
500			let log = EventLog::from_env();
501			assert!(!log.is_enabled(), "observability must be off by default");
502			assert!(log.path().is_none());
503			log.record(sample_event()).expect("no-op record succeeds");
504		});
505		assert!(!log_path.exists(), "disabled log must not create a file");
506		let _ = std::fs::remove_dir_all(&dir);
507	}
508
509	#[test]
510	fn record_writes_one_valid_json_line() {
511		let dir = scratch_dir("record");
512		std::fs::create_dir_all(&dir).expect("create scratch dir");
513		let path = dir.join("events.jsonl");
514		let log = EventLog::new(path.clone());
515		assert!(log.is_enabled());
516		let event = sample_event();
517		log.record(event.clone()).expect("record");
518		let contents = std::fs::read_to_string(&path).expect("read log");
519		let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
520		assert_eq!(lines.len(), 1, "exactly one JSON line must be written");
521		let parsed: Event = serde_json::from_str(lines[0]).expect("valid JSON line");
522		assert_eq!(parsed, event);
523		let _ = std::fs::remove_dir_all(&dir);
524	}
525
526	#[test]
527	fn redact_strips_bearer_cfut_and_authorization() {
528		let header = "Authorization: Bearer cfut_secret_token_12345";
529		let out = redact(header);
530		assert!(!out.contains("Authorization"), "Authorization key must be scrubbed");
531		assert!(!out.contains("Bearer cfut_secret_token_12345"), "Bearer token must be scrubbed");
532		assert!(!out.contains("cfut_secret_token_12345"), "cfut_ token must be scrubbed");
533
534		assert!(!redact("token cfut_abc123-def here").contains("cfut_abc123-def"));
535		assert!(!redact("Bearer cfwt_deadbeef").contains("cfwt_deadbeef"));
536		// Case-insensitive header redaction.
537		assert!(!redact("authorization: bearer cfut_lower_xyz").contains("cfut_lower_xyz"));
538	}
539
540	#[test]
541	fn debug_protocol_enabled_exact_one_only() {
542		with_env(&[(DEBUG_PROTOCOL_ENV, None)], || {
543			assert!(!debug_protocol_enabled(), "unset must be false");
544		});
545		with_env(&[(DEBUG_PROTOCOL_ENV, Some("1"))], || {
546			assert!(debug_protocol_enabled(), "exact 1 must be true");
547		});
548		with_env(&[(DEBUG_PROTOCOL_ENV, Some("0"))], || {
549			assert!(!debug_protocol_enabled(), "0 must be false");
550		});
551		with_env(&[(DEBUG_PROTOCOL_ENV, Some("true"))], || {
552			assert!(!debug_protocol_enabled(), "true must be false");
553		});
554		with_env(&[(DEBUG_PROTOCOL_ENV, Some(" 1 "))], || {
555			assert!(!debug_protocol_enabled(), "whitespace-padded must be false (exact match only)");
556		});
557	}
558
559	#[test]
560	fn event_serde_roundtrip_snake_case() {
561		let event = sample_event();
562		let json = serde_json::to_string(&event).expect("serialize");
563		let back: Event = serde_json::from_str(&json).expect("deserialize");
564		assert_eq!(back, event);
565		assert!(json.contains("\"model_id\""), "keys must be snake_case");
566		assert!(json.contains("\"latency_ms\""));
567		assert!(json.contains("\"input_tokens\""));
568		assert!(json.contains("\"output_tokens\""));
569		assert!(json.contains("\"estimated_cost_usd\""));
570		assert!(json.contains("\"tool_call_count\""));
571		assert!(json.contains("\"cache_status\""));
572		assert!(json.contains("\"trace_id\""));
573	}
574}