Zero-dependency logging
LogTape is a logging library built library-first. Your code records freely; the application decides if, where, and how those logs play back.
deno add jsr:@logtape/logtapeReal LogTape output: leveled, categorized, and structured. Values stay first-class, not stringified.
The unobtrusive contract
A library built on LogTape stays completely silent until the application opts in. No setup means no output, no errors, no side effects, so your dependencies never spam a console or force a logger on anyone. The app stays in full control of if, where, and how logs play back.
// inside your library
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["shopkit", "checkout"]);
export function charge(orderId: string) {
logger.debug("charging order {orderId}", { orderId });
// ...
}// inside the application, opt in when you want
import { configure, getConsoleSink } from "@logtape/logtape";
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{ category: ["shopkit"], lowestLevel: "debug", sinks: ["console"] },
],
});Hierarchical by design
Most JavaScript loggers give every logger a flat name. LogTape arranges them in a tree: a category like ["app", "db"] is a child of ["app"], and configuration flows from parent to child. Raise one subtree to debug while the rest stays at info, and give each library its own namespace so their logs never collide.
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{ category: ["app"], lowestLevel: "info", sinks: ["console"] },
{ category: ["app", "db"], lowestLevel: "debug" },
],
}); One setting on ["app", "db"] turns the whole database subtree verbose; siblings keep inheriting info.
Everything in the box
LogTape covers the whole lifecycle of a log line, from how you write it to how it is filtered, formatted, redacted, and tested.
info("sold {n}", { n })Record values as first-class data, kept intact for downstream sinks instead of flattened into a string.
info`hello ${name}`A terse tagged-template form for when you just want a quick, readable line.
withContext({ requestId })Bind request or user data once and it follows every log emitted within that scope.
debug(l => l`…${heavy()}`)Defer expensive work so it runs only when a sink is actually going to use it.
trace · debug · … · fatalFrom trace to fatal, with per-category lowest-level thresholds.
sinks: [withFilter(sink, f)]Decide exactly which records reach each sink, by level, category, or your own predicate.
getTextFormatter({ … })Shape console and file output down to each field, or plug in your own renderer.
no-message-interpolationCatch logging anti-patterns at development time with dedicated lint rules.
recorder.assertLogged({ … })Capture and assert on emitted logs so your logging itself stays under test.
Modern by default
Dual ESM and CommonJS, published to both npm and JSR, with first-class TypeScript types bundled in: no @types side-package, no transitive dependencies. LogTape follows today's packaging standards, so it installs clean and behaves the same everywhere.
One TypeScript source, built once, shipped to ESM, CommonJS, and JSR alike.
One package family
A small, sharp core surrounded by official packages for the sinks, frameworks, and loggers you already use. Add only what you need.
Bring your own
No base classes, no plugins to register, no lifecycle to learn. A sink, a filter, and a text formatter are each one function of a log record. The only thing that changes is what you return.
type Sink = (record: LogRecord) => voidwhere each record goestype Filter = (record: LogRecord) => booleanwhether it passestype TextFormatter = (record: LogRecord) => stringhow it readsimport type { Sink } from "@logtape/logtape";
// batch records, then flush; it is just a function
const sink: Sink = (record) => {
buffer.push(record);
if (buffer.length >= 100) flush(buffer);
}; Need to await inside a sink? Sinks stay synchronous by design, but an AsyncSink returns Promise<void> and fromAsyncSink() adapts it into a regular sink, preserving order and catching errors. Async sinks →
Security built in
LogTape scrubs sensitive data three ways: redactByField() drops a field by name, redactByPattern() masks a value by its shape, and createHmacPseudonymizer() turns it into a stable token you can still correlate on, without ever logging the original. Each ships with sensible defaults.
import {
createHmacPseudonymizer,
redactByFieldAsync,
} from "@logtape/redaction";
// same input, same token; the original is never logged
const pseudonymize = await createHmacPseudonymizer({ key });
const sink = redactByFieldAsync(getConsoleSink(), {
fieldPatterns: [/userId/i, /email/i],
action: pseudonymize,
});Pseudonyms use keyed HMAC via Web Crypto, so the same value always maps to the same token across records, and back to nothing without the key.
What you ship
5.3 KB, zero dependencies, fully tree-shakable, and quick: on real console output LogTape sits with Pino at the top, several times ahead of winston, bunyan, and log4js. You pay for it once at install and again at run time, and both bills are small.
See the full comparison →Bundle size · min + gzip
Console overhead · Node.js
How it feels
Small, composable functions, no class hierarchies or ceremony. The same call site works whether or not anyone is listening.
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["my-app", "auth"]);
export function signIn(userId: string) {
logger.info("user {userId} signed in", { userId });
}Press record
Add LogTape to a library or an application today. It stays silent until you ask for output, so there is nothing to undo if you change your mind.
deno add jsr:@logtape/logtapeLogTape is free and open source. If it helps you, ♥ Sponsor the work →