Log Tool Activity

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 97 of 200

Why Log Tool Activity

With tools, Claude's work is no longer only text: it takes actions in your systems. When something goes wrong, such as a wrong answer, a failed booking, a slow response, or an unexpected cost, you need to know what happened:

QuestionWhat the log must show
Why did the assistant give this answer?Which tools were called, with which inputs, and what they returned
Why was this request slow?How long each tool took, and how many model requests were made
Why did a tool fail?The error type and message, and the input that caused it
What did this request cost?Token usage per model request
Did the assistant do anything it should not have?A complete record of actions, per user and per run (an audit trail)

Logging tool activity is part of running tools responsibly (Lessons 176–178), not just a debugging aid.


What the SDK Logs for You

According to the tool runner documentation:

  • When a tool raises an unhandled exception, the Python SDK logs the full exception, including its stack trace, through Python's standard logging module, while Claude receives only the exception's type and message.
  • The ANTHROPIC_LOG environment variable turns on the SDK's own logging of requests and responses:
export ANTHROPIC_LOG=info     # or: debug, for more verbose output

ANTHROPIC_LOG=debug is useful during development, but too verbose, and potentially too revealing, for production. For production, add structured logs of your own at two levels: tools and model requests.


Two Levels of Logs

LevelLogged whereFields
Tool callsA wrapper around each tool functionRun ID, tool name, input (redacted), duration, outcome, error type, result size
Model requestsThe loop that iterates the runnerRun ID, step, message ID, stop reason, tools requested, token usage

A shared run ID, a unique identifier for one user request, connects all entries from the same run, so you can reconstruct exactly what happened.


Logging Tool Calls with a Wrapper

As in Lesson 96, a wrapper with functools.wraps adds behavior to a tool without changing it, and keeps the generated definition intact. @beta_tool must be outermost:

@beta_tool
@audit.wrap
def search_courses(topic: str) -> str:
    ...

The wrapper measures the duration with time.perf_counter(), logs one entry per call, and re-raises exceptions, so the tool runner still turns them into error results for Claude.

Redact Sensitive Inputs

Tool inputs can contain personal data: emails, phone numbers, free-text messages. Logs are often widely accessible and kept for a long time. Redact sensitive fields before logging, and log sizes of results rather than full content:

SENSITIVE = {"email", "phone", "password", "token", "message"}
safe_input = {key: ("[redacted]" if key in SENSITIVE else value) for key, value in kwargs.items()}

Structured (JSON) Logs

As in Lesson 49, write each log entry as one line of JSON. Log platforms can then filter and aggregate by field, for example "all tool_call entries with outcome error in the last hour", or "average duration_ms per tool".


Practical Example: An Audited Tool Run

This example logs every tool call and every model request of a run as JSON lines, linked by a run ID. One tool fails, to show how errors appear in the log.

"""audited_run.py: structured logs for tool calls and model requests."""

import functools
import json
import logging
import time
import uuid

from anthropic import Anthropic, beta_tool

logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("tool_audit")
SENSITIVE = {"email", "phone", "password", "token", "message"}


class Audit:
    """Logs tool activity for one run."""

    def __init__(self, user_id: str):
        self.run_id = uuid.uuid4().hex[:12]
        self.user_id = user_id

    def event(self, **fields) -> None:
        log.info(json.dumps({"run_id": self.run_id, "user": self.user_id, **fields}, ensure_ascii=False))

    def wrap(self, function):
        @functools.wraps(function)
        def wrapper(*args, **kwargs):
            safe_input = {k: ("[redacted]" if k in SENSITIVE else v) for k, v in kwargs.items()}
            start = time.perf_counter()
            try:
                result = function(*args, **kwargs)
            except Exception as error:
                self.event(event="tool_call", tool=function.__name__, input=safe_input, outcome="error",
                           error=type(error).__name__,
                           duration_ms=round((time.perf_counter() - start) * 1000, 1))
                raise
            self.event(event="tool_call", tool=function.__name__, input=safe_input, outcome="ok",
                       result_chars=len(result),
                       duration_ms=round((time.perf_counter() - start) * 1000, 1))
            return result
        return wrapper


def make_tools(audit: Audit) -> list:
    @beta_tool
    @audit.wrap
    def search_courses(topic: str) -> str:
        """Search the GOCOURSE catalog for courses on one topic.

        Use it when a student asks which courses exist for a subject.
        Returns course titles as JSON.

        Args:
            topic: One subject, e.g. 'Django'
        """
        catalog = {"django": ["Django for Beginners", "Advanced Django APIs"], "sql": ["SQL Basics"]}
        return json.dumps(catalog.get(topic.lower(), []))

    @beta_tool
    @audit.wrap
    def get_course_fee(course_title: str) -> str:
        """Return the fee for one course in Indian rupees.

        Use it when a student asks what a course costs.

        Args:
            course_title: Exact course title from search_courses
        """
        raise TimeoutError("payments service timeout")     # Simulated outage

    return [search_courses, get_course_fee]


def run(user_id: str, question: str) -> str:
    audit = Audit(user_id)
    runner = Anthropic().beta.messages.tool_runner(
        model="claude-opus-5-5",
        max_tokens=4096,
        max_iterations=6,
        system="You are the GOCOURSE assistant. If a tool fails, say what you could not check.",
        tools=make_tools(audit),
        messages=[{"role": "user", "content": question}],
    )
    last = None
    for step, message in enumerate(runner, start=1):
        audit.event(event="model_response", step=step, message_id=message.id,
                    stop_reason=message.stop_reason,
                    tools_requested=[b.name for b in message.content if b.type == "tool_use"],
                    input_tokens=message.usage.input_tokens, output_tokens=message.usage.output_tokens)
        last = message
    return "".join(b.text for b in last.content if b.type == "text")


if __name__ == "__main__":
    print(run("meena", "Which beginner Django course do you have, and what does it cost?"))

Example output (log lines, then the answer; run IDs, message IDs, token counts, and durations vary):

{"run_id": "4f1c2a9be07d", "user": "meena", "event": "model_response", "step": 1, "message_id": "msg_01...", "stop_reason": "tool_use", "tools_requested": ["search_courses"], "input_tokens": 912, "output_tokens": 88}
{"run_id": "4f1c2a9be07d", "user": "meena", "event": "tool_call", "tool": "search_courses", "input": {"topic": "Django"}, "outcome": "ok", "result_chars": 48, "duration_ms": 0.1}
{"run_id": "4f1c2a9be07d", "user": "meena", "event": "model_response", "step": 2, "message_id": "msg_01...", "stop_reason": "tool_use", "tools_requested": ["get_course_fee"], "input_tokens": 1034, "output_tokens": 71}
{"run_id": "4f1c2a9be07d", "user": "meena", "event": "tool_call", "tool": "get_course_fee", "input": {"course_title": "Django for Beginners"}, "outcome": "error", "error": "TimeoutError", "duration_ms": 0.0}
{"run_id": "4f1c2a9be07d", "user": "meena", "event": "model_response", "step": 3, "message_id": "msg_01...", "stop_reason": "end_turn", "tools_requested": [], "input_tokens": 1150, "output_tokens": 64}
Our beginner Django course is Django for Beginners. I couldn't check its fee right now because the payments service isn't responding. Please try again later.

How It Works

  1. Audit is created per run, with a short unique run_id from uuid.uuid4(). Every entry includes the run ID and the user, so a single filter on run_id reconstructs the whole run.
  2. audit.wrap wraps each tool. It redacts sensitive keys, times the call with time.perf_counter(), logs either an ok entry with the result size, or an error entry with the exception type, and re-raises errors so the tool runner can still send them to Claude. The search result ["Django for Beginners", "Advanced Django APIs"] is 48 characters long as JSON text, which is what result_chars records.
  3. @beta_tool is outermost, and functools.wraps keeps each tool's name, docstring, and annotations, so the generated definitions are unchanged (Lesson 96).
  4. The runner loop logs one model_response entry per step: message ID, stop reason, requested tools, and token usage. These entries show the order of decisions and the cost of each step.
  5. Ordering: the runner yields a message first, so its model_response entry is logged, and then runs the requested tools when the loop continues, so the tool_call entries follow. Reading the log top to bottom shows the run exactly as it happened.
  6. The failing tool appears as outcome: "error" with error: "TimeoutError". The Python SDK additionally logs the full traceback, as documented, so developers can investigate, while Claude received only a short error result and answered honestly.

Note: Logs are data about your users. Apply retention limits, restrict access, and never log secrets. Redact personal fields, and prefer result sizes over full results unless you have a clear need and appropriate protections.


Common Mistakes

Logging Without a Correlation ID

What happens: tool calls from different users and requests are mixed, and runs cannot be reconstructed.

Fix: include a run ID (and user ID) in every entry.

Swallowing Exceptions in the Logging Wrapper

What happens: Claude receives None or a misleading result instead of an error.

Fix: log, then re-raise.

Logging Sensitive Inputs and Full Results

What happens: personal data accumulates in logs.

Fix: redact sensitive fields, and log sizes or summaries.

Unstructured Log Messages

What happens: "Tool search_courses called with Django" is hard to filter or aggregate.

Fix: log JSON with consistent field names.

Leaving Debug Logging On in Production

What happens: ANTHROPIC_LOG=debug writes request and response details into production logs.

Fix: use debug logging only during development.


Best Practices

  • Log every tool call and every model response, linked by a run ID.
  • Record durations, outcomes, error types, and token usage.
  • Re-raise exceptions after logging them.
  • Redact sensitive inputs, and keep results out of logs by default.
  • Use structured JSON logs with consistent fields.
  • Use ANTHROPIC_LOG for development diagnostics, not routine production logging.
  • Review logs regularly for failing tools, slow tools, and unusual call patterns.

Exercise

  1. Add the total number of tool calls and the total tokens to a final run_summary log entry.
  2. Change SENSITIVE handling to also redact any string value longer than 200 characters.
  3. Write a script that reads the JSON log lines and prints, per tool, the number of calls, the error rate, and the average duration.
  4. Explain why the wrapper logs result_chars instead of the full result.
Show answers for steps 3 and 4

Step 3:

import json
from collections import defaultdict


def tool_stats(lines: list[str]) -> None:
    stats = defaultdict(lambda: {"calls": 0, "errors": 0, "total_ms": 0.0})
    for line in lines:
        entry = json.loads(line)
        if entry.get("event") != "tool_call":
            continue
        s = stats[entry["tool"]]
        s["calls"] += 1
        s["errors"] += entry["outcome"] == "error"
        s["total_ms"] += entry["duration_ms"]
    for tool, s in stats.items():
        print(f"{tool:<16} calls={s['calls']:<4} error_rate={s['errors'] / s['calls']:.0%} "
              f"avg_ms={s['total_ms'] / s['calls']:.1f}")

Step 4: full results can be large, which makes logs expensive to store and slow to search, and they can contain personal or sensitive data from your systems. The result size is usually enough to spot problems, such as empty results or unexpectedly huge ones. When full results are genuinely needed for debugging, log them separately, with restricted access and short retention.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Log Tool Activity and get answers drawn from it.

Signed-in readers only.