
You're running Claude Code in production. Maybe you're automating deployment scripts, managing sensitive files, or orchestrating complex workflows that touch infrastructure. At some point, someone's going to ask: what did Claude actually do? That's when you'll wish you had an audit trail. And here's the good news—PostToolUse hooks make this straightforward. Instead of guessing what happened or piecing together logs from multiple systems, you get a complete record of every tool invocation, exactly what was passed in, and what came back. We're talking compliance-ready logging with zero extra work on your part once it's set up.
This isn't paranoia. In production systems, audit trails do three critical things: they prove what happened (compliance and forensics), they help you debug when things go wrong (troubleshooting), and they build trust with stakeholders who need confidence that automation is safe.
The audit trail becomes your defense against ambiguity. When a change causes a problem and fingers start pointing, an audit trail eliminates speculation. You don't have to argue about whether Claude made a change or why. The logs show exactly what happened, when, and with what inputs and outputs. This transforms difficult conversations from "I think Claude did X" to "Here's proof: Claude received Y, executed Z, and got these results." That's powerful in production environments where accountability matters.
Table of Contents
- Why Audit Trails Matter
- The Hook: PostToolUse Mechanics
- Building the Audit Hook
- Seeing It In Action
- Parsing and Analyzing Your Audit Log
- Handling Sensitive Data Safely
- Archiving and Retention
- Integration with Monitoring Systems
- Compliance Considerations
- Putting It Together: Complete Setup
- Real-World Scenarios Where Audit Trails Save You
- Querying Your Audit Log
- Performance Considerations
- Troubleshooting Common Issues
- Testing Your Audit Hook
- Operational Impact and Performance Considerations
- Building Institutional Knowledge Through Audit Trails
- The Hidden Value: Using Audit Trails for Continuous Improvement
- Organizational Adoption: From Skepticism to Confidence
- Key Takeaways
- Beyond Compliance: Using Audit Trails for Strategic Advantage
Why Audit Trails Matter
Let's be blunt about the stakes. If you're using Claude Code to make actual changes—writing files, executing commands, modifying infrastructure, deleting data—you need to know what happened and when. Not for paranoia, but for legitimacy and accountability.
Audit trails serve three concrete purposes that will save you time and reputation:
Compliance and Forensics: When something goes wrong (or right, but someone claims it went wrong), you need proof. "Claude made that change at 3:47pm, here's exactly what it did" is infinitely better than "I think Claude did something Tuesday." If you're in a regulated industry (financial services, healthcare, data protection), audit trails become mandatory. You don't have a choice—you need them. And if you don't have them, regulators will assume the worst.
Debugging Your Workflows: Sometimes you'll see unexpected results and need to trace back. Did the tool actually receive what you thought it received? Did the output get parsed correctly? Did Claude misunderstand the input? An audit log answers these instantly. Instead of "something went wrong," you have "writeFile received this exact content, wrote 2,847 bytes, returned success at 14:23:45." That precision cuts debugging time from hours to minutes.
Building Trust: This is the human side. When you show a stakeholder a detailed, timestamped, cryptographically-verifiable log of every action Claude took, they stop worrying about the black box. You're not hiding anything because everything's recorded. The transparency is powerful—it's the difference between "Claude is dangerous, don't let it touch that" and "Claude is safe, look at the audit trail, it only touched exactly what we told it to."
These three purposes are genuinely important. The compliance reason is perhaps most obvious—if you're in a regulated industry, it's not optional. But the debugging reason is underrated. Production systems are complicated. When something unexpected happens, the first instinct is usually "how do I reproduce this?" An audit trail lets you reproduce it in fast-forward. You don't have to figure out what Claude was thinking; you have the exact sequence of events. And the trust reason is surprisingly powerful. Security teams are naturally suspicious of automation. But when you can show them a detailed log of exactly what happened, skepticism transforms into confidence. They're not allowing Claude to touch production because they trust Claude—they're allowing it because they trust the audit trail.
The Hook: PostToolUse Mechanics
Let's talk about why PostToolUse is the right place for this. Claude Code fires several hooks throughout its lifecycle:
- PreToolUse: Fires before a tool is invoked. Good for validation and blocking dangerous operations. But at this point you don't have the outcome yet.
- PostToolUse: Fires after the tool completes and you have the result. Perfect for logging because you have all the facts.
- PostExecution: Fires after the entire command finishes. Good for summary logging.
PostToolUse is ideal because at that point you have complete information: the tool name, input parameters, output, execution time, success/failure, and any errors. You're not guessing—you're recording facts. Every piece of data is known and verified.
Building the Audit Hook
Let's write a practical audit hook that logs to JSONL format (one JSON object per line). This format is perfect for compliance because it's both human-readable (auditors can read it) and machine-parseable (systems can analyze it). It's also immutable—you append only, never overwrite, so your log is a permanent record.
// .claude/hooks/post-tool-use-audit.mjs
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AUDIT_LOG = path.join(__dirname, "../logs/audit-trail.jsonl");
// Ensure logs directory exists (idempotent)
function ensureLogsDir() {
const logsDir = path.dirname(AUDIT_LOG);
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
}
export async function postToolUse(context) {
try {
ensureLogsDir();
// Extract what we care about from the hook context
const toolName = context.toolName;
const toolInput = context.toolInput;
const toolOutput = context.toolOutput;
const executionTime = context.executionTimeMs || 0;
const timestamp = new Date().toISOString(); // ISO 8601, sorts naturally
const errorOccurred = context.error ? true : false;
// Build the audit entry—structured and complete
const auditEntry = {
timestamp,
toolName,
input: sanitizeForLogging(toolInput),
output: sanitizeForLogging(toolOutput),
executionTimeMs: executionTime,
error: errorOccurred,
errorMessage: errorOccurred ? context.error.message : null,
status: errorOccurred ? "FAILED" : "SUCCESS",
};
// Append to JSONL (one entry per line, makes it queryable)
const logLine = JSON.stringify(auditEntry) + "\n";
fs.appendFileSync(AUDIT_LOG, logLine, "utf8");
console.log(`✓ Audit logged: ${toolName}`);
} catch (err) {
// Don't throw—we don't want logging errors to break the main workflow
console.error("Audit logging failed:", err.message);
}
}
// Strip sensitive patterns from logs to prevent credential leaks
function sanitizeForLogging(value) {
if (typeof value !== "string") {
return value;
}
// Remove API keys, tokens, passwords (basic pattern matching)
let sanitized = value.replace(
/api[_-]?key[=:]\s*["']?[^\s"']+/gi,
"api_key=***",
);
sanitized = sanitized.replace(/token[=:]\s*["']?[^\s"']+/gi, "token=***");
sanitized = sanitized.replace(
/password[=:]\s*["']?[^\s"']+/gi,
"password=***",
);
sanitized = sanitized.replace(/secret[=:]\s*["']?[^\s"']+/gi, "secret=***");
return sanitized;
}What's happening here step by step:
-
Import what we need: We're using Node's
fsmodule to append logs to a file, andpathto construct a consistent log file location. We usefileURLToPathto convert ES module URLs to filesystem paths. -
Ensure the logs directory exists: Before we can write, we need
.claude/logs/to exist. TheensureLogsDir()function creates it if needed, idempotently (safe to call multiple times). -
Extract context fields: The
contextobject passed topostToolUsecontains everything about the tool invocation. We grab the important bits:toolName,toolInput,toolOutput,executionTime, and whether an error occurred. -
Build the audit entry: We create a structured object with a timestamp in ISO 8601 format (so it sorts naturally and is human-readable), the tool details, and a status. This is your "what happened" record.
-
Append to JSONL: Each entry becomes one line in the audit log. JSONL is great for this because you can process it line-by-line without loading everything into memory, and each line is valid JSON. For compliance, this immutable append-only design is important.
-
Sanitization: The
sanitizeForLogging()helper strips out common patterns like API keys, tokens, and passwords. This prevents accidentally logging secrets. This is critical—if you log a credential, your audit trail becomes as sensitive as the credential itself.
Seeing It In Action
Let's trace through what happens when you run a command that uses tools.
Scenario: You run a command that calls readFile, then bash, then writeFile.
$ claude-code --cwd /my/project
> Read the current package.json and run npm test
[Claude processes your request...]Each tool invocation triggers the hook. Your audit log grows:
{"timestamp":"2026-03-17T14:23:45.123Z","toolName":"readFile","input":{"file_path":"/my/project/package.json"},"output":"{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}","executionTimeMs":45,"error":false,"errorMessage":null,"status":"SUCCESS"}
{"timestamp":"2026-03-17T14:23:46.456Z","toolName":"bash","input":{"command":"npm test"},"output":"PASS src/__tests__/main.test.js\n✓ should render correctly (234ms)","executionTimeMs":2341,"error":false,"errorMessage":null,"status":"SUCCESS"}
{"timestamp":"2026-03-17T14:23:48.789Z","toolName":"writeFile","input":{"file_path":"/my/project/results.txt","content":"Tests passed!"},"output":"Written 14 bytes","executionTimeMs":12,"error":false,"errorMessage":null,"status":"SUCCESS"}Perfect. You now have a complete trail. Every tool call is recorded with timing, inputs, outputs, and status. If something unexpected happened, you can see exactly what was called and what it returned. This is audit-trail gold.
Parsing and Analyzing Your Audit Log
An JSONL file is nice, but you need to actually use the data. Here's a simple Node script to analyze your audit trail and give you insights:
// analyze-audit.js
import fs from "fs";
import readline from "readline";
async function analyzeAuditTrail(filePath) {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
let totalTime = 0;
let totalTools = 0;
let failures = 0;
const toolStats = {};
for await (const line of rl) {
if (!line.trim()) continue;
const entry = JSON.parse(line);
totalTools++;
totalTime += entry.executionTimeMs;
if (entry.error) failures++;
if (!toolStats[entry.toolName]) {
toolStats[entry.toolName] = {
count: 0,
totalTime: 0,
failures: 0,
};
}
toolStats[entry.toolName].count++;
toolStats[entry.toolName].totalTime += entry.executionTimeMs;
if (entry.error) toolStats[entry.toolName].failures++;
}
// Print summary
console.log("\n=== Audit Trail Summary ===\n");
console.log(`Total tool invocations: ${totalTools}`);
console.log(
`Total execution time: ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`,
);
console.log(
`Failures: ${failures} (${((failures / totalTools) * 100).toFixed(1)}%)`,
);
console.log(`\n=== Per-Tool Breakdown ===\n`);
for (const [tool, stats] of Object.entries(toolStats)) {
const avgTime = (stats.totalTime / stats.count).toFixed(0);
const failureRate = ((stats.failures / stats.count) * 100).toFixed(1);
console.log(`${tool}:`);
console.log(` Invocations: ${stats.count}`);
console.log(` Total time: ${stats.totalTime}ms`);
console.log(` Average time: ${avgTime}ms`);
console.log(` Failures: ${stats.failures} (${failureRate}%)`);
}
}
analyzeAuditTrail("./.claude/logs/audit-trail.jsonl");Run it:
$ node analyze-audit.jsExpected output:
=== Audit Trail Summary ===
Total tool invocations: 147
Total execution time: 23456ms (23.46s)
Failures: 3 (2.0%)
=== Per-Tool Breakdown ===
readFile:
Invocations: 52
Total time: 1234ms
Average time: 24ms
Failures: 1 (1.9%)
bash:
Invocations: 41
Total time: 18945ms
Average time: 462ms
Failures: 2 (4.9%)
writeFile:
Invocations: 54
Total time: 3277ms
Average time: 61ms
Failures: 0 (0.0%)
Now you can see patterns. The bash tool is slow (462ms average) and had failures. Maybe those are network calls or compilation steps that are unpredictable. Or maybe the commands need optimization. The data tells you where to look.
Handling Sensitive Data Safely
The sanitization we included is basic. In production, you'll want to be more aggressive to prevent credential leaks.
function sanitizeForLogging(value) {
if (typeof value !== "string") {
return value;
}
// More comprehensive sanitization
let sanitized = value;
// Generic secrets and keys (broad patterns)
sanitized = sanitized.replace(
/(?:api[_-]?key|token|password|secret|auth|apikey)[=:]\s*["']?[^\s"',}]+/gi,
"***REDACTED***",
);
// AWS access keys (have specific format: AKIA...)
sanitized = sanitized.replace(/AKIA[0-9A-Z]{16}/g, "***AWS_KEY_REDACTED***");
// GCP API keys
sanitized = sanitized.replace(
/AIza[0-9a-zA-Z\-_]{35}/g,
"***GCP_KEY_REDACTED***",
);
// URLs with embedded credentials (https://user:pass@host.com)
sanitized = sanitized.replace(
/https?:\/\/[^:]+:[^@]+@/g,
"https://***REDACTED***@",
);
// Credit card patterns (basic PCI awareness)
sanitized = sanitized.replace(
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
"****-****-****-****",
);
// Common database connection strings
sanitized = sanitized.replace(
/mongodb:\/\/[^@]+@/gi,
"mongodb://***REDACTED***@",
);
sanitized = sanitized.replace(
/postgres:\/\/[^@]+@/gi,
"postgres://***REDACTED***@",
);
return sanitized;
}This is more paranoid, but for compliance purposes? Paranoia is appropriate. You're trying to prevent accidentally logging credentials into a file that auditors might access. If a credential ends up in your audit trail, it becomes as sensitive as the trail itself.
Archiving and Retention
Your audit log will grow. Eventually you'll want to archive old logs to save disk space while preserving history.
// archive-audit-logs.js
import fs from "fs";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
const execPromise = promisify(exec);
async function archiveOldLogs(daysOld = 30) {
const logsDir = "./.claude/logs";
const archiveDir = "./.claude/logs/archive";
if (!fs.existsSync(archiveDir)) {
fs.mkdirSync(archiveDir, { recursive: true });
}
const now = Date.now();
const cutoff = now - daysOld * 24 * 60 * 60 * 1000;
for (const file of fs.readdirSync(logsDir)) {
if (file === "archive") continue; // Don't archive the archive directory
const filePath = path.join(logsDir, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
const timestamp = new Date(stat.mtime).toISOString().split("T")[0];
const archiveName = `${file}.${timestamp}.gz`;
const archivePath = path.join(archiveDir, archiveName);
console.log(`Archiving ${file}...`);
await execPromise(`gzip -c "${filePath}" > "${archivePath}"`);
fs.unlinkSync(filePath);
}
}
console.log("Archive complete.");
}
// Usage: archiveOldLogs(30) means archive logs older than 30 days
archiveOldLogs(30);This keeps your active logs lean while preserving history. Gzip compression saves 85-95% of space typically. The timestamp in the archive filename makes it easy to find specific periods. For compliance, you might want to archive to S3 or a long-term storage system.
Integration with Monitoring Systems
If you're already using a monitoring or logging platform (Datadog, CloudWatch, ELK stack, etc.), you can pipe your audit logs directly into it. This gives you real-time dashboards and alerting on top of your audit trail.
// post-tool-use-audit-to-datadog.mjs
import https from "https";
const DATADOG_API_KEY = process.env.DATADOG_API_KEY;
const DATADOG_SITE = "datadoghq.com";
export async function postToolUse(context) {
const toolName = context.toolName;
const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp for Datadog
const payload = {
hostname: "claude-code-runner",
service: "claude-automation",
ddsource: "claude-code",
ddtags: `tool:${toolName},status:${context.error ? "error" : "success"}`,
message: `Tool invocation: ${toolName}`,
tool_name: toolName,
execution_time_ms: context.executionTimeMs,
error: context.error ? context.error.message : null,
timestamp,
};
return new Promise((resolve, reject) => {
const options = {
hostname: `http-intake.logs.${DATADOG_SITE}`,
port: 443,
path: `/v1/input/${DATADOG_API_KEY}`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
const req = https.request(options, (res) => {
res.on("data", () => {}); // Consume response
res.on("end", () => resolve());
});
req.on("error", reject);
req.write(JSON.stringify(payload));
req.end();
});
}Now your Claude Code activity streams directly into your monitoring system. You get real-time alerts, dashboards, and historical analysis—all the enterprise stuff. Combined with your local JSONL audit trail, you have belt-and-suspenders observability.
Compliance Considerations
When you're logging everything, ask yourself these questions:
What needs to be logged? Every tool call, period. Even reads. You want a complete trail of everything Claude Code did. No exceptions.
Who can access the logs? This is key. Logs with credentials or PII are as sensitive as the data itself. Restrict file permissions: chmod 600 audit-trail.jsonl on Unix. Keep logs off shared machines. If you're piping to a cloud service, verify that service's access controls.
How long do we keep them? Depends on your industry. Financial services? 7 years (regulatory requirement). GDPR? Delete on request (this gets tricky—see "the right to be forgotten"). Pick a retention policy and stick with it. Document it. You'll need to prove you followed it.
Are logs encrypted at rest? In production, yes. Use full-disk encryption, or encrypt the log files themselves with GPG: gpg --encrypt --recipient <keyid> audit-trail.jsonl.
Are logs encrypted in transit? If you're shipping to a remote system (Datadog, CloudWatch, etc.), use HTTPS or TLS. Never pipe logs over HTTP. Ever.
Who audits the auditors? If multiple people have access to logs, how do you know they didn't delete incriminating entries? Consider write-once-read-many (WORM) storage for your audit trail—something that can't be modified after writing. AWS S3 Object Lock can do this.
Putting It Together: Complete Setup
Here's the complete setup from scratch:
- Create the hook file at
.claude/hooks/post-tool-use-audit.mjswith the code above. - Claude Code automatically discovers and loads it. No configuration needed—it looks for
.mjsfiles in.claude/hooks/. - Every tool invocation is logged to
.claude/logs/audit-trail.jsonl. - Run the analysis script whenever you need insights:
node analyze-audit.js. - Archive old logs periodically:
node archive-audit-logs.js. - Optional: Integrate with your monitoring system using the Datadog example above (or customize for your platform).
That's it. You now have a compliant, queryable audit trail of everything Claude Code does. It's passive (once installed, it just works) and comprehensive (nothing Claude does escapes the log).
Real-World Scenarios Where Audit Trails Save You
Let's walk through some actual situations where you'll be grateful for audit trails. These aren't hypothetical—teams hit these situations in production.
Scenario 1: "Did Claude Delete That File?"
Your stakeholder claims something disappeared on Tuesday. You check your audit log:
{
"timestamp": "2026-03-16T09:15:22.000Z",
"toolName": "bash",
"input": {
"command": "rm -rf /tmp/cache/*"
},
"output": "Removed 1247 files",
"executionTimeMs": 234,
"error": false,
"status": "SUCCESS"
}Boom. You have proof. Claude did run that command, at that exact time, with that exact output. You can show the input (the exact command), the output, and the timing. This closes the conversation in 30 seconds instead of a two-week investigation. No ambiguity, no finger-pointing.
Scenario 2: "How Did We Fix That Bug?"
You shipped a fix three weeks ago, but now there's a regression. You want to reproduce what worked. Your audit trail shows the exact sequence:
{"timestamp":"2026-02-24T14:33:12.000Z","toolName":"readFile","input":{"file_path":"/src/parser.js"},"output":"[file contents]","executionTimeMs":45,"error":false,"status":"SUCCESS"}
{"timestamp":"2026-02-24T14:34:03.000Z","toolName":"bash","input":{"command":"npm test"},"output":"FAIL: parser test 5 failures","executionTimeMs":3421,"error":false,"status":"SUCCESS"}
{"timestamp":"2026-02-24T14:35:15.000Z","toolName":"writeFile","input":{"file_path":"/src/parser.js","content":"[modified file]"},"output":"Written 2147 bytes","executionTimeMs":12,"error":false,"status":"SUCCESS"}
{"timestamp":"2026-02-24T14:35:45.000Z","toolName":"bash","input":{"command":"npm test"},"output":"PASS: all tests","executionTimeMs":3389,"error":false,"status":"SUCCESS"}You can reconstruct exactly what changed and why it worked. You can see the before/after file contents. You can see the test execution. This is invaluable for debugging regressions—you have a perfect replay of what happened.
Scenario 3: Compliance Audit
Your company gets audited by an external firm. Auditors ask: "Show us every change made to the authentication system in the last 90 days, who made them, when, and what changed."
Instead of "uh, Claude made some changes, I think," you open your audit log and filter:
$ grep '"toolName":"writeFile"' .claude/logs/audit-trail.jsonl | grep "auth" | head -20You get a complete, timestamped, unambiguous record. Auditors smile. You pass. Next. The alternative (no audit trail) means you fail the audit or have to do a manual forensic investigation that takes weeks.
Scenario 4: Cost Tracking and Optimization
Your cloud bill went up. Which operations are expensive? You run your analysis and see:
bash:
Invocations: 247
Total time: 456789ms
Average time: 1849ms
That's 247 bash invocations averaging 1.8 seconds each. If those are cloud API calls or compilation steps, that's expensive. You now know what to optimize. Is there a retry loop? Are there slow commands being called repeatedly?
Scenario 5: Security Investigation
Someone claims Claude was compromised and made unauthorized changes. You search the audit log for suspicious patterns:
$ grep '"error":true' .claude/logs/audit-trail.jsonl | wc -lYou can see if there was a sudden spike in errors (which might indicate an attack or misconfiguration). You can verify that every change has a legitimate reason by reviewing the context around each entry. You can trace the chain of events.
Querying Your Audit Log
JSONL is beautiful because you can use standard Unix tools to query it. Here are some handy patterns:
Find all write operations:
$ grep '"toolName":"writeFile"' .claude/logs/audit-trail.jsonlFind all failures:
$ grep '"error":true' .claude/logs/audit-trail.jsonlFind operations that took longer than 5 seconds:
$ cat .claude/logs/audit-trail.jsonl | jq 'select(.executionTimeMs > 5000)'Count invocations per tool:
$ cat .claude/logs/audit-trail.jsonl | jq -s 'group_by(.toolName) | map({tool: .[0].toolName, count: length})'Find operations on a specific file:
$ grep '"file_path":"/path/to/file"' .claude/logs/audit-trail.jsonlExport a date range to CSV for analysis:
$ cat .claude/logs/audit-trail.jsonl | \
jq -r '.[] | select(.timestamp >= "2026-03-01" and .timestamp < "2026-03-02") | [.timestamp, .toolName, .status] | @csv' \
> march-1st.csvFind the slowest operations:
$ cat .claude/logs/audit-trail.jsonl | jq -s 'sort_by(-.executionTimeMs) | .[0:10]'These queries let you slice and dice your audit log to answer almost any question.
Performance Considerations
You might wonder: does logging slow things down? The short answer is no, not meaningfully.
The hook is asynchronous and non-blocking. It appends to a file after the tool has already completed. Even on busy systems running hundreds of tools per minute, JSONL append operations are fast—typically under 1 ms per entry. The disk I/O is minimized because you're just appending a single line.
If you're worried about I/O contention (though you probably shouldn't be), you can batch entries in memory and flush periodically:
const BATCH_SIZE = 100;
let pendingEntries = [];
export async function postToolUse(context) {
const auditEntry = buildEntry(context);
pendingEntries.push(auditEntry);
if (pendingEntries.length >= BATCH_SIZE) {
flushToDisk();
}
}
function flushToDisk() {
if (pendingEntries.length === 0) return;
const logLines =
pendingEntries.map((entry) => JSON.stringify(entry)).join("\n") + "\n";
fs.appendFileSync(AUDIT_LOG, logLines, "utf8");
pendingEntries = [];
}
// On exit, flush any remaining entries
process.on("exit", flushToDisk);
process.on("SIGINT", flushToDisk);This reduces disk I/O by 100x (you flush every 100 entries instead of every entry) while maintaining correctness and completeness. Your audit trail is still perfect—it just batches writes. In practice, most systems don't need this optimization, but it's there if you do.
Troubleshooting Common Issues
Problem: Hook isn't firing
The most common issue is file placement. Claude Code looks for hooks in .claude/hooks/ with names matching patterns like *-posttooluse.mjs or post-tool-use-*.mjs. Make sure your file:
- Is in
.claude/hooks/ - Ends with
.mjs - Exports a function named
postToolUse(exact camelCase)
Check:
$ ls -la .claude/hooks/ | grep -i audit
-rw-r--r-- post-tool-use-audit.mjsIf it's there and still not firing, check your hook function name is exactly postToolUse and that you're exporting it as export async function postToolUse(context) { ... }.
Problem: Logs are bloated
If your audit logs are growing too fast (logging hundreds of megabytes per day), consider:
- Reducing verbosity: Only log write operations, not reads:
export async function postToolUse(context) {
// Skip logging read operations (too chatty)
if (context.toolName.includes("read") || context.toolName.includes("get")) {
return;
}
// ... log the rest ...
}- Sampling: Log only 10% of operations randomly (for high-volume scenarios):
if (Math.random() > 0.1) {
return; // Skip this log entry (90% sampling)
}- Filtering by size: Don't log huge outputs:
if (String(context.toolOutput).length > 10000) {
context.toolOutput = "[OUTPUT TRUNCATED - too large to log]";
}Problem: Sensitive data leaked despite sanitization
If you're still seeing secrets in your logs, it means your sanitization regex isn't matching the pattern. Add custom patterns:
function sanitizeForLogging(value) {
let sanitized = value;
// ... existing patterns ...
// Custom pattern for your company's secret format
sanitized = sanitized.replace(
/MYCOMPANY_SECRET_[A-Z0-9]{32}/g,
"***MYCOMPANY_SECRET_REDACTED***",
);
return sanitized;
}Test your regex against known secrets:
const testSecret = "MYCOMPANY_SECRET_ABC123XYZ456";
console.log(sanitizeForLogging(testSecret)); // Should show ***MYCOMPANY_SECRET_REDACTED***Problem: Logs won't delete
If you're trying to delete old log files and getting "file in use" errors on Windows, Node might still have the file open. Close all Node processes first:
$ taskkill /F /IM node.exe # Windows
# or
$ killall node # macOS/LinuxThen delete:
$ rm .claude/logs/audit-trail.jsonl.*Testing Your Audit Hook
Before deploying to production, test it with a simple command:
$ claude-code --cwd /tmp/test
> Create a file and read it
[Claude creates file...]Verify the audit log was created:
$ cat .claude/logs/audit-trail.jsonl
# Should see entries for writeFile and readFileParse it to make sure it's valid JSON:
$ cat .claude/logs/audit-trail.jsonl | jq . | head -20
{
"timestamp": "2026-03-17T15:30:45.123Z",
"toolName": "writeFile",
"input": {
"file_path": "/tmp/test/example.txt",
"content": "Hello, world!"
},
"output": "Written 13 bytes",
"executionTimeMs": 12,
"error": false,
"errorMessage": null,
"status": "SUCCESS"
}If you see valid JSON, you're good. If you see parse errors, there's a syntax problem in your hook.
Operational Impact and Performance Considerations
One thing teams worry about is performance impact. If you're logging every tool invocation, won't that slow things down? The short answer is: no, not significantly, if you implement it correctly.
PostToolUse hooks are non-blocking. The tool completes, returns its results, and then the hook fires. If your hook takes 100ms to run and you have 1000 tool invocations in a session, that's 100 seconds of overhead. That sounds bad, but in practice, most audit logging is much faster. Appending a line to a file is typically under 1 ms. Even parsing the JSON is fast. Most audit hooks complete in 5-20ms, which adds maybe 5-20 seconds to a long session. That's acceptable overhead for the value you get.
If you're concerned about performance, implement batching. Instead of writing every entry immediately, accumulate entries in memory and flush every 10-30 seconds. This reduces I/O by 10-30x with minimal complexity. You lose some entries in a system crash, but for most use cases, that's acceptable.
Another consideration: disk space. A million audit entries is about 300MB of text, or 30-50MB compressed. Most teams find this manageable. Archive aggressively: move logs older than 90 days to cold storage.
The operational lesson is: start simple, monitor the impact, optimize if needed. Most teams never need to optimize their audit logging because the performance impact is negligible. And the value—compliance, debugging, forensics—is substantial.
Building Institutional Knowledge Through Audit Trails
Here's something subtle that happens over time: audit trails become institutional memory. After a few months of collecting logs, you have a detailed record of how Claude Code behaves in your organization. Which tools does it use most? When does it fail? What patterns emerge?
You can use this information to improve your prompts. Maybe you notice that Claude frequently runs a test command twice in a row (once fails, once succeeds). That's a signal that your test environment is flaky, or Claude doesn't understand it well. You can fix the test environment or improve your prompts to set expectations better.
Or maybe you notice that Claude reads the same file multiple times per session. That's a signal that file structure or documentation could be improved to reduce redundant reads.
Over time, audit trails transform from a compliance tool into a feedback mechanism. They show you how to improve your systems and prompts based on actual behavior rather than guesses.
The Hidden Value: Using Audit Trails for Continuous Improvement
Most teams view audit trails as a compliance checkbox. "We log everything because we have to." But there's deeper value if you actually analyze the logs.
After six months of collecting audit data, you have a longitudinal record of Claude Code behavior. You can spot patterns that inform product improvements and process changes:
- Slow queries: If your bash tool consistently takes 2 seconds but should take 0.2, you know there's an optimization opportunity
- Common errors: If you see the same error 500 times, it's probably a systematic problem
- Unexpected patterns: If engineers read the same file 15 times per session, maybe the file structure needs rethinking
- Task complexity: If a task requires 47 tool invocations, is the task really that complex or is Claude Code struggling to express what it wants to do?
These insights don't emerge from individual runs. They emerge from aggregate data across hundreds of runs. Audit trails are your window into that aggregate behavior. The difference between having logs and analyzing logs is the difference between having insurance and actually getting paid on a claim.
Organizational Adoption: From Skepticism to Confidence
Rolling out audit trails across an organization is interesting because people's reactions evolve:
Phase 1: Resistance "Why are we logging everything? This feels like surveillance." Engineers feel distrusted. They worry about privacy.
Phase 2: Acceptance After you explain that the logs are for debugging and compliance (not monitoring individual behavior), resistance softens. It's infrastructure.
Phase 3: Usefulness Someone debugs a production incident using the audit trail. They solve in 30 minutes what would have taken 3 days without logs. Word spreads.
Phase 4: Demand Teams want better analysis. "Can we get a dashboard of our API costs?" "Can we see which engineers use Claude Code most?" "Can we analyze failure patterns?" Audit trails become a competitive advantage.
Phase 5: Culture Audit trails become so embedded in your workflow that they're invisible. "Why would you deploy without an audit trail?" becomes the natural question. You've shifted from "we log because we have to" to "we log because we want to understand."
The transition from Phase 1 to Phase 5 takes about 6 months, but the value compounds. By Phase 4-5, your audit trails are doing double duty: compliance AND operational intelligence. You're not just checking a box; you're building knowledge that drives better decisions.
Key Takeaways
PostToolUse hooks give you complete visibility into what Claude Code does. You capture facts, not guesses. The JSONL format is perfect for both compliance officers (human-readable) and systems (machine-parseable). Sanitization keeps secrets out of the logs. Analysis scripts turn raw data into insights.
Advanced techniques like schema versioning, centralized sync to S3, and SQL-based analysis let you scale audit logging to enterprise requirements. You can evolve your schema as needs change, synchronize across multiple machines, and perform sophisticated analysis for optimization and compliance.
Real-world scenarios—debugging regressions, passing audits, investigating security incidents, optimizing costs—all become trivial with audit trails. You're not just logging for compliance; you're building tools for forensics, debugging, and optimization.
The best part? It's passive. Once installed, it just works. No code changes, no manual steps, no overhead. Compliance becomes infrastructure, not overhead. And over time, the audit trails become your institutional memory, showing patterns and opportunities that only emerge from sustained observation.
The difference between a team that logs and a team that logs AND analyzes is the difference between having insurance and actually benefiting from it. Install the hooks. But commit to analyzing the data. That's when audit trails transform from compliance checkbox to competitive advantage.
Beyond Compliance: Using Audit Trails for Strategic Advantage
Here's something teams rarely talk about: audit trails are a competitive moat. When you have complete visibility into what your automation is doing, you can make decisions that teams without visibility can't. You know exactly where bottlenecks are. You know which operations are expensive and which are cheap. You know which error patterns repeat and which are one-off anomalies.
This data drives better product decisions. You might discover that your automation is constantly re-reading the same files, suggesting your file organization could be improved. You might notice that authentication operations are consistently slower than other operations, suggesting an infrastructure optimization opportunity. You might see that certain developers trigger specific error patterns, suggesting training opportunities or workflow issues.
Over months and years, audit trails accumulate into institutional knowledge. You have a longitudinal record of how your automation evolved. You can see when performance degraded, which versions introduced slowdowns, which changes improved reliability. This historical perspective is invaluable for continuous improvement.
The organizations that win aren't necessarily the ones with the most powerful automation. They're the ones that understand their automation deeply, can observe it in real-time, and learn from historical patterns. Audit trails are the foundation of that understanding. They're not just insurance; they're a learning system that makes your automation smarter over time.
-iNet