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

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.

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:

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.

Generated from the documentation that ships with searchcode enterprise. Questions: ben@boyter.org