Audit Logging & Trace Correlation

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.

  1. mise run dev, then confirm Claude Code is connected: claude mcp list should show tofu-external-mcp - ✓ Connected.

  2. Call a mutation tool that has an audit hook — e.g. ask Claude to create_extraction for an existing entity/document page (read-only tools like get_document don’t exercise the bonsapi audit-hook path).

  3. Get the call’s trace_id from tofu-external-mcp’s own tool_call_result line:

    docker logs tofu-external-mcp 2>&1 | grep '"tool_name":"create_extraction"' | tail -1
    
  4. Pull both services’ lines for that trace_id and 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_request and one tool_call_result line, same trace_id/operation_id.
    • bonsapi: an extraction_created line (there’s no separate invoice_created — an AP/AR extraction’s derived invoice is covered by the same event, see the Event names table above) with the same trace_id, caller_service: "tofu-external-mcp", and data.changes containing only allowlisted fields (no page titles, contact names, etc.).
    • Both lines’ dd.trace_id decimal values match.
  5. 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.