Skip to content

Health & metrics

Lasagna ships its own health probes and Prometheus exporter so a freshly configured app can satisfy a Kubernetes deployment manifest or a Grafana scrape config without pulling another dependency.

Endpoints

Mount the routes from start/routes.ts:

ts
import { multitenancyRoutes } from '@adonisjs-lasagna/saas-tenancy/health'

multitenancyRoutes()                              // root paths
multitenancyRoutes({ prefix: '/internal' })        // /internal/livez, etc.
multitenancyRoutes({ metrics: false })             // skip /metrics

Four endpoints are exposed by default:

PathPurposeStatus code
GET /livezLiveness — process is up. Never touches DB or Redis.Always 200 while the event loop is alive
GET /readyzReadiness — every registered check passes200 when ok or degraded; 503 when fail
GET /healthzSame data as /readyz, full JSON report200 / 503
GET /metricsPrometheus text exposition (snapshot of tenants, circuits, queues, uptime)200

Each one is opt-in via multitenancyRoutes({ health: false, metrics: false }) so you can host them under your own auth middleware:

ts
import router from '@adonisjs/core/services/router'
router.group(() => multitenancyRoutes()).prefix('/_ops').use([adminAuth])

Built-in checks

Lasagna registers no checks by default — they're explicit so you can control timeouts, dependencies, and ordering. The package exports three ready-to-use checks:

ts
import app from '@adonisjs/core/services/app'
import {
  HealthService,
  backofficeDbCheck,
  redisCheck,
  makeCircuitBreakerCheck,
} from '@adonisjs-lasagna/saas-tenancy/health'
import { CircuitBreakerService } from '@adonisjs-lasagna/saas-tenancy/services'

const health = await app.container.make(HealthService)
const breaker = await app.container.make(CircuitBreakerService)

health.addCheck('backoffice_db', backofficeDbCheck)
health.addCheck('redis', redisCheck)
health.addCheck(
  'circuits',
  makeCircuitBreakerCheck(() => breaker.allMetrics())
)
CheckWhat it doesWhen it fails
backofficeDbCheckSELECT 1 against the backoffice connectionDB unreachable, credentials wrong
redisCheckPING against the default RedisRedis down or misconfigured
makeCircuitBreakerCheck(fn)Reports fail if any tenant circuit is OPENOne or more tenant DBs are tripped
billingHealthCheckPings the Stripe API + asserts webhooks are flowing (when active subs exist)API unreachable, webhook secret missing, or last processed > 15 min

When billing is enabled, register billingHealthCheck alongside the others. The check skips quietly when config.billing is unset, so it's safe to register unconditionally:

ts
import { billingHealthCheck } from '@adonisjs-lasagna/saas-tenancy/health'

health.addCheck('billing', billingHealthCheck)

Thresholds (SLOW_API_THRESHOLD_MS = 3000, stale-warn 5 min, fail 15 min) are documented in Billing satellite#health. The exposed SLOW_API_THRESHOLD_MS constant is importable for tests that need to drive the degraded branch deterministically.

The Stripe webhook receiver itself is mounted via a separate helper (it's a route, not a check):

ts
import { multitenancyBillingRoutes } from '@adonisjs-lasagna/saas-tenancy/health'

multitenancyBillingRoutes()

Custom checks

Any function returning Promise<CheckResult> | CheckResult works:

ts
import type { HealthCheckFn } from '@adonisjs-lasagna/saas-tenancy/health'

const customCheck: HealthCheckFn = async () => {
  try {
    await someExternalDependency.ping()
    return { status: 'pass', durationMs: 0 }
  } catch (error: any) {
    return { status: 'fail', durationMs: 0, message: error.message }
  }
}

health.addCheck('custom_dependency', customCheck)

HealthService enforces a 2-second timeout per check (Promise.race) and never lets a slow check block the readiness response. Failures and timeouts both surface as status: 'fail' with the error message in message.

The aggregate /readyz status is:

  • ok — every check passed (or no checks registered)
  • degraded — at least one passed, at least one failed (200 so Kubernetes keeps routing traffic but the dashboard reflects the issue)
  • fail — every check failed (503, traffic is removed)

Prometheus metrics

/metrics returns text-exposition format with these series, no external prom-client dependency:

multitenancy_tenants_total                      gauge
multitenancy_tenants_by_status{status="..."}    gauge
multitenancy_circuit_state{tenant_id="..."}     gauge   0=CLOSED 1=HALF_OPEN 2=OPEN
multitenancy_circuit_failures_total{...}        counter
multitenancy_circuit_successes_total{...}       counter
multitenancy_queue_jobs{tenant_id,queue,state}  gauge   state ∈ waiting,active,completed,failed,delayed
multitenancy_uptime_seconds                     gauge

A typical Prometheus scrape config:

yaml
- job_name: lasagna
  scrape_interval: 30s
  metrics_path: /metrics
  static_configs:
    - targets: ['app.internal:3333']

Building snapshots programmatically

collectSnapshot() and renderPrometheus() are exported for cases where you want to push metrics elsewhere (statsd bridge, scheduled job, custom dashboard endpoint):

ts
import { collectSnapshot, renderPrometheus } from '@adonisjs-lasagna/saas-tenancy/health'

const snapshot = await collectSnapshot()
const text = renderPrometheus(snapshot)
await fetch('https://my-collector.internal/ingest', { method: 'POST', body: text })

MetricsSnapshot is also exported as a type when you want to derive your own format.

Kubernetes probe example

yaml
livenessProbe:
  httpGet: { path: /livez, port: 3333 }
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet: { path: /readyz, port: 3333 }
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

/livez is intentionally cheap so the kubelet doesn't kill a pod because Postgres hiccupped — that's /readyz's job.

Was this page helpful?

Released under the MIT License.