-
Notifications
You must be signed in to change notification settings - Fork 0
feat(health-check): add health-check package #242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # @map-colonies/health-check | ||
|
|
||
| A wrapper around `@godaddy/terminus` to manage health checks (liveness/readiness) and graceful shutdown for Node.js applications. It abstracts the complexity of Terminus into a clean, object-oriented API while preserving the ability to pass advanced configuration when needed. | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| npm install @map-colonies/health-check | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| Whether your application is a REST API or a background worker, `HealthCheckManager` requires an `http.Server` to expose the Kubernetes probes. | ||
|
|
||
| ```typescript | ||
| import http from 'http'; | ||
| import { HealthCheckManager } from '@map-colonies/health-check'; | ||
|
|
||
| // If this is a worker without an existing API, simply create a lightweight server: | ||
| // const server = http.createServer(); | ||
| const server = http.createServer((req, res) => { | ||
| res.writeHead(200); | ||
| res.end('Hello MapColonies!'); | ||
| }); | ||
|
|
||
| // Using a standard @map-colonies/js-logger | ||
| const logger = myLogger; | ||
|
|
||
| const healthCheckManager = new HealthCheckManager(server, logger, { | ||
| timeout: 5000, | ||
| signals: ['SIGINT', 'SIGTERM'], | ||
| }); | ||
|
|
||
| // 1. Setup Liveness Check (e.g. memory limits) | ||
| healthCheckManager.registerLivenessCheck('memory', async () => { | ||
| const memUsage = process.memoryUsage(); | ||
| if (memUsage.rss > 500 * 1024 * 1024) { | ||
| throw new Error('Memory usage exceeded 500MB'); | ||
| } | ||
| }); | ||
|
|
||
| // 2. Setup Readiness Check (e.g. Postgres or S3 connectivity) | ||
| healthCheckManager.registerReadinessCheck('database', async () => { | ||
| // DB ping logic | ||
| }); | ||
|
|
||
| // 3. Setup Graceful Shutdown | ||
| healthCheckManager.registerShutdownHook('database', async () => { | ||
| logger.info('Closing database connection...'); | ||
| // e.g., await connection.close(); | ||
| }); | ||
|
|
||
| server.listen(8080, () => { | ||
| logger.info('Server is listening on port 8080'); | ||
| }); | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", | ||
| "extends": "../../api-extractor.json" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import baseConfig from '@map-colonies/eslint-config/ts-base'; | ||
| import { defineConfig } from 'eslint/config'; | ||
|
|
||
| export default defineConfig(baseConfig, { ignores: ['vitest.config.ts'] }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| ## API Report File for "@map-colonies/health-check" | ||
|
|
||
| > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). | ||
|
|
||
| ```ts | ||
|
|
||
| import type { Logger } from '@map-colonies/js-logger'; | ||
| import type { Server } from 'node:http'; | ||
| import { TerminusOptions } from '@godaddy/terminus'; | ||
|
|
||
| // @public (undocumented) | ||
| export type HealthCheckFunction = () => Promise<void>; | ||
|
|
||
| // @public (undocumented) | ||
| export class HealthCheckManager { | ||
| constructor(server: Server, logger: Logger, terminusOptions?: TerminusOptions); | ||
| // (undocumented) | ||
| getActiveRequestsCount(): number; | ||
| // (undocumented) | ||
| get isReady(): boolean; | ||
| // (undocumented) | ||
| registerLivenessCheck(name: string, check: HealthCheckFunction): void; | ||
| // (undocumented) | ||
| registerReadinessCheck(name: string, check: HealthCheckFunction): void; | ||
| // (undocumented) | ||
| registerShutdownHook(name: string, hook: HealthCheckFunction): void; | ||
| // (undocumented) | ||
| setReady(state: boolean): void; | ||
| // (undocumented) | ||
| waitUntilZeroActiveRequests(checkIntervalMs?: number): Promise<void>; | ||
| } | ||
|
|
||
| // (No @packageDocumentation comment for this package) | ||
|
|
||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| { | ||
| "name": "@map-colonies/health-check", | ||
| "version": "0.0.1", | ||
| "description": "Health check manager and terminus wrapper for map colonies", | ||
| "main": "./dist/index.js", | ||
| "type": "commonjs", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.js", | ||
| "require": "./dist/index.js" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "lint": "eslint .", | ||
| "lint:fix": "eslint --fix .", | ||
| "test": "vitest run", | ||
| "prebuild": "pnpm run clean", | ||
| "build": "tsc --project tsconfig.build.json", | ||
| "clean": "rimraf dist", | ||
| "prepack": "turbo run build", | ||
| "check-dist": "publint && attw --pack .", | ||
| "knip": "knip --directory ../.. --workspace packages/health-check", | ||
| "api": "api-extractor run --local --verbose", | ||
| "api:check": "api-extractor run --verbose" | ||
| }, | ||
| "repository": "github:MapColonies/infra-packages", | ||
| "files": [ | ||
| "dist/**/*" | ||
| ], | ||
| "publishConfig": { | ||
| "access": "public" | ||
| }, | ||
| "engines": { | ||
| "node": ">=24" | ||
| }, | ||
| "dependencies": { | ||
| "@godaddy/terminus": "^4.12.1", | ||
| "@map-colonies/js-logger": "workspace:^" | ||
| }, | ||
| "devDependencies": { | ||
| "@map-colonies/eslint-config": "workspace:^", | ||
| "@map-colonies/tsconfig": "workspace:^", | ||
| "@types/node": "catalog:", | ||
| "typescript": "catalog:", | ||
| "vitest-config": "workspace:^", | ||
| "vitest": "catalog:", | ||
| "eslint": "catalog:", | ||
| "@microsoft/api-extractor": "catalog:", | ||
| "publint": "catalog:", | ||
| "@arethetypeswrong/cli": "catalog:", | ||
| "rimraf": "catalog:" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import type { Server } from 'node:http'; | ||
| import { createTerminus, type TerminusOptions } from '@godaddy/terminus'; | ||
| import type { Logger } from '@map-colonies/js-logger'; | ||
|
|
||
| const CHECK_INTERVAL_MS = 1000; | ||
|
|
||
| /** @public */ | ||
| export type HealthCheckFunction = () => Promise<void>; | ||
|
|
||
| /** @public */ | ||
| export class HealthCheckManager { | ||
| private readonly livenessChecks: Record<string, HealthCheckFunction> = {}; | ||
| private readonly readinessChecks: Record<string, HealthCheckFunction> = {}; | ||
| private readonly shutdownHooks: Record<string, HealthCheckFunction> = {}; | ||
| private readonly logger: Logger; | ||
| private ready = true; | ||
| private readonly server: Server; | ||
| private activeRequestsCount = 0; | ||
|
|
||
| public constructor(server: Server, logger: Logger, terminusOptions?: TerminusOptions) { | ||
| this.logger = logger; | ||
| this.server = server; | ||
|
|
||
| this.server.on('request', (req, res) => { | ||
| this.activeRequestsCount++; | ||
| res.on('close', () => { | ||
| this.activeRequestsCount--; | ||
| }); | ||
| }); | ||
|
|
||
| const defaultSignals = ['SIGINT', 'SIGTERM']; | ||
|
|
||
| this.registerReadinessCheck('default', async () => { | ||
| await Promise.resolve(); | ||
| if (!this.ready) { | ||
| throw new Error('App is not ready'); | ||
| } | ||
| }); | ||
|
|
||
| const healthChecks: Record<string, () => Promise<void>> = { | ||
| '/liveness': async (): Promise<void> => { | ||
| await Promise.all( | ||
| Object.entries(this.livenessChecks).map(async ([name, check]) => { | ||
| try { | ||
| await check(); | ||
| } catch (error) { | ||
| this.logger.error({ msg: `Liveness check failed: ${name}`, err: error }); | ||
| throw error; | ||
| } | ||
| }) | ||
| ); | ||
| }, | ||
| '/readiness': async (): Promise<void> => { | ||
| await Promise.all( | ||
| Object.entries(this.readinessChecks).map(async ([name, check]) => { | ||
| try { | ||
| await check(); | ||
| } catch (error) { | ||
| this.logger.error({ msg: `Readiness check failed: ${name}`, err: error }); | ||
| throw error; | ||
| } | ||
| }) | ||
| ); | ||
| }, | ||
| }; | ||
|
|
||
| const newTerminusOptions: TerminusOptions = { | ||
| signals: defaultSignals, | ||
| ...terminusOptions, | ||
| healthChecks: { | ||
| ...healthChecks, | ||
| ...(terminusOptions?.healthChecks ?? {}), | ||
| }, | ||
| onSignal: async (): Promise<void> => { | ||
| if (terminusOptions?.onSignal) { | ||
| await terminusOptions.onSignal(); | ||
| } | ||
| await Promise.all( | ||
| Object.entries(this.shutdownHooks).map(async ([name, hook]) => { | ||
| try { | ||
| this.logger.info(`Running shutdown hook: ${name}`); | ||
| await hook(); | ||
| } catch (err) { | ||
| this.logger.error({ msg: `Shutdown hook failed: ${name}`, err }); | ||
| } | ||
| }) | ||
| ); | ||
| }, | ||
| logger: (msg: string, error: Error): void => { | ||
| this.logger.error({ err: error, msg }); | ||
| if (terminusOptions?.logger) { | ||
| terminusOptions.logger(msg, error); | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| createTerminus(this.server, newTerminusOptions); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
split wiring out of the constructor, and guard against accidental double-construction (note: @godaddy/terminus itself doesn't expose a way to unregister its signal handlers, so a full close() isn't possible without forking it — the pragmatic fix is to make "one per process" an enforced invariant instead of an assumption): nice to have also close()/teardown API. |
||
| } | ||
|
|
||
| public get isReady(): boolean { | ||
| return this.ready; | ||
| } | ||
|
|
||
| public setReady(state: boolean): void { | ||
| this.ready = state; | ||
| } | ||
|
|
||
| public getActiveRequestsCount(): number { | ||
| return this.activeRequestsCount; | ||
| } | ||
|
|
||
| public async waitUntilZeroActiveRequests(checkIntervalMs = CHECK_INTERVAL_MS): Promise<void> { | ||
| const sleep = async (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); | ||
| while (this.getActiveRequestsCount() > 0) { | ||
| await sleep(checkIntervalMs); | ||
|
Comment on lines
+114
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potentially, it can loop forever if the pod is stuck (for example, jobnik) |
||
| } | ||
| } | ||
|
|
||
| public registerLivenessCheck(name: string, check: HealthCheckFunction): void { | ||
| this.livenessChecks[name] = check; | ||
| } | ||
|
|
||
| public registerReadinessCheck(name: string, check: HealthCheckFunction): void { | ||
| this.readinessChecks[name] = check; | ||
| } | ||
|
|
||
| public registerShutdownHook(name: string, hook: HealthCheckFunction): void { | ||
| this.shutdownHooks[name] = hook; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './healthCheckManager'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DRY: /liveness and /readiness handlers are copy-pasted