npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agentage/observability

v0.15.1

Published

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.

Readme

@agentage/observability

One /health endpoint, one log shape, one trace pipeline - for every Node service you run.

Install it, add three lines, and every service answers the same health envelope, writes logs that link to their traces, and reports errors once instead of twice. Health works on its own with zero configuration; tracing stays completely inert until you point it at a collector.

What is this?

Running more than a couple of services, you end up writing the same three things again and again: a /health route, a logger, and some OpenTelemetry wiring. They drift. One service calls itself auth, another agentage-auth. One /health returns {ok: true}, another returns a bare 200. When something breaks at 3am, none of it lines up.

This package is those three things, written once:

  • Health - a /health endpoint whose answer you can actually interrogate: which checks ran, what each one cost, whether the reply was cached, and which replica sent it.
  • Logs - JSON with a fixed shape, stamped with the current trace so a log line and its trace are one click apart - and log.error(err) flags that trace as failed.
  • Traces - an OpenTelemetry bootstrap that emits one span per request instead of fifty.

Use one part or all three. The health module has no dependencies at all and runs on Node, Cloudflare Workers, Deno, Bun and edge runtimes; the tracing parts only load when configured.

Get started

npm install @agentage/observability

A health endpoint, in one line

// Next.js app/health/route.ts - also Hono, Workers, Deno, Bun
import { health } from '@agentage/observability/health';

export const dynamic = 'force-dynamic'; // or commit/buildTime bake in at build time
export const GET = health();

That is already a valid Kubernetes liveness probe: the process is up, and it checks no dependencies (which is exactly what liveness should not do). Add checks and the same factory becomes your readiness probe:

export const GET = health({
  checks: {
    db: () => pool.query('SELECT 1'), // throws, rejects, hangs or false = down
    cache: { run: () => redis.ping(), timeoutMs: 250, optional: true }, // optional = degraded
  },
  facts: () => ({ users: userCount }), // counts and modes, never state
});

Express - same options, mounted as a handler:

import { nodeHealth } from '@agentage/observability/health';

app.get('/health', nodeHealth({ checks: { db: () => pool.query('SELECT 1') } }));

Mount it on /api/health too where your edge only routes /api, and register it before any rate limiter. For images with no Node process (nginx, a built SPA), generate the payload at build time with staticHealth() and serve it from an exact-match location declared before any SPA fallback - otherwise every path answers 200 and the probe proves nothing.

Logs

import { logger } from '@agentage/observability';

const log = logger(); // service name comes from OTEL_SERVICE_NAME
log.info({ route: '/login' }, 'user signed in');

try {
  await risky();
} catch (err) {
  log.error(err); // structured log + the request's trace flagged red, one call
}

log.error(err) and log.fatal(err) record the exception on the active trace span and mark it failed - there is no separate capture call to learn. Pass context alongside the error as log.error({ err, userId }); the message defaults to the error's. Stdio MCP servers must pass stream: 'stderr' - on stdio, stdout is the JSON-RPC channel.

Request logs

import { createRequestLog, logger } from '@agentage/observability';

app.use(createRequestLog(logger())); // before the routers, so 404s are counted too

One line per finished request: kind: 'http', method, path, route (templated), status, duration_ms, user_id, plus trace_id/span_id from the logger mixin. The route falls back to a low-cardinality template derived from the URL when Express has no matched route (404s) or matched by RegExp (a whole app mounted behind one pattern). Pass classify to add user_type, and userId when the user does not live at req.user.id.

Error events

One error line, one shape, whatever the runtime: err, route (templated), method, status, user_id, error_code, fingerprint, source, plus cause, frame, target and category. Set err.fingerprint to override how the errors page groups it.

cause is the deepest .cause in the chain (capped at 5, cycle-safe) summarized as Error: getaddrinfo ENOTFOUND agentage-web_backend, and its system code becomes error_code when the error carries no application code of its own. frame is the top in-app stack frame, src/provision.ts:42:11 in provisionMemory - node_modules, node:, internal/, webpack-internal and native frames are skipped. Every emitter gets both.

category classifies the root cause into timeout, connectivity, db (a SQLSTATE like 23505) or logic, the first split on an error dashboard: is it them, or is it us. It is always present. target names what an outbound call was reaching for, POST api.test:8443/v1/memories/:id - method, host with port, templated path, no query and no credentials. It is set by tracedFetch and found through any wrapping cause.

import { errorMiddleware } from '@agentage/observability'; // Express: mount last
app.use(errorMiddleware(log));

export const onRequestError = onRequestErrorHook(log); // Next instrumentation.ts
// import { onRequestError as onRequestErrorHook } from '@agentage/observability/next';

server.tool('memory__search', wrapToolHandler(log, 'memory__search', handler)); // MCP

wrapToolHandler also catches isError results, which travel over HTTP 200, and logs the tool arguments with credential-looking keys redacted and long values truncated.

An outbound fetch that fails rejects with a bare TypeError: fetch failed whose stack holds no application frame and never says which call failed. tracedFetch is a drop-in replacement that captures the call site and the target before awaiting and attaches both to the error, so frame points at your code and target names the endpoint:

import { tracedFetch } from '@agentage/observability';
const res = await tracedFetch(`${backend}/api/memories`, { headers });

From the browser

@agentage/observability/browser is dependency-free and tiny - it pulls in no pino, no OpenTelemetry, and does nothing outside a browser. It hooks window.onerror, unhandledrejection and console.error, then batches over sendBeacon:

import { installErrorReporter } from '@agentage/observability/browser';
installErrorReporter({ endpoint: '/api/client-errors', service: 'web', userId: user?.id });

app.post(
  '/api/client-errors',
  express.text({ type: '*/*' }),
  collectorHandler(log, {
    allowOrigins: ['https://app.agentage.io'], // 403 for anything else, 413 over 64KB
  })
);

The reporter rate-limits itself (20 events/minute by default), drops identical consecutive messages, and never throws. The collector whitelists the payload - unknown keys never reach your logs - and re-emits each event as the same error line with source: 'client' and the reporting app's service. sendBeacon posts text/plain, so parse the body as text (or express.json({ type: '*/*' })); parseClientEvents(body) accepts either a string or an already-parsed object.

Traces

Preload the bootstrap in your Dockerfile CMD (or NODE_OPTIONS):

node --import @agentage/observability/bootstrap dist/index.js

--import, never --require: a CJS preload cannot patch imported modules, so your Express and HTTP spans silently vanish. Next.js apps skip the preload entirely - add @vercel/otel and a one-line src/instrumentation.ts:

export { register } from '@agentage/observability/next';

Then set OTEL_SERVICE_NAME and OTEL_EXPORTER_OTLP_ENDPOINT (see Configuration). Without both, the SDK is never even imported and the process behaves exactly like an uninstrumented one - logging still works.

The /health envelope

Every service answers the same shape, so one probe reads your whole estate:

{
  "success": true, // false only on a real outage, paired with 503
  "data": {
    "status": "ok", // ok | degraded | unavailable
    "service": "memory-mcp", // defaults to OTEL_SERVICE_NAME
    "instance": "b82fc8d3", // random per process - never the hostname
    "version": "21150d69...", // COMMIT_SHA, or "0.0.0-dev"
    "commit": "21150d6",
    "buildTime": "2026-08-09T10:08:07Z", // ISO or null, never ""
    "startedAt": "2026-08-09T10:09:50.122Z",
    "uptimeSeconds": 590,
    "checkedAt": "2026-08-10T01:04:40.586Z", // when THIS payload was computed
    "durationMs": 22.7, // total server-side cost
    "checks": { "store": "ok" }, // ok | degraded | down | skipped
    "timings": { "store": 21.5, "facts": 1.1 },
    "reasons": { "search": "timed out after 60ms" }, // only when a check is not ok
    "facts": { "memories": 412 }, // counts and modes only, never state
  },
}

status is the worst check: any down makes it unavailable (HTTP 503), any degraded keeps it at 200. A dependency the service survives without must report degraded, not down. data is always present, including on a 503, so a probe reads a stated outage instead of an empty body.

checks vs facts is the distinction people get wrong. A check is state you actually determined. A fact is a count or a mode. If the value can read healthy while the thing is broken, it is a fact - authMode: 'oauth' belongs in facts.

Reading the timing fields

A status word cannot be interrogated. These four can:

| Question | Field | Reading | | ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Is this response cached? | checkedAt | Frozen across two probes means something in front is serving a copy - a CDN, a proxy, a lost force-dynamic. | | Did the check measure anything? | timings.<name> | Near 0 is a memoized value; milliseconds is a real round trip. A memoized check reports ok forever after the dependency dies. | | Service, or network? | durationMs | Subtract it from your round trip; the rest is TLS, the edge and the wire. | | Why did two probes differ? | instance | A changed value means a different replica answered - not a cache, not a restart. |

instance is random per process and never the hostname: /health is public and must not leak internal topology. Behind a load balancer it is the only thing separating "two replicas" from "a crash loop" - uptimeSeconds looks identical either way.

The same numbers also go out as a Server-Timing header (health;dur=22.7, store;dur=21.5), so the split shows in browser devtools and proxy logs without parsing the body.

Rules worth following

  • Bound the facts producer, not just the checks. It is the one people forget: an unbounded count off a wedged database outlives your container HEALTHCHECK --timeout, and the orchestrator kills a task that was only ever slow to count rows. factsTimeoutMs defaults to 1s.
  • Liveness is not readiness. If a dependency check can return 503, do not assert response.ok in your container HEALTHCHECK, or the orchestrator restart-loops your container over an outage that restarting cannot fix.
  • Never put /health behind auth, a redirect, or a rate limiter.
  • Verify the path is actually reachable through your edge after routing. A route the proxy never forwards is decorative.

API

| Import | Export | Purpose | | ----------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------- | | @agentage/observability/health | health(options?) | Fetch-native handler: Next, Hono, Workers, Deno, Bun | | | nodeHealth(options?) | Express/Connect handler | | | staticHealth(options?) | Build-time JSON for images with no process | | | healthEnvelope, resolveHealth, runChecks, serverTimingHeader | Lower-level pieces if you build your own transport | | @agentage/observability | logger(options?) | pino preset: trace-linked lines, log.error capture | | | withSpan(name, fn, attrs?) | Add depth deliberately; no-op without an SDK | | | setMcpTool, markSpanError, setSpanAttributes | MCP tool-call span semantics | | | createRequestLog(log, options?) | Express middleware: one wide event per request | | | errorMiddleware(log, options?) | Express error handler emitting the ErrorEvent | | | onRequestError(log) | Next instrumentation.ts error hook | | | wrapToolHandler(log, tool, handler) | MCP tool errors, including isError results | | | tracedFetch(input, init?) | fetch keeping call site + target on rejection | | | rootCauseOf, causeSummaryOf, causeCodeOf, frameOf | The cause/frame lifters, standalone | | | categoryOf, targetOf, fetchTargetOf | The category/target lifters, standalone | | @agentage/observability/bootstrap | (side effect) | node --import trace bootstrap | | @agentage/observability/next | register, onRequestError | Next.js instrumentation.ts |

createHealthHandler, healthResponse and staticHealthJson are the previous names for nodeHealth, health and staticHealth; createLogger is the previous name for logger, and captureError(log, err) is what log.error(err) now does by itself. All remain exported and unchanged. checks also accepts the original [{ name, run, timeoutMs, optional }] array form.

Configuration

Standard OTEL_* env, read by the SDK itself:

| Variable | Notes | | ---------------------------------------------- | ------------------------------------------------------------------------------------ | | OTEL_EXPORTER_OTLP_ENDPOINT | Base URL; the exporter appends /v1/traces. Unset = tracing off. | | OTEL_SERVICE_NAME | Required to start tracing; also the default service in /health. | | OTEL_EXPORTER_OTLP_HEADERS | Authorization=Bearer%20<token> - percent-encoded (, and = inside a token too). | | OTEL_RESOURCE_ATTRIBUTES | deployment.environment, service.namespace. | | OTEL_TRACES_SAMPLER[_ARG] | parentbased_traceidratio + a ratio matched to service volume. | | OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER | none where host agents own those signals. | | OTEL_SDK_DISABLED | true = hard off even when configured. | | OTEL_LOG_LEVEL | Unset = silent. debug to diagnose a missing-trace report. | | COMMIT_SHA / BUILD_TIME | Image build args, surfaced as version/commit/buildTime. | | LOG_LEVEL | pino level for createLogger (default info). |

COMMIT_SHA and BUILD_TIME must be redeclared as ARG and promoted to ENV in the runner stage - ARGs do not cross Docker stage boundaries, and without that your endpoint reports 0.0.0-dev forever.

A container health check needs no curl or wget, since node is already in the image:

NODE_OPTIONS= node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

Use 127.0.0.1, not localhost: an IPv6 resolution against an IPv4-only listener is a recurring way to fail a healthy container.

What gets traced (minimal by design)

Node services emit exactly one SERVER span per request ({method} {route}, status code, duration; scanner probes on unmatched routes collapse to {method} (unmatched); health probes are never recorded) plus CLIENT spans for outbound http/fetch calls, which carry W3C propagation to the next service. No Express layer spans, no fs/dns/db auto-spans. Next apps mirror this through the /next entry (noise sampler + span-name normalizer).

Depth is intentional, not automatic:

import { withSpan } from '@agentage/observability';

await withSpan('store.read', () => store.read(id), { memory: id });

bootstrap.js calls module.register() with @opentelemetry/instrumentation/hook.mjs - the supported replacement for the deprecated --experimental-loader flag, and the only way to patch pure-ESM imports. fs/net/dns auto-instrumentation is deliberately disabled: it buries request traces under noise.

Develop

npm install
npm test           # vitest
npm run verify     # type-check + lint + format:check + test + build + dist smoke

test/edge-safety.test.ts scans the raw source of the health module - comments included - for Node-only APIs and imports. The health module must stay dependency-free and Web-API-only: bundlers detect Node APIs in edge bundles statically, so a typeof guard does not save you, and a single slip breaks every app that imports the module through a shared barrel.

Release

A chore(release): X.Y.Z squash-merge subject on master publishes to npm via GitHub Actions; the workflow skips any version already published. Changes: CHANGELOG.md.

License

MIT - see LICENSE.