LogTape 2.1.0: Throttling, logfmt, and smarter redaction · dahlia logtape · Discussion #165

6 min read Original article ↗

LogTape is a logging library for JavaScript and TypeScript that works across Deno, Node.js, Bun, and browsers. It's built around structured logging, has zero dependencies, and is designed to work as well in library code as in application code.

Version 2.1.0 adds a throttling filter for high-volume production environments, a logfmt formatter that splits the difference between plain text and JSON, timezone control for timestamps, and substantial improvements to the redaction package. Here's what changed.

Throttling filter

Production services sometimes hit conditions where the same log message fires thousands of times a second: a database that's down, a misconfigured retry loop, a validation error on every request. The log volume becomes noise, and the underlying cause gets buried.

The new getThrottlingFilter() addresses this. It tracks records by category, level, and raw message template, so records with different interpolated values are still counted as the same pattern:

import { configure, getConsoleSink, getThrottlingFilter } from "@logtape/logtape";

await configure({
  filters: {
    throttle: getThrottlingFilter({ limit: 5, windowMs: 10_000 }),
  },
  sinks: { console: getConsoleSink() },
  loggers: [
    { category: ["my-app"], sinks: ["console"], filters: ["throttle"] }
  ],
});

The default mode is fixed-window: the window opens when the first matching record arrives, allows up to limit records during windowMs, and suppresses the rest until it closes. Switch to "sliding" if you want a rolling count instead.

You can define what counts as “the same record” with a custom key function. For multi-tenant applications, for example, you might want to throttle per tenant ID rather than per message template:

const throttle = getThrottlingFilter({
  limit: 20,
  windowMs: 60_000,
  key: (record) => String(record.properties.tenantId),
});

When suppressed records are released, the filter can emit a summary so you know how many were dropped:

getThrottlingFilter({
  limit: 5,
  windowMs: 10_000,
  summary: {
    logger: getLogger(["my-app", "log-throttle"]),
    level: "warning",
    message: "Last log message was suppressed {suppressed} times.",
  },
});

Summary records carry structured properties including suppressed, allowed, startTime, endTime, and the first and last records from the suppressed window.

See the throttling filter documentation for the full API.

Logfmt formatter

Plain text is readable but loses structure. JSON preserves structure but is noisy to scan in a terminal. Logfmt splits the difference: level=info msg="User login" user_id=123 duration=45ms is both grep-friendly and parseable by log management tools.

LogTape now ships logfmtFormatter as a built-in:

import { configure, getConsoleSink, logfmtFormatter } from "@logtape/logtape";

await configure({
  sinks: {
    console: getConsoleSink({ formatter: logfmtFormatter }),
  },
  // ...
});

When you need to customize behavior, use getLogfmtFormatter() with LogfmtFormatterOptions. There's also a #logfmt shorthand for @logtape/config configurations.

Timezone control for timestamps

Until now, text-based formatters rendered timestamps in UTC regardless of where the application was running. The new timeZone option lets you specify an IANA timezone name or a fixed UTC offset:

import { getTextFormatter } from "@logtape/logtape";

const formatter = getTextFormatter({ timeZone: "Asia/Seoul" });
// or
const formatter = getTextFormatter({ timeZone: "+09:00" });

Pass null to use the system's local timezone. The default behavior (no timeZone option) remains UTC. The same option is available on getAnsiColorFormatter() and @logtape/pretty's getPrettyFormatter(). Invalid timezone values throw a TypeError at formatter creation time rather than silently falling back.

Error logging with extra properties

Since 2.0.0, you can pass an Error object directly to logger.error(), logger.warn(), and logger.fatal(). The default message template becomes {error.message}, and the full error is available in properties.

The new overload in 2.1.0 extends this to accept additional structured properties alongside the error, contributed by @fadomire:

// Before: had to choose between the error shorthand and extra properties
logger.error("Request failed: {error.message}", { error, requestId });

// After: both at once
logger.error(error, { requestId, userId });

Agent skill for AI coding assistants

The @logtape/logtape npm package now bundles an Agent Skills skill file that teaches AI coding assistants how to use LogTape correctly. When you use a tool like Claude Code, GitHub Copilot, or Cursor on a project that has LogTape installed, the assistant can automatically pick up the skill and apply it when writing logging code.

To make the skill available to your AI tool, use skills-npm by Anthony Fu:

{
  "scripts": {
    "prepare": "skills-npm"
  },
  "devDependencies": {
    "skills-npm": "latest"
  }
}

Running npm install then symlinks the skill into .claude/skills/, .cursor/skills/, and other agent directories. See the LLM integration documentation for manual setup instructions.

New package: @logtape/adaptor-bunyan

@logtape/adaptor-bunyan is a new package that forwards LogTape log records to Bunyan loggers. Structured properties pass through as Bunyan's merge-object, and the category formatting follows the same conventions as @logtape/adaptor-pino.

This completes the picture for Node.js logging infrastructure adaptors: if you have an existing Bunyan-based application and want to adopt LogTape-instrumented libraries without changing your logging stack, this package handles the bridge.

Redaction improvements

Async redaction actions

@logtape/redaction now supports field-based redaction actions that perform asynchronous work. The new redactByFieldAsync() function preserves record order while starting independent redaction work concurrently:

import { redactByFieldAsync } from "@logtape/redaction";

const sink = redactByFieldAsync(consoleSink, {
  fieldPatterns: [/email/i],
  action: async (value) => await lookupPseudonym(value),
});

HMAC pseudonymization

Replacing sensitive values with [REDACTED] breaks log correlation: you can't trace a user's journey across log records when every user ID looks the same. The new createHmacPseudonymizer() replaces sensitive fields with stable keyed HMAC pseudonyms instead. The same input always produces the same output for a given key, so you can correlate records without exposing the original values:

import { createHmacPseudonymizer, redactByField } from "@logtape/redaction";

const pseudonymize = await createHmacPseudonymizer({ key: cryptoKey });

const sink = redactByField(consoleSink, {
  fieldPatterns: [/userId/i, /email/i],
  action: pseudonymize,
});

CryptoKey inputs derive the default output prefix from the key's HMAC hash algorithm. Explicit hash mismatches are rejected.

Bug fixes

Two correctness bugs in redactByField() were fixed. Field patterns using global or sticky regular expressions now produce consistent results across repeated records (a subtle state issue with RegExp.lastIndex). Also, public fields named __proto__ are now preserved as own properties rather than changing the redacted object's prototype.

Hono integration improvement

@logtape/hono's HonoContext interface now extends Hono's Context directly, contributed by @HamzaZia1. Custom formatter and skip callbacks now receive the real runtime context, which means they can read context variables via c.get().

Bug fix: lazy property callbacks

Lazy property callbacks were sometimes invoked even when the log level was disabled, which defeated the purpose of lazy evaluation. This is now fixed consistently across all logging methods on both regular loggers and contextual loggers created with Logger.with().

Upgrading

npm update @logtape/logtape

# if you use the redaction package
npm update @logtape/redaction

# if you use the Hono integration
npm update @logtape/hono

# new Bunyan adaptor
npm install @logtape/adaptor-bunyan

There are no breaking changes in 2.1.0. See the full changelog for complete details.