Observability¶
PrivaCI emits a structured, machine-readable event stream so a run can be piped straight into log aggregation, audited after the fact, or watched live. Every significant lifecycle moment is one JSON object on its own line on stdout. stderr is reserved for unexpected, uncaught failures.
For the per-command flags see cli-reference.md; for the
on-disk audit trail see state-schema.md.
The stdout event stream¶
Each line is a complete JSON object terminated by \n, with no ANSI color
codes or other decoration. That means you can pipe stdout through jq:
privaci run --config mask-rules.yaml | jq -c 'select(.event == "table.end")'
Every event carries a common envelope:
| Field | Description |
|---|---|
timestamp |
ISO-8601 UTC with microseconds (e.g. 2026-06-10T21:14:09.123456+00:00). |
level |
debug, info, warning, or error. |
event |
The event identifier (see the catalog below). |
run_id |
The run UUID, present from run.start onward. |
Event-specific fields are merged into the same object. Ordinary (non-lifecycle)
log lines are rendered as {"event": "log", "message": "...", ...}.
Example¶
{"timestamp":"2026-06-10T21:14:09.000123+00:00","level":"info","event":"run.start","run_id":"018f...","engine_version":"0.1.0","config_hash":"...","salt_fingerprint":"...","source_db_hash":"...","commercial_layer_present":false}
{"timestamp":"2026-06-10T21:14:09.450000+00:00","level":"info","event":"table.start","schema_name":"public","table_name":"users","estimated_rows":1000}
{"timestamp":"2026-06-10T21:14:11.500000+00:00","level":"info","event":"table.progress","schema_name":"public","table_name":"users","rows_processed":5000,"rows_per_sec":2380.95,"percent_complete":100.0}
{"timestamp":"2026-06-10T21:14:11.900000+00:00","level":"info","event":"table.end","schema_name":"public","table_name":"users","rows_processed":1000,"duration_ms":1450.2,"status":"done"}
{"timestamp":"2026-06-10T21:14:12.000000+00:00","level":"info","event":"run.end","run_id":"018f...","status":"succeeded","duration_ms":3000.0,"tables_processed":1,"rows_processed":1000,"errors":0}
Event catalog¶
| Event | Level | Key fields |
|---|---|---|
run.start |
info | engine_version, config_hash, salt_fingerprint, source_db_hash, commercial_layer_present |
preflight.ok / preflight.fail |
info / error | checks (array of {name, status, detail}) |
schema.cloned |
info | tables_created, schemas_created |
table.start |
info | schema_name, table_name, estimated_rows |
table.progress |
info | rows_processed, rows_per_sec, percent_complete |
table.end |
info | rows_processed, duration_ms, status |
column.masked |
info | schema_name, table_name, column_name, action, rows_affected |
cycle_break |
info | tables, deferred_constraint |
polymorphic_fk_warning |
warning | table_id, message |
implied_fk_warning |
warning | source_column_path, message |
skipped_object |
info | schema_name, object_name, kind, reason (optional) |
created_object |
info | schema_name, object_name, kind, elevated, ddl_phase |
definition_only_object |
info | schema_name, object_name, kind, contents_copied, refreshed, ddl_phase |
new_table |
info | schema_name, table_name, reason |
binary_fallback |
warning | schema_name, table_name, unsupported_types |
warning |
warning | message |
error |
error | message, exit_code |
run.end |
info | status, duration_ms, tables_processed, rows_processed, errors |
Durable _privaci.audit_log event types for schema validation and object
disposition (in addition to stdout preflight.ok / preflight.fail):
Audit event_type |
When |
|---|---|
schema.validated |
schema_mode: assume_existing validation succeeded |
schema.validation_failed |
Validation refused the load (before exit 2) |
created_object |
View, function, trigger, or index DDL replicated to the target (ddl_phase: pre-data or post-data; triggers also carry object_name in the audit payload) |
definition_only_object |
Materialized-view shell created with WITH NO DATA (contents_copied: false; refreshed set after optional post-load refresh; ddl_phase: post-data) |
skipped_object |
Object intentionally not replicated (kind / reason) |
column.conditional_skip |
CEL when: skipped masking for some rows (expression_hash, skipped_rows, evaluated_rows; no cell values). Segment-local per stream pass (resume may emit another rollup for remaining rows). |
table.progress throttling¶
For long-running tables, table.progress is emitted at most once every two
seconds per table so the stream stays readable on multi-million-row loads.
PII redaction¶
No event payload ever contains a raw column value. Any value-bearing field is
collapsed to a non-reversible marker of the form ***len={n}:{hex} (length plus
a salted hash prefix). Example: john@acme.com becomes something like
***len=13:a1b2c3d4 — never a plaintext character preview. Empty or null values
render as ***. Free-text fields such as message, detail, and cause are
redacted by default. A captured stdout file is therefore safe to share for
debugging or audit.
Log level¶
Control verbosity with --log-level or the PRIVACI_LOG_LEVEL environment
variable (default info):
privaci run --log-level debug --config mask-rules.yaml
PRIVACI_LOG_LEVEL=warning privaci run --config mask-rules.yaml
At debug, additional trace events appear; at warning or error, only the
matching severities are emitted.
Optional Prometheus metrics¶
Metrics are off by default — no network port is opened unless you ask for
one. Pass --prometheus-port to serve the Prometheus exposition format on
/metrics:
privaci run --prometheus-port 9100 --config mask-rules.yaml
# then: curl http://localhost:9100/metrics
Exposed series:
| Metric | Type | Labels |
|---|---|---|
privaci_run_rows_processed_total |
counter | table |
privaci_run_duration_seconds |
histogram | — |
privaci_run_errors_total |
counter | type |
privaci_table_progress_ratio |
gauge | table |
The endpoint requires the optional prometheus-client package:
pip install prometheus-client
If the package is missing, --prometheus-port exits with a configuration error
(exit code 3) explaining how to install it.