Digging Deeper
Monitor
Introduction
JCC Express MVC ships with an opt-in observability subsystem. It collects request, runtime, error, database, and queue metrics, runs configurable health checks, exposes a Prometheus endpoint and a JSON dashboard, and can fire email alerts when thresholds are breached.
Core capabilities:
- Request/HTTP metrics — count, latency histograms, status codes, slow-request detection
- System/runtime metrics — RSS, heap, CPU%, event-loop lag, uptime
- Error tracking — captures Express errors,
uncaughtException,unhandledRejection - DB query timing & queue job stats (wired via the active ORM / queue)
- Pluggable health checks — memory, disk, cache, database, queue, custom
- Multiple exporters — Prometheus text, JSON snapshot, structured logs, HTTP push (Datadog/PushGateway)
- Threshold-based alerts with email and log notifiers (uses the framework
Mailfacade) - Built-in HTML dashboard (vanilla JS, no build step) — and publishable page views in three view stacks: jsBlade (default), React + Inertia, or Vue 3 + Inertia
- Static
Monitorfacade for ergonomic access from anywhere
The system is opt-in — nothing runs until you register MonitorServiceProvider.
Installation
The fastest way to enable Monitor is the publish artisan command. It is idempotent — safe to run repeatedly — and:
- Creates
app/Config/monitor.ts(config — skipped if it already exists; passforce=trueto overwrite). - Creates
routes/monitor.ts(your editable route definitions, using the frameworkRouteAPI). - Creates the page views for the selected view stack (see Dashboard views below):
jsblade(default) →resources/views/monitor/*.blade.html+resources/views/partials/monitor/*.blade.htmlreact→resources/js/Pages/Monitor/*.jsx(React + Inertia page components)vue→resources/js/Pages/Monitor/*.vue(Vue 3 + Inertia page components)
- Wires
monitorintoapp/Config/index.ts. - Wires
MonitorServiceProviderintobootstrap/providers.ts. - Appends
require("./monitor")to the bottom ofroutes/web.tsso your published routes are loaded.
After publishing, restart your dev server. The system is live at /_monitor. (For react / vue, also make sure the matching Inertia adapter is configured and rebuild client assets — npm run watch.)
What "publish" gives you
routes/monitor.ts— owned by your app. Uses the framework'sRouteAPI (Route.prefix(...).controller(MonitorController).group(...)). Change the prefix, attach middleware (auth, IP allowlist, rate-limit), or swap the controller.- The page views for the chosen stack — owned by your app, fully customizable:
jsblade→resources/views/monitor/*.blade.html(each view extendslayout.app) plus sharedresources/views/partials/monitor/{sidebar,top-header,empty-state}.blade.html. The only placeholder is{!! basePath !!}(raw output, since it lands inside a JS string literal).react/vue→resources/js/Pages/Monitor/*— Inertia page components (Dashboard,Requests, …) plus their shared helpers (MonitorLayout,Sidebar,ui/Panel/DataTable/ badges /format). They live together so the relative imports keep resolving.
MonitorController— used by the published route file. Lives in the framework so future updates flow through automatically. ItsrenderPageresolves the view in this order: Inertia page (resources/js/Pages/Monitor/<Page>.{jsx,tsx,vue}+res.inertiaavailable) → published jsBlade template (resources/views/monitor/<page>.blade.html) → bundled single-page HTML. So whichever stack you published just works, and an unpublished app still gets the built-in dashboard.
When the user runs publish, the provider detects routes/monitor.ts at boot and skips its own auto-mount. Without publish, the provider registers the same endpoints itself — also using the framework's Route API, not raw Express — so the routing pipeline (middleware groups, controller dispatch, templating) is identical in both modes.
Dashboard views
Monitor ships its dashboard in three view stacks; pick the one your app uses with the optional view argument to publish Monitor:
| View | Argument | Output location | Renders via |
|---|---|---|---|
jsblade | (default) or jsblade/blade | resources/views/monitor/ | jsBlade engine (res.render) |
react | react (alias jsx) | resources/js/Pages/Monitor/ | React + Inertia (res.inertia) |
vue | vue (alias vuejs) | resources/js/Pages/Monitor/ | Vue 3 + Inertia (res.inertia) |
Notes:
- The view argument only applies to the
Monitorpublishable. Config / route / provider wiring is the same regardless of stack. - React/Vue pages are plain components — page props (
title,page,basePath,items/stats/recentRequests/recentErrors) are passed straight through byMonitorController. - The shared
DataTablepaginates client-side — a Prev/Next footer appears once a section has more than 25 rows. PasspageSize={n}(React) /:page-size="n"(Vue) to change it, or0to disable. - An unknown view name warns and falls back to
jsblade. - If you publish more than one stack, the controller prefers Inertia pages over jsBlade — delete the set you don't want.
- Nothing published yet? The bundled
UI/dashboard.html(vanilla JS, no build step) is still served, so the dashboard works out of the box.
Manual setup (alternative)
If you'd rather wire it yourself:
1. Add the provider
Edit bootstrap/providers.ts:
2. (Optional) Wire the config file
Edit app/Config/index.ts:
If you skip this step, the provider falls back to defaultMonitorConfig — the system still works.
Configuration and bootstrapping
Monitor is configured in app/Config/monitor.ts and merged in app/Config/index.ts. At runtime, MonitorServiceProvider reads app.config.monitor, deep-merges it onto defaultMonitorConfig, and instantiates the manager.
3. Configuration shape
You can call MonitorManager.getInstance(customConfig) directly when writing tests — see Testing.
Pages and endpoints
Once the provider is booted, the following endpoints are available under the configured basePath (default /_monitor).
Pages (Tailwind)
Inspired by Laravel Nightwatch — each section is its own page with a shared sidebar:
| Path | Section | Component (react/vue) |
|---|---|---|
GET /_monitor | Overview | Monitor/Dashboard |
GET /_monitor/requests | Requests | Monitor/Requests |
GET /_monitor/jobs | Jobs | Monitor/Jobs |
GET /_monitor/commands | Commands | Monitor/Commands |
GET /_monitor/scheduled | Scheduled Tasks | Monitor/Scheduled |
GET /_monitor/exceptions | Exceptions | Monitor/Exceptions |
GET /_monitor/queries | Queries | Monitor/Queries |
GET /_monitor/notifications | Notifications | Monitor/Notifications |
GET /_monitor/mail | Monitor/Mail | |
GET /_monitor/cache | Cache | Monitor/Cache |
GET /_monitor/outgoing | Outgoing Requests | Monitor/Outgoing |
How a page renders depends on what you published (see Dashboard views):
- jsBlade (default) — each page is a Blade view that extends
layout.appand uses Tailwind, published intoresources/views/monitor/+resources/views/partials/monitor/. TheMonitorControllercallsres.render("monitor/<page>", { ... })through the framework's jsBlade engine. - React / Vue — each page is an Inertia component published into
resources/js/Pages/Monitor/. TheMonitorControllercallsres.inertia("Monitor/<Page>", { ... }). - Nothing published — the bundled single-page
UI/dashboard.htmlis served.
All three receive the same props (title, page, basePath, plus the section's data).
API endpoints
| Path | Purpose |
|---|---|
GET /_monitor/health | Liveness probe — cheap, returns 200 while the process is up |
GET /_monitor/ready | Readiness — runs all registered health checks (200 / 503) |
GET /_monitor/metrics | Prometheus exposition format (text/plain; version=0.0.4) |
GET /_monitor/json | JSON snapshot — metrics + health + recent buffers |
The /_monitor path is excluded from request metric collection so the dashboard does not measure itself.
Auto-wired vs. manual collectors
| Section | Data source |
|---|---|
| Requests | Auto — MonitorMiddleware records every request |
| Exceptions | Auto — ErrorCollector + MonitorErrorMiddleware |
| Queries | Auto when collectors.database.enabled — ORM query hooks |
| Jobs | Auto when collectors.queue.enabled — Queue records job events |
| Commands | Auto when collectors.commands.enabled — console kernel records runs |
| Scheduled Tasks | Auto when collectors.schedule.enabled — scheduler records runs |
Auto when collectors.mail.enabled — wraps the Mail facade | |
| Cache | Auto when collectors.cache.enabled — wraps Cache.get/put/forget |
| Outgoing | Auto when collectors.outgoing.enabled — axios + fetch patch |
| Logs | Auto when collectors.logs.enabled — framework logger integration |
| Notifications | Manual — Monitor.recordNotification({ ... }) after dispatch |
The Monitor facade
A static Monitor facade gives you typed, ergonomic access without resolving from the container:
Each metric helper returns the underlying primitive, so you can store the reference and reuse it:
Metric primitives
Three primitive types are supported, all label-aware and Prometheus-compatible.
Counter
Monotonically increasing. Throws if you pass a negative amount.
Gauge
Arbitrary values that can go up or down — memory usage, active connections, queue depth.
Histogram
Observes a distribution and emits sum, count, and bucket counters compatible with Prometheus' histogram_quantile().
If buckets is omitted, sane HTTP-latency defaults are used: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000].
Collectors
Collectors populate metrics automatically. They are enabled per-flag in config.collectors.
| Collector | Enabled by default | Notes |
|---|---|---|
http | yes | Wired through MonitorMiddleware. Records count, latency, status code. |
system | yes | Periodic sampler — RSS, heap, CPU%, event-loop lag, uptime. |
errors | yes | Captures uncaughtException and unhandledRejection plus express errors. |
database | no | Requires manual record() / track() from your ORM hook (see below). |
queue | no | Requires track() against the queue's events emitter. |
Database integration
The framework supports multiple ORMs (JCC Eloquent/Knex, Sequelize, Mongoose, Prisma, TypeORM). The collector exposes a generic record() API.
When MonitorServiceProvider is registered and collectors.database.enabled is true, Knex query hooks are attached automatically only when DB_ORM=jcc. For Prisma, TypeORM, Sequelize, or Mongoose, wire record() yourself in a service provider (example below uses Knex):
For Sequelize, use the benchmark: true option and logging callback. For Mongoose, use mongoose.set("debug", fn).
Queue integration
The framework's Queue class exposes a public events EventEmitter. Tell the collector to subscribe:
Expected event payloads: "job:processing" | "job:completed" | "job:failed" with shape { job, durationMs?, attempts?, error? }. If your worker doesn't emit these, you can record manually:
Health checks
Health checks back the /ready endpoint and surface in the dashboard. Built-in checks are toggled via config.healthChecks; custom ones use Monitor.addHealthCheck().
A HealthCheckResult is { healthy: boolean; message?: string; details?: object; durationMs?: number }.
Exporters
Exporters present metrics to the outside world. Multiple exporters can be active at once.
Prometheus (/metrics)
Renders the registry in Prometheus text format with # HELP, # TYPE, label sets, and _bucket / _sum / _count lines for histograms. Pair with Grafana and prometheus.yml:
JSON (/json)
Returns the full snapshot — metrics, health, recent.{requests,errors,jobs,queries}, system, uptimeSec. Used by the built-in dashboard and any external consumer that prefers JSON.
Log
Emits a structured JSON line (one per snapshot) on a configurable interval. Defaults to stdout; supply file to append to disk. Compatible with Loki, ELK, Datadog log pipelines.
Push
Periodically POSTs the snapshot to an HTTP endpoint. Useful for Datadog, a Prometheus PushGateway (set format: "prometheus"), or any custom collector.
Push and log exporters never throw on failure — observability glitches must not crash the host application.
Storage drivers
Monitor's recent-* record streams (requests, errors, jobs, queries, commands, scheduled, notifications, mail, cache ops, outgoing, logs) live in capped in-memory ring buffers. A pluggable storage driver mirrors writes to a backing store so records survive restarts (and, for redis, can be shared across workers). Reads on the hot path stay synchronous — the driver hydrates the buffers on boot.
Pick a driver in app/Config/monitor.ts:
| Driver | Backing store | Notes |
|---|---|---|
memory | None (the ring buffers themselves) | Default. Fastest. Records vanish on restart. |
file | JSON-lines files at <path>/<type>.jsonl | Append-only; rewritten to the trailing capacity lines once a file grows past 1.5× capacity. |
redis | Lists at <prefix>:<type>, capped with LTRIM | Uses ioredis. Records can be inspected from multiple workers when they share an instance. |
database | Single table monitor_records (id, type, payload, created_at) | Uses the framework's jcc-eloquent DB. Run bun artisanNode make:monitor-table to create the table. |
All drivers respect the per-type buffers.recentX capacities — older records are evicted (file: trimmed, redis: LTRIM, database: id-cutoff DELETE every ~capacity/4 inserts).
Behaviour
- Reads —
m.recentRequests.toArray(),Monitor.snapshot(), the dashboard, etc., always read from the in-memory ring buffers; the driver is never on the hot path. - Writes — every
Monitor.recordX(...)writes to the ring buffer and fire-and-forgets astorage.persist(type, rec). Persist errors are swallowed — observability glitches must not crash the host application. - Hydrate — on
manager.start(), the driver loads up tocapacitymost-recent records of each type back into the buffers (oldest → newest order). - Teardown —
manager.stop()callsstorage.close?.()(closes the Redis client; no-op for the others).
Database driver
Create the table once:
This writes database/migrations/<timestamp>_monitor_records_table.ts and runs it. The schema:
You can swap the table name via storage.database.table if you need a non-default name (multiple apps sharing one database, etc.).
Custom driver
Implement the MonitorStorage interface and pass an instance directly:
(For most apps the four built-in drivers cover the use cases.)
Alerts and notifications
Alerts evaluate registered rules on a tick (default 30s) against the current snapshot and dispatch to one or more notifier channels.
Defining a rule
Severity is one of "info" | "warning" | "critical". The email notifier filters by severityFloor.
Email notifications
Email alerts use the framework's existing Mail facade — no extra setup required. Configure under alerts.notifications.email:
When a rule fires, the notifier sends a plain-text email per recipient with rule name, severity, metric/value/threshold, and timestamp. Email send failures are swallowed so alerting never crashes the host.
Log notifier
Always registered as a fallback. Writes a single-line JSON record to stdout (type: "monitor.alert") — easy to scrape from container logs without configuring email.
Custom notifiers
Implement Notifier and register:
Dashboard
GET /_monitor renders the Overview page using whichever view stack you published (see Dashboard views). It surfaces:
- Top stats — total requests, 5xx errors, average latency, uptime, RSS, heap
- Health — each registered check with status dot, message, duration
- Recent requests, errors, DB queries, jobs (capped by
buffers.*)
Out of the box (nothing published), it's a self-contained HTML page that polls /json every 5 seconds — plain HTML + vanilla JS, no React, no Inertia, no build step. Publish jsblade, react, or vue views to take ownership of the markup. To restrict access, mount the routes manually with a guard (see Restricting access) or wrap the dashboard route at the app level.
Restricting access
By default, the monitor routes are open. For production you almost certainly want auth on /_monitor, /_monitor/json, and /_monitor/metrics. Two options:
1. Skip provider auto-mount and mount manually:
2. Wrap at the proxy layer (nginx, Traefik, Cloudflare Access).
The /health endpoint is intentionally not guarded — load balancers and orchestrators need to reach it.
Custom collectors and exporters
Both interfaces are minimal:
Programmatic API
If you need to bypass the provider — tests, CLI scripts, one-off pipelines — use MonitorManager directly:
Testing
MonitorManager.reset() cleanly stops timers and discards the singleton between test runs.