-
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
Open
Harsh23Kashyap
wants to merge
8
commits into
sourcebot-dev:main
Choose a base branch
from
Harsh23Kashyap:feat/health-process-info
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e1bdc0a
feat(web): enrich /api/health with process info (version, uptime, sta…
Harsh23Kashyap a8e337e
test(web): cover the new /api/health process-info shape
Harsh23Kashyap 133dbc9
docs(openapi): sync PublicHealthResponse with the new /api/health shape
Harsh23Kashyap 6a342bc
chore: changelog entry for /api/health process-info enrichment
Harsh23Kashyap 5903559
fix(web): anchor /api/health uptime to process boot, not module load
Harsh23Kashyap 465b33d
chore: condense /api/health changelog entry to one sentence with PR link
Harsh23Kashyap 286fbbd
test(web): widen startedAt recency window in /api/health test
Harsh23Kashyap ec9292e
fix(web): opt /api/health out of Next.JS build-time cache
Harsh23Kashyap File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,61 @@ | ||
| '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; | ||
| }; | ||
| }; | ||
|
|
||
| // In Next.JS 14, GET methods with no params are cached by default at build | ||
| // time. The /api/health response carries live process fields (uptime, | ||
| // pid, startedAt) that must reflect the running server, so opt out of | ||
| // the build-time cache. Same pattern as /api/version. | ||
| // @see: https://nextjs.org/docs/14/app/building-your-application/routing/route-handlers#caching | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| // 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); | ||
|
Harsh23Kashyap marked this conversation as resolved.
|
||
| }, { track: false }); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.