Early access is open. Free plan during early access. Start free
Logthread

Package

@logthread/node

@logthread/nodev0.2.1Download logthread-node-0.2.1.tgz51.5 KBSHA-256 fa8c1c712c91…

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>.tgz

The 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 key
import '@logthread/node/auto';          // or: node --import @logthread/node/auto app.js
console.log('hello');                   // printed as usual, and forwarded

Or 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.

optionenvdefaultnotes
apiKeyLOGTHREAD_INGEST_KEYnonesent as a bearer token; never logged
endpointLOGTHREAD_ENDPOINTnonebase URL; /v1/logs is appended. HTTPS required except localhost, 127.0.0.1, ::1, *.localhost or allowInsecure
allowInsecureLOGTHREAD_ALLOW_INSECUREfalseallow plain http to other hosts
serviceLOGTHREAD_SERVICE, OTEL_SERVICE_NAMEscript name, else unknown_serviceservice.name
environmentLOGTHREAD_ENVIRONMENTnonedeployment.environment
versionLOGTHREAD_SERVICE_VERSIONnoneservice.version
resourcenone{}extra resource attributes
attributesnone{}added to every record
minLevelLOGTHREAD_MIN_LEVELinfotrace debug info warn error fatal
enabledLOGTHREAD_DISABLED=true disablestruekill switch; the environment variable always wins
batchSizeLOGTHREAD_BATCH_SIZE512records per batch
flushIntervalMsLOGTHREAD_FLUSH_INTERVAL_MS1000
maxQueueRecordsLOGTHREAD_MAX_QUEUE_RECORDS10000hard bound
maxQueueBytesLOGTHREAD_MAX_QUEUE_BYTES8 MiBhard bound on queued JSON
maxBatchBytesLOGTHREAD_MAX_BATCH_BYTES1 MiBbatch cut size
requestTimeoutMsLOGTHREAD_REQUEST_TIMEOUT_MS10000per attempt
maxRetriesLOGTHREAD_MAX_RETRIES6retries per batch, after the first attempt
redactKeysLOGTHREAD_REDACT_KEYS (comma separated)password, passwd, secret, token, authorization, api_key, apikey, cookie, set-cookiecase-insensitive, whole key or dotted suffix; [] disables
beforeSendnone(event) => event | null | false | void: edit or drop a record, see "Masking values"
correlationHeadersLOGTHREAD_CORRELATION_HEADERS (comma separated)x-correlation-id, x-request-idthe first is echoed on responses
echoCorrelationHeaderLOGTHREAD_ECHO_CORRELATION_HEADERtrue
captureExceptionsfalseopt in, see below
handleSignalsLOGTHREAD_HANDLE_SIGNALSfalse (auto turns it on)flush on SIGTERM / SIGINT, then exit as usual, see below
shutdownTimeoutMsLOGTHREAD_SHUTDOWN_TIMEOUT_MS5000upper bound for the flush on a signal
proxyHTTPS_PROXY, HTTP_PROXY, NO_PROXYfrom the environmentproxy URL for log delivery; false ignores the environment, see "Proxy and TLS"
caFileLOGTHREAD_CA_FILEnonePEM file of extra certificate authorities to trust
onErrornone(info) => void diagnostics callback
debugLOGTHREAD_DEBUG=truefalseverbose 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 maxQueueRecords and maxQueueBytes. 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, debug and stats().
  • The SDK never forwards its own diagnostics, and console capture does not recurse.
  • shutdown({ timeoutMs }) and flush({ 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: call await lt.shutdown() first.
  • auto wraps console.* (the original runs first) and installs no uncaughtException / unhandledRejection handlers.
  • handleSignals is opt in for the library (@logthread/node/auto turns it on): on SIGTERM or SIGINT the SDK flushes for up to shutdownTimeoutMs, 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 call await lt.shutdown() there. A hard kill (SIGKILL) or process.exit() cannot be flushed.
  • init({ captureExceptions: true }) opts in: the error is logged at fatal, 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 own uncaughtException handler, 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#

responseaction
200done
400, 415, 422, other 4xx (except 408 and 425)dropped, counted dropped_rejected, one diagnostic with the server message
401, 403stop sending for 5 minutes (records keep queueing up to the bound), then try one batch
413split in two and send each half; a single too-large record is dropped
429wait Retry-After (at most 60 s)
408, 425, 500, 502, 503, 504, network error, timeoutexponential 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 metrics

Or 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.

OptionEnvDefaultMeaning
ingestKeyLOGTHREAD_INGEST_KEYnoneIngest key, sent as a bearer token and never logged.
endpointLOGTHREAD_ENDPOINTnoneBase URL; /v1/traces and /v1/metrics are appended. HTTPS except loopback.
allowInsecureLOGTHREAD_ALLOW_INSECUREfalseAllow plain http to a non-local host.
serviceNameLOGTHREAD_SERVICE, OTEL_SERVICE_NAMEscript nameservice.name.
serviceVersionLOGTHREAD_SERVICE_VERSIONnoneservice.version.
environmentLOGTHREAD_ENVIRONMENTnonedeployment.environment.
tracesLOGTHREAD_TRACINGtrueExport traces.
metricsLOGTHREAD_METRICStrueExport metrics (delta temporality).
sampleRatioLOGTHREAD_TRACE_SAMPLING_RATIO1Parent-based ratio sampling, 0 to 1: a sampled parent keeps its children.
metricExportIntervalMsLOGTHREAD_EXPORT_INTERVAL_MS60000Metric export interval.
maxAttributeValueLengthLOGTHREAD_MAX_ATTRIBUTE_VALUE_LENGTH256Longest span attribute and metric attribute value kept.
cardinalityLimitLOGTHREAD_CARDINALITY_LIMIT500Distinct series kept per metric; the rest fold into one otel.metric.overflow series. 0 turns it off.
dropMetricAttributesnonenoneExtra attribute names removed from every metric point.
autoInstrumentnonetrueLoad @opentelemetry/auto-instrumentations-node when installed (the noisy fs instrumentation is off).
instrumentationsnonenoneExtra OpenTelemetry instrumentations.
resourceAttributesnonenoneExtra resource attributes.
proxy, caFileHTTPS_PROXY / NO_PROXY, LOGTHREAD_CA_FILEnoneSame 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 column

For 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 (or LOGTHREAD_DEBUG=true) for diagnostics on stderr, or pass onError.
  • lt.stats() returns queued, sent_records, sent_batches, dropped_queue_full, dropped_rejected, dropped_retries_exhausted, dropped_shutdown, retries, last_error, disabled_until and enabled.
  • Nothing arrives and enabled is false: the key or endpoint is missing or the endpoint is plain http to a non-local host.
  • disabled_until set: 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 });