OpsAPI runs inside nginx workers, so the usual “attach a profiler to the app server” doesn’t apply. What you get instead is a small set of endpoints and records built for the people who run it: three health endpoints with distinct jobs, Prometheus metrics from every worker, one error envelope with a correlation id, and tables that record every sign-in and every user action.

Three health endpoints, three jobs

Which caller uses which health endpointkubeletstartup + livenesskubeletreadinessUptime checkspeople, dashboards/liveprocess is up/readycan serve now/health?detailed=truealways 200 while nginx runs; never touches the database503 if: database down · a plugin failed to load ·chat requires Redis and the subscriber isn't connectedhealthy / degraded (200) / unhealthy (503); detailed addsRedis, object storage, disk, migrations, memory
Point the liveness probe at /live, not /ready: a database blip should take a pod out of rotation, not restart it.
$ curl -s https://identity.demo.opsapi.org/ready
{"ready":true,"version":"1.0.281","timestamp":1793195573}

$ curl -s https://identity.demo.opsapi.org/health
{"status":"degraded","services":{"database":{"status":"healthy"},
 "minio":{"status":"unhealthy","error":"MinIO not reachable on any endpoint"}}, ...}

“Degraded” still returns 200: the demo has no object storage, which matters for uploads but not for sign-in. Use /health for humans and dashboards and /ready for anything that routes traffic.

Metrics

Every worker records into a shared dictionary through nginx-lua-prometheus, and GET /metrics exposes the lot. The sample Prometheus config scrapes the API every five seconds.

Metrics flow from the API pods to Prometheus, Grafana and alertsAPI podsGET /metricsnginx-lua-prometheusscrapePrometheusscrape every 5salert rulesGrafanadashboardsAlertsto your notifierReporting viewsread-only DB role
The Grafana dashboards read Prometheus; reports about users read a set of database views through a read-only role that can't see passwords or tokens.
MetricLabelsUse it for
nginx_http_requests_totalhost, status, method, endpointTraffic and error ratio per route
nginx_http_request_duration_secondshost, method, endpointLatency percentiles
nginx_http_5xx_errors_totalhost, status, endpointThe first alert to set
api_auth_failures_totalreasonCredential stuffing, broken clients
api_database_query_duration_secondsoperationSlow queries
nginx_http_rate_limited_totalhost, ipWho is hitting the limits
opsapi_auth_events_totalevent, result, methodSign-in volume by outcome
opsapi_activity_dropped_totalreasonActivity rows lost under load (should be 0)

The repo’s alert rules include high error rate, slow responses, slow database queries, high authentication failure rate and request-rate spikes. Start with the 5xx rate and authentication failures; add the rest once you know your baseline.

One error envelope, with a correlation id

Route handlers return errors through Errors.response(self, CODE, opts). The helper resolves the reader’s locale, looks the code up in the message catalog, writes one sanitised row to error_occurrences and returns the same shape every time:

HTTP/1.1 401
{"error":{
  "code":"AUTH_INVALID_CREDENTIALS",
  "title":"Invalid credentials",
  "message":"The email or password you entered is incorrect.",
  "category":"error",
  "correlation_id":"627512de-3867-4a88-9606-bc75ad743d1d",
  "occurrence_uuid":"43555f92-2f0c-45d1-a342-c5c1d8219212"
}}

If the caller sends X-Request-ID, that becomes the correlation id, so one id can follow a request from the edge through the API and into the database row. Support asks the user for the id; the occurrence row has the endpoint, method, status and request context with passwords redacted.

How a route handler produces a catalog-backed error responseRoute handlerErrors.response()Catalog lookupcode + locale, cachederror_occurrencesone sanitised rowEnvelopecode, message,correlation_idCatalog missingtables not createdBare SYSTEM_500for every errorthe bug we fixed
The normal path along the top. Along the bottom, what happened when the catalog tables didn't exist.

Lesson: anything every request needs belongs in the core

The catalog tables were created by the tax module’s migrations, but the error helper is used by every module. A deployment without tax, such as the minimal identity build behind the demo, had no catalog. Every error, including an ordinary wrong password, came back as a generic SYSTEM_500 “Something went wrong”, and the real cause (a failed catalog read, then a failed occurrence insert) only showed in the nginx debug log. The fix moved those three migrations into the core set; deployments that already had them don’t run them again.

When you build a slim deployment, test an error path, not just a success path: a wrong password should be a 401 with a readable message.

Who signed in, and what they did

TableHoldsKept for
auth_eventsEvery sign-in success or failure, 2FA, refresh, sign-out, password change1 year by default
user_login_statsOne row per user: last sign-in, count, consecutive failures, last seenLife of the account
user_activityWhat signed-in users did, partitioned by month90 days; whole partitions dropped
user_activity_dailyPer workspace, day, user and action countersFor the in-app Activity page

Login details are kept out of the users table on purpose: user rows are returned by many APIs, and new columns there would leak IP addresses. Deleting a user deletes their activity and auth events whatever path deleted them, which covers erasure requests.

Events that survive a broker outage

Audit and business events go to Kafka when it’s configured. If the producer can’t reach a broker, the event is written to a kafka_outbox table instead, and the consumer side works through the outbox and marks rows sent. A broker outage delays events; it doesn’t lose them, and it never fails the business write that produced them.

Kafka producer with a database outbox fallbackBusiness writeKafkaProducer.sendbroker upbroker downKafka topickafka_outboxstatus, next_retry_atConsumerretries the outboxmarks rows sent
The outbox turns an outage into a delay.

A starting runbook

  1. Pods not ready: read /ready from inside the pod. The reason names the failing dependency.
  2. Users report “something went wrong”: ask for the correlation id and find the occurrence row.
  3. Error rate up: break nginx_http_5xx_errors_total down by endpoint before reading logs.
  4. Sign-in complaints: auth_events for that user shows each attempt and why it failed.
  5. Events missing downstream: count unsent rows in kafka_outbox.