@japfa/jarvis-apm
v1.0.0
Published
Lightweight APM + audit trail for Node.js. HTTP tracking, latency, error capture, custom audit logs, and embedded ClickHouse collector — all in one package.
Maintainers
Readme
apm-lite-audit
Lightweight APM + audit trail for Node.js — HTTP tracking, latency, error capture, custom audit logs, and an embedded ClickHouse collector, all in one package.
Supports: Express · Fastify · NestJS
Storage: ClickHouse (auto schema, auto TTL)
Node.js: >= 16.0.0
Installation
npm install apm-lite-auditQuick Start
Express
const express = require('express');
const apm = require('apm-lite-audit');
apm.init({
serviceName: 'my-service',
collector: {
port: 3100,
clickhouse: {
host: 'http://localhost:8123',
username: 'default',
password: process.env.CLICKHOUSE_PASSWORD,
},
},
});
const app = express();
app.use(apm.middleware()); // must be before routes
app.get('/', (req, res) => res.json({ ok: true }));
app.listen(3000);NestJS
// main.ts
import { apm } from 'apm-lite-audit/nestjs';
apm.init({
serviceName: 'my-service',
collector: {
port: 3100,
clickhouse: { host: 'http://localhost:8123', password: process.env.CLICKHOUSE_PASSWORD },
},
});
// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApmInterceptor } from 'apm-lite-audit/nestjs';
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: ApmInterceptor }],
})
export class AppModule {}ClickHouse setup:
docker compose up -d— adocker-compose.ymlis included in this package. The schema is created automatically on first run.
Configuration
apm.init(options)
apm.init({
// ── Required ────────────────────────────────
serviceName: 'payment-service',
// ── Optional ────────────────────────────────
environment: 'production', // default: 'production'
sampleRate: 1.0, // 0–1, fraction of HTTP requests to keep. Errors always kept.
ignorePaths: ['/health', '/metrics'],
maxAuditPerSecond: 100, // rate-limit apm.log() — 0 = unlimited
timeout: 5000, // transport HTTP timeout (ms)
maxRetries: 3,
maxBatchSize: 50,
flushInterval: 2000, // flush every N ms
maxQueueSize: 2000,
// ── Embedded collector ──────────────────────
// Omit to use `endpoint` instead (external collector).
collector: {
port: 3100,
database: 'apm', // default: 'apm'
table: 'traces', // default: 'traces'
ttlDays: 90, // auto-delete rows older than N days
clickhouse: {
host: 'http://clickhouse:8123',
username: 'default',
password: process.env.CLICKHOUSE_PASSWORD,
},
},
});apm.initFromConfig()
Load config from apm.config.js at your project root:
// apm.config.js
module.exports = {
serviceName: process.env.SERVICE_NAME || 'my-service',
environment: process.env.NODE_ENV || 'production',
sampleRate: parseFloat(process.env.APM_SAMPLE_RATE || '1.0'),
ignorePaths: ['/health', '/metrics'],
collector: {
port: parseInt(process.env.APM_COLLECTOR_PORT || '3100'),
database: process.env.CLICKHOUSE_DATABASE || 'apm',
table: process.env.CLICKHOUSE_TABLE || 'traces',
ttlDays: 90,
clickhouse: {
host: process.env.CLICKHOUSE_HOST || 'http://localhost:8123',
username: process.env.CLICKHOUSE_USER || 'default',
password: process.env.CLICKHOUSE_PASSWORD || '',
},
},
};const apm = require('apm-lite-audit');
apm.initFromConfig(); // reads apm.config.js from process.cwd()
app.use(apm.middleware());HTTP Tracking
Express — apm.middleware()
app.use(apm.middleware()); // place before routesAutomatically captures every request: trace ID, latency, status, errors. Enrichment fields are picked up from req when the response finishes:
// Set in your auth middleware
req.userId = user.id; // → user_id
req.tenantId = user.tenantId; // → tenant_id
req.requestId = req.headers['x-request-id']; // → request_id
req.apmMeta = { role: user.role }; // → metadata{}
req.apmError = err.message; // → error_message (on 5xx)req.traceId is injected by the middleware — use it in apm.log() to link audit events to the HTTP trace.
Fastify — apm.fastifyPlugin()
fastify.register(apm.fastifyPlugin());
// then use request.userId, request.tenantId, request.apmMeta, etc.NestJS Instrumentation
Import from apm-lite-audit/nestjs.
ApmInterceptor — automatic HTTP span per controller
Registered as a global interceptor, it creates a span for every controller method call, linked to the active trace.
// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApmInterceptor } from 'apm-lite-audit/nestjs';
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: ApmInterceptor }],
})
export class AppModule {}@Traceable() — function-level spans on services
Wraps every method on the decorated class with an APM child span, giving you a full call-stack trace per request.
import { Injectable } from '@nestjs/common';
const { Traceable } = require('apm-lite-audit/nestjs');
@Traceable()
@Injectable()
export class AgentService {
async createAgent(dto: CreateAgentDto) { ... }
async deleteAgent(id: string) { ... }
// all methods are automatically traced
}Decorate with
@Traceable()above@Injectable(). Both sync and async methods are supported.
setApm(apm) — wire APM into the NestJS module
Call this once after apm.init() so the interceptor and @Traceable() can record spans:
import apm from 'apm-lite-audit';
import { setApm } from 'apm-lite-audit/nestjs';
apm.init({ ... });
setApm(apm);Or if you use apm.initFromConfig():
apm.initFromConfig();
setApm(apm);Audit Logging — apm.log(data)
Emit a business event. Always stored — bypasses HTTP sampling.
apm.log({
event: 'payment.completed', // required — use dot notation
user_id: req.userId,
trace_id: req.traceId, // links this event to the HTTP trace
request_id: req.requestId,
// any extra fields → stored as metadata{}
amount: 99000,
currency: 'IDR',
});| Field | Description |
|---|---|
| event | Event name, e.g. 'agent.created', 'user.login' |
| user_id | User who triggered the event |
| tenant_id | Tenant / organization |
| trace_id | Link to the HTTP trace (req.traceId) |
| request_id | Request identifier |
| ...rest | Stored as metadata{} (max 2 KB) |
NestJS: @Audit() decorator
Instead of calling apm.log() manually, use the @Audit() decorator on controller methods together with AuditInterceptor to log business events automatically.
1. Create the decorator:
// src/common/decorators/audit.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const AUDIT_EVENT_KEY = 'audit_event';
export const Audit = (event: string) => SetMetadata(AUDIT_EVENT_KEY, event);2. Create the interceptor:
// src/common/interceptors/audit.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { tap } from 'rxjs/operators';
import { AUDIT_EVENT_KEY } from '../decorators/audit.decorator';
const apm = require('apm-lite-audit');
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const event = this.reflector.getAllAndOverride<string>(AUDIT_EVENT_KEY, [
ctx.getHandler(), ctx.getClass(),
]);
if (!event) return next.handle();
const req = ctx.switchToHttp().getRequest();
return next.handle().pipe(
tap({ next: () => {
apm.log({
event,
user_id: req.user?.userId,
trace_id: req.traceId,
request_id: req.requestId,
resource_id: req.params?.id,
});
}}),
);
}
}3. Register globally and use on controllers:
// app.module.ts — add AuditInterceptor alongside ApmInterceptor
providers: [
{ provide: APP_INTERCEPTOR, useClass: ApmInterceptor },
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
]
// agents.controller.ts
@Post()
@Audit('agent.created')
createAgent(@Body() dto: CreateAgentDto) { ... }
@Put(':id')
@Audit('agent.updated')
updateAgent(@Param('id') id: string, @Body() dto: UpdateAgentDto) { ... }
@Delete(':id')
@Audit('agent.deleted')
deleteAgent(@Param('id') id: string) { ... }Utility Methods
apm.stats()
app.get('/internal/apm-stats', (_req, res) => res.json(apm.stats()));
// { queueSize: 3, droppedEvents: 0, sentEvents: 1482, failedBatches: 0 }apm.flush()
Force-flush all pending events. Useful in tests or graceful shutdown:
process.on('SIGTERM', async () => {
await apm.flush();
process.exit(0);
});Distributed Tracing
Pass x-trace-id between services. apm.middleware() reads it automatically, so all spans share the same trace ID:
// Service A — outbound call
await fetch('http://inventory/reserve', {
headers: { 'x-trace-id': req.traceId },
});
// Service B — req.traceId === same ID from Service AClickHouse Schema
Created automatically on first startup. For reference:
CREATE TABLE IF NOT EXISTS `apm`.`traces` (
trace_id String,
type LowCardinality(String) DEFAULT 'http',
service_name LowCardinality(String),
environment LowCardinality(String) DEFAULT 'production',
endpoint Nullable(String),
method LowCardinality(Nullable(String)),
status Nullable(UInt16),
latency Nullable(Float32),
event Nullable(String),
user_id Nullable(String),
tenant_id Nullable(String),
request_id Nullable(String),
error UInt8 DEFAULT 0,
error_message Nullable(String),
metadata String DEFAULT '{}',
timestamp DateTime64(3, 'UTC') DEFAULT now64()
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service_name, timestamp, trace_id)
TTL toDateTime(timestamp) + INTERVAL 90 DAY;Common queries:
-- Slowest endpoints (last hour)
SELECT endpoint, avg(latency) AS avg_ms, count() AS hits
FROM apm.traces
WHERE type = 'http' AND timestamp > now() - INTERVAL 1 HOUR
GROUP BY endpoint ORDER BY avg_ms DESC LIMIT 20;
-- All errors (last 24 hours)
SELECT timestamp, endpoint, status, error_message, user_id
FROM apm.traces
WHERE error = 1 AND timestamp > now() - INTERVAL 1 DAY
ORDER BY timestamp DESC;
-- Audit trail for a user
SELECT timestamp, event, metadata
FROM apm.traces
WHERE type = 'audit' AND user_id = '42'
ORDER BY timestamp DESC LIMIT 100;
-- Full trace
SELECT * FROM apm.traces
WHERE trace_id = 'abc123'
ORDER BY timestamp;Environment Variables
| Variable | Description |
|---|---|
| APM_DEBUG=true | Enable verbose debug logs to stderr |
| SERVICE_NAME | Service name (used in apm.config.js) |
| NODE_ENV | Environment (used in apm.config.js) |
| APM_SAMPLE_RATE | Sample rate 0–1 (used in apm.config.js) |
| APM_COLLECTOR_PORT | Embedded collector port (used in apm.config.js) |
| CLICKHOUSE_HOST | ClickHouse HTTP URL |
| CLICKHOUSE_DATABASE | ClickHouse database name |
| CLICKHOUSE_USER | ClickHouse username |
| CLICKHOUSE_PASSWORD | ClickHouse password |
License
MIT
