JCC Express

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 Mail facade)
  • 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 Monitor facade 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:

  1. Creates app/Config/monitor.ts (config — skipped if it already exists; pass force=true to overwrite).
  2. Creates routes/monitor.ts (your editable route definitions, using the framework Route API).
  3. 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.html
    • reactresources/js/Pages/Monitor/*.jsx (React + Inertia page components)
    • vueresources/js/Pages/Monitor/*.vue (Vue 3 + Inertia page components)
  4. Wires monitor into app/Config/index.ts.
  5. Wires MonitorServiceProvider into bootstrap/providers.ts.
  6. Appends require("./monitor") to the bottom of routes/web.ts so your published routes are loaded.
Bash
# jsBlade templates (the default)
bun artisanNode publish Monitor
# Output:
#   📦 Publishing Monitor (jsblade views)
#   create app/Config/monitor.ts                              — Monitor config
#   create routes/monitor.ts                                   — Monitor route definitions
#   create resources/views/monitor/dashboard.blade.html       — Monitor dashboard view
#   create resources/views/monitor/requests.blade.html        — Monitor requests view
#   ... (one per section) ...
#   create resources/views/partials/monitor/sidebar.blade.html  — Monitor sidebar partial
#   update app/Config/index.ts                                — wired config
#   update bootstrap/providers.ts                             — wired provider
#   append routes/web.ts                                       — loaded monitor routes
#   ✓ Done.

# React + Inertia page components → resources/js/Pages/Monitor/
bun artisanNode publish Monitor react

# Vue 3 + Inertia page components → resources/js/Pages/Monitor/
bun artisanNode publish Monitor vue

# Overwrite existing files (config / views / routes). View + force may appear in any order:
bun artisanNode publish Monitor force=true
bun artisanNode publish Monitor react force=true

# List available publishables (and the Monitor view choices):
bun artisanNode publish

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's Route API (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:
    • jsbladeresources/views/monitor/*.blade.html (each view extends layout.app) plus shared resources/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 / vueresources/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. Its renderPage resolves the view in this order: Inertia page (resources/js/Pages/Monitor/<Page>.{jsx,tsx,vue} + res.inertia available) → 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:

ViewArgumentOutput locationRenders via
jsblade(default) or jsblade/bladeresources/views/monitor/jsBlade engine (res.render)
reactreact (alias jsx)resources/js/Pages/Monitor/React + Inertia (res.inertia)
vuevue (alias vuejs)resources/js/Pages/Monitor/Vue 3 + Inertia (res.inertia)
Bash
bun artisanNode publish Monitor          # → jsBlade (default)
bun artisanNode publish Monitor react    # → React + Inertia
bun artisanNode publish Monitor vue      # → Vue 3 + Inertia

Notes:

  • The view argument only applies to the Monitor publishable. 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 by MonitorController.
  • The shared DataTable paginates client-side — a Prev/Next footer appears once a section has more than 25 rows. Pass pageSize={n} (React) / :page-size="n" (Vue) to change it, or 0 to 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:

TypeScript
import { MonitorServiceProvider } from "jcc-express-mvc/lib/Monitor";

export const providers = [
  // ...existing providers
  MonitorServiceProvider,
];

2. (Optional) Wire the config file

Edit app/Config/index.ts:

TypeScript
import { monitor } from "./monitor";

export const config = {
  // ...existing keys
  monitor,
};

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

TypeScript
// app/Config/monitor.ts
import { config } from "jcc-express-mvc";
import type { MonitorConfig } from "jcc-express-mvc/lib/Monitor";

export const monitor: Partial<MonitorConfig> = {
  enabled: config.get("MONITOR_ENABLED", "true") === "true",
  basePath: config.get("MONITOR_BASE_PATH", "/_monitor"),

  collectors: {
    http: {
      enabled: true,
      sampleRate: 1.0,
      slowThresholdMs: 1000,
      ignore: ["/favicon.ico"],
    },
    system: { enabled: true, intervalMs: 15_000 },
    errors: { enabled: true },
    database: { enabled: true, slowQueryMs: 200 },
    queue: { enabled: true },
    commands: { enabled: true },
    schedule: { enabled: true },
    mail: { enabled: true },
    cache: { enabled: true },
    outgoing: { enabled: true },
    logs: { enabled: true },
  },

  exporters: {
    prometheus: { enabled: true },
    json: { enabled: true },
    log: { enabled: false, file: null, level: "info" },
    push: {
      enabled: false,
      url: config.get("MONITOR_PUSH_URL", ""),
      intervalMs: 30_000,
      headers: {},
      format: "json",
    },
  },

  healthChecks: {
    database: { enabled: false, timeoutMs: 2000 },
    cache: { enabled: false, timeoutMs: 1000 },
    queue: { enabled: false, timeoutMs: 1000 },
    disk: { enabled: false, path: ".", warnPercent: 90 },
    memory: { enabled: true, warnPercent: 85 },
  },

  buffers: {
    recentRequests: 200,
    recentErrors: 100,
    recentJobs: 100,
    recentQueries: 100,
    recentCommands: 100,
    recentScheduledTasks: 100,
    recentNotifications: 100,
    recentMail: 100,
    recentCacheOps: 100,
    recentOutgoingRequests: 100,
    recentLogs: 100,
  },

  storage: {
    driver: config.get("MONITOR_STORAGE", "memory") as
      | "memory"
      | "file"
      | "redis"
      | "database",
    file: { path: config.get("MONITOR_STORAGE_PATH", "storage/monitor") },
  },

  alerts: {
    enabled: false,
    tickIntervalMs: 30_000,
    notifications: {
      email: {
        enabled: false,
        from: "alerts@example.com",
        to: [],
        throttleMs: 15 * 60 * 1000,
        severityFloor: "warning",
      },
    },
  },

  dashboard: { enabled: true, guard: null },
};

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:

PathSectionComponent (react/vue)
GET /_monitorOverviewMonitor/Dashboard
GET /_monitor/requestsRequestsMonitor/Requests
GET /_monitor/jobsJobsMonitor/Jobs
GET /_monitor/commandsCommandsMonitor/Commands
GET /_monitor/scheduledScheduled TasksMonitor/Scheduled
GET /_monitor/exceptionsExceptionsMonitor/Exceptions
GET /_monitor/queriesQueriesMonitor/Queries
GET /_monitor/notificationsNotificationsMonitor/Notifications
GET /_monitor/mailMailMonitor/Mail
GET /_monitor/cacheCacheMonitor/Cache
GET /_monitor/outgoingOutgoing RequestsMonitor/Outgoing

How a page renders depends on what you published (see Dashboard views):

  • jsBlade (default) — each page is a Blade view that extends layout.app and uses Tailwind, published into resources/views/monitor/ + resources/views/partials/monitor/. The MonitorController calls res.render("monitor/<page>", { ... }) through the framework's jsBlade engine.
  • React / Vue — each page is an Inertia component published into resources/js/Pages/Monitor/. The MonitorController calls res.inertia("Monitor/<Page>", { ... }).
  • Nothing published — the bundled single-page UI/dashboard.html is served.

All three receive the same props (title, page, basePath, plus the section's data).

API endpoints

PathPurpose
GET /_monitor/healthLiveness probe — cheap, returns 200 while the process is up
GET /_monitor/readyReadiness — runs all registered health checks (200 / 503)
GET /_monitor/metricsPrometheus exposition format (text/plain; version=0.0.4)
GET /_monitor/jsonJSON 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

SectionData source
RequestsAuto — MonitorMiddleware records every request
ExceptionsAuto — ErrorCollector + MonitorErrorMiddleware
QueriesAuto when collectors.database.enabled — ORM query hooks
JobsAuto when collectors.queue.enabledQueue records job events
CommandsAuto when collectors.commands.enabled — console kernel records runs
Scheduled TasksAuto when collectors.schedule.enabled — scheduler records runs
MailAuto when collectors.mail.enabled — wraps the Mail facade
CacheAuto when collectors.cache.enabled — wraps Cache.get/put/forget
OutgoingAuto when collectors.outgoing.enabled — axios + fetch patch
LogsAuto when collectors.logs.enabled — framework logger integration
NotificationsManual — Monitor.recordNotification({ ... }) after dispatch

The Monitor facade

A static Monitor facade gives you typed, ergonomic access without resolving from the container:

TypeScript
import { Monitor } from "jcc-express-mvc/lib/Monitor";

// Metrics
Monitor.counter("orders.created").inc();
Monitor.counter("orders.created", "Orders successfully created").inc(2, {
  plan: "pro",
});
Monitor.gauge("active.users").set(42);
Monitor.histogram("payment.duration_ms").observe(123);

// Manual recording
Monitor.recordRequest({
  method: "POST",
  route: "/checkout",
  status: 200,
  durationMs: 84,
  timestamp: Date.now(),
});
Monitor.recordError(err, { route: "/checkout" });
Monitor.recordQuery({ sql: "SELECT 1", durationMs: 4, timestamp: Date.now() });
Monitor.recordJob({
  job: "SendWelcomeEmail",
  status: "completed",
  durationMs: 50,
  timestamp: Date.now(),
});

// Snapshot (used by the dashboard and alert evaluator)
const snap = await Monitor.snapshot();

Each metric helper returns the underlying primitive, so you can store the reference and reuse it:

TypeScript
const orders = Monitor.counter("orders.created");
orders.inc(1, { plan: "pro" });
orders.inc(1, { plan: "free" });

Metric primitives

Three primitive types are supported, all label-aware and Prometheus-compatible.

Counter

Monotonically increasing. Throws if you pass a negative amount.

TypeScript
Monitor.counter("http_requests_total").inc(); // +1
Monitor.counter("http_requests_total").inc(3, {
  route: "/users",
  method: "GET",
});

Gauge

Arbitrary values that can go up or down — memory usage, active connections, queue depth.

TypeScript
Monitor.gauge("active_websocket_connections").set(7);
Monitor.gauge("active_websocket_connections").inc();
Monitor.gauge("active_websocket_connections").dec();

Histogram

Observes a distribution and emits sum, count, and bucket counters compatible with Prometheus' histogram_quantile().

TypeScript
const h = Monitor.histogram(
  "payment_duration_ms",
  "Payment processing time in ms",
  [10, 50, 100, 250, 500, 1000, 2500, 5000]
);
h.observe(83);
h.observe(140, { provider: "stripe" });

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.

CollectorEnabled by defaultNotes
httpyesWired through MonitorMiddleware. Records count, latency, status code.
systemyesPeriodic sampler — RSS, heap, CPU%, event-loop lag, uptime.
errorsyesCaptures uncaughtException and unhandledRejection plus express errors.
databasenoRequires manual record() / track() from your ORM hook (see below).
queuenoRequires 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):

TypeScript
import { Knex } from "knex";
import { DatabaseCollector } from "jcc-express-mvc/lib/Monitor";

export class AppServiceProvider extends ServiceProvider {
  boot(): void {
    const dbCollector = this.app.resolve<DatabaseCollector>(
      "MonitorDatabaseCollector"
    );
    const knex = this.app.resolve<Knex>("knex");

    knex.on("query", (q) => {
      (q as any).__start = Date.now();
    });
    knex.on("query-response", (_resp, q) => {
      dbCollector.record({
        sql: q.sql,
        durationMs: Date.now() - (q as any).__start,
        bindings: q.bindings,
      });
    });
  }
}

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:

TypeScript
import { QueueCollector } from "jcc-express-mvc/lib/Monitor";

const qCollector = app.resolve<QueueCollector>("MonitorQueueCollector");
qCollector.track(app.resolve<any>("Queue").events);

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:

TypeScript
Monitor.recordJob({
  job: "SendInvoice",
  status: "failed",
  durationMs: 1200,
  error: err.message,
  timestamp: Date.now(),
});

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().

TypeScript
import {
  Monitor,
  DatabaseCheck,
  CacheCheck,
} from "jcc-express-mvc/lib/Monitor";

// Memory (built-in, on by default — fails when heap > 85%)
// Disk    (built-in, opt-in)

// Custom probe
Monitor.addHealthCheck({
  name: "stripe",
  timeoutMs: 1500,
  run: async () => {
    const ok = await stripe.charges
      .list({ limit: 1 })
      .then(() => true)
      .catch(() => false);
    return {
      healthy: ok,
      message: ok ? "stripe reachable" : "stripe unreachable",
    };
  },
});

// Wire framework cache health
Monitor.addHealthCheck(
  new CacheCheck({ cache: Cache as any, timeoutMs: 1000 })
);

// Wire DB probe (Knex example)
Monitor.addHealthCheck(
  new DatabaseCheck({
    probe: async () => {
      await knex.raw("SELECT 1");
    },
    timeoutMs: 2000,
  })
);

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:

yaml
scrape_configs:
  - job_name: "my-app"
    metrics_path: "/_monitor/metrics"
    static_configs:
      - targets: ["app:3000"]

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.

TypeScript
exporters: {
  log: { enabled: true, file: "storage/logs/monitor.log", level: "info" },
}

Push

Periodically POSTs the snapshot to an HTTP endpoint. Useful for Datadog, a Prometheus PushGateway (set format: "prometheus"), or any custom collector.

TypeScript
exporters: {
  push: {
    enabled: true,
    url: "https://intake.example.com/metrics",
    intervalMs: 30_000,
    headers: { Authorization: "Bearer ..." },
    format: "json",   // or "prometheus"
  },
}

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:

TypeScript
storage: {
  driver: config.get("MONITOR_STORAGE", "memory") as
    | "memory"
    | "file"
    | "redis"
    | "database",
  file: { path: config.get("MONITOR_STORAGE_PATH", "storage/monitor") },
  redis: {
    connection: config.get("MONITOR_REDIS_URL", "redis://127.0.0.1:6379"),
    prefix: config.get("MONITOR_REDIS_PREFIX", "monitor"),
  },
  database: { table: config.get("MONITOR_DB_TABLE", "monitor_records") },
}
DriverBacking storeNotes
memoryNone (the ring buffers themselves)Default. Fastest. Records vanish on restart.
fileJSON-lines files at <path>/<type>.jsonlAppend-only; rewritten to the trailing capacity lines once a file grows past 1.5× capacity.
redisLists at <prefix>:<type>, capped with LTRIMUses ioredis. Records can be inspected from multiple workers when they share an instance.
databaseSingle 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

  • Readsm.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 a storage.persist(type, rec). Persist errors are swallowed — observability glitches must not crash the host application.
  • Hydrate — on manager.start(), the driver loads up to capacity most-recent records of each type back into the buffers (oldest → newest order).
  • Teardownmanager.stop() calls storage.close?.() (closes the Redis client; no-op for the others).

Database driver

Create the table once:

Bash
bun artisanNode make:monitor-table

This writes database/migrations/<timestamp>_monitor_records_table.ts and runs it. The schema:

TypeScript
Schema.create("monitor_records", (table) => {
  table.id();
  table.string("type", 32);
  table.text("payload"); // JSON-encoded record
  table.timestamp("created_at").useCurrent();
  table.index(["type", "id"]); // hot path for hydrate + prune
});

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:

TypeScript
import {
  MonitorManager,
  type MonitorStorage,
} from "jcc-express-mvc/lib/Monitor";

class S3Storage implements MonitorStorage {
  readonly name = "s3";
  async hydrate(target) {
    /* fetch last N records → target.push(type, records) */
  }
  async persist(type, record) {
    /* PutObject — swallow errors */
  }
  async clear(type) {
    /* DeleteObject(s) */
  }
  async close() {
    /* drop the S3 client */
  }
}

// Late-bind on the singleton:
MonitorManager.getInstance().storage = new S3Storage();

(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

TypeScript
Monitor.addAlert({
  name: "high_5xx_rate",
  description: "More than 100 5xx responses observed",
  metric: "http_requests_errors_total",
  comparator: ">",
  threshold: 100,
  severity: "critical",
  channels: ["email", "log"],
  throttleMs: 15 * 60 * 1000, // do not re-fire within 15 minutes
});

// Or a custom predicate against the full snapshot:
Monitor.addAlert({
  name: "event_loop_stall",
  predicate: (snap) =>
    (snap.metrics.find((m) => m.name === "event_loop_lag_ms")?.value ?? 0) >
    200,
  severity: "warning",
  channels: ["email"],
  throttleMs: 5 * 60 * 1000,
});

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:

TypeScript
alerts: {
  enabled: true,
  tickIntervalMs: 30_000,
  notifications: {
    email: {
      enabled: true,
      from: "alerts@app.com",
      to: ["ops@example.com", "oncall@example.com"],
      severityFloor: "warning",   // info-level alerts skipped
      throttleMs: 15 * 60 * 1000,
    },
  },
}

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:

TypeScript
import {
  Monitor,
  type Notifier,
  type AlertPayload,
} from "jcc-express-mvc/lib/Monitor";

class SlackNotifier implements Notifier {
  readonly name = "slack";
  constructor(private webhookUrl: string) {}
  async send(p: AlertPayload) {
    await fetch(this.webhookUrl, {
      method: "POST",
      body: JSON.stringify({
        text: `[${p.severity}] ${p.rule}: ${p.value} > ${p.threshold}`,
      }),
    });
  }
}

Monitor.addNotifier(new SlackNotifier(process.env.SLACK_WEBHOOK!));
// ...then add `"slack"` to a rule's `channels: [...]`.

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:

TypeScript
import {
  mountMonitorRoutes,
  MonitorMiddleware,
  MonitorErrorMiddleware,
  MonitorManager,
} from "jcc-express-mvc/lib/Monitor";

// In your AppServiceProvider.boot():
const expressApp = this.app.resolve<ExpressApp>("expressApp");
const manager = MonitorManager.getInstance();

expressApp.use(MonitorMiddleware(manager));
mountMonitorRoutes(expressApp, manager, {
  guard: (req, res, next) => {
    if (req.user?.can("viewMonitor")) return next();
    return res.status(403).end();
  },
});
expressApp.use(MonitorErrorMiddleware(manager));

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:

TypeScript
import type {
  Collector,
  Exporter,
  MonitorSnapshot,
  MetricSample,
} from "jcc-express-mvc/lib/Monitor";

class TenantCollector implements Collector {
  readonly name = "tenants";
  start() {
    /* set up listeners or interval timers */
  }
  stop() {
    /* tear them down */
  }
}

class DatadogStatsdExporter implements Exporter {
  readonly name = "statsd";
  async emit(samples: MetricSample[], _snap: MonitorSnapshot) {
    for (const s of samples) {
      // ship to dogstatsd UDP socket...
    }
  }
}

MonitorManager.getInstance()
  .registerCollector(new TenantCollector())
  .registerExporter(new DatadogStatsdExporter());

Programmatic API

If you need to bypass the provider — tests, CLI scripts, one-off pipelines — use MonitorManager directly:

TypeScript
import { MonitorManager } from "jcc-express-mvc/lib/Monitor";

const manager = MonitorManager.getInstance({
  enabled: true,
  collectors: { http: { enabled: false } } as any,
});

manager.counter("custom_metric").inc();
const snap = await manager.snapshot();

await manager.stop(); // tears down collectors, exporters, alert tick
MonitorManager.reset(); // discards the singleton (useful between test runs)

Testing

TypeScript
import { MonitorManager } from "jcc-express-mvc/lib/Monitor";

beforeEach(() => MonitorManager.reset());

it("records a request", async () => {
  const m = MonitorManager.getInstance({ enabled: true });
  m.recordRequest({
    method: "GET",
    route: "/x",
    status: 200,
    durationMs: 5,
    timestamp: Date.now(),
  });
  const snap = await m.snapshot();
  expect(snap.recent.requests).toHaveLength(1);
  expect(
    m.counter("http_requests_total").get({
      route: "/x",
      method: "GET",
      status: "200",
    })
  ).toBe(1);
});

MonitorManager.reset() cleanly stops timers and discards the singleton between test runs.


Summary