Skip to content

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

  1. Pull the records

    Tail audit records
    docker compose logs mcsinglewire | grep mcsinglewire.audit

    For 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 awk step strips the leading time level logger prefix that Python’s logger prepends, leaving pure JSON for jq to consume.

  2. 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"
    }
    FieldWhat 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 GET on a successful call (the server refuses everything else).
    pathThe expanded URL path with path_params substituted.
    query_paramsThe query parameters sent. Keys and values both, unless SINGLEWIRE_AUDIT_REDACT_QUERY=1 is 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"). null on success.
    duration_msServer-measured time from api_call entry 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).
  3. 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_fp field. 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}'
    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")'
  4. 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.log on the shared volume, when MCSINGLEWIRE_AUDIT_FILE is 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 in docker-compose.yml per service.
    • Periodic export of /data/audit.log to immutable storage (e.g., a cron job writing daily compliance snapshots).

    The file at /data/audit.log grows unbounded until you act — roughly one line per MCP call, so it’s slow, but plan for it.

  5. 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=1

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