-
Notifications
You must be signed in to change notification settings - Fork 0
Feature Enhanced Logging Plan
Feature ID: CRD-001
Spec: .speckit/features/enhanced-logging/spec.md
Branch: 002-enhanced-logging
Created: 2026-01-22
┌─────────────────────────────────────────────────────────────────┐
│ CCH Binary │
├─────────────────────────────────────────────────────────────────┤
│ main.rs │
│ ├── Parse --debug-logs flag │
│ ├── Check CCH_DEBUG_LOGS env var │
│ └── Pass DebugConfig to process_event() │
├─────────────────────────────────────────────────────────────────┤
│ hooks.rs │
│ ├── process_event(event, debug_config) → Response │
│ ├── Build EventDetails from Event │
│ ├── Track RuleEvaluation per rule (debug mode) │
│ ├── Build ResponseSummary from Response │
│ └── Construct enhanced LogEntry │
├─────────────────────────────────────────────────────────────────┤
│ models.rs (Enhanced) │
│ ├── EventDetails enum (Bash, Write, Edit, Read, etc.) │
│ ├── ResponseSummary struct │
│ ├── RuleEvaluation struct │
│ ├── MatcherResults struct │
│ └── LogEntry (extended with new fields) │
├─────────────────────────────────────────────────────────────────┤
│ config.rs (Enhanced) │
│ └── Settings.debug_logs: bool │
└─────────────────────────────────────────────────────────────────┘
Decision: Implement EventDetails::extract() as a standalone function in a new extract module.
Rationale:
- Keeps
models.rsclean (data structures only) - Allows unit testing extraction logic independently
- Follows single responsibility principle
Implementation:
// cch_cli/src/extract.rs
impl EventDetails {
pub fn extract(event: &Event) -> Self { ... }
}Decision: Create a DebugConfig struct to encapsulate debug settings from all sources.
Rationale:
- Single source of truth for debug state
- Clean API for
process_event() - Easy to extend with future debug options
Implementation:
pub struct DebugConfig {
pub enabled: bool,
pub include_raw_event: bool,
pub include_rule_evaluations: bool,
}
impl DebugConfig {
pub fn from_env_and_config(config: &Config, cli_flag: bool) -> Self {
let enabled = cli_flag
|| std::env::var("CCH_DEBUG_LOGS").is_ok()
|| config.settings.debug_logs;
Self {
enabled,
include_raw_event: enabled,
include_rule_evaluations: enabled,
}
}
}Decision: Track matcher results during rule evaluation, not as a separate pass.
Rationale:
- Avoids duplicate regex compilation
- Captures actual match state (not re-computed)
- Minimal performance overhead (just struct building)
Implementation:
- Modify
matches_rule()to return(bool, MatcherResults)when debug mode enabled - Store results in
Vec<RuleEvaluation>during evaluation loop
Decision: All new LogEntry fields are Option<T> with #[serde(skip_serializing_if = "Option::is_none")].
Rationale:
- Existing log parsers ignore unknown fields (JSON standard)
- Old logs still parse correctly
- No schema version bump required (but recommend logging schema version)
Decision: Add extraction logic directly to models.rs as impl EventDetails block, rather than creating events/ module.
Rationale:
- Simpler structure for this scope
- EventDetails is tightly coupled to Event struct
- Avoids over-engineering for ~100 lines of extraction code
Changes:
- Add
EventDetailsenum afterLogMetadata(line ~232) - Add
ResponseSummarystruct - Add
RuleEvaluationandMatcherResultsstructs - Add
DebugConfigstruct - Extend
LogEntrywith 4 new optional fields - Add
impl EventDetailswithextract()method
New Code (~150 lines):
// === Enhanced Logging Types ===
/// Typed event details for known tools
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "tool_type")]
pub enum EventDetails {
Bash { command: String },
Write { file_path: String },
Edit { file_path: String },
Read { file_path: String },
Glob {
pattern: Option<String>,
path: Option<String>
},
Grep {
pattern: Option<String>,
path: Option<String>
},
Session {
source: Option<String>,
reason: Option<String>,
transcript_path: Option<String>,
cwd: Option<String>,
},
Permission {
permission_mode: Option<String>,
tool_details: Box<EventDetails>,
},
Unknown {
tool_name: Option<String>
},
}
/// Summary of response sent to Claude
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResponseSummary {
#[serde(rename = "continue")]
pub continue_: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_length: Option<usize>,
}
/// Per-rule evaluation details (debug mode)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RuleEvaluation {
pub rule_name: String,
pub matched: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher_results: Option<MatcherResults>,
}
/// Individual matcher results
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MatcherResults {
#[serde(skip_serializing_if = "Option::is_none")]
pub tools_matched: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions_matched: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub directories_matched: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command_match_matched: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operations_matched: Option<bool>,
}
/// Debug mode configuration
#[derive(Debug, Clone, Default)]
pub struct DebugConfig {
pub enabled: bool,
}
impl DebugConfig {
pub fn new(cli_flag: bool, config_setting: bool) -> Self {
let enabled = cli_flag
|| std::env::var("CCH_DEBUG_LOGS").is_ok()
|| config_setting;
Self { enabled }
}
}LogEntry Extension:
pub struct LogEntry {
// ... existing fields ...
// === New fields ===
#[serde(skip_serializing_if = "Option::is_none")]
pub event_details: Option<EventDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<ResponseSummary>,
// === Debug mode fields ===
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_event: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_evaluations: Option<Vec<RuleEvaluation>>,
}Add impl EventDetails block:
impl EventDetails {
/// Extract typed details from an Event
pub fn extract(event: &Event) -> Self {
let tool_name = event.tool_name.as_deref();
let tool_input = event.tool_input.as_ref();
match tool_name {
Some("Bash") => {
let command = tool_input
.and_then(|ti| ti.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
EventDetails::Bash { command }
}
Some("Write") => {
let file_path = tool_input
.and_then(|ti| ti.get("file_path").or_else(|| ti.get("filePath")))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
EventDetails::Write { file_path }
}
Some("Edit") => {
let file_path = tool_input
.and_then(|ti| ti.get("file_path").or_else(|| ti.get("filePath")))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
EventDetails::Edit { file_path }
}
Some("Read") => {
let file_path = tool_input
.and_then(|ti| ti.get("file_path").or_else(|| ti.get("filePath")))
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
EventDetails::Read { file_path }
}
Some("Glob") => {
let pattern = tool_input
.and_then(|ti| ti.get("pattern"))
.and_then(|p| p.as_str())
.map(String::from);
let path = tool_input
.and_then(|ti| ti.get("path"))
.and_then(|p| p.as_str())
.map(String::from);
EventDetails::Glob { pattern, path }
}
Some("Grep") => {
let pattern = tool_input
.and_then(|ti| ti.get("pattern"))
.and_then(|p| p.as_str())
.map(String::from);
let path = tool_input
.and_then(|ti| ti.get("path"))
.and_then(|p| p.as_str())
.map(String::from);
EventDetails::Grep { pattern, path }
}
None if matches!(event.hook_event_name, EventType::SessionStart | EventType::SessionEnd) => {
let source = tool_input
.and_then(|ti| ti.get("source"))
.and_then(|s| s.as_str())
.map(String::from);
let reason = tool_input
.and_then(|ti| ti.get("reason"))
.and_then(|r| r.as_str())
.map(String::from);
let transcript_path = tool_input
.and_then(|ti| ti.get("transcript_path"))
.and_then(|t| t.as_str())
.map(String::from);
let cwd = tool_input
.and_then(|ti| ti.get("cwd"))
.and_then(|c| c.as_str())
.map(String::from);
EventDetails::Session { source, reason, transcript_path, cwd }
}
_ => EventDetails::Unknown {
tool_name: tool_name.map(String::from)
},
}
}
}
impl ResponseSummary {
/// Create from a Response
pub fn from_response(response: &Response) -> Self {
Self {
continue_: response.continue_,
reason: response.reason.clone(),
context_length: response.context.as_ref().map(|c| c.len()),
}
}
}Changes:
- Modify
process_event()signature to acceptDebugConfig - Build
EventDetailsfrom event - Build
ResponseSummaryfrom response - Conditionally include
raw_eventandrule_evaluations - Track matcher results when debug mode enabled
Key Changes:
/// Process a hook event and return the appropriate response
pub async fn process_event(event: Event, debug_config: &DebugConfig) -> Result<Response> {
let start_time = std::time::Instant::now();
// Load configuration
let config = Config::load(None)?;
// Extract event details (always)
let event_details = EventDetails::extract(&event);
// Evaluate rules (with optional debug tracking)
let (matched_rules, response, rule_evaluations) =
evaluate_rules(&event, &config, debug_config).await?;
let processing_time = start_time.elapsed().as_millis() as u64;
// Build response summary
let response_summary = ResponseSummary::from_response(&response);
// Log the event with enhanced fields
let entry = LogEntry {
timestamp: event.timestamp,
event_type: format!("{:?}", event.hook_event_name),
session_id: event.session_id.clone(),
tool_name: event.tool_name.clone(),
rules_matched: matched_rules.into_iter().map(|r| r.name.clone()).collect(),
outcome: match response.continue_ {
true if response.context.is_some() => Outcome::Inject,
true => Outcome::Allow,
false => Outcome::Block,
},
timing: LogTiming {
processing_ms: processing_time,
rules_evaluated: config.enabled_rules().len(),
},
metadata: Some(LogMetadata {
injected_files: response
.context
.as_ref()
.map(|_| vec!["injected".to_string()]),
validator_output: None,
}),
// New fields
event_details: Some(event_details),
response: Some(response_summary),
raw_event: if debug_config.enabled {
Some(serde_json::to_value(&event).unwrap_or_default())
} else {
None
},
rule_evaluations: if debug_config.enabled {
Some(rule_evaluations)
} else {
None
},
};
// ... rest of function
}Add to Settings struct:
pub struct Settings {
// ... existing fields ...
/// Enable debug logging with full event and rule details
#[serde(default)]
pub debug_logs: bool,
}Changes:
- Add
--debug-logsglobal flag - Build
DebugConfigfrom all sources - Pass to
process_event()
#[derive(Parser)]
#[command(name = "cch")]
struct Cli {
/// Enable debug logging with full event and rule details
#[arg(long, global = true)]
debug_logs: bool,
#[command(subcommand)]
command: Option<Commands>,
}
// In main():
let debug_config = DebugConfig::new(cli.debug_logs, config.settings.debug_logs);
let response = hooks::process_event(event, &debug_config).await?;Add unit tests for EventDetails extraction:
#[cfg(test)]
mod event_details_tests {
use super::*;
#[test]
fn test_extract_bash_event() {
let event = Event {
hook_event_name: EventType::PreToolUse,
tool_name: Some("Bash".to_string()),
tool_input: Some(serde_json::json!({
"command": "git push --force"
})),
session_id: "test".to_string(),
timestamp: Utc::now(),
user_id: None,
};
let details = EventDetails::extract(&event);
assert!(matches!(details, EventDetails::Bash { command } if command == "git push --force"));
}
#[test]
fn test_extract_unknown_tool() {
let event = Event {
hook_event_name: EventType::PreToolUse,
tool_name: Some("FutureTool".to_string()),
tool_input: None,
session_id: "test".to_string(),
timestamp: Utc::now(),
user_id: None,
};
let details = EventDetails::extract(&event);
assert!(matches!(details, EventDetails::Unknown { tool_name } if tool_name == Some("FutureTool".to_string())));
}
// ... tests for each tool type
}Integration tests for enhanced logging:
- Test normal mode log structure
- Test debug mode includes
raw_event - Test debug mode includes
rule_evaluations - Test
--debug-logsflag - Test
CCH_DEBUG_LOGSenvironment variable - Test config
debug_logs: truesetting
| Phase | Files | Estimated LOC | Priority |
|---|---|---|---|
| 1 |
models.rs (data structures) |
+120 | P1 |
| 2 |
models.rs (extraction impl) |
+80 | P1 |
| 3 |
hooks.rs (integration) |
+40, ~20 modified | P1 |
| 4 |
config.rs, main.rs (plumbing) |
+15 | P2 |
| 5 |
models.rs tests, new integration test |
+150 | P1 |
| Total | ~400 new LOC |
| Risk | Mitigation |
|---|---|
| Breaking existing log parsers | All new fields are Option<T> with skip_serializing_if
|
| Performance regression from debug tracking | Matcher tracking only builds structs, no extra regex compilation |
| Large log files in debug mode | Document that debug mode should be temporary; recommend log rotation |
| Permission event recursion | Use Box<EventDetails> for heap allocation |
After implementation, verify:
-
Normal Mode:
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"},"session_id":"test"}' | cch # Check ~/.claude/logs/cch.log contains event_details and response
-
Debug Mode (CLI flag):
echo '...' | cch --debug-logs # Check log contains raw_event and rule_evaluations
-
Debug Mode (env var):
CCH_DEBUG_LOGS=1 echo '...' | cch # Check log contains raw_event and rule_evaluations
-
Backward Compatibility:
# Parse old log entries with new code - should work # Parse new log entries with old tools - unknown fields ignored
-
All Tests Pass:
cargo test # All 47+ existing tests pass # New enhanced logging tests pass
-
Spec:
.speckit/features/enhanced-logging/spec.md -
PRD:
docs/prds/change_request/CRD-001-enhanced-logging.md -
Current Models:
cch_cli/src/models.rs -
Current Hooks:
cch_cli/src/hooks.rs