Logging (structured audit trail) — on by default
What it is: a structured, durable, machine-parseable record of what the agent did on every investigation — which Exabeam tools it called, how long they took, whether they succeeded, the gated action it took (which alert/case, to what disposition), and when the security guardrails fired. A good agent keeps an audit trail; Raffkin keeps one by default, so a production tenant can reconstruct a session or drive anomaly detection instead of relying on the free-form investigation report alone.
It runs inside the bundled connector (the local MCP bridge, the one place that sees every Exabeam call) and is built on observra, an open-source agent-telemetry SDK. Events are written as newline-delimited JSON (one object per line) in the CIM-normalized observra schema.
The bridge installs the observra library it needs (1.1 or newer); nothing for you to set up.
Exactly what is recorded
One event per tool call, plus a session marker at each end:
event_type |
When | Key fields |
|---|---|---|
mcp_session_start / mcp_session_end |
bridge process start / exit | session_id, host context; on start also the configuration attestation: telemetry_backend, telemetry_destination (resolved file path, or scheme + host of the endpoint), dry_run (writes simulated; test runs only), plugin_version, gate_log |
tools_list |
the remote's tool definitions arrive (once per session) | tool_count (the definitions the model sees), metadata_stripped / metadata_flagged (hidden code points removed or flagged in tool descriptions — counts only), withheld_tools (definitions the screen could not read, so the model never saw them), tool_shas (a hash per definition, so a changed definition is visible across sessions) |
tool_start |
a tool call begins | tool_name |
tool_end with action.wildcardFieldsRedirected: true |
a search asked for every column (fields: ["*"]) and was answered with the column list instead of being sent — a workaround for the MCP server's schema text, kept until the server is fixed |
tool_name |
tool_end |
a tool call succeeds | tool_name, duration_ms, + the fields below |
tool_error |
a tool call fails | tool_name, duration_ms, error_class, stage (neutralize = the write-side guardrail refused to forward, with guardrail_refused: true; metadata_screen = a definition the screen withheld was called by name and the bridge refused it, also with guardrail_refused: true; remote = the upstream call failed; upstream_tool = the tool ran on the proxy and reported an error), and for a remote failure the structured parts of what failed: error_type_name, error_code (the platform's own code, e.g. AAA_ESA_1000_400, when it sent one), http_status when there was one, is_retryable; outcome_unknown: true when a write's request had gone out before the session died (verify before re-issuing); dropped_fields naming, by the schema's spelling, the fields the bridge dropped from an update. Never the error message itself: it quotes the request. A failed tools/list at startup is recorded under tool_name: tools/list. |
Every event also carries: framework: "mcp", agent_name (the plugin's name from its identity.json
— raffkin here; a copy shipped under another name logs under that name), skill_name: "soc-investigate",
ULID session_id / trace_id / span_id for correlation, a timestamp, and host context
(host, user, os, arch, library_version) for accountability.
On a tool_end, three extra kinds of field appear when relevant:
1. The decision record — for a gated write (update_alert, update_case, create_case,
create_case_notes), the safe identifier/enum fields of the action, namespaced action.*:
"action.alertId": "4471", "action.alertStatus": "closed", "action.disposition": "false_positive"
Captured fields: alertId, caseId, alertStatus, caseStatus, stage, priority, severity,
queue, disposition, useCases. This is what the agent decided, on which object — the audit-grade
core of the trail.
2. Guardrail firings — proof the defenses acted, correlated to the exact call:
"defang_formula": 1, "defang_link": 1 // output neutralizer defanged a formula / phishing link on a write
"hygiene_stripped": 3, "hygiene_classes": "U+200B,U+202E" // input canonicalizer stripped smuggling code points on a read
"hygiene_kept": 2, "hygiene_kept_classes": "U+200D,U+200F" // joiners / directional marks were present — kept verbatim, flagged here
"hygiene_screen_failed": true // screening threw on a block; the block was withheld (fail-closed)
hygiene_kept is the only place that signal exists: the text is never altered and no marker is ever
written in-band (a marker in alert data would be forgeable). A right-to-left mark or a zero-width joiner in
a value is not proof of anything — Persian, Arabic and emoji use them legitimately — but on a homoglyph or
bidi-shaped investigation it is the field to query.
The gate's own record
The bundled Claude Code hook keeps a second, smaller log beside the telemetry: ~/.raffkin/gate.jsonl
(RAFFKIN_GATE_LOG=off disables it and says so on stderr; another path overrides; rotates at ~5 MB with
three backups). One line per decision:
{"ts": "2026-09-05T16:01:26+00:00", "tool": "mcp__plugin_raffkin_exabeam__exabeam_update_alert",
"decision": "ask", "reason": "Raffkin gate: exabeam_update_alert dismisses or closes. It needs the analyst's explicit yes — ask, and wait.",
"target": {"alertId": "4471", "alertStatus": "DISMISSED"}}
The same hook runs again after the call. An ask-tier call that completed is recorded as approved,
beside the ask line — inferred from completion: an ask completes only when a human answered yes. When
the session ran in a mode where nobody could answer (bypassPermissions, dontAsk) the line says
ran_unasked instead, because the ask did not hold for that call. That line should never appear; it
exists so that a gate that somehow did not hold is visible in the record rather than silent. Both lines
carry the host's permission_mode and tool_use_id when present, so a post line ties to its ask:
{"ts": "2026-09-05T16:01:41+00:00", "tool": "mcp__plugin_raffkin_exabeam__exabeam_update_alert",
"decision": "approved", "reason": "an ask-tier call completed after the gate asked: inferred from completion, an ask completes only on a yes",
"permission_mode": "default", "tool_use_id": "toolu_01ABC",
"target": {"alertId": "4471", "alertStatus": "DISMISSED"}}
A deny-tier tool that ran anyway is recorded as ran_despite_deny. Allow-tier reads leave no post-call
line: this file records decisions, and the telemetry records every call. The host runs the post-call
hook only when the tool succeeded, so an approved write that failed upstream leaves the ask line and
the bridge's tool_error event, and no approved line.
target carries the same safe identifier and disposition fields the telemetry's action.* record uses,
and nothing else — so a refused attempt reads as tried to dismiss alert 4471 as dismissed, which is the
near-miss a SOC wants to see, while the note, description or reason text a payload can ride in never
enters the file. This is also the only record of an attempt the gate stopped before it reached the
bridge; the telemetry sees only calls that got that far.
What is deliberately NOT recorded (privacy by construction)
The log stores metadata about the agent's actions — never the raw evidence. Specifically excluded:
- Free-text field values —
note,alertDescription,alertName,supportingReason,closedReason,tags. The case-note text and alert prose never enter the log. - Tool arguments and results in general —
tool_args/tool_resultare alwaysnull. - The neutralized payloads themselves — a defanged formula or phishing URL is counted, never quoted.
- Upstream error text — a failed call is recorded by its error code, HTTP status and class; the platform's message, which quotes the request that failed, goes to stderr for the operator and to the agent, not to the log.
The whole point of the guardrails is to neutralize hostile content; the audit log must not become a second copy of it. observra additionally applies its own PII redaction over everything above.
assigneeis intentionally not in the decision record. Operator identity is already captured once per session as host context (user/host), which is the accountability signal; the per-action assignee is omitted to avoid scattering it across every write.
Where it goes, and how it stays bounded
Default backend is a local, rotating JSON-lines file — no network egress:
| Setting | Env var | Default |
|---|---|---|
| Backend | RAFFKIN_OBSERVRA |
jsonl (set off to disable) |
| File path | RAFFKIN_OBSERVRA_PATH |
~/.raffkin/telemetry.jsonl |
| Rotate at size | RAFFKIN_OBSERVRA_MAX_BYTES |
10485760 (10 MB) |
| Backups kept | RAFFKIN_OBSERVRA_BACKUPS |
5 |
| Webhook destination | RAFFKIN_OBSERVRA_URL |
— (required for webhook) |
| OTLP endpoint | RAFFKIN_OBSERVRA_ENDPOINT |
— (else OTEL_EXPORTER_OTLP_ENDPOINT / ..._LOGS_ENDPOINT, else http://localhost:4318) |
Whatever the backend, the bridge prints the resolved destination on stderr at startup (a path, or the
scheme + host of the endpoint — never a URL path or query, which can carry a token) and records the same on
the mcp_session_start event, so the trail itself attests where it was shipped. One disclosure cannot go
into the log: if telemetry disables itself (a misconfigured backend, a missing library) that fact is
printed to stderr only, because there is no longer a log to write it to.
So the log rotates (telemetry.jsonl → .1 → … → .5, oldest deleted) and is bounded to roughly
60 MB by default. It never grows without limit.
Other backends (opt-in via RAFFKIN_OBSERVRA=): exabeam (routes telemetry back into Exabeam using the
bridge's own creds), otel / otel_log (OpenTelemetry), webhook. These make network calls, so jsonl
is the default. On first enable, the bridge prints one line to stderr naming the destination and how to
turn it off — disclosed, not silent.
Enabling a network backend is an egress decision you own. The default (
jsonl) never leaves the host.exabeam/otel/webhooksend events off-box — action metadata and safe IDs only, never case-note text, evidence, or payloads (the same privacy rules apply) — but you are choosing to route telemetry to that destination. Point them only at systems you control.
Finding and reading your log
By default it's at ~/.raffkin/telemetry.jsonl (rotated backups are telemetry.jsonl.1 … .5). It's
one JSON object per line — read it with anything that speaks JSON lines:
tail -f ~/.raffkin/telemetry.jsonl # watch events live
# the gated actions this session took (which alert, to what disposition):
jq -c 'select(.event_type=="tool_end" and (.data|has("action.disposition")))
| {tool: .tool_name, alert: .data."action.alertId", disp: .data."action.disposition"}' \
~/.raffkin/telemetry.jsonl
# every time a guardrail fired:
jq -c 'select(.data.defang_formula or .data.defang_link or .data.hygiene_stripped)
| {tool: .tool_name, defang_formula: .data.defang_formula, defang_link: .data.defang_link,
hygiene: .data.hygiene_stripped}' ~/.raffkin/telemetry.jsonl
Events from one investigation share a session_id, so you can reconstruct a run by grouping on it.
Fail-open
Logging is best-effort and can never break or slow an investigation. Two different failures, two different responses:
- Configuration-level — observra is unavailable or a backend is misconfigured. Telemetry disables itself (a one-line stderr note) and the bridge carries on exactly as if logging were off.
- A single event failing to emit — that one event is dropped and the trail stays on. A transient fault must not silently switch off a mandatory audit log for the rest of the session, so only a configuration-level failure disables. The drop is disclosed on stderr, never swallowed.
observra's own backend write errors are routed to stderr too, prefixed bridge: observra …, so a
failed write is visible rather than vanishing into a library logger. One gap worth knowing for an audit
trail: if its internal queue fills, observra drops the oldest event and counts the drop rather than
announcing it. The security guardrails are independent and keep running throughout.
Turning it off
export RAFFKIN_OBSERVRA=off
Off means off: no file, and observra is never imported. The bridge says so on stderr when it starts —
bridge: observra logging is OFF (RAFFKIN_OBSERVRA=off) — this session is not recorded — so an unrecorded
session is never a silent one.
Known limitation
The trail records the gated action and its disposition deterministically at the write sink, and because
the gate ships on, an update_alert / update_case write only reaches the bridge after the human
approves it — so the write
event is evidence the approval happened. On Claude Code the bundled hook also records the approval,
as the approved line in gate.jsonl above — inferred from the call completing after an ask, not
observed. Neither record names who answered: that lives in the host agent's approval layer, which
neither the hook nor the bridge can see. The operator is carried on every event as host context
(user/host). Codex has no hook, so there the write event is the only
approval record.