Traces, metrics and logs from Node.js apps to ClickStack, built on the upstream OpenTelemetry
Node.js SDK. One flag or one init call starts the SDK with the upstream auto-instrumentations,
records console output and crashes, and flushes telemetry on exit, termination signals and AWS
Lambda invocations. Exporters, batching, sampling and every OTEL_* variable stay upstream. Apps
that set up the SDK themselves do not need this package
(Using the upstream SDK directly).
If you're migrating from @hyperdx/node-opentelemetry please follow the
migration guide.
npm install @clickhouse/clickstack-nodeThe package needs Node.js 22.15 or later and ships ES module and CommonJS builds.
@opentelemetry/api is a peer dependency, which npm installs automatically, so the app and the
SDK share one copy. If the app imports it too, install @opentelemetry/api@~1.9.1: npm's latest
1.10 is for OpenTelemetry JS 3.x and outside this package's range.
export OTEL_EXPORTER_OTLP_ENDPOINT=https://example-collector.clickhouse.com:4318
export OTEL_EXPORTER_OTLP_HEADERS="authorization=<YOUR_INGESTION_API_KEY>"
export OTEL_SERVICE_NAME=example-app
node --import @clickhouse/clickstack-node/register server.jsThe endpoint is the OTLP HTTP address of your OpenTelemetry collector (port 4318), without
/v1/traces. With ClickStack, it is your service's collector URL.
The flag works for CommonJS and ES module apps, and --require works too. In NODE_OPTIONS, set
it only on the command that runs the app, because build tools and every other Node.js process
inherit it.
Or call init in code, at the top of a CommonJS entry file before anything else is required:
const { init } = require("@clickhouse/clickstack-node");
init({
endpoint: "https://example-collector.clickhouse.com:4318",
apiKey: "<YOUR_INGESTION_API_KEY>",
serviceName: "example-app",
});Static imports run before any code in an ES module, so an ES module app calls init in a file of
its own and preloads it: node --import ./instrumentation.mjs server.mjs. A preload file also runs
in every worker thread, such as the workers of pino transports, and each init there starts an
SDK. Guard the call with isMainThread from node:worker_threads, as the register entry does.
| Option | Environment variable (wins) | Default | Meaning |
|---|---|---|---|
endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4318 |
OTLP/HTTP base URL of the collector. /v1/traces, /v1/metrics and /v1/logs are appended. |
serviceName |
OTEL_SERVICE_NAME, or service.name in OTEL_RESOURCE_ATTRIBUTES |
unknown_service: and the executable name |
service.name of all telemetry. |
environment |
OTEL_RESOURCE_ATTRIBUTES (deployment.environment.name=<env>) |
None | deployment.environment.name, such as production or staging. |
apiKey |
OTEL_EXPORTER_OTLP_HEADERS (authorization=<key>) |
None | Ingestion key, sent as the authorization header. Send the bare key; Bearer <key> works only on ClickStack 2.38.0 or later. |
httpBodyCapture |
CLICKSTACK_CAPTURE_HTTP_BODY |
false |
Records request and response bodies (Privacy). |
instrumentations |
None | Upstream defaults | The getNodeAutoInstrumentations() config map. An enabled value here beats OTEL_NODE_ENABLED_INSTRUMENTATIONS and OTEL_NODE_DISABLED_INSTRUMENTATIONS. |
additionalInstrumentations |
None | [] |
Instrumentation instances to add to the bundle's, for libraries the bundle does not cover. |
init writes serviceName, endpoint, apiKey and httpBodyCapture to their variables only
when those are unset or blank, so an operator's value wins and child processes inherit it;
environment goes on the resource only. When OTEL_EXPORTER_OTLP_HEADERS already holds other
headers, apiKey is ignored with a warning. Use either the flag or init: a second
start logs a warning and does nothing. The rest is fixed:
- Instrumentations: the upstream auto-instrumentations bundle with its defaults, plus
consolecapture (@opentelemetry/instrumentation-console) and crash capture. To turnconsolecapture off, addconsoletoOTEL_NODE_DISABLED_INSTRUMENTATIONS. It is also off whileOTEL_TRACES_EXPORTER,OTEL_METRICS_EXPORTERorOTEL_LOGS_EXPORTERincludesconsole, because those exporters print withconsole. - The router instrumentation is off. Express 5 is built on the
routerpackage, so with it every Express middleware gets a second span and every route error three exception events. To turn it on, listrouterinOTEL_NODE_ENABLED_INSTRUMENTATIONS, or setenabled: truefor@opentelemetry/instrumentation-routerininstrumentations. - Resource detectors:
container,host,os,process,serviceinstance,env, so nothing probes cloud metadata endpoints at startup. To add a cloud detector, setOTEL_NODE_RESOURCE_DETECTORSto the whole list, without spaces and withenvlast, for examplecontainer,host,os,process,serviceinstance,aws,env. - Diagnostics go to stderr at WARN level (
OTEL_LOG_LEVEL), unless the app registered its owndiaglogger first. They include unknown names inOTEL_NODE_ENABLED_INSTRUMENTATIONS,OTEL_NODE_DISABLED_INSTRUMENTATIONSandOTEL_NODE_RESOURCE_DETECTORS. Messages from this package start withclickstack-node:. - Every other OpenTelemetry variable keeps its upstream meaning, for example
OTEL_EXPORTER_OTLP_PROTOCOL(http/protobuf;grpcgoes to port 4317),OTEL_TRACES_SAMPLER,OTEL_PROPAGATORSandOTEL_METRIC_EXPORT_INTERVAL. Boolean variables are true only fortrue.OTEL_SDK_DISABLED=truemakes the flag andinitdo nothing.
Other functions:
flush(timeoutMillis)exports what is buffered. It waits at most 5000 ms by default and never rejects.shutdown(timeoutMillis)flushes and stops the SDK, with the same bound. Await it before your own code exits the process (Lifecycle).withFlush(handler)wraps an async AWS Lambda handler, so telemetry is exported before each invocation returns.
console output and crash stack traces are exported by default. Headers and bodies are recorded
only when you turn them on; they can hold personal data and credentials.
- Headers:
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_{SERVER,CLIENT}_{REQUEST,RESPONSE}each list exact header names, comma-separated, such asuser-agent,x-request-id. They are recorded ashttp.request.header.<name>andhttp.response.header.<name>. Names onOTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS(by defaultauthorization,cookie,set-cookie,proxy-authorization,x-api-key) are never recorded. - Bodies:
httpBodyCapturerecordsnode:httpserver and client bodies andfetchbodies (on Node.js 24.4 or later). Only textual UTF-8 bodies without aContent-Encodingare recorded, from the first 8192 bytes of each body; others get a size only. Values of keys that containpassword,passwd,secret,token,api_key,apikey,api-key,authorization,cookieorsessionbecome[REDACTED]in JSON-looking text (also one level of JSON escaped inside a string) and in form fields. This is a best effort, so keep a redaction step in your collector. - The app's streams are never read, paused or resumed, and the bytes it sends and receives are unchanged.
This file sets up the upstream SDK with the same instrumentations and defaults. Start the app with
node --require ./telemetry.cjs app.js and the variables from the quick start, plus
OTEL_NODE_RESOURCE_DETECTORS=container,host,os,process,serviceinstance,env.
// telemetry.cjs
const {
getNodeAutoInstrumentations,
getResourceDetectors,
} = require("@opentelemetry/auto-instrumentations-node");
const {
ConsoleInstrumentation,
} = require("@opentelemetry/instrumentation-console");
const { NodeSDK } = require("@opentelemetry/sdk-node");
// Without a log level, NodeSDK prints nothing, not even export errors.
process.env.OTEL_LOG_LEVEL ??= "warn";
const sdk = new NodeSDK({
// Reads OTEL_NODE_RESOURCE_DETECTORS; the default list includes the cloud detectors.
resourceDetectors: getResourceDetectors(),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-runtime-node": {
captureUncaughtException: true,
},
// Express 5 is already covered by the express instrumentation.
"@opentelemetry/instrumentation-router": { enabled: false },
}),
new ConsoleInstrumentation(),
],
});
sdk.start();
process.once("SIGTERM", () => {
sdk.shutdown().finally(() => process.kill(process.pid, "SIGTERM"));
});What the package adds to this:
- ES modules: upstream registers no ES module loader hook on the 2.x line. Add
--experimental-loader=@opentelemetry/instrumentation/hook.mjs, which prints anExperimentalWarningon every start. - Crashes: the record of an uncaught exception is exported before Node.js exits, and the process still exits with code 1. Upstream drops it.
- Signals: SIGINT as well as SIGTERM, a bounded shutdown, and the exit status when the app runs as PID 1, where a re-raised signal is ignored.
- AWS Lambda:
withFlush, and the flush after each invocation by@opentelemetry/instrumentation-aws-lambda, whichNodeSDKleaves bound to a no-op provider. It now also flushes logs, and ends before the invocation's deadline. - Header capture from the variables above, and body capture. Upstream reads no header variables and has no body option.
- Resource:
service.name,deployment.environment.namewhen set,telemetry.sdk.*,telemetry.distro.name(clickstack-node),telemetry.distro.version, and the detector attributes (host.*,os.*,process.*,container.id,service.instance.id). - Spans, metrics and log records from the upstream instrumentations, unchanged.
consolelog records: the formatted message as the body, a severity number per method (debug5,logandinfo9,warn13,error17) and the active trace context.- A FATAL log record (severity number 21, body
exception) withexception.type,exception.messageandexception.stacktracefor an uncaught exception (Errors). - With capture on:
http.request.header.<name>andhttp.response.header.<name>(string arrays),http.request.body.contentandhttp.response.body.content, andhttp.request.body.sizeandhttp.response.body.size(bytes). The body keys are in development in the semantic conventions.
To find a request by user or tenant, set the attributes on its server span. The framework
instrumentations make each middleware and handler span the active span, so
trace.getActiveSpan() in middleware returns that short span, not the request's:
import { context } from "@opentelemetry/api";
import { getRPCMetadata } from "@opentelemetry/core"; // @opentelemetry/core@^2.12.0
app.use((req, res, next) => {
getRPCMetadata(context.active())?.span?.setAttributes({
"user.id": req.user.id,
});
next();
});Child spans and log records do not get these attributes. Tagging every span and log record of a
request takes span and log record processors, which need plain NodeSDK
(migration guide).
-
Uncaught exceptions, and unhandled rejections under Node's default
--unhandled-rejections=throw, become the FATAL log record, exported before the process exits (Lifecycle). It carries the active trace when the error comes from an asynchronous callback, such as a timer or a rejected promise. A request still open at the crash exports no span. -
exception.stacktraceis the raw stack, with the error'scausechain, up to five deep, appended asCaused by: Name: messagesections. A thrown value that is not an error, such asthrow { code: 1 }, is recorded as JSON text inexception.message; Node.js itself wraps a rejection reason that is not an error in anERR_UNHANDLED_REJECTIONerror. -
Start TypeScript and bundled apps with
--enable-source-maps, for example inNODE_OPTIONS, so that stack traces point to your sources when the error happens. ClickStack maps only browser stack traces. -
An app
uncaughtExceptionlistener takes over the exit: the FATAL record is still recorded, also when the app keeps running, but the package neither prints the error nor exits, so flush before your listener ends the process:import { flush } from "@clickhouse/clickstack-node"; process.on("uncaughtException", (err) => { console.error(err); void flush().finally(() => process.exit(1)); });
An app
unhandledRejectionlistener turns crash capture off for rejections, because Node.js then no longer treats them as crashes. -
Errors thrown in route handlers of instrumented frameworks, such as Express, Koa, Hapi and NestJS, or passed to
next(err), are recorded asexceptionevents on the handler's span. Recording them again yourself records each one twice. Express instruments only the last handler of ause()call with several, such asrouter.use("/api", auth, apiRouter), so an error thatauthpasses tonext(err)is not recorded; a 5xx response still marks the server span as an error. -
Errors that your code catches are not recorded. Record them on the active span:
import { SpanStatusCode, trace } from "@opentelemetry/api"; const span = trace.getActiveSpan(); span?.recordException(err); span?.setStatus({ code: SpanStatusCode.ERROR });
-
console.error(err)sends a log record whose body is the formatted error with its stack trace. It has noexception.*attributes, so it does not count as an exception. -
Telemetry still buffered when the app calls
process.exit()is lost unless the app awaitsshutdown()first.
-
SIGTERM and SIGINT: the SDK shuts down (at most 5 seconds) and the signal is raised again, so the process exits with 143 or 130. If the app has its own listener for the signal, the SDK only flushes and the app owns the exit; await
shutdown()in that listener before it exits. -
Uncaught exceptions, and unhandled rejections under the default
--unhandled-rejections=throw: the error goes to stderr as Node.js prints it, the SDK shuts down, and the process exits with code 1, after 2 seconds when@sentry/nodeis installed so that Sentry can send its event. An appuncaughtExceptionlistener takes over the exit (Errors). -
When the event loop empties, the SDK shuts down.
process.exit()skips this, so scripts callawait shutdown()first. -
When the collector cannot be reached, these shutdowns wait the full 5 seconds. A second Ctrl-C, more than a second after the first, exits at once. Set
OTEL_SDK_DISABLED=truewhere no collector runs. -
In Docker, use the exec form of
CMD(a JSON array), ordocker run --init. In the shell form,/bin/shis PID 1 and does not forward signals, sodocker stopkills the app after 10 seconds without a flush. -
On AWS Lambda, set
NODE_OPTIONS=--import @clickhouse/clickstack-node/registerand wrap the handler. Only async handlers are supported. Flushes end 500 ms before the invocation's deadline, so an unreachable collector does not make invocations time out.import { withFlush } from "@clickhouse/clickstack-node"; export const handler = withFlush(async (event) => { console.log("order received", event.id); return { statusCode: 200 }; });
-
The flag does nothing in worker threads.
initin a worker starts an SDK for that worker only.
Call init from register() in instrumentation.ts, in the Node.js runtime only, and keep the
package out of the server bundle:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { init } = await import("@clickhouse/clickstack-node");
init();
}
}// next.config.mjs
export default {
serverExternalPackages: ["@clickhouse/clickstack-node"],
};-
Each request has two server spans: the HTTP instrumentation's
GET, which continues the incoming trace, and Next.js's ownGET /routeunder it. -
Requests for static files can be half of the HTTP instrumentation's server spans. Ignoring them drops every span of those requests; with
basePath, put it in front of the path:init({ instrumentations: { "@opentelemetry/instrumentation-http": { ignoreIncomingRequestHook: (req) => req.url?.startsWith("/_next/static/") === true, }, }, });
-
Next.js sets
http.routeon the HTTP instrumentation's server span after the response may have ended, which logsCannot execute the operation on ended Spanat the default log level.OTEL_LOG_LEVEL=errorhides these lines; export errors still print. -
On SIGTERM or SIGINT,
next startand the standaloneserver.jsexit as soon as the server closes, before the SDK's flush ends, so the last spans, logs and metrics are lost. WithNEXT_MANUAL_SIG_HANDLE=truein the server's environment, Next.js leaves signals to the SDK, which flushes and exits, and no longer waits for requests in flight. -
A warning such as
Error: 'import-in-the-middle' failed to wrap 'file:///app/node_modules/zod/index.js'means that the ES module loader hook could not wrap that module. It loads unchanged, and only an instrumentation of that package would miss it.--trace-warningsshows the cause;NODE_OPTIONS=--disable-warning=Errorhides these lines, along with any other warning issued as a plain error.
- Node.js 22.15 or later. CI runs the latest 22, 24 and 26. Windows is not tested.
- OpenTelemetry JS 2.x:
@opentelemetry/sdk-node^0.223.0,@opentelemetry/core^2.12.0and@opentelemetry/auto-instrumentations-node^0.81.0, with@opentelemetry/api>=1.9.1 <1.10.0. - ES modules: the flag uses synchronous loader hooks on Node.js 22.22.3 or later, 24.11.1 or later
and 26, and
module.register()on earlier versions and whererequire()of ES modules is off, such as AWS Lambda. Withmodule.register(), a named import fromnode:httpafter the import of a package that loadsnode:httpitself stays uninstrumented (importnode:httpfirst), and an ES module entry file needs a file extension. - Default imports of CommonJS packages: the default export does not see functions that an
instrumentation replaces on the package's exports. With
import grpc from "@grpc/grpc-js", gRPC client calls get no spans; useimport * as grpc from "@grpc/grpc-js"or named imports. - TypeScript: CommonJS TypeScript fails under any ES module loader hook on Node.js 22 and on 24.0
to 24.11, so use
--requirethere. With ts-node, or any transpiler that hooksrequire, always use--require: any--import, also inNODE_OPTIONS, makes Node.js load the.tsentry through the ES module loader, past the transpiler. Both flags install the ES module loader hook. - Bundlers: code that a bundler inlined cannot be instrumented, and nothing warns. Keep this
package and the instrumented libraries external (esbuild
--packages=external, webpackexternals, Next.jsserverExternalPackages); for Next.js, see Next.js. - ClickStack: any version that accepts the ingestion key as a bare
authorizationheader.