Observability
Timing spans through observability.onSpan, built-in Prometheus metrics behind a bearer token, the health endpoint, request IDs, and what to alert on.
An identity system is on the path of every request, so you need to know when it slows down, starts refusing people, or stops delivering mail. Better IAM reports on itself in three ways: a span for every unit of work, which you can send to any metrics library or tracer; an optional built-in Prometheus collector fed by those spans; and an unauthenticated health endpoint for load balancers. None of them needs an extra dependency.
Spans
A span is one timed unit of work: a provisioning operation, an authorization check or
, an authentication call, or an HTTP request. Spans let you chart IAM
latency and outcomes next to your own service. observability.onSpan receives each one after it completes.
| Kind | Named by | Covers |
|---|---|---|
operation | The action, such as iam:identities:create | Every provisioning operation. |
authorize, listAccessible | The action asked about | Authorization checks and reverse queries (which resources the caller may act on). |
authorizeMany | authorizeMany | Batch checks. |
auth | The authentication method, such as signIn | Authentication calls. |
http | The route, such as identities/create | Requests through the HTTP handler, with status and requestId. |
Prop
Type
The handler must be synchronous and cheap. Exceptions it throws are ignored, so observability can never affect a request. Feed spans to a metrics library or tracer:
observability: {
onSpan(span) {
latency.observe({ kind: span.kind, name: span.name, outcome: span.outcome }, span.durationMs);
if (span.outcome === 'denied') denials.inc({ code: span.code ?? '' });
},
},Prometheus metrics
When you already run Prometheus (or anything that scrapes its text format), you can skip wiring onSpan yourself.
observability.metrics keeps Prometheus-style counters and histograms from the spans, in memory, without any
extra dependency:
| Metric | Type | Labels |
|---|---|---|
better_iam_spans_total | counter | kind, name, outcome, code |
better_iam_span_duration_seconds | histogram | kind (configurable buckets) |
better_iam_http_requests_total | counter | path, status |
better_iam_outbox_messages | gauge (with gauges: true) | state: pending or failed |
better_iam_sessions_live | gauge (with gauges: true) | kind: user, api-key, role, or session-token |
The collector keeps no tenant labels. Names the caller controls (unknown
routes, rejected input, made-up actions) collapse into (invalid) or (unknown), and series beyond maxSeries
(2000 per metric) collapse into (other), so a hostile client cannot grow memory.
Set a bearerToken and point a scraper at GET /api/iam/metrics (under your basePath) with
Authorization: Bearer followed by the token. Other requests get 401. The token is compared in constant time;
doctor warns when it is shorter than 24 characters.
observability: { metrics: { bearerToken: process.env.METRICS_TOKEN, gauges: true } },scrape_configs:
- job_name: better-iam
metrics_path: /api/iam/metrics
scheme: https
authorization:
type: Bearer
credentials_file: /etc/prometheus/better-iam-token
static_configs:
- targets: ['identity.example.com']With gauges: true, each scrape also reads the outbox and session collections to report the gauges at that
moment. Leave it off for very large deployments.
Default histogram buckets are 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, and 10 seconds. With both
onSpan and metrics set, every span reaches both.
Health checks
GET /api/iam/health (under your basePath) is always available and needs no credential. It performs one
database read by primary key, whose cost does not grow with the number of tenants, and reveals nothing else about
the deployment:
{ "status": "ok", "database": "ok", "latencyMs": 3, "time": 1790000000000 }When the store fails, it answers 503 with { "status": "unavailable", "database": "error", "time": ... }. Use it
for load-balancer and orchestrator probes.
Request IDs
Request IDs let you follow one request from your gateway's logs into IAM's spans. A plain X-Request-Id header (letters, digits, and ._:-, at most 128 characters) is echoed on the response,
success or error, and appears as requestId on the request's http span, so IAM latency and outcomes join your
gateway's request logs. Other values are ignored. Browser clients on trusted origins can read the header through
CORS.
What to watch
Observe these signals and alert on the ones that matter to you:
- authentication failures and rate-limit responses (
deniedspans withRATE_LIMITED,IP_NOT_ALLOWED,IP_BLOCKED, orSESSION_NETWORK_MISMATCH); - denied and root-override audit events;
- database busy errors (
STORAGE_BUSY, 503); - outbox retries and abandoned deliveries (
better_iam_outbox_messages,doctor'soutbox-*findings); - token issuance and revocation.
Audit events are the other half of monitoring. Subscribe a or an in-process
subscriber to
auth:signin:fail for brute-force alerting, to binding:* for elevation, and filter by outcomes: ['deny'] to
feed a security information and event management system (SIEM). See Webhooks and
the observability recipes.
Public errors never expose SQL or raw protocol assertions, so error codes are safe to log and count. For storage
latency, wrap the adapter with instrumentStore (see
Database operations).
Next steps
Better IAM is created by Sean Filimon
Last updated
Scheduled jobs
The worker jobs a deployment schedules (outbox, purge, sweep, reconcile, digest, remind, certifications, invariants, audit archive) with cadences and results.
Security model
Better IAM's trust boundaries, root authority and delegation, authentication guarantees, external identity rules, and what stays with operators.