Skip to content

feat: collector route, dual onRequestError, serveHealth, stderr banner - #58

Merged
vreshch merged 1 commit into
masterfrom
feature/v1.1-followups
Sep 13, 2026
Merged

vreshch merged 1 commit into
masterfrom
feature/v1.1-followups

Conversation

@vreshch

@vreshch vreshch commented Sep 13, 2026

Copy link
Copy Markdown
Member

Follow-ups collected from migrating 8 consumer repos to 1.0.0. Version bumped 1.0.0 -> 1.1.0 (additive + bug fixes), so merging publishes 1.1.0 - intended; consumers on ^1.0.0 pick it up on the Friday dependabot train.

1. P1 bug: the tracer banner was on stdout

src/internal/tracer.ts printed otel: tracing enabled ... via console.log whenever an OTLP endpoint was set - i.e. production only. stdout is the JSON-RPC channel of a stdio MCP server, so the line corrupts the stream.

Evidence: server-memory and cli both ship a process.stdout.write diversion around the bootstrap import purely to survive this.

Banner is now a plain process.stderr.write (not the log singleton - it is not safely constructible during tracer init, and the service has not loaded its env yet).

stdout audit of the whole src/ turned up a second leak: configureDiagnostics installed the SDK's DiagConsoleLogger, whose info/debug levels go through the console methods Node routes to stdout. OTEL_LOG_LEVEL=info on a stdio MCP server would have corrupted the stream just as badly. Replaced with a stderr-only diag logger at every level. console.warn/console.error were verified to be stderr in Node and are still allowed; src/browser.ts only mentions console.error in prose (browser-side, not a process stream). No other writer found.

Tests: test/contract/stdout-silence.test.ts scans every src/**/*.ts raw source (comments included, like the edge-safety test) for console.log|info|debug|dir|table|count|group|time, process.stdout and DiagConsoleLogger; npm run smoke:stdio (new, in verify) spawns a child under the built bootstrap with OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_LOG_LEVEL=info set and asserts its stdout is byte-for-byte the one JSON-RPC frame the child itself wrote - and that the banner is still on stderr.

2. New collectorRoute(opts?) from /next

App Router POST handler (fetch Request -> Response) for the client-error collector: text/plain sendBeacon bodies, whitelist pinned to parseClientEvents (so the 32-hex non-zero trace_id rule, the 200-char field truncation and the 4000-char stack cap are shared, not re-derived), same-origin guard, 64KB cap, 20 events/request, a 60/60s in-memory sliding window, no-store + x-robots-tag: noindex on every answer, 204 empty on success, 405 + Allow otherwise. Each event logged through the kit log with source: 'client'.

Evidence: dashboard (src/app/api/client-errors/route.ts + src/lib/client-errors.ts), catalog-web (packages/web/src/app/api/client-errors/route.ts) and the web backend each hand-rolled this - with three different origin rules, two opposite answers for a missing Origin (dashboard allowed it, catalog required Sec-Fetch-Site: same-origin), rate limiting in exactly one of them, and no-store/noindex in exactly one of them.

The export is the union of those guards: exact-origin allowlist (default from OTEL_CLIENT_ERROR_ORIGINS) or host match against the forwarded host (x-forwarded-host leftmost, then Host, then the request URL - behind Traefik the Host header is the edge's), missing Origin rejected unless Sec-Fetch-Site: same-origin or allowMissingOrigin, and the size cap now also checks what was actually read, not only the client's content-length.

3. onRequestError is dual-mode

It was a factory (onRequestError(log) -> handler) while the v1 README promised a value. A bare re-export type-checked, shipped, and silently swallowed every server render error: Next called the factory, got a function back, discarded it.

Evidence: dashboard, landing and catalog-web all carry the kitOnRequestError(log) boilerplate plus their own lib/logger; admin has none, so its render errors are unreported today.

One logger-like argument (has .error and .child) -> bound handler, back-compat. Two or more args, or one non-logger arg -> acts as the handler itself over the log singleton. instrumentation.ts collapses to export { register, onRequestError } from '@agentage/observability/next';. Both paths tested, including the bare-re-export trap and a lone non-logger argument.

4. New serveHealth(port, checks?, opts?) from the root

node:http server answering GET/HEAD on /health + /api/health through the existing healthResponse machinery; returns { listening, server, close() }.

Evidence: replaces catalog-crawler's 87-line startHealthServer shim (packages/crawler/src/health.ts).

Kept from the shim: bind all interfaces (the container HEALTHCHECK probes 127.0.0.1, admin reaches it over the overlay), port 0 for tests, close() on shutdown. Fixed what the shim got wrong: no content-type on the success path, exact req.url === '/health' (so /health?probe=1 and /health/ 404'd), no HEAD, no /api/health, a bare header-less 404, and no listen error handling at all - EADDRINUSE was an unhandled 'error' event that crashed the crawler. It now rejects listening (pre-caught, so an un-awaited failure never crashes the worker). Crawler-specific coupling (passFacts, Mongo) stays in the crawler. Lives in src/serve-health.ts, not health.ts, which must stay edge-safe.

5. Request-log idempotency (coordinator follow-up, admin-api)

The KIT_REQUEST_LOG marker is only visible on the middleware itself. admin-api wraps createRequestLog in a /health-skip closure, the wrapper hides the symbol from autoWire's stack scan, and every request was logged twice until they set OBS_REQUEST_LOG=off.

Took the zero-surface option rather than the README-only one: isKitRequestLog now follows a .wrapped property chain, so Object.assign(mine, { wrapped: createRequestLog(log) }) is seen. No new public export. README documents both routes precisely (marker is visible only when mounted directly; wrap -> .wrapped or OBS_REQUEST_LOG=off). Covered by a new express-patch integration test in admin-api's exact shape.

Surface budget

Grows 9 -> 11. CLAUDE.md updated with the justification and a higher bar for a 12th: both additions are runtimes the listen() auto-wiring provably cannot reach (a Next route handler, a worker with no Express app), and in both cases multiple repos had already built it by hand, divergently.

Notes / deviations

  • /next's static graph now includes pino (via the log singleton needed by direct-mode onRequestError and by collectorRoute). Dashboard, landing and catalog-web already import pino into instrumentation.ts today and build green, so this is the established shape - but admin, which only re-exports register, picks it up newly. Flagging in case admin's edge build objects.
  • isLoggerLike requires both .error and .child; one existing test double in test/unit/user.test.ts had only .error and was updated.
  • verify gained smoke:stdio and smoke:dist now asserts serveHealth / collectorRoute exist.

npm run verify green locally (440 tests, build, dist + bootstrap + stdio smokes).

@vreshch
vreshch marked this pull request as ready for review September 13, 2026 22:46
@vreshch
vreshch merged commit 663f9c0 into master Sep 13, 2026
1 check passed
@vreshch
vreshch deleted the feature/v1.1-followups branch September 13, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant