diff --git a/.changeset/ops-status-plane.md b/.changeset/ops-status-plane.md new file mode 100644 index 000000000..93fe6c41c --- /dev/null +++ b/.changeset/ops-status-plane.md @@ -0,0 +1,6 @@ +--- +"@croco/transports-http": patch +"@croco/cli": patch +--- + +Expose canonical operations endpoints and add `croco ops status` for machine-readable and human-readable runtime status checks. diff --git a/packages/cli/package.json b/packages/cli/package.json index 94941d852..d7df829f0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "@clack/prompts": "^0.9.1", "@croco/migration-runner": "workspace:*", "@croco/openapi-spec": "workspace:*", + "@croco/problems-core": "workspace:*", "@croco/rpc-codegen": "workspace:*", "citty": "^0.1.6", "ts-morph": "^24.0.0" diff --git a/packages/cli/src/bin/croco.ts b/packages/cli/src/bin/croco.ts index 0dbbc20a8..6a2644092 100644 --- a/packages/cli/src/bin/croco.ts +++ b/packages/cli/src/bin/croco.ts @@ -6,6 +6,7 @@ import { create } from "../commands/create.js"; import { generate } from "../commands/generate.js"; import { make } from "../commands/make.js"; import { migrate } from "../commands/migrate.js"; +import { ops } from "../commands/ops.js"; import { GLOBAL_OPTIONS } from "../commands/options.js"; const main = defineCommand({ @@ -23,6 +24,7 @@ const main = defineCommand({ codegen, contracts, migrate, + ops, }, }); diff --git a/packages/cli/src/commands/ops.ts b/packages/cli/src/commands/ops.ts new file mode 100644 index 000000000..1b0870d30 --- /dev/null +++ b/packages/cli/src/commands/ops.ts @@ -0,0 +1,333 @@ +import { defineCommand } from "citty"; +import { Problem, ProblemCategory } from "@croco/problems-core"; +import { GLOBAL_OPTIONS } from "./options.js"; + +const DEFAULT_TIMEOUT_MS = 5000; +const DEFAULT_TOKEN_HEADER = "X-Diagnostics-Token"; +const OPS_ENDPOINTS = [ + { name: "health", path: "/health" }, + { name: "ready", path: "/ready" }, + { name: "diagnostics", path: "/diagnostics" }, + { name: "metrics", path: "/metrics" }, +] as const; + +export type OpsEndpointName = (typeof OPS_ENDPOINTS)[number]["name"]; + +export type OpsEndpointSnapshot = { + readonly name: OpsEndpointName; + readonly url: string; + readonly httpStatus: number | null; + readonly ok: boolean; + readonly body: unknown; + readonly error?: string; +}; + +export type OpsStatusSummary = "healthy" | "degraded" | "unhealthy"; + +export type OpsStatusReport = { + readonly target: string; + readonly timestamp: string; + readonly summary: OpsStatusSummary; + readonly endpoints: readonly OpsEndpointSnapshot[]; +}; + +export type OpsStatusFetch = (input: string, init?: RequestInit) => Promise; + +export type RunOpsStatusOptions = { + readonly fetch?: OpsStatusFetch; + readonly timeoutMs?: number; + readonly token?: string; + readonly tokenHeader?: string; +}; + +class InvalidOpsTimeoutProblem extends Problem { + constructor(value: unknown) { + super( + "cli/invalid-ops-timeout", + ProblemCategory.BadRequest, + `Invalid timeout: ${String(value)}`, + ); + } +} + +class InvalidOpsTargetUrlProblem extends Problem { + constructor(target: string) { + super( + "cli/invalid-ops-target-url", + ProblemCategory.BadRequest, + `Invalid Croco app URL: ${target}`, + ); + } +} + +export const opsStatus = defineCommand({ + meta: { + name: "status", + description: "Read Croco operational endpoints", + }, + args: { + ...GLOBAL_OPTIONS, + url: { + type: "positional", + required: true, + description: "Croco app base URL", + }, + json: { + type: "boolean", + description: "Print the machine-readable status report", + }, + token: { + type: "string", + description: "Diagnostics token", + }, + tokenHeader: { + type: "string", + default: DEFAULT_TOKEN_HEADER, + description: "Diagnostics token header", + }, + timeout: { + type: "string", + description: "Per-endpoint timeout in milliseconds", + }, + }, + async run({ args }) { + const report = await runOpsStatus(String(args.url ?? ""), { + token: typeof args.token === "string" ? args.token : undefined, + tokenHeader: typeof args.tokenHeader === "string" ? args.tokenHeader : DEFAULT_TOKEN_HEADER, + timeoutMs: parseTimeoutMs(args.timeout), + }); + + console.log(args.json ? JSON.stringify(report, null, 2) : formatOpsStatusReport(report)); + process.exitCode = getOpsStatusExitCode(report.summary); + }, +}); + +export const ops = defineCommand({ + meta: { + name: "ops", + description: "Inspect Croco operational endpoints", + }, + args: { + ...GLOBAL_OPTIONS, + }, + subCommands: { + status: opsStatus, + }, +}); + +export async function runOpsStatus( + target: string, + options: RunOpsStatusOptions = {}, +): Promise { + const targetUrl = parseTargetUrl(target); + const fetchEndpoint = options.fetch ?? fetch; + const timeoutMs = parseTimeoutMs(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) ?? DEFAULT_TIMEOUT_MS; + const tokenHeader = options.tokenHeader ?? DEFAULT_TOKEN_HEADER; + const snapshots = await Promise.all( + OPS_ENDPOINTS.map(async (endpoint) => + fetchOperationalEndpoint({ + endpoint, + fetchEndpoint, + targetUrl, + timeoutMs, + token: options.token, + tokenHeader, + }), + ), + ); + + return { + target: targetUrl.toString(), + timestamp: new Date().toISOString(), + summary: summarizeOpsStatus(snapshots), + endpoints: snapshots, + }; +} + +export function formatOpsStatusReport(report: OpsStatusReport): string { + const lines = [ + `Croco ops status: ${report.summary}`, + `Target: ${report.target}`, + `Checked: ${report.timestamp}`, + "", + ]; + + for (const endpoint of report.endpoints) { + const httpStatus = endpoint.httpStatus === null ? "ERR" : String(endpoint.httpStatus); + const status = describeEndpointStatus(endpoint); + const suffix = endpoint.error ? ` - ${endpoint.error}` : ""; + lines.push(`${endpoint.name.padEnd(11)} ${httpStatus.padEnd(3)} ${status}${suffix}`); + } + + return lines.join("\n"); +} + +export function getOpsStatusExitCode(summary: OpsStatusSummary): number { + return summary === "healthy" ? 0 : 1; +} + +function parseTimeoutMs(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new InvalidOpsTimeoutProblem(value); + } + + return parsed; +} + +function parseTargetUrl(target: string): URL { + try { + return new URL(target); + } catch { + throw new InvalidOpsTargetUrlProblem(target); + } +} + +async function fetchOperationalEndpoint({ + endpoint, + fetchEndpoint, + targetUrl, + timeoutMs, + token, + tokenHeader, +}: { + readonly endpoint: (typeof OPS_ENDPOINTS)[number]; + readonly fetchEndpoint: OpsStatusFetch; + readonly targetUrl: URL; + readonly timeoutMs: number; + readonly token?: string; + readonly tokenHeader: string; +}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const headers = new Headers({ Accept: "application/json" }); + + if (token && endpoint.name === "diagnostics") { + headers.set(tokenHeader, token); + } + + const url = resolveEndpointUrl(targetUrl, endpoint.path); + + try { + const response = await fetchEndpoint(url, { + headers, + signal: controller.signal, + }); + const body = await readResponseBody(response); + + return { + name: endpoint.name, + url, + httpStatus: response.status, + ok: response.ok, + body, + }; + } catch (error) { + return { + name: endpoint.name, + url, + httpStatus: null, + ok: false, + body: null, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +function resolveEndpointUrl(targetUrl: URL, path: string): string { + const url = new URL(targetUrl.toString()); + const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + url.pathname = `${basePath}${path}`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +async function readResponseBody(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) { + return null; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function summarizeOpsStatus(endpoints: readonly OpsEndpointSnapshot[]): OpsStatusSummary { + if (endpoints.some((endpoint) => isEndpointUnhealthy(endpoint))) { + return "unhealthy"; + } + + if (endpoints.some((endpoint) => isEndpointDegraded(endpoint))) { + return "degraded"; + } + + return "healthy"; +} + +function isEndpointUnhealthy(endpoint: OpsEndpointSnapshot): boolean { + if (!endpoint.ok) { + return endpoint.name === "health" || endpoint.name === "ready"; + } + + const status = readStringField(endpoint.body, "status"); + const summary = readStringField(endpoint.body, "summary"); + + return status === "down" || status === "unhealthy" || summary === "issues_detected"; +} + +function isEndpointDegraded(endpoint: OpsEndpointSnapshot): boolean { + if (!endpoint.ok) { + return true; + } + + const status = readStringField(endpoint.body, "status"); + const summary = readStringField(endpoint.body, "summary"); + + return status === "degraded" || summary === "degraded"; +} + +function describeEndpointStatus(endpoint: OpsEndpointSnapshot): string { + if (!endpoint.ok) { + return "unavailable"; + } + + const status = readStringField(endpoint.body, "status"); + const summary = readStringField(endpoint.body, "summary"); + + if (status) { + return status; + } + + if (summary) { + return summary; + } + + if (endpoint.name === "metrics") { + return "available"; + } + + return "ok"; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) { + return undefined; + } + + const field = value[key]; + return typeof field === "string" ? field : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1a8c74409..e9b07bd90 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,4 +27,20 @@ export { makeEvent } from "./commands/makeEvent"; export { makeListener } from "./commands/makeListener"; export { makeRepository } from "./commands/makeRepository"; export { migrate } from "./commands/migrate"; +export { + formatOpsStatusReport, + getOpsStatusExitCode, + ops, + opsStatus, + runOpsStatus, +} from "./commands/ops"; export { GLOBAL_OPTIONS } from "./commands/options"; + +export type { + OpsEndpointName, + OpsEndpointSnapshot, + OpsStatusFetch, + OpsStatusReport, + OpsStatusSummary, + RunOpsStatusOptions, +} from "./commands/ops"; diff --git a/packages/cli/src/tests/opsStatus.spec.ts b/packages/cli/src/tests/opsStatus.spec.ts new file mode 100644 index 000000000..5dcbe7b4f --- /dev/null +++ b/packages/cli/src/tests/opsStatus.spec.ts @@ -0,0 +1,113 @@ +import { Container } from "typedi"; +import { beforeEach, describe, expect, it } from "vitest"; +import { formatOpsStatusReport, getOpsStatusExitCode, ops, runOpsStatus } from "../commands/ops.js"; +import type { OpsStatusFetch } from "../commands/ops.js"; + +describe("ops status", () => { + beforeEach(() => { + Container.reset(); + }); + + it("fetches the standard operational endpoints and summarizes a healthy app", async () => { + const calls: FetchCall[] = []; + const fetchStatus: OpsStatusFetch = async (input, init) => { + calls.push({ input, init }); + + if (input.endsWith("/health")) { + return Response.json({ status: "ok" }); + } + if (input.endsWith("/ready")) { + return Response.json({ status: "up", results: [] }); + } + if (input.endsWith("/diagnostics")) { + return Response.json({ summary: "all_healthy", components: [], recentErrors: [] }); + } + + return Response.json({ + timestamp: "2026-06-16T00:00:00.000Z", + metrics: { + standardEndpointPathCount: 7, + healthCheckCount: 0, + }, + }); + }; + + const report = await runOpsStatus("http://localhost:3000/api", { + fetch: fetchStatus, + token: "ops-secret", + timeoutMs: 1000, + }); + + expect(report.summary).toBe("healthy"); + expect(report.endpoints.map((endpoint) => endpoint.url)).toEqual([ + "http://localhost:3000/api/health", + "http://localhost:3000/api/ready", + "http://localhost:3000/api/diagnostics", + "http://localhost:3000/api/metrics", + ]); + expect(calls).toHaveLength(4); + expect(new Headers(calls[0].init?.headers).get("X-Diagnostics-Token")).toBeNull(); + expect(new Headers(calls[1].init?.headers).get("X-Diagnostics-Token")).toBeNull(); + expect(new Headers(calls[2].init?.headers).get("X-Diagnostics-Token")).toBe("ops-secret"); + expect(new Headers(calls[3].init?.headers).get("X-Diagnostics-Token")).toBeNull(); + expect(formatOpsStatusReport(report)).toContain("Croco ops status: healthy"); + }); + + it("marks unavailable optional operational endpoints as degraded", async () => { + const fetchStatus: OpsStatusFetch = async (input) => { + if (input.endsWith("/health")) { + return Response.json({ status: "ok" }); + } + if (input.endsWith("/ready")) { + return Response.json({ status: "up", results: [] }); + } + + return Response.json({ error: "Not Found" }, { status: 404 }); + }; + + const report = await runOpsStatus("http://localhost:3000", { fetch: fetchStatus }); + + expect(report.summary).toBe("degraded"); + expect(formatOpsStatusReport(report)).toContain("diagnostics 404 unavailable"); + }); + + it("marks a failing readiness endpoint as unhealthy", async () => { + const fetchStatus: OpsStatusFetch = async (input) => { + if (input.endsWith("/ready")) { + return Response.json({ status: "down", results: [] }, { status: 503 }); + } + + return Response.json({ status: "ok" }); + }; + + const report = await runOpsStatus("http://localhost:3000", { fetch: fetchStatus }); + + expect(report.summary).toBe("unhealthy"); + }); + + it("registers the status subcommand under ops", () => { + expect(Object.keys(ops.subCommands ?? {})).toEqual(["status"]); + }); + + it("uses non-zero exit codes for degraded and unhealthy summaries", () => { + expect(getOpsStatusExitCode("healthy")).toBe(0); + expect(getOpsStatusExitCode("degraded")).toBe(1); + expect(getOpsStatusExitCode("unhealthy")).toBe(1); + }); + + it("reports invalid inputs as Problem details", async () => { + await expect(runOpsStatus("not-a-url")).rejects.toMatchObject({ + code: "cli/invalid-ops-target-url", + status: 400, + }); + await expect(runOpsStatus("http://localhost:3000", { timeoutMs: 0 })).rejects.toMatchObject({ + code: "cli/invalid-ops-timeout", + status: 400, + }); + }); +}); + +type FetchCall = { + readonly input: string; + readonly init?: RequestInit; +}; diff --git a/packages/docs/src/content/docs/api/transports-http/src/classes/HealthCheckRegistry.md b/packages/docs/src/content/docs/api/transports-http/src/classes/HealthCheckRegistry.md index be65c369a..853c22a48 100644 --- a/packages/docs/src/content/docs/api/transports-http/src/classes/HealthCheckRegistry.md +++ b/packages/docs/src/content/docs/api/transports-http/src/classes/HealthCheckRegistry.md @@ -29,6 +29,16 @@ HTTP 애플리케이션 구성과 라우트 실행에 사용하는 핵심 공개 *** +### getRegisteredCheckCount() + +> **getRegisteredCheckCount**(): `number` + +#### Returns + +`number` + +*** + ### register() > **register**(`name`, `check`, `options?`): `void` diff --git a/packages/docs/src/content/docs/api/transports-http/src/type-aliases/OperationalMetricsResponse.md b/packages/docs/src/content/docs/api/transports-http/src/type-aliases/OperationalMetricsResponse.md new file mode 100644 index 000000000..d39e4117a --- /dev/null +++ b/packages/docs/src/content/docs/api/transports-http/src/type-aliases/OperationalMetricsResponse.md @@ -0,0 +1,28 @@ +--- +editUrl: false +next: false +prev: false +title: "OperationalMetricsResponse" +--- + +> **OperationalMetricsResponse** = `object` + +## Properties + +### metrics + +> `readonly` **metrics**: `object` + +#### healthCheckCount + +> `readonly` **healthCheckCount**: `number` + +#### standardEndpointPathCount + +> `readonly` **standardEndpointPathCount**: `number` + +*** + +### timestamp + +> `readonly` **timestamp**: `string` diff --git a/packages/docs/src/content/docs/api/transports-http/src/variables/METRICS_ENDPOINT_PATH.md b/packages/docs/src/content/docs/api/transports-http/src/variables/METRICS_ENDPOINT_PATH.md new file mode 100644 index 000000000..3489e1c6a --- /dev/null +++ b/packages/docs/src/content/docs/api/transports-http/src/variables/METRICS_ENDPOINT_PATH.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "METRICS_ENDPOINT_PATH" +--- + +> `const` **METRICS\_ENDPOINT\_PATH**: `"/metrics"` = `"/metrics"` diff --git a/packages/docs/src/content/docs/api/transports-http/src/variables/OPERATIONAL_ENDPOINT_PATHS.md b/packages/docs/src/content/docs/api/transports-http/src/variables/OPERATIONAL_ENDPOINT_PATHS.md new file mode 100644 index 000000000..71c5335d4 --- /dev/null +++ b/packages/docs/src/content/docs/api/transports-http/src/variables/OPERATIONAL_ENDPOINT_PATHS.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "OPERATIONAL_ENDPOINT_PATHS" +--- + +> `const` **OPERATIONAL\_ENDPOINT\_PATHS**: readonly \[`"/health"`, `"/health/live"`, `"/ready"`, `"/health/ready"`, `"/diagnostics"`, `"/health/diagnostics"`, `"/metrics"`\] diff --git a/packages/docs/src/content/docs/api/transports-http/src/variables/STANDARD_DIAGNOSTICS_ENDPOINT_PATH.md b/packages/docs/src/content/docs/api/transports-http/src/variables/STANDARD_DIAGNOSTICS_ENDPOINT_PATH.md new file mode 100644 index 000000000..957a3fb53 --- /dev/null +++ b/packages/docs/src/content/docs/api/transports-http/src/variables/STANDARD_DIAGNOSTICS_ENDPOINT_PATH.md @@ -0,0 +1,8 @@ +--- +editUrl: false +next: false +prev: false +title: "STANDARD_DIAGNOSTICS_ENDPOINT_PATH" +--- + +> `const` **STANDARD\_DIAGNOSTICS\_ENDPOINT\_PATH**: `"/diagnostics"` = `"/diagnostics"` diff --git a/packages/transports-http/src/index.ts b/packages/transports-http/src/index.ts index 4a4bb52e0..61dbe4cc5 100644 --- a/packages/transports-http/src/index.ts +++ b/packages/transports-http/src/index.ts @@ -73,7 +73,13 @@ export type { HealthCheckStatus, } from "./libs/HealthCheckRegistry"; -export { DIAGNOSTICS_ENDPOINT_PATH, DIAGNOSTICS_TOKEN_HEADER } from "./libs/operationalEndpoints"; +export { + DIAGNOSTICS_ENDPOINT_PATH, + DIAGNOSTICS_TOKEN_HEADER, + METRICS_ENDPOINT_PATH, + OPERATIONAL_ENDPOINT_PATHS, + STANDARD_DIAGNOSTICS_ENDPOINT_PATH, +} from "./libs/operationalEndpoints"; export type { DiagnosticsAccessContext, @@ -81,6 +87,7 @@ export type { DiagnosticsExposureMode, DiagnosticsGuard, OperationalLivenessResponse, + OperationalMetricsResponse, SafeDiagnosticsErrorRecord, SafeDiagnosticsReport, } from "./libs/operationalEndpoints"; diff --git a/packages/transports-http/src/libs/CrocoApp.ts b/packages/transports-http/src/libs/CrocoApp.ts index 284dbe8fa..291c2c086 100644 --- a/packages/transports-http/src/libs/CrocoApp.ts +++ b/packages/transports-http/src/libs/CrocoApp.ts @@ -13,7 +13,10 @@ import { HealthCheckRegistry } from "./HealthCheckRegistry"; import { PipelineRunner } from "./PipelineRunner"; import { DIAGNOSTICS_ENDPOINT_PATH, + METRICS_ENDPOINT_PATH, + STANDARD_DIAGNOSTICS_ENDPOINT_PATH, authorizeDiagnosticsRequest, + createOperationalMetricsResponse, createDefaultDiagnosticsCollector, resolveDiagnosticsEndpointPolicy, sanitizeDiagnosticsReport, @@ -181,17 +184,30 @@ export class CrocoApp { if (diagnosticsPolicy.exposure !== "off") { const collector = diagnosticsPolicy.collector ?? createDefaultDiagnosticsCollector(); - this.hono.get(DIAGNOSTICS_ENDPOINT_PATH, async (c) => { - if (!(await authorizeDiagnosticsRequest(c, diagnosticsPolicy))) { - return c.json({ error: "Forbidden" }, 403, { "Cache-Control": "no-store" }); - } + const registerDiagnosticsRoute = (path: string): void => { + this.hono.get(path, async (c) => { + if (!(await authorizeDiagnosticsRequest(c, diagnosticsPolicy))) { + return c.json({ error: "Forbidden" }, 403, { "Cache-Control": "no-store" }); + } - const report = await collector.getReport(); - return c.json(sanitizeDiagnosticsReport(report, diagnosticsPolicy), 200, { - "Cache-Control": "no-store", + const report = await collector.getReport(); + return c.json(sanitizeDiagnosticsReport(report, diagnosticsPolicy), 200, { + "Cache-Control": "no-store", + }); }); - }); + }; + + registerDiagnosticsRoute(STANDARD_DIAGNOSTICS_ENDPOINT_PATH); + registerDiagnosticsRoute(DIAGNOSTICS_ENDPOINT_PATH); } + + this.hono.get(METRICS_ENDPOINT_PATH, (c) => + c.json( + createOperationalMetricsResponse(this.healthCheckRegistry.getRegisteredCheckCount()), + 200, + { "Cache-Control": "no-store" }, + ), + ); } lambdaHandler(): LambdaHandler { diff --git a/packages/transports-http/src/libs/HealthCheckRegistry.ts b/packages/transports-http/src/libs/HealthCheckRegistry.ts index 150b05f16..49ab35587 100644 --- a/packages/transports-http/src/libs/HealthCheckRegistry.ts +++ b/packages/transports-http/src/libs/HealthCheckRegistry.ts @@ -42,6 +42,10 @@ export class HealthCheckRegistry { this.service.register(new RegisteredHealthCheckIndicator(name, check), options); } + getRegisteredCheckCount(): number { + return this.checks.size; + } + async check(): Promise { return this.service.check(); } diff --git a/packages/transports-http/src/libs/operationalEndpoints.ts b/packages/transports-http/src/libs/operationalEndpoints.ts index ca3985e5f..14fe85136 100644 --- a/packages/transports-http/src/libs/operationalEndpoints.ts +++ b/packages/transports-http/src/libs/operationalEndpoints.ts @@ -1,14 +1,27 @@ import { DiagnosticsCollector, type DiagnosticsReport, + type DiagnosticsProvider, type ErrorRecord, + type HealthStatus, } from "@croco/diagnostics-core"; import { EventBusDiagnosticsProvider } from "@croco/events-core"; import { ContainerDiagnosticsProvider } from "@croco/framework-context"; import type { Context as HonoContext } from "hono"; export const DIAGNOSTICS_ENDPOINT_PATH = "/health/diagnostics"; +export const STANDARD_DIAGNOSTICS_ENDPOINT_PATH = "/diagnostics"; +export const METRICS_ENDPOINT_PATH = "/metrics"; export const DIAGNOSTICS_TOKEN_HEADER = "X-Diagnostics-Token"; +export const OPERATIONAL_ENDPOINT_PATHS = [ + "/health", + "/health/live", + "/ready", + "/health/ready", + STANDARD_DIAGNOSTICS_ENDPOINT_PATH, + DIAGNOSTICS_ENDPOINT_PATH, + METRICS_ENDPOINT_PATH, +] as const; const DEFAULT_RECENT_ERROR_LIMIT = 100; const DEFAULT_MESSAGE_LIMIT = 100; @@ -44,6 +57,14 @@ export type OperationalLivenessResponse = { readonly status: "ok"; }; +export type OperationalMetricsResponse = { + readonly timestamp: string; + readonly metrics: { + readonly standardEndpointPathCount: number; + readonly healthCheckCount: number; + }; +}; + export type SafeDiagnosticsErrorRecord = Omit; export type SafeDiagnosticsReport = Omit & { @@ -52,6 +73,7 @@ export type SafeDiagnosticsReport = Omit & { export function createDefaultDiagnosticsCollector(): DiagnosticsCollector { const collector = new DiagnosticsCollector(); + collector.registerProvider(new RuntimeDiagnosticsProvider()); try { collector.registerProvider(new ContainerDiagnosticsProvider()); @@ -68,6 +90,28 @@ export function createDefaultDiagnosticsCollector(): DiagnosticsCollector { return collector; } +class RuntimeDiagnosticsProvider implements DiagnosticsProvider { + readonly name = "runtime"; + + async getHealth(): Promise { + return { + status: "healthy", + component: "runtime", + details: getRuntimeMetadata(), + lastChecked: new Date().toISOString(), + }; + } +} + +function getRuntimeMetadata(): Record { + return { + runtime: "node", + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }; +} + export function resolveDiagnosticsEndpointPolicy( options: DiagnosticsEndpointOptions | undefined, env: NodeJS.ProcessEnv = process.env, @@ -139,6 +183,18 @@ export function sanitizeDiagnosticsReport( }; } +export function createOperationalMetricsResponse( + healthCheckCount: number, +): OperationalMetricsResponse { + return { + timestamp: new Date().toISOString(), + metrics: { + standardEndpointPathCount: OPERATIONAL_ENDPOINT_PATHS.length, + healthCheckCount, + }, + }; +} + function parseDiagnosticsExposure(value: string | undefined): DiagnosticsExposureMode | undefined { if (value === "off" || value === "private" || value === "token" || value === "custom") { return value; diff --git a/packages/transports-http/src/tests/OperationalEndpoints.spec.ts b/packages/transports-http/src/tests/OperationalEndpoints.spec.ts index 8aae8491e..9cbde4c52 100644 --- a/packages/transports-http/src/tests/OperationalEndpoints.spec.ts +++ b/packages/transports-http/src/tests/OperationalEndpoints.spec.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../libs/CrocoApp"; import { ErrorHandler } from "../libs/ErrorHandler"; import { HealthCheckRegistry } from "../libs/HealthCheckRegistry"; +import { createDefaultDiagnosticsCollector } from "../libs/operationalEndpoints"; import type { DiagnosticsEndpointOptions } from "../libs/operationalEndpoints"; class StaticDiagnosticsProvider { @@ -82,6 +83,50 @@ describe("Operational endpoints", () => { expect(validTokenResponse.headers.get("cache-control")).toBe("no-store"); }); + it("serves diagnostics from the canonical and legacy endpoint paths", async () => { + const collector = new DiagnosticsCollector(); + collector.registerProvider(new StaticDiagnosticsProvider()); + const app = createApp({ + controllers: [], + diagnostics: { + exposure: "private", + collector, + }, + }); + + const canonical = await app.fetch(new Request("http://localhost/diagnostics")); + const legacy = await app.fetch(new Request("http://localhost/health/diagnostics")); + + expect(canonical.status).toBe(200); + expect(legacy.status).toBe(200); + await expect(canonical.json()).resolves.toMatchObject({ + summary: "degraded", + components: [{ component: "static" }], + }); + await expect(legacy.json()).resolves.toMatchObject({ + summary: "degraded", + components: [{ component: "static" }], + }); + }); + + it("returns minimal operational metrics without exposing diagnostics details", async () => { + const registry = Container.get(HealthCheckRegistry); + registry.register("db", async () => ({ status: "up", latency: 10 })); + const app = createApp({ controllers: [] }); + + const response = await app.fetch(new Request("http://localhost/metrics")); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ + timestamp: expect.any(String), + metrics: { + standardEndpointPathCount: 7, + healthCheckCount: 1, + }, + }); + }); + it("supports custom diagnostics guards", async () => { const app = createApp({ controllers: [], @@ -163,6 +208,25 @@ describe("Operational endpoints", () => { }); }); + it("includes runtime metadata in the default diagnostics collector", async () => { + const collector = createDefaultDiagnosticsCollector(); + + const report = await collector.getReport(); + + expect(report.components).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "healthy", + component: "runtime", + details: expect.objectContaining({ + runtime: "node", + nodeVersion: expect.any(String), + }), + }), + ]), + ); + }); + it("keeps the legacy environment token mode", async () => { vi.stubEnv("CROCO_DIAGNOSTICS_ENABLED", "true"); vi.stubEnv("CROCO_DIAGNOSTICS_TOKEN", "legacy-token"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 232c48c15..38d982789 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,9 @@ importers: '@croco/openapi-spec': specifier: workspace:* version: link:../openapi-spec + '@croco/problems-core': + specifier: workspace:* + version: link:../problems-core '@croco/rpc-codegen': specifier: workspace:* version: link:../rpc-codegen