Monitoring
searchcode enterprise publishes Prometheus metrics on a listener of its own, ships a Grafana dashboard and a set of alert rules built against those metrics, answers health probes on the API listener, and can send traces to your own OTLP collector. None of it needs an agent, a sidecar or a vendor account.
The metrics listener
/metrics is served on a third port, separate from the API and MCP listener
(server.listen) and the UI listener (server.uiListen).
| Setting | Default | Reload |
|---|---|---|
observability.metrics.enabled |
true |
restart |
observability.metrics.listen |
0.0.0.0:9090 |
restart |
It is a separate port rather than a route for one reason: repository names appear in metric labels, and they are often sensitive. A NetworkPolicy or firewall rule can say "this port, from the Prometheus namespace", but it cannot say "this path". Give Prometheus the port and nobody else.
The endpoint is unauthenticated, so restrict who can reach it at the network.
Validation refuses an address that one of the other two listeners already uses, and the bound address is announced at startup on stderr as
searchcode metrics listening on http://…/metrics. See
Ports, names and what sits in front of each listener for where each listener should be
reachable from.
Turning enabled off is a decision about scraping, not about exposure: the
listener is already separate.
Per-repository series
indexing.metricsPerRepo controls whether the per-repository metrics carry one
series per repository. Each repository costs about two series and a few hundred
bytes of scrape, so set it to false above roughly 2,000 repositories.
When it is false the repo label is kept and every series collapses onto
repo="all". The label set never changes with the setting, so a dashboard query
that groups by repo keeps working either way. Lag collapses to the maximum (the
stalest repository is the question); counts are summed.
The estate gauges are sampled every ten seconds off the request path, so a scrape
reads a snapshot and never waits on a clone. /api/v1/status reports the age of
that snapshot.
What is exported
The list below is the whole set. Nothing else is published: there are no
go_* or process_* series.
Licence and metering
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_license_expiry_timestamp_seconds |
gauge | — | Unix time the licence expires. Zero when there is no expiry. |
searchcode_licensed_users_active |
gauge | — | Distinct subjects seen in the last thirty days: the user meter's count. |
searchcode_licensed_users_limit |
gauge | — | The licensed user ceiling. Zero means unlimited. |
searchcode_user_limit_refusals_total |
counter | — | Sign-ins refused at the user limit. Somebody was turned away. |
searchcode_licensed_repositories |
gauge | — | Repositories counted against the repository meter. Directory sources count as units, not mounts. |
searchcode_licensed_repositories_limit |
gauge | — | The licensed repository ceiling. Zero means unlimited. This meter warns and never blocks. |
See Licensing for what each meter does.
Freshness and ingestion
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_repo_index_lag_seconds |
gauge | repo |
Seconds since the repository last ingested successfully. The freshness metric; alert on its p99. |
searchcode_repo_index_failures_total |
counter | repo, reason |
Failed clones and fetches. |
searchcode_files_skipped |
gauge | repo, reason |
Skip events from the most recent scan of each repository. |
searchcode_indexing_duration_seconds |
histogram | phase |
Seconds spent in one analysis pass, by phase. Not labelled by repository, deliberately. |
searchcode_ingestion_paused |
gauge | reason |
1 while ingestion is paused: window, disk_full or license_expired. Serving is never affected. |
searchcode_files_skipped is a gauge, not a file count, despite the name.
For timeout, too_large, minified, binary, excluded and
excluded_default it counts files. For gitignore, ignore_file and other it
counts skip events, and a whole skipped directory is one event. Each scan replaces
a repository's counts, so the value falls when a repository shrinks or an
exclusion is narrowed. Alert on the value, never on increase(). A non-zero
timeout or minified count is how you find out files are missing from search.
excluded is something you configured; excluded_default is the built-in
exclusion set (dependency, build-output and cache directories). They have
different remedies, which is why they are counted apart.
Capacity
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_disk_usage_ratio |
gauge | path |
Fraction of the dataDir filesystem in use, 0.0 to 1.0. |
searchcode_disk_available_bytes |
gauge | path |
Bytes available on the dataDir filesystem. |
Disk is sampled every thirty seconds, never per write. A check on every write
puts a system call on the ingestion path, and a slow or failing filesystem is
exactly where it would cost the most. /api/v1/status, /metrics and the admin
page all read the same cached sample, so they cannot disagree. On a platform with
no way to measure the filesystem the series are omitted rather than reported as
zero, because a disk reported 0% full is a false all-clear.
Access and credentials
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_authz_denials_total |
counter | surface |
Authorisation denials. A spike is an attack or a policy edit somebody regrets. |
searchcode_auth_mode_open |
gauge | — | 1 when auth.mode is open: nobody is authenticated and every repository is readable by anyone who reaches the port. |
searchcode_credential_anomaly_total |
counter | — | Times an agent credential exceeded api.apiKeys.rateLimit.anomalyMultiplier times its own rolling baseline. Each is also an apikey.anomaly audit record naming the key. |
searchcode_credential_refused_total |
counter | — | Requests refused by the per-credential ceiling api.apiKeys.rateLimit.requestsPerMinute. The credential was valid and sending too fast. |
searchcode_credential_last_used_dropped_total |
counter | — | Last-used observations discarded because the write buffer was full. Deliberate, but a rising rate means the dormant-key report reads older timestamps. |
Code-host connections
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_connection_repositories |
gauge | connection |
Repositories a connection currently contributes. |
searchcode_connection_last_success_timestamp_seconds |
gauge | connection |
Unix time of the last sync that finished with no gaps. Zero if never, so guard an age alert with > 0. |
searchcode_connection_state |
gauge | connection, phase |
1 on the current phase: never_synced, syncing, ok, partial, paused or failed. |
searchcode_connection_syncs_total |
counter | connection, result |
Syncs by outcome: ok, partial, paused, failed. |
searchcode_connection_repositories_retired |
gauge | connection |
Repositories retired and not yet reclaimed: out of search, still on disk for the retention window. |
partial is the phase worth alerting on. A failure is loud everywhere else; a
connection quietly missing one organisation of three is not.
Search index
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_search_index_repositories |
gauge | state |
Repositories by index state: indexed, below_minimum, building, not_applicable, failed. The five sum to the repository count. |
searchcode_search_index_rebuilding |
gauge | — | Repositories serving an index while its replacement builds. A subset of indexed. |
searchcode_search_index_documents |
gauge | — | Documents across the indexes being served. |
searchcode_search_index_bytes |
gauge | — | Resident bytes of search index. This is what governs an estate's memory. |
searchcode_search_index_builds_total |
counter | outcome |
Build attempts: built, skipped, failed. A rising failed is search falling back to the slower path. |
searchcode_search_index_build_seconds_total |
counter | — | Total seconds in build attempts. Divide by the attempt count for the mean build cost. |
searchcode_search_index_warming |
gauge | — | 1 while a build is queued or running. Alert if it stays 1 longer than a warm should take. |
A repository without an index is still searched, correctly, by reading its files. The index makes search faster; it never decides what matches.
Analysis
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_analysis_repositories |
gauge | state |
Repositories by deep-analysis state: analysed or pending. The same reading as analysis.coverage on /api/v1/status. |
searchcode_analysis_jobs |
gauge | pool, state |
Jobs by pool (inventory, deep) and state (active, queued). Queued falling to zero is a re-analysis finishing. |
searchcode_analysis_workers |
gauge | pool |
Configured concurrency of each pool. Active equal to this is a saturated pool. |
HTTP and usage
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_http_requests_total |
counter | listener, route, method, status |
Requests by listener, route pattern, method and status class. |
searchcode_http_request_duration_seconds |
histogram | listener, route, method |
Seconds to serve one request, measured outside authentication so a slow proxy or password hash is visible. |
searchcode_searches_total |
counter | surface, result |
Searches by surface and outcome. A count, not a query: it keeps working with audit.logSearchQueries: false. |
searchcode_zero_result_searches_total |
counter | — | Searches that matched nothing and were not denied. Each is a coverage gap or a ranking gap. |
searchcode_agent_instruction_coverage_ratio |
gauge | — | Fraction of indexed repositories whose agent-instruction file mentions searchcode. |
searchcode_mcp_calls_total |
counter | tool, result |
MCP tool calls by tool and outcome. |
route is the registered route pattern, never the request path. Paths carry
repository names and file paths, so a per-path label would be cardinality driven by
your estate.
Advisory data
| Metric | Type | Labels | Meaning |
|---|---|---|---|
searchcode_advisory_database_age_days |
gauge | — | Age in days of the offline advisory data. Zero when none is loaded. |
searchcode_advisory_database_stale |
gauge | — | 1 when the advisory data is absent, is the small compiled-in seed, or is a side-loaded bundle more than 30 days old. |
An instance that has side-loaded no advisory bundle reads 1 here, which is correct: the compiled-in seed is a handful of entries and is not coverage.
The shipped dashboard and alerts
Two files are shipped with every release and built against the list above:
deploy/grafana/searchcode-dashboard.json— one dashboard, in rows for licence and metering, freshness and ingestion, capacity, access, usage and adoption, HTTP, search index and analysis.deploy/prometheus/searchcode-rules.yaml— Prometheus alerting rules. Check them withpromtool check rules deploy/prometheus/searchcode-rules.yaml.
The Helm chart carries the same two files and renders them when asked:
serviceMonitor.enabled, prometheusRule.enabled and grafanaDashboard.enabled
(a ConfigMap labelled grafana_dashboard for the Grafana sidecar). All three are
off by default. See Install with Helm.
The alerts
| Alert | Severity | Fires when |
|---|---|---|
SearchcodeLicenceExpiringSoon |
warning | The licence expires in under 30 days. |
SearchcodeLicenceExpired |
critical | The licence has expired. The instance keeps serving; indexing pauses once grace ends. |
SearchcodeUserLimitRefusals |
critical | A sign-in was refused at the user limit in the last hour. |
SearchcodeRepositoryLimitExceeded |
warning | Over the licensed repository count for a day. Nothing is blocked. |
SearchcodeIndexLagHigh |
warning | p99 index lag is over six hours for 30 minutes. |
SearchcodeRepoIndexFailing |
warning | One repository failed to ingest more than three times in an hour. |
SearchcodeFilesSkipped |
warning | More than 100 files are skipped for timeout, too_large or minified. |
SearchcodeIngestionPaused |
warning | Ingestion has been paused for an hour. window is usually your schedule; disk_full and license_expired are not. |
SearchcodeDiskHigh |
warning | dataDir is over 80% full for 30 minutes. |
SearchcodeDiskCritical |
critical | dataDir is over 95% full. Ingestion is about to pause, or has. |
SearchcodeAuthModeOpen |
warning | auth.mode is open for an hour. Usually a config meant to be changed before going live. |
SearchcodeAuthzDenialSpike |
warning | More than one denial a second on a surface for 15 minutes. |
SearchcodeAgentInstructionCoverageLow |
info | Under half of indexed repositories tell their agents about searchcode, for a week. |
SearchcodeZeroResultSearchesHigh |
info | Over 40% of searches return nothing, for six hours. |
The two licence alerts guard on > 0: a community instance and a licence with no
expiry both report zero, and without the guard both would page as long expired.
Every alert about ingestion says the same thing in its description, and it is the property to remember when one fires: serving is unaffected. A failed fetch, a paused scheduler or a full disk stops the estate growing, never answering.
Health endpoints
All of these are on the API listener (server.listen) and need no credential,
because a probe runs before any credential exists.
| Endpoint | Answers | Use it for |
|---|---|---|
/livez |
200 ok whenever the process can answer. Touches no storage. |
Liveness. |
/readyz |
200 once the server is accepting traffic; not ready while starting or draining. | Readiness. |
/startupz |
200 once the process has booted, and never flips back. | Startup probe. |
/version |
The build identity. | Support tickets. |
/api/status |
This process: build, readiness, start time, auth mode, and which configuration file it loaded. Nothing about the estate. | Quick checks and searchcode doctor. |
Two properties look like oversights and are not:
/readyzdoes not wait for repositories. The instance is ready as soon as it can serve, and it serves while the estate is still cloning, reporting what is not ready yet as a coverage gap. Gating readiness on ingestion would mean a large estate never passes its probe and the orchestrator restarts it forever./liveztouches no storage. A liveness probe that reads a slow disk restarts the process and makes the disk slower.
The estate itself — per-repository state, analysis coverage, index coverage, the
licence and the meters — is on /api/v1/status, which authenticates.
Tracing
OTLP tracing is off by default. It is one of the three things that can make the process open an outbound connection, and it points at your own collector, never at a vendor.
| Setting | Default | Reload |
|---|---|---|
observability.tracing.enabled |
false |
restart |
observability.tracing.endpoint |
— | restart |
observability.tracing.sampleRate |
1.0 |
reloads |
Traces are sent as OTLP over HTTP with a JSON payload, posted to /v1/traces
on the endpoint (appended when the endpoint names only a host). Every span carries
the same request identifier as the audit record and the log lines for that request,
so one request can be followed across all three.
enabled and endpoint are restart-only on purpose: opening an outbound
connection is not something an edited file should do a second later without the
deployment noticing. sampleRate adjusts how much of an already-permitted stream
is sent, so it reloads. A disabled tracer installs nothing at all; there is no
half-armed state where a collector is dialled to find out it was not wanted.
Trying it locally
deploy/observability/ is a separate Docker Compose project that brings up an
instance with Prometheus, Grafana and Jaeger already wired to it:
cd deploy/observability
docker compose up -d --build
Grafana is on http://localhost:13000 with the shipped dashboard loaded,
Prometheus on http://localhost:19091 with the shipped alert rules, Jaeger on
http://localhost:16686, and the instance's UI on http://localhost:18081. The
shipped dashboard and rules are mounted rather than copied, so what renders is
exactly what you get. It uses its own ports so it does not collide with an instance
already running on the usual ones. It is for looking, not for production.
Related
- Sizing for what the capacity numbers mean for hardware.
- searchcode doctor for diagnosing an ingestion failure an alert reported.
- Log codes for the code on every log line.