Read the audit log
Every call to api_call emits exactly one JSON record on the
mcsinglewire.audit logger at INFO. The record is small on purpose:
no bearer tokens, no PHI by default, no response bodies — only
enough to answer “who, what, when, did it work”.
-
Pull the records
Tail audit records docker compose logs mcsinglewire | grep mcsinglewire.auditFor working with the data, pipe through
jq:Filter and pretty-print docker compose logs mcsinglewire \| awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \| jq .The
awkstep strips the leadingtime level loggerprefix that Python’s logger prepends, leaving pure JSON forjqto consume. -
The fields, decoded
A typical record:
{"ts": "2026-05-13T14:22:11.043+00:00","event": "api_call","operation_id": "getIpSpeakers","method": "GET","path": "/v1/ipSpeakers","query_params": {"limit": 50, "siteId": "abc-123"},"status": 200,"error": null,"duration_ms": 138,"bearer_fp": "a3f7c1ed982b4f10","mcp_client_id": "Ab12CdEf"}Field What it means tsISO-8601 UTC timestamp, millisecond precision. Recorded at the moment the call completes. operation_idThe OpenAPI operationId the LLM called. Always present. methodHTTP method. Always GETon a successful call (the server refuses everything else).pathThe expanded URL path with path_paramssubstituted.query_paramsThe query parameters sent. Keys and values both, unless SINGLEWIRE_AUDIT_REDACT_QUERY=1is set, in which case values become"<redacted>".statusUpstream HTTP status ( 200,401, etc.) on success. The literal string"rejected"on validation failure. The literal string"write_blocked"on a transport-layer write attempt.errorThe internal error code if any (e.g. "unknown_operation","operation_denylisted").nullon success.duration_msServer-measured time from api_callentry to response. Includes upstream latency.bearer_fpFirst 16 hex characters of SHA-256(bearer). The bearer itself never appears. Stable per Singlewire user. mcp_client_idThe MCP-side client identifier (the OAuth Proxy’s), distinct per Claude Code session / Claude Desktop install / etc. What’s not in the record:
- Response bodies. (You can correlate with HTTP-level access logs if you want them, but the audit log is intentionally narrow.)
- The bearer token, ever.
- Request bodies (there are none — this is a read-only server).
-
Common compliance queries
”What did this user read in the last 24 hours?”
Section titled “”What did this user read in the last 24 hours?””You need their bearer fingerprint. Have them run any tool through the server; one record will appear and the fingerprint is in the
bearer_fpfield. Then:The container log line itself carries the timestamp (from
docker compose’s log prefix); the JSON record carries the rest. Keep the prefix if you want time, strip it if you only want the JSON:With time prefix preserved docker compose logs --since 24h --timestamps mcsinglewire \| grep mcsinglewire.audit \| grep '"bearer_fp":"a3f7c1ed982b4f10"'JSON only, filtered docker compose logs --since 24h mcsinglewire \| awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \| jq 'select(.bearer_fp == "a3f7c1ed982b4f10")| {op: .operation_id, status, query_params, error}'“Did anyone attempt a write?”
Section titled ““Did anyone attempt a write?””Write attempts docker compose logs mcsinglewire \| awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \| jq 'select(.error == "method_not_allowed" or .status == "write_blocked")'A non-empty result here is interesting — it means an LLM tried a mutation. The server caught it, but you might want to know which prompt or question triggered it.
”Which operations are getting hit most?"
Section titled “”Which operations are getting hit most?"”Operation frequency docker compose logs mcsinglewire \| awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \| jq -r '.operation_id' \| sort | uniq -c | sort -rn"Has any denylisted operation been called?”
Section titled “"Has any denylisted operation been called?””Denylisted attempts docker compose logs mcsinglewire \| awk '/mcsinglewire.audit/{print substr($0, index($0, "{"))}' \| jq 'select(.error == "operation_denylisted")' -
Persistence and retention
Audit records land in two places:
- stdout, captured by Docker’s logging driver (default
json-file). This is the canonical source — every record goes here regardless of configuration. /data/audit.logon the shared volume, whenMCSINGLEWIRE_AUDIT_FILEis set (the docker-compose deployment sets it). This is what the audit viewer in the portal reads.
Neither rotates on its own. For production you almost certainly want one of:
- A logging driver that ships stdout records to a SIEM
(
fluentd,gelf,splunk, etc.) — configured indocker-compose.ymlper service. - Periodic export of
/data/audit.logto immutable storage (e.g., a cron job writing daily compliance snapshots).
The file at
/data/audit.loggrows unbounded until you act — roughly one line per MCP call, so it’s slow, but plan for it. - stdout, captured by Docker’s logging driver (default
-
Privacy posture
The bundled OpenAPI spec doesn’t have any GET endpoints whose query parameters carry PHI (patient identifiers, phone numbers, message bodies). If you customise the deployment in a way that does — or you’re being thorough — set:
.env SINGLEWIRE_AUDIT_REDACT_QUERY=1The keys are still logged (so you can see what kind of query was made); the values become
"<redacted>". Bear in mind: redaction makes some compliance questions harder to answer (you can’t reproduce exactly which speaker was asked about), so opt in deliberately.