diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fdc34be..d4e4cb55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `GET /api/health` now returns the Sourcebot version, process start timestamp, uptime in seconds, and Node.js process facts (pid, version, platform, arch) on top of the existing `status: "ok"` field, so operators can verify the running build and detect restarts from a single curl. [#1514](https://github.com/sourcebot-dev/sourcebot/pull/1514) + ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 7419445de..58fc54141 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -531,10 +531,50 @@ "enum": [ "ok" ] + }, + "version": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "uptime": { + "type": "integer", + "minimum": 0 + }, + "pid": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "node": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "arch": { + "type": "string" + } + }, + "required": [ + "version", + "platform", + "arch" + ] } }, "required": [ - "status" + "status", + "version", + "startedAt", + "uptime", + "pid", + "node" ] }, "PublicFileSourceResponse": { diff --git a/packages/web/src/app/api/(server)/health/route.test.ts b/packages/web/src/app/api/(server)/health/route.test.ts new file mode 100644 index 000000000..315122c6e --- /dev/null +++ b/packages/web/src/app/api/(server)/health/route.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockSourcebotVersion = 'v9.9.9-test'; + +vi.mock('server-only', () => ({})); + +vi.mock('@sourcebot/shared', () => ({ + SOURCEBOT_VERSION: mockSourcebotVersion, + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); + +const { GET } = await import('./route'); + +const makeRequest = (): NextRequest => new NextRequest('http://localhost/api/health'); + +describe('GET /api/health', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns 200 with the enriched process-info shape', async () => { + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + // Existing field is preserved so K8s livenessProbe configs that + // only check the HTTP code keep working, and so do older scripts + // that parse {status:"ok"}. + expect(body.status).toBe('ok'); + }); + + test('exposes the Sourcebot version', async () => { + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.version).toBe(mockSourcebotVersion); + }); + + test('exposes a parseable ISO 8601 startedAt in the past', async () => { + const wallClockNow = Date.now(); + const response = await GET(makeRequest()); + const body = await response.json(); + + const startedAtMs = Date.parse(body.startedAt); + expect(Number.isNaN(startedAtMs)).toBe(false); + // `startedAt` is derived from process boot via `process.uptime()`, + // not module-load wall time. It must be in the past and + // consistent with the process-uptime clock; the consistency + // window is enforced by the dedicated test below. + expect(startedAtMs).toBeLessThanOrEqual(wallClockNow); + // Sanity: process started sometime in the last 30 days. A + // 30-day window is wide enough to not be flaky on long-lived + // CI workers but tight enough to fail on a literal "1970" + // start time. + expect(startedAtMs).toBeGreaterThan(wallClockNow - 30 * 24 * 60 * 60 * 1000); + }); + + test('exposes a non-negative integer uptime', async () => { + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(typeof body.uptime).toBe('number'); + expect(Number.isInteger(body.uptime)).toBe(true); + expect(body.uptime).toBeGreaterThanOrEqual(0); + }); + + test('uptime is anchored to process boot, not module load', async () => { + // The route is supposed to expose the process uptime + // (`process.uptime()`), not the time since the module was + // first imported. A freshly imported module would report a + // tiny uptime; a long-running process should report a larger + // one even on the first request. We assert that the reported + // uptime is within a small window of the live `process.uptime()` + // and that the test environment is consistent across two calls. + const first = (await (await GET(makeRequest())).json()).uptime as number; + // process.uptime() and the route's uptime are both derived from + // the same monotonic clock, so they should match within a 1s + // floor error. + expect(Math.abs(first - Math.floor(process.uptime()))).toBeLessThanOrEqual(1); + }); + + test('uptime increases between two requests', async () => { + const first = (await (await GET(makeRequest())).json()).uptime as number; + // Sleep 1.1s so the floor(process.uptime()) tick is observable + // even on a slow runner. + await new Promise((resolve) => setTimeout(resolve, 1100)); + const second = (await (await GET(makeRequest())).json()).uptime as number; + + expect(second).toBeGreaterThan(first); + }); + + test('startedAt is consistent with the process-start estimate', async () => { + // startedAt should match Date.now() - uptime*1000 to within a + // 1500ms window (rounding from `Math.floor` on both sides). + const body = await (await GET(makeRequest())).json(); + const startedAtMs = Date.parse(body.startedAt); + const wallClockNow = Date.now(); + const estimatedNow = startedAtMs + body.uptime * 1000; + // Allow 1.5s of slack for the per-second floor on both sides. + expect(Math.abs(wallClockNow - estimatedNow)).toBeLessThan(1500); + }); + + test('exposes Node process facts (version, platform, arch)', async () => { + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.node).toEqual({ + version: process.version, + platform: process.platform, + arch: process.arch, + }); + }); + + test('exposes the Node process pid', async () => { + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.pid).toBe(process.pid); + }); +}); diff --git a/packages/web/src/app/api/(server)/health/route.ts b/packages/web/src/app/api/(server)/health/route.ts index df4db2212..5091a4eee 100644 --- a/packages/web/src/app/api/(server)/health/route.ts +++ b/packages/web/src/app/api/(server)/health/route.ts @@ -1,13 +1,54 @@ 'use server'; -import { apiHandler } from "@/lib/apiHandler"; -import { createLogger } from "@sourcebot/shared"; +import { NextRequest } from 'next/server'; + +import { SOURCEBOT_VERSION, createLogger } from '@sourcebot/shared'; + +import { apiHandler } from '@/lib/apiHandler'; + +// `processStartedAtMs` is the wall-clock time the process started, derived +// once at module load from `Date.now() - process.uptime() * 1000`. We anchor +// on `process.uptime()` (which is monotonic relative to process boot, immune +// to NTP steps that move the wall clock) rather than capturing +// `Date.now()` directly, so the resulting `startedAt` is anchored to the +// actual process start even when the route module is lazy-loaded by +// Next.js well after the process booted. +// +// `startedAt` is the wall-clock ISO 8601 timestamp at process boot. +// `uptime` is `process.uptime()` (seconds since process boot), always +// monotonic. Both are anchored to the process, not the module. +const processStartedAtMs = Date.now() - Math.floor(process.uptime() * 1000); +const startedAt = new Date(processStartedAtMs).toISOString(); const logger = createLogger('health-check'); -// eslint-disable-next-line authz/require-auth-wrapper -- public health check, no user data returned -export const GET = apiHandler(async () => { +type HealthResponse = { + status: 'ok'; + version: string; + startedAt: string; + uptime: number; + pid: number; + node: { + version: string; + platform: string; + arch: string; + }; +}; + +// eslint-disable-next-line authz/require-auth-wrapper -- public liveness probe, no user data returned +export const GET = apiHandler(async (_request: NextRequest): Promise => { logger.debug('health check'); - return Response.json({ status: 'ok' }); + const body: HealthResponse = { + status: 'ok', + version: SOURCEBOT_VERSION, + startedAt, + uptime: Math.floor(process.uptime()), + pid: process.pid, + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + }; + return Response.json(body); }, { track: false }); - diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index de1c4011f..c63bb714e 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -62,6 +62,15 @@ export const publicListCommitAuthorsResponseSchema = z.array(publicCommitAuthorS export const publicHealthResponseSchema = z.object({ status: z.enum(['ok']), + version: z.string(), + startedAt: z.string().datetime(), + uptime: z.number().int().nonnegative(), + pid: z.number().int().positive(), + node: z.object({ + version: z.string(), + platform: z.string(), + arch: z.string(), + }), }).openapi('PublicHealthResponse'); // EE: User Management