# qURL Connector Audit Logging

> Ship and operate the qURL Connector audit log: the control-plane event taxonomy, JSONL schema, sink topologies, retention, and verification for compliance and forensics.

Source: https://layerv.ai/docs/connectors/audit-logging/

Use this guide with the standard qURL Connector install guide at https://layerv.ai/docs/connectors/ to route and verify the Connector's audit stream. Audit logging is on by default; the decision you make here is where it lands and how you consume it.

---

## Overview

Audit logging ships enabled in current qURL Connector releases (v0.4.0 and later). The Connector emits one structured record per control-plane decision — bootstrap, knock, login, proxy, and teardown — and dual-sinks each record:

- A forensic-grade JSONL file at `audit.file_path` (default `/var/log/layerv/qurl-connector/audit.log`) with bounded size, age, and count rotation.
- A mirror onto the runtime log stream at INFO, so central log shippers (journald, CloudWatch, GCP Cloud Logging) see the same events without bind-mounting the file.

Two starting points:

- **Zero-config visibility:** with the defaults, audit entries already appear inline in the Connector's runtime logs via the slog mirror. `docker logs` and native cloud log aggregation pick them up with no extra wiring.
- **Compliance-grade record:** for a clean JSONL stream suited to forensic replay or retention, bind-mount the audit file out of the container, or point `QURL_AUDIT_FILE` at a host path you ship. The file is the canonical record regardless of the mirror.

## Event Taxonomy

A healthy tunnel session produces roughly 5 to 10 entries. The `event` field uses these stable strings. The proxy path emits only `tunnel.proxy.allow`; there is no per-proxy deny or error event today.

| Event | When |
|-------|------|
| `tunnel.bootstrap.success` | The Connector registered its persistent key with the NHP server on first start or after state recovery. |
| `tunnel.bootstrap.deny` | The NHP server refused registration (bad public key, registry full, or an unauthorized bootstrap key). |
| `tunnel.bootstrap.error` | A transport, timeout, or parse error stopped the Connector from reaching the NHP server. |
| `tunnel.knock.success` | The NHP knock to the access controller succeeded and an access token was returned. |
| `tunnel.knock.deny` | The access controller accepted the transaction but issued no token for the requested resource. |
| `tunnel.knock.error` | A transport, timeout, or crypto error interrupted the knock. |
| `tunnel.login.success` | The control channel to the qURL tunnel server came up. |
| `tunnel.login.deny` | The tunnel server rejected the knock token. |
| `tunnel.login.error` | A dial, timeout, or login error occurred before a session was established. |
| `tunnel.proxy.allow` | Routes were registered with the tunnel server — emitted once per healthy cycle alongside login success. |
| `tunnel.teardown` | The tunnel session ended; the `outcome` field records whether the shutdown was clean or errored. |

Each line is one JSON object. The values below are illustrative; your records carry their own IDs, addresses, and timestamps. Readers must tolerate unknown fields, because the schema can add fields over time.

```json
{"ts":"2026-06-12T14:03:21.512Z","event":"tunnel.knock.success","outcome":"success","actor":"prod-dashboard","trace_id":"7c1f9a2e","resource_id":"res_8f2a31","source_ip":"203.0.113.42","latency_ms":48.6,"machine_id":"e3b0c44298fc","proxy_version":"v0.4.0"}
{"ts":"2026-06-12T14:03:21.998Z","event":"tunnel.login.success","outcome":"success","actor":"prod-dashboard","trace_id":"7c1f9a2e","machine_id":"e3b0c44298fc","proxy_version":"v0.4.0"}
{"ts":"2026-06-12T15:11:09.204Z","event":"tunnel.knock.error","outcome":"error","reason":"dial_timeout","actor":"prod-dashboard","trace_id":"9a4d20b1","error":"dial tcp 198.51.100.7:62201: i/o timeout","machine_id":"e3b0c44298fc","proxy_version":"v0.4.0"}
```

Field reference:

- `ts`: event timestamp, UTC, RFC 3339. Stamped when the event is recorded, not when it is flushed.
- `event`: the taxonomy string from the table above.
- `outcome`: `success`, `allow`, `deny`, or `error`. Key dashboards and alerts on this field, not on the log level.
- `reason`: short cause tag on deny and error entries, for example `dial_timeout` or `knock_expired`. Empty on success.
- `actor`: the qURL Connector identity that performed the operation (its connector ID).
- `trace_id`: correlates knock, login, proxy, and teardown entries within one supervisor cycle.
- `resource_id`: the qURL resource the decision is about, when applicable.
- `source_ip`: origin of the operation, best-effort; may be empty when the local socket address is unknown.
- `latency_ms`, `bytes_sent`, `bytes_received`: per-operation metrics, present where the call site measures them.
- `error`: underlying error message on the error path. Empty on policy deny and on success.
- `machine_id`, `proxy_version`: host machine identifier and the Connector build version that emitted the entry.

## Configuration

The defaults are production-safe — leaving the `audit` block out of `qurl-proxy.yaml` entirely produces a working pipeline against the default file path with standard rotation. Set only the knobs you need to change.

```yaml
audit:
  enabled: true                                       # default true; QURL_AUDIT_ENABLED is the env kill switch
  file_path: /var/log/layerv/qurl-connector/audit.log # forensic-grade JSONL record
  mirror_slog: true                                   # also emit each entry to the runtime log stream at INFO
  buffer_size: 4096                                   # in-process channel buffer; rarely needs tuning
  max_size_mb: 100                                    # rotate the active file above 100 MB
  max_age_days: 90                                    # evict rotated backups older than 90 days
  max_backups: 14                                     # keep at most 14 rotated files
  compress: true                                      # gzip rotated backups
```

Environment overrides:

```bash
# Redirect the audit file without editing YAML (compose / k8s manifests):
QURL_AUDIT_FILE=/var/log/layerv/qurl-connector/audit.log

# Disable audit emission entirely (env kill switch; overrides the YAML):
QURL_AUDIT_ENABLED=false
```

Retention defaults:

- The active file rotates above `max_size_mb` (default 100 MB) using size, age, and count limits.
- Backups older than `max_age_days` (default 90, the SOC 2 and PCI DSS minimum hot-tier retention) are evicted.
- At most `max_backups` (default 14) rotated files are retained; `compress: true` gzips them.
- Bootstrap events fire before the YAML loads, so set `QURL_AUDIT_FILE` — not only `audit.file_path` — to co-locate them with runtime events.

## Sink Topologies

The mirror and the file are independent destinations. Pick the one that matches your pipeline.

- **File plus runtime-log mirror (default):** the right choice for most installs. Rotated JSONL on disk is the forensic-grade record; your central log shipper also sees every entry on the runtime stream through the slog mirror, so no bind-mount is required just to get visibility.
- **File only (`mirror_slog: false`):** set this when audit volume is high and you do not want the duplicate on the runtime stream, or when the shipper is fed directly from the bind-mounted file with `-v /var/log/layerv:/var/log/layerv`. The file remains the canonical record.
- **Runtime-log mirror only:** uncommon. Only sensible when the local file is unavailable, for example a read-only filesystem, and central logging is the sole destination. Rotation still needs a writable parent directory, so point `file_path` at a writable tmpfs path in that case.

Filter by attribute, not by level:

- Every audit record mirrors at INFO, including `deny` and `error` outcomes. A `deny` is a correct policy outcome, not a malfunction, so it is deliberately not emitted at a higher level. A shipper that filters `level >= WARN` will see no audit records at all.
- Key dashboards and alerting on the `outcome` attribute (`outcome=deny`, `outcome=error`), never on the log level.
- If your shipper can only filter by level, route audit through the file sink and ship that file directly — the file path is canonical regardless.

## Verify and Operate

After the Connector connects, confirm audit entries are flowing on whichever sink you intend to consume, then confirm rotation is keeping the volume bounded.

```bash
# Default install: audit entries appear inline in the Connector's runtime
# logs via the slog mirror at INFO. Filter for the audit event prefix:
docker logs -f qurl-connector-prod-dashboard | grep '"event":"tunnel.'

# Compliance-grade JSONL: bind-mount the audit directory out of the
# container (docker run ... -v /var/log/layerv:/var/log/layerv ...),
# then tail the clean stream from the host:
sudo tail -f /var/log/layerv/qurl-connector/audit.log

# Confirm rotated backups are bounded and gzipped:
sudo ls -lh /var/log/layerv/qurl-connector/
```

The pipeline is healthy when all four are true:

- `tunnel.knock.success` and `tunnel.login.success` appear after the Connector connects.
- The chosen sink — file, runtime-log mirror, or both — carries the entries.
- Dashboards bucket on the outcome attribute and surface deny and error counts.
- Rotated, gzipped backups exist and stay within the configured limits.

## Troubleshooting

The audit file or log stream shows no audit events:

- Entries are written per control-plane decision, so they appear after the first tunnel cycle (typically 5 to 10 per session). Confirm `audit.enabled` is true and that `QURL_AUDIT_ENABLED` is not set to false.

The central log shipper receives no audit records even though they appear in `docker logs`:

- Every audit entry is mirrored at INFO, including deny and error outcomes — a shipper filtering `level >= WARN` drops all of them. Key your dashboards and alerts on the `outcome` attribute, or ship the file sink directly.

Bootstrap events land in a different file than the runtime events:

- The first `tunnel.bootstrap.*` entries are emitted before the YAML loads, so they follow `QURL_AUDIT_FILE` (or the default path), not `audit.file_path`. When redirecting the sink, set `QURL_AUDIT_FILE` so bootstrap and runtime events co-locate.

The audit volume is growing faster than expected:

- Lower `max_size_mb`, `max_backups`, or `max_age_days`. Rotated backups are gzipped when `compress` is true; JSONL typically compresses 5 to 10 times.

You need to silence audit during incident triage without editing YAML:

- Set `QURL_AUDIT_ENABLED=false`. The env kill switch overrides the YAML and suppresses both the file and the slog mirror until it is removed.

---

*This markdown version is pre-authored for developer and AI-agent consumption. For the full branded experience, visit https://layerv.ai/docs/connectors/audit-logging/.*
