Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 17 additions & 5 deletions src/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
78 changes: 78 additions & 0 deletions test/edge-safety.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});