Advanced OpenTelemetry Setup with Watt
Introduction
Watt includes built-in telemetry support that can be configured declaratively in your watt.json or platformatic.json files. This works well for most use cases with OTLP and Zipkin exporters.
However, you may need manual OpenTelemetry SDK setup when you:
- Need custom instrumentations beyond what the built-in telemetry provides
- Want to configure custom span processors or exporters
- Need fine-grained control over OpenTelemetry SDK initialization
This guide covers how to set up the OpenTelemetry Node.js SDK manually in Watt.
Understanding Multi-Worker Architecture
Watt runs each application in isolated Node.js Worker Threads. This has important implications for OpenTelemetry setup:
- Each worker is isolated: Every worker thread runs its own OpenTelemetry SDK instance
- Initialization must happen early: OpenTelemetry must load before any instrumented modules
- Context propagation is automatic: Watt handles trace context propagation between workers via HTTP headers
The execArgv configuration with --import ensures your initialization script runs in each worker thread before application code loads.
Configuration Options
Watt provides the execArgv configuration on each application to pass Node.js flags to worker threads. This is required for OpenTelemetry because the instrumentation hooks must be registered via --import before any application code loads.
Application-Level Configuration
Use the execArgv option on each application to configure OpenTelemetry:
{
"$schema": "https://schemas.platformatic.dev/wattpm/3.0.0.json",
"applications": [
{
"id": "api",
"path": "./services/api",
"execArgv": [
"--import", "@opentelemetry/instrumentation/hook.mjs",
"--import", "./telemetry-init.mjs"
]
}
],
"server": {
"port": 3000
}
}
Multiple Applications
When you have multiple applications, each needs its own execArgv configuration:
{
"$schema": "https://schemas.platformatic.dev/wattpm/3.0.0.json",
"applications": [
{
"id": "api",
"path": "./services/api",
"execArgv": [
"--import", "@opentelemetry/instrumentation/hook.mjs",
"--import", "./telemetry-init.mjs"
]
},
{
"id": "worker",
"path": "./services/worker",
"execArgv": [
"--import", "@opentelemetry/instrumentation/hook.mjs",
"--import", "./telemetry-init.mjs"
]
}
]
}
Initialization Script
The initialization script configures the OpenTelemetry SDK and must be loaded before any application code. Here's a complete example:
// telemetry-init.mjs
import { workerData } from 'node:worker_threads'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { Resource } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
// Get service name from workerData (set by Platformatic)
const serviceName = workerData?.applicationConfig?.id || 'unknown-service'
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || '1.0.0'
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS
? JSON.parse(process.env.OTEL_EXPORTER_OTLP_HEADERS)
: {}
}),
instrumentations: [
getNodeAutoInstrumentations({
// Disable specific instrumentations if needed
'@opentelemetry/instrumentation-fs': { enabled: false }
})
]
})
sdk.start()
// Graceful shutdown to flush pending spans
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Telemetry terminated'))
.catch((error) => console.log('Error terminating telemetry', error))
.finally(() => process.exit(0))
})
The workerData object is automatically set by Watt for each worker thread and contains the application configuration. The applicationConfig.id property holds the service identifier as defined in your watt.json.
Why the Hook is Required
The module loading order is critical:
- Node.js processes
--importflags in order before the application starts - The OpenTelemetry hook registers loader hooks to intercept module imports
- Your initialization script configures and starts the SDK
- Application code loads (instrumentation is applied via the hook)
Without the hook, OpenTelemetry cannot intercept imports and instrumentation will not work.
Disabling Built-in Telemetry
When using manual SDK setup, you should disable Watt's built-in telemetry to avoid conflicts (duplicate spans, multiple exporters, etc.):
{
"applications": [
{
"id": "api",
"path": "./services/api",
"execArgv": [
"--import", "@opentelemetry/instrumentation/hook.mjs",
"--import", "./telemetry-init.mjs"
],
"telemetry": {
"enabled": false
}
}
]
}
Troubleshooting
Telemetry Not Appearing
- Check module loading order: Ensure OpenTelemetry loads before application code
- Verify exporter URL: Confirm the collector endpoint is accessible
- Check for errors: Look for initialization errors in logs
- Validate configuration: Ensure environment variables are set correctly
Module Loading Errors
Common issues:
- "Cannot use import statement outside a module": Ensure your initialization file has
.mjsextension - Module not found: Check the import path is correct and the package is installed
OpenTelemetry SDK Not Initializing
- Verify all required environment variables are set
- Ensure the module path in
execArgvis correct and the module exists - Check for initialization errors in the console output
- Verify the OTLP endpoint is accessible from the application
Complete Example with Jaeger
watt.json:
{
"$schema": "https://schemas.platformatic.dev/wattpm/3.0.0.json",
"entrypoint": "api",
"applications": [
{
"id": "api",
"path": "./services/api",
"execArgv": [
"--import", "@opentelemetry/instrumentation/hook.mjs",
"--import", "./telemetry.mjs"
]
}
],
"env": {
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318/v1/traces"
},
"server": {
"port": 3000
}
}
telemetry.mjs:
import { workerData } from 'node:worker_threads'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { Resource } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
// Get service name from workerData (set by Platformatic)
const serviceName = workerData?.applicationConfig?.id || 'unknown-service'
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: serviceName
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
}),
instrumentations: [getNodeAutoInstrumentations()]
})
sdk.start()
process.on('SIGTERM', () => sdk.shutdown())
Start Jaeger:
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
Issues
If you run into a bug or have a suggestion for improvement, please raise an issue on GitHub or join our Discord feedback channel.