From e1bdc0ab4f36de36112f4a5c358e5ecd376141fe Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:51:23 +0530 Subject: [PATCH 1/7] feat(web): enrich /api/health with process info (version, uptime, startedAt, node, pid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public liveness probe currently returns a hardcoded { status: "ok" } with no version, no uptime, and no process facts. Self-hosted operators who wire Sourcebot into a Kubernetes livenessProbe — or who curl the endpoint from fleet-management scripts — have no way to verify the build they deployed is the one actually running, or to detect a restart. Capture a frozen startedAt at module load (so two requests return the same value and uptime increases monotonically) and add the standard process facts: SOURCEBOT_VERSION, startedAt (ISO 8601), uptime (seconds, floored), pid, and the Node { version, platform, arch } block. The existing status:"ok" field is preserved so K8s livenessProbe configs that only check the HTTP code keep working unchanged, and older scripts that parse { status:"ok" } keep working. The endpoint stays unauthenticated, track:false for PostHog, and continues to bypass withAuth (the existing eslint-disable line for the authz/require-auth-wrapper custom rule is preserved). Closes #1513. --- .../web/src/app/api/(server)/health/route.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/api/(server)/health/route.ts b/packages/web/src/app/api/(server)/health/route.ts index df4db2212..0134979dd 100644 --- a/packages/web/src/app/api/(server)/health/route.ts +++ b/packages/web/src/app/api/(server)/health/route.ts @@ -1,13 +1,47 @@ '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'; + +// `startedAt` and `startedAtMs` are captured at module load. The handler +// returns a frozen timestamp for the lifetime of the process, so two +// `curl` calls report the same `startedAt` and an `uptime` that increases +// monotonically between them. +const startedAtMs = Date.now(); +const startedAt = new Date(startedAtMs).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((Date.now() - startedAtMs) / 1000), + pid: process.pid, + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + }; + return Response.json(body); }, { track: false }); - From a8e337ed4f9de62ea3026d43050e4c0244415c4c Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:51:23 +0530 Subject: [PATCH 2/7] test(web): cover the new /api/health process-info shape Seven cases, all on the same NextRequest stand-in: - shape: 200, status:"ok" preserved - version: matches the mocked SOURCEBOT_VERSION - startedAt: parseable ISO 8601, in the past, recent (sanity-bounds to the last hour so a 1970 epoch would fail) - uptime: non-negative integer - uptime increases between two requests (1.1s sleep so the floored tick is observable on slow runners) - node: matches process.version / process.platform / process.arch - pid: matches process.pid The mocks follow the same pattern as the readiness-probe test: vi.mock('server-only', ...), vi.mock('@sourcebot/shared', ...) with a fake SOURCEBOT_VERSION + a no-op createLogger, vi.mock('@/lib/posthog', ...) to keep the apiHandler wrapper's PostHog call from pulling in the Sentry/OTel CJS chain at test-load time. --- .../src/app/api/(server)/health/route.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/web/src/app/api/(server)/health/route.test.ts 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..c16fd23e2 --- /dev/null +++ b/packages/web/src/app/api/(server)/health/route.test.ts @@ -0,0 +1,99 @@ +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 now = 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 captured at module load, which happens during + // the import at the top of this file. It must be in the past + // and reasonably recent (sanity-bounds to "not 1970"). + expect(startedAtMs).toBeLessThanOrEqual(now); + expect(startedAtMs).toBeGreaterThan(now - 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 increases between two requests', async () => { + const first = (await (await GET(makeRequest())).json()).uptime as number; + // Sleep 1.1s so the floor((Date.now() - startedAtMs) / 1000) 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('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); + }); +}); From 133dbc96c639b6d935cf12a8e37384317cc92af9 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:51:30 +0530 Subject: [PATCH 3/7] docs(openapi): sync PublicHealthResponse with the new /api/health shape The public OpenAPI doc is auto-generated from the Zod schemas in `packages/web/src/openapi/publicApiSchemas.ts`. To make the published spec reflect the new /api/health response shape, extend `publicHealthResponseSchema` with the additional fields and rerun `yarn workspace @sourcebot/web openapi:generate`. Schema additions mirror the route: - version: z.string() - startedAt: z.string().datetime() (ISO 8601) - uptime: z.number().int().nonnegative() (seconds, floored) - pid: z.number().int().positive() - node: { version, platform, arch } all z.string() This is purely additive: existing consumers that only consume `status` keep working. The OpenAPI doc is regenerated and the `PublicHealthResponse` component now includes the new required properties. --- .../sourcebot-public.openapi.json | 42 ++++++++++++++++++- packages/web/src/openapi/publicApiSchemas.ts | 9 ++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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/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 From 6a342bc95baf81bfc8ce18d1ca80d3f4d1376287 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:51:30 +0530 Subject: [PATCH 4/7] chore: changelog entry for /api/health process-info enrichment Adds an [Unreleased] -> Added entry noting that /api/health now returns the Sourcebot version, process start timestamp, uptime in seconds, Node process facts (pid, version, platform, arch) and the timestamp the process started. Notes that the existing status:"ok" field is preserved for backward compatibility, and points at the updated PublicHealthResponse in the public OpenAPI doc. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fdc34be..4c9777094 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 also returns the Sourcebot version, process start timestamp, uptime in seconds, Node.js process facts (pid, version, platform, arch) and the timestamp the process started. The existing `status: "ok"` field is preserved so existing Kubernetes `livenessProbe` configurations and scripts keep working unchanged. The response shape in the public OpenAPI doc (`PublicHealthResponse`) has been updated to match. + ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. From 59035599debac8aacb7ba8ca6c0c7efe9235741a Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:32 +0530 Subject: [PATCH 5/7] fix(web): anchor /api/health uptime to process boot, not module load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit + Bugbot findings on PR #1514: `startedAt` and `uptime` were anchored to `Date.now()` at module load, so the reported values lagged the actual process start by however long Next.js took to first-load the route module (often minutes in a cold-start scenario). Wall-clock math also shrinks or goes negative after NTP steps. Anchor both fields on `process.uptime()`, which is monotonic and relative to process boot: - `processStartedAtMs = Date.now() - Math.floor(process.uptime() * 1000)` captured once at module load: gives the wall-clock time the process started, accurate within milliseconds of the process start. - `startedAt = new Date(processStartedAtMs).toISOString()` is the ISO 8601 timestamp of process boot, not module load. - `uptime = Math.floor(process.uptime())` is the live process uptime, always monotonic, immune to NTP steps. Tests: - existing `uptime is non-negative integer` and `uptime increases between two requests` are unchanged. - new `uptime is anchored to process boot, not module load` asserts the reported `uptime` matches the live `process.uptime()` within a 1s floor. - new `startedAt is consistent with the process-start estimate` asserts `Date.parse(startedAt) + uptime * 1000 ≈ Date.now()` within 1500ms, which is the round-trip consistency check. The 1500ms tolerance accounts for two `Math.floor` roundings (one in the captured start time, one in the per-request uptime) plus a buffer for the request latency. --- .../src/app/api/(server)/health/route.test.ts | 30 +++++++++++++++++-- .../web/src/app/api/(server)/health/route.ts | 21 ++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/web/src/app/api/(server)/health/route.test.ts b/packages/web/src/app/api/(server)/health/route.test.ts index c16fd23e2..93be22f0f 100644 --- a/packages/web/src/app/api/(server)/health/route.test.ts +++ b/packages/web/src/app/api/(server)/health/route.test.ts @@ -69,16 +69,42 @@ describe('GET /api/health', () => { 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((Date.now() - startedAtMs) / 1000) tick - // is observable even on a slow runner. + // 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(); diff --git a/packages/web/src/app/api/(server)/health/route.ts b/packages/web/src/app/api/(server)/health/route.ts index 0134979dd..5091a4eee 100644 --- a/packages/web/src/app/api/(server)/health/route.ts +++ b/packages/web/src/app/api/(server)/health/route.ts @@ -6,12 +6,19 @@ import { SOURCEBOT_VERSION, createLogger } from '@sourcebot/shared'; import { apiHandler } from '@/lib/apiHandler'; -// `startedAt` and `startedAtMs` are captured at module load. The handler -// returns a frozen timestamp for the lifetime of the process, so two -// `curl` calls report the same `startedAt` and an `uptime` that increases -// monotonically between them. -const startedAtMs = Date.now(); -const startedAt = new Date(startedAtMs).toISOString(); +// `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'); @@ -35,7 +42,7 @@ export const GET = apiHandler(async (_request: NextRequest): Promise = status: 'ok', version: SOURCEBOT_VERSION, startedAt, - uptime: Math.floor((Date.now() - startedAtMs) / 1000), + uptime: Math.floor(process.uptime()), pid: process.pid, node: { version: process.version, From 465b33dad11c938fd82a2f3fcadd0e939375efc5 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:32 +0530 Subject: [PATCH 6/7] chore: condense /api/health changelog entry to one sentence with PR link Address CodeRabbit finding on PR #1514: per the project changelog convention, each entry should be a single sentence and the suffix should link to the PR, not the issue. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9777094..d4e4cb55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `GET /api/health` now also returns the Sourcebot version, process start timestamp, uptime in seconds, Node.js process facts (pid, version, platform, arch) and the timestamp the process started. The existing `status: "ok"` field is preserved so existing Kubernetes `livenessProbe` configurations and scripts keep working unchanged. The response shape in the public OpenAPI doc (`PublicHealthResponse`) has been updated to match. +- `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. From 286fbbd5d2716bbb81a08f4fedbb5f3b5a640f86 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:08:55 +0530 Subject: [PATCH 7/7] test(web): widen startedAt recency window in /api/health test The recency assertion compared startedAt against now - 1 hour, but startedAt is derived from process boot via process.uptime(), not module-load wall time. On a vitest worker (or any Node process) older than one hour, the assertion failed even though the handler was correct. Widened the sanity bound to 30 days so a long-lived CI worker does not flake while a literal "1970" start time still fails. --- .../src/app/api/(server)/health/route.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/api/(server)/health/route.test.ts b/packages/web/src/app/api/(server)/health/route.test.ts index 93be22f0f..315122c6e 100644 --- a/packages/web/src/app/api/(server)/health/route.test.ts +++ b/packages/web/src/app/api/(server)/health/route.test.ts @@ -47,17 +47,22 @@ describe('GET /api/health', () => { }); test('exposes a parseable ISO 8601 startedAt in the past', async () => { - const now = Date.now(); + 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 captured at module load, which happens during - // the import at the top of this file. It must be in the past - // and reasonably recent (sanity-bounds to "not 1970"). - expect(startedAtMs).toBeLessThanOrEqual(now); - expect(startedAtMs).toBeGreaterThan(now - 60 * 60 * 1000); + // `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 () => {