Audit Logging & Trace Correlation #
Classification: Internal-Only
Covers the audit/application logging that ships with tofu-external-mcp and its bonsapi counterpart (the P7.2/P7.3 work: ENG-7196, ENG-7197) — what gets logged, how the two services’ logs join on one trace id, and how to read them when debugging. For general log search/retention (Datadog, CloudWatch, S3), see Log Management & Search.
How a trace flows across the two services #
tofu-external-mcp bonsapi
------------------ -------
generateTraceContext()
-> trace_id (32 hex), span_id (16 hex)
withLogEvent wraps the tool handler
logs ToolCallRequest
... handler runs, calls bonsapi via customFetch ...
forwards headers:
traceparent: 00-<trace_id>-<span_id>-01
X-TOFU-TRACE-ID: <trace_id> -> BonsaiRootSpanBuilder parses both:
X-Tool-Name: <tool> traceparent (OTel parent context)
X-Operation-ID: <operation_id> X-TOFU-TRACE-ID (human-readable trace_id span field)
X-Tool-Name / X-Operation-ID (span fields)
BonsaiUser::from_request verifies the
bearer token and, only for a genuine
MCP OAuth client, calls
record_authenticated_caller_service()
repository (Entity/Invoice/Extraction)
audit hook gates on that, builds a
redacted delta, logs entity_created/
updated/deleted etc.
logs ToolCallResult (+ ToolError on failure)
Same trace_id (and its Datadog decimal form, dd.trace_id) shows up on both services’ log lines for one call — that’s the join key. tool_name/operation_id are a second, human-readable correlation path when a log line’s trace_id field is truncated or a search UI doesn’t index it.
Important: caller_service (bonsapi) is only ever set from record_authenticated_caller_service, driven by the verified bearer token’s client id — never from the client-suppliable X-Tool-Name/X-Operation-ID headers. A forged X-Tool-Name header cannot make a non-MCP request’s mutation look MCP-originated in the audit trail.
New logging fields #
Both services emit one flat, structured JSON log line per event (libs/typescript/logger’s LogEvent / bonsai-utils’s Rust counterpart in event_log.rs — field-for-field the same shape). Field names below are the actual JSON keys, not TS/Rust parameter names.
Envelope fields (top-level on every line) #
| Field | Emitted by | Meaning |
|---|---|---|
trace_id |
both | 32-hex W3C trace id, shared by both services for one call |
dd.trace_id / dd.span_id |
both | Decimal (lower-64-bit) form of trace_id/span_id — the format Datadog’s trace/log correlation requires |
operation_id |
both | Unique per tool-call invocation, including a client retry. MCP mints it and forwards it as X-Operation-ID; bonsapi echoes the same value back on its audit line (current_operation_id(), from the header) rather than minting its own |
session_id |
both (bonsapi currently always empty) | MCP transport session id (Mcp-Session-Id) — one chat session can span many tool calls. bonsapi has the field wired (current_session_id()) but MCP doesn’t forward it yet (ENG-7715), so it’s always absent on bonsapi’s side today |
event_type |
both | audit_log or application_log — see Event names |
event_name |
both | Which specific event (see table below) |
service |
both | tofu-external-mcp or bonsapi — which process emitted this line |
caller_service |
both, but only meaningful on bonsapi | MCP’s own line always names itself ("tofu-external-mcp" — there’s no further upstream to attribute it to). On bonsapi’s line this is the actual signal: "tofu-external-mcp" when the mutation was reached via an MCP tool call, absent for a direct webapp/other-origin request — see the important note above |
result |
both | success / failure (named result, not status — Datadog reserves status for its own log-severity remapping) |
actor_user_id / actor_org_id |
both | Resolved from the caller’s verified identity |
actor_client_id / actor_user_ip |
MCP only | No bonsapi equivalent today (always absent there) |
http_status |
both | HTTP status of the underlying request/response, when applicable |
data-nested fields (vary by event; not top-level)
#
| Field | Emitted by | Meaning |
|---|---|---|
data.tool_name |
MCP (forwarded as X-Tool-Name) |
The MCP tool that was called (e.g. create_extraction) — constant across retries. Also present, from the same header, inside bonsapi’s own audit-line data (see data.entity_id/data.changes below) |
data.params |
MCP (tool_call_request/tool_call_result) |
The tool’s request params, redacted per the tool’s own logSafeRequestFields allowlist — this is ENG-7193’s “request_params (redacted)” |
data.response |
MCP (tool_call_result) |
The tool’s response body, redacted the same way |
data.auth_mode |
MCP | Which credential authenticated the call — always "oauth" today (JWT-only, ENG-7010) |
data.latency_ms |
MCP (tool_call_result/tool_error) |
Call duration in ms |
data.error / data.error_code |
MCP (tool_call_result/tool_error, on failure) |
See tool-call error codes below |
data.limit / data.cost |
MCP (rate_limited) |
The ceiling in force, and how many tool calls this request asked for (>1 for a JSON-RPC batch, charged and refused as a unit) |
data.current_count |
MCP (rate_limited) |
Calls already inside the window. Absent on a fail-open, where the count is exactly what could not be read |
data.failed_open |
MCP (rate_limited) |
true when Redis was unreachable and the request ran unlimited, rather than the caller actually being over the limit. While this is firing there is no limit in effect, so it wants its own monitor |
data.suppressed_since_last |
MCP (rate_limited, fail-open only) |
How many fail-open lines were collapsed since the previous one — the log is throttled, so this is the real volume |
data.entity_id |
bonsapi (application_log) |
The audited record’s id — always keyed entity_id regardless of entity_type ("entity", "invoice", "extraction") |
data.changes |
bonsapi (application_log) |
The delta: {field: {old, new}}, already narrowed to that entity type’s PII allowlist — see PII redaction |
Event names #
Audit-log events (event_type: audit_log) — security/compliance
#
event_name |
Meaning |
|---|---|
auth_failure |
Caller identity itself was rejected (missing/invalid header, bad JWT, wrong client id, bonsapi rejected an already-verified token mid-call) |
authz_failure |
Identity was fine, access was denied (org feature flag off, no active org, bonsapi denied access) |
authenticated |
A successful auth (OAuth) |
session_start / session_end |
MCP transport session lifecycle |
transport_rejected |
MCP transport itself rejected the request at the protocol level (e.g. no session yet) |
unknown_session_id |
An Mcp-Session-Id this instance doesn’t recognize — request still serves via stateless fallback |
rate_limited |
A caller was refused by the per-user tool-call limiter, or the limiter let a request through unlimited because Redis was unavailable (data.failed_open splits the two) — see Rate Limiting |
service_start / service_stop / service_degraded |
Process lifecycle, not tied to any one caller |
audit_error |
Non-tool-call operational failure that’s still audit-relevant (e.g. Clerk upstream failure during OAuth) |
logging_failure |
The logging pipeline itself failed to emit a line |
Application-log events (event_type: application_log) — operational
#
event_name |
Meaning |
|---|---|
tool_call_request |
Emitted before a tool handler runs (params only) |
tool_call_result |
Emitted after the handler settles (params, response, and, for a mutation, data.changes) |
tool_error |
Dual-written alongside a failing tool_call_result, individually queryable by name |
application_error |
Non-tool-call operational failure (upstream fetch error, oversized response body, etc.) |
entity_created / entity_updated / entity_deleted |
bonsapi EntityRepository audit hook |
invoice_updated / invoice_deleted |
bonsapi InvoiceRepository audit hook (no invoice_created — invoices are created as a side effect of extraction creation, logged under extraction_created instead) |
extraction_created / extraction_updated / extraction_deleted |
bonsapi ExtractionRepository audit hook |
Tool-call error codes (error_code, MCP only)
#
A failed tool call’s error_code (libs/typescript/logger’s ErrorCode, from error-mapper.ts):
error_code |
http_status |
Meaning |
|---|---|---|
auth_failure |
401 | Caller’s credential was rejected |
authz_failure |
403 | Caller is who they say, but lacks permission |
validation_error |
400 | Bad input — schema/params rejected before or by bonsapi |
not_found |
404 | Referenced id doesn’t exist, or the caller can’t see it |
bonsapi_error |
(bonsapi’s own status, or 413/502) | Any other bonsapi rejection, oversized response body, or transport failure |
internal_error |
500 | Unclassified failure |
auth_failure/authz_failure tool errors are dual-written to the audit log too (bonsapi_rejected_token / bonsapi_denied_access under AuthFailureReason) — a downstream auth/authz failure is a security signal, not just an operational one.
PII redaction #
bonsapi’s audit deltas are default-deny: apps/bonsapi/src/audit/pii_redaction.rs narrows both the old and new state to a fixed per-entity_type allowlist before diffing — any field not on that list is replaced with a type-tagged placeholder ("[redacted:string]", "[redacted:object]", etc.) rather than logged. An unrecognized entity_type gets the empty allowlist (redacts everything, logs a warn! so the gap is diagnosable) — a forgotten allowlist entry fails safe.
Current allowlists: invoice (id, entity_id, status, invoice_number, total_amount, currency_id, invoice_date, created_at, updated_at), extraction (id, entity_id, type, status, created_at, updated_at), entity (id, name, created_at, updated_at, country_id). See pii_redaction.rs for the full, current list (it’s the source of truth — this doc will drift before that file does).
MCP-side request/response redaction (apps/tofu-external-mcp/src/logging/redact.ts) is a separate, per-tool opt-in allowlist (logSafeRequestFields/logSafeResponseFields) — same default-deny principle, different mechanism, since MCP tool payloads are arbitrary nested JSON rather than one flat entity record.
How to read logs by trace_id #
Local dev (mise run dev):
# Both services log to their container's stdout/stderr.
docker logs tofu-external-mcp 2>&1 | grep '<trace_id>'
docker logs bonsapi 2>&1 | grep '<trace_id>'
# Or follow both live while you make a call:
docker compose logs -f tofu-external-mcp bonsapi | grep --line-buffered '<trace_id>'
Get <trace_id> from the MCP side’s own tool_call_result line (or tool_call_request) for the call you care about — it’s present on every log line either service produces for that call.
Datadog (dev/prod): @trace_id:<trace_id> searches across both services (see Log Management & Search for the general search UI). dd.trace_id/dd.span_id are what Datadog’s own trace/log correlation UI (not just full-text search) uses to link a log line to an APM trace — bonsapi has real OTel spans; tofu-external-mcp currently has log-correlation only (no dd-trace instrumentation yet, see docs/ai/tofu-external-mcp.md).
E2E verification checklist #
Requires a running dev stack and a real Clerk login (this is an OAuth-gated MCP server — there’s no static token to script around). Re-run this after any change to tofu-external-mcp’s src/logging/ or src/tracing.ts, or bonsapi’s src/audit/ or src/model/app/logging.rs.
-
mise run dev, then confirm Claude Code is connected:claude mcp listshould showtofu-external-mcp - ✓ Connected. -
Call a mutation tool that has an audit hook — e.g. ask Claude to
create_extractionfor an existing entity/document page (read-only tools likeget_documentdon’t exercise the bonsapi audit-hook path). -
Get the call’s
trace_idfromtofu-external-mcp’s owntool_call_resultline:docker logs tofu-external-mcp 2>&1 | grep '"tool_name":"create_extraction"' | tail -1 -
Pull both services’ lines for that
trace_idand check them against the tables above:docker logs tofu-external-mcp 2>&1 | grep '<trace_id>' docker logs bonsapi 2>&1 | grep '<trace_id>'- MCP: one
tool_call_requestand onetool_call_resultline, sametrace_id/operation_id. - bonsapi: an
extraction_createdline (there’s no separateinvoice_created— an AP/AR extraction’s derived invoice is covered by the same event, see the Event names table above) with the sametrace_id,caller_service: "tofu-external-mcp", anddata.changescontaining only allowlisted fields (no page titles, contact names, etc.). - Both lines’
dd.trace_iddecimal values match.
- MCP: one
-
Confirm PII redaction: nothing outside each entity type’s allowlist (above) appears un-redacted in
data.changes.
Breaking changes #
None in this phase — every field above is additive to the existing @bonsai/logger/bonsai-utils log envelope. Two changes from earlier phases are worth knowing if you’re comparing against old log samples: ToolCall was split into separate tool_call_request/tool_call_result events, and the envelope’s status field was renamed to result (Datadog reserves status for log-severity remapping) — both already shipped, not part of this phase.