Skip to content
All posts
8 min readBrian McManus

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.

observabilityopentelemetrysiemagent-securityspectra

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

ClientSwitchSignalDefault
Claude CodeCLAUDE_CODE_ENABLE_TELEMETRY=1 plus OTEL_METRICS_EXPORTER and OTEL_LOGS_EXPORTEROTLP metrics and log events, optional spans in betaOff; tool details and prompts redacted
Codex[otel] block in ~/.codex/config.tomlOTLP logs and metricsOff; log_user_prompt = false
CursorHooks in .cursor/hooks.json or the enterprise pathJSON lines from your own scriptNothing native to a collector
GitHub CopilotAudit log API on the GitHub sideServer-side audit eventsLocal 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.

How is your team using AI agents?

Pick your function and answer 20 questions on agent workflows and controls. Your result appears on the page.

Governed AI agents for the work your teams do.