TRUSTIVAN exposes four operational surfaces for three different audiences. Which one you point a given tool at matters more than it looks.

Liveness and readiness are not the same question

They are separate endpoints because they answer separate questions, and conflating them causes an outage rather than preventing one.

/health checks nothing, deliberately

It returns 200 if the process is running and able to serve HTTP. It does not touch the database, Redis, or the scanner. That is not laziness. A liveness probe that consults the database restarts every application instance during a database failover, converting a recoverable dependency outage into a total one — and each restarted instance comes back to the same unavailable database and is killed again. Liveness answers “is this process wedged”; the correct response to a wedged process is a restart, and the correct response to an unreachable database is not.

/ready checks the dependencies

Returns 200 when the database and cache are reachable, and 503 when either is not, with a body naming which component is down:
A 503 takes the instance out of the load balancer’s rotation without killing it, so it rejoins by itself when the dependency recovers. Note what the body does not contain. The driver’s own error is discarded, not rendered — it names hosts, ports, usernames and versions, and this endpoint is unauthenticated. Configure your orchestrator accordingly: liveness on /health, readiness on /ready. Pointing liveness at /ready reintroduces exactly the failure mode /health exists to avoid.

/api/v1/system/status

The authenticated view, and the one that tells a user something the probes cannot: whether the advisory data behind their results is trustworthy. Everything it reports is the caller’s own tenant’s — queue depth, scan counts, overdue schedules. A global queue depth would be more useful to an operator and would also tell every customer how much work every other customer is doing, which is a business disclosure wearing an operations badge. Global figures belong in metrics, behind an operator’s own authentication. A tenant’s status is reported unhealthy when the advisory data is not trustworthy. Reporting the process healthy while it can only produce results it has to caveat would make the endpoint agree with the failure it exists to catch. See Vulnerability database. The scanning engine is never named in the payload. A CI guard fails the build if its identity reaches the API layer, and a test asserts the same thing about the response body.

Metrics

Why it is a separate, private listener

Metrics are cross-tenant aggregates: how many scans run, how many findings exist, how often authentication fails. Every one of those is a fact about the business rather than about a tenant, so none of them belongs on a customer-facing API even behind an authorisation check. TRUSTIVAN has no operator plane, and inventing one purely to guard a route would be a worse answer than not exposing the route publicly. So the metrics live on their own listener, bound to loopback. Binding this to 0.0.0.0 publishes those aggregates to anything that can route to the host. If you need remote scraping, route to it deliberately — through a sidecar, an overlay network, or an authenticating proxy — rather than by widening the bind address.

Gauges are refreshed on a timer, not computed on scrape

Values are refreshed every 30 seconds by a periodic task rather than queried during a scrape. A collector that queries the database while Prometheus is scraping turns an unreachable database into a hung scrape, so monitoring goes blind at exactly the moment it is needed — and it lets anyone who can reach the port generate database load. The cost is that a gauge can be up to 30 seconds behind, which is the right trade for a number you alert on over minutes.

Label cardinality is a hard constraint

A Prometheus label with unbounded values is not a slow query, it is an out-of-memory: every series is retained. Route labels therefore use the registered pattern (/api/v1/findings/:id), which is the difference between a dozen series and one per finding ever viewed. Unmatched requests collapse to a single label, because a 404 sweep is exactly the traffic that would otherwise mint a series per URL an attacker tries. A test asserts that no metric carries a tenant, user, finding, asset or image label. If you add a metric, that constraint applies to you too.

The metric worth alerting on

trustowl_scheduler_lag_seconds. It is the one that says the scheduler has stopped, which a queue depth of zero cannot: a dead scheduler produces no work, so every other figure then looks healthy. An empty queue and a broken scheduler are otherwise indistinguishable. Alongside it, worth watching:
  • Queue depth trending up while worker count is flat — add worker replicas, see Scaling.
  • Scan failure rate, which distinguishes a registry problem from a capacity problem.
  • Anything indicating the vulnerability database is ageing past VULN_DB_MAX_AGE_HOURS, which means blocked egress, a stale mirror, or a cache directory that is not actually persisted.

Tracing

Spans are created throughout both the request and the scan pipeline — HTTP handler, service, repository, scanner engine, job worker — regardless of this setting: leaving it unset means nothing is ever exported, not that nothing is instrumented. There is no separate TRACING_ENABLED flag; the endpoint IS the switch, so there is exactly one way to misconfigure this (a typo’d host) rather than two. Every span carries tenant.id once a request or scan is attributed to one, and a scan’s spans additionally carry scan.id. Never a secret, customer code, or scanned content — the same rule the metrics labels above follow, for the same reason: a trace is retained and read by whoever operates the collector, which for a self-hosted deployment is a third party more often than not.

Logs

Both processes log structured events. Three lines worth recognising: Failure messages stored on a scan are sanitised before they are written, because they are returned by the API: registry client errors routinely embed the URL they were fetching, and a registry URL can carry a bearer token in a query parameter. Anything matching a marker is replaced wholesale rather than trimmed — trimming invites a near-miss that leaks the tail of a token — control characters are stripped so an error cannot forge extra log lines, and the message is capped at 300 characters.

What is not here yet

Alerting and the Grafana dashboard ship in infra/monitoring/ in the repository (alerts.yml, dashboard.json) — see Service level objectives for the thresholds they encode.

Next