Skip to content

beingmartinbmc/node-actuator-lite

Repository files navigation

Node Actuator Lite

CI npm version npm downloads License: MIT

Spring Boot Actuator for Node.js — lightweight monitoring endpoints, a built-in dashboard, and a single runtime dependency.

node-actuator-lite — Spring Boot Actuator for Node.js

npm install node-actuator-lite

Express (10 lines)

import express from 'express';
import { actuatorMiddleware } from 'node-actuator-lite';

const app = express();
const { handler } = actuatorMiddleware();
app.use(handler);
app.listen(3000, () => console.log('http://localhost:3000/actuator'));

Fastify (10 lines)

import Fastify from 'fastify';
import { actuatorPlugin } from 'node-actuator-lite';

const app = Fastify();
await app.register(actuatorPlugin);
await app.listen({ port: 3000 });
console.log('http://localhost:3000/actuator');

Requires Node.js >= 22. The only runtime dependency is prom-client.

CJS/ESM: The package ships CommonJS (require). If you use ES modules, either import from 'node-actuator-lite' (Node resolves the CJS export) or use a bundler that handles CJS interop. A dual ESM build is on the roadmap.

Spring Boot Actuator Parity

Spring Boot Endpoint node-actuator-lite Status
/actuator (discovery) Shipped
/actuator/health ✅ (shallow/deep, groups, custom indicators) Shipped
/actuator/info ✅ (build + runtime + contributors) Shipped
/actuator/env ✅ (masked, allowlist, connection-string aware) Shipped
/actuator/metrics ✅ (process JSON + Prometheus) Shipped
/actuator/prometheus ✅ (prom-client, custom metrics) Shipped
/actuator/threaddump ✅ (event-loop, handles, ELU, workers) Shipped
/actuator/heapdump ✅ (async, throttled) Shipped
/actuator/loggers ✅ (Pino/Winston/Bunyan, runtime level change) Shipped
/actuator/mappings 🗺️ Route inventory Roadmap
/actuator/httpexchanges 🗺️ Recent request history Roadmap
/actuator/caches Not planned
/actuator/beans N/A (no DI container)
/actuator/conditions N/A (no auto-config)
/actuator/configprops N/A
/actuator/flyway / liquibase N/A
/actuator/sessions Not planned
/actuator/scheduledtasks 🗺️ Roadmap
/actuator/shutdown 🗺️ Graceful shutdown Roadmap

Why?

If you're coming from Spring Boot, you expect /actuator/health, /actuator/info, /actuator/env, and /actuator/prometheus out of the box. This library gives you exactly that for Node.js — one dependency, zero config, and adapters for Express, Fastify, Koa, the built-in http module, and serverless. It also ships a self-contained HTML dashboard so you can eyeball your service without wiring up Grafana first.

Features

  • Health — shallow (status only) and deep (per-component details), custom indicators, and health groups for Kubernetes liveness / readiness.
  • Info — build metadata plus runtime details, with pluggable info contributors.
  • Metrics — process-level CPU, memory, and uptime as JSON.
  • Environmentprocess.env as Spring-style property sources, with automatic sensitive-value masking and an optional allowlist.
  • Thread Dump — event-loop state, active handles/requests, V8 heap stats, and worker threads.
  • Heap Dump — V8 heap snapshots saved to disk, throttled to prevent abuse.
  • Prometheus — all default Node.js metrics plus custom counters, gauges, histograms, and summaries via prom-client, or inject your app's existing Registry to avoid duplicate registrations.
  • Loggers — Spring Boot-style /actuator/loggers: list every logger and change levels at runtime, with adapters for Pino, Winston, and Bunyan.
  • Dashboard — a self-contained HTML page at /actuator/dashboard that surfaces every enabled endpoint.
  • DiscoveryGET /actuator lists all enabled endpoints, just like Spring Boot.
  • Custom endpoints — register your own routes under /actuator, per instance or globally.
  • Framework adapters — Express, Fastify, Koa, and node:http, plus a serverless mode with direct method calls.
  • Pluggable auth & logging — a single auth callback guards every endpoint, and any logger (pino, winston, …) can be injected.
  • Single runtime dependencyprom-client.

Production Safety

Actuator endpoints expose operational data. In production, use the preset: 'production' option to disable sensitive endpoints by default, or gate everything behind an auth callback.

preset is never inferred automatically — endpoint defaults never change just because NODE_ENV=production is set, so upgrading this library can't silently disable endpoints in an existing deployment. If NODE_ENV=production is detected without an explicit preset, a console warning suggests setting preset: 'production'. A separate warning is emitted whenever sensitive endpoints are enabled without an auth callback.

import { NodeActuator } from 'node-actuator-lite';

// Explicit production preset — safest option:
const actuator = new NodeActuator({
  port: 8081,
  preset: 'production',
  auth: ({ raw }) => true, // replace with a real token/allowlist check
  health: {
    groups: {
      liveness: ['process'],
      readiness: ['diskSpace'],
    },
  },
  prometheus: { enabled: true },
});

Production preset disables: /env, /threaddump, /heapdump, /loggers, /dashboard.
Production preset enables: /health (shallow only), /info, /metrics, /prometheus.

You can still override individual endpoints:

new NodeActuator({
  preset: 'production',
  env: { enabled: true }, // explicitly opt in
  auth: checkBearerToken,
});

Installation

npm install node-actuator-lite

Requires Node.js >= 22. The only runtime dependency is prom-client.

Quick Start

Standalone (HTTP server)

import { NodeActuator } from 'node-actuator-lite';

const actuator = new NodeActuator({
  port: 8081,
  health: {
    showDetails: 'always',
    custom: [
      {
        name: 'database',
        critical: true,
        check: async () => ({ status: 'UP', details: { latency: '2ms' } }),
      },
    ],
    groups: {
      liveness: ['process'],
      readiness: ['diskSpace', 'database'],
    },
  },
  prometheus: {
    customMetrics: [
      { name: 'http_requests_total', help: 'Total HTTP requests', type: 'counter', labels: ['method', 'path'] },
    ],
  },
});

await actuator.start();
// Actuator listening on http://localhost:8081/actuator
// Dashboard at        http://localhost:8081/actuator/dashboard

Express

import express from 'express';
import { actuatorMiddleware } from 'node-actuator-lite';

const app = express();
const { handler, actuator } = actuatorMiddleware({ prometheus: { defaultMetrics: true } });
app.use(handler);

// All /actuator/* endpoints are now live.
// Access the actuator instance for custom metrics:
// actuator.prometheus.metric('my_counter')!.inc();

app.listen(3000);

Fastify

import Fastify from 'fastify';
import { actuatorPlugin } from 'node-actuator-lite';

const app = Fastify();
await app.register(actuatorPlugin, { prometheus: { defaultMetrics: true } });
// All /actuator/* routes registered. Access the instance via app.actuator.

await app.listen({ port: 3000 });

Koa

import Koa from 'koa';
import { actuatorKoa } from 'node-actuator-lite/middleware/koa';

const app = new Koa();
const { middleware, actuator } = actuatorKoa({ prometheus: { defaultMetrics: true } });
app.use(middleware);

// actuator.prometheus.metric('my_counter')!.inc();

app.listen(3000);

Built-in node:http (and connect-style stacks)

import http from 'node:http';
import { actuatorHttp } from 'node-actuator-lite/middleware/http';

const { handler, actuator } = actuatorHttp({ prometheus: { defaultMetrics: true } });

// As a standalone server:
http.createServer(handler).listen(8080);

// Or chained to your own router — requests outside basePath fall through to next():
// server.on('request', (req, res) => handler(req, res, () => myRouter(req, res)));

Serverless (Vercel, Lambda, etc.)

In serverless mode no server is started; you call the data methods directly.

import { NodeActuator } from 'node-actuator-lite';

const actuator = new NodeActuator({ serverless: true });
await actuator.start(); // no-op, no server started

const health  = await actuator.getHealth();          // shallow
const deep    = await actuator.getHealth('always');   // deep
const info    = await actuator.getInfoAsync();
const metrics = actuator.getMetrics();
const prom    = await actuator.getPrometheus();
const env     = actuator.getEnv();
const threads = actuator.getThreadDump();
const heap    = await actuator.getHeapDump();

Endpoints

All endpoints live under the configured basePath (default /actuator). Each row appears in discovery only when its feature is enabled.

Method Path Description
GET /actuator Discovery — lists all enabled endpoints
GET /actuator/dashboard Self-contained HTML dashboard
GET /actuator/health Health check (shallow or deep based on config)
GET /actuator/health?showDetails=always Force deep health check
GET /actuator/health/{component} Single health component
GET /actuator/health/{group} Health group (e.g. liveness, readiness)
GET /actuator/info Build and runtime information
GET /actuator/metrics Process metrics (CPU, memory, uptime) as JSON
GET /actuator/env Environment variables (masked)
GET /actuator/env/{name} Single environment variable
GET /actuator/threaddump Thread / event-loop dump
POST /actuator/heapdump Generate and save a V8 heap snapshot
GET /actuator/prometheus Prometheus metrics (text exposition format)
GET /actuator/loggers List all loggers and their levels
GET /actuator/loggers/{name} Single logger's level
POST /actuator/loggers/{name} Change a logger's level at runtime ({ "configuredLevel": "DEBUG" })

Dashboard

A self-contained HTML dashboard is served at <basePath>/dashboard (enabled by default). It renders the discovery view and links to every enabled endpoint, with no external assets or CDN calls — useful for a quick look at a running service before reaching for a full metrics stack.

Disable it in locked-down environments:

const actuator = new NodeActuator({ dashboard: { enabled: false } });

Configuration

The full option shape lives in src/core/types.ts (ActuatorOptions). The most common options:

interface ActuatorOptions {
  port?: number;            // default 0 (random); standalone server only
  basePath?: string;        // default '/actuator'
  serverless?: boolean;     // default false

  /**
   * Safety preset. 'production' disables /env, /threaddump, /heapdump,
   * /loggers, /dashboard and hides health details by default. Never inferred
   * from NODE_ENV automatically — see "Production Safety" above.
   */
  preset?: 'production' | 'development';

  /** Authorization callback applied to every endpoint. Return false → 401. */
  auth?: (ctx: {
    method: string;
    subPath: string;
    query: Record<string, string>;
    params: Record<string, string>;
    raw?: unknown;
  }) => boolean | Promise<boolean>;

  /** Custom logger (pino/winston/…). Defaults to the built-in JSON console logger. */
  logger?: {
    trace(msg: string, data?: unknown): void;
    debug(msg: string, data?: unknown): void;
    info(msg: string, data?: unknown): void;
    warn(msg: string, data?: unknown): void;
    error(msg: string, data?: unknown): void;
  };

  info?: {
    enabled?: boolean;                  // default true
    build?: Record<string, any>;        // overrides auto-detected package.json info
    contributors?: Array<{
      name: string;
      collect: () => Record<string, any> | Promise<Record<string, any>>;
    }>;
  };

  metrics?: { enabled?: boolean };      // default true

  health?: {
    enabled?: boolean;                  // default true
    showDetails?: 'never' | 'always';   // default 'always'
    timeout?: number;                   // per-indicator timeout in ms, default 5000
    indicators?: {
      diskSpace?: { enabled?: boolean; threshold?: number; path?: string };
      process?: { enabled?: boolean };
    };
    groups?: Record<string, string[]>;  // e.g. { liveness: ['process'], readiness: ['diskSpace', 'db'] }
    custom?: Array<{
      name: string;
      check: () => Promise<{ status: 'UP' | 'DOWN' | 'OUT_OF_SERVICE' | 'UNKNOWN'; details?: Record<string, any> }>;
      critical?: boolean;               // if true, DOWN here → overall DOWN
    }>;
  };

  env?: {
    enabled?: boolean;      // default true
    mask?: {
      patterns?: string[];    // default ['PASSWORD','SECRET','KEY','TOKEN','AUTH','CREDENTIAL','PRIVATE','SIGNATURE']
      additional?: string[];  // extra variable names to mask
      replacement?: string;   // default '******'
      allowlist?: string[];   // if set, ONLY these variables are exposed at all
    };
  };

  threadDump?: { enabled?: boolean };   // default true

  heapDump?: {
    enabled?: boolean;      // default true
    outputDir?: string;     // default './heapdumps'
    minIntervalMs?: number; // throttle window between dumps, default 60000 (0 disables)
  };

  prometheus?: {
    enabled?: boolean;      // default true
    defaultMetrics?: boolean; // collect default Node.js metrics, default true
    prefix?: string;
    customMetrics?: Array<{
      name: string;
      help: string;
      type: 'counter' | 'gauge' | 'histogram' | 'summary';
      labels?: string[];
      buckets?: number[];   // histogram only
    }>;
    /** Inject an existing prom-client Registry instead of creating a new one. */
    registry?: import('prom-client').Registry;
  };

  /** Built-in HTML dashboard at `<basePath>/dashboard`. Enabled by default. */
  dashboard?: { enabled?: boolean };

  /** Dynamic loggers endpoint (list/change log levels at runtime). Enabled by default. */
  loggers?: { enabled?: boolean };

  /** Custom endpoints mounted under basePath. */
  endpoints?: Array<{
    id: string;
    method?: 'GET' | 'POST';
    handler: (context?: {
      method?: string;
      path?: string;
      params?: Record<string, string>;
      query?: Record<string, string>;
      raw?: any;
    }) => any | Promise<any>;
    contentType?: 'json' | 'text';
  }>;
}

Health — Shallow vs Deep

Shallow (showDetails: 'never', or the default GET /actuator/health):

{ "status": "UP" }

Deep (showDetails: 'always', or GET /actuator/health?showDetails=always):

{
  "status": "UP",
  "components": {
    "diskSpace": {
      "status": "UP",
      "details": { "total": 499963174912, "free": 250000000000, "threshold": 10485760, "path": "/" }
    },
    "process": {
      "status": "UP",
      "details": { "pid": 12345, "uptime": 3600, "version": "v20.11.0" }
    },
    "database": {
      "status": "UP",
      "details": { "latency": "2ms" }
    }
  }
}

Health Groups

Model Kubernetes liveness and readiness probes:

const actuator = new NodeActuator({
  health: {
    groups: {
      liveness: ['process'],
      readiness: ['diskSpace', 'database'],
    },
  },
});
  • GET /actuator/health/liveness → aggregated status of process only
  • GET /actuator/health/readiness → aggregated status of diskSpace + database

Returns HTTP 200 when UP, 503 when DOWN.

Custom Health Indicators

const actuator = new NodeActuator({
  health: {
    custom: [
      {
        name: 'redis',
        critical: true,
        check: async () => {
          const ok = await redis.ping();
          return ok
            ? { status: 'UP', details: { latency: '1ms' } }
            : { status: 'DOWN', details: { error: 'ping failed' } };
        },
      },
    ],
  },
});

Add or remove indicators at runtime:

actuator.health.addIndicator({ name: 'cache', check: async () => ({ status: 'UP' }) });
actuator.health.removeIndicator('cache');

Info

GET /actuator/info returns build metadata (auto-detected from package.json, or overridden via info.build) plus runtime details. Info contributors let you append computed sections:

const actuator = new NodeActuator({
  info: {
    build: { name: 'orders-service', version: '1.4.2' },
    contributors: [
      { name: 'git', collect: () => ({ commit: process.env.GIT_SHA }) },
    ],
  },
});
{
  "build": { "name": "orders-service", "version": "1.4.2" },
  "runtime": {
    "nodeVersion": "v20.11.0",
    "platform": "linux",
    "arch": "x64",
    "pid": 12345,
    "cwd": "/app",
    "uptime": 3600
  },
  "contributors": { "git": { "commit": "a1b2c3d" } }
}

Metrics

GET /actuator/metrics returns process-level metrics as JSON (distinct from the Prometheus text endpoint):

{
  "process": {
    "uptime": 3600,
    "memory": { "rss": 52428800, "heapTotal": 20971520, "heapUsed": 15728640 },
    "cpu": { "user": 1200000, "system": 350000 }
  }
}

Environment

GET /actuator/env returns a Spring-style property-source response with sensitive values masked:

{
  "activeProfiles": ["production"],
  "propertySources": [
    {
      "name": "systemEnvironment",
      "properties": {
        "PATH": { "value": "/usr/local/bin:..." },
        "DATABASE_PASSWORD": { "value": "******" }
      }
    },
    {
      "name": "systemProperties",
      "properties": {
        "node.version": { "value": "v20.11.0" },
        "os.hostname": { "value": "my-server" }
      }
    }
  ]
}

For defence-in-depth in production, set env.mask.allowlist so that only named variables are exposed and everything else is omitted entirely. Values that look like connection strings (postgres://user:pass@host/db, mongodb+srv://..., redis://..., etc.) have only the password segment masked — the host and database name stay visible for debugging, even when the key itself matches a mask pattern.

Prometheus

GET /actuator/prometheus exposes metrics in Prometheus text format, backed by prom-client — the library's one runtime dependency.

If your app already runs its own prom-client Registry, inject it so actuator metrics land in the same registry instead of creating a second, isolated one:

import { Registry } from 'prom-client';

const registry = new Registry();
const actuator = new NodeActuator({ prometheus: { registry } });

Loggers

GET /actuator/loggers lists every known logger and its level; POST /actuator/loggers/{name} changes one at runtime — handy for turning on DEBUG in production without a redeploy.

curl http://localhost:8081/actuator/loggers
curl http://localhost:8081/actuator/loggers/ROOT
curl -X POST http://localhost:8081/actuator/loggers/ROOT \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"DEBUG"}'

The built-in logger (ROOT) is wired up out of the box. To manage an external logging library at runtime, register an adapter:

import { PinoLoggerAdapter, WinstonLoggerAdapter, BunyanLoggerAdapter } from 'node-actuator-lite';

// Pino
actuator.loggers.addAdapter(new PinoLoggerAdapter(pinoLogger));

// Winston — OFF maps to Winston's `silent` flag (Winston has no silent level)
actuator.loggers.addAdapter(new WinstonLoggerAdapter(winstonLogger));

// Bunyan — OFF sets a level above FATAL so nothing is emitted
actuator.loggers.addAdapter(new BunyanLoggerAdapter(bunyanLogger));

With more than one adapter registered, logger names are qualified as {adapter}:{logger} (e.g. pino:ROOT) in GET /actuator/loggers and in the POST body's target name.

Heap Dump

POST /actuator/heapdump writes a .heapsnapshot to heapDump.outputDir and returns metadata:

{
  "timestamp": "2025-01-15T10:30:00.000Z",
  "pid": 12345,
  "filePath": "./heapdumps/heapdump-2025-01-15T10-30-00-000Z-a1b2c3d4.heapsnapshot",
  "fileSize": 15728640,
  "duration": 1250,
  "memoryBefore": { "heapUsed": 15728640 },
  "memoryAfter": { "heapUsed": 16777216 }
}

Heap dumps block the event loop, so requests are throttled to one per heapDump.minIntervalMs (default 60s). Open the .heapsnapshot file in Chrome DevTools → Memory → Load.

Custom Endpoints

Mount your own endpoints under basePath. They appear in discovery and are served by every adapter.

Per instance, via config:

const actuator = new NodeActuator({
  endpoints: [
    { id: 'build', handler: () => ({ commit: process.env.GIT_SHA }) },
  ],
});

Or at runtime on an instance:

actuator.registerEndpoint({ id: 'cache-stats', handler: () => cache.stats() });

Or globally — useful for ecosystem packages that extend any actuator created afterwards:

import { registerEndpoint } from 'node-actuator-lite';

registerEndpoint('feature-flags', () => flags.snapshot(), { contentType: 'json' });

Serverless Integration

Vercel

// api/actuator/[...path].js
import { NodeActuator } from 'node-actuator-lite';

const actuator = new NodeActuator({ serverless: true });

export default async function handler(req, res) {
  const segments = req.query['...path'];
  const path = Array.isArray(segments) ? segments.join('/') : segments || '';

  switch (path) {
    case '':
      return res.json(actuator.discovery());
    case 'health':
      return res.json(await actuator.getHealth());
    case 'info':
      return res.json(await actuator.getInfoAsync());
    case 'metrics':
      return res.json(actuator.getMetrics());
    case 'env':
      return res.json(actuator.getEnv());
    case 'threaddump':
      return res.json(actuator.getThreadDump());
    case 'prometheus':
      res.setHeader('Content-Type', 'text/plain');
      return res.send(await actuator.getPrometheus());
    default:
      return res.status(404).json({ error: 'Not found' });
  }
}

AWS Lambda

import { NodeActuator } from 'node-actuator-lite';
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

const actuator = new NodeActuator({ serverless: true });

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  const path = event.pathParameters?.proxy || '';

  const routes: Record<string, () => Promise<{ code: number; type: string; body: string }>> = {
    health: async () => ({ code: 200, type: 'application/json', body: JSON.stringify(await actuator.getHealth()) }),
    info: async () => ({ code: 200, type: 'application/json', body: JSON.stringify(await actuator.getInfoAsync()) }),
    metrics: async () => ({ code: 200, type: 'application/json', body: JSON.stringify(actuator.getMetrics()) }),
    env: async () => ({ code: 200, type: 'application/json', body: JSON.stringify(actuator.getEnv()) }),
    prometheus: async () => ({ code: 200, type: 'text/plain', body: await actuator.getPrometheus() }),
    threaddump: async () => ({ code: 200, type: 'application/json', body: JSON.stringify(actuator.getThreadDump()) }),
  };

  const route = routes[path];
  if (!route) return { statusCode: 404, body: '{"error":"Not found"}' };

  const result = await route();
  return { statusCode: result.code, headers: { 'Content-Type': result.type }, body: result.body };
};

Programmatic API

Every endpoint has a method equivalent — no HTTP server required.

const actuator = new NodeActuator({ serverless: true });

// Discovery
actuator.discovery();

// Health
await actuator.getHealth();                   // shallow or deep (based on config)
await actuator.getHealth('always');           // force deep
await actuator.getHealthComponent('diskSpace');
await actuator.getHealthGroup('readiness');

// Info & metrics
actuator.getInfo();         // sync, no contributors
await actuator.getInfoAsync(); // includes contributors
actuator.getMetrics();

// Environment
actuator.getEnv();
actuator.getEnvVariable('NODE_ENV');

// Thread dump
actuator.getThreadDump();

// Heap dump
await actuator.getHeapDump();

// Prometheus
await actuator.getPrometheus();

// Loggers
actuator.loggers.collect();                          // list all loggers
actuator.loggers.getLogger('ROOT');
actuator.loggers.setLevel('ROOT', 'DEBUG');
actuator.loggers.addAdapter(new PinoLoggerAdapter(pinoLogger));

// Custom endpoints
actuator.registerEndpoint({ id: 'build', handler: () => ({ commit: 'abc' }) });
await actuator.invokeEndpoint('build');

Ecosystem

node-actuator-lite is part of a small Node.js observability ecosystem you can adopt independently or together:

  • node-actuator-lite — Spring Boot-style /actuator/health, /info, /metrics, /env, /threaddump, /heapdump, and /prometheus endpoints.
  • node-eventloop-watchdog — Detects event-loop stalls, captures stack traces and hotspots, and triggers recovery.
  • node-request-trace — Per-request timelines, browser dashboard, and CLI without OpenTelemetry.

When all three are installed:

  • node-eventloop-watchdog automatically registers /actuator/eventloop, /actuator/eventloop/history, /actuator/eventloop/hotspots, and /actuator/eventloop/metrics under this actuator.
  • Event-loop block events include the active request id, route, and method captured by node-request-trace.

Runnable example: examples/ecosystem.

Quickest setup: Use node-observability-lite to wire the three packages together with production-safe presets in one line.

const observability = require('node-observability-lite');
observability.express(app, {
  preset: 'production',
  auth: req => req.get('authorization') === `Bearer ${process.env.OPS_TOKEN}`,
});

Examples

Runnable examples live in examples/:

  • Express app with bearer-token protected actuator routes
  • Fastify app with safe endpoint defaults
  • AWS Lambda handler using serverless mode
  • Kubernetes deployment probes for liveness and readiness

Contributing

See CONTRIBUTING.md for development setup and release checks.

Security issues should be reported privately; see SECURITY.md.

Release notes are tracked in CHANGELOG.md.

License

MIT — see LICENSE.

About

Spring Boot Actuator-style health, metrics, env, thread dump, heap dump, and Prometheus endpoints for Node.js

Topics

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors