Audit Events¶
Privyx appends a PII-safe audit trail as one JSON object per line to audit.path
(default privyx-audit.log; ~/.local/state/privyx/audit.log for privyx run).
This is the stable, versioned contract a store, query layer, or dashboard reads.
Implementation: observability/audit.py.
Envelope¶
Every record — whatever the event — starts with the same envelope, then carries
event-specific fields flattened alongside it (so the line stays jq-friendly and
maps cleanly onto SQL columns):
| field | type | notes |
|---|---|---|
schema_version |
int | Bumped on a backward-incompatible change. Currently 1. Branch on it. |
time |
string | ISO-8601 UTC, millisecond precision (e.g. 2026-09-18T03:11:18.737Z). Human-readable. |
ts |
float | Unix epoch seconds. Cheap ordering / range queries. |
event |
string | One of the event types below. |
request_id |
string | null | Correlates every event of one proxied exchange. null for standalone library / CLI (mask) calls that run outside a request. |
session_id |
string | null | The pseudonym-map session, when one applies. |
Events¶
Naming is consistent <domain>.<action>: session.* are privacy-pipeline events
on a session; proxy.* describe one HTTP exchange.
session.created¶
A new session (pseudonym map) was created.
| field | type | notes |
|---|---|---|
source |
string | How the id was chosen: header, client, conversation, or ephemeral. |
session.transform¶
Request text was pseudonymized. Aggregated: a request pseudonymizes many text
leaves, but the trail records one session.transform per exchange.
| field | type | notes |
|---|---|---|
entity_counts |
object | {entity_type: count} histogram — types and counts only, never the matched text. |
transformations |
int | Total replacements applied across the request. |
detector_counts |
object | Present when a detector reported work: llm_calls, llm_input_tokens / llm_output_tokens (estimated at ~4 characters per token), llm_fallbacks (scans that fell back to regex under llm_fallback_on_error). Counts only. |
A request whose only activity is detector work (an LLM scan that found nothing,
or a fallback) still records session.transform, with transformations: 0.
session.restore¶
Pseudonyms were reversed in the response. One per exchange, on both the batch and streaming paths.
| field | type | notes |
|---|---|---|
transformations |
int | Total pseudonyms reversed. |
session.deleted¶
A session and its mapping were destroyed: deleted from the vault by
privyx session prune, or, for the default ephemeral source, dropped from
memory when its exchange completed or the client disconnected. Sticky
header, client, and conversation sessions remain available for reuse and
therefore do not emit this event during ordinary request cleanup.
| field | type | notes |
|---|---|---|
reason |
string | ephemeral_request_complete (proxy cleanup) or prune (privyx session prune). |
mapping_count |
int | Number of mappings destroyed; values and keys are never recorded. |
proxy.request¶
The upstream returned response headers (time to first byte).
| field | type | notes |
|---|---|---|
method / path / schema |
string | Request line and matched wire schema (openai / anthropic / null). |
status |
int | Upstream HTTP status. |
stream |
bool | Whether the response is an SSE stream. |
duration_ms |
float | Time to the upstream response headers (not the whole stream). |
transform_ms |
float | The part of duration_ms spent masking the request (detection, pseudonymization, and the session's vault read and write); the rest is mostly the upstream. Absent when no body was masked (a path outside proxy.routes, a body that is not JSON). |
upstream |
string | Upstream host. |
proxy.response¶
The response was fully delivered to the client, or the client disconnected
before a stream ended (aborted).
| field | type | notes |
|---|---|---|
status |
int | HTTP status. |
stream |
bool | |
bytes |
int | Batch responses only: response size. |
frames |
int | Streamed responses only: SSE frames forwarded. |
restored |
int | Pseudonyms reversed on the way back. |
duration_ms |
float | Total exchange time. |
aborted |
bool | Present (true) when the client disconnected mid-stream, e.g. a coding tool's request cancelled with Esc; frames and restored then count what was sent. Not an error, so privyx_proxy_errors_total does not count it. |
proxy.error¶
An exchange failed.
| field | type | notes |
|---|---|---|
phase |
string | Where it broke: transform (masking the request failed, so it was not forwarded and the client got 503), vault (reading or writing the session failed; the client got 503), upstream (no response from the upstream; the client got 502, 503, or 504, see Errors), stream, or response. |
error_type |
string | The exception's class name — never its message. |
status |
int | Present when a status was already known. |
duration_ms |
float | Time until the failure. |
Guarantees¶
- PII-safe by construction. No field carries matched text, original values,
pseudonyms, or exception messages.
session.transformtakes a{type: count}histogram, not spans;proxy.errortakes a class name, not a message. - Correlated. All events of one exchange share a
request_id, so a reader can reconstruct the timeline (session.created→session.transform→proxy.request→session.restore→proxy.response→session.deleted, orproxy.error). - Lifecycle-aware.
privyx session prunewritessession.deletedonly after the vault deletion succeeds, so the trail never reports a deletion that failed. An ephemeral session is never in the vault; itssession.deletedmarks the end of its request. - Source-aware retention. Default
ephemeralsessions end with their exchange and never reach the vault. Explicit-header and derivedclient/conversationsessions stay in the vault untilprivyx session prunedeletes them (audited) orvault.ttlexpires them (not audited: the vault drops them without a caller to report it). - Resilient. A failed audit write is logged and swallowed; it can never break a proxied request.
Consuming the trail¶
For the common questions, no jq is needed:
privyx audit stats [FILE] [--since 24h] # totals: requests, errors by phase, sessions, entity types
privyx audit tail [FILE] [-n 20] # one readable line per event, then follow (--no-follow)
FILE defaults to the configured audit.path. A running proxy also serves the
same totals at GET /metrics in the Prometheus text format, counted in memory
from startup, so they work with audit.enabled: false and reset on restart:
| metric | labels | notes |
|---|---|---|
privyx_audit_events_total |
event |
Events emitted, by event type. |
privyx_entities_masked_total |
entity_type |
Sum of session.transform entity_counts. |
privyx_detector_counts_total |
counter |
Sum of session.transform detector_counts. |
privyx_proxy_errors_total |
phase |
proxy.error events by phase. |
privyx_pseudonyms_restored_total |
Sum of session.restore transformations. |
|
privyx_response_duration_seconds |
Summary (_sum, _count) of proxy.response duration_ms. |
/metrics needs no key, like /health; it exposes counts only, but keep it off
networks where traffic volume should stay private.
Rotation. Privyx opens audit.path once and keeps appending to that open
file, so rotate it with logrotate's copytruncate: a rotation that moves the
file away leaves Privyx writing to the moved file until it restarts.
privyx audit tail follows a truncated file from the top.
The file is append-only JSON Lines: tail it, jq it, or bulk-load it. Because
each line is self-describing (versioned envelope, flat fields) it maps directly to
a row in a future audit store / dashboard without reshaping. When
schema_version changes, a reader branches on it rather than guessing.
Disable the trail entirely with audit.enabled: false /
PRIVYX_AUDIT_ENABLED=false.