A logging framework for Node/TypeScript that shares one mental model and one
JSON log shape with its Python sibling, logquill
(repo: logquill-python).
Status: 1.0 — the full core Logger, level filtering, non-blocking
async dispatch with configurable backpressure, a full plugin pipeline
(context/redaction/PII/tamper-evidence/sampling/rate-limiting/alerting),
JSONFormatter, a broad transport catalog — console/file/HTTP, SQL,
NoSQL, message queues, and cloud-native log platforms — agentic/harness
tracing (including LangChainAdapter/LangGraphAdapter and
OtelSpanProcessor), a separate logquill/browser build, and
winston/pino migration bridges are all shipped; see CHANGELOG.md for
the full history.
- Structured by default — every call carries a
metaobject, not just a message string - Cross-language record shape — identical JSON shape and level names/weights as
logquillon PyPI - Pluggable formatters —
JSONFormatterout of the box; implementformat(record) -> stringfor your own - A transport for wherever your logs need to go — console, file, and HTTP out of the box, plus SQL (SQLite/Postgres/MySQL), NoSQL (MongoDB/DynamoDB/Redis), message queues (Kafka/RabbitMQ/SQS/Pub-Sub), and cloud-native platforms (CloudWatch/Cloud Logging/App Insights/Datadog/Elasticsearch/New Relic) — see Transports. Every backend driver is an optional peer dependency: install only the one you use, or inject a pre-built client. Write your own by subclassing
Transport;CollectingTransportships as an in-memory sink, handy for tests - Plugin pipeline —
ContextPlugin,RedactPlugin,PIIRedactPlugin, tail-basedSamplingPlugin,TamperEvidentPlugin, andAlertingPlugin(SlackAlertPlugin/PagerDutyAlertPlugin/EmailAlertPlugin) out of the box;beforeLog/afterLog/onErrorhooks, or just pass a plain function to.use(); a throwing plugin can't crash logging — see Plugins - Child loggers —
.child()inherits level, transports, and plugins, and merges its ownmetaon top - Typed throughout — TypeScript strict mode, no
anyin the public API - Dual package — works via both
require()(CJS) andimport(ESM) from the same published package - Tracing & agentic logging —
.thought()/.action()/.observation()/.decision(),Logger.span()for nested/durationMs-stamped spans,RunPlugin(per-run id + step counter), andTraceContextPlugin(cross-servicetraceId, OTel-aware) — see Tracing & agentic logging - LangChain.js / LangGraph.js adapter —
LangChainAdapter, aBaseCallbackHandlerthat maps chain/LLM/tool/agent events onto the calls above with zero manual instrumentation, from a separatelogquill/langchainentry point — see Agentic framework adapters - OpenTelemetry-native integration —
OtelSpanProcessorbridges frameworks that emit OTel spans directly (e.g. the Vercel AI SDK) onto.action()/.observation()/.error(), with zero real dependency on@opentelemetry/*— see OpenTelemetry-native integration - Non-blocking async dispatch — a call returns before its write runs, via a bounded internal queue with a configurable backpressure policy (
dropOldest/dropNewest/block);logger.flush(),withLambda/withCloudFunction/withAzureFunction, andinstallShutdownHandlerscover draining it before a process pauses or exits — see Async dispatch & shutdown - Request-scoped context propagation —
bindContext(),AsyncLocalStorage-backed, so a value set once is visible in every nested log call underneath it without threading it through every signature by hand;RateLimitPlugincaps a noisy loop without silencing everything else — see Context propagation & error capture - Migration bridges —
LogQuillWinstonTransport(fromlogquill/winston) andLogQuillPinoDestinationlet an existingwinston/pinoapp adopt LogQuill's transports/plugins with no call-site changes — see Migration bridges - Browser build — a separate
logquill/browserentry (Logger,JSONFormatter, the plugin pipeline,ConsoleTransport,BeaconTransport) with no Node built-ins in its module graph — see Browser build
- Install
- Usage
- Transports
- Plugins
- Tracing & agentic logging
- Agentic framework adapters
- Async dispatch & shutdown
- Context propagation & error capture
- Migration bridges
- Browser build
- API reference
- Development
- License
npm install logquillimport { Level, Logger } from "logquill";
const logger = new Logger("app", { level: Level.INFO });
const record = logger.info("user signed up", { user_id: 42, plan: "pro" });
console.log(record);
// { timestamp: '2026-08-29T07:12:55.968Z', level: 'INFO', logger: 'app',
// message: 'user signed up', meta: { user_id: 42, plan: 'pro' } }
logger.debug("below threshold, dropped"); // -> null, filtered by level
logger.setLevel("debug");
logger.debug("now visible"); // -> a recordEvery log call returns the record (or null if filtered by level) —
{"timestamp": ISO8601, "level": string, "logger": string, "message": string, "meta": object},
the same shape shared with logquill on PyPI.
Use JSONFormatter to serialize a record to the canonical JSON line:
import { JSONFormatter } from "logquill";
console.log(new JSONFormatter().format(record));
// '{"timestamp":"2026-08-29T07:12:55.968Z","level":"INFO","logger":"app","message":"user signed up","meta":{"user_id":42,"plan":"pro"}}'.child() creates a logger scoped under this one, inheriting its level,
transports, and plugins, and merging its own meta on top:
const dbLogger = logger.child("db", { component: "pool" });
dbLogger.warn("connection lost");
// logger: "app.db", meta: { component: "pool" }.use() registers a plugin implementing beforeLog/afterLog/onError
hooks (all optional; a throwing hook is caught and routed to onError
rather than crashing logging):
logger.use({
beforeLog(record) {
return { ...record, message: record.message.toUpperCase() };
},
});Works from both ESM (import) and CommonJS (require) — the package ships
a dual build with full TypeScript types.
Attach transports to a Logger to actually write records somewhere. Each
record is dispatched to every attached transport synchronously
(non-blocking dispatch isn't implemented yet). Every backend driver below
is an optional peer dependency — npm install only pulls in what you
actually import; nothing is installed on your behalf.
| Transport | Backend | Peer dependency | Setup |
|---|---|---|---|
ConsoleTransport |
stdout/stderr via console.* |
(none) | zero-setup, isomorphic |
BeaconTransport |
browser log endpoint, via navigator.sendBeacon |
(none, isomorphic) | zero-setup — see Browser build |
FileTransport |
local file, with rotation | (none) | zero-setup |
HTTPTransport |
any HTTP log endpoint | (none, uses fetch) |
zero-setup |
SQLiteTransport |
SQLite | better-sqlite3 |
zero-setup (file or :memory:) |
PostgresTransport |
PostgreSQL | pg |
needs a server |
MySQLTransport |
MySQL | mysql2 |
needs a server |
MongoDBTransport |
MongoDB | mongodb |
needs a server |
DynamoDBTransport |
AWS DynamoDB | @aws-sdk/client-dynamodb |
needs an AWS account |
RedisTransport |
Redis Streams | redis |
needs a server |
KafkaTransport |
Kafka | kafkajs |
needs a broker |
RabbitMQTransport |
RabbitMQ | amqplib |
needs a broker |
SQSTransport |
AWS SQS | @aws-sdk/client-sqs |
needs an AWS account |
PubSubTransport |
GCP Pub/Sub | @google-cloud/pubsub |
needs a GCP account |
CloudWatchTransport |
AWS CloudWatch Logs | @aws-sdk/client-cloudwatch-logs |
needs an AWS account |
CloudLoggingTransport |
GCP Cloud Logging | @google-cloud/logging |
needs a GCP account |
AppInsightsTransport |
Azure Application Insights | applicationinsights |
needs an Azure account |
DatadogTransport |
Datadog Logs | (none, uses fetch) |
needs a Datadog account |
ElasticsearchTransport |
Elasticsearch _bulk API |
(none, uses fetch) |
needs a cluster |
NewRelicTransport |
New Relic Log API | (none, uses fetch) |
needs a New Relic account |
Every transport also accepts an injected client/sender in place of its default driver — the pattern every example below and every transport's own test suite uses, so you never need a live backend just to test your logging setup.
import { ConsoleTransport, FileTransport, HTTPTransport, Logger } from "logquill";
const logger = new Logger("app", {
transports: [
new ConsoleTransport(), // console.log; ERROR/FATAL to console.error, colorized
new FileTransport("app.log", { maxBytes: 10 * 1024 * 1024, backupCount: 5 }),
new HTTPTransport("https://logs.example.com/ingest", { batchSize: 50 }),
],
});
logger.info("user signed up", { user_id: 42 });
logger.close(); // flushes the HTTPTransport's pending batch, closes the FileTransport's fdConsoleTransport writes via console.log/console.error (not
process.stdout/stderr), so it works unmodified in a browser bundle.
Colorizing defaults to on, unless the NO_COLOR
environment variable is set. FileTransport rotates the file once it
exceeds maxBytes, keeping backupCount numbered backups (.1, .2, …).
HTTPTransport batches formatted lines and POSTs them as
newline-delimited JSON once batchSize is reached, or on .close()/
.flush(); pass sender to swap in a fake for tests or a different
backend.
Write your own by subclassing Transport (implement write(formatted, record); format() and close() have defaults), or use
CollectingTransport — an in-memory sink included for tests:
import { CollectingTransport } from "logquill";
const sink = new CollectingTransport();
const logger2 = new Logger("app", { transports: [sink] });
logger2.info("hello");
console.log(sink.formatted); // ['{"timestamp":...,"message":"hello",...}']SQLiteTransport, PostgresTransport, and MySQLTransport all extend
BaseSQLTransport, which owns a fixed logs table schema (timestamp,
level, logger, message, meta, plus runId/spanId/parentSpanId/
traceId for future trace correlation) and always batches inserts into one
parameterized multi-row INSERT — never one query per log call. Each
driver (better-sqlite3, pg, mysql2) is an optional peer
dependency — install only the one you use, or inject a pre-built
client/pool directly (handy for tests). Production schema/migrations are
your responsibility; pass ensureSchema: true to auto-create the table for
local dev/test only.
import { Logger, SQLiteTransport } from "logquill";
const transport = new SQLiteTransport({ filename: "app.db", ensureSchema: true });
const logger = new Logger("app", { transports: [transport] });
logger.info("user signed up", { userId: "u_123" });
await new Promise((r) => setImmediate(r)); // let the batch flush in this example
logger.close();import { Logger, PostgresTransport } from "logquill";
// npm install pg
const transport = new PostgresTransport({ connectionString: process.env.DATABASE_URL, maxRecords: 100 });
const logger = new Logger("app", { transports: [transport] });
logger.info("user signed up", { userId: "u_123" });
logger.close();import { Logger, MySQLTransport } from "logquill";
// npm install mysql2
const transport = new MySQLTransport({ connectionString: "mysql://user:pass@localhost:3306/app" });
const logger = new Logger("app", { transports: [transport] });
logger.info("user signed up", { userId: "u_123" });
logger.close();MongoDBTransport, DynamoDBTransport, and RedisTransport are all
optional peer dependencies too — install only the driver you use, or
inject a pre-built client/collection directly.
import { Logger, MongoDBTransport } from "logquill";
// npm install mongodb
const transport = new MongoDBTransport({
connectionString: "mongodb://localhost:27017",
database: "app",
collectionName: "logs",
});
const logger = new Logger("app", { transports: [transport] });
logger.info("user signed up", { userId: "u_123" });import { Logger, DynamoDBTransport } from "logquill";
// npm install @aws-sdk/client-dynamodb
// Partition key is meta.runId (falling back to meta.traceId, then the
// logger name); sort key is `timestamp`. Batches are chunked to respect
// BatchWriteItem's 25-item cap.
const transport = new DynamoDBTransport({ tableName: "app-logs", region: "us-east-1" });
const logger = new Logger("app", { transports: [transport] });
logger.info("order placed", { runId: "run-42", orderId: "o_9" });import { Logger, RedisTransport } from "logquill";
// npm install redis
// Writes to a Redis Stream via XADD — a fast local buffer/tail, not a
// durable system of record.
const transport = new RedisTransport({ url: "redis://localhost:6379", stream: "app:logs" });
const logger = new Logger("app", { transports: [transport] });
logger.info("cache miss", { key: "user:42" });KafkaTransport, RabbitMQTransport, SQSTransport, and PubSubTransport
all extend BaseQueueTransport, which owns the "always batch, never
publish one message per log call" contract — each only implements
publishBatch() against its own driver's batch-publish API. Every
transport takes a topic option naming the destination (a Kafka topic, a
RabbitMQ queue, an SQS queue URL, or a Pub/Sub topic, respectively). Each
driver is an optional peer dependency — install only the one you use, or
inject a pre-built client.
import { Logger, KafkaTransport } from "logquill";
// npm install kafkajs
// Each message is keyed by meta.runId (falling back to meta.traceId), so
// kafkajs's default partitioner keeps one run/trace on the same partition.
const transport = new KafkaTransport({ topic: "app-logs", brokers: ["localhost:9092"] });
const logger = new Logger("app", { transports: [transport] });
logger.info("order placed", { runId: "run-42", orderId: "o-123" });
logger.close();import { Logger, RabbitMQTransport } from "logquill";
// npm install amqplib
const transport = new RabbitMQTransport({ topic: "app-logs", url: "amqp://localhost" });
const logger = new Logger("app", { transports: [transport] });
logger.warn("low disk space", { host: "worker-3" });import { Logger, SQSTransport } from "logquill";
// npm install @aws-sdk/client-sqs
// SendMessageBatch caps a request at 10 messages; larger flushes are
// automatically chunked.
const transport = new SQSTransport({
topic: "https://sqs.us-east-1.amazonaws.com/123456789012/app-logs",
region: "us-east-1",
});
const logger = new Logger("app", { transports: [transport] });
logger.error("payment failed", { orderId: "o-123" });import { Logger, PubSubTransport } from "logquill";
// npm install @google-cloud/pubsub
const transport = new PubSubTransport({ topic: "app-logs", projectId: "my-gcp-project" });
const logger = new Logger("app", { transports: [transport] });
logger.info("job completed", { jobId: "j-9" });CloudWatchTransport, CloudLoggingTransport (GCP), and
AppInsightsTransport (Azure) are SDK-based — each driver is an optional
peer dependency, or inject a pre-built client. DatadogTransport,
ElasticsearchTransport, and NewRelicTransport are plain fetch-based
and need no extra dependency at all; pass sender to swap in a fake for
tests or an alternate backend.
import { Logger, CloudWatchTransport } from "logquill";
// npm install @aws-sdk/client-cloudwatch-logs
const logger = new Logger("app", {
transports: [new CloudWatchTransport({ logGroupName: "/my-app", logStreamName: "prod", region: "us-east-1" })],
});
logger.info("service started");
logger.close(); // flushes buffered log eventsimport { Logger, CloudLoggingTransport } from "logquill";
// npm install @google-cloud/logging
const logger = new Logger("app", {
transports: [new CloudLoggingTransport({ projectId: "my-gcp-project", logName: "my-app" })],
});
logger.info("service started");import { Logger, AppInsightsTransport } from "logquill";
// npm install applicationinsights
const logger = new Logger("app", {
transports: [new AppInsightsTransport({ connectionString: process.env.APPINSIGHTS_CONNECTION_STRING })],
});
logger.info("service started");import { Logger, DatadogTransport } from "logquill";
const logger = new Logger("app", {
transports: [new DatadogTransport({ apiKey: process.env.DD_API_KEY!, site: "datadoghq.eu" })],
});
logger.info("service started");import { Logger, ElasticsearchTransport } from "logquill";
const logger = new Logger("app", {
transports: [
new ElasticsearchTransport({ node: "https://localhost:9200", index: "app-logs", apiKey: process.env.ES_API_KEY }),
],
});
logger.info("service started");import { Logger, NewRelicTransport } from "logquill";
// `region` selects the ingest host ("US" default, or "EU") — set it
// explicitly for EU accounts, since a mismatched region is rejected.
// Batches are gzip-compressed, and a 429 pauses further sends until the
// `Retry-After` window elapses.
const logger = new Logger("app", {
transports: [new NewRelicTransport({ licenseKey: process.env.NEW_RELIC_LICENSE_KEY!, region: "EU" })],
});
logger.info("service started");Plugins hook into the pipeline around each log call: beforeLog(record) can
transform a record or return null to drop it, afterLog(record) runs once
it's been dispatched to every transport, and onError(error, record) catches
anything a plugin's own hooks throw — a broken plugin can't take down logging.
import { ContextPlugin, Logger, RedactPlugin, SamplingPlugin } from "logquill";
const logger = new Logger("app");
logger.use(new ContextPlugin({ service: "api", env: "prod" })); // merged into every record's meta
logger.use(new RedactPlugin({ keys: ["password", "token"] })); // replaces matching meta values
logger.use(new SamplingPlugin(0.1)); // keep ~10% of records that reach this point
logger.info("login attempt", { user_id: 42, password: "hunter2" });
// meta: { service: "api", env: "prod", user_id: 42, password: "***" }
// (unless this call was one of the ~90% sampling dropped, in which case it's null)Write your own by implementing Plugin; every hook is optional. .use()
also accepts a plain function in place of a Plugin — sugar for a
single-method beforeLog plugin, Express/Koa-style:
logger.use((record) => {
delete record.meta.ssn;
return record; // or null to drop the record
});SamplingPlugin can do more than flat-rate sampling: pass transports
and it buffers a sampled-out record under its meta.traceId instead of
dropping it outright. If a later record on that same trace reaches
elevateAt (default ERROR), the whole trace is flushed — every buffered
record for it, plus everything from then on — so a request that turned out
to matter still produces a complete trace, even though most of its steps
would otherwise have been sampled away.
import { CollectingTransport, Logger, SamplingPlugin } from "logquill";
const sink = new CollectingTransport();
const logger = new Logger("app", {
transports: [sink],
plugins: [new SamplingPlugin(0.01, { transports: [sink] })], // keep 1%, but never lose an errored trace
});
logger.info("step 1", { traceId: "req-42" }); // likely dropped...
logger.info("step 2", { traceId: "req-42" }); // ...and this one too
logger.error("step 3 failed", { traceId: "req-42" }); // elevates req-42 — steps 1-3 all shipPIIRedactPlugin complements RedactPlugin's exact-key matching with
regex-based scanning of meta values — emails, SSNs, credit-card
numbers, and phone numbers are redacted wherever they appear, recursively
through nested objects/arrays, regardless of which key holds them.
import { Logger, PIIRedactPlugin } from "logquill";
const logger = new Logger("app", { plugins: [new PIIRedactPlugin()] });
logger.info("support ticket", { notes: "contact me at jane@example.com" });
// meta.notes: "contact me at ***"TamperEvidentPlugin hash-chains every record — each one's meta.hash is
a SHA-256 digest over its own content plus the previous record's hash — so
editing, removing, or reordering a written line breaks the chain from that
point on, detectable later with the static verifyChain(). Opt-in: hashing
every record has a real CPU cost.
import { Logger, TamperEvidentPlugin } from "logquill";
const logger = new Logger("app", { plugins: [new TamperEvidentPlugin()] });
const records = [logger.info("one"), logger.info("two")];
TamperEvidentPlugin.verifyChain(records); // trueAlertingPlugin is the base for plugins that fire an external alert on
ERROR/FATAL (or any configurable threshold) without ever blocking the
log call that triggered it. Repeated matches within a dedupe window
collapse into a single follow-up alert reporting the total count, instead
of spamming the destination once per record.
import { Logger, SlackAlertPlugin } from "logquill";
const logger = new Logger("app", {
plugins: [new SlackAlertPlugin("https://hooks.slack.com/services/...")],
});
logger.error("payment webhook failed", { orderId: "o-123" });PagerDutyAlertPlugin (Events API v2, no extra dependency) and
EmailAlertPlugin (SMTP via the optional nodemailer peer dependency, or
inject a sender) follow the same shape. Write your own by extending
AlertingPlugin and implementing sendAlert(record, occurrences).
RateLimitPlugin drops records once a key — by default (logger, level)
— exceeds maxRecords within a rolling perSeconds window, so a noisy
loop (a retry logging the same error every iteration) can't drown out a
logger's other messages. Each key gets its own window, so unrelated keys
never reset in lockstep; pass keyFunc to key on something else, e.g. an
error message or a meta field identifying the caller.
import { Logger, RateLimitPlugin } from "logquill";
const logger = new Logger("app", { plugins: [new RateLimitPlugin(5, 60)] }); // at most 5 per key per minute
for (let i = 0; i < 10; i++) {
logger.error("db connection failed"); // only the first 5 in any 60s window ship
}.thought()/.action()/.observation()/.decision() are .info() with
meta.kind pre-set, for tagging steps of an agent's reasoning loop:
import { Logger } from "logquill";
const logger = new Logger("agent");
logger.thought("deciding which tool to call", { candidates: ["search", "calculator"] });
logger.action("calling search", { query: "current weather in nyc" });
logger.observation("search returned 3 results");
logger.decision("using search result #1");Logger.span() wraps an operation, emitting one record on completion with
meta.spanId/meta.durationMs — every record logged inside it, including
across an await, is automatically stamped with meta.parentSpanId, so a
full run's nesting can be reconstructed by sorting on spanId/parentSpanId.
It still emits (at ERROR, with meta.error) and rethrows if the block
throws:
import { Logger } from "logquill";
async function callLlm(prompt: string): Promise<string> {
return `response to: ${prompt}`;
}
const logger = new Logger("agent");
const answer = await logger.span(
"callLlm",
async () => {
logger.action("requesting completion", { model: "gpt-4" });
const response = await callLlm("current weather in nyc");
logger.observation("received completion");
return response;
},
{ model: "gpt-4" },
);RunPlugin stamps meta.runId (generated, or given explicitly) plus an
incrementing meta.step — attach a fresh instance per run so concurrent
runs don't share a counter:
import { Logger, RunPlugin } from "logquill";
const runLogger = new Logger("app").child("agent").use(new RunPlugin());
runLogger.thought("step one"); // meta: { kind: "thought", runId: "...", step: 0 }
runLogger.action("step two"); // meta: { kind: "action", runId: "...", step: 1 }TraceContextPlugin stamps meta.traceId, for correlating one request
across services — distinct from runId, which scopes one agent run. It
resolves, in priority order: an active OpenTelemetry span (if
@opentelemetry/api is installed — never a required dependency), an
inbound traceparent/X-Ray/GCP trace header, or a freshly generated id:
import { Logger, setTraceparent, TraceContextPlugin } from "logquill";
const logger = new Logger("app", { plugins: [new TraceContextPlugin()] });
// in HTTP middleware, before the handler runs:
const reset = setTraceparent(req.headers["traceparent"]);
try {
logger.info("handling request"); // meta.traceId resolved from the inbound header
} finally {
reset();
}LangChainAdapter implements LangChain.js's BaseCallbackHandler, mapping
chain/LLM/tool/agent events onto .action()/.observation()/.decision() and
span()-shaped records — pass it into callbacks: [...] and a chain's
full call tree is captured with zero manual instrumentation:
import { RunnableLambda } from "@langchain/core/runnables";
import { Logger, RunPlugin } from "logquill";
import { LangChainAdapter } from "logquill/langchain";
const logger = new Logger("agent").use(new RunPlugin());
const handler = new LangChainAdapter(logger);
const answerQuestion = RunnableLambda.from((question: string) => `answer: ${question}`);
await answerQuestion.invoke("what is 2+2?", { callbacks: [handler] });
// logs one record: { message: "RunnableLambda", meta: { kind: "span", spanId: "...", durationMs: ... } }LangGraphAdapter is the same handler under its own name, for LangGraph.js
graphs — its nodes run as ordinary LangChain Runnables, so no extra
mapping is needed; pass it the same way:
import { LangGraphAdapter } from "logquill/langchain";
const handler = new LangGraphAdapter(logger);
// const graph = builder.compile({ checkpointer });
// await graph.invoke(input, { callbacks: [handler], configurable: { thread_id: "1" } });This is a separate entry point — import ... from "logquill/langchain",
not the main "logquill" import — because LangChainAdapter has to
extends BaseCallbackHandler, LangChain's own class. Importing plain
logquill never touches @langchain/core; only importing
logquill/langchain does. Install @langchain/core yourself (it's an
optional peer dependency) — no separate @langchain/langgraph dependency
is needed for LangGraphAdapter.
Some frameworks aren't callback-handler-based — the Vercel AI SDK's
experimental_telemetry is the main JS example, emitting OpenTelemetry
spans directly instead of calling into a handler object like
BaseCallbackHandler. OtelSpanProcessor covers that case: register it on
any OTel tracer provider, and every span becomes one .action() call on
start plus one .observation() (or .error(), on an error status) call on
end, carrying the span's own spanId/parentSpanId — OTel span ids are
already the same 16-hex-char shape LogQuill's own ids use — plus
meta.durationMs on the end record:
import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
import { Logger, OtelSpanProcessor } from "logquill";
const logger = new Logger("agent");
const provider = new BasicTracerProvider({
spanProcessors: [new OtelSpanProcessor(logger)],
});
const tracer = provider.getTracer("my-app");
const span = tracer.startSpan("callLlm");
span.end();
// logs two records: one .action() on start, one .observation() on end
// (or .error() if span.setStatus({ code: SpanStatusCode.ERROR }) was called)Unlike LangChainAdapter, this is exported from the main "logquill"
entry point, not a separate subpath — it's fully duck-typed against the
Span/ReadableSpan shape, the same approach TraceContextPlugin uses
for its own OTel lookup, so it never imports @opentelemetry/api or
@opentelemetry/sdk-trace-base for real. Non-empty span attributes are
copied onto meta.attributes (configurable via attributesKey) verbatim;
mapping onto the OTel gen_ai.* semantic conventions is left to the
planned OTLPTransport (v2.0).
Every Logger call returns before the write it triggers actually runs —
the write (and any plugin afterLog hooks) is handed to an internal,
bounded dispatch queue, drained outside the caller's own call stack:
import { CollectingTransport, Logger } from "logquill";
const transport = new CollectingTransport();
const logger = new Logger("app", { transports: [transport] });
logger.info("hello");
console.log(transport.records.length); // 0 — still queued
await logger.flush();
console.log(transport.records.length); // 1 — now writtenThe queue has a configurable size and backpressure policy, so a sustained burst can't grow memory without bound:
const logger = new Logger("app", {
transports: [transport],
queue: {
maxSize: 10_000, // default
policy: "dropOldest", // "dropOldest" (default) | "dropNewest" | "block"
},
});"dropOldest"— once the queue is full, the longest-waiting record is discarded to make room for the new one."dropNewest"— the incoming record is discarded; everything already queued is left alone."block"— nothing is ever dropped: once the queue is full, a call runs its write immediately, on the caller's own stack, instead of queueing it.
Any dropped-record policy calls onDrop(count, policy) — rate-limited to
once per warnIntervalMs (default 5000) — so sustained overload is
visible without flooding your own logs with one warning per drop.
logger.flush() waits for every record dispatched so far to reach its
transports. Note it does not force a batching transport (SQL, a queue,
HTTPTransport, ...) to send a batch still under its own threshold early
— for that, use withLambda/withCloudFunction/withAzureFunction
(same wrapper, three platform-matching names) around a serverless handler,
which additionally forces every batching transport's buffer out before
the wrapped function's result settles — important because a frozen or
recycled execution environment may never come back to finish a partial
batch on its own:
import { withLambda } from "logquill";
export const handler = withLambda(logger, async (event) => {
logger.info("handling request", { requestId: event.requestId });
return { statusCode: 200 };
});For a long-running process, installShutdownHandlers flushes and closes a
logger once on SIGTERM/SIGINT/beforeExit, so nothing queued is lost
when the process stops:
import { installShutdownHandlers } from "logquill";
installShutdownHandlers(logger); // Node onlyDefault to ConsoleTransport, not FileTransport, for anything running in
a container. A container's filesystem is ephemeral and invisible to the
rest of the cluster — a log file written inside it disappears the moment
the pod is rescheduled, and nothing aggregates it in the meantime unless
you also run a sidecar to tail it back out. Writing to stdout/stderr
instead costs nothing extra: every major container runtime already
captures both streams, and the node-level log agent your cluster runs
(Fluentd, Fluent Bit, Vector, or your cloud provider's own) ships them to
your aggregator without any code on your side that needs to know that
agent exists:
import { ConsoleTransport, Logger } from "logquill";
const logger = new Logger("app", { transports: [new ConsoleTransport()] });Kubernetes sends SIGTERM and then kills the process after
terminationGracePeriodSeconds (30s by default) regardless of whether
it's finished shutting down, so a record still sitting in the dispatch
queue at that instant can be lost if nothing catches the signal.
installShutdownHandlers(logger) closes that gap the same way
withLambda closes it for a serverless freeze — call it once, on process
start, so a pod that's told to stop still flushes and closes the logger
before Kubernetes kills it.
bindContext() merges values into a request-scoped context, backed by
AsyncLocalStorage: every Logger call underneath it — through any
number of function calls and awaits deep — picks them up in meta
automatically, without threading them through every signature by hand.
Concurrent async operations sharing one Logger never see each other's
bound context.
import { bindContext, Logger } from "logquill";
const logger = new Logger("app");
await bindContext({ requestId: "abc123" }, async () => {
await handleRequest(); // any logging in here, or in what it calls,
// gets meta.requestId = "abc123" for free
});
function handleRequest() {
logger.info("handled"); // meta: { requestId: "abc123" }
}Nested bindContext() calls merge, with the inner value winning on key
collision — the same way an explicit call-site meta value always wins
over anything bound this way.
Pass an Error as meta.err and it's replaced with a formatted
meta.stack, the same shape Logger.span() already produces internally
for a thrown error:
try {
await riskyOperation();
} catch (err) {
logger.error("operation failed", { err, orderId: "o-123" });
// meta: { orderId: "o-123", stack: "Error: ...\n at ..." } — no meta.err
}For an app already using winston or pino, LogQuill can sit alongside
either with no call-site changes, so a migration can happen transport by
transport instead of all at once.
LogQuillWinstonTransport (from the separate logquill/winston entry
point, since it has to extend winston-transport's own class) plugs a
LogQuill Logger into an existing winston.createLogger() as one more
transport:
import winston from "winston";
import { Logger } from "logquill";
import { LogQuillWinstonTransport } from "logquill/winston";
const logquill = new Logger("app");
const winstonLogger = winston.createLogger({
transports: [new LogQuillWinstonTransport(logquill)],
});
winstonLogger.info("still works exactly as before", { userId: 42 });LogQuillPinoDestination is a pino destination — pass it straight into
pino() and pino's own NDJSON output is parsed back into LogQuill calls.
It needs no dependency on pino itself (a destination only has to be
duck-type compatible with a Node Writable), so it ships from the main
entry point:
import pino from "pino";
import { Logger, LogQuillPinoDestination } from "logquill";
const logquill = new Logger("app");
const log = pino(new LogQuillPinoDestination(logquill));
log.info({ userId: 42 }, "still works exactly as before");Both re-filter by the LogQuill Logger's own level after the
source library's own filtering runs — the stricter of the two wins — and
map that library's levels onto LogQuill's via a levelMap option,
overridable for a non-default level configuration.
import ... from "logquill/browser" is a separate entry point shipping
the same Logger, levels, JSONFormatter, and plugin pipeline
(ContextPlugin/RedactPlugin/PIIRedactPlugin/SamplingPlugin), plus
ConsoleTransport and BeaconTransport. FileTransport, HTTPTransport,
every SQL/NoSQL/queue/cloud-native transport, and the LangChain adapters
are absent from this entry's module graph entirely — not tree-shaken,
simply never imported — so this bundle never pulls in a Node built-in.
import { BeaconTransport, ConsoleTransport, Logger } from "logquill/browser";
const logger = new Logger("app", {
transports: [
new ConsoleTransport(),
new BeaconTransport("https://logs.example.com/ingest", { batchSize: 20 }),
],
});
logger.info("page loaded", { path: location.pathname });
window.addEventListener("pagehide", () => logger.close()); // flushes the pending beacon batchBeaconTransport batches formatted records and sends them via
navigator.sendBeacon, which — unlike fetch — can complete even after
the page that queued it starts unloading; it falls back to a keepalive
fetch where sendBeacon isn't available (a worker, an older browser).
Keep batchSize small — sendBeacon payloads are capped (64KB in most
browsers).
One behavioral difference from the Node build: Logger.span()'s
parentSpanId nesting is backed by a plain stack here instead of
AsyncLocalStorage (which browsers don't have), so two spans on the same
Logger running concurrently across an await can interleave and stamp
the wrong parentSpanId — fine for the common case of one span in flight
at a time.
Every exported class and function carries a TSDoc comment; the full
reference, generated from those comments with
TypeDoc, is published at
nikhilvdev.github.io/logquill-js
and rebuilt on every push to main. To build it locally:
npm run docs # writes static HTML to site/npm install
npm run build # dist/index.{mjs,cjs,d.ts} via tsup
npm run lint # eslint
npm run typecheck # tsc --noEmit
npm run coverage # vitest run --coverage
npm run docs # typedoc -> site/See CONTRIBUTING.md for the PR workflow, the Code of Conduct for community standards, and SECURITY.md for how to report a vulnerability.
MIT