The audit log
searchcode enterprise writes a separate audit stream: who signed in and who was refused, who searched, read or asked about what, which agent did it on whose behalf, and every change an administrator made. It is yours, not ours — it goes where you point it, in a format your SIEM can tail, and it is never sent to us.
This page is the reference a compliance team or a SIEM engineer needs: what is recorded, the shape of a record, where records go, how long they are kept, and what the stream deliberately does not do.
What you can rely on
- It is always on. There is no licence check, no
enabledflag and no output setting that discards records. A licence that lapses does not stop the stream. That is a security property rather than a pricing one: if a lapse could silently stop recording denials, every claim that denials are recorded would depend on the licence. - Denials are recorded as well as grants.
outcome: deniedis the half most audit implementations leave out and the half a reviewer asks about first. - It is a contract. Records carry a
version. Changes are additive only: a field or an action may be added; none is renamed or removed. A rule you write against today's stream keeps working. - It never blocks a request, and it never loses a record silently. See When records are dropped.
- If it cannot be opened, the instance does not start. An instance serving while recording nothing is worse than one that refuses to start, because nothing about it would be visible from outside.
Where records go
audit:
output: file # stdout | stderr | file | syslog
retentionDays: 400 # 0 keeps everything
logSearchQueries: true
syslogAddress: "" # only with output: syslog
audit.output |
Where | Notes |
|---|---|---|
stdout (default) |
The process's standard output, one JSON object per line | Needs no writable path beyond dataDir, so a read-only container works with one mount. Shares the stream with the application log; see below. |
stderr |
Standard error, the same format | |
file |
dataDir/audit/audit-YYYY-MM-DD.jsonl, one file per UTC day |
Files are mode 0600 in a 0700 directory, because records carry usernames, email addresses and groups. Survives external log rotation. The setting a compliance requirement usually wants. |
syslog |
RFC 5424 to a syslog daemon | Facility local0, severity informational, APP-NAME searchcode. syslogAddress takes network://address, such as udp://10.0.0.1:514 or tcp://collector:601; empty uses the host's local syslog socket. |
On stdout, audit records and application log lines share one stream. Every
audit record carries version and action, and no application log line carries
either, so a forwarder separates them on those fields. If you would rather not
separate them at all, use file.
A remote syslog address opens an outbound connection. It is one of the three things that can make this process connect anywhere, all of which you configure; see trust.
The audit block takes effect at start. Changing it needs a restart, because the
sink is opened once, before anything that produces records.
At start the instance logs where the stream is going (audit stream configured,
code e5b93f74), with the output, the directory, the retention, whether query
text is recorded, and the schema version. That line answers "where do our access
denials go" without anybody reading the configuration.
Retention
audit.retentionDays defaults to 400: a thirteen-month audit cycle plus a
month. 0 keeps everything forever.
Retention applies to output: file. It runs once at start and then every twelve
hours, and it is the only thing that ever deletes an audit record. It deletes
whole day files: a record lives until its entire UTC day has aged out, so the
effective window is up to a day longer than configured. Erring long is the right
direction for an audit trail.
With stdout, stderr or syslog, records leave the process as they are
written, and retention is whatever your log pipeline keeps.
The shape of a record
One JSON object per line:
{
"version": 1,
"ts": "2026-10-15T03:12:44.318Z",
"id": "evt_8f3a21c49d07b2e6a15c",
"action": "search.performed",
"uniqueCode": "a1b2c3d4",
"trace_id": "4bf92f3577b34da6",
"actor": {
"id": "a.person@example.com",
"type": "agent",
"name": "a.person",
"email": "a.person@example.com",
"groups": ["engineering"],
"key_id": "k_7d2e",
"client": "Claude Code — laptop",
"source": "apikey"
},
"target": {"type": "repository", "id": "example__billing-service"},
"outcome": "allowed",
"metadata": {"query": "retry backoff", "results": 12, "surface": "mcp"}
}
The values above are illustrative; which fields are present depends on the action.
| Field | Meaning |
|---|---|
version |
The schema version, currently 1. Additive changes do not move it. |
ts |
RFC 3339, UTC, millisecond precision. |
id |
Unique per record, so a SIEM can deduplicate a file it read twice. Random, so it does not collide across restarts. |
action |
What happened, from the closed list below. |
uniqueCode |
Which line of the product wrote it. See three identifiers. |
trace_id |
Which request. Present on records written while serving one. |
actor.id |
The principal: a person's subject, service:<name> for a service account, or empty when a request was refused before any identity existed. |
actor.type |
user, agent (a person's credential used by a tool), service or anonymous. |
actor.source |
How the principal was established: apikey, oauth, proxy, local or open. |
actor.groups |
The identity groups the decision used. Behind a proxy these exist nowhere else, so the record is what lets a denial be reproduced a month later. |
actor.ip, actor.userAgent |
Where the call came from, when known. |
actor.key_id, actor.client |
The credential and the client name it was issued for. null, not an empty string, when no credential was involved, so key_id != null means "an agent did this". |
target.type |
repository, instance, account or credential. |
target.id |
Which one. |
target.repo_groups |
On a repository target in an authorisation record: the repository's groups, so a denial reads without a second lookup. |
outcome |
See below. |
metadata |
Action-specific detail, such as the query text, the result count, the surface (api, mcp or ui), a path, a reason, or an administrator's before and after values. |
Outcomes
outcome |
Meaning |
|---|---|
allowed |
It happened. |
denied |
Authorisation or authentication refused it. |
not_found |
The target does not exist, or the principal may not see it (the two are deliberately indistinguishable). |
invalid |
The request was malformed. |
error |
Something failed inside the instance. |
abandoned |
The caller went away before the answer was ready. |
unavailable |
The instance was allowed to answer and could not, for a reason that is neither the caller's nor a fault here: the repository has no commits, or the code host refused a clone. |
A rule that only knows the first five still reads every record correctly: the
last two were added later and moved records out of not_found and error, so
what remains under those words is still true.
Three identifiers
action, uniqueCode and trace_id answer three different questions, and none
of them substitutes for another.
actionis what semantically happened. There are a few dozen, they are documented below, and they are the contract with your compliance team. Write rules against these.uniqueCodeis which line of the product said it. The same action can be written from several places —auth.proxy_rejectedcomes from five, because an untrusted peer, a wrong shared secret and a duplicated identity header need different answers — and the code tells them apart. It is the contract with support. See log codes.trace_idis which request. Every response carries it in anX-Request-Idheader, and every application log line written while serving that request carries it too. That is the join: the audit record says what happened and to whom; the log lines for the sametrace_idsay everything else.
A caller may supply its own X-Request-Id (8 to 32 lowercase hex characters), or
a W3C traceparent, whose trace id is adopted, so an instance behind an
instrumented proxy correlates with it. Anything else is discarded and a fresh
identifier is made.
No error response carries a unique code. Distinct codes for "no such user"
and "wrong password" would tell an attacker which one it was. The response
carries X-Request-Id; you have the logs, and the logs carry the code.
What is recorded
Every action the product can write is in this list. It is closed: a new action is added here before anything writes it.
Signing in — auth.*
| Action | Recorded when |
|---|---|
auth.signin |
A principal established a session |
auth.signout |
A principal ended its session |
auth.login_failed |
A local password did not verify |
auth.login_succeeded |
A local password verified and a session was issued |
auth.authenticated |
A request was attributed to a principal |
auth.proxy_rejected |
Identity headers arrived from an untrusted peer or were malformed |
auth.session_invalid |
A session cookie did not resolve to a live session |
auth.csrf_rejected |
A state-changing request carried no valid CSRF token |
auth.credential_wrong_listener |
A credential was presented on the listener that does not accept it |
auth.no_credential |
An authenticated route was reached with nothing to authenticate |
auth.seat_refused |
Reserved name; a seat refusal is written as license.user_limit_reached |
auth.subject_disabled |
The subject behind the credential is disabled |
auth.login_rate_limited |
A login was refused before the password hash ran |
auth.sessions_revoked |
Every session for a subject was invalidated |
Questions about the policy — authz.*
| Action | Recorded when |
|---|---|
authz.explain_access |
Somebody asked why a principal can or cannot reach a repository |
authz.explain_refused |
An explain-access call by a principal that is neither administrator nor auditor |
Reading code — search.*, repo.*, file.*, estate.*
| Action | Recorded when |
|---|---|
search.performed |
A search ran against the estate |
search.denied |
A search was refused for the repository it named |
estate.queried |
An estate-wide rollup, findings or dependency query ran |
estate.trend_aggregated |
A hotspot set's complexity trend was aggregated across repositories |
estate.risk_ranked |
Files were ranked by their risk factors across repositories |
repo.browsed |
A repository's structure, symbol outline or inventory was listed |
repo.hotspots_viewed |
A repository's churn-and-complexity ranking was read |
repo.coupling_viewed |
The files that change together with a given file were read |
repo.dependencies_viewed |
What a file imports, what imports it, or the tests that cover it were read |
file.viewed |
A file's contents were rendered |
file.raw_fetched |
A file's bytes were fetched unrendered |
file.blame_viewed |
Line-level authorship was read |
file.history_viewed |
A file's commit history was read |
file.trend_viewed |
A file's complexity was measured at every commit that touched it |
The reads are separate actions on purpose. "Who pulled the estate's risk ranking" and "who listed this repository" are different questions, and one action covering both would make each unanswerable.
Agents — mcp.*, apikey.*, credential.*, oauth.*
| Action | Recorded when |
|---|---|
mcp.tool_called |
An agent called a tool |
mcp.tool_denied |
An agent's tool call was refused |
apikey.created |
An agent credential was created |
apikey.issued |
An agent credential was issued and its secret shown once |
apikey.revoked |
An agent credential was revoked |
apikey.expired |
An agent credential was refused because its lifetime had elapsed |
apikey.rate_limited |
An agent credential exceeded its request ceiling |
apikey.anomaly |
An agent credential's call rate departed from its own baseline |
apikey.authenticated |
An agent credential resolved to a principal |
credential.revoked |
Every credential for a subject was revoked |
oauth.client_registered |
An MCP client registered itself |
oauth.token_issued |
An MCP client exchanged an authorisation for an access token |
oauth.code_exchange_refused |
An authorisation code could not be exchanged for a token |
oauth.token_refused_stale_groups |
A refresh was refused because the person's groups could not be re-resolved |
Accounts — user.*
| Action | Recorded when |
|---|---|
user.created |
An account was created |
user.disabled |
An account was disabled |
user.enabled |
An account was re-enabled |
user.password_reset |
An administrator reset somebody's password |
user.groups_changed |
An account's groups were edited |
The estate — repo.*, connection.*
| Action | Recorded when |
|---|---|
repo.added |
A repository entered the estate |
repo.removed |
A repository left the estate |
repo.indexed |
A repository was cloned or refreshed and indexed |
repo.index_failed |
A repository could not be cloned, refreshed or indexed |
connection.created |
An administrator created a code-host connection (name, type and host; never the token) |
connection.updated |
An administrator edited or re-enabled a connection (never the token) |
connection.disabled |
An administrator disabled a connection; it stops enumerating and keeps what it found |
connection.deleted |
An administrator deleted a connection and its stored token |
connection.sync_started |
A connection began enumerating its targets |
connection.sync_completed |
A connection finished a sync with no gaps; metadata carries the counts |
connection.sync_partial |
A connection finished and named targets it could not read |
connection.sync_paused |
A connection was rate limited and will resume; not a failure |
connection.sync_failed |
A connection could not be enumerated at all; metadata carries the reason |
connection.repository_retired |
A repository left the estate because a successful sync no longer named it |
Operating the instance — config.*, admin.*, license.*, audit.*
| Action | Recorded when |
|---|---|
config.reloaded |
The configuration on disk was adopted without a restart |
config.reload_failed |
A configuration change was rejected and the previous one kept |
admin.setting_changed |
An administrator changed a setting; metadata always carries field, before and after |
license.user_limit_reached |
The licensed user limit refused a subject this instance has never seen |
license.grace_entered |
The licence expired and the instance entered its grace window |
license.expired |
The grace window elapsed and indexing paused |
audit.exported |
Reserved. No release writes it. See below |
Should a record ever arrive under a name that is not in this list, it is written
anyway, counted as unknown_actions on /api/v1/status, and logged. A record
under an undocumented name can be recovered; a dropped one cannot.
Search query text
audit.logSearchQueries defaults to true. Set it to false and the query text
is removed from every record before it is written — the query, query_text
and terms metadata keys — whichever surface produced it. Everything else stays,
including the result count, so usage reporting keeps working.
This is enforced in one place, inside the audit stream itself, so a surface added later cannot leak query text by forgetting. Recording what developers search for is regulated in some jurisdictions, and this is the setting to show a works council.
When records are dropped
Writing a record never makes a request wait. Records go onto a bounded queue of 4,096 with one writer behind it, so a wedged syslog daemon or a slow disk cannot become a slow search.
If the queue fills, the record is dropped, and the drop is never silent:
- It is counted, and the count is on
/api/v1/statusunderaudit.dropped, besideemitted,writtenandwrite_errors. The admin page shows the same block. - The first drop is logged at once (code
6f2b81da), and a sustained overflow is re-reported periodically rather than once per record. - At shutdown, a run that dropped anything logs that the audit log has a gap
(code
c9f5a2e4).
An audit log with a recorded gap is a compliance event you can see and explain.
One with an unrecorded gap is not. Alert on audit.dropped being above zero.
Who read the audit log?
The stream cannot tell you, and that is by design. Records are written to a file
or a stream you own so that getting them out needs no API call: reading them is
cat, tail or a forwarder, and the instance is not part of that. There is
nothing for it to observe.
If "who read the audit log" has to be answerable, answer it where the read
happens: auditd or a file-access policy on dataDir/audit/, the volume's mount
policy, or your SIEM's own access log once records are forwarded.
The list carries a reserved audit.exported action for the day an export path
exists. No release writes it, and its description says so, because a rule for
"the audit log was exported" that never fires could otherwise be read as
"nobody has read it".
Feeding a SIEM
The file output is built to be tailed. Fluent Bit, Vector, the Splunk universal
forwarder and the Elastic agent all read JSON lines directly. A Fluent Bit input
for an instance with dataDir: /var/lib/searchcode:
[INPUT]
Name tail
Path /var/lib/searchcode/audit/audit-*.jsonl
Parser json
Tag searchcode.audit
Rules worth writing on day one:
| Watch for | Match |
|---|---|
| Repeated refusals for one principal | outcome == "denied", grouped by actor.id |
| Forged or misrouted identity headers | action == "auth.proxy_rejected" |
| A credential behaving unlike itself | action == "apikey.anomaly" |
| Somebody enumerating the estate | action == "search.performed" with a high metadata.results, or many estate.* records from one actor |
| Policy questions being asked | action in authz.explain_access, authz.explain_refused |
| Every administrative change | action == "admin.setting_changed" or connection.created / connection.deleted |
| Part of the estate going missing | action in connection.sync_partial, connection.sync_failed, connection.repository_retired |
| Licence state changing | action starting license. |
Group, filter and alert on action and outcome, never on wording, and join to
the application log on trace_id when you need the detail.
Application logs are a different stream
Application logs are ours, for running the product: JSON on stdout with ts,
level, msg, component and uniqueCode, with their own retention and no
stability promise beyond the codes. The audit stream is yours, with a schema
version and an additive-only promise. They are never mixed in meaning, even when
output: stdout puts them on the same pipe. See
log codes for the application log, and
who sees what for the policy the authz.* records are about.