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

api-observe

v1.2.4

Published

Framework-agnostic plugin that captures and displays API failure details — works with Fastify, Express, NestJS, Koa, or plain Node.js HTTP

Readme

api-observe

Framework-agnostic API failure tracker for Node.js. Captures failed HTTP requests with full request/response details and forwards them to your own storage service. Serves a built-in dashboard that reads from that service.

Works with Fastify, Express, NestJS, Koa, or plain Node.js HTTP.

Features

  • Captures upstream API failures (via Axios interceptors)
  • Captures controller/route-level errors (via framework hooks)
  • Failure records are keyed to the inbound request — when /parties calls /api/aggregate and it fails, the log entry shows /parties at the top level with the full /api/aggregate failure nested inside
  • Built-in HTML dashboard at /observe
  • Forwards all captured failures to your own storage service via HTTP — no local state
  • Summary stats scoped to last 24 hours
  • Optional header capture for inbound requests and downstream calls
  • Automatic redaction of sensitive headers (authorization, tokens, passwords)
  • Zero external dependencies

How it works

Inbound request
      │
      ▼
AsyncLocalStorage tracks the active request
      │
      ▼
Your route handler calls a downstream HTTP client
      │
      ├── On failure → Axios interceptor captures full details
      ├── On thrown error → onError hook captures it
      └── On 4xx/5xx reply → onSend hook captures it
                │
                ▼
        POST { source, record } → your dataEndpoint
                │
                ▼
        Your DB service stores it
                │
                ▼
        Dashboard fetches from dataEndpoint
        and displays it at /observe

Install

npm install api-observe

Quick Start

Fastify

const Fastify = require('fastify');
const apiObserve = require('api-observe');

const fastify = Fastify();

await fastify.register(apiObserve, {
  dataEndpoint: 'http://my-db-service:3001/api/failures', // required
  source: 'my-service-name',                              // identifies which app sent the record
  endpointHeaders: { Authorization: 'Bearer my-token' },  // optional auth
  includeHeaders: true,                                   // capture req/res headers (default: true)
  sanitize: (data) => data,                               // custom sanitizer (optional)
});

fastify.after(() => {
  // Option A: single client
  fastify.observer.attach(myHttpClient);

  // Option B: auto-attach to all clients on an object
  fastify.observer.attachToAll(fastify.services);
});

await fastify.listen({ port: 3000 });
// Dashboard: http://localhost:3000/observe

The Fastify plugin automatically captures:

  • Upstream errors — failed HTTP calls from attached clients (via Axios interceptors)
  • Controller errors — any error thrown during the request lifecycle (via onError hook)
  • 404s and 4xx/5xx replies — responses sent directly without throwing (via onSend hook)

How upstream failures are recorded

When an inbound request (e.g. POST /parties) triggers a downstream call that fails (e.g. GET /api/aggregate returns 500), the failure record is keyed to the inbound route — not the downstream URL. Opening the record in the dashboard shows:

  • Top level: POST /parties — the service, method, URL, correlation ID, and inbound request headers/body
  • Upstream Call section: the downstream service name, GET /api/aggregate, status code, duration, error message, outgoing request headers/body, and the downstream response

Express

const express = require('express');
const { expressMiddleware } = require('api-observe/express');

const app = express();

const observe = expressMiddleware({
  dataEndpoint: 'http://my-db-service:3001/api/failures',
  source: 'my-service-name',
  endpointHeaders: { Authorization: 'Bearer my-token' },
  includeHeaders: true,
});

// Mount the dashboard and API routes
app.use(observe);

// ... your routes here ...

// Mount the error handler AFTER your routes
app.use(observe.errorHandler);

// Attach to your HTTP clients
observe.attach(myHttpClient);
observe.attachToAll(myServices);

app.listen(3000);
// Dashboard: http://localhost:3000/observe

observe (main middleware) serves the dashboard and API routes at /observe.

observe.errorHandler is an Express error middleware (err, req, res, next) that captures controller-level errors. Place it after your routes.


Plain Node.js HTTP

Works with any framework that exposes (req, res) — including Koa, NestJS, Hapi, etc.

const http = require('http');
const { createHttpHandler } = require('api-observe/http');

const observe = createHttpHandler({
  dataEndpoint: 'http://my-db-service:3001/api/failures',
  source: 'my-service-name',
  includeHeaders: true,
});

const server = http.createServer((req, res) => {
  if (observe.handle(req, res)) return;

  // Your app logic...
  res.end('Hello');
});

observe.attach(myHttpClient);

// Manually capture errors
try {
  await doSomething();
} catch (err) {
  observe.captureError(err, {
    method: 'POST',
    url: '/api/users',
    headers: req.headers,
  });
}

server.listen(3000);

NestJS

NestJS + Express (default):

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { expressMiddleware } from 'api-observe/express';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const observe = expressMiddleware({
    dataEndpoint: 'http://my-db-service:3001/api/failures',
    source: 'my-nestjs-service',
  });
  app.use(observe);
  observe.attach(myHttpClient);

  await app.listen(3000);
}
bootstrap();

NestJS + Fastify:

import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import apiObserve from 'api-observe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, new FastifyAdapter());

  const fastify = app.getHttpAdapter().getInstance();
  await fastify.register(apiObserve, {
    dataEndpoint: 'http://my-db-service:3001/api/failures',
    source: 'my-nestjs-service',
  });

  await app.listen(3000);
}
bootstrap();

Configuration Options

All adapters accept the same options:

| Option | Type | Required | Description | |--------|------|----------|-------------| | dataEndpoint | string | Yes | Base URL of your storage service failures endpoint | | source | string | Recommended | Name of this service — stored on every captured failure as source | | endpointHeaders | object | No | Headers sent on every request to dataEndpoint (e.g. { Authorization: 'Bearer ...' }) | | includeHeaders | boolean | No (default: true) | Include inbound and downstream request/response headers in captured records | | sanitize | function | No | Custom sanitizer for request/response bodies and headers | | exclude | Array<string\|RegExp> | No | URL patterns to skip. Supports exact strings, wildcards, and RegExp |

Excluding routes

apiObserve({
  dataEndpoint: '...',
  exclude: [
    '/health',          // exact match
    '/metrics',
    '/internal/*',      // wildcard: matches /internal/anything (no slashes)
    /^\/admin\//,       // RegExp
  ],
});

Default Sanitization

The built-in sanitizer automatically redacts these headers/fields:

authorization · x-access-token · cookie · set-cookie · secret · password · token · refresh · x-api-key

Custom sanitizer:

apiObserve({
  dataEndpoint: '...',
  sanitize: (data) => {
    if (!data || typeof data !== 'object') return data;
    const copy = { ...data };
    delete copy.ssn;
    delete copy.creditCard;
    return copy;
  },
});

Dashboard

Visit /observe in your browser to access the built-in dashboard.

  • Failure table with filtering by type, service, method, status code, URL, and correlation ID
  • Summary cards: total failures (last 24h), affected services, top status code, last failure time
  • Charts: failures by service, failures by status code
  • Detail drawer: inbound request details, upstream call breakdown, headers, request/response bodies
  • Auto-refresh every 30 seconds
  • Persistent error banner if the data endpoint is unreachable

REST API

Endpoints served by api-observe on the consuming service:

| Method | Endpoint | Description | |--------|----------|-------------| | GET | /observe | HTML dashboard | | GET | /observe/api/failures | List failures (proxied to dataEndpoint) | | GET | /observe/api/failures/:id | Single failure detail (proxied to dataEndpoint/:id) | | GET | /observe/api/summary | Stats grouped by service/status (proxied to dataEndpoint/summary) |

Query Parameters for GET /observe/api/failures

| Parameter | Type | Description | |-----------|------|-------------| | type | string | Filter by upstream or controller | | service | string | Filter by service/route name (partial match) | | method | string | Filter by HTTP method (GET, POST, etc.) | | statusCode | number | Filter by response status code (exact) | | url | string | Filter by URL (partial match) | | correlationId | string | Filter by correlation ID (partial match) | | from | ISO string | Filter records at or after this timestamp | | to | ISO string | Filter records at or before this timestamp | | limit | number | Results per page (default: 50) | | offset | number | Pagination offset (default: 0) |

# Get all 5xx failures from the payments route
curl "http://localhost:3000/observe/api/failures?service=payments&statusCode=500"

# Get summary stats (last 24h)
curl "http://localhost:3000/observe/api/summary"

DB Service — Required Endpoints

api-observe forwards captured failures to and reads data from a dataEndpoint that you implement. Below is the full contract and a reference implementation using Express + MongoDB.

POST body shape (what the package sends)

{
  "source": "ps-orchesr",
  "record": {
    "id": "44cd5021-f133-47e2-a701-7fc773457c05",
    "timestamp": "2026-07-08T03:51:15.747Z",
    "type": "upstream",
    "service": "/api/v2/payments/:id/download",
    "method": "GET",
    "url": "/api/v2/payments/LA%3APH%3A12101436%3AEB556593/download",
    "correlationId": "ba0d2a1a1e2f4ab1a9d8264099f5356b",
    "durationMs": 183,
    "errorMessage": "Request failed with status code 500",
    "errorCode": "ERR_BAD_RESPONSE",
    "statusCode": 500,
    "request": {
      "params": { "id": "LA:PH:12101436:EB556593" },
      "query": {}
    },
    "response": null,
    "upstreamCall": {
      "service": "file-flw-svc",
      "method": "POST",
      "url": "https://file-flw-svc.example.com/download/eOR",
      "durationMs": 183,
      "statusCode": 500,
      "errorMessage": "Request failed with status code 500",
      "errorCode": "ERR_BAD_RESPONSE",
      "request": { "params": null, "body": "{\"receiptNo\":\"EB556593\"}" },
      "response": {
        "body": { "isSuccess": false, "message": "No data found for Receipt No EB556593" }
      }
    }
  }
}

Required endpoint contract

| Method | Path | Description | |--------|------|-------------| | POST | /api/failures | Save a captured failure | | GET | /api/failures/summary | Stats for last 24h — register before /:id | | GET | /api/failures | List with filters + pagination | | GET | /api/failures/:id | Single record by record.id |

GET /api/failures response shape

{
  "data": [ ...record objects... ],
  "total": 123,
  "limit": 50,
  "offset": 0
}

Each item in data must be the record object (not the full MongoDB document).

GET /api/failures/summary response shape

Scoped to the last 24 hours only.

{
  "totalFailures": 10,
  "byService":    { "/api/v2/payments/:id/download": 5, "/api/v2/orders": 3 },
  "byStatusCode": { "500": 6, "404": 2, "0": 2 },
  "byType":       { "upstream": 8, "controller": 2 },
  "oldestEntry":  "2026-07-09T00:00:00.000Z",
  "newestEntry":  "2026-07-09T12:00:00.000Z"
}

Return null for oldestEntry / newestEntry when there are no records.


Reference Implementation — Express + MongoDB

failures.router.js

const express = require('express');
const router = express.Router();

// ── POST /api/failures ────────────────────────────────────────────────────
// Receives { source, record } from api-observe
router.post('/', async (req, res) => {
  try {
    const { source, record } = req.body;

    if (!record) {
      return res.status(400).json({ error: 'Missing record in request body' });
    }

    const doc = {
      source,
      record,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await req.db.collection('failures').insertOne(doc);
    res.status(201).json(doc);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// ── GET /api/failures/summary ─────────────────────────────────────────────
// Must be registered BEFORE /:id so the path "/summary" is not treated as an ID
router.get('/summary', async (req, res) => {
  try {
    const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();

    const docs = await req.db
      .collection('failures')
      .find({ 'record.timestamp': { $gte: since } })
      .toArray();

    const byService = {};
    const byStatusCode = {};
    const byType = {};
    let oldest = null;
    let newest = null;

    for (const doc of docs) {
      const r = doc.record;

      const svc = r.service || 'unknown';
      byService[svc] = (byService[svc] || 0) + 1;

      const code = String(r.statusCode ?? 0);
      byStatusCode[code] = (byStatusCode[code] || 0) + 1;

      const type = r.type || 'upstream';
      byType[type] = (byType[type] || 0) + 1;

      if (!oldest || r.timestamp < oldest) oldest = r.timestamp;
      if (!newest || r.timestamp > newest) newest = r.timestamp;
    }

    res.json({
      totalFailures: docs.length,
      byService,
      byStatusCode,
      byType,
      oldestEntry: oldest,
      newestEntry: newest,
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// ── GET /api/failures ─────────────────────────────────────────────────────
router.get('/', async (req, res) => {
  try {
    const {
      source, service, statusCode, method, url,
      type, correlationId, from, to,
      limit = 50, offset = 0,
    } = req.query;

    const filter = {};

    if (source)        filter.source                  = new RegExp(source, 'i');
    if (service)       filter['record.service']       = new RegExp(service, 'i');
    if (method)        filter['record.method']        = method.toUpperCase();
    if (statusCode)    filter['record.statusCode']    = Number(statusCode);
    if (url)           filter['record.url']           = new RegExp(url, 'i');
    if (type)          filter['record.type']          = type;
    if (correlationId) filter['record.correlationId'] = new RegExp(correlationId, 'i');
    if (from || to) {
      filter['record.timestamp'] = {};
      if (from) filter['record.timestamp'].$gte = from;
      if (to)   filter['record.timestamp'].$lte = to;
    }

    const col = req.db.collection('failures');
    const total = await col.countDocuments(filter);
    const docs = await col
      .find(filter)
      .sort({ 'record.timestamp': -1 })
      .skip(Number(offset))
      .limit(Number(limit))
      .toArray();

    res.json({
      data: docs.map(d => d.record),
      total,
      limit: Number(limit),
      offset: Number(offset),
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// ── GET /api/failures/:id ─────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
  try {
    const doc = await req.db
      .collection('failures')
      .findOne({ 'record.id': req.params.id });

    if (!doc) {
      return res.status(404).json({ error: 'Failure record not found' });
    }

    res.json(doc.record);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

module.exports = router;

app.js

const express = require('express');
const { MongoClient } = require('mongodb');
const failuresRouter = require('./failures.router');

async function start() {
  const client = await MongoClient.connect(process.env.MONGO_URI);
  const db = client.db(process.env.MONGO_DB || 'observe');

  // Indexes for efficient querying
  await db.collection('failures').createIndexes([
    { key: { 'record.timestamp': -1 } },
    { key: { 'record.id': 1 }, unique: true },
    { key: { source: 1 } },
  ]);

  const app = express();
  app.use(express.json());
  app.use((req, _res, next) => { req.db = db; next(); });
  app.use('/api/failures', failuresRouter);

  app.listen(process.env.PORT || 3001, () => {
    console.log(`DB service running on port ${process.env.PORT || 3001}`);
  });
}

start();

Then point api-observe at it:

await fastify.register(apiObserve, {
  dataEndpoint: 'http://my-db-service:3001/api/failures',
  source: 'my-service-name',
});

Compatible HTTP Client

api-observe intercepts failures from any HTTP client object that exposes:

  • _axios — an Axios instance
  • serviceName — a string identifier for the service
const axios = require('axios');

class BaseHttpClient {
  constructor({ baseUrl, serviceName, timeoutMs = 5000 }) {
    this.serviceName = serviceName;
    this._axios = axios.create({
      baseURL: baseUrl,
      timeout: timeoutMs,
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    });
  }

  async get(path, options = {}) {
    const res = await this._axios.get(path, { params: options.params, correlationId: options.correlationId });
    return res.data;
  }

  async post(path, data, options = {}) {
    const res = await this._axios.post(path, data, { correlationId: options.correlationId });
    return res.data;
  }

  async put(path, data, options = {}) {
    const res = await this._axios.put(path, data, { correlationId: options.correlationId });
    return res.data;
  }

  async delete(path, options = {}) {
    const res = await this._axios.delete(path, { correlationId: options.correlationId });
    return res.data;
  }
}

module.exports = { BaseHttpClient };

Creating a service client

const { BaseHttpClient } = require('./base-http.client');

class UserService {
  constructor(config) {
    this.client = new BaseHttpClient({
      baseUrl: config.baseUrl,
      serviceName: 'user-service',
      timeoutMs: config.timeoutMs,
    });
  }

  async getUserById(id, opts = {}) {
    return this.client.get(`/v1/users/${id}`, { correlationId: opts.correlationId });
  }

  async createUser(data, opts = {}) {
    return this.client.post('/v1/users', data, { correlationId: opts.correlationId });
  }
}

module.exports = UserService;

Attaching to api-observe

// Single client
observe.attach(userService.client);

// Auto-attach all clients on an object
// Works for direct BaseHttpClient instances or service wrappers with a .client property
const services = {
  userService: new UserService({ baseUrl: 'http://user-svc:4001' }),
  orderService: new OrderService({ baseUrl: 'http://order-svc:4002' }),
};
observe.attachToAll(services);

Exports

// Default: Fastify plugin
const apiObserve = require('api-observe');

// Express adapter
const { expressMiddleware } = require('api-observe/express');

// Plain HTTP adapter
const { createHttpHandler } = require('api-observe/http');

// Core building blocks (for custom integrations)
const {
  RemoteStore,         // HTTP-forwarding store
  attachInterceptor,   // Attach to a single client
  attachToAll,         // Auto-attach to all clients on an object
  createRouter,        // Framework-agnostic route handler
} = require('api-observe');

License

MIT