Health Checks

Focus

Gateway Health Monitoring System

The health check subsystem continuously monitors the Gateway's operational status. It exposes cached health summaries via getHealthCache(), tracks versions with getHealthVersion(), and provides structured error logging through the logHealth interface — all integrated with the GatewayRequestContext and scheduled by CronService.

Cache Accessor getHealthCache() returns HealthSummary | null
Version Counter getHealthVersion() returns monotonic version
Error Logger logHealth: {error: (msg: string) => void}
Scheduler CronService runs checks every 30 seconds

Table of Contents

Overview

Health checks serve two primary purposes: providing a readiness signal for load balancers and orchestrators (Kubernetes, systemd), and offering internal diagnostics for operators monitoring the Gateway's subsystems. The health system runs as a CronService task that periodically evaluates all registered health probes and caches the aggregated result.

External consumers (HTTP health endpoints, monitoring dashboards) call getHealthCache() to retrieve the latest cached HealthSummary without triggering a fresh evaluation. The getHealthVersion() function returns a monotonically increasing counter that increments each time the health status changes, allowing efficient polling via ETags or long-polling.

💡 Design principle: Health checks are intentionally decoupled from request handling. They run on a fixed schedule via CronService and never block incoming requests. The cached result ensures that even under heavy load, health endpoints respond in constant time.

Health Check Cycle

The following flowchart illustrates the complete health check cycle, from scheduled trigger through cache update and version increment.

flowchart TD
    A[CronService Trigger] --> B[Collect Health Probes]
    B --> C{All probes registered?}
    C -- No --> D[Log warning: missing probes]
    D --> E[Run available probes]
    C -- Yes --> E
    E --> F[Evaluate each probe]
    F --> G{Any probe failed?}
    G -- Yes --> H[Aggregate: status = unhealthy]
    G -- No --> I{Any probe degraded?}
    I -- Yes --> J[Aggregate: status = degraded]
    I -- No --> K[Aggregate: status = healthy]
    H --> L[Build HealthSummary]
    J --> L
    K --> L
    L --> M[Compare with cached summary]
    M --> N{Status changed?}
    N -- Yes --> O[Increment healthVersion]
    O --> P[Update healthCache]
    N -- No --> P
    P --> Q[Log health result]
    Q --> R[Schedule next check]

HealthSummary Type

The HealthSummary is the primary data structure returned by getHealthCache(). It contains the overall status, individual probe results, timestamps, and diagnostic metadata used by monitoring systems.

type HealthStatus = "healthy" | "degraded" | "unhealthy";

interface HealthSummary {
  status: HealthStatus;
  version: number;
  checkedAtMs: number;
  durationMs: number;
  probes: HealthProbeResult[];
  metadata: {
    gatewayId: string;
    uptime: number;
    activeConnections: number;
    pendingRequests: number;
  };
}

interface HealthProbeResult {
  name: string;
  status: HealthStatus;
  message: string;
  durationMs: number;
  lastSuccessMs: number | null;
  consecutiveFailures: number;
}

// The cache accessor — returns the latest cached summary or null if
// no health check has run yet (e.g., during initial boot)
function getHealthCache(): HealthSummary | null {
  return healthCacheStore.get();
}

// Returns a monotonically increasing version counter that increments
// each time the health status changes (not on every check cycle)
function getHealthVersion(): number {
  return healthCacheStore.getVersion();
}
⚠ Null on cold start: getHealthCache() returns null until the first health check cycle completes (typically within 30 seconds of boot). Load balancers should treat null as "not yet ready" and avoid routing traffic until a non-null summary with status "healthy" is available.

How It Works

Register Health Probes

During boot, each subsystem registers a health probe with the HealthCheckManager. A probe is a named async function that evaluates one aspect of the Gateway's health. Probes are registered in the GatewayRequestContext initialization phase.

interface HealthProbe {
  name: string;
  check: () => Promise<HealthProbeResult>;
  timeoutMs: number;
  critical: boolean;
}

class HealthCheckManager {
  private probes: Map<string, HealthProbe> = new Map();
  private cache: HealthSummary | null = null;
  private version: number = 0;

  registerProbe(probe: HealthProbe): void {
    if (this.probes.has(probe.name)) {
      logHealth.error(`Duplicate probe registration: ${probe.name}`);
      return;
    }
    this.probes.set(probe.name, probe);
  }

  async runAllProbes(ctx: GatewayRequestContext): Promise<HealthSummary> {
    const start = Date.now();
    const results: HealthProbeResult[] = [];

    for (const [name, probe] of this.probes) {
      try {
        const result = await Promise.race([
          probe.check(),
          timeout(probe.timeoutMs, name),
        ]);
        results.push(result);
      } catch (err) {
        results.push({
          name,
          status: "unhealthy",
          message: `Probe threw: ${String(err)}`,
          durationMs: Date.now() - start,
          lastSuccessMs: null,
          consecutiveFailures: 1,
        });
      }
    }

    const overallStatus = this.aggregateStatus(results);
    const summary: HealthSummary = {
      status: overallStatus,
      version: this.version,
      checkedAtMs: Date.now(),
      durationMs: Date.now() - start,
      probes: results,
      metadata: {
        gatewayId: ctx.gatewayId,
        uptime: Date.now() - ctx.startedAtMs,
        activeConnections: ctx.nodeRegistry.size(),
        pendingRequests: ctx.metrics.getPendingCount(),
      },
    };

    // Only increment version on status change
    if (!this.cache || this.cache.status !== summary.status) {
      this.version++;
      summary.version = this.version;
    }

    this.cache = summary;
    return summary;
  }

  private aggregateStatus(results: HealthProbeResult[]): HealthStatus {
    if (results.some(r => r.status === "unhealthy")) return "unhealthy";
    if (results.some(r => r.status === "degraded")) return "degraded";
    return "healthy";
  }
}

Schedule with CronService

The health check cycle is registered as a CronService task during boot. It runs every 30 seconds and updates the health cache after each cycle.

// Registered during initializeServices() in boot sequence
cronService.registerTask(
  "health-check",
  "*/30 * * * * *",
  async () => {
    const summary = await healthCheckManager.runAllProbes(ctx);

    if (summary.status === "unhealthy") {
      logHealth.error(
        `Health check FAILED: ${summary.probes
          .filter(p => p.status === "unhealthy")
          .map(p => p.name)
          .join(", ")}`
      );
    }

    ctx.logger.debug(
      `Health check: ${summary.status} (v${summary.version}, ${summary.durationMs}ms)`
    );
  }
);

Expose via HTTP Endpoint

The Gateway exposes a /health HTTP endpoint that returns the cached health summary. This endpoint is used by load balancers for readiness probes and by monitoring dashboards for status display.

httpRouter.get("/health", (req, res) => {
  const cached = getHealthCache();
  if (!cached) {
    res.status(503).json({ status: "not-ready", message: "Health check pending" });
    return;
  }

  const statusCode = cached.status === "healthy" ? 200
    : cached.status === "degraded" ? 200
    : 503;

  res.setHeader("ETag", `"v${cached.version}"`);
  res.setHeader("Cache-Control", "no-cache, must-revalidate");
  res.status(statusCode).json(cached);
});

httpRouter.get("/health/version", (req, res) => {
  res.json({ version: getHealthVersion() });
});

Handle Health Errors with logHealth

The logHealth object provides a dedicated error logging interface for health-related issues. It writes to a separate log stream so health diagnostics don't get buried in request logs.

const logHealth: { error: (message: string) => void } = {
  error: (message: string) => {
    const entry = {
      level: "error",
      category: "health",
      timestamp: new Date().toISOString(),
      message,
    };
    process.stderr.write(JSON.stringify(entry) + "\n");

    // Also increment the health error counter for metrics
    ctx.metrics.increment("health.errors");
  },
};

Health Check Sequence

This sequence diagram shows a complete health check cycle including probe execution, cache update, and external consumer access.

sequenceDiagram
    participant CS as CronService
    participant HM as HealthCheckManager
    participant WS as WebSocket Probe
    participant DB as Session Store Probe
    participant NR as Node Registry Probe
    participant HC as Health Cache
    participant LB as Load Balancer

    CS->>HM: runAllProbes(ctx)

    par Probe execution
        HM->>WS: check()
        WS-->>HM: {status: "healthy", durationMs: 2}
    and
        HM->>DB: check()
        DB-->>HM: {status: "healthy", durationMs: 15}
    and
        HM->>NR: check()
        NR-->>HM: {status: "degraded", message: "3 stale nodes"}
    end

    HM->>HM: aggregateStatus() → "degraded"
    HM->>HM: Compare with previous status
    alt status changed
        HM->>HM: Increment version
    end
    HM->>HC: Update cache with new HealthSummary

    Note over LB: Periodic readiness probe
    LB->>HC: GET /health
    HC-->>LB: 200 {status: "degraded", version: 42}
    LB->>LB: Mark Gateway as degraded

Built-in Health Probes

The Gateway ships with several built-in health probes that are automatically registered during boot. Custom probes can be added via the registerProbe() API.

Probe NameWhat It ChecksTimeoutCritical
websocket-serverWebSocket server is accepting connections5sYes
session-storeSession store is readable and writable10sYes
node-registryNodeRegistry has no stale entries exceeding threshold5sNo
cron-serviceCronService event loop is responsive3sYes
memory-usageHeap usage below 90% of max1sNo
disk-spaceSnapshot directory has sufficient free space5sNo
✅ Critical vs non-critical: A failing critical probe immediately sets the overall status to "unhealthy". Non-critical probes set it to "degraded". This distinction allows load balancers to differentiate between "must not receive traffic" and "can receive traffic but performance may be impacted."

Cache Invalidation Strategy

The health cache uses a write-on-schedule pattern — it is only updated when the CronService triggers a new check cycle. Between cycles, the cache returns the same HealthSummary instance. The version field allows efficient change detection.

EventCache BehaviorVersion Impact
Scheduled check completesCache updated with new summaryIncremented only if status changed
Probe timeoutProbe marked "unhealthy" in summaryIncremented if overall status changed
Gateway shutdown initiatedCache set to "unhealthy" immediatelyIncremented unconditionally
Manual invalidationCache cleared; next getHealthCache() returns nullReset to 0
Between check cyclesCache returns stale (up to 30s old) summaryUnchanged

Integration with GatewayRequestContext

The health system is wired into the GatewayRequestContext during boot. The context holds references to the HealthCheckManager, the cache accessor functions, and the logHealth logger. This integration ensures that all subsystems can contribute health probes and query health status without importing global singletons.

// Fields added to GatewayRequestContext for health integration
interface GatewayRequestContext {
  // ... other fields ...

  healthCache: {
    get: () => HealthSummary | null;
    getVersion: () => number;
    invalidate: () => void;
  };

  healthCheckManager: HealthCheckManager;

  logHealth: {
    error: (message: string) => void;
  };
}

// During buildGatewayRequestContext():
const healthCheckManager = new HealthCheckManager();
const ctx: GatewayRequestContext = {
  // ...
  healthCache: {
    get: () => healthCheckManager.getCache(),
    getVersion: () => healthCheckManager.getVersion(),
    invalidate: () => healthCheckManager.clearCache(),
  },
  healthCheckManager,
  logHealth,
};

Error Handling & Edge Cases

ScenarioBehaviorOperator Impact
Probe throws exceptionCaught; probe marked "unhealthy" with error messageCheck logs for health.errors metric
Probe times outProbe marked "unhealthy" with "timeout" messageInvestigate slow subsystem
All probes healthyStatus "healthy", version unchangedNo action needed
CronService itself unhealthyHealth checks stop running; cache becomes staleMonitor checkedAtMs age in cache
Memory pressurememory-usage probe returns "degraded"Consider scaling or restarting
Graceful shutdownCache immediately set to "unhealthy"LB drains connections
🚨 Stale cache alert: If getHealthCache().checkedAtMs is more than 90 seconds old, it likely means the CronService has stopped running. Monitoring systems should alert when Date.now() - checkedAtMs > 90000.

Configuration Reference

ParameterTypeDefaultDescription
health.checkIntervalSecnumber30Seconds between health check cycles
health.probeTimeoutMsnumber10000Default timeout per probe
health.staleThresholdMsnumber90000Age after which cache is considered stale
health.memoryThresholdnumber0.9Heap usage fraction triggering degraded
health.diskMinFreeMbnumber100Minimum free disk space in MB

Directory Map

Source files for this topic
src/gateway/
├── health.ts                  ← HealthCheckManager, getHealthCache(), getHealthVersion()
├── health-probes.ts           ← Built-in probe implementations
├── health-types.ts            ← HealthSummary, HealthProbeResult, HealthStatus
├── health-log.ts              ← logHealth interface and error stream
├── cron.ts                    ← CronService, health check task scheduling
└── context.ts                 ← GatewayRequestContext health integration