Package
@logthread/node
Send your Node.js logs to Logthread. Zero runtime dependencies (OpenTelemetry is an optional peer, only for @logthread/node/otel), Node 18 or newer, ESM and CommonJS.
Install#
npm install https://downloads.logthread.com/sdk/node/<version>/logthread-node-<version>.tgzThe package is hosted by Logthread, not on npm yet. The exact command for the current version, its size and SHA-256 are on the download page. You still import it as @logthread/node.
Quick start#
export LOGTHREAD_ENDPOINT=https://ingest.example.com # shown in onboarding
export LOGTHREAD_INGEST_KEY=... # shown once, when you create the keyimport '@logthread/node/auto'; // or: node --import @logthread/node/auto app.js
console.log('hello'); // printed as usual, and forwardedOr explicitly:
import { init } from '@logthread/node';
const lt = init({ service: 'api' });
lt.info('order created', { orderId: 1 });With no key or no endpoint the SDK disables itself, prints one line to stderr and your app carries on.
Service name. Records are grouped by service.name. Set it with the service option, or with the LOGTHREAD_SERVICE environment variable, or with the standard OTEL_SERVICE_NAME (checked in that order). Without any of them the script name is used, so name your services explicitly.
Configuration#
Precedence: explicit options, then environment, then defaults.
| option | env | default | notes |
|---|---|---|---|
apiKey | LOGTHREAD_INGEST_KEY | none | sent as a bearer token; never logged |
endpoint | LOGTHREAD_ENDPOINT | none | base URL; /v1/logs is appended. HTTPS required except localhost, 127.0.0.1, ::1, *.localhost or allowInsecure |
allowInsecure | LOGTHREAD_ALLOW_INSECURE | false | allow plain http to other hosts |
service | LOGTHREAD_SERVICE, OTEL_SERVICE_NAME | script name, else unknown_service | service.name |
environment | LOGTHREAD_ENVIRONMENT | none | deployment.environment |
version | LOGTHREAD_SERVICE_VERSION | none | service.version |
resource | none | {} | extra resource attributes |
attributes | none | {} | added to every record |
minLevel | LOGTHREAD_MIN_LEVEL | info | trace debug info warn error fatal |
enabled | LOGTHREAD_DISABLED=true disables | true | kill switch; the environment variable always wins |
batchSize | LOGTHREAD_BATCH_SIZE | 512 | records per batch |
flushIntervalMs | LOGTHREAD_FLUSH_INTERVAL_MS | 1000 | |
maxQueueRecords | LOGTHREAD_MAX_QUEUE_RECORDS | 10000 | hard bound |
maxQueueBytes | LOGTHREAD_MAX_QUEUE_BYTES | 8 MiB | hard bound on queued JSON |
maxBatchBytes | LOGTHREAD_MAX_BATCH_BYTES | 1 MiB | batch cut size |
requestTimeoutMs | LOGTHREAD_REQUEST_TIMEOUT_MS | 10000 | per attempt |
maxRetries | LOGTHREAD_MAX_RETRIES | 6 | retries per batch, after the first attempt |
redactKeys | LOGTHREAD_REDACT_KEYS (comma separated) | password, passwd, secret, token, authorization, api_key, apikey, cookie, set-cookie | case-insensitive, whole key or dotted suffix; [] disables |
beforeSend | none | (event) => event | null | false | void: edit or drop a record, see "Masking values" | |
correlationHeaders | LOGTHREAD_CORRELATION_HEADERS (comma separated) | x-correlation-id, x-request-id | the first is echoed on responses |
echoCorrelationHeader | LOGTHREAD_ECHO_CORRELATION_HEADER | true | |
captureExceptions | false | opt in, see below | |
handleSignals | LOGTHREAD_HANDLE_SIGNALS | false (auto turns it on) | flush on SIGTERM / SIGINT, then exit as usual, see below |
shutdownTimeoutMs | LOGTHREAD_SHUTDOWN_TIMEOUT_MS | 5000 | upper bound for the flush on a signal |
proxy | HTTPS_PROXY, HTTP_PROXY, NO_PROXY | from the environment | proxy URL for log delivery; false ignores the environment, see "Proxy and TLS" |
caFile | LOGTHREAD_CA_FILE | none | PEM file of extra certificate authorities to trust |
onError | none | (info) => void diagnostics callback | |
debug | LOGTHREAD_DEBUG=true | false | verbose diagnostics on stderr |
Invalid values fall back to the default with one diagnostic; they never throw.
What it does for your app#
- A log call never blocks, throws or does I/O: it builds the record and appends it to a bounded queue.
- Memory is bounded by
maxQueueRecordsandmaxQueueBytes. When full, the new record is dropped and counted. - One batch is in flight at a time, sent in order, from an
unref'd timer, so the SDK never keeps your process alive. - Failures are swallowed and counted; they surface only through
onError,debugandstats(). - The SDK never forwards its own diagnostics, and console capture does not recurse.
shutdown({ timeoutMs })andflush({ timeoutMs })are bounded (default 5 s) and never throw. Logging after shutdown does nothing.- On a normal exit the SDK flushes what is left (up to 2 s). It cannot flush on
process.exit()or a hard kill: callawait lt.shutdown()first. autowrapsconsole.*(the original runs first) and installs nouncaughtException/unhandledRejectionhandlers.handleSignalsis opt in for the library (@logthread/node/autoturns it on): onSIGTERMorSIGINTthe SDK flushes for up toshutdownTimeoutMs, then re-raises the signal so the process still ends the way it would have (a second signal ends it at once). If your app has its own listener for the signal, the SDK only flushes and leaves the exit to you, so callawait lt.shutdown()there. A hard kill (SIGKILL) orprocess.exit()cannot be flushed.init({ captureExceptions: true })opts in: the error is logged atfatal, flushed for up to 2 s, the handler removes itself and the error is re-thrown so the crash and exit code stay as before. If your app has its ownuncaughtExceptionhandler, the error is logged and not re-thrown. Rejections are covered through Node's default behaviour of raising them as uncaught exceptions.
Calling init() again flushes and stops the previous client, so a hot reload does not leak a worker.
API: init, getLogger, shutdown, and on the logger log, trace debug info warn error fatal (an Error as the second argument or as attrs.err is expanded), child(attrs), flush, shutdown, stats().
Integrations:
import pino from 'pino';
import { logthreadStream } from '@logthread/node/pino';
const log = pino(logthreadStream()); // or pino.multistream([...])
import winston from 'winston';
import { LogthreadTransport } from '@logthread/node/winston';
const logger = winston.createLogger({ transports: [new LogthreadTransport()] });
import bunyan from 'bunyan';
import { logthreadBunyanStream } from '@logthread/node/bunyan';
const blog = bunyan.createLogger({ name: 'api', streams: [{ type: 'raw', stream: logthreadBunyanStream() }] });
import log4js from 'log4js'; // the appender module is loaded by log4js itself
log4js.configure({
appenders: { logthread: { type: '@logthread/node/log4js', service: 'api' } }, // same options as init()
categories: { default: { appenders: ['logthread'], level: 'info' } },
});bunyan levels 10 (trace) to 60 (fatal) map to the Logthread levels, the logger name becomes logger.name, and an err field becomes exception.*. log4js uses the level, the category name (logger.name), addContext values (attributes) and the first Error argument (exception.*). Both add the request correlation id and never throw into your app.
Masking values (beforeSend)#
redactKeys masks by attribute name. For values hiding in free text, pass beforeSend. It receives { level, message, attributes } after attributes are flattened and redactKeys has run, and before the record is truncated and queued. Return the event (or nothing, after editing it in place) to send it, null or false to drop it. The result goes through the same rules as any record: logthread.* names are dropped, redactKeys applies to keys you add, and the attribute and size limits hold. If the hook throws, the record is dropped (never sent unmasked) and onError is told. It runs on the calling code's turn, so keep it quick and synchronous.
init({
beforeSend(ev) {
ev.message = ev.message.replace(/\b\d{13,16}\b/g, '[card]');
if (ev.attributes.path === '/healthz') return null; // drop noise
},
});Proxy and TLS#
Log delivery honours HTTPS_PROXY / HTTP_PROXY (either case) and NO_PROXY (host, .suffix, host:port, *). Loopback addresses are never proxied from the environment. Pass proxy: 'http://user:pass@proxy:3128' to set one in code, or proxy: false to ignore the environment. HTTPS goes through a CONNECT tunnel; the proxy never sees the key or the logs. Annotations use the same rules.
To trust a private CA, or the CA of a TLS-inspecting proxy, set caFile (or LOGTHREAD_CA_FILE) to a PEM file; the built-in roots stay trusted. NODE_EXTRA_CA_CERTS also works. Without a proxy or caFile the SDK uses Node's global fetch; with either it uses node:https, still with zero dependencies. Redirects are never followed.
NestJS#
import { LogthreadNestLogger } from '@logthread/node/nest';
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(new LogthreadNestLogger()); // uses init() / the environment; or { client: lt, context: 'X' }Nest's context string becomes the logger.name attribute and a stack passed to error() becomes exception.stacktrace. There is no @nestjs/* dependency. For Express or Fastify under Nest, app.use(correlation()) as below. Serilog is a .NET library; the .NET SDK plugs into Microsoft.Extensions.Logging, and a Serilog sink is not provided.
Delivery and retries#
| response | action |
|---|---|
| 200 | done |
| 400, 415, 422, other 4xx (except 408 and 425) | dropped, counted dropped_rejected, one diagnostic with the server message |
| 401, 403 | stop sending for 5 minutes (records keep queueing up to the bound), then try one batch |
| 413 | split in two and send each half; a single too-large record is dropped |
| 429 | wait Retry-After (at most 60 s) |
| 408, 425, 500, 502, 503, 504, network error, timeout | exponential backoff with full jitter (base 500 ms, cap 30 s); Retry-After honoured on 503 |
After maxRetries retries the batch is dropped (dropped_retries_exhausted) and delivery moves on. Delivery is at-least-once; each record has a stable event_id. Redirects are not followed.
Correlating with traces#
If @opentelemetry/api is installed (optional), the active span's trace_id and span_id are attached to each record. An explicit traceId / spanId (or trace_id / span_id) attribute of 32 / 16 hex characters overrides it.
Traces and metrics (@logthread/node/otel)#
One call points the OpenTelemetry Node SDK at Logthread: traces and metrics go to the same endpoint, with the same ingest key, as your logs (OTLP/HTTP protobuf, gzip, Authorization: Bearer). The logging SDK itself stays dependency-free; the OpenTelemetry packages are optional peers you install only for this entry point.
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/resources \
@opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-metrics-otlp-proto \
@opentelemetry/auto-instrumentations-node # last one: http, express, fastify, pg, mysql2, mongodb, redis, ...// first lines of the entry file, before other imports load http, express, pg, ...
import { startTelemetry } from '@logthread/node/otel';
const telemetry = startTelemetry({ serviceName: 'api', environment: 'production' });
// ... on shutdown:
await telemetry.shutdown(); // flushes queued spans and metricsOr with no code change, from the environment only (LOGTHREAD_ENDPOINT, LOGTHREAD_INGEST_KEY, LOGTHREAD_SERVICE, ...):
node --require @logthread/node/otel/register app.js # CommonJS
node --import @logthread/node/otel/register app.mjs # ESM (auto-instrumentation of ES modules also needs OpenTelemetry's loader)Trace and log correlation needs nothing more: a log written inside a span carries its trace_id and span_id (see above), and the span carries the same ids.
| Option | Env | Default | Meaning |
|---|---|---|---|
ingestKey | LOGTHREAD_INGEST_KEY | none | Ingest key, sent as a bearer token and never logged. |
endpoint | LOGTHREAD_ENDPOINT | none | Base URL; /v1/traces and /v1/metrics are appended. HTTPS except loopback. |
allowInsecure | LOGTHREAD_ALLOW_INSECURE | false | Allow plain http to a non-local host. |
serviceName | LOGTHREAD_SERVICE, OTEL_SERVICE_NAME | script name | service.name. |
serviceVersion | LOGTHREAD_SERVICE_VERSION | none | service.version. |
environment | LOGTHREAD_ENVIRONMENT | none | deployment.environment. |
traces | LOGTHREAD_TRACING | true | Export traces. |
metrics | LOGTHREAD_METRICS | true | Export metrics (delta temporality). |
sampleRatio | LOGTHREAD_TRACE_SAMPLING_RATIO | 1 | Parent-based ratio sampling, 0 to 1: a sampled parent keeps its children. |
metricExportIntervalMs | LOGTHREAD_EXPORT_INTERVAL_MS | 60000 | Metric export interval. |
maxAttributeValueLength | LOGTHREAD_MAX_ATTRIBUTE_VALUE_LENGTH | 256 | Longest span attribute and metric attribute value kept. |
cardinalityLimit | LOGTHREAD_CARDINALITY_LIMIT | 500 | Distinct series kept per metric; the rest fold into one otel.metric.overflow series. 0 turns it off. |
dropMetricAttributes | none | none | Extra attribute names removed from every metric point. |
autoInstrument | none | true | Load @opentelemetry/auto-instrumentations-node when installed (the noisy fs instrumentation is off). |
instrumentations | none | none | Extra OpenTelemetry instrumentations. |
resourceAttributes | none | none | Extra resource attributes. |
proxy, caFile | HTTPS_PROXY / NO_PROXY, LOGTHREAD_CA_FILE | none | Same as the logging SDK. The proxy applies to https endpoints (a CONNECT tunnel). |
Cardinality guard. Every metric point loses per-request attributes (url.full, url.path, url.query, http.url, http.target, user_agent.original, user.id, enduser.id, session.id, request.id, client.address, peer addresses and ports, trace_id, span_id) and has long values cut, and each metric is bounded to cardinalityLimit series. A user id or a raw path can therefore never turn into a time series; use route templates (http.route) for endpoints. The .NET distro uses an allow-list on the meters it wires; here the guard covers every instrument, including your own, because auto-instrumentation attribute sets vary by library.
Nothing is sent, and one line goes to stderr, when the key or endpoint is missing; if an OpenTelemetry package is missing, startTelemetry() throws a message that names the npm install command. Call it once per process. It registers the global OpenTelemetry providers, so do not start another OpenTelemetry SDK next to it. Logs keep going through init() / @logthread/node/auto.
Supported OpenTelemetry versions. The 2.x family (sdk-trace-base, sdk-metrics, resources 2.x with @opentelemetry/sdk-node and the OTLP exporters 0.200 to 0.223, api 1.9) and the 3.x family (sdk-trace, sdk-metrics, resources 3.x with sdk-node and the exporters 0.300, api 1.10). The peer dependency ranges say exactly that; OpenTelemetry JS 1.x is not supported (its metric views cannot drop attributes, so the cardinality guard would not hold). @opentelemetry/auto-instrumentations-node 0.60 to 0.82 was checked with the matching family. Install the families together: sdk-node 0.2xx needs api below 1.10, sdk-node 0.300 needs 1.10 or later.
To reproduce the compatibility matrix (needs network access to the npm registry; it is not part of npm test):
npm run test:otel-matrix # oldest 2.x, latest 2.x, 3.x, plus nested (pnpm-style) layouts
OTEL_MATRIX_ONLY=2-oldest npm run test:otel-matrix # one columnFor each column it packs this SDK, installs it with that OpenTelemetry family into a scratch project (OTEL_MATRIX_DIR, default a temp directory), and runs startTelemetry from ESM and from CommonJS against an in-process OTLP receiver, asserting the bearer key, gzip, resource attributes, parent-based sampling, the cardinality guard and attribute truncation.
Correlation ids#
Every log written while handling one HTTP request gets the same correlation_id attribute. The id comes from the first valid header in correlationHeaders (1 to 128 characters of A-Za-z0-9._:-), else the trace id of a valid W3C traceparent header, else a generated UUID, and is echoed on the response as X-Correlation-Id unless the app already set it. Records outside a request context get none; an explicit correlation_id attribute wins. auto does not install middleware; add one line to your app:
// Express / Connect
import { correlation } from '@logthread/node';
app.use(correlation());
// Fastify
import { fastifyCorrelation } from '@logthread/node';
await app.register(fastifyCorrelation);
// Koa
import { koaCorrelation } from '@logthread/node';
app.use(koaCorrelation());
// plain http
import { withCorrelation } from '@logthread/node';
http.createServer(withCorrelation((req, res) => { /* ... */ }));
// queue consumers and jobs (generates an id when none is given)
import { withCorrelationId, getCorrelationId } from '@logthread/node';
await withCorrelationId(message.id, async () => { lt.info('processing'); });
// outgoing calls (opt in): global fetch, or headers for axios / http.request
import { instrumentFetch, correlationHeaders } from '@logthread/node';
instrumentFetch();
axios.get(url, { headers: correlationHeaders() });The same functions are available from @logthread/node/correlation.
Mark a deploy or event (annotations)#
Draw a marker on your charts when something happens, such as a deploy. This needs an API key with the "Create annotations" permission (Keys → API keys), not an ingest key. Set it as LOGTHREAD_API_KEY; LOGTHREAD_API_URL defaults to https://logthread.com. time defaults to now and must be within the last 30 days; times are sent as UTC ISO 8601 with a Z. Errors carry the standard code and message (forbidden for a key without the permission, invalid_request for a bad body).
import { createAnnotation } from '@logthread/node';
const a = await createAnnotation({
title: 'deploy 1.4.2',
text: 'rolled out to production', // optional
service: 'api', // optional
tags: ['deploy'], // optional
// time / end: Date, epoch ms or ISO string. apiKey / endpoint override the environment.
});
// a.id, a.link, ... ; failures throw LogthreadApiError with .code, .message and .status
// (code `timeout` or `network_error` when no reply came). Also: timeoutMs (default 10000), proxy, caFile.The Node SDK ships no command line tool, so CI uses the one-liner or curl below.
In CI, a GitHub Actions step after your deploy:
- name: Mark deploy in Logthread
env:
LOGTHREAD_API_KEY: ${{ secrets.LOGTHREAD_API_KEY }}
run: |
node -e "import('@logthread/node').then(m => m.createAnnotation({title: 'deploy ' + process.env.GITHUB_SHA.slice(0, 7), service: 'api', tags: ['deploy']}))"The same call without the SDK:
curl -fsS -X POST https://logthread.com/api/annotations \
-H "Authorization: Bearer $LOGTHREAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"deploy '"$GITHUB_SHA"'","service":"api","tags":["deploy"]}'Troubleshooting#
- Set
debug: true(orLOGTHREAD_DEBUG=true) for diagnostics on stderr, or passonError. lt.stats()returnsqueued,sent_records,sent_batches,dropped_queue_full,dropped_rejected,dropped_retries_exhausted,dropped_shutdown,retries,last_error,disabled_untilandenabled.- Nothing arrives and
enabledis false: the key or endpoint is missing or the endpoint is plain http to a non-local host. disabled_untilset: the gateway rejected the key (401/403); sending resumes after the cool-off.
Limits#
init() sends logs only. Traces and metrics go through startTelemetry() from @logthread/node/otel (above), or through any standard OpenTelemetry setup pointed at the same Logthread endpoint and ingest key: OTEL_EXPORTER_OTLP_ENDPOINT=$LOGTHREAD_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20$LOGTHREAD_INGEST_KEY. Either way this SDK attaches the active trace and span ids to log records so logs and traces link up.
Report a change#
Report a deploy, config change, flag flip or migration so an incident can list what changed before it. It uses the same API key as annotations; type is deploy, config, flag, migration, infra or other, and a retry with the same id updates the change. See What changed before an incident.
const change = await createChange({ type: 'deploy', title: 'checkout 2.4', service: 'checkout', version: '2.4', id: process.env.GITHUB_RUN_ID });