Governance & Audit
Observability for agent systems — structured logs, distributed traces through tool calls, OpenTelemetry, PII redaction. Safety guardrails — kill switches, rate caps, allow-lists, breakglass approval.
Why this is infrastructure's problem
The Agent Systems team decides what the agent does. You decide what gets recorded, who can stop it, and what bounds it stays inside. In a high-stakes fintech, that boundary is the difference between "shippable" and "shouldn't have shipped":
- Regulators ask "why did your AI do X?" — you produce the trace.
- Security asks "could an agent action escape its scope?" — you point at the allow-list and the audit log.
- Ops asks "an agent is in a retry storm against a partner, how do I stop it?" — you point at the kill switch.
- Privacy asks "do you handle PII in logs correctly?" — you point at the redaction layer.
This is exactly what the JD means by "Define guardrails, observability, and failure handling for agent-driven workflows."
Structured logs
Plain text logs are useless at scale. Use JSON-line logs with consistent fields. In Rust, the tracing crate is the standard.
use tracing::{info, instrument};
#[instrument(skip(req), fields(run_id = %req.run_id, model = %req.model))]
pub async fn handle(req: GenerateReq) -> Result<Resp, Error> {
info!(input_tokens = req.tokens.len(), "request received");
let resp = backend.call(&req).await?;
info!(output_tokens = resp.tokens.len(), cost_usd = ?resp.cost, "request complete");
Ok(resp)
}
Logs to ship:
- Per request: request_id, run_id, user_id, model, input_tokens, output_tokens, cost, latency, status.
- Per tool call: run_id, tool_name, idem_key, args_hash, result_hash, status, latency.
- Per approval: run_id, tool_name, approver, decision, latency-to-approve.
- Per error: error_kind, source, retryable, full chain via
tracing-error.
Don't log full prompts and responses by default — they're large, PII-laden, and expensive to store hot. Log hashes and store the bodies in cheaper content-addressed storage.
Trace IDs propagated through tool calls
A distributed trace is a tree of spans. Each span is one operation (HTTP call, function, tool invocation). Spans share a trace ID and reference their parent span ID. Result: you can reconstruct the entire path of a request, across services.
For an agent run:
trace(run_id=R1)
├── span: orchestrator.run
│ ├── span: model.call (step 1)
│ ├── span: tool.dispatch.search_kyc
│ │ └── span: kyc_service.lookup
│ ├── span: model.call (step 2)
│ ├── span: tool.dispatch.draft_summary
│ └── span: model.call (step 3)
Propagation rules:
- HTTP/gRPC requests carry trace context in headers (
traceparent,tracestate). tower/tonic with the OTel middleware do this automatically. - Async tasks inside a process — use
#[instrument]or.instrument(span)to keep the span context across spawn boundaries. - Background jobs and queues — propagate trace context in the message envelope. Easy to forget.
- Don't generate a fresh trace ID per step — the whole agent run shares one trace ID.
OpenTelemetry in Rust
OTel is the cross-language standard. In Rust, tracing + tracing-opentelemetry bridges to OTLP exporters (Jaeger, Tempo, Honeycomb, Datadog, etc).
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::WithExportConfig;
use tracing_subscriber::{prelude::*, EnvFilter};
fn init_tracing() {
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(
opentelemetry_otlp::new_exporter()
.tonic()
.with_endpoint("http://otel-collector:4317")
)
.install_batch(opentelemetry_sdk::runtime::Tokio)
.unwrap();
let otel = tracing_opentelemetry::layer().with_tracer(tracer);
let fmt = tracing_subscriber::fmt::layer().json();
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(otel)
.with(fmt)
.init();
}
Metrics use opentelemetry-prometheus or push directly to OTLP. Standard metrics for an inference gateway:
requests_total{model, status}(counter)request_duration_seconds{model, status}(histogram, for p50/p99)tokens_total{model, kind="input|output"}(counter)cost_usd_total{model, tenant}(counter)concurrent_inflight{backend}(gauge)circuit_breaker_state{backend}(gauge: 0=closed, 1=open, 2=half-open)
PII redaction
Two flavors of PII risk in agent infra:
- In logs and traces — full prompts may contain customer names, account numbers, identifiers. Don't ship those raw to a third-party APM.
- In prompts sent to external model providers — depending on contract and jurisdiction, you may need to redact or tokenize before the call.
Patterns:
- Tokenize at the boundary. Replace
account_id=12345withaccount_id=ACCT_T0a8f. Keep the mapping in a separate, access-controlled store. Logs reference tokens, not raw values. - Redact known patterns. Regex for emails, SSNs, card numbers; replace with
[REDACTED]. - Hash, not log. Log
prompt_hash=sha256(prompt); store the prompt body in an access-controlled content store keyed by hash. - Per-tenant data plane. EU customer prompts only go to EU-resident models when contractually required.
Even hashed prompts can leak. If your prompt template includes the customer's name in a predictable place, two requests for the same customer hash to the same value. Salt per-tenant; or only hash for diagnostics, not as a primary identifier.
Audit logs for autonomous actions
An audit log is different from regular service logs: append-only, retention-bound, tamper-evident, indexed for legal/regulatory queries. For every agent run, you record:
- Run start: run_id, user, model versions, prompt versions, tool catalog version, policy version.
- Each model call: prompt hash, response hash, model+params, latency, cost.
- Each tool dispatch: tool, args hash, idem key, outcome hash, approver (if applicable).
- Each approval: approver, timestamp, decision, what they saw.
- Run end: final outcome, total cost, total steps.
Storage:
- Append-only Postgres table (write-only role) or Kafka topic with infinite retention for the audit window.
- Hash-chain events for tamper evidence: each event includes a hash of the previous event.
- Cold archive to S3 with object-lock for the regulatory retention period.
- Searchable index (OpenSearch / BigQuery) — denormalized for fast "give me all runs for user X on date Y."
Safety guardrails — the kit
Guardrails are the things that bound an agent's behavior independent of what it "wants" to do. The kit:
| Guardrail | Effect |
|---|---|
| Kill switch | Halt all agent runs immediately (global or per-policy). |
| Rate caps | Per-user, per-tenant, per-tool, per-policy. Stops runaway agents. |
| Action allow-list | Per-policy enumerated set of tools that may be called. |
| Cost budget | Per-run, per-tenant, per-day. Hard cap. |
| Step limit | Per-run max steps. Prevents loops. |
| Deadline | Wall-clock max per run. |
| Risk-tier approval | High-tier actions require human approval before dispatch. |
| Anomaly rate-limit | If tool-call rate deviates >Nσ from baseline, pause and alert. |
Kill switches
The simplest, most important guardrail. A flag in a fast-readable store (Redis, a config service) that every executor checks before each step.
pub struct KillSwitch {
client: redis::aio::ConnectionManager,
cache: std::sync::Arc<arc_swap::ArcSwap<KillState>>,
}
impl KillSwitch {
// Polled in the background, latest state cached locally for cheap reads.
pub fn is_killed(&self, policy: &str) -> bool {
self.cache.load().policies.contains(policy)
|| self.cache.load().global
}
}
// In the executor:
if kill.is_killed(&run.policy) {
halt_run(&run, "kill_switch").await?;
return;
}
Properties to verify in design discussion:
- Activatable in seconds. Not "redeploy with a feature flag." A button somewhere.
- Per-policy granularity. Killing the SAR-draft agent shouldn't kill the alert-triage agent.
- Audited. Who pushed the button, when, why.
- Discoverable. On-call should know it exists and how to use it.
- Cached locally so a Redis outage doesn't break the kill check (it should fail closed = act-as-killed, or fail open = act-as-allowed; the safer default depends on context).
Rate caps
Token-bucket per dimension. See chapter 11 for the implementation. Dimensions you want for agent infra:
- Per user. One user can't burn the whole tenant's budget.
- Per tenant. One tenant can't burn the fleet.
- Per tool. A flaky external tool can't be hammered.
- Per (run, tool). A single run can't call the same tool 1000 times.
- Per model. Expensive models get tighter caps.
Action allow-lists
Per-policy, the agent has access to a named set of tools and no others. The orchestrator enforces this at dispatch time — never at prompt-construction time only. Defense in depth: even if a model is prompt-injected into requesting a forbidden tool, the dispatcher refuses.
pub struct Policy {
pub id: String,
pub tools_allowed: std::collections::HashSet<String>,
pub risk_tier: RiskTier,
pub max_steps: u32,
pub max_cost_usd: f32,
}
fn authorize_tool(policy: &Policy, tool: &str) -> Result<(), AuthError> {
if policy.tools_allowed.contains(tool) {
Ok(())
} else {
Err(AuthError::ToolNotAllowed(tool.into()))
}
}
Human-approval breakglass
"Breakglass" comes from physical-security parlance: a sealed override available only in emergency. In agent infra:
- High-risk tools require human approval in the normal flow.
- Some workflows need an "emergency override" — e.g., security incident triage where you accept a higher-risk action under sign-off.
- Breakglass is audited heavily. Multiple approvers, justification text, automatic retroactive review.
Architecturally: the approval queue is its own service. Tools that require approval publish a request; an operator UI displays it; the operator approves/denies; the executor receives the decision and continues. Timeouts on approval (e.g., 30 min) result in a default-deny.
"For high-tier autonomous actions, the architecture is: allow-list at dispatch, idempotency key on the call, approval gate before execution, audit event on intent and outcome, kill switch over the whole policy. Defense in depth — no single failure of any one of those lets a bad action through."