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.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 |
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.