# Request log
`omni-dev` keeps a local, append-only log of every invocation **and** the HTTP
requests it issues, and ships an `omni-dev log` subcommand to search and
pretty-print it. It is the single, durable, queryable record of *what was run*
and *what it talked to over the network* — the thing `RUST_LOG` tracing (ephemeral,
stderr-only) is not.
## What gets recorded
One JSON object per line (NDJSON). A `kind` field discriminates the two record
types, so the log is a complete invocation history, not just an HTTP history:
- **`kind: "invocation"`** — one per process run (and one per MCP tool call):
the resolved subcommand path, full argv (with secret-bearing flag values
redacted), exit code, duration, any top-level error, and a whitelisted
`OMNI_DEV_*` env snapshot.
- **`kind: "http"`** — one per outbound request (recorded *inside* each client's
retry loop, so retries and transport failures are captured too): service,
method, URL (secret-bearing query/fragment parameter **values** redacted —
see [Redaction posture](#redaction-posture)), status, elapsed, and any error.
- **`kind: "gh"`** — one per `gh` CLI subprocess invocation. Every GitHub call
funnels through `gh` (the token never enters our process), so these are
subprocess records, not `http` ones. Each carries the semantic subcommand
(`api graphql`, `pr list`, `repo view`, …) in `command`, the scrubbed argv in
`command_line`, the `source`, the exit code, and the duration. This is what
`omni-dev log count --kind gh` aggregates — see [Counting GitHub API calls](#counting-github-api-calls).
- **`kind: "worktree"`** — one per `omni-dev git worktree <verb>` invocation
(thin logged wrappers over `git worktree add/remove/list/move/prune/repair`,
#1392). Tagged `service: "worktree"`, with `command` set to
`["git", "worktree", "<verb>"]` and the recovery-relevant metadata in the
free-form `context` map — for `remove`: the worktree's `path`, `branch`,
`commit` (HEAD *before* removal), `had_uncommitted`, and `used_force`; for
`prune`: a JSON `pruned` list of `{path, branch, commit}`. See
[Recovering a removed worktree](#recovering-a-removed-worktree).
- **`kind: "drivemutation"`** — one per `omni-dev drive rename`/`drive
move`/`drive create`/`drive upload`/`drive edit`/`drive sheets *` attempt,
written from
*inside* the mutation itself so it covers every current and future caller
(CLI today, MCP later), not just the CLI. Includes a refused move
(`Blocked`, ADR-0070) or create/upload/edit (`Blocked` by the folder
write-permission gate, ADR-0071, issue #1574) — no mutating API call
happens for those, but the refusal is itself the security-relevant event.
Tagged `service: "drive"`, with `command` set to `["drive",
"<operation>"]` and the outcome detail in the free-form `context` map:
`file_id`, `file_name`, `status`, and — only when present —
`added_principals`/`removed_principals` (comma-separated,
`move`-only) and `crosses_drive_boundary` (`move`-only), plus
`resolved_folder_id`/`decided_by_folder_id`/`decided_by_depth`
(the folder the write-permission gate evaluated against, and which
configured rule, if any, decided the verdict — absent for the ungated
`rename`/`move`), and `decided_by_file_id` when a **file** rule decided
it (issue [#1612](https://github.com/rust-works/omni-dev/issues/1612)).
`decided_by_file_id` is mutually exclusive with `decided_by_folder_id`,
deliberately: a file id never appears in the folder field, so an existing
`--query decided_by_folder_id:<id>` cannot start matching a different
kind of id. A file rule matches the target itself, so it walks no chain —
`decided_by_depth` and `resolved_folder_id` are both absent alongside it.
See [docs/drive.md](drive.md#write-permissions).
Cell writes through the Sheets API (issue
[#1589](https://github.com/rust-works/omni-dev/issues/1589),
[ADR-0073](adrs/adr-0073.md)) use this same kind, with `operation` of
`sheets-write`/`sheets-append`/`sheets-clear`/`sheets-create`/
`sheets-find-replace` — so
`command` reads `["drive", "sheets-write"]` even though the CLI spells it
`drive sheets write`. They add five more omit-if-absent context keys:
`range` (the A1 range as composed and sent), and `updated_range`,
`updated_rows`, `updated_columns`, `updated_cells` (what the API reported
actually changing). `updated_range` can differ from `range` — the server
resolves an open-ended range against the sheet's real extent — which is
what makes `omni-dev log --query kind:drivemutation` able to answer "how
much did that write touch", not just "a write happened".
`sheets-find-replace` also records `occurrences_changed` and a compact
scope/count summary. Its `--find` and `--replacement` values are document
content and are redacted from the invocation log and absent from mutation
records.
Structural edits (issue
[#1613](https://github.com/rust-works/omni-dev/issues/1613),
[ADR-0075](adrs/adr-0075.md)) use the same kind again, with `operation` of
`sheets-add-sheet`/`sheets-rename-sheet`/`sheets-insert-rows`/
`sheets-insert-columns` — **one record per verb**, which is also one per
`spreadsheets.batchUpdate` request, since each verb sends a batch of
exactly one. They add four more omit-if-absent context keys: `sheet_id`
(the stable numeric id the API addresses, which survives a later rename
and so is the only durable answer to "which tab was this"), `sheet_title`
(its title at the time), `sheet_new_title` (the title a `rename-sheet`
moved it *to* — set by that verb alone, since `sheet_title` necessarily
holds the title the sheet had before, and without it a record could say
which tab was renamed but not to what), and `dimension_range` (e.g.
`"ROWS 5:7"`, 1-based inclusive like the CLI's `--at`). `dimension_range`
is the structural analogue of `range`, for effects A1 notation cannot
express, and is omitted for a span that spans nothing; a structural verb
sets no `range` and no cell counts. `move-rows`/`move-columns` (issue
[#1834](https://github.com/rust-works/omni-dev/issues/1834)) add one more
omit-if-absent context key, `move_to`: the raw 1-based `--before` value —
`None` for every other verb. It is distinct from `dimension_range`, which
for these two verbs holds the *source* span that moved, not the
destination.
Destructive edits (issue
[#1623](https://github.com/rust-works/omni-dev/issues/1623),
[ADR-0077](adrs/adr-0077-sheets-deletion-via-batchupdate.md)) use the same
kind again, with `operation` of `sheets-delete-sheet`/`sheets-delete-rows`/
`sheets-delete-columns`/`sheets-delete-range` — same
one-record-per-verb-and-per-request shape as the structural edits above,
and the same four structural context keys (`sheet_id`, `sheet_title`,
`dimension_range` for the two dimension-delete verbs). They add one more
omit-if-absent context key: `grid_range` (e.g. `"rows 2-4, columns 2-3"`,
1-based inclusive like the CLI's `--start-row`/`--end-row`/
`--start-column`/`--end-column`) — the `deleteRange` analogue of
`dimension_range`, which only ever spans one axis and so cannot record a
rectangle. `delete-sheet` sets neither `dimension_range` nor `grid_range`.
Every `--dry-run`, additive or destructive, is unlogged — `record_attempt`
only ever runs for a real mutation attempt — and a destructive one stays
structural-only besides: no cell content is ever read or logged for any
structural verb, delete included.
Formatting, data validation and protected ranges (issue
[#1643](https://github.com/rust-works/omni-dev/issues/1643),
[ADR-0078](adrs/adr-0078.md)) use the same kind again, still **one record
per verb**: `update-borders` can set up to four `Border` sides in a single
request, which is the first case where one request carries more than one
independently-toggleable effect, but it is still one request, so the
one-record-per-verb-per-request equivalence ADR-0075 §7 established holds.
New `operation` values: `sheets-format-cells`, `sheets-update-borders`,
`sheets-merge-cells`, `sheets-unmerge-cells`,
`sheets-auto-resize-dimension`, `sheets-update-dimension-properties`,
`sheets-duplicate-sheet`, `sheets-reorder-sheet`, `sheets-hide-sheet`,
`sheets-show-sheet`, `sheets-move-rows`, `sheets-move-columns`
(issue [#1834](https://github.com/rust-works/omni-dev/issues/1834)),
`sheets-set-data-validation`,
`sheets-clear-data-validation`, `sheets-protect-range`,
`sheets-update-protection`, `sheets-unprotect-range`. Six more
omit-if-absent context keys: `fields_changed` (a human-readable summary of
which `CellFormat`/border/dimension-property fields a verb set — the
formatting analogue of `dimension_range`, needed because a single request
here can carry more than one named effect), `discarded_cells`
(`merge-cells` only — the non-top-left, non-blank cells a merge
discarded, as `"A1: value"` strings, so the one formatting request that
destroys data has a record saying exactly what was lost),
`validation_type` (the condition type a `set-data-validation` applied, or
`"cleared"`), `protected_range_id` (the stable numeric id of a protected
range a protection verb acted on — server-assigned for `protect-range`,
otherwise the one resolved against), and `protection_editors_added`/
`protection_editors_removed` (comma-separated).
Filter views and the basic filter (issue
[#1794](https://github.com/rust-works/omni-dev/issues/1794),
[ADR-0081](adrs/adr-0081.md)) use the same kind again. New `operation`
values: `sheets-set-basic-filter`, `sheets-clear-basic-filter`,
`sheets-add-filter-view`, `sheets-update-filter-view`,
`sheets-delete-filter-view`. One more omit-if-absent context key:
`filter_view_id` (the stable numeric id of a filter view acted on —
server-assigned for `add-filter-view`, otherwise the one resolved
against; absent for `set-basic-filter`/`clear-basic-filter`, which
reuse `sheet_id` instead, since a basic filter is scoped to a sheet
rather than id-addressed). `fields_changed` is reused as-is for
`update-filter-view`'s changed-field summary.
Charts and slicers (issue
[#1797](https://github.com/rust-works/omni-dev/issues/1797),
[ADR-0081](adrs/adr-0081.md) §3) use the same kind again. New `operation`
values: `sheets-add-chart`, `sheets-update-chart`, `sheets-delete-chart`,
`sheets-add-slicer`, `sheets-update-slicer`, `sheets-delete-slicer`. One
more omit-if-absent context key: `embedded_object_id` (the stable numeric
id of a chart or slicer acted on — server-assigned for `add-chart`/
`add-slicer`, otherwise the one resolved against; one key for both kinds,
since `deleteEmbeddedObject` itself addresses both by the same `objectId`
with no discriminator). `sheet_id` is reused for the sheet the object is
anchored on (`None` for a chart put on its own new sheet, which doesn't
exist yet); `fields_changed` is reused for the changed-field summary and,
for `update-chart`/`update-slicer`/`delete-chart`/`delete-slicer` (every
verb that resolves against an existing object), has the object's
type/title/anchor appended — the ADR-0081 §3 preview text, in the audit
trail as well as the `--dry-run` report.
Moving/resizing an existing chart or slicer, and setting a chart's border
colour (issue [#1837](https://github.com/rust-works/omni-dev/issues/1837))
use the same kind again. New `operation` values: `sheets-move-chart`,
`sheets-move-slicer`, `sheets-update-chart-border`. No new context key:
`embedded_object_id` and `fields_changed` are reused exactly as above, and
`sheet_id` records the destination sheet for a move — including a
resize-only call, which carries the object's *current* anchor's sheet
forward — and is `None` only for a `--new-sheet` move, which has no
destination sheet yet.
Banded ranges (issue
[#1832](https://github.com/rust-works/omni-dev/issues/1832),
[ADR-0082](adrs/adr-0082-banded-ranges.md)) use the same kind again. New
`operation` values: `sheets-add-banding`, `sheets-update-banding`,
`sheets-delete-banding`. One more omit-if-absent context key:
`banded_range_id` (the stable numeric id of a banded range acted on —
server-assigned for `add-banding`, otherwise the one resolved against),
mirroring `filter_view_id`'s precedent. `sheet_id` is reused for the
sheet the banded range sits on.
Dimension groups (issue
[#1833](https://github.com/rust-works/omni-dev/issues/1833),
[ADR-0084](adrs/adr-0084-dimension-groups.md)) use the same kind again.
New `operation` values: `sheets-add-dimension-group`,
`sheets-update-dimension-group`, `sheets-delete-dimension-group`. No new
context key: a dimension group carries no id of its own, so `sheet_id`
and `dimension_range` (already used by the insert/delete-dimension
structural verbs) are reused for the sheet and span a group acts on, and
`fields_changed` (already used by the dimension-property formatting
verbs) records `collapsed=true`/`collapsed=false` for
`sheets-update-dimension-group`.
`update-sheet-properties` (issue #1835,
[ADR-0085](adrs/adr-0085.md)) uses the same kind again, with
`operation` of `sheets-update-sheet-properties`. It may set several
properties (frozen rows/columns, hidden gridlines, tab color,
right-to-left) in one request, so it needs no new context key: it reuses
`fields_changed` for a comma-joined summary of what it set (e.g.
`"frozenRowCount=2, tabColorStyle=#FF8800"`), the same key `format-cells`
and friends already write, rather than introducing one of its own.
`update-workbook-properties` (issue #1836,
[ADR-0086](adrs/adr-0086-workbook-properties.md)) uses the same kind
again, with `operation` of `sheets-update-workbook-properties`. Like
`update-sheet-properties` it may set several properties (locale, time
zone, auto-recalc, iterative calculation) in one request, so it reuses
`fields_changed` rather than a new key, but the summary it writes is a
prose join rather than `update-sheet-properties`' `key=value` pairs —
e.g. `"locale -> 'en_US', auto-recalc -> ON_CHANGE, iterative calculation
-> on (max 50 iterations, threshold 0.01)"`, or `"iterative calculation ->
off"` when clearing it — the same string `--dry-run`/the real-run
confirmation print, from the one `workbook_properties_summary` helper
that builds it. It is the one structural verb with no sheet target at
all, so `sheet_id`/`sheet_title` are always absent on its records,
unlike every other `sheets-*` operation.
`insert-range` (issue #1838) uses the same kind again, with `operation`
of `sheets-insert-range`. It is `delete-range`'s exact inverse and so
needs no new context key either: it reuses `grid_range` (introduced
above for `delete-range`) to record the same rectangle notation. Unlike
the destructive edits above, `insert-range` is gated `sheets-structure`,
not `sheets-delete` — no cell value is discarded, cells are shifted
within the sheet and the vacated cells become empty — so `grid_range`
is not exclusively a destructive-edit signal: a `sheets-insert-range`
record carries it too, and only the `operation` value tells the two
apart.
`auto-fill` (issue #1840, [ADR-0083](adrs/adr-0083.md) §1) uses the same
kind again, with `operation` of `sheets-auto-fill` and gated
`sheets-write`. No new context key for the destination itself: `range`
(introduced above for `write`/`append`/`clear`) is reused for the
destination's A1 address, and `fields_changed` for a prose summary of the
effect — the destination, the source range it extends, the direction, and
the alternate-series flag. That summary is deliberately **tense-neutral**:
the same string is rendered under both `Would …` and `Applied: …`, so it
never describes a completed mutation in the conditional.
Two new keys. `overwritten_cells`: the non-blank cells in the destination
that were overwritten, as **bare A1 addresses** — deliberately not
`"A1: value"` like `discarded_cells`, since a fill's values are Sheets'
own series detection and this crate never records them, before or after
the request. `overwritten_cells_are_upper_bound`: present (and `"true"`)
only for `auto-fill --range`, where Sheets decides for itself which cells
in the named range are the source and which are filled — some of the
listed cells are the source and were never touched. Absent means the list
is exact, which is `--source` and every other verb.
`text-to-columns` (issue #1843, [ADR-0083](adrs/adr-0083.md) §§1, 5) uses
the same kind and keys again, with `operation` of
`sheets-text-to-columns`, gated by `sheets-write` **and**
`sheets-structure`. `range` carries the source column's A1
address (not the spill span — the request log's `range` key is always
the value that started the write for every verb in this tranche), and
`fields_changed` the tense-neutral summary: the source, the delimiter,
and the upper-bound width and spill span. `overwritten_cells_are_upper_bound`
is **always** `"true"` for this verb, unlike `auto-fill`'s form-dependent
flag — the API decides for itself how many columns each row's split
needs, so the locally computed span can only ever be an upper bound, and
`overwritten_cells` never carries the split pieces or the source's
values, only the bare A1 addresses of the cells the split might reach.
Under `--delimiter auto` the span is not even a bound (Sheets picks the
separator too, and was measured picking a tab, which the local split
never tries), but the key has no third state; that caveat is carried in
the rendered output rather than in the record.
`trim-whitespace` and `delete-duplicates` (issue #1844,
[ADR-0083](adrs/adr-0083.md) §§1, 2, 6) use the same kind, with
`operation` of `sheets-trim-whitespace` and `sheets-delete-duplicates`.
They are the tranche's one pair taking **different gates** —
`sheets-write` for the trim, `sheets-delete` for the dedupe — so a
`decided_by_*` chain on one says nothing about the other. Neither adds a
context key: `fields_changed` carries the server's own count
(`cellsChangedCount`, `duplicatesRemovedCount`) plus the range, or says
the API reported no count.
Both counts are present as `0` rather than omitted when nothing
changed — measured live — so `fields_changed` reading "trimmed 0
cell(s)" is the ordinary no-op case, not a missing value. The
"the API reported no count" wording is reserved for a reply that
carried no response object at all.
`delete-duplicates` deliberately records **no** cell or row list. ADR-0083
§6 places it in the server-decided preview tier — Sheets chooses the rows
by an equality rule this crate does not reproduce — so there is nothing
to record that the tool could vouch for. `trim-whitespace` records none
either: its real run reports the server's exact count instead of
re-reading the range, so the candidate list exists only in the `--dry-run`
outcome, which writes no mutation record at all.
Text writes through the Docs API (issue
[#1615](https://github.com/rust-works/omni-dev/issues/1615),
[ADR-0076](adrs/adr-0076.md)) use this same kind, with `operation` of
`docs-replace`/`docs-append`/`docs-insert`/`docs-delete` (or `slides-replace`
for Slides text replacement), and add three more omit-if-absent context
keys: `occurrences_changed` (what the *server* reported changing, which
can differ from the client-side `--dry-run` estimate), `inserted_chars`,
and `required_revision_id` (the revision lease presented, recorded so a
`stale-revision` refusal is as auditable as a success — an opaque,
short-lived id, not a secret).
Anchored edits ([ADR-0094](adrs/adr-0094.md)) reuse these keys:
insertion reports `inserted_chars`, and deletion reports its status and
required revision. Resolved ranges, tab ids and paragraph/removal counts are
in the command outcome, rather than new log context fields.
**The searched, replacement, appended, inserted and deleted text, including
anchor text, are never recorded in these metadata fields.** They
are user prose, and often the most sensitive thing in the invocation —
sharper than the Sheets case, where an A1 range is metadata rather than
content. Only counts and the opaque revision id go in.
- **`kind: "audit"`** — written to the **separate** `audit.jsonl` sink, never
to this file — see [Audit log](#audit-log). Every other `kind` above stays
exactly as described regardless of the audit log's existence: a leased
Drive write (issue [#1664](https://github.com/rust-works/omni-dev/issues/1664),
[ADR-0080](adrs/adr-0080.md)) still produces its normal `drivemutation`
record here, correlated to its `audit` record by the shared
`invocation_id`.
Every HTTP, `gh`, `worktree`, and `drivemutation` record shares an
`invocation_id` with the invocation that issued it, so you can pull a run and
all of its requests with a single `--id`.
**Coverage caveat:** `omni-dev`'s own in-process HTTP clients and its Rust `gh`
calls (daemon *and* one-shot CLI) are recorded. The one gap left is the VS Code
companion extension's `gh pr list`, which runs in a **separate Node process** and
does not write to this log.
## Location
Resolved in this order:
1. `OMNI_DEV_LOG_FILE` if set.
2. Otherwise `dirs::state_dir()` joined with `omni-dev/log.jsonl` — on Linux,
`~/.local/state/omni-dev/log.jsonl`.
3. On platforms without a state dir (macOS), it falls back to the data dir,
matching the daemon's convention: `~/Library/Application Support/omni-dev/log.jsonl`.
The directory is created `0700` and the file `0600`, the same posture as other
`omni-dev` runtime state. The log lives entirely on your machine.
## Environment variables
| `OMNI_DEV_LOG_FILE` | Override the log path. |
| `OMNI_DEV_LOG_DISABLE=1` | Disable logging entirely. |
| `OMNI_DEV_LOG_BODIES=1` | Opt in to recording request/response bodies (off by default; payloads are large and may contain customer content). |
| `OMNI_DEV_LOG_HEADERS=1` | Opt in to recording (redacted) request/response headers. |
| `OMNI_DEV_LOG_MAX_SIZE` | Enable automatic size-capped rotation on write, e.g. `10mb` (unix only; see [Bounding growth](#bounding-growth)). |
| `OMNI_DEV_LOG_KEEP_FILES` | Number of rotated files to keep when rotation is enabled (default `3`). |
| `OMNI_DEV_AUDIT_LOG_FILE` | Override the **audit** log path — see [Audit log](#audit-log); none of the other variables above affect it. |
Logging is **best effort**: a write failure is swallowed (logged only at
`tracing::debug`) and can never change the command's exit code. The audit log
is the deliberate exception — see [Audit log](#audit-log).
## `omni-dev log`
```
omni-dev log [OPTIONS] # search (default)
omni-dev log prune [OPTIONS] # trim the log — see Bounding growth
```
With no subcommand, `omni-dev log` searches and prints the log using the filters
below. The `prune` subcommand trims it; see [Bounding growth](#bounding-growth).
### Filters
| `--since <DUR_OR_TS>` | Lower time bound: a relative window (`30m`, `2h`, `1d`, `1w`, `45s`), a date (`2026-07-01`), or an RFC3339 timestamp. |
| `--until <DUR_OR_TS>` | Upper time bound: same forms as `--since` (a relative value means that long ago). Pair with `--since` for a bounded window. |
| `--method <METHOD>` | HTTP method (case-insensitive). |
| `--status <STATUS>` | Exact (`200`), class (`5xx`), or comma list (`4xx,5xx`). |
| `--service <NAME>` | `jira`, `confluence`, `datadog`, `browser-bridge`, `snowflake`, `transcript`, `anthropic`, `bedrock`, `openai`, `ollama`, `claude-cli`. |
| `--command <PATH>` | Resolved command-path prefix on whole segments, e.g. `"jira read"`. |
| `--url <SUBSTR>` | Substring of the request URL. |
| `--grep <REGEX>` | Regular expression against the raw JSON line. |
| `--fuzzy <TOKEN>` | Substring of the raw line; repeatable, AND-ed. |
| `--query <EXPR>` | Query expression (see below); repeatable, AND-ed. |
| `--id <ID>` | This record `id` **or** `invocation_id` — pulls a run and its requests. |
### Output
| `-o, --output <oneline\|json\|full>` | `oneline` (default), `json` (NDJSON, byte-identical to the file — composes with `jq`), or `full` (pretty block). |
| `-n, --limit <N>` | Show at most the N most recent matching records. |
| `-f, --follow` | Tail the log, printing new matching records as they arrive. |
| `--audit` | Read `audit.jsonl` instead of `log.jsonl` — see [Audit log](#audit-log). Every filter, the `--query` mini-language, and all three output formats apply unchanged; only the file being read differs. |
### The `--query` mini-language
- **Structured terms:** `field:value` — `kind`, `source`, `service`, `method`,
`status` (supports `5xx`), `command`, `url`, `id`, `invocation_id` (alias
`inv`), `mcp_tool` (alias `tool`), `via_daemon`, `error`, plus the fields that
were previously reachable only through `--grep`: `exit_code` (alias `exit`),
`duration_ms` (aliases `duration`, `dur`), `elapsed_ms` (alias `elapsed`),
`hostname` (alias `host`), `system_user` (alias `user`), `cwd`, and
`auth_principal` (alias `principal`). Field matching is shared with the flags,
so `--status 5xx` and `status:5xx` behave identically.
- **`status` is kind-aware:** for every kind but `drivemutation` it matches the
HTTP `status_code` (class or comparator syntax, below). A `drivemutation`
record has no `status_code` — its domain status (`blocked`, `written`,
`stale-revision`, ...) lives in the `context` map instead, so `status:` there
matches `context["status"]` by exact case-insensitive equality, e.g.
`kind:drivemutation status:blocked`.
- **Numeric comparisons:** numeric fields (`exit_code`, `duration_ms`,
`elapsed_ms`, and `status`) accept a leading comparator — `>`, `>=`, `<`,
`<=`, or bare `=`/`N` for equality — so slow requests and failed runs are
expressible directly (`elapsed:>1000`, `exit_code:>0`). `status` still also
accepts its class syntax (`5xx`). Comparator/class syntax applies only to the
HTTP `status_code` path, not the `drivemutation` case above.
- **Text fields** (`url`, `hostname`, `system_user`, `cwd`, `auth_principal`)
match a case-insensitive substring.
- **Context fields:** any other field name falls back to the record's free-form
`context` map (case-insensitive substring), so worktree recovery fields query
directly: `branch:issue-1392`, `path:demo-wt`.
- **Bare tokens** are fuzzy substring matches against the raw JSON line.
- **Operators:** `AND` (also implicit between adjacent terms), `OR`, `NOT` (or a
leading `-`), and parentheses. Use `"quotes"` for a value containing spaces.
```bash
omni-dev log --query 'kind:http AND (status:5xx OR method:POST)'
omni-dev log --query 'service:jira -status:2xx' # jira requests that did not 2xx
omni-dev log --query 'elapsed:>1000' # requests slower than 1s
omni-dev log --query 'kind:invocation exit_code:>0' # failed runs
```
### Examples
```bash
# The last 20 things you ran.
omni-dev log -n 20
# Server errors in the last two hours.
omni-dev log --since 2h --status 5xx
# Slow datadog requests (native — no jq needed).
omni-dev log --service datadog --query 'elapsed:>1000'
# A bounded historical window.
omni-dev log --since 2026-07-01 --until 2026-07-02
# A run and every request it made.
omni-dev log --id 0001718000000-0a1b2c3d4e5f6071
# Compose with jq for anything not directly expressible.
# Follow live.
omni-dev log -f --service browser-bridge
```
### Recovering a removed worktree
`omni-dev git worktree remove` records the worktree's branch, HEAD commit, and
dirtiness *before* the removal, so an accidental remove is recoverable minutes
later from the log alone:
```bash
# What worktree did I just remove?
omni-dev log --command 'git worktree remove' --since 1d -o full
# Or by any recovery field directly.
omni-dev log --service worktree --since 30m
omni-dev log --query 'branch:issue-1392'
```
The `full` record's `context` carries `path`, `branch`, `commit`,
`had_uncommitted`, and `used_force` — enough to re-create the branch
(`git branch <branch> <commit>`) and re-attach a worktree
(`omni-dev git worktree add <path> <branch>`). `prune` records carry the same
triple per pruned entry in a JSON `pruned` list.
## Counting records
`omni-dev log count` aggregates the log by record **kind** and **source** over an
optional window — a quick tally without paging through matches:
```
omni-dev log count [--since <DUR_OR_TS>] [--until <DUR_OR_TS>] [--source <SOURCE>] [--kind <KIND>] [--json]
```
| `--since <DUR_OR_TS>` | Lower time bound — same forms as `omni-dev log --since` (`1h`, `2026-07-01`, RFC3339). |
| `--until <DUR_OR_TS>` | Upper time bound (a relative value means that long ago). |
| `--source <SOURCE>` | Restrict to `cli`, `daemon`, or `mcp`. |
| `--kind <KIND>` | Restrict to `invocation`, `http`, `gh`, `worktree`, or `drivemutation`. `gh` unlocks the GitHub breakdown (below). |
| `--json` | Emit JSON (string map keys, composes with `jq`) instead of the table. |
With no `--kind` it reports the total and the split by kind and source:
```bash
# What has this machine done in the last day?
omni-dev log count --since 1d
```
### Counting GitHub API calls
Because every GitHub call is recorded as a `kind: "gh"` line (see [What gets
recorded](#what-gets-recorded)), `--kind gh` turns the log into a ground-truth
count of the GitHub API calls `omni-dev` itself makes — the *local* counterpart
to `gh api rate_limit`, which is GitHub's *server-side* view of everything the
token spent. It renders a richer breakdown by **category** — `api` (`gh api …`),
`subcommand` (`gh pr list`, `gh repo view`, …), and `local` (`gh --version`,
excluded from the API total) — as well as by subcommand and by source:
```bash
# All GitHub API calls in the last hour.
omni-dev log count --kind gh --since 1h
# Just what the daemon's pollers spent today, as JSON.
omni-dev log count --kind gh --since 1d --source daemon --json
# The raw records (composes with jq / anything log can express).
omni-dev log --query 'kind:gh'
```
The counts share the request log's storage, so they honor the same window and
are subject to `prune`/rotation. The daemon also logs a summary of these counters
to its own log (`daemon.log` / the journal) ~5 seconds after startup, every 10
minutes, and once on shutdown — a periodic, restart-delimited footprint without
anyone running a command; `daemon status` surfaces the current count too.
Those daemon figures count calls **since the daemon started**. It reads no part of
the log that was already there when it started, and each summary reads only the
records appended since the previous one, so what a summary costs does not grow with
the size of the log — a multi-gigabyte `log.jsonl` no longer delays startup,
`daemon status` or shutdown. If the log is replaced or truncated underneath it
(`log prune`, size-capped rotation), the daemon rebuilds its count once from what
the live file holds, which is what `omni-dev log count --kind gh --since <daemon
start>` reports at that point, so records rotated into `log.jsonl.1` stop being
counted. A summary that shutdown cut short, or that could not read the log, is
marked `incomplete`: in the summary line, and as `"incomplete": true` in the
`summary` op's reply.
**Not yet counted:** the VS Code companion extension's `gh pr list` runs in a
separate process and does not write to the log (a planned follow-up).
## Bounding growth
The log is default-on for every invocation **and** every outbound request, so on
an active machine it grows steadily. Two opt-in bounds keep it in check; both are
off by default, so nothing changes unless you ask for it.
### `omni-dev log prune`
Trims the log in place, by age and/or by size:
```
omni-dev log prune [--older-than <DUR>] [--max-size <SIZE>] [--dry-run]
```
| `--older-than <DUR>` | Remove records older than a relative window (`7d`, `24h`, `2w`, `45m`). |
| `--max-size <SIZE>` | After age pruning, drop the **oldest** records until the file is at most `<SIZE>` (`10mb`, `512kb`, or a bare byte count). |
| `--dry-run` | Report what would be removed without modifying the file. |
At least one of `--older-than` / `--max-size` is required. Sizes are binary
(`kb`/`mb`/`gb` = 1024-based). Records with a missing or unparseable timestamp
are kept (age pruning only removes records it can positively date as old), and
`--max-size` always keeps at least the single most recent record.
```bash
# Keep the last 30 days; preview first.
omni-dev log prune --older-than 30d --dry-run
omni-dev log prune --older-than 30d
# Cap the file at 20 MB, dropping the oldest records to fit.
omni-dev log prune --max-size 20mb
```
Pruning rewrites the file atomically (a same-directory temp file is renamed over
the original, preserving the `0600` mode), so a concurrent reader never sees a
half-written file. It is not locked against concurrent **writers**, though: a
record appended during the rewrite may be lost, and — because `prune` is itself
a logged invocation — pruning the active log appends one new record of its own.
For exact accounting, prune with `OMNI_DEV_LOG_DISABLE=1` or when the log is idle.
### Automatic size-capped rotation
Set `OMNI_DEV_LOG_MAX_SIZE` (e.g. `10mb`) to rotate on write: before an append
that would push the file past the cap, `log.jsonl` is renamed to `log.jsonl.1`
(shifting any existing `log.jsonl.1` → `.2`, and so on) and a fresh `log.jsonl`
is started. `OMNI_DEV_LOG_KEEP_FILES` (default `3`) bounds how many rotated files
are retained; the oldest beyond that is deleted. Total on-disk use is therefore
roughly `(OMNI_DEV_LOG_KEEP_FILES + 1) × OMNI_DEV_LOG_MAX_SIZE`.
Rotation is **unix-only** and **best effort**: when it is enabled, writers
serialize on a stable `log.jsonl.lock` file (created `0600`) for the
check-rotate-append sequence, and a rotation failure falls back to appending
without rotating rather than dropping the record. The env vars are read per
write, so they must be present in the environment of whatever writes the log
(your shell for CLI runs, or the daemon's environment for daemon-served
requests). A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` is ignored (logged at
`tracing::debug`) and leaves rotation off. The `omni-dev log` reader already
tolerates truncation and rotation, so `-f/--follow` keeps working across a
rotation (it restarts from the top of the fresh file).
## Audit log
`audit.jsonl` is a **separate** file, a sibling of `log.jsonl` under the same
runtime directory, reusing the same `LogRecord` schema and the same
`omni-dev log` reader — pass `--audit` to read it instead of `log.jsonl`; see
[Filters](#filters)/[Output](#output) above, all of which apply unchanged.
It exists to serve the opposite guarantee from everything above: where
`log.jsonl` is best-effort, prunable, and can be disabled outright,
`audit.jsonl` is **fail-closed** — an operation whose audit record cannot be
written does not happen — and exempt from every growth bound this page just
described:
| On a write failure | Swallowed; the command's exit code is unaffected | Propagated; the caller aborts the operation it was about to audit |
| `OMNI_DEV_LOG_DISABLE=1` | Suppresses all writes | No effect |
| `omni-dev log prune` | Trims by age/size | Refuses `--audit` outright (`log prune does not support --audit`), **and** refuses any resolved log path that names the audit file — e.g. `OMNI_DEV_LOG_FILE` pointed at `audit.jsonl` directly, via a `..` segment, or via a symlink (redundant in practice since #1747: the aliasing `OMNI_DEV_LOG_FILE` fails to resolve at all before `prune` ever runs — kept as defense in depth for a caller that bypasses normal path resolution) |
| `OMNI_DEV_LOG_MAX_SIZE` rotation | Applies | Never applies, regardless of the setting or how `OMNI_DEV_LOG_FILE` is spelled |
| Path override | `OMNI_DEV_LOG_FILE` | `OMNI_DEV_AUDIT_LOG_FILE` |
| Location when unset | `<state dir>/omni-dev/log.jsonl` | `<state dir>/omni-dev/audit.jsonl` |
`--audit` is a flag on the bare search form (`omni-dev log --audit ...`) and,
separately, on `prune` (`omni-dev log prune --audit`, always refused, above);
placed before a subcommand name instead — `omni-dev log --audit prune ...` —
it is refused rather than silently ignored, since a subcommand never
consults it. If `OMNI_DEV_LOG_FILE` and `OMNI_DEV_AUDIT_LOG_FILE` are
configured to resolve to the **same file**, the request-log path itself
simply fails to resolve — compared by file identity (the `same-file` crate
— inode on unix, a file handle on Windows — falling back to a normalized
path when one side doesn't exist yet), not merely by identical spelling, so
a `..` segment, a relative-vs-absolute spelling, or a symlink can't slip
past the check. The two sinks are siblings by default but not otherwise
mutually exclusive by construction, so this misconfiguration is caught once,
centrally, at path resolution ([#1747](https://github.com/rust-works/omni-dev/issues/1747))
rather than by each consumer of the request-log path separately: neither a
best-effort append, rotation, nor `prune` can ever reach `audit.jsonl`
through an aliasing `OMNI_DEV_LOG_FILE`, since none of them get a path to
act on in the first place.
Every record carries `kind: "audit"` and, in `context`, an `integration` key
(`"drive"` today, so a later integration's audit trail is additive rather
than a rename), a `file_id`, and a `verdict` — alongside the usual
invocation/HTTP fields. `RecordKind::Audit` is the schema landed by issue
[#1664](https://github.com/rust-works/omni-dev/issues/1664)
([ADR-0080](adrs/adr-0080.md) §11); what populates it is the Drive
leased-write lifecycle, landing across that same issue's phases.
**`drive lease acquire`** writes one record per attempt, `command: ["drive",
"lease-acquire"]`, regardless of outcome — `verdict` is `acquired`,
`acquired-headless-waiver` (ADR-0080 §8/§13, issue #1677: proceeded under the
headless opt-out, no human ever prompted), `already-leased`,
`refused-native-document`, `refused-concurrent-change` (issue #1693: the
file changed while its backup was being taken, so no lease could vouch for
that backup — `error` names what moved), `denied`, `unavailable`, or
`failed`, matching `AcquireResult`'s own kebab-case status — plus, on
`already-leased`/`refused-concurrent-change`/`failed`, the
`-backup-orphaned` suffix (issue #1690): this attempt took a real backup
before discovering it isn't referenced by any ledger row, and reclaiming
that backup itself then failed, so it is now truly orphaned (`drive lease
prune` cannot see it either). An `acquired` record additionally carries
`lease_id` (the token), `version_after`/`modified_time_after` (the Drive
state actually recorded into the ledger — re-read after the backup, not the
pre-authentication snapshot, for the same TOCTOU reason the ledger itself
does), `backup_location` (a local path for a byte backup, or the backup
copy's own file id for a native-document backup),
`backup_sha256`/`backup_size` (byte backups only), and `auth_policy`
(`device-owner` or `biometrics-only`). An
`already-leased`/`refused-concurrent-change`/`failed` record also carries
`backup_location` whenever this attempt took a backup (a
`refused-concurrent-change` always did), whether or not reclaiming it
succeeded — the `-backup-orphaned` suffix is what distinguishes the two. Unlike a leased *write*'s own record (below), this
one is best-effort rather than write-ahead/fail-closed: acquiring mutates no
Drive content — by the time the record is written the consent, the backup
and the ledger row have already durably happened, so a
logging failure is warned and does not turn a successful acquisition into a
reported failure.
A leased **write** produces records in **both** files, correlated by
`invocation_id`: its ordinary `drivemutation` record here in `log.jsonl`
(ADR-0070/0071/0073/0075/0076/0077/0078), and, in `audit.jsonl`, one or
two `audit` records from `crate::drive::lease::check` — shared by every
leased-write engine (`drive edit`, the five Sheets engines and `drive docs
write`). Their `command` is `["drive", <operation>]` with the **verb's**
operation name — `["drive", "edit"]`, `["drive", "sheets-delete-sheet"]`,
`["drive", "docs-replace"]` — byte-for-byte the `command` of the same
write's `drivemutation` record, so `--query 'command:sheets-delete-sheet'`
matches both files and the audit record names the verb that ran, not
merely which engine ran it:
- A refusal that never reaches the mutating call writes **one** best-effort
record — `verdict: refused-no-lease`, `refused-lease-expired`,
`refused-lease-wrong-file` or `refused-lease-stale`, mirroring the
engine's own reported result, or `failed` with the `error` when the
ledger lock could not be taken. There is no mutation to pair a
write-ahead record around.
- A presented lease that checks out writes a **write-ahead** `pending`
intent record carrying `lease_id`, `version_before` and
`modified_time_before`, `fsync`ed before the mutating API call this
lease authorises, and fail-closed: if this record cannot be written the
write itself is refused rather than proceeding unaudited (the one place
in the whole ADR this contract applies, per §11). After the mutating
call returns, a best-effort **outcome** record follows with the same
`lease_id`: `verdict: allowed` with `version_after`/`modified_time_after`
(from `files.update`'s own response for `drive edit`, from the post-write
`files.get` the ledger refresh already pays for on Sheets/Docs), or
`verdict: failed` with the API `error` — including a Docs `412` on
`writeControl.requiredRevisionId`, which the CLI reports as
`stale-revision`: the *lease* was not stale, the mutation simply did not
happen, and the error says why.
- A Sheets `batchUpdate` that answers **2xx but whose reply cannot be
parsed** is still a mutation that happened (issue #2021): its outcome
record is `verdict: allowed` (with `version_after`, and the ledger is
refreshed), and its `drivemutation` record has `status:
applied-reply-unreadable`, `error` set to the parse detail, and
`fields_changed` set to the change summary — never `failed`. Only a
non-2xx response, or a transport failure before any status arrives,
records `failed`.
An intent record with no matching outcome is itself the signal that
something was interrupted mid-write — the whole point of writing the
former durably before the latter can even be attempted.
**`drive lease restore <TOKEN>`** writes its own top-level, best-effort
record, `command: ["drive", "lease-restore"]`, in addition to the
generic write-ahead/outcome pair its internal fresh-lease acquisition and
restore write already produce (both keyed on the *fresh* token). This one
names **both** tokens (ADR-0080 §10/§11): `lease_id` is the fresh token
once minted (or the existing one, for `already-leased`), and
`restored_from_lease_id` is always the backup lease's own token — the
`<TOKEN>` argument — so `--query 'restored_from_lease_id:<token>'` finds
every restore attempt made from one backup regardless of outcome. `verdict`
matches `drive lease restore`'s own reported status: `restored`,
`restored-sheet`, `restored-headless-waiver`,
`restored-sheet-headless-waiver`, `sheet-already-restored`,
`no-such-backup-token`, `no-typed-restore-path`,
`backup-too-large-for-simple-upload`, `refused-no-visible-parents`,
`blocked`, `already-leased`, `refused-native-document`,
`refused-concurrent-change`, `denied`, `unavailable`, `failed`, or
`fresh-lease-but-write-failed`.
Restore *supersedes* the backup lease it restores from (issue #1685): the
internal fresh-lease acquisition releases that row in the same ledger write
that records the new one, so its `command: ["drive", "lease-acquire"]`
record additionally carries `superseded_lease_id` — the backup token — on
the `acquired` verdict, and only when a row was actually released. It is
the record to look for to answer "when did this lease stop being live?"
when that happened without a `release`.
**`drive lease release <TOKEN>`** writes one best-effort record per
attempt, `command: ["drive", "lease-release"]`, with `lease_id` the token
presented (even on a refusal, so a presented-but-dead token is greppable)
and `verdict` matching the command's own status: `released`,
`release-not-live` (already expired or already released; the row is left
untouched), `release-no-such-token`, or `failed` carrying the error. A
release makes no Drive call, so there is no `drivemutation` record to join
against.
## Redaction posture
No secret material is ever written, under any code path:
- Auth headers/tokens are **redacted centrally** before writing; only a
non-secret `auth_principal` identity is ever kept. Redaction matches both a
fixed list of known header names and any name containing `auth`, `token`,
`key`, `secret`, `cookie`, `password`, `session`, `signature`, or
`credential` (case-insensitive).
- URL query and fragment parameters whose keys look secret-bearing have their
**values** replaced with `REDACTED` before writing: keys suffixed `token`,
`secret`, `password`, `passwd`, `signature`, or `api_key`/`apikey`; the exact
keys `sig`, `sas`, `jwt`, and `auth`; and the `X-Amz-*` / `X-Goog-*`
signed-URL families. Host, path, and parameter keys are preserved, so
`--url` substring filtering keeps working. This matters mostly for the
browser bridge, which logs arbitrary operator-supplied target URLs
(presigned URLs, `?access_token=…`).
- Request/response bodies are **opt-in** via `OMNI_DEV_LOG_BODIES=1`.
- The `OMNI_DEV_*` env snapshot redacts any name containing `TOKEN`, `SECRET`,
`KEY`, `PASSWORD`, or `PASSWD`.
- Argv in the invocation record is scrubbed before writing, in both
`--flag value` and `--flag=value` forms: `--header` values naming a sensitive
header are redacted keeping the name (`Authorization: REDACTED`), inline
`--body` values are redacted (`@file` references are kept), and any flag
whose name has a `token`/`secret`/`password`/`passwd`/`key` segment has its
value redacted (flags ending in `-file`/`-path` carry paths and are exempt).
Every argv element is then run through the same URL query/fragment redaction
as `--url` above, so a secret carried in a URL argument (e.g.
`--url /path?access_token=…` or a presigned target) is redacted even though
`--url` is not a secret-bearing flag name; benign argv passes through
byte-identical.
### What redaction does not cover: prompt bodies
The guarantees above keep *secret material* out of the log; they do not make
prompt **content** secret. AI prompts carry whatever you asked about — repo
diffs, commit messages, JIRA/Confluence data — and that content surfaces on
three paths:
- `OMNI_DEV_LOG_BODIES=1` records AI request/response bodies, which **are** the
prompts.
- `RUST_LOG=debug` stderr tracing emits the **full system/user prompt and full
response** from every AI backend. Tracing is ephemeral and stderr-only, but
if you redirect it to a file or paste it into a bug report, prompt bodies go
with it.
- On a `claude-cli` subprocess failure, the returned error embeds the child's
**stdout/stderr verbatim**, which can carry prompt-derived content into
whatever captures the error (terminal, CI logs, this log's `error` field).
No API keys or tokens appear on any of these paths — this is a
data-sensitivity note, not a credential leak.
## Daemon-served requests
Requests executed inside the daemon (the browser bridge and the Snowflake
session pool) set `via_daemon: true`. Although the daemon is a separate process
from the CLI that asked it to act, such requests are still stamped with the
*originating* CLI invocation's `invocation_id`: the thin client threads its id
across to the daemon — on the control socket for Snowflake (an
`origin_invocation_id` envelope field) and on the `X-Omni-Bridge-Origin` header
for the bridge — and the daemon scopes it around the request it serves. So
`omni-dev log --id <cli-invocation>` surfaces the `via_daemon` requests that run
triggered, correlating both halves of one logical operation
([#1198](https://github.com/rust-works/omni-dev/issues/1198)).
The threaded id is non-secret (it is not a token), and the record's `source`
stays `daemon`, so a daemon-served request remains distinguishable by
`via_daemon` / `source` even while its `invocation_id` points at the caller. The
same mechanism correlates a standalone `bridge serve` (there `source` stays
`cli` and `via_daemon` is unset).
## Schema and compatibility
Records are read and written through a single forward-compatible struct: every
field is `#[serde(default)]` and every optional field is `skip_serializing_if`.
A newer reader never chokes on an older line, and an older reader never chokes on
a newer one — the same forward-rolling contract the daemon wire types use. The
record id is time-sortable, so sorting lines by `id` ≈ sorting by time.
### Slides replacement
`drive slides replace` records operation `slides-replace` in `drivemutation`
records. Outcomes include `replaced`, `blocked`, `stale-revision`, lease refusals,
and `applied-response-unreadable` (HTTP success; inspect before retrying). Records
include file identity, revision and server-reported occurrences when available.
Search/replacement prose and server diagnostics are excluded because an error
may echo request text. Dry runs emit no mutation record. Drive lease audit pairs
use `slides-replace` and conclude an unreadable successful response as `allowed`.