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โ€‹

TierWhereTTLPurpose
HotGateway's state Postgres90 days (configurable)Fast operator queries, dashboards
ArchiveS3 / GCS / Azure Blob, optional1y / 7y / foreverCompliance retention
StreamOptional sink: stdout, syslog, OTLP, KafkalivePlug into existing SIEM/Splunk/Datadog

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 / 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_piiโ€”Hard-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

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.