Sending coding agent telemetry to your SIEM with OpenTelemetry
What Claude Code, Codex, Cursor and Copilot each emit, the collector configuration that receives it, the exact attributes to delete before storage, and the detections that only work once the decisions and the tool calls land in the same index.
Getting coding agent telemetry into your SIEM means turning on each client's exporter, receiving OTLP at a collector you control, and keeping the decision record rather than the prompt. The clients emit different event sets, none covers every tool call, and the collector is where you reconcile them.
Two defaults decide the outcome. Telemetry is off until an administrator turns it on, and content is redacted until someone turns that off. Leave both as they are.
What each client emits
| Client | Switch | Signal | Default |
|---|---|---|---|
| Claude Code | CLAUDE_CODE_ENABLE_TELEMETRY=1 plus OTEL_METRICS_EXPORTER and OTEL_LOGS_EXPORTER | OTLP metrics and log events, optional spans in beta | Off; tool details and prompts redacted |
| Codex | [otel] block in ~/.codex/config.toml | OTLP logs and metrics | Off; log_user_prompt = false |
| Cursor | Hooks in .cursor/hooks.json or the enterprise path | JSON lines from your own script | Nothing native to a collector |
| GitHub Copilot | Audit log API on the GitHub side | Server-side audit events | Local tool calls absent |
Claude Code is the most complete. Events include tool_decision with approved or denied and where the decision came from, tool_result, and cost and token counters, and the documented SIEM pattern is to send that stream to a collector over OTLP (monitoring). Administrators pin the destination from managed settings, and a repository cannot turn it back on or redirect it:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.internal:4317",
"OTEL_RESOURCE_ATTRIBUTES": "deployment.environment=prod"
}
}
Two limits in that documentation are worth designing around. Claude Code does not pass OTEL_* variables to subprocesses, so a tool it runs does not inherit the exporter. And tool arguments stay off unless OTEL_LOG_TOOL_DETAILS=1, which is the setting to leave alone if source code and secrets must not reach the SIEM.
Codex takes a configuration block instead of environment variables:
# ~/.codex/config.toml
[otel]
environment = "prod"
log_user_prompt = false
exporter = { otlp-grpc = {
endpoint = "http://collector.internal:4317"
} }
The event set includes codex.tool_decision with approved or denied and whether configuration or the user decided, codex.tool_result, and codex.conversation_starts carrying the sandbox and approval policy (approvals and security). Export needs network access, so a machine left in the default no-network workspace-write mode will buffer and then drop.
Both examples use http because the receiver below listens without TLS. If you terminate TLS in front, switch the endpoints to https and give the exporter the matching certificate; otherwise the handshake fails and the SIEM stays empty.
Cursor and Copilot are the honest gap. Cursor hooks fire on sessionStart, beforeShellExecution, beforeMCPExecution, afterFileEdit and stop, and a script can write a JSON line or post to an HTTP endpoint (hooks). That is a shim you own, and it sends what you write, not what the vendor decided. GitHub's Copilot audit log covers plan and policy changes and agent activity on GitHub, and GitHub states it does not include local client session data (audit logs).
One collector, four sources
Send everything to a collector rather than to the SIEM directly. You get retry and buffering, one place to redact and enrich, and the ability to change backend without touching laptops.
# /etc/otelcol-contrib/config.yaml
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
attributes/redact:
actions:
# Claude Code content attributes, present only when the content gates are on
- { key: prompt, action: delete }
- { key: prompt_text, action: delete }
- { key: response, action: delete }
- { key: tool_input, action: delete }
- { key: tool_parameters, action: delete }
- { key: full_command, action: delete }
- { key: bash_command, action: delete }
- { key: content, action: delete }
- { key: output, action: delete }
- { key: diff, action: delete }
- { key: error, action: delete }
- { key: process.command_line, action: delete }
resource/enrich:
attributes:
- { key: tenant.id, value: acme, action: upsert }
- { key: deployment.environment, value: prod, action: upsert }
batch: {}
extensions:
basicauth/es:
client_auth:
username: otel_writer
password: ${env:ES_PASSWORD}
exporters:
elasticsearch:
endpoints: ["https://es.internal:9200"]
logs_index: logs-ai-agent
auth:
authenticator: basicauth/es
service:
extensions: [basicauth/es]
pipelines:
logs:
receivers: [otlp]
processors: [memory_limiter, attributes/redact, resource/enrich, batch]
exporters: [elasticsearch]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [elasticsearch]
Every key there is a name the clients emit: prompt and prompt_text on the user prompt event, response on the assistant response, tool_input and tool_parameters on the tool result, and the rest on span events when tracing is on. A filter that deletes tool.input removes nothing.
For Splunk, the contrib distribution ships a HEC exporter, and the pipeline list changes rather than the rest of the file:
exporters:
splunk_hec:
token: ${env:SPLUNK_HEC_TOKEN}
endpoint: https://splunk.internal:8088/services/collector
source: ai-agents
sourcetype: otel:log
index: ai_agents
Validate the file, then prove the redaction with a record that carries a sentinel:
otelcol-contrib validate --config=/etc/otelcol-contrib/config.yaml
curl -s http://collector.internal:4318/v1/logs \
-H 'Content-Type: application/json' \
-d '{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"attributes":[
{"key":"event.name","value":{"stringValue":"tool_result"}},
{"key":"tool_input","value":{"stringValue":"SENTINEL-9f3c"}},
{"key":"session.id","value":{"stringValue":"s-7f31"}}]}]}]}]}'
Run that against a scratch config whose only exporter is debug with verbosity: detailed, ; the printed record carries event.name and session.id with no tool_input. If SENTINEL-9f3c appears in the output, the key name is wrong and the filter does nothing.
Redact before storage, not after an incident
A SIEM keeps what you send for as long as retention allows, and an agent's tool arguments are source code. The fields worth deleting at the collector:
- Tool arguments and file contents, in full. Keep the tool name, the path's hash and the decision.
- Prompt text and model responses. Keep the model id, token counts and a hash.
- Environment and command lines from process attributes, which leak keys more often than files do.
- Any authorisation header or token that appears when verbose logging is on.
error is on the list because those messages quote .env contents and command lines; if you keep it, restrict who can read the index.
The reason is not storage cost. A store of agent arguments is a second copy of the secrets the governance layer protects, with different access controls and a longer retention.
Detections that need the join
Single events answer simple questions: how many denials this week, which repositories see the most prompts, which tools are unused. The interesting detections need the sequence, and a SIEM is the right place to build them because it already keeps history.
In ES|QL, on documents in OTel mapping mode, using the names Claude documents for its own events:
FROM logs-ai-agent
| WHERE attributes.event.name == "tool_decision" AND attributes.decision == "reject"
| STATS rejections = COUNT(*) BY attributes.session.id
| WHERE rejections >= 3
The attributes. prefix comes from the otel mapping mode, which nests log-record attributes, and nothing in the pipeline renames them. Claude's decision event uses event.name, decision with the value reject, and the standard session.id.
Three rules that the stream supports once decisions and tool calls land in one index:
- A secret path read followed by egress from the same session inside thirty minutes. This is the pair that matters most, and it needs the file path, the network event and the session id.
- A rejection followed by the same tool and a different command line, which is an agent retrying around policy.
- A tool or MCP server seen for the first time in the fleet this week, which catches a config change nobody approved.
The third is the cheapest win for a SOC that already tunes first-seen rules for processes: it works on inventory attributes, so it needs no content logging.
Retention, completeness and one number to keep outside
The audit chain is what makes the SIEM record checkable. If the endpoint writes a hash-chained record, store the head digest in the SIEM or in object storage with immutable retention, so an export that verifies internally can still be compared against a value the endpoint cannot reach. Exporting the audit trail covers the chain verification and the paginated export in detail.
Two numbers to decide rather than inherit: retention, because a vendor audit log at 180 days lines up badly with a six month obligation discovered in month seven, and completeness, because a gap in the stream leaves no trace inside the stream. A per-endpoint heartbeat event, expected once every few minutes, is the cheapest way to notice a laptop that stopped exporting.
Where Alyria Beacon fits
Alyria Beacon emits the decisions from the endpoint, where the tool calls happen, and Spectra receives that OpenTelemetry and routes it to Observatory or into your own Elastic or Splunk pipeline. The fields are the ones the detections above need: agent and version, session id, tool or MCP server, the policy id and version, the decision, and the result. Because the record is created at the decision point rather than reconstructed from a client's logs, a call the agent made through a stdio MCP server shows up the same way as one it made through a shell.
That is the whole argument: emit at the decision, ship immediately, retain per policy. Configuration described here comes from the Claude Code, Codex, Cursor, GitHub Copilot and OpenTelemetry collector documentation read in November 2026.
Try it with Alyria
Point one endpoint at your own collector through Spectra and confirm that a policy decision and the tool call behind it arrive in the index your SOC already searches. Then write the read-then-egress rule on data you can see, rather than on a diagram. Start free and send your first decision events.