# PRD 0055: Egress traffic logging

- **Status:** Active
- **Author:** claude
- **Created:** 2026-06-06
- **PR:** #207

## Summary

Adds structured log levels to the egress proxy so operators can observe
traffic and security decisions without modifying any application code.
Three integer levels control verbosity: `0` (off), `1` (security events
only), and `2` (full request/response capture). All output is JSON lines
written to stderr.

## Problem

The egress proxy makes per-request allow/block decisions and DLP scans, but
until now those decisions are invisible unless something is actively blocked
and the caller inspects the 403 body. Debugging unexpected blocks, auditing
what an agent is sending upstream, and verifying DLP detector behaviour all
require adding ad-hoc instrumentation or tailing the sidecar container logs
with no structure to grep against.

## Goals / Success Criteria

1. **Level 0 (off, default):** no egress output to stderr beyond the boot
   line. Existing behaviour for production deployments.
2. **Level 1 (blocks):** every block or DLP warn event is emitted to stderr
   as a JSON line with the event type, human-readable reason (including the
   secret type detected for DLP hits), and the request context (host, method,
   path; plus upstream status code for response-phase events). No traffic
   bodies are logged.
3. **Level 2 (full):** all level-1 events, plus a `egress_request` JSON line
   for every forwarded request (method, path, headers, body after auth
   injection) and an `egress_response` JSON line for every response that
   passes DLP (status, headers, body).
4. The log level is a single integer field `log` at the top of the egress
   config (routes.yaml in the sidecar; `egress.log` in the bottle manifest).
   Values other than 0, 1, 2 are rejected at parse time on both sides.
5. The boot message includes the active log level label (`off`, `blocks`,
   `full`).

## Non-goals

- Log rotation or file sinks — stderr output is captured by the container
  runtime (Docker, smolmachines) and goes wherever the operator routes it.
- Per-route log levels — all routes share the global level.
- Redacting secrets from the level-2 body dump — at level 2 the operator
  has explicitly requested full visibility; redaction belongs in the
  log consumer, not the proxy.

## Design

### Wire format

`routes.yaml` gains an optional top-level `log` key:

```yaml
log: 1          # 0 = off (default), 1 = blocks, 2 = full
routes:
  - host: "api.anthropic.com"
    ...
```

The field is omitted entirely when the level is 0 (default).

### Manifest format

```yaml
egress:
  log: 1
  routes:
    - host: "api.anthropic.com"
      ...
```

`egress.log` accepts integers 0, 1, or 2. Booleans and strings are rejected.

### Log events

**Block / DLP block (level ≥ 1):**
```json
{
  "event": "egress_block",
  "reason": "egress DLP: GitHub token (classic) found in request",
  "host": "api.github.com",
  "method": "POST",
  "path": "/gists"
}
```

Response-phase block also includes `"response_status"`.

**DLP warn (level ≥ 1):**
```json
{
  "event": "egress_warn",
  "reason": "egress DLP: possible prompt injection detected",
  "host": "api.anthropic.com",
  "method": "POST",
  "path": "/v1/messages",
  "response_status": 200
}
```

**Forwarded request (level 2):**
```json
{
  "event": "egress_request",
  "host": "api.anthropic.com",
  "method": "POST",
  "path": "/v1/messages",
  "headers": { "authorization": "Bearer sk-ant-...", "content-type": "application/json" },
  "body": "{\"model\": \"claude-opus-4-8\", ...}"
}
```

The request is logged after auth injection, so the outgoing `Authorization`
header is present. The agent's original `Authorization` header is stripped
before logging.

**Response (level 2):**
```json
{
  "event": "egress_response",
  "host": "api.anthropic.com",
  "status": 200,
  "headers": { "content-type": "application/json" },
  "body": "{\"id\": \"msg_...\", ...}"
}
```

Responses are logged before DLP scanning, so the body is always the raw
upstream response.

### Implementation

- **`egress_addon_core.py`**: `Config.log: int = LOG_OFF` (`LOG_OFF=0`,
  `LOG_BLOCKS=1`, `LOG_FULL=2`). `parse_config()` validates the integer and
  rejects booleans.
- **`egress_addon.py`**: `_block()` emits JSON when `log >= LOG_BLOCKS`. The
  `_req_ctx()` helper builds `{host, method, path}` for every call site.
  `_log_request()` / `_log_response()` fire when `log >= LOG_FULL`.
- **`manifest_egress.py`**: `EgressConfig.Log: int = 0`, parsed from
  `egress.log`, validated against `{0, 1, 2}`.
- **`egress.py`**: `egress_render_routes(routes, *, log: int = 0)` emits
  `log: N` at the top of routes.yaml when N > 0. `EgressPlan.log: int = 0`.
