npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fend/firo

v0.0.7

Published

Elegant logger for Node.js, Bun and Deno with brilliant DX and pino-grade speed

Downloads

613

Readme

firo 🌲

npm JSR JSR Score License: MIT Build Best logger ever

Spruce up your logs!

The logger for Node.js, Bun and Deno you've been looking for.

Beautiful dev output - out of the box. Fast, structured NDJSON for prod.

Think of it as pino, but with brilliant DX.

Demo

Beautiful colors in dev mode:

firo in action

Structured NDJSON in production mode:

firo prod output

Features

  • Dev mode — colored, timestamped, human-readable output with context badges
  • Prod mode — structured NDJSON, one record per line
  • Context system — attach key/value pairs that beautifully appear in every subsequent log line
  • Child loggers — inherit parent context, fully isolated from each other
  • Per-call context — attach extra fields to a single log call without mutating state
  • Severity Level filtering — globally or per-mode thresholds to reduce noise
  • 30 named colorsFIRO_COLORS palette with great handpicked colors, plus raw ANSI/256-color/truecolor support
  • Zero dependencies — small and fast, no bloat, no native addons. Works on Node.js, Bun and Deno.

Install

# for node.js, one of:
npm install @fend/firo
yarn add @fend/firo
pnpm add @fend/firo

# for bun:
bun add @fend/firo

# for deno:
deno add jsr:@fend/firo

Quick start

import { createFiro } from '@fend/firo'

const log = createFiro()

// log() is shorthand for log.info()
log('Server started')

log.warn('Disk usage high', { used: '92%' })
log.error('Connection lost', new Error('ECONNREFUSED'))

Dev output:

[14:32:01.204] Server started
[14:32:01.205] [WARN] Disk usage high { used: '92%' }
[14:32:01.206] [ERROR] Connection lost Error: ECONNREFUSED

Modes

Dev (default)

Colored, human-readable. Errors go to stderr, everything else to stdout.

const log = createFiro({ mode: 'dev' })

Prod

Structured NDJSON. Everything goes to stdout — let your infrastructure route it.

const log = createFiro({ mode: 'prod' })

log.info('Request handled', { status: 200 })
// {"timestamp":"2024-01-15T14:32:01.204Z","level":"info","message":"Request handled","data":{"status":200}}

Log levels

Four levels, in order: debuginfowarnerror.

log.debug('Cache miss', { user: 42, requestId: 'req-123' })
log.info('Request received')
log.warn('Retry attempt', { n: 3 })
log.error('Unhandled exception', err)

Debug lines are dimmed in dev mode to reduce visual noise.

Filtering

const log = createFiro({ minLevel: 'warn' })

Error signatures

error() accepts multiple call signatures:

// Message only will be automatically wrapped in an Error object to intentionally capture and preserve the stack trace
// because stack trace with a couple of extra levels of indirection is definitely better than no stack trace at all
log.error('Something went wrong')

// Message + Error object
log.error('Query failed', new Error('timeout'))

// Error object only
log.error(new Error('Unhandled'))

// Error + extra data
log.error(new Error('DB down'), { query: 'SELECT ...', reqId: 123 })

// Anything — will be coerced to Error
log.error(someUnknownThing)

Context

Attach persistent key/value pairs to a logger instance. They appear in every log line.

const log = createFiro()

log.addContext('service', 'auth')
log.addContext('env', 'production')

log.info('Started')
// dev:  [14:32:01.204] [service:auth] [env:production] Started
// prod: {"level":"info","service":"auth","env":"production","message":"Started",...}

Context options

Three ways to add context:

// 1. Simple key-value — just the basics
log.addContext('service', 'auth')

// 2. Key + value with options — when you need control
log.addContext('traceId', { value: 'abc-123-xyz', hideIn: 'dev' })
log.addContext('region', { value: 'west', color: '38;5;214' })

// 3. Object form — everything in one object
log.addContext({ key: 'userId', value: 'u-789', omitKey: true })
log.addContext({ key: 'span', value: 'xyz', color: '38;2;255;100;0' })

Available options (styles 2 and 3):

// Hide the key, show only the value: [u-789] instead of [userId:u-789]
log.addContext({ key: 'userId', value: 'u-789', omitKey: true })

// Pin a specific color by palette index (0–29)
log.addContext('region', { value: 'west', colorIndex: 3 })

// Use any ANSI color — 256-color, truecolor, anything
log.addContext('trace', { value: 'abc', color: '38;5;214' })       // 256-color orange
log.addContext({ key: 'span', value: 'xyz', color: '38;2;255;100;0' })  // truecolor

// Hide in dev — useful for traceIds that clutter the terminal
log.addContext('traceId', { value: 'abc-123-xyz', hideIn: 'dev' })

// Hide in prod — dev-only debugging context
log.addContext('debugTag', { value: 'perf-test', hideIn: 'prod' })

Context API

log.getContext()        // ContextItem[]
log.hasInContext('key') // boolean
log.removeFromContext('env')

Child loggers

Create a scoped logger that inherits the parent's context at the moment of creation. Parent and child are fully isolated — mutations on one do not affect the other.

const log = createFiro()
log.addContext('service', 'api')

const reqLog = log.child({ requestId: 'req-123', method: 'POST' })
reqLog.info('Request received')
// [service:api] [requestId:req-123] [method:POST] Request received

// Parent is unchanged
log.info('Still here')
// [service:api] Still here

Children can be nested arbitrarily:

const txLog = reqLog.child({ txId: 'tx-999' })
txLog.info('Transaction committed')
// [service:api] [requestId:req-123] [method:POST] [txId:tx-999] Transaction committed

Per-call context

Add context to a single log call without touching the logger's state:

log.info('User action', payload, {
  ctx: [{ key: 'userId', value: 42, omitKey: true }]
})

Works on all log methods including error:

log.error('Payment failed', err, {
  ctx: [{ key: 'orderId', value: 7 }]
})

Dev formatter options

Fine-tune the dev formatter's timestamp format. For example, to remove seconds and milliseconds:

import { createFiro } from '@fend/firo'

const log = createFiro({
  devFormatterConfig: {
    timeOptions: {
      hour: '2-digit',
      minute: '2-digit',
      second: undefined,
      fractionalSecondDigits: undefined
    }
  }
})

Color palette

Most loggers give you monochrome walls of text. Firo gives you 30 handpicked colors that make context badges instantly scannable — you stop reading and start seeing.

firo color palette

How it works

By default, firo auto-assigns colors from all 30 palette colors using a hash of the context key. Similar keys like user-1 and user-2 land on different colors automatically.

You can also pin a specific color using FIRO_COLORS — a named palette with full IDE autocomplete:

import { createFiro, FIRO_COLORS } from '@fend/firo'

const log = createFiro()

log.addContext('region', { value: 'west', color: FIRO_COLORS.coral })
log.addContext('service', { value: 'auth', color: FIRO_COLORS.skyBlue })
log.addContext('env', { value: 'staging', color: FIRO_COLORS.lavender })

Available colors: cyan, green, yellow, magenta, blue, brightCyan, brightGreen, brightYellow, brightMagenta, brightBlue, orange, pink, lilac, skyBlue, mint, salmon, lemon, lavender, sage, coral, teal, rose, pistachio, mauve, aqua, gold, thistle, seafoam, tangerine, periwinkle.

Want even more variety?

You can also pass any raw ANSI code as a string — 256-color, truecolor, go wild:

log.addContext('trace', { value: 'abc', color: '38;5;214' })         // 256-color
log.addContext('span', { value: 'xyz', color: '38;2;255;105;180' })  // truecolor pink

Restrict to safe colors

If your terminal doesn't support 256 colors, you can restrict auto-hash to 10 basic terminal-safe colors:

const log = createFiro({ useSafeColors: true })

Prod formatter options

Configure the prod (JSON) formatter's timestamp format:

// Epoch ms (faster, same as pino)
const log = createFiro({
  mode: 'prod',
  prodFormatterConfig: { timestamp: 'epoch' }
})
// {"timestamp":1711100000000,"level":"info","message":"hello"}

// ISO 8601 (default, human-readable)
const log = createFiro({ mode: 'prod' })
// {"timestamp":"2024-01-15T14:32:01.204Z","level":"info","message":"hello"}

Custom destination

By default, prod formatter writes to process.stdout. You can redirect output to any object with a .write(string) method:

import { createFiro } from '@fend/firo'
import { createWriteStream } from 'node:fs'

// Write to a file
const log = createFiro({
  mode: 'prod',
  prodFormatterConfig: { dest: createWriteStream('/var/log/app.log') }
})

// Use SonicBoom for async buffered writes (same as pino)
import SonicBoom from 'sonic-boom'
const log = createFiro({
  mode: 'prod',
  prodFormatterConfig: { dest: new SonicBoom({ fd: 1 }) }
})

Custom formatter

If for some reason all the options are not enough and you need to take full control of the output, you can provide your own formatter function.

import type { FormatterFn } from '@fend/firo'

const myFormatter: FormatterFn = (level, context, msg, data, opts) => {
  // level:   'debug' | 'info' | 'warn' | 'error'
  // context: ContextItemWithOptions[]
  // msg:     string | Error | unknown
  // data:    Error | unknown
  // opts:    LogOptions | undefined
}

const log = createFiro({ formatter: myFormatter })

You don't have to start from scratch — all the helpers we use internally are yours too:

FiroUtils

FiroUtils exposes helper functions useful for building custom formatters:

import { FiroUtils } from '@fend/firo'

FiroUtils.wrapToError(value)      // coerce unknown → Error
FiroUtils.serializeError(err)     // Error → plain object { message, stack, name, cause?, ... }
FiroUtils.safeStringify(obj)      // JSON.stringify with bigint support + fallback
FiroUtils.jsonReplacer            // replacer for JSON.stringify (handles bigint)
FiroUtils.extractMessage(msg)     // extract message string from string | Error | unknown
FiroUtils.colorize(text, idx, c?) // wrap text in ANSI color by palette index or raw code
FiroUtils.colorizeLevel(level, t) // wrap text in level color (red/yellow/dim)

Best practices

AsyncLocalStorage (Traceability)

The best way to use firo in web frameworks is to store a child logger in AsyncLocalStorage. This gives you automatic traceability (e.g. requestId) across your entire call stack without passing the logger as an argument.

import { AsyncLocalStorage } from 'node:util'
import { createFiro } from '@fend/firo'

const logger = createFiro()
const storage = new AsyncLocalStorage()

// Middleware — traceId is essential in prod logs but noisy in dev terminal
function middleware(req, res, next) {
  const reqLog = logger.child({
    traceId: { value: req.headers['x-trace-id'] || crypto.randomUUID(), hideIn: 'dev' },
    method: req.method
  })
  storage.run(reqLog, next)
}

// Deeply nested function — no logger passing needed
function someService() {
  const log = storage.getStore() ?? logger
  log.info('Service action performed')
  // dev:  [method:GET] Service action performed
  // prod: {"traceId":"a1b2c3","method":"GET","message":"Service action performed"}
}

Why not pino?

Pino is Italian for Pine. It's a great, sturdy tree, especially in production.

But sometimes you need to Spruce up your development experience.

The problem with pino is development. Its default output is raw JSON — one giant line per log entry, completely unreadable. You reach for pino-pretty, and suddenly you're maintaining infrastructure just to see what your app is doing.

firo is the Fir of logging: elegant, refined, and designed to look great in your terminal, while remaining a rock-solid performer in the production forest.

  • Context first: Badges like [requestId:abc] stay on the same line — no messy JSON trees.
  • Message first: log.info('message', data) — because why you're looking at the log is more important than the supporting data.
  • Compact by default: Objects are printed inline, one line, not twenty.
  • Visual hierarchy: Debug lines are dimmed; high-signal logs stay readable.
  • Zero config: Beautiful output from the first second.

In prod it emits clean NDJSON, same as pino. Your log aggregator won't know the difference. And the speed tax? Smaller than you'd think.

Performance

Firo vs pino — head-to-head, both writing to stdout, same machine, same conditions.

| Scenario | pino ops/sec | firo ops/sec | pino ms | firo ms | diff | | ------------------------------ | -----------: | -----------: | ------: | ------: | -------: | | simple string | 941,986 | 812,970 | 106.2 | 123.0 | +15.82% | | string + small obj | 749,782 | 673,332 | 133.4 | 148.5 | +11.32% | | string + bigger obj | 582,000 | 523,643 | 171.8 | 191.0 | +11.18% | | with 3 context items | 818,123 | 589,433 | 122.2 | 169.7 | +38.87% | | child logger (2 ctx) | 807,551 | 592,472 | 123.8 | 168.8 | +36.35% | | deep child (7 ctx) + rich data | 408,246 | 314,244 | 245.0 | 318.2 | +29.88% | | error with Error obj | 389,665 | 458,247 | 256.6 | 218.2 | -14.96% |

Apple M1, Node.js 25, 10 runs × 100K logs per scenario.

Pino is backed by 10 years of relentless optimization: SonicBoom async writer, fast-json-stringify with schema-compiled serialization, pre-serialized child context stored as raw JSON fragments, C++ worker threads. It is an obsessively optimized piece of engineering and fully deserves its reputation as the fastest logger in Node.js.

Firo uses the most vanilla tools imaginable — JSON.stringify and process.stdout.write, shipping since 2009. Zero dependencies. Zero tricks. ~30% behind pino on a realistic deep-child scenario with nested payloads. 15% ahead on error serialization.

For context, here's where the other loggers stand according to pino's own benchmarks (basic "hello world", same machine): winston 174ms, bunyan 228ms, bole 107ms. firo's 123ms puts it comfortably ahead of winston and bunyan, neck and neck with bole — and all of that with a DX that none of them can match.

So yes — if you're looking for a pino alternative with gorgeous DX, structured context, and beautiful dev output, firo is right there performance-wise. Almost a drop-in replacement.*

* Okay, not exactly drop-in — we put the message first and the data second, like normal humans. log.info("hello", data) instead of log.info(data, "hello"). We'll let you decide which API sparks more joy.

Run the benchmark yourself: pnpm bench

API reference

Logger methods

| Method | Description | |---|---| | debug(msg, data?, opts?) | Debug-level log (dimmed in dev) | | info(msg, data?, opts?) | Info-level log | | warn(msg, data?, opts?) | Warning | | error(msg, err?, opts?) | Error — also accepts error(err) | | child(ctx) | Create a child logger with additional context | | addContext(key, value \| ext) | Add a context entry | | addContext(item) | Add a context entry (object form) | | removeFromContext(key) | Remove a context entry by key | | getContext() | Return the current context array | | hasInContext(key) | Check if a context key exists |

createFiro(config?)

| Option | Type | Default | Description | |---|---|---|---| | mode | 'dev' \| 'prod' | 'dev' | Selects the built-in formatter | | minLevel | LogLevel | 'debug' | Minimum log level | | formatter | FormatterFn | — | Custom formatter, overrides mode | | devFormatterConfig | DevFormatterConfig | — | Options for the built-in dev formatter | | prodFormatterConfig | ProdFormatterConfig | — | Options for the built-in JSON prod formatter | | useSafeColors | boolean | false | Restrict auto-hash to 10 terminal-safe colors (set true for basic terminals) |

Context options

| Option | Type | Default | Description | |---|---|---|---| | colorIndex | number | auto | Color palette index (0–29) | | color | string | — | Raw ANSI color code (e.g. '38;5;214'). Takes priority over colorIndex | | omitKey | boolean | false | Hide the key, show only the value as [value] | | hideIn | 'dev' \| 'prod' | — | Hide this context item in dev or prod mode |

License

MIT License