This wiki contains detailed integration and operational guidance for @senzops/apm-node.
The package is the native Senzor SDK for Node.js API services. It captures traces, spans, errors, logs, and task runs without requiring OpenTelemetry in the application.
Preload mode gives the SDK the best chance to capture all supported libraries because instrumentation is installed before the application imports framework, database, queue, and HTTP client modules.
CommonJS:
SENZOR_API_KEY=sz_apm_your_key_here node -r @senzops/apm-node/register server.jsESM:
SENZOR_API_KEY=sz_apm_your_key_here node --import @senzops/apm-node/register server.mjsDocker:
ENV SENZOR_API_KEY=sz_apm_your_key_here
CMD ["node", "-r", "@senzops/apm-node/register", "dist/server.js"]PM2:
{
"apps": [
{
"name": "orders-api",
"script": "dist/server.js",
"node_args": "-r @senzops/apm-node/register",
"env": {
"SENZOR_API_KEY": "sz_apm_your_key_here",
"NODE_ENV": "production"
}
}
]
}Programmatic mode is useful when your hosting platform does not support preload flags.
Place Senzor.init() at the top of the entrypoint, before importing application modules when possible.
import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!,
endpoint: process.env.SENZOR_ENDPOINT,
batchSize: 100,
flushInterval: 10000,
maxSpansPerTrace: 500
});
import './server';Senzor.init({
apiKey: 'sz_apm_your_key_here',
endpoint: 'https://api.senzor.dev',
batchSize: 100,
flushInterval: 10000,
flushTimeoutMs: 5000,
maxQueueSize: 10000,
maxSpansPerTrace: 500,
maxAttributeLength: 2048,
maxAttributes: 64,
captureHeaders: false,
captureDbStatement: true,
frameworkSpans: true,
captureMiddlewareSpans: true,
captureRouterSpans: true,
captureLifecycleHookSpans: true,
autoLogs: true,
debug: false
});| Option | Production recommendation |
|---|---|
apiKey |
Always pass through environment or secret manager. Do not hardcode. |
endpoint |
Use https://api.senzor.dev unless your Senzor tenant provides a custom ingest URL. |
batchSize |
Start with 100. Increase for very high-throughput APIs if network overhead matters. |
flushInterval |
Start with 10000 ms. Lower for short-lived processes. |
flushTimeoutMs |
Keep between 3000 and 10000 ms for API services. |
maxQueueSize |
Keep bounded. Increase only after checking memory pressure. |
maxSpansPerTrace |
Keep 500 unless traces are expected to fan out heavily. |
captureHeaders |
Keep false unless sanitized headers are required for debugging. |
captureDbStatement |
Keep enabled only if SQL normalization is acceptable for your data policy. |
autoLogs |
Keep enabled if correlated logs are wanted. Disable if another log pipeline already owns logs. |
debug |
Use only during troubleshooting. |
Enable only selected instrumentation:
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!,
instrumentations: ['http', 'fetch', 'pg', 'redis']
});Disable auto-instrumentation while keeping manual APIs:
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!,
instrumentations: false
});The preload entrypoint supports these variables:
SENZOR_API_KEY=sz_apm_your_key_here
SENZOR_ENDPOINT=https://api.senzor.dev
SENZOR_BATCH_SIZE=100
SENZOR_FLUSH_INTERVAL=10000
SENZOR_FLUSH_TIMEOUT_MS=5000
SENZOR_MAX_QUEUE_SIZE=10000
SENZOR_MAX_SPANS_PER_TRACE=500
SENZOR_CAPTURE_HEADERS=false
SENZOR_CAPTURE_DB_STATEMENT=true
SENZOR_FRAMEWORK_SPANS=true
SENZOR_CAPTURE_MIDDLEWARE_SPANS=true
SENZOR_CAPTURE_ROUTER_SPANS=true
SENZOR_CAPTURE_LIFECYCLE_HOOK_SPANS=true
SENZOR_AUTO_LOGS=true
SENZOR_DEBUG=falseAccepted API key variables:
SENZOR_API_KEYSENZOR_APM_API_KEYSENZOR_SERVICE_API_KEY
Accepted endpoint variables:
SENZOR_ENDPOINTSENZOR_APM_ENDPOINT
Preload mode automatically captures inbound HTTP requests. For better Express route names and error capture, add the Senzor middleware.
import express from 'express';
import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
const app = express();
app.use(Senzor.requestHandler());
app.get('/orders/:orderId', async (req, res) => {
res.json({ orderId: req.params.orderId });
});
app.use(Senzor.errorHandler());
app.listen(3000);For NestJS on Express:
import Senzor from '@senzops/apm-node';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(Senzor.requestHandler());
await app.listen(3000);
}
bootstrap();import Fastify from 'fastify';
import Senzor from '@senzops/apm-node';
const fastify = Fastify();
fastify.register(Senzor.fastifyPlugin, {
apiKey: process.env.SENZOR_API_KEY!
});
fastify.get('/orders/:orderId', async (request) => {
return { orderId: request.params.orderId };
});
await fastify.listen({ port: 3000 });import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
export const GET = Senzor.wrapNextRoute(async (request: Request) => {
const url = new URL(request.url);
return Response.json({ path: url.pathname });
});import type { NextApiRequest, NextApiResponse } from 'next';
import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
export default Senzor.wrapNextPages(
async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ ok: true });
}
);For serverless functions:
await Senzor.flush();import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
export default Senzor.wrapH3(
defineEventHandler(async (event) => {
return { ok: true };
})
);import http from 'http';
import Senzor from '@senzops/apm-node';
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!
});
const server = http.createServer(async (req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
server.listen(3000);With preload mode, no manual request wrapper is required.
Supported package: pg.
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const result = await pool.query(
'SELECT id, email FROM users WHERE id = $1',
[userId]
);Captured span example:
{
"name": "Postgres SELECT",
"type": "db",
"meta": {
"operation": "SELECT",
"db.system.name": "postgresql",
"db.operation.name": "SELECT",
"db.query.text": "SELECT id, email FROM users WHERE id = $?",
"rowCount": 1,
"library": "pg"
}
}Supported package: mongodb.
const user = await db.collection('users').findOne({ _id: userId });
const orders = await db.collection('orders').find({ userId }).toArray();Captured operations include common collection operations and cursor execution:
findOnefindaggregateinsertOneinsertManyupdateOneupdateManydeleteOnedeleteManybulkWritecountDocuments
Supported package: mongoose.
const user = await User.findById(userId).exec();
await user.save();Captured metadata includes model name, collection name, operation, and driver family.
Supported packages:
mysqlmysql2
const [rows] = await connection.execute(
'SELECT id, email FROM users WHERE id = ?',
[userId]
);Captured metadata includes SQL operation, sanitized statement, row count when available, and package name.
Supported packages:
redisioredis
await redis.get(`user:${userId}`);
await redis.set(`user:${userId}`, JSON.stringify(user));Captured span names follow the command name:
Redis GETRedis SETRedis HGETALLRedis DEL
The SDK propagates distributed tracing headers on outgoing calls:
traceparent: 00-f3b2c2c9c70443f5a4b7f0ff6d5b9a17-9d8a4d5f17e24d2a-01
x-senzor-trace-id: f3b2c2c9c70443f5a4b7f0ff6d5b9a17
x-senzor-parent-span-id: 9d8a4d5f17e24d2aSupported clients:
- Node
http.request - Node
https.request - Node
http.get - Node
https.get - Global
fetch undici
Example:
const response = await fetch('https://inventory.internal/items/sku_123');
const data = await response.json();Captured span example:
{
"name": "GET inventory.internal",
"type": "http",
"status": 200,
"meta": {
"method": "GET",
"library": "fetch",
"http.request.method": "GET",
"url.path": "/items/sku_123",
"server.address": "inventory.internal"
}
}console.info('order accepted', {
orderId: 'ord_123',
customerId: 'cus_456'
});Captured log example:
{
"message": "order accepted",
"level": "info",
"traceId": "f3b2c2c9c70443f5a4b7f0ff6d5b9a17",
"attributes": {
"orderId": "ord_123",
"customerId": "cus_456"
},
"timestamp": "2026-05-16T15:30:00.000Z"
}Disable console log capture:
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!,
autoLogs: false
});try {
await chargeCustomer(customerId);
} catch (error) {
Senzor.captureException(error, {
customerId,
operation: 'charge_customer'
});
throw error;
}Captured error example:
{
"errorClass": "Error",
"message": "Card declined",
"traceId": "f3b2c2c9c70443f5a4b7f0ff6d5b9a17",
"context": {
"customerId": "cus_456",
"operation": "charge_customer"
},
"timestamp": "2026-05-16T15:30:00.000Z"
}Global handlers capture:
uncaughtExceptionMonitoruncaughtExceptionunhandledRejectionwarningmultipleResolvesSIGTERMSIGINT
const rebuildSearchIndex = Senzor.wrapTask(
'rebuild_search_index',
'custom',
{
metadata: {
owner: 'search',
schedule: 'manual'
}
},
async () => {
await rebuildIndex();
}
);
await rebuildSearchIndex();When bullmq is installed and auto-instrumentation is enabled, worker jobs are captured as task runs.
import { Worker } from 'bullmq';
new Worker('billing', async (job) => {
await sendInvoice(job.data.invoiceId);
});Captured task fields include:
taskNametaskTypequeueDelayattemptsisDeadLettermetadata.jobIdmetadata.queueNameresourceMetrics
import cron from 'node-cron';
cron.schedule(
'*/5 * * * *',
async () => {
await syncReports();
},
{
name: 'sync_reports'
}
);The SDK wraps scheduled handlers and sends each execution as a task run.
Use track() when the runtime cannot use framework wrappers and you already have timing data.
Senzor.track({
method: 'POST',
route: '/webhooks/payment',
path: '/webhooks/payment',
status: 202,
duration: 18.7,
spans: []
});const span = Senzor.startSpan('reserve_inventory', 'function');
try {
await reserveInventory(orderId);
span.end({ orderId }, 200);
} catch (error) {
span.end({ orderId, error: String(error) }, 500);
throw error;
}Span types:
httpdbfunctioncustom
Endpoint:
POST /api/ingest/apmPayload:
{
"traces": [
{
"traceId": "f3b2c2c9c70443f5a4b7f0ff6d5b9a17",
"parentTraceId": "8cb1f2e2f43e4b2b8b3411c7df81e7e0",
"parentSpanId": "6c62c908a21d4f49",
"method": "GET",
"route": "/orders/:orderId",
"path": "/orders/ord_123?include=items",
"status": 200,
"duration": 53.29,
"ip": "203.0.113.10",
"userAgent": "Mozilla/5.0",
"timestamp": "2026-05-16T15:30:00.000Z",
"spans": [
{
"spanId": "9d8a4d5f17e24d2a",
"parentSpanId": "91f6c551d5a2403f",
"name": "GET inventory.internal",
"type": "http",
"startTime": 5.41,
"duration": 22.16,
"status": 200,
"meta": {
"method": "GET",
"library": "fetch",
"http.request.method": "GET",
"server.address": "inventory.internal"
}
}
]
}
],
"errors": [],
"logs": []
}Endpoint:
POST /api/ingest/taskPayload:
{
"runs": [
{
"runId": "3917bd35-b1d6-4e23-a1d2-d969e1a7d6a1",
"taskName": "billing:send_invoice_email",
"taskType": "queue",
"status": "success",
"duration": 188.3,
"queueDelay": 92,
"attempts": 1,
"triggerTraceId": "f3b2c2c9c70443f5a4b7f0ff6d5b9a17",
"metadata": {
"jobId": "142",
"queueName": "billing"
},
"resourceMetrics": {
"memoryDeltaBytes": 1048576,
"cpuUserUs": 12000,
"cpuSystemUs": 3000
},
"isDeadLetter": false,
"spans": [],
"timestamp": "2026-05-16T15:30:00.000Z"
}
],
"errors": [],
"logs": []
}The SDK is conservative by default.
Sensitive keys are redacted from attributes, headers, logs, and error context:
authorizationcookieset-cookiepasswordpasswdpwdsecrettokenapi-keyx-api-keyaccess-tokenrefresh-tokenclient-secretprivate-key
Cardinality controls:
- URL routes are normalized, for example
/users/123becomes/users/:id. - UUID-like IDs and Mongo ObjectIds are normalized in routes.
- Attribute count and string length are bounded.
- Span count per trace is bounded.
- Queue size is bounded.
Production recommendations:
- Keep
captureHeadersdisabled unless required. - Never capture request bodies by default.
- Avoid putting raw customer data into span names.
- Use route templates and IDs in metadata, not in operation names.
The SDK batches telemetry and sends it asynchronously.
Behavior:
- Queues are bounded with
maxQueueSize. - Flush occurs on
batchSizeorflushInterval. - Ingest calls have a timeout with
flushTimeoutMs. - Failed batches are requeued within queue limits.
- SDK ingest requests are marked as internal so they are not traced recursively.
- A best-effort flush runs during
beforeExit.
Serverless recommendation:
await Senzor.flush();Call this after the response is prepared but before the function exits when deterministic delivery matters.
Check the startup order first. Preload mode should be used when possible:
node -r @senzops/apm-node/register server.jsIf programmatic mode is used, initialize before importing pg, mongodb, redis, framework modules, or HTTP clients.
The SDK deduplicates active traces in common cases. If duplicates still appear, check whether the app is using both native preload mode and custom manual track() calls for the same request.
Verify:
SENZOR_API_KEYis present.endpointpoints to the expected Senzor environment.- The runtime can reach the ingest endpoint.
Senzor.flush()is called in short-lived runtimes.debug: trueshows flush attempts.
Tune:
Senzor.init({
apiKey: process.env.SENZOR_API_KEY!,
maxQueueSize: 5000,
maxSpansPerTrace: 250,
maxAttributes: 32,
maxAttributeLength: 1024
});Use framework wrappers where possible so route templates are available:
app.use(Senzor.requestHandler());For manual traces, pass normalized routes:
Senzor.track({
method: 'GET',
route: '/users/:id',
path: '/users/123',
status: 200,
duration: 12.4
});Build the package:
npm run buildThe build produces:
- CommonJS:
dist/index.js - ESM:
dist/index.mjs - Browser/global bundle:
dist/index.global.js - Preload entrypoint:
dist/register.jsanddist/register.mjs - Type declarations:
dist/index.d.ts,dist/register.d.ts
Before publishing, verify:
npx tsc --noEmit
npm run build