Send traces from a Node.js application that runs under PM2 to SigNoz with OpenTelemetry. The setup works in fork mode and in cluster mode.
Overview
The Node.js guide loads OpenTelemetry with the NODE_OPTIONS environment variable. Under PM2, this approach has two problems:
- In cluster mode, PM2 applies the
envblock of your ecosystem file after the Node.js process starts.NODE_OPTIONSnever takes effect, and SigNoz receives no traces. - PM2 stops, restarts, and reloads processes with
SIGINT. The zero-code agent flushes buffered spans onSIGTERMbut not onSIGINT, so you lose the spans of the last few seconds on every restart.
This guide loads OpenTelemetry from a tracing.js file in your code. The file reads its configuration from the PM2 ecosystem file and flushes spans on SIGINT.
Prerequisites
- Node.js 20.6.0+
- PM2 installed, and an application that you start with a PM2 ecosystem file
- An instance of SigNoz (either Cloud or Self-Hosted)
Send traces to SigNoz
Step 1. Install OpenTelemetry packages
Run this command in your application directory:
npm install --save @opentelemetry/api @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node @opentelemetry/resourcesStep 2. Create the tracing file
Create tracing.js next to your application entry file. If your package.json sets "type": "module", use the ES module tab.
const { hostname } = require('os')
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const { resourceFromAttributes } = require('@opentelemetry/resources')
const sdk = new NodeSDK({
// One stable ID per PM2 instance, kept across restarts
resource: resourceFromAttributes({
'service.instance.id': `${hostname()}-${process.env.NODE_APP_INSTANCE ?? 0}`,
}),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
// PM2 stops processes with SIGINT. Flush buffered telemetry before exit.
// If the application has its own handler, let that handler exit.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
const appHandlesExit = process.listenerCount(signal) > 1
sdk.shutdown().finally(() => {
if (!appHandlesExit) process.exit(0)
})
})
}import { hostname } from 'node:os'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { resourceFromAttributes } from '@opentelemetry/resources'
const sdk = new NodeSDK({
// One stable ID per PM2 instance, kept across restarts
resource: resourceFromAttributes({
'service.instance.id': `${hostname()}-${process.env.NODE_APP_INSTANCE ?? 0}`,
}),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
// PM2 stops processes with SIGINT. Flush buffered telemetry before exit.
// If the application has its own handler, let that handler exit.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
const appHandlesExit = process.listenerCount(signal) > 1
sdk.shutdown().finally(() => {
if (!appHandlesExit) process.exit(0)
})
})
}The SDK reads the exporter endpoint, the ingestion key, and the service name from OTEL_* environment variables. You set them in the next step.
PM2 gives each cluster instance a NODE_APP_INSTANCE number (0, 1, and so on) and keeps that number when the instance restarts. The file uses it to set service.instance.id, so each worker keeps one identity in SigNoz.
Step 3. Load the tracing file first
The tracing file must load before any other module. OpenTelemetry can only instrument modules, such as http or express, that load after it.
Add this line as the first line of the file that the script field of your ecosystem file points to, for example app.js:
require('./tracing')-
Install the packages that provide the ES module loader hooks:
npm install --save @opentelemetry/instrumentation import-in-the-middle -
Create
start.jsnext totracing.js. Replace./app.jswith your application entry file:start.jsimport { register } from 'node:module' import { register as registerHooks, supportsSyncHooks } from 'import-in-the-middle/register-hooks.mjs' // Use the in-thread hook where Node.js supports it. Fall back on older versions. if (supportsSyncHooks()) registerHooks() else register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) await import('./tracing.js') await import('./app.js') -
In the next step, set the
scriptfield of your ecosystem file tostart.js.
start.js runs after PM2 sets the OTEL_* variables. The loader hook lets OpenTelemetry instrument modules that your application loads with import. On Node.js versions that support synchronous hooks, start.js uses module.registerHooks(). On older versions, it falls back to module.register(), which Node.js 26 deprecates.
Step 4. Set environment variables in the ecosystem file
Add the OTEL_* variables to the env block of your application in ecosystem.config.js:
module.exports = {
apps: [
{
name: '<app-name>',
script: 'app.js', // 'start.js' for an ES module app
exec_mode: 'cluster', // or 'fork'
instances: 2,
env: {
OTEL_SERVICE_NAME: '<service-name>',
OTEL_TRACES_EXPORTER: 'otlp',
OTEL_METRICS_EXPORTER: 'none',
OTEL_LOGS_EXPORTER: 'none',
OTEL_EXPORTER_OTLP_ENDPOINT: 'https://ingest.<region>.signoz.cloud:443',
OTEL_EXPORTER_OTLP_HEADERS: 'signoz-ingestion-key=<your-ingestion-key>',
OTEL_NODE_RESOURCE_DETECTORS: 'env,host,os',
},
},
],
}Verify these values:
<app-name>: The PM2 name of your application.<service-name>: Name of your service in SigNoz (for example,payment-service).<region>: Your SigNoz Cloud region.<your-ingestion-key>: Your SigNoz ingestion key.
The none values for OTEL_METRICS_EXPORTER and OTEL_LOGS_EXPORTER keep this setup to traces. Without them, the SDK also exports metrics and logs.
Step 5. Start or restart the application
Run this command in the directory that contains ecosystem.config.js:
pm2 start ecosystem.config.jsIf the application already runs under PM2, restart it from the ecosystem file:
pm2 restart ecosystem.config.jspm2 restart <app-name> keeps the environment from the first start and does not load the new OTEL_* variables. To keep the new environment after a server reboot, run pm2 save.
Run pm2 ls. Each instance of your application shows the status online.
Step 6. Send metrics (optional)
The same tracing.js file can export metrics. In the env block of ecosystem.config.js, change OTEL_METRICS_EXPORTER to otlp, then run pm2 restart ecosystem.config.js:
OTEL_METRICS_EXPORTER: 'otlp',Auto-instrumentation then sends metrics such as http.server.request.duration. Each worker reports its own series with its own service.instance.id. For custom and runtime metrics, see Send metrics from Node.js.
Validate
- Send a few requests to your application.
- In SigNoz, open Services and look for
<service-name>. If it does not appear, click Refresh.

- In cluster mode, make sure that every worker sends spans. Open Traces, select the Table view, filter by
service.name = '<service-name>', and group byservice.instance.id. You see one row per PM2 instance, such as<hostname>-0and<hostname>-1.

Troubleshooting
Traces arrive in fork mode but not in cluster mode
In cluster mode, PM2 sets the env block after the Node.js process starts. NODE_OPTIONS and the node_args field in the ecosystem file load OpenTelemetry before PM2 sets the OTEL_* variables, or not at all.
- Remove
NODE_OPTIONSandnode_argsentries that load OpenTelemetry. - Load
tracing.jsfrom your code as shown in Step 3. - Run
pm2 restart ecosystem.config.js.
Spans from the last seconds before a restart are missing
The SDK sends spans in batches every 5 seconds by default. If the process exits before the next batch, you lose the spans in the buffer. The SIGINT handler in tracing.js sends the buffer before the process exits.
If your application has its own SIGINT or SIGTERM handler, tracing.js flushes the buffer but does not exit the process. Your handler finishes its cleanup and exits. PM2 sends SIGKILL if the process does not exit within 1.6 seconds. To give the flush more time, set kill_timeout in the ecosystem file.
Service name or endpoint changes do not apply
pm2 restart <app-name> keeps the environment from the first start. Run pm2 restart ecosystem.config.js instead, then send a few requests and look for the new values in SigNoz.
ERR_REQUIRE_ESM or no spans in an ES module app
require('./tracing') works only in CommonJS apps. If your package.json sets "type": "module", follow the ES module tabs in Step 2 and Step 3, and point the script field to start.js.
Service does not appear in SigNoz
- Run
pm2 env <id>and make sure that theOTEL_*variables show the values you set. Get the ID frompm2 ls. - Run
pm2 logs <app-name>and look for export errors. - Follow Debug missing traces, logs, and metrics in SigNoz for SDK diagnostics and common ingestion errors.
Setup OpenTelemetry Collector (Optional)
To send telemetry through an OpenTelemetry Collector that runs on the same host, change two variables in the env block of ecosystem.config.js:
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318',
// Remove OTEL_EXPORTER_OTLP_HEADERS. The Collector adds the ingestion key.Then run pm2 restart ecosystem.config.js. To install and configure the Collector, see Switch from direct export to Collector.
Next steps
- Send logs from your application with Winston or Pino.
- Turn off instrumentation for libraries you do not need with selective instrumentation.
- Add custom spans with manual instrumentation.
- Drop health-check spans with Exclude HTTP endpoints.