-
Notifications
You must be signed in to change notification settings - Fork 330
feat(web): enrich /api/health with process info (version, uptime, startedAt, node, pid) #1514
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: main
Are you sure you want to change the base?
Changes from all commits
e1bdc0a
a8e337e
133dbc9
6a342bc
5903559
465b33d
286fbbd
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,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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> => { | ||
| 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); | ||
|
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. Health route missing force-dynamicMedium Severity
Reviewed by Cursor Bugbot for commit 286fbbd. Configure here. |
||
| }, { track: false }); | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.