Audit Events and Metrics
orlop records what happens to a tenant’s files in two complementary forms:
audit.log: one JSON object per line (JSONL), one line per filesystem operation, chunk/manifest RPC, lease transition, and GC sweep. It answers “who touched what, when, and was it allowed?”/metrics: a Prometheus exposition on the server that reports the same activity in aggregate (latencies, byte counters, lease gauges).
The agent is untrusted, so the audit log never takes the caller’s word for
identity: server-side lines carry the tenant_id/agent_id taken from the
verified client certificate, not from anything in the request. The two halves
of orlop (the Rust mount client and the Go data-plane server) write the
same line shape to their own audit.log, so a single jq filter works
across both.
Where events come from
Section titled “Where events come from”| Surface | Process | Writes |
|---|---|---|
| FUSE / NFS handlers (lookup, read, create, write, …) | orlop (Rust mount client) |
src/audit.rs |
| Client read-cache integrity check | orlop (Rust mount client) |
src/backend/dataplane/cache.rs |
| Agent enrollment | orlop (Rust mount client) |
src/main.rs |
| Data-plane RPCs (manifest, chunk, dir, journal) | orlop-server (Go) |
cmd/orlop-server/dataplane_server.go |
| Lease lifecycle | orlop-server (Go) |
cmd/orlop-server/lease.go |
| Chunk GC + agent purge | orlop-server (Go) |
cmd/orlop-server/gc.go, control_purge_agent.go |
Control HTTP endpoint (/audit) |
orlop-server (Go) |
cmd/orlop-server/handlers.go |
Each side writes to the path named by its audit_log config key (default
./audit.log). The client streams its own file with orlop audit tail; the
server’s /audit endpoint reads the server file.
A user-level operation often appears on both sides. A setattr, for example,
produces one client line (the FUSE op the kernel handed orlop) and one server
line (the wire op orlop-server executed). Tell them apart by the identity
fields: client lines name the calling process in command and have no
tenant_id; server lines have command: "orlop-server" plus tenant_id and
the cert fields.
The envelope
Section titled “The envelope”Every line is a flat JSON object. These fields form the shared envelope; the catalogue below lists only the fields specific to each event.
| Field | Type | Meaning |
|---|---|---|
ts |
RFC3339 string | When the event was written (UTC). |
event |
string | Event name (the catalogue keys below). |
path |
string | Mount-relative path the op acted on. Empty for content-addressed (chunk_*), journal, and sweep events. |
allowed |
bool | true if the op completed or was permitted; false for a denial. See conventions. |
command |
string | Client: the calling process name (from /proc/<pid>/comm on Linux). Server: "orlop-server". |
agent_pid |
int | Client: PID of the process that issued the syscall. |
uid, gid |
int | Caller’s user/group id. |
Identity richness depends on the emitter:
- Server data-plane and HTTP events add
agent_id,tenant_id,cert_serial,cert_subject, all read from the verified client cert. session_id(optional) is the mount’s implicit session id: at mount time orlop stamps each backend with amount:<hex>id (derived from the exclusive-mount lease), and every write for the lifetime of that mount carries it. It is omitted on non-write events.size,offset, andcommandare always-present keys — emitted on every line asnullwhen not applicable, not omitted (a SIEM can rely on the key existing).sizeholds a byte count on byte-measuring events (read,flush, read-onlyopen,lookupon hit,readdir_entry/readdirplus_entry,head_file,manifest_get/manifest_put,chunk_get/chunk_put,cache_corrupt,cache_evicted) and isnullelsewhere;chunk_hascarriescountinstead.offsetis set only by the clientread(and the macOS write path). By contrast, the richer identity fields below (agent_pid,uid,gid,agent_id,tenant_id,cert_*,session_id) are omitted when absent, not null.- Lease-lifecycle and GC events carry a deliberately reduced envelope. See those sections.
Conventions
Section titled “Conventions”- Success vs failure is the
allowedboolean, not a status code. The wire has no HTTP status; a failed op is logged withallowed: false. - Policy and authorization denials reuse the op’s own event name. When an
agent cert’s per-agent scope rejects a path, the server writes the normal op
line (e.g.
manifest_put) withallowed: false, rather than a distinct “denied” event. One wrinkle: the per-agent path-moat denial logs the lowercased wire op, so a denied directory listing or stat is recorded aslist/stat(notlist_entries/head_file). lease_deniedandlease_violationare notallowed: false. Both hard-codeallowed: true:lease_deniedrecords that the client fell back to an uncached path, andlease_violationrecords a server-side lease invariant break. Neither is a caller-facing access denial.- What is not logged: file contents never appear; chunk events carry the
BLAKE3 hash and byte count only.
chunk_hasis summarized as a batch count rather than one line per probed hash, to bound log volume. The log is unredacted, so keepaudit.logaccess-controlled like any sensitive log.
Catalogue
Section titled “Catalogue”Mount client: FUSE/NFS surface (Rust)
Section titled “Mount client: FUSE/NFS surface (Rust)”These are emitted as the kernel drives the mount. Envelope identity is the
calling process (command, agent_pid, uid, gid).
event |
When | Extra fields |
|---|---|---|
lookup |
path resolution | size (on hit) |
opendir |
directory open | none |
readdir_entry |
one per child returned to readdir |
size |
readdirplus_entry |
one per child returned to readdirplus |
size |
open |
file open | size (read-only opens; absent on write opens) |
read |
read(2) served from the chunk cache |
size, offset |
create |
file create | mode |
mkdir |
directory create | mode |
unlink |
file remove | none |
rmdir |
directory remove | none |
rename |
rename | to_path (path is the source) |
link |
create a hard-link name | to_path (path is an existing name) |
symlink |
symlink create | none |
setattr |
chmod/chown/truncate/utimes |
setattr_fields (bitmask) |
flush |
dirty file flushed to the server | size, chunks_new, chunks_reused, cas_retries, version_new, recovery_* (on a write conflict) |
lease_denied |
write lease refused; client falls back | allowed: true |
setattr_fields is a bitmask of which attributes the call set:
| Bit | Field |
|---|---|
0x01 |
mode |
0x02 |
uid |
0x04 |
gid |
0x08 |
size (truncate) |
0x10 |
mtime |
0x20 |
atime |
Sample of a read of 4 KiB at offset 0:
{"ts":"2026-06-27T18:21:09.114Z","event":"read","path":"/agent-7/data/model.bin", "size":4096,"offset":0,"command":"python3","agent_pid":48211,"uid":1000,"gid":1000, "allowed":true}Sample of a flush that wrote two new chunks and reused one:
{"ts":"2026-06-27T18:21:10.882Z","event":"flush","path":"/agent-7/data/out.txt", "size":1310720,"chunks_new":2,"chunks_reused":1,"cas_retries":0,"version_new":8, "command":"python3","agent_pid":48211,"uid":1000,"gid":1000,"allowed":true}On a write conflict, the flush line also carries a flattened recovery hint:
| Field | Meaning |
|---|---|
recovery_kind |
conflict kind, e.g. cas_conflict |
recovery_suggested_action |
human-readable remediation |
recovery_your_version |
version the client wrote against |
recovery_current_version |
server’s current version |
recovery_last_writer_agent_id |
agent that last won the path (if known) |
recovery_last_writer_session_id |
that writer’s session (if known) |
recovery_last_writer_at_unix_ms |
when that write landed (unix ms) |
The columns above describe the Linux FUSE surface (the production path). The macOS NFS surface emits the same event names but a leaner envelope: no
agent_pid/uid/gid(commandisnull),createcarriessize: 0instead ofmode,setattromitssetattr_fields,readandreaddir_entryomitsize, writes log asflushwithsize+offset(no chunk stats), andsymlink/readlinkare not emitted.
Mount client: cache integrity (Rust)
Section titled “Mount client: cache integrity (Rust)”event |
When | Extra fields |
|---|---|---|
cache_corrupt |
a cached chunk’s bytes failed BLAKE3 re-verification; the entry is dropped and refetched | path = hex hash, size, allowed: false |
cache_evicted |
the local read-cache LRU-prunes chunks to reclaim space | size = bytes freed, chunks_reused = chunks evicted, reason (low_water | high_water), path empty, allowed: true |
These cache events carry the default, empty identity — no command, agent_pid,
uid, or gid:
{"ts":"2026-06-27T18:22:01.004Z","event":"cache_corrupt", "path":"7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730", "size":1048576,"offset":null,"command":null,"allowed":false}{"ts":"2026-06-27T18:25:40.512Z","event":"cache_evicted","path":"","size":104857600, "offset":null,"chunks_reused":128,"reason":"low_water","command":null,"allowed":true}Mount client: enrollment (Rust)
Section titled “Mount client: enrollment (Rust)”event |
When | Extra fields |
|---|---|---|
enrollment |
the agent obtains its leaf certificate at mount time | path = new cert serial on success; empty with allowed: false on failure |
Data plane: binary RPCs (Go server)
Section titled “Data plane: binary RPCs (Go server)”Emitted over the long-lived mTLS data connection (binary frames + msgpack),
not over HTTP. Identity comes from the agent’s client cert: these lines carry
agent_id, tenant_id, cert_serial, cert_subject, and command: "orlop-server".
event |
When | Extra fields |
|---|---|---|
list_entries |
directory listing | none |
head_file |
file stat | size (on success) |
manifest_get |
read a path’s manifest | size, version |
manifest_put |
write a path’s manifest (CAS on version) | size, version (post-write), session_id? |
manifest_delete |
delete a path | session_id? |
manifest_rename |
rename a path | path = source, session_id? |
link |
create a hard-link name | path = existing name, session_id? |
dir_create |
create a directory | session_id? |
dir_remove |
remove a directory | session_id? |
setattr |
apply attribute change | session_id? |
symlink |
create a symlink | session_id? |
mknod |
create a special node | session_id? |
readlink |
read a symlink target | none |
chunk_get |
fetch a chunk | hash, size (path empty) |
chunk_put |
store a chunk | hash, size, session_id? (path empty) |
chunk_has |
presence probe for a batch of hashes | count = hashes probed (path empty) |
journal_query |
query the per-tenant journal | none (path empty) |
journal_revert_path |
revert a path via the journal | none (path empty) |
Sample of a manifest_put accepted at version 8:
{"ts":"2026-06-27T18:21:10.901Z","event":"manifest_put","path":"/agent-7/data/out.txt", "size":1310720,"version":8,"agent_id":"agent-7","tenant_id":"acme", "cert_serial":"3af9...","cert_subject":"spiffe://orlop.example/agent/agent-7", "uid":0,"gid":0,"command":"orlop-server","allowed":true}Lease lifecycle (Go server)
Section titled “Lease lifecycle (Go server)”Leases coordinate concurrent writers (see
design-data-plane.md). These lines carry a reduced
envelope (agent_id, lease_id, mode, reason) and always
allowed: true. mode is read for a shared-read lease, write for an
exclusive-write lease.
event |
When | reason values |
|---|---|---|
lease_grant |
lease granted | (empty) |
lease_refresh |
holder renews | (empty) |
lease_release |
lease released | client, conn_closed |
lease_revoke |
holder asked to yield | contention, manifest_put_contention |
lease_violation |
holder did not yield in time, or the lease expired | revoke_timeout, expired |
{"ts":"2026-06-27T18:21:11.220Z","event":"lease_revoke","path":"/agent-7/data/out.txt", "lease_id":"5f2a1c7e9b0d4a3f8c6e2d1b0a9f8e7d","mode":"write","agent_id":"agent-7", "reason":"contention","allowed":true}Chunk GC and agent purge (Go server)
Section titled “Chunk GC and agent purge (Go server)”event |
When | Extra fields |
|---|---|---|
gc_swept_chunks |
one per tenant per GC sweep cycle | tenant_id, count, bytes_freed, dry_run (path empty) |
agent_data_purged |
an agent’s subtree is purged via the control endpoint | tenant_id, count (manifests deleted), bytes_freed |
{"ts":"2026-06-27T03:00:00.000Z","event":"gc_swept_chunks","path":"","tenant_id":"acme", "count":42,"bytes_freed":58720256,"dry_run":false,"command":"orlop-server","allowed":true}Control HTTP endpoint (Go server)
Section titled “Control HTTP endpoint (Go server)”The server’s only HTTP audit events. Everything else above rides the binary data plane.
event |
When | Extra fields |
|---|---|---|
http_get_audit |
the mTLS-gated GET /audit endpoint served the log |
path = /audit, size = number of audit records returned (after tenant/agent scoping and the limit cap) |
http_auth |
a request failed authentication at the HTTP layer | path = request path+query, allowed: false |
orlop audit tail
Section titled “orlop audit tail”Streams the client’s audit.log. Filters AND together.
orlop audit tail [--event <name>] # repeatable; matches if any listed name matches [--lease-id <hex>] # only lease_* events for that lease [--limit N] # print the last N matching lines [--follow] # keep streaming new lines--lease-id skips any event that has no lease_id. Omitting both --limit
and an explicit --follow defaults to follow mode.
orlop audit tail --event lease_revokeorlop audit tail --event manifest_put --event lease_grant --followorlop audit tail --lease-id 5f2a1c7e9b0d4a3f8c6e2d1b0a9f8e7d --limit 50Metrics: /metrics
Section titled “Metrics: /metrics”orlop-server exposes a Prometheus exposition at GET /metrics. Both
/metrics and /healthz are unauthenticated by design: scrapers and health
checks do not carry mTLS client certs. /audit, by contrast, is mTLS-gated.
The four primary collectors:
-
orlop_dataplane_op_duration_seconds{op}: histogram of server-side handler latency. Buckets are exponential, base 2, 14 buckets starting at 500 µs. Theoplabel set (noread; file reads are served client-side from the chunk cache):list stat manifest_get manifest_put manifest_delete manifest_renamedir_create dir_remove setattr symlink readlink mknod linkchunk_get chunk_has chunk_put journal_query journal_revert_pathping close lease_grant lease_refresh lease_release lease_revokeNote the metric labels
list/statcorrespond to the audit eventslist_entries/head_file; the names differ between the two surfaces. -
orlop_dataplane_bytes_total{direction, op}: counter of payload bytes.direction=infor client→server (chunk_put,manifest_put),direction=outfor server→client (chunk_get,chunk_has,manifest_get). -
orlop_chunks_total{state}: chunk lifecycle counter. Emitted states:fetched(achunk_gethit),cached(achunk_putthat stored fresh bytes),deduped(achunk_putwhose content already existed). The help text also namesevicted, but GC does not currently touch this counter, so that state is reserved and never emitted. -
orlop_lease_held{path}: gauge set to1while a lease is held onpath, with the series removed on release. Per-path cardinality is bounded by the number of files under active write; watch series count on workloads with many short-lived leases.
Also exposed:
| Metric | Type | Labels |
|---|---|---|
orlop_journal_writes_total |
counter | op, allocation_id |
orlop_journal_query_duration_seconds |
histogram | none |
orlop_journal_rows_total |
gauge | allocation_id |
orlop_journal_revert_total |
counter | allocation_id, result |
orlop_session_forgery_rejected_total |
counter | reason (bad_format, bad_hex, unknown_or_wrong_holder, fenced) |
orlop_session_rebind_total |
counter | none; legitimate live-lease reconnects, kept separate from forgery alerts |
orlop_agent_path_denied_total |
counter | op |
orlop_agent_path_denied_total increments when a connection whose cert carries
an /agent/<id> SAN touches a path outside that agent’s subtree, the same
event that writes an allowed: false op line to audit.log.