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
Maintainers
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
/partiescalls/api/aggregateand it fails, the log entry shows/partiesat the top level with the full/api/aggregatefailure 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 /observeInstall
npm install api-observeQuick 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/observeThe 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
onErrorhook) - 404s and 4xx/5xx replies — responses sent directly without throwing (via
onSendhook)
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/observeobserve (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 instanceserviceName— 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
