Skip to content

feat: optional observability via kit bootstrap - #38

Merged
vreshch merged 2 commits into
masterfrom
feature/obs-v1
Sep 13, 2026
Merged

vreshch merged 2 commits into
masterfrom
feature/obs-v1

Conversation

@vreshch

@vreshch vreshch commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

Goal

Give the public stdio MCP server observability - without making anyone pay for it on a npx cold start.

Change

src/bin/server-memory.ts attempts await 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).

  • Kit installed -> per-tool spans, one kind: "tool" wide event per call, crash capture. OTEL_SERVICE_NAME defaults to agentage-server-memory; still inert until OTEL_EXPORTER_OTLP_ENDPOINT names a collector.
  • Kit absent -> silent skip. One stderr line only under AGENTAGE_DEBUG=1.
  • Either way stdout stays the JSON-RPC wire. Every kit line is stderr, by construction.

The kit is in devDependencies (types + tests) and there is deliberately no peerDependency - npx would try to resolve one.

Install weight: unchanged

npm pack + clean npm i <tarball> --omit=dev, master vs this branch:

master this branch
packages installed 97 97
node_modules bytes 17,966,195 17,968,789 (+2.6 KB, this package's own dist)
@opentelemetry/* in the tree 0 0
tarball 15,541 B 16,584 B

stdout purity: verified both directions

New test/observability.test.ts spawns the built bin over raw stdio and captures both streams. The kit-absent case is a copy of dist beside a node_modules holding every runtime dependency but not the kit - a real npx cold start, not a mock:

  • with the kit: stdout = JSON-RPC only; stderr carries {"kind":"tool","tool":"memory__list","status":"ok","service":"agentage-server-memory"}
  • without the kit: stdout = JSON-RPC only, both requests answered; stderr empty
  • without the kit + AGENTAGE_DEBUG=1: stderr = [server-memory] observability off: Cannot find package '@agentage/observability'

Verified

npm run verify green locally: type-check + lint + format:check + 26 tests (6 files) + build.

Not in this PR

No publish. npm publish is 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/observability 1.0.0 announces an enabled tracer with a console.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 sets OTEL_EXPORTER_OTLP_ENDPOINT.

Worked around here by diverting process.stdout.write to stderr for the duration of the bootstrap import (one try/finally), covered by the third test case. The real fix belongs in the kit - that line should be a log.info, i.e. stderr like every other line it writes. Drop the workaround when the kit ships it.

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
vreshch marked this pull request as ready for review September 13, 2026 22:28
@vreshch
vreshch merged commit aa40eca into master Sep 13, 2026
1 check passed
@vreshch
vreshch deleted the feature/obs-v1 branch September 13, 2026 22:35
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.
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