diff --git a/package-lock.json b/package-lock.json index 622dd94..aba1a53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "@opentelemetry/api": "1.9.1", diff --git a/package.json b/package.json index 0f38527..ac4c180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "description": "Shared observability kit for agentage services: OTLP trace bootstrap (node --import), pino logger preset with trace correlation, one-call error capture, and the estate /health envelope.", "type": "module", "license": "MIT", diff --git a/src/health.ts b/src/health.ts index b041abd..1f37025 100644 --- a/src/health.ts +++ b/src/health.ts @@ -59,12 +59,24 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now( /** One decimal: 0.3ms (memoized) and 0ms (not run) must not round to the same number. */ const since = (start: number): number => Math.round((now() - start) * 10) / 10; -// process.uptime() is the real process start. A module-level `new Date()` is only -// module-LOAD time, and a lazily-imported Next route handler can load minutes after -// boot - reporting a fresh uptime for a container that has been up for hours. +// Real process start, not module-LOAD time: a lazily-imported Next route handler can +// load minutes after boot and would otherwise report a fresh uptime for a container +// that has been up for hours. +// +// performance.timeOrigin, NOT Node's process-uptime API. Both give the same instant to +// the millisecond, but that one is Node-only, and Next detects Node APIs in Edge +// Runtime bundles STATICALLY - a `typeof` guard does not save you, because the check +// never runs. This module is re-exported from shared barrels that middleware imports, +// so a single Node API here 500s every gated route in an app that never knowingly +// touched the health kit. That happened (admin, 2026-08-10). timeOrigin is a Web API +// present in Node, the Edge Runtime and browsers alike. +// +// Keep this module free of Node APIs - `test/edge-safety.test.ts` enforces it, and it +// scans raw source INCLUDING comments, so the forbidden names cannot be spelled here +// even in prose: tsc emits comments into dist, and not every downstream analyzer +// parses an AST rather than grepping. function processStart(): Date { - const uptime = typeof process?.uptime === 'function' ? process.uptime() : 0; - return new Date(Date.now() - Math.round(uptime * 1000)); + return new Date(typeof performance !== 'undefined' ? performance.timeOrigin : Date.now()); } const STARTED_AT = processStart(); diff --git a/test/edge-safety.test.ts b/test/edge-safety.test.ts new file mode 100644 index 0000000..c784ec4 --- /dev/null +++ b/test/edge-safety.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * `health.ts` is re-exported from the `@agentage/shared` barrel in several repos, and + * those barrels are imported by `middleware.ts`, which runs in the **Edge Runtime**. + * + * Next detects Node APIs in an edge bundle **statically**, at build or module + * evaluation - not when the line runs. So a `typeof process?.uptime === 'function'` + * guard is worthless: the mere presence of the identifier throws + * + * Error: A Node.js API is used (process.uptime) which is not supported in the Edge Runtime. + * + * at module evaluation, which fails the gate before any page renders. On 2026-08-10 + * that 500'd every authenticated route on admin.agentage.io, in an app that only ever + * wanted `links` from the barrel. + * + * So this is a SOURCE-TEXT test, deliberately. A behavioural test cannot catch it - + * the code works perfectly under Node. The only thing that fails is a bundler reading + * the source, so that is what this reproduces. + */ + +// Raw text, comments INCLUDED. tsc emits comments into dist, and while Next parses an +// AST, not every analyzer that might read this package does - so the forbidden names +// must not appear at all, even in prose explaining why they are forbidden. The first +// run of this test caught exactly that: the comment documenting the fix. +const SOURCE = readFileSync(fileURLToPath(new URL('../src/health.ts', import.meta.url)), 'utf8'); + +// `process.env` is deliberately absent: Next supports it in edge bundles (it inlines +// them), and the envelope's whole provenance story is built on it. +const NODE_ONLY = [ + 'process.uptime', + 'process.cwd', + 'process.hrtime', + 'process.memoryUsage', + 'process.versions', + 'process.platform', + 'process.exit', + 'process.nextTick', + 'require(', + "from 'node:", + 'from "node:', +]; + +describe('health.ts stays edge-safe', () => { + it.each(NODE_ONLY)('does not reference %s', (api) => { + expect(SOURCE).not.toContain(api); + }); + + it('has no imports at all - every symbol comes from a cross-runtime global', () => { + const imports = SOURCE.match(/^\s*import\s+(?!type\b)/gm) ?? []; + expect(imports).toEqual([]); + }); + + it('reads the process start from performance.timeOrigin, a Web API', () => { + expect(SOURCE).toContain('performance.timeOrigin'); + }); + + it('ships a dist free of them too, when one has been built', async () => { + const dist = new URL('../dist/health.js', import.meta.url); + let built: string; + try { + built = readFileSync(fileURLToPath(dist), 'utf8'); + } catch { + return; // test runs before build in `npm run verify`; the source scan above is the gate + } + for (const api of NODE_ONLY) expect(built).not.toContain(api); + }); + + it('still reports a plausible process start after the swap', async () => { + const { healthEnvelope } = await import('../src/health.js'); + const { startedAt, uptimeSeconds } = healthEnvelope('edge-check').data; + const drift = Math.abs(Date.parse(startedAt) - (Date.now() - process.uptime() * 1000)); + expect(drift).toBeLessThan(1000); + expect(uptimeSeconds).toBeGreaterThanOrEqual(0); + }); +});