@app-logs-ai/node-logger
v0.4.0
Published
Backend log collector for AI Application Logs — batches and ships logs to the ingest API.
Readme
@app-logs-ai/node-logger
Backend log collector for AI Application Logs. Batches log entries and ships them to the ingest API. One-line setup for a Railway-hosted (or any Node) service.
Install
npm install @app-logs-ai/node-loggerDirect use
import { createLogger } from "@app-logs-ai/node-logger";
const logger = createLogger({
apiKey: process.env.APP_LOGS_KEY!, // your project API key
endpoint: "https://your-api.example.com/v1/ingest", // defaults to https://api-app-logs.up.railway.app
});
logger.info("server started", { port: 8080 });
logger.error("checkout failed", { orderId, status: 500, error: "ECONNREFUSED" });Exported API
@app-logs-ai/node-logger exports:
createLogger(options)createWinstonTransport(options)- default export (Pino transport target)
recordHttpRequest(client, event)recordException(client, event)startMetricsHeartbeat(client, options?)expressTelemetry(client, options?)
Types are exported too (Logger, LoggerOptions, Level, HttpRequestEvent,
ExceptionEvent, MetricsOptions, TelemetryOptions).
Entries are buffered and flushed every 2s or once 20 are queued (both configurable).
Sending is best-effort — logging never throws into your app. On a graceful exit the
logger flushes automatically (see Shutdown durability); call await
logger.flush() yourself only if you've set flushOnExit: false.
Bandwidth & durability
The logger is built to be light on the integrator's network and to not lose logs on a blip:
- Batched — one POST per batch (every
flushIntervalMs, or whenbatchSizeis reached), never one request per log line. - Compressed — bodies at/above
gzipThresholdbytes are gzipped (the API decompresses transparently). - Retried — network errors,
429, and5xxare retried with exponential backoff; if still failing, the batch stays buffered for the next cycle. Non-retryable4xx(bad key/payload) is dropped with a throttledconsole.warnrather than retried forever. - Bounded — the buffer is capped at
maxBufferSize; during a long outage the oldest entries are dropped so memory can't grow without bound. Dropped entries aren't lost silently — seefallbackToStderrbelow. - Flush on exit (
flushOnExit, default on) — onSIGTERM/SIGINT/beforeExitthe logger flushes, then checkpoints anything undelivered (topersistPathif set, else to stderr). Covers rolling deploys anddocker stop. - stderr fallback (
fallbackToStderr, default on) — when entries can't be delivered and there's nopersistPath, they're printed to stderr as JSON so the platform's log pipeline (docker logs, Railway, CloudWatch) captures them instead of losing them. Zero infra required. - Crash-durable (
persistPath) — point it at a writable file and undelivered entries survive a restart (checkpointed on flush and on exit, reloaded on startup).
const logger = createLogger({
apiKey: process.env.APP_LOGS_KEY!,
batchSize: 20, // flush after N entries
flushIntervalMs: 2000, // …or after this long
maxBufferSize: 10000, // cap while offline (oldest dropped beyond this)
maxRetries: 4, // attempts per flush before re-queueing
retryBackoffMs: 500, // base for exponential backoff
gzipThreshold: 1024, // gzip bodies ≥ 1KB (0 disables)
flushOnExit: true, // flush + checkpoint on graceful shutdown (default)
fallbackToStderr: true, // dump undeliverable entries to stderr (default)
persistPath: "/data/app-logs-buffer.json", // crash durability (optional)
});Containers: don't lose logs on shutdown
Without
persistPath, a graceful stop still delivers (flush on exit) when the API is reachable; if it isn't, entries fall back to stderr — captured by your platform's logs. ASIGKILL/OOM can't be caught, so nothing survives that without a disk.To survive hard kills, set
persistPathto a path on a mounted volume (an ephemeral container layer is wiped on recreation), e.g. indocker-compose.yml:services: your-app: stop_grace_period: 30s # give the flush time to finish volumes: - applogs-buffer:/data # persistPath lives here volumes: applogs-buffer:If your app does its own signal handling or creates multiple loggers, set
flushOnExit: false(it callsprocess.exit(0)) and flush them yourself.
HTTP request sampling
expressTelemetry records one http_request event per request. On a busy
service you can sample the successful ones to cut volume — errors (status ≥ 400)
are always kept:
app.use(expressTelemetry(logger, { sampleRate: 0.1 })); // 10% of 2xx/3xx, all errorsNote: sampling scales down recorded request volume, so volume-based metrics (request_count, error-rate denominator) under-report by that factor.
Telemetry helpers
recordHttpRequest
Emit a single completed request as a normalized http_request event.
import { createLogger, recordHttpRequest } from "@app-logs-ai/node-logger";
const logger = createLogger({ apiKey: process.env.APP_LOGS_KEY! });
recordHttpRequest(logger, {
method: "GET",
route: "/v1/orders/:id",
status: 200,
durationMs: 37,
extra: { userId: "u_123" },
});recordException
Emit an exception event with route/method/status context and a shortened stack.
import { createLogger, recordException } from "@app-logs-ai/node-logger";
const logger = createLogger({ apiKey: process.env.APP_LOGS_KEY! });
try {
await doWork();
} catch (error) {
recordException(logger, {
error,
route: "/v1/orders/:id",
method: "GET",
status: 500,
extra: { orderId: "ord_42" },
});
}startMetricsHeartbeat
Periodically emits process metrics (rss, heap, event-loop lag, uptime) as
metrics events. Returns a stop function.
import { createLogger, startMetricsHeartbeat } from "@app-logs-ai/node-logger";
const logger = createLogger({ apiKey: process.env.APP_LOGS_KEY! });
const stop = startMetricsHeartbeat(logger, { intervalMs: 15000 });
// On shutdown/tests:
stop();
await logger.flush();expressTelemetry
Express middleware that records one http_request event per response. You can
sample successful traffic while always keeping 4xx/5xx.
import express from "express";
import { createLogger, expressTelemetry, recordException } from "@app-logs-ai/node-logger";
const app = express();
const logger = createLogger({ apiKey: process.env.APP_LOGS_KEY! });
app.use(expressTelemetry(logger, { sampleRate: 0.1 }));
app.use((err: unknown, req, res, next) => {
recordException(logger, {
error: err,
route: req.route?.path ?? req.path,
method: req.method,
status: res.statusCode >= 400 ? res.statusCode : 500,
});
next(err);
});With Pino
import pino from "pino";
const logger = pino({
transport: {
target: "@app-logs-ai/node-logger",
options: { apiKey: process.env.APP_LOGS_KEY },
},
});
logger.error({ orderId, status: 500 }, "checkout failed");Pino levels map to debug | info | warn | error | fatal; structured fields become
the log entry's attributes.
With Winston
import winston from "winston";
import { createWinstonTransport } from "@app-logs-ai/node-logger";
const logger = winston.createLogger({
level: "info",
transports: [
new winston.transports.Stream({
stream: createWinstonTransport({
apiKey: process.env.APP_LOGS_KEY!,
endpoint: "https://your-api.example.com/v1/ingest",
}),
}),
],
});
logger.info("server started", { port: 8080 });
logger.error("checkout failed", { orderId, status: 500, error: "ECONNREFUSED" });Winston levels are normalized to debug | info | warn | error | fatal.
Common HTTP fields (status, statusCode, durationMs, responseTime,
req/res, meta.req/meta.res) are auto-normalized into http_request events
so request count, error rate, and latency cards can populate without extra code.
Process metrics are auto-emitted by createWinstonTransport(...) and the Pino
transport every 15s by default (as type=metrics), so the dashboard Process
card populates without extra setup. Control this with:
enableMetricsHeartbeat: falseto disablemetricsHeartbeatMs: 10000to change interval
new winston.transports.Stream({
stream: createWinstonTransport({
apiKey: process.env.APP_LOGS_KEY!,
enableMetricsHeartbeat: true,
metricsHeartbeatMs: 15000,
}),
});