@proxon/agent-observer
v0.1.0
Published
Drop-in agent-cost telemetry SDK: auto-instruments the Anthropic + OpenAI SDKs (ESM, via import-in-the-middle), maps per-call token usage to the Proxon-native agent-cost event, and fire-and-forget POSTs it to the ingest tier (POST /v1/agent-cost/events).
Readme
@proxon/agent-observer
Drop-in agent-cost telemetry for your own bot or agent. Install it, launch with one flag, and every LLM call your process makes reports per-agent / per-task / per-owner cost + token usage into Proxon — with zero call-site changes.
It emits the Proxon-native agent-cost schema (the same contract Proxon dogfoods). Providers return tokens, not dollars; Proxon recomputes cost server-side from a maintained price book, so you never ship pricing in your agent. It is fire-and-forget: it never throws into — or slows — your request path, and if it isn't configured it emits nothing (a clean no-op).
- Runs anywhere Node does — CI, containers, servers, a laptop. Node ≥ 18.19.
- Instruments the Anthropic + OpenAI SDKs (and OpenAI-compatible providers like
DeepSeek via
OPENAI_BASE_URL). A Python SDK is planned; until then, non-Node apps use OpenTelemetry. - Never sees prompt or response content — only token counts + metadata.
Quickstart (60 seconds)
Get a service credential. In Proxon, go to Admin → Service Credentials and generate one. You get a
prx_svc_…bearer, shown once — copy it.Install:
npm i @proxon/agent-observerPoint the SDK at Proxon (via env — see Configuration):
export PROXON_OBSERVER_INGEST_URL="https://ingest.proxon.dev" # your ingest host export PROXON_OBSERVER_SERVICE_CREDENTIAL="prx_svc_<prefix>_<secret>" export PROXON_OBSERVER_AGENT="my-agent" # what this process reports asLaunch your app with the register flag — no code changes:
node --import @proxon/agent-observer/register app.js
That's it. Every Anthropic/OpenAI call your app makes now reports its cost to Proxon. You'll see a one-line confirmation banner in your logs at startup:
[proxon-agent-observer] active — reporting agent-cost to https://ingest.proxon.dev as agent "my-agent" (incl. spawned claude CLI subprocesses)What do you want to do?
- Instrument a Node app I run — the zero-edit path.
- Instrument a client I construct myself — CommonJS, or when
--importisn't an option. - Attribute cost to an agent / task / customer — slice spend by whatever dimensions you care about.
- Track a multi-process agent fleet — orchestrator → sub-agent roll-up.
- Capture a spawned
claudeCLI — auto, zero-code. - I'm already on OpenTelemetry (or not a Node app).
- Confirm it's working / troubleshoot.
Instrument a Node app I run
The recommended path. Launch your process with the register entry — it patches the
Anthropic + OpenAI SDKs at module load via the OpenTelemetry-standard
import-in-the-middle loader hook:
node --import @proxon/agent-observer/register app.jsNo source edits. @anthropic-ai/sdk and openai are optional peer
dependencies — the SDK instruments whichever you already depend on and never adds
the other to your runtime graph.
ESM only. The loader hook is ESM-native. The package itself is ESM (
"type": "module"). A CommonJS app can't use--importauto-instrumentation (native CJS support viarequire-in-the-middleis a planned follow-up) — usewrap()instead.
OpenAI chat streaming: pass
stream_options: { include_usage: true }tochat.completions.create— without it the API returns no token counts and streamed calls are silently skipped (no cost row). The SDK warns once per process when it detects a usage-less stream. (OpenAI Responses streaming always includes usage; Anthropic streaming is unaffected.)
Instrument a client I construct myself
If you can't use --import (a CommonJS app, a bundled runtime, or you just prefer
an explicit call), wrap the client instance in place:
import Anthropic from '@anthropic-ai/sdk';
import { wrap } from '@proxon/agent-observer';
const anthropic = wrap(new Anthropic()); // also works for `new OpenAI()`wrap() returns the same client, now instrumented; calling it twice on the
same client is a no-op. Everything else (config, attribution, flushing) is
identical to the auto-instrument path.
Attribute cost to an agent / task / customer
The instrumentation can't know what a given messages.create is for — that's
business context you supply. Wrap a unit of work in withContext and every event
emitted inside it (across await boundaries) inherits the attribution:
import { withContext, flush } from '@proxon/agent-observer';
await withContext(
{
agent: 'support-bot',
task_type: 'triage',
linear_ticket: 'PRX-42',
owner: 'octocat',
// Open-ended custom dimensions — the Proxon rollup auto-discovers and
// breaks down by whatever keys you supply. No schema change needed.
attributes: { tenant: 'acme', feature: 'search' },
},
async () => {
await anthropic.messages.create({
/* ... */
}); // auto-attributed with everything above
}
);
await flush(); // ship the final batch on graceful shutdown / after a one-shot batchFixed attribution fields (first-class dashboard dimensions): agent,
task_type, run_type, linear_ticket (string), pr_number (number), owner,
outcome, plus runId (auto-generated if you don't set one) and querySource.
Anything else goes in the generic attributes bag (string → string|number), which
the rollup discovers as attr:<key> breakdowns automatically.
runWithis an alias forwithContextif that name reads better in your code.
Track a multi-process agent fleet
For an orchestrator that spawns child agents in separate processes, carry the
active context across the process boundary so the child's cost rolls up under the
parent. It's two explicit touchpoints — propagateContext() on the parent,
contextFromEnv() on the child.
Parent — serialize the active context into the child's env:
import { propagateContext } from '@proxon/agent-observer';
import { spawn } from 'node:child_process';
// Inside the parent's withContext(...) run:
spawn('node', ['--import', '@proxon/agent-observer/register', 'child.js'], {
env: { ...process.env, ...propagateContext() },
});Child — rehydrate it and run your work under it. The register entry does
not do this for you; the child's contextFromEnv() is the one SDK touchpoint
at the spawn boundary (run standalone, it returns undefined and the work emits as
a normal main run):
import { contextFromEnv, withContext } from '@proxon/agent-observer';
const inherited = contextFromEnv();
if (inherited) {
await withContext(inherited, run);
} else {
await run();
}The child becomes a sub-agent: it gets a fresh run_id, its query_source is
forced to subagent, and the parent's run_id is carried as the child's
parent_run_id — so orchestrator → sub-agent cost links end-to-end. The generic
attributes bag propagates too (so a child inherits tenant/feature/…);
outcome does not (a run's outcome isn't known until after its children were
spawned).
Black-box / OpenTelemetry-only parents: if your parent never calls
withContextbut does set OTel Baggage, the SDK reads that baggage at emit time and maps it to attribution for you (see Attribution precedence).
Capture a spawned claude CLI
When you launch with --import @proxon/agent-observer/register, the SDK also
captures the LLM spend of claude CLI subprocesses your process spawns (e.g.
claude --print …) — zero code, through the same emitter, config, and
attribution as in-process calls. On by default.
The --import hooks only see in-process SDK calls, and a spawned claude is a
separate process. So the SDK instruments child_process.spawn/execFile: when it
detects a claude spawn it injects Claude Code's OpenTelemetry metrics export into
the child (pointed at a loopback 127.0.0.1 receiver inside your process), receives
the OTLP metrics in-process, and emits one agent-cost event per spawn. The spawned
claude is attributed as a sub-agent (query_source = subagent, its own
run_id, parent_run_id + agent / task_type / linear_ticket / … carried
from the active withContext at spawn time). provider = anthropic,
endpoint = claude-code-cli.
It only injects telemetry env the child didn't already have — if you run your own
Claude Code OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT or
CLAUDE_CODE_ENABLE_TELEMETRY set in the environment, any value including 0),
the SDK defers to your setup and stays out of the way. No credential or non-OTEL
secret is ever passed to the child.
Opt out with PROXON_OBSERVER_CAPTURE_SUBPROCESSES=0.
- Limitation: this covers the
claudeCLI specifically (it exports OTLP metrics natively). Any other subprocess that makes its own LLM calls needs its own--import @proxon/agent-observer/register(or your own OpenTelemetry pipeline) to be captured. - One-tick startup race: the loopback receiver binds asynchronously at import
—
registernever blocks your app's startup to wait for it. Aclaudespawn in your process's very first synchronous tick (before the bind completes) may be missed. In practice this never matters: event-driven agents spawnclaudein response to a request/event, i.e. after their first async turn, by which point the receiver is long since bound. This is the deliberate safe trade-off (a telemetry SDK must never gate or slow the host's startup).
Not a Node app?
The SDK and OpenTelemetry do the same job two ways. For a Node app you control, the SDK auto-instruments it with one line. For anything else — another language, or a tool that already emits OpenTelemetry (Claude Code, GitHub Copilot, your own OTLP exporter) — point that exporter at Proxon's OTLP endpoint instead; no new agent, no proxy, no certificate. Either is enough; running both adds detail, not new coverage.
Configuration
Configure via PROXON_OBSERVER_* environment variables, or programmatically with
configure({ … }) / init({ … }) (an alias) at startup. Programmatic options take
precedence over env.
| Variable | Required | Description |
| -------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PROXON_OBSERVER_INGEST_URL | yes | Base URL of your Proxon ingest host (e.g. https://ingest.proxon.dev); /v1/agent-cost/events is appended. |
| PROXON_OBSERVER_SERVICE_CREDENTIAL | yes | The prx_svc_<prefix>_<secret> service credential (Admin → Service Credentials). |
| PROXON_OBSERVER_AGENT | yes* | The agent name this process reports as. *Required unless you supply agent per-run via withContext({ agent }) / configure({ agent }); an event with no resolvable agent is dropped. |
| PROXON_OBSERVER_BATCH_SIZE | no | Events buffered before an automatic flush (default 20). |
| PROXON_OBSERVER_FLUSH_INTERVAL_MS | no | Max ms an event waits before an automatic flush (default 5000). |
| PROXON_OBSERVER_ATTRIBUTES | no | Process-wide default attributes, as a JSON object (e.g. {"env":"prod"}). Merged into every event, below per-run withContext attributes. Malformed JSON is ignored, never fatal. |
| PROXON_OBSERVER_DISABLED | no | Kill-switch. Only the exact value 1 disables — true/yes/on are NOT recognised (deliberate, so an ambient boolean env var can't silently turn telemetry off). |
| PROXON_OBSERVER_CAPTURE_SUBPROCESSES | no | Spawned claude CLI capture (above). On by default; only the exact value 0 opts out (same exact-match discipline as PROXON_OBSERVER_DISABLED). |
| PROXON_OBSERVER_AUTO_ATTRIBUTES | no | Auto-detect ambient attribution (env/host/runtime/CI) into the attribute bag. On by default; 0 opts out. |
| PROXON_OBSERVER_BAGGAGE | no | Read OpenTelemetry Baggage at emit time and map it into attribution. On by default; 0 opts out. No-op if @opentelemetry/api isn't installed. |
| PROXON_OBSERVER_INFER_FROM_PROMPT | no | Let the wrappers infer attribution from request content (e.g. a Linear ticket id in the prompt). Opt-in for privacy — off by default; 1 enables. Gap-fills only. |
Every knob above is also an ObserverOptions field on configure() (camelCase:
ingestUrl, serviceCredential, agent, batchSize, flushIntervalMs,
attributes, enabled, captureSubprocesses, autoAttributes, readBaggage,
inferFromPrompt).
Note: the credential's
secretis sensitive — keep it in your secret manager, not in code. Rotate/revoke it any time from Admin → Service Credentials.
How attribution is resolved
Attribution is layered, lowest → highest precedence — a higher layer only gap-fills or overrides what's below it, so you can lean on the free ambient signal and override exactly what you care about:
- Prompt inference (
inferFromPrompt, opt-in) — a fuzzy gap-fill, e.g. a Linear ticket id in the request content; the lowest layer, overridden by everything below. - Ambient auto-detect (
autoAttributes) —env/host/runtime/ CI, for free. - Process defaults —
configure({ attributes })/PROXON_OBSERVER_ATTRIBUTES. - OpenTelemetry Baggage (
readBaggage) — active baggage at emit time:enduser.id→owner, every other key → anattr:<key>. Beats config and ambient — onlywithContextoverrides it. - Per-run context —
withContext({ … }). Always wins.
So a customer already on OpenTelemetry gets meaningful attribution with no
Proxon-specific code at all (baggage + ambient), while a customer who wants exact
control uses withContext.
What Proxon receives (the wire event)
Each captured call becomes one agent-cost event. The SDK reports tokens, not
dollars — Proxon recomputes cost_usd server-side from its price book (the SDK
never ships pricing). Every SDK event is stamped billing_mode: api_metered (it
instruments an API-key client, so the tokens are real cash spend). The public
AgentCostEvent / QuerySource / BusinessContext / ObserverConfig /
ObserverOptions / WrapDeps types are exported for reference.
Token classes are reported separately, never summed (and an explicit 0 is
kept as a real datum): tokens_input, tokens_output, tokens_cache_read,
tokens_cache_write, tokens_reasoning, tokens_audio_input/output,
tokens_image_input/output. Content (prompts, responses) is never sent.
API reference
The package export is intentionally narrow — only what a customer calls:
| Export | What it does |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| wrap(client) | Instrument an Anthropic/OpenAI client instance in place (returns the same client). Idempotent. |
| withContext(ctx, fn) / runWith | Run fn with ctx as the active attribution; every event emitted inside inherits it. |
| getContext() | The active BusinessContext, or undefined outside any withContext. |
| propagateContext(ctx?, querySource?) | Serialize the active (or given) context into env vars for a spawned child (defaults to subagent). |
| contextFromEnv(env?) | Rehydrate a BusinessContext from the env vars propagateContext wrote — call it in the spawned child, then run under withContext(inherited, …). |
| configure(opts) / init(opts) | Set config programmatically (alias). Overrides env. |
| getConfig() / loadConfigFromEnv() | Read the resolved config / re-read it from env. |
| flush() | Ship the buffered batch now — call on graceful shutdown or after a one-shot run. |
| CONTEXT_ENV | The env-var names propagateContext/contextFromEnv use (for advanced/manual wiring). |
The mappers, event builder, transport, and per-provider instrument* functions are
not exported — reach the wrappers through wrap(). This keeps the public
surface stable.
Runnable examples
Two complete, copy-paste programs (they mirror the SDK's own CI e2e fixtures).
Zero-edit auto-instrumentation — nothing Proxon-specific in the file; the
telemetry is bolted on at launch via --import:
// app.mjs — run: node --import @proxon/agent-observer/register app.mjs
// env: PROXON_OBSERVER_INGEST_URL, PROXON_OBSERVER_SERVICE_CREDENTIAL,
// PROXON_OBSERVER_AGENT (+ ANTHROPIC_API_KEY for the SDK client)
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const message = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 64,
messages: [{ role: 'user', content: 'Say ok.' }],
});
console.log('usage:', JSON.stringify(message.usage));Custom attribution dimensions — stamp every call inside the callback with
your own tenant / feature / owner; Proxon auto-discovers the breakdown:
// run: node --import @proxon/agent-observer/register app.mjs
import Anthropic from '@anthropic-ai/sdk';
import { withContext } from '@proxon/agent-observer';
const client = new Anthropic();
await withContext({ attributes: { tenant: 'acme', feature: 'checkout' } }, async () => {
const message = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 64,
messages: [{ role: 'user', content: 'Say ok.' }],
});
console.log('usage:', JSON.stringify(message.usage));
});The OpenAI SDK auto-instruments the exact same way (swap the import). The
multi-process and spawned-claude-CLI shapes are covered in
Track a multi-process agent fleet and
Capture a spawned claude CLI above.
Confirm it's working / troubleshoot
Telemetry is optional infra, but the SDK is not silent about its state. At
startup the --import entry logs one [proxon-agent-observer] banner so you can
confirm it from your process logs:
[proxon-agent-observer] active — reporting agent-cost to https://ingest.proxon.dev as agent "my-agent" (incl. spawned claude CLI subprocesses)
[proxon-agent-observer] loaded but INACTIVE — set PROXON_OBSERVER_INGEST_URL and PROXON_OBSERVER_SERVICE_CREDENTIAL to emit telemetry (currently a no-op)| Symptom | Likely cause / fix |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| loaded but INACTIVE banner | PROXON_OBSERVER_INGEST_URL and/or PROXON_OBSERVER_SERVICE_CREDENTIAL unset. Set both. |
| No banner at all | The register entry didn't load — check you're launching with node --import @proxon/agent-observer/register … (ESM), not a bare node app.js. |
| Banner is active but no rows in Proxon | An event with no resolvable agent is dropped — set PROXON_OBSERVER_AGENT or pass agent via withContext/configure. Also confirm the credential is live (Admin → Service Credentials). |
| OpenAI streamed calls missing | Pass stream_options: { include_usage: true } to chat.completions.create (see above). |
| CommonJS app, --import does nothing | Auto-instrumentation is ESM-only — use wrap(). |
| Spawned claude spend missing | Ensure PROXON_OBSERVER_CAPTURE_SUBPROCESSES isn't 0, and that you launched with --import (the capture rides the register entry). |
Runtime failures are swallowed with at most a single warn per failure streak — the
SDK never throws into your request path. If you need to hard-disable it, set
PROXON_OBSERVER_DISABLED=1.
