1use std::io;
26use std::path::{Path, PathBuf};
27
28pub const OBSERVABILITY_ENV: &str = "AUTH_CLOUDFLARE_OBSERVABILITY";
30pub const EVENT_LOG_ENV: &str = "AUTH_CLOUDFLARE_EVENT_LOG";
32pub const DEBUG_PROTOCOL_ENV: &str = "AUTH_CLOUDFLARE_DEBUG_PROTOCOL";
34const DEFAULT_EVENT_LOG_FILE: &str = "events.jsonl";
36
37#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub struct Event {
45 pub timestamp: String,
47 pub event: String,
49 pub model_id: String,
51 pub request_kind: String,
53 pub stream: bool,
55 pub status: String,
57 pub latency_ms: u64,
59 pub input_tokens: u64,
61 pub output_tokens: u64,
63 pub estimated_cost_usd: f64,
65 pub tool_call_count: u64,
67 pub cache_status: String,
69 pub trace_id: String,
71}
72
73#[derive(Debug, Clone)]
79pub struct EventLog {
80 path: Option<PathBuf>,
82}
83
84impl EventLog {
85 pub fn disabled() -> Self {
87 Self { path: None }
88 }
89
90 pub fn new(path: impl Into<PathBuf>) -> Self {
92 Self { path: Some(path.into()) }
93 }
94
95 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 pub fn is_enabled(&self) -> bool {
117 self.path.is_some()
118 }
119
120 pub fn path(&self) -> Option<&Path> {
122 self.path.as_deref()
123 }
124
125 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 #[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
153pub fn debug_protocol_enabled() -> bool {
157 env_flag_is_one(DEBUG_PROTOCOL_ENV)
158}
159
160pub 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
182fn env_flag_is_one(name: &str) -> bool {
184 std::env::var(name).map(|v| v == "1").unwrap_or(false)
185}
186
187fn default_log_path() -> PathBuf {
190 hermes_home().join("auth-cloudflare").join(DEFAULT_EVENT_LOG_FILE)
191}
192
193fn 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
209fn 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
225fn 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
238fn 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 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 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
276fn 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]); 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
317fn 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
346fn 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
392fn 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 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 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 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 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}