Instrumenting Claude Code: OTel logging for Detection Engineers

16 min read Original article ↗

Claude Code ships with native OpenTelemetry support and emits structured, application-level telemetry for every significant action: tool calls, prompts, permission decisions, API requests. That’s a different shape than the system telemetry most detection engineers work with daily, and it fills a gap that endpoint tooling leaves open.

Your EDR will show you the process tree, the child bash processes, the commands that ran. But it fragments the session. You see individual commands without the context that connects them: what prompted the action, what the model was attempting, how the sequence fits together. An engineer prompting Claude via Cursor to set up a new GitLab environment might produce keychain access and bash history searches. In endpoint telemetry, that looks suspicious. Without the session context (the prompt, the tool sequence, the authorization source) you can’t reason about whether those commands came from a legitimate request or something else directing the agent.

For detection engineers, this telemetry fills a gap between endpoint telemetry and model activity. It exposes the authorization chain behind tool execution, which traditional EDR data does not capture.

This post covers what the data source contains, how to configure it, where it falls short, and what attacks look like against it. Part 2 will apply the Detection Engineering Baseline methodology to build detections from it.

Before writing detections against this data, it helps to understand how Claude Code structures its telemetry.

Claude Code exports two separate data streams via OTel, and understanding the difference matters before you configure anything.

Metrics (OTEL_METRICS_EXPORTER) are time series counters: session counts, token usage, cost, lines of code modified. They aggregate over a 60-second export interval by default. You can use them to infer abnormal activity. A session cost that’s an order of magnitude above a user’s baseline may indicate a security issue. But the aggregation loses the event-level detail you need to understand what actually happened.

Events (OTEL_LOGS_EXPORTER) are structured log records, one per action, near-real-time at a 5-second default export interval. Every bash command, every tool decision, every API call, every error. Metrics can surface that something looks off. Events tell you what it was.

Configuring only OTEL_METRICS_EXPORTER leaves you without the command-level visibility that detection requires.

Enabling both:

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp      # dashboards, cost tracking
export OTEL_LOGS_EXPORTER=otlp         # detection, audit trail
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.company.com:4317

For centralized enforcement, distribute this via the managed settings file at /Library/Application Support/ClaudeCode/managed-settings.json on macOS, or the equivalent platform path. Settings defined there cannot be overridden by individual users. Relying on developers to self-configure is not a good security strategy.

Two flags are off by default and each requires a deliberate decision. OTEL_LOG_USER_PROMPTS=1 captures prompt content, which helps in both directions: when an injection attempt didn’t change downstream behavior (nothing anomalous fired, so behavioral detection has nothing to work with), and when you need to reconstruct exactly what was asked during a post-incident investigation regardless of outcome. OTEL_LOG_TOOL_DETAILS=1 exposes MCP server and tool names on every MCP invocation; without it, MCP activity is opaque in the event stream.

Both have costs beyond infrastructure. Prompt logging raises privacy, legal, and retention questions, and depending on what developers are working on, that telemetry may contain PII, customer data, or other content your organization cannot store in a security pipeline. Tool details logging adds event volume. Leaving them off is still a decision, one that should be made deliberately. The recommended configuration table is later in the post.

Every event carries a set of standard attributes. The service.name is always claude-code. You can attach additional resource attributes via OTEL_RESOURCE_ATTRIBUTES (department=engineering, team.id=platform, cost_center=12345) for team-level aggregation and cost attribution without custom instrumentation. Per-event identifiers include session.id, user.account_uuid, organization.id (when authenticated via SSO), terminal.type, and app.version (off by default; enable with OTEL_METRICS_INCLUDE_VERSION=true).

terminal.type captures the terminal or IDE context (values such as Apple_Terminal, vscode, cursor). Automated scripts running Claude Code via the API won’t have a terminal type, or will show a non-interactive value. That distinction will come up in Part 2.

Fires when a developer submits a prompt. Carries prompt_length (always present) and prompt (full text, redacted by default, requires OTEL_LOG_USER_PROMPTS=1).

prompt_length alone tells you very little. Developers regularly submit large prompts when working with skills, feeding in large codebases, or running multi-step workflows with significant context attached. If OTEL_LOG_USER_PROMPTS=1 is not enabled and you’re relying on prompt_length to catch injection attempts, you’re buying into a false sense of security. The signals that matter show up downstream in tool_result and api_request events.

Fires when a tool completes execution. This is the highest-value event for detection.

The key fields: tool_name (Bash, Read, Write, Edit, NotebookEdit, or an MCP tool name), tool_result_success (boolean), tool_result_size_bytes, tool_duration_ms, error (if failed), decision (accept or reject), decision_source , and tool_parameters (a JSON string with tool-specific content).

tool_parameters varies by tool. For Bash it contains bash_command, full_command, timeout, description, dangerouslyDisableSandbox, and git_commit_id. When dangerouslyDisableSandbox is true, the command ran with the developer’s full OS privileges. This can be useful in conditional severity scoring. For MCP tools with OTEL_LOG_TOOL_DETAILS=1 enabled, it exposes mcp_server_name and mcp_tool_name.

tool_result_size_bytes matters beyond logging overhead. A large output from cat or find on sensitive directories may potentially suggest bulk data collection ahead of exfiltration.

Fires at permission decision time, before execution. Unlike the claude_code.code_edit_tool.decision metric which only covers Edit, Write, and NotebookEdit, this fires for all tools including Bash.

Fields: tool_name, decision (accept or reject), and decision_source (config, hook, user_permanent, user_temporary, user_abort, user_reject).

A rejected tool use fires here with decision=reject but never fires in tool_result. No execution to report. For a complete audit trail you want both. For detection, tool_result is the primary source because it includes tool_parameters with the actual command content.

The decision_source field tells you the mechanism behind the decision, not whether Claude’s own guardrails were involved. user_reject means the developer said no interactively. config means the settings file made the decision automatically. There is no dedicated value indicating that Claude’s internal safety guardrails blocked an action. In many cases the model declines the action before a tool invocation is attempted, which means no tool_decision event appears at all. If you want to know whether a config denylist blocked something versus the model declining on its own, you need to correlate the decision_source value against what your config files actually contain.

Fires on each API call. Fields: model, cost_usd, duration_ms, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, and is_retry.

Two token fields matter beyond cost accounting.

tokens_input velocity within a session can be a behavioral indicator for context displacement injection. Some indirect prompt injection techniques attempt to flood the context window with a large payload to displace or dilute the system prompt’s influence. That produces a spike in tokens_input on a single api_request event sharply elevated relative to earlier requests in the same session. You can baseline tokens_input per request within each session and flag when a request exceeds the rolling 99th percentile and the next event hits a sensitive command signature.

tokens_cache_creation is a secondary indicator for the same attack class. Injections that front-load large context windows can show up as unusually high tokens_cache_creation on the first request of the session, before the developer has done anything meaningful. This can be used as a supporting signal rather than a standalone indicator. A developer legitimately starting a session with a large codebase also produces high cache creation. The signal strengthens when followed by anomalous tool execution.

is_retry is useful for API error storm detection. Separating first attempts from retries produces a cleaner signal when computing burst rates against a per-user baseline.

Fires on API failures. Fields: model, error, status_code, duration_ms, and attempt (retry attempt number).

The prompt.id attribute links all events triggered by a single prompt: the user_prompt event, all api_request events for the model calls, and all tool_result events for tools invoked while processing that prompt. A single prompt.id that generates Read → Read → Read → Bash (network call) tells a much clearer story than those events in isolation.

prompt.id is intentionally excluded from metrics. Each prompt generates a unique ID, which would create unbounded cardinality in a time series backend.

The decision_source field on tool_result and tool_decision has no analog in traditional endpoint or EDR tooling. It records not just what happened, but how it was authorized:

config means the settings file made the authorization decision, not a human. But that alone is not a detection signal. Developers legitimately pre-configure auto-approvals for tools they trust, and those fire as config throughout a normal interactive session. The signal is a shift in decision_source mid-session: a session that starts with user_temporary or user_permanent approvals and pivots to config- or hook-sourced execution is worth examining. Under prompt injection or config abuse, that pivot happens because the agent is now acting on instructions the developer didn’t give. No other telemetry source in the endpoint or EDR stack exposes this kind of authorization provenance. This distinction drives the detection logic in Part 2.

One important limitation: decision_source tells you the mechanism, not whether Claude’s own guardrails were involved. A rejection sourced from config means the settings file blocked it. A rejection sourced from user_reject means the developer said no. When Claude’s internal safety guardrails decline an action, the model often does so before attempting a tool invocation, which means no tool_decision event appears at all. If you want to distinguish a config denylist rejection from Claude independently declining an action, you need to cross-reference decision_source against what your config files actually contain and account for the absence of events. That gap is worth understanding before you build detection logic that depends on reject patterns.

hook deserves a separate note. Hooks are user-defined shell commands, HTTP endpoints, or LLM-evaluated prompts that fire automatically at specific lifecycle events: PreToolUse, PostToolUse, SessionStart, SessionEnd, UserPromptSubmit, and others. A PreToolUse hook can block tool execution entirely before it reaches the permission system. A session where tool decisions show decision_source=hook before any user_prompt event has fired means project-level configuration ran something before the developer typed a single character. This could be a detection signal a typical session starts with a prompt.

This is a generalization. Your organization may have hooks that legitimately fire at session start, for example a context-loading hook or an environment validation script. Know your baseline before alerting on this pattern.

A few configuration decisions matter more than the detection rules themselves.

Managed settings vs. self-configuration. Distributing OTel configuration via the managed settings file ensures coverage but centralizes a security-sensitive config. The endpoint URL in that file is where all Claude Code telemetry goes. It needs access controls, a rotation policy, and monitoring on the collector itself.

Export interval and detection latency. The default 5-second export interval is low enough for near-real-time detection on most session-level rules. Lowering it increases collector load and can produce partial-session records that complicate session-level feature computation. 5 seconds is a reasonable default.

The OTel endpoint as an attack surface. If an attacker compromises a developer machine, the OTel endpoint URL is visible in the environment. A sophisticated attacker who knows OTel is in play could modify the endpoint, disable telemetry flags, or redirect events to an attacker-controlled collector. Require authentication on the collector endpoint and monitor the managed settings file for changes.

Unmanaged machines are blind spots. Developers running Claude Code on personal machines or accounts not enrolled in MDM have no OTel coverage unless they configure it themselves. Track OTel coverage as a metric: percentage of developer accounts with active telemetry in the last 7 days. Gaps are themselves a detection signal.

Prompt logging storage. Enabling OTEL_LOG_USER_PROMPTS=1 means a developer’s prompt history flows through your telemetry pipeline. That data is sensitive in ways a list of bash commands is not. Your collector may not make it easy to separate this from other telemetry, so settle retention and access policies before enabling the flag.

The full recommended configuration:

Understanding the gaps matters as much as understanding the schema. These limitations define the boundaries of what OTel-based detection can realistically achieve.

Refused injections leave no trace without prompt logging. If Claude receives a prompt injection and refuses to act on it, the tool events don’t change because no anomalous tools fired. The only record is in the prompt content itself.

File content is not captured. You see that a file was Read, but not what was in it. Indirect prompt injection payloads are invisible in telemetry unless they trigger detectable downstream tool behavior.

MCP tool descriptions are not captured. With OTEL_LOG_TOOL_DETAILS=1 you see the MCP server name and tool name, but not the tool description text, which is exactly where MCP tool poisoning payloads live.

Response content is not captured in OTEL events. Claude’s text responses don’t appear in OTel event fields. You can infer from tokens_output counts and subsequent tool calls, but you can’t inspect responses for injected instructions or leaked prompts through OTel alone.

Sandbox bypass effects are not visible. When dangerouslyDisableSandbox is true, the command ran with full OS privileges, but OTel doesn’t capture what those privileges enabled downstream. File system modifications, network connections, and persistence mechanisms show up in endpoint telemetry, not here.

Secrets exposure is visible after the fact. Claude Code runs with your user's filesystem permissions and inherits your shell environment. It can read .env files, run printenv, or inject credentials into curl commands when troubleshooting auth errors. Deny rules on the Read tool do not prevent Bash-based access to the same files. These actions appear in tool_result events with full command content, so you can detect them in telemetry. But by the time you see the event, the secret has already entered the conversation context. If credentials must remain unexposed to the agent, they should not exist as plaintext on disk or in the shell environment.

Worth knowing: Claude Code automatically writes full conversation transcripts to local JSONL files at ~/.claude/projects/<project-hash>/<session-id>.jsonl. These include both prompts and responses regardless of OTel configuration. They’re useful for ad-hoc investigation on a single machine, but getting them into a SIEM or data warehouse requires explicit tooling (a log shipper, endpoint agent, or hook-based forwarding script) to collect and ship them at scale. Without that, they stay local.

Direct API access bypasses this telemetry. An attacker with a developer’s API key calling the Anthropic API directly generates no Claude Code OTel events.

Telemetry suppression is possible on unmanaged machines. A developer who knows OTel is enabled can unset CLAUDE_CODE_ENABLE_TELEMETRY before a session. On MDM-managed machines this is detectable; on unmanaged machines it isn’t. Track account-level telemetry absence.

Post-execution activity is not visible here. OTel records what commands Claude Code ran, not what those commands did afterward. Those effects show up in endpoint telemetry. The two sources are complementary.

In practice, OTel-based detection is behavioral. You are detecting the effects of attacks: anomalous tool sequences, unexpected network calls, configuration modifications. Not the payloads themselves.

The gaps above are not hypothetical. Here is what has been demonstrated against AI coding assistants in the last year, and what each attack looks like in the telemetry.

Indirect prompt injection via repository content. Pillar Security’s “Rules File Backdoor” (March 2025) demonstrated hidden Unicode characters in .cursorrules files directing Copilot to inject backdoors during code generation. Tenable (November 2025) showed that even a filename containing injection instructions gets processed. In OTel terms: a session running interactive approvals at human speed that pivots mid-session to config-sourced, machine-speed Bash execution is the signal. The decision_source field (covered above) provides context that endpoint telemetry typically lacks for this kind of session-level behavioral shift.

Malicious project configuration. CVE-2025-59536 (Check Point Research, February 2026) showed that Hook definitions embedded in .claude/settings.json execute on every collaborator’s machine the moment they open the project, before any trust dialog appears. CVE-2026-21852 showed that ANTHROPIC_BASE_URL in project settings silently redirects all API traffic, including the developer’s API key, to an attacker-controlled proxy. Detection surface: tool_result events firing before the first user_prompt event in a session (covered in the event schema above), and anomalous api_request.duration_ms patterns across projects.

MCP tool poisoning. Noma Security’s ContextCrush (March 2026) weaponized a read-only MCP server with no execution capabilities by poisoning its tool response content. The server delivered the payload into the agent’s context, where the model interpreted it as instructions and executed them via the Bash tool. Without OTEL_LOG_TOOL_DETAILS=1 (see configuration table), the MCP invocation is opaque. Even with it, the tool description text that carried the payload is not captured, a gap called out in the blind spots section above.

Slopsquatting. A USENIX Security 2025 study across 16 LLMs and 2.23 million code samples found 5.2% of packages from commercial models were hallucinated, and 43% of hallucinated packages recurred across prompts, making them predictable pre-registration targets. The detection surface is tool_parameters.bash_command fields in tool_result events containing package installs for packages not present in the organization’s dependency inventory.

Autonomous permission escalation. CVE-2025-53773 (GitHub Copilot) showed the full chain: injection triggered a settings modification to enable auto-approve, then arbitrary commands executed without user oversight. In OTel terms: a Write event targeting .claude/settings.json followed immediately by elevated Bash execution. The dangerouslyDisableSandbox field in tool_parameters (covered in the tool_result schema above) being set to true is worth treating as elevated severity regardless of other context.

These attacks share a common thread. The commands that executed were visible in process telemetry. What was not visible was the authorization chain behind them, whether a human decided to run them, and what session context preceded them. That is what this data source provides.

This post covered what Claude Code’s telemetry contains, how to configure it, and where it falls short. The tool_result event is the foundation for detection, capturing command content, execution context, and outcome. Paired with decision_source, you get authorization provenance at the command level, something endpoint telemetry does not provide. That combination makes detection, response, and investigation possible in ways that process trees alone cannot.

Your EDR shows process execution. This shows why the process ran, whether a human prompted it, whether a hook triggered it, whether a configuration rule pre-approved it. That distinction matters when you’re building detections and triaging alerts on developer machines where agentic tools are running.

This data source is not without its limits. File content, MCP tool descriptions, response content, and refused injections are not captured. Secrets that enter the session, whether from prompts, environment variables, file reads may appear in plaintext in your telemetry.

If Claude Code is in your environment, the instrumentation path is relatively straightforward: enable both exporters, route to your collector, and start building visibility before you need it. The agentic coding space is still finding its footing on security instrumentation, and this is one of the more mature options available.

Part 2 will cover detections built against this data.

Share

Discussion about this post

Ready for more?