Skip to main content

07 — Logging & Retention

Two distinct streams. Don't confuse them.

StreamWherePurpose
Audit logState Postgres audit_log tableForensic record. Synchronous, mandatory, append-only.
Stdout logProcess stdout, one JSON object per lineOperational observability. Ingested by Loki / Splunk / etc. via Alloy.

The audit log is the system of record. The stdout log is a sidecar for operators; it MUST NOT be the only place a tool call leaves a trace.

What "audit log" means here

Append-only record of every tool invocation that hits a target database, written synchronously before results return to the agent. If the audit write fails, the request fails. There is no "best effort" audit.

Fields

FieldSource
request_idGenerated per request, returned to client
tsServer-side, monotonic
user_email, user_id, groups, session_idFrom session (see 04-auth-sso)
agent_clientSelf-reported MCP client banner
ipRequest socket
server, databaseLogical names
toolrun_query, describe_schema, …
sqlFull SQL (or redacted / metadata-only per policy)
reasonUser-provided reason string if policy required one
row_countFinal row count returned (after truncation)
truncatedWhether row_limit cut the result
duration_msWall-clock for the DB call
outcomeok, forbidden, timeout, error_*
error_messageSanitized — no credentials or raw connection details

SQL capture policies

Per-database setting:

PolicyEffect
full (default)Raw SQL stored verbatim
redactedLiterals and string constants replaced with placeholders before storage
metadata_onlySQL not stored; only tables touched (parsed from the query plan) and counts

redacted is the right default for databases known to contain PII in literal values (e.g. WHERE email='alice@example.com'). metadata_only is for the truly paranoid; it costs you the ability to reproduce a query later.

Storage

TierWhereTTLPurposeStatus
HotGateway's state Postgres90 days (configurable)Fast operator queries, dashboardsShipped
ArchiveS3 / GCS / Azure Blob, optional1y / 7y / foreverCompliance retentionNot shipped — roadmap Phase 4
StreamOptional sink: stdout, syslog (config: logging.stream: [{kind: stdout}, {kind: syslog, host: ..., port: ...}]), OTLP, KafkalivePlug into existing SIEM/Splunk/Datadogstdout + syslog shipped (#218); OTLP/Kafka not shipped

Hot and stream are independent — you can send everything to Splunk and keep 90 days in Postgres. Archive is a periodic batch job that exports hot entries to compressed JSONL files keyed by date.

Retention pruning

A background task runs hourly:

  1. Find audit rows older than hot TTL.
  2. If archive is configured and the row hasn't been archived yet → export.
  3. Delete from hot once successfully archived (or unconditionally if no archive configured and TTL exceeded).

Pruning is logged. Failures alert (and refuse to delete the row that failed to archive).

Operator queries

Common questions, runnable directly against state DB:

-- Who ran what against prod in the last hour?
select ts, user_email, database, sql, row_count
from audit_log
where server = 'prod' and ts > now() - interval '1 hour'
order by ts desc;

-- Top queries by user for the week
select user_email, count(*) from audit_log
where ts > now() - interval '7 days'
group by user_email order by 2 desc;

-- Find every query that touched a sensitive table
select * from audit_log
where sql ilike '%customer_pii%'
and ts > now() - interval '30 days';

A small read-only SQL view shipped with the gateway exposes the same fields with friendlier column names.

What is not in the audit log

  • The DB password / connection string. Never.
  • Result row contents. Counts and column names only. (Storing actual rows would defeat the purpose of redacted-SQL mode.)
  • Anything that would re-identify a user beyond what's already in the session.

Stdout log — dispatch line contract

Every tool dispatch emits exactly one JSON-per-line record on stdout, in addition to (not in place of) the audit row. Loki / Alloy ingest this without per-line parsing; the keys are top-level and the types are stable. Operator-facing detail lives in docs/deployment/logging.md; this section is the canonical field contract.

Baseline keys on every log line:

FieldTypeNotes
timestampRFC 3339 stringSubscriber default
levelstringINFO / WARN / ERROR / DEBUG
messagestringStatic log-line string

The dispatch line (message = "tool dispatched") carries additionally:

FieldTypeWhen set
request_idstringJSON-RPC request id
user_substringOIDC sub of the caller; "anonymous" on unauthenticated paths
toolstringTool name (run_query, explain, …)
serverstringLogical server name from call args, empty for tools that aren't server-scoped
dbstringLogical database name from call args, empty for tools that aren't database-scoped
outcomestringOne of the spec 03 codes (success / forbidden / forbidden_sql / invalid_arguments / timeout / syntax_error / unavailable / rate_limited / service_overloaded / internal)
duration_msintegerWall-clock the tool itself took. 0 for outcomes where no tool work ran (e.g. forbidden before dispatch).

The audit-write-failure line adds:

FieldTypeNotes
audit_write_duration_msintegerWall-clock the failed audit insert took. duration_ms on the same line keeps the contract above — tool execution time, not audit latency.

Renames or removals of any field above are contract breaks. Additions are fine.

Error reporting (GlitchTip)

Unhandled panics and unexpected errors are shipped to a GlitchTip instance (self-hosted Sentry-compatible) for error tracking. This is a third stream, distinct from the audit log and the stdout log, and it carries no DB credentials — see the scrubber contract below.

Initialization order

Sentry inits first in fn main, before the tokio runtime is built. The init returns a guard (ClientInitGuard) bound to a variable that lives for the whole process; dropping it early stops event delivery, so it is held until main returns. Only then is the multi-thread runtime constructed and the async entry (run()) block_on'd. This guarantees a panic during runtime setup is still captured.

Configuration

KnobSourceNotes
dsnSENTRY_DSN env varUnset / empty → client initializes disabled; the app runs normally and ships nothing. The DSN is never hardcoded.
environmentSENTRY_ENVIRONMENT env varFalls back to development.
releaseCARGO_PKG_VERSIONPinned to the crate version at build time.
send_default_piiHard-coded false. The gateway never sends PII.

Error tracking only. GlitchTip supports neither performance tracing nor profiling nor replay. traces_sample_rate is pinned to 0.0; no spans or transactions are created. Adding tracing/profiling/replay options is a contract break against this section.

before_send scrubber (mandatory)

Every outbound event passes through a before_send hook that redacts secrets. This is defense-in-depth on top of the typed-error discipline in the stdout contract — GlitchTip payloads cross the process boundary and must never carry a credential. The hook scrubs, replacing the secret portion with ***REDACTED***:

  • database connection strings with embedded credentials — postgres://user:pass@host, mysql://…, mongodb://…, and any scheme://user:pass@host shape;
  • bare password-like values in message, exception value/type, breadcrumb message/data, tags, user, request, and extra/contexts.

Env keys whose values must never leak (the scrubber targets their shapes, not the values themselves):

STATE_DB_URL TARGET_DB_URL PERMISSIONS_DB_DSN OIDC_CLIENT_SECRET SESSION_SIGNING_KEY

Scrubber contract:

RuleDetail
Replace only the secret portionThe match is rewritten; the rest of the string is untouched.
Non-matching text is preserved byte-for-byteAn event that carries no secret ships exactly as the SDK built it.
A whole field collapsing to ***REDACTED*** is a failure, not a scrubIt destroys the event and fingerprints every distinct error into one issue.
Patterns compile once, at bootA pattern that does not compile aborts the process — a scrubber that cannot scrub is a build defect, and the gateway must not open an event stream it cannot clean.
The regex features the patterns use are named explicitlyunicode-case for (?i), unicode-perl for \w / \s / \b.
bin/check-scrub-features asserts those features in the no-dev-dependency graphThat is the graph the release image builds; dev-dependencies mask a missing feature from cargo test.

Runtime injection

The DSN is injected at runtime via a Kubernetes sealed-secret (not baked into the image). Operator setup lives in deployment/logging. With no secret mounted, SENTRY_DSN is absent and the client is a no-op — a missing GlitchTip config must never prevent the gateway from serving traffic.