feat: optional observability via kit bootstrap - #38
Merged
Merged
Conversation
The stdio bin tries `@agentage/observability/bootstrap` behind a try/catch before loading the MCP SDK, so an installed kit instruments tool registration and absent means a silent skip. The kit stays out of `dependencies` - this package is run via `npx` and the kit would add ~41 packages to every cold start.
The kit announces an enabled tracer with a console.log, so a user who sets OTEL_EXPORTER_OTLP_ENDPOINT would get a non-JSON-RPC frame mid-handshake. stdout is diverted to stderr for the duration of the bootstrap import.
vreshch
marked this pull request as ready for review
September 13, 2026 22:28
vreshch
added a commit
to agentage/cli
that referenced
this pull request
Sep 13, 2026
## Goal Give the CLI and its local daemon the estate's observability, without breaking a single byte of stdout. ## Dependency decision: a real one `@agentage/observability` (+ its `@opentelemetry/api` peer, listed so one copy is hoisted) is a plain `dependency` here - `agentage` is installed **globally, once** (`npm install -g @agentage/cli`), so the kit's ~41 packages are a one-time cost, not a per-invocation one. That is the opposite call from `@agentage/server-memory` (agentage/server-memory#38), which is run through `npx` per MCP client start and takes the kit **optionally** instead. `src/package-guard.test.ts`, which pins the runtime dependency set exactly, is updated with that rationale. ## Change Both bins become loaders: the kit instruments `node:http`, `fetch` and MCP tool registration through module hooks, and a hook only sees modules imported **after** it registers - so nothing may be statically imported ahead of the bootstrap. - `src/cli.ts` - node-version guard, `startObservability('agentage-cli')`, then dynamically imports `src/program.ts` (the commander wiring, moved verbatim). - `src/daemon-entry.ts` - same shape; the daemon itself moved to `src/daemon/entry.ts` and exports `boot()` instead of self-invoking. The bin path is unchanged (`dist/daemon-entry.js`), which is what `spawnDaemon` resolves. - `src/observability.ts` - the guarded bootstrap. Wrapped in try/catch even though the kit is a dependency: a half-installed global must degrade to a working CLI. - `spawnDaemon` pins `OTEL_SERVICE_NAME` for the child, so the daemon reports as `agentage-daemon` and never inherits the name we defaulted into the CLI's env. A name the **user** set is passed through to both. Inert until `OTEL_EXPORTER_OTLP_ENDPOINT` names a collector. What you get then: HTTP client/server spans, one `kind: "tool"` wide event per `memory__*` call served by the daemon (the embedded `@agentage/server-memory` surface), one error shape, crash capture. ## stdout purity `@agentage/observability` 1.0.0 announces an enabled tracer with a **`console.log`** (`dist/internal/tracer.js:88`) - which would land inside `--json` output or on the daemon's wire. `startObservability` diverts `process.stdout.write` to stderr for the duration of the bootstrap import and restores it in a `finally`; a unit test asserts both the silence and the restore. The real fix belongs in the kit (that line should be a `log.info`); drop the workaround when it ships. Runtime evidence, built bin, collector configured (`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:9`): ``` $ agentage --version -> stdout "0.32.0", stderr empty $ agentage --no-daemon status --json -> stdout parses as JSON, stderr empty $ node dist/daemon-entry.js (foreground, then a tools/call over 127.0.0.1:PORT/mcp) stdout: (empty) stderr: otel: tracing enabled - service agentage-daemon -> http://127.0.0.1:9 {"service":"agentage-daemon","trace_id":"88e5…","span_id":"46f0…", "kind":"tool","tool":"memory__list","duration_ms":8,"status":"ok","msg":"tool_call"} ``` ## Verified - `npm run verify` green: type-check (+ e2e) + lint + format:check + **462 tests / 46 files** + build. - `npm run test:e2e -- --grep @offline` green: **35 passed**, including the daemon `/mcp` tier that spawns `dist/daemon-entry.js` - the restructured entry path. ## Notes, not fixed here - The daemon is spawned with `stdio: 'ignore'`, so its stderr (kit lines included) is discarded in normal use; telemetry reaches a collector over OTLP regardless. Pre-existing, unchanged by this PR. - The daemon serves raw `node:http`, so it gets HTTP spans and tool events but no request-log line (the kit's request log wires itself onto express `listen`). Its `/api/health` is still the daemon's own envelope, not `@agentage/observability/health`. - CLI HTTP calls to the backend send `User-Agent: agentage-cli/<v>` + `X-Agentage-*-Version` but **no `x-client-type`**, so the edge classifies them by UA (`service`). The e2e suite does tag `x-client-type: test` (`e2e/client-type.ts`). Flagging only - out of scope here. ## Not in this PR No publish. `npm publish` is workflow-only in this estate; the release rides the Friday train and wants dogfooding first.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Goal
Give the public stdio MCP server observability - without making anyone pay for it on a
npxcold start.Change
src/bin/server-memory.tsattemptsawait import('@agentage/observability/bootstrap')in a try/catch before the MCP SDK is loaded (the kit instruments tool registration through module hooks, which only see modules imported after they are registered - hence the dynamic imports below it).kind: "tool"wide event per call, crash capture.OTEL_SERVICE_NAMEdefaults toagentage-server-memory; still inert untilOTEL_EXPORTER_OTLP_ENDPOINTnames a collector.AGENTAGE_DEBUG=1.The kit is in
devDependencies(types + tests) and there is deliberately no peerDependency -npxwould try to resolve one.Install weight: unchanged
npm pack+ cleannpm i <tarball> --omit=dev, master vs this branch:node_modulesbytes@opentelemetry/*in the treestdout purity: verified both directions
New
test/observability.test.tsspawns the built bin over raw stdio and captures both streams. The kit-absent case is a copy ofdistbeside anode_modulesholding every runtime dependency but not the kit - a realnpxcold start, not a mock:{"kind":"tool","tool":"memory__list","status":"ok","service":"agentage-server-memory"}AGENTAGE_DEBUG=1: stderr =[server-memory] observability off: Cannot find package '@agentage/observability'Verified
npm run verifygreen locally: type-check + lint + format:check + 26 tests (6 files) + build.Not in this PR
No publish.
npm publishis workflow-only in this estate and the release rides the Friday train - this wants dogfooding through the CLI first.Kit finding (worked around here, needs a fix upstream)
@agentage/observability1.0.0 announces an enabled tracer with aconsole.log(dist/internal/tracer.js:88,otel: tracing enabled - service … -> …). On a stdio MCP server that is a non-JSON-RPC frame on the wire, mid-handshake, for anyone who setsOTEL_EXPORTER_OTLP_ENDPOINT.Worked around here by diverting
process.stdout.writeto stderr for the duration of the bootstrap import (onetry/finally), covered by the third test case. The real fix belongs in the kit - that line should be alog.info, i.e. stderr like every other line it writes. Drop the workaround when the kit ships it.