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

@roastery/aroma

v0.0.3

Published

Structured, transport-based logger for the Roastery CMS ecosystem — pino-style API, pluggable transports & processors, redaction by default, and optional OpenTelemetry correlation.

Downloads

152

Readme

@roastery/aroma

Structured, transport-based logger for the Roastery CMS ecosystem — a pino-style logging core with pluggable Transports, Processors, async Context propagation, and optional OpenTelemetry correlation.

Checked with Biome

Overview

aroma provides a fast, composable logging core for TypeScript services:

  • Logger — pino-style call shape (log.info({ userId }, "msg")), six levels (tracefatal), child() context inheritance, and a zero-allocation dropped path (calls below the configured level are bound to a shared no-op at construction time).
  • Transports — pluggable sinks. Buffered non-blocking stdio, rotating files, worker-thread offloading, in-memory capture for tests, or any pino-shaped sink via the compat shim.
  • Processors — a synchronous pipeline applied once per event before broadcast: redaction, enrichment, filtering, sampling, and ECS remapping.
  • Redaction by default — common secret keys are masked out of the box; opt out with redact: false.
  • Crash-safeerror/fatal lines are written synchronously, so they survive an immediate process.exit().

Technologies

| Tool | Purpose | |------|---------| | @roastery/terroir | Exception hierarchy (AromaExceptionInfraException) | | @opentelemetry/api | Optional trace/span correlation (@roastery/aroma/otel) | | tsup | Bundling to ESM + CJS with .d.ts generation | | Bun | Runtime, test runner, and package manager | | Knip | Unused exports and dependency detection | | Biome | Linting and formatting | | Husky + commitlint | Git hooks and conventional commit enforcement |

Installation

Install the package and its peer dependency:

bun add @roastery/aroma typescript

Or install them separately:

# Install the library (pulls in @roastery/terroir)
bun add @roastery/aroma

# Install the peer dependency
bun add -d typescript

# Optional — only needed for the @roastery/aroma/otel subpath
bun add @opentelemetry/api

Local development (link)

If you're developing aroma alongside another project, you can link it locally:

# Inside the aroma directory
bun run setup  # builds and registers the link

# Inside your consuming project
bun link @roastery/aroma

Logger

Build a logger with createAroma. Every option is optional — createAroma() returns a working logger that writes JSON to stdout/stderr at "info" and above, with default redaction applied.

import { createAroma } from "@roastery/aroma";

const log = createAroma();

log.info({ userId: 42 }, "user registered");
// stdout: {"level":"info","time":1700…,"msg":"user registered","bindings":{},"meta":{"userId":42}}

log.info({ password: "x" }, "tried");   // → password redacted by default

const req = log.child({ requestId: "abc-123" });
req.error(new Error("boom"), "checkout failed");

Configuration

const log = createAroma({
  level: "info",                 // minimum severity broadcast to transports
  redact: ["customSecret"],      // ADDED to the default keys (or `false` to disable redaction)
  processors: [/* … */],         // run after the auto-injected redact processor
  transports: [/* … */],         // defaults to a single FastStdioTransport
  onError: (err) => telemetry.record("logger.failure", err),
});

Call shapes

The first argument is parsed at runtime; the optional msg comes second.

| Call | Meaning | |------|---------| | log.info("event happened") | message only | | log.info({ userId: 42 }, "registered") | meta + message | | log.info({ event: "queue.empty" }) | msg-less; all data in meta | | log.error(err, "checkout failed") | Error as first arg | | log.error({ err, step: "auth" }, "failed") | Error inside a meta.err key |

Levels, least to most severe: trace · debug · info · warn · error · fatal. log.log(...) emits at the logger's configured default level.

Graceful shutdown

error/fatal reach the kernel synchronously, but buffered info/warn lines need an explicit drain:

async function shutdown(): Promise<void> {
  await log.flush();   // drain buffered transports (e.g. FastStdioTransport)
  await log.close();   // release file handles, sockets, worker threads
  process.exit(0);
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Transports

A transport receives fully-built, already-redacted ILogEvents and decides where they go. The logger broadcasts fire-and-forget; a rejected write is surfaced through onError as an AromaException and never blocks peer transports or the caller.

| Class | Description | |-------|-------------| | FastStdioTransport | Buffered, non-blocking stdio writer — one syscall per buffer-fill. The default. | | FileTransport | Persistent file writer with size/interval rotation and optional gzip | | WorkerTransport | Offloads a sink (e.g. a FileTransport) to a worker thread | | ConsoleTransport | Direct stream writer (one write per event); kept for compatibility | | NullTransport | In-memory capture for tests (transport.events) |

import { FastStdioTransport, FileTransport } from "@roastery/aroma/transports";
import { createAroma } from "@roastery/aroma";

const log = createAroma({
  transports: [
    new FastStdioTransport({ bufferSize: 8 * 1024, backpressure: "drop" }),
    new FileTransport({
      path: "/var/log/app.log",
      rotation: { size: "50MB", interval: "daily" },
      compress: "gzip",
    }),
  ],
});

Worker transport

Offload I/O to a worker thread. A ready-made file worker ships at @roastery/aroma/transports/worker/file-worker:

import { WorkerTransport } from "@roastery/aroma/transports";

const transport = new WorkerTransport({
  target: require.resolve("@roastery/aroma/transports/worker/file-worker"),
  targetOptions: { path: "/var/log/app.log", rotation: { size: "10MB" } },
  onError: (err) => console.error("worker err:", err),
});

Backpressure

Buffered transports (FastStdioTransport, FileTransport) cap in-memory bytes at maxBuffered and apply a policy when saturated:

| Policy | Behavior | |--------|----------| | "drop" (default) | Discards the line, increments the drop count, fires onDrop | | "sample" | Keeps roughly 1 in 10 lines under sustained saturation | | "block" | Never drops — buffers the overflow line and may grow past maxBuffered (does not block the calling thread) |


Processors

Processors run synchronously, in declaration order, once per event before any transport sees it. Returning null drops the event from the pipeline.

| Factory | Description | |---------|-------------| | createRedactProcessor({ keys }) | Masks top-level fields with "[REDACTED]" (shallow) | | createEnrichProcessor(extras) | Merges fixed fields into every event's bindings | | createFilterProcessor(predicate) | Drops events failing a predicate | | createSampleProcessor(rates) | Probabilistically drops events per-level | | createEcsProcessor() | Remaps the event into Elastic Common Schema — run last |

import { createAroma } from "@roastery/aroma";
import {
  createEnrichProcessor,
  createSampleProcessor,
  createEcsProcessor,
} from "@roastery/aroma/processors";

const log = createAroma({
  processors: [
    createEnrichProcessor({ service: "checkout-api", environment: process.env.NODE_ENV }),
    createSampleProcessor({ trace: 0.01, debug: 0.1 }),
    createEcsProcessor(), // format-final: emits @timestamp / log.level / message / error.*
  ],
});

Redaction

createAroma auto-injects a redact processor (unless redact: false) using DEFAULT_REDACT_KEYS:

authorization · cookie · password · token · secret · apiKey · api_key

Extra keys passed via redact: [...] are added to these defaults.


Context

AsyncLocalStorage-backed propagation. Importing @roastery/aroma/context activates the integration — the core lazy-detects the store at emit time, so it stays runtime-agnostic until you opt in.

import { createAroma } from "@roastery/aroma";
import { runWithContext, getContext } from "@roastery/aroma/context";

const log = createAroma();

app.use((req, _res, next) => {
  runWithContext({ requestId: req.id, route: req.path }, () => {
    log.info("request received"); // event.bindings carries requestId + route
    next();
  });
});

Context bindings win over the logger's own bindings on key collision (narrower scope overrides broader scope).


OpenTelemetry

Opt-in trace correlation. @opentelemetry/api is an optional peer dependency; importing this subpath does not pull it into the core bundle.

import { createAroma } from "@roastery/aroma";
import { createOtelProcessor, primeOtel } from "@roastery/aroma/otel";

await primeOtel(); // resolve the lazy import once at boot — then reads are synchronous

const log = createAroma({ processors: [createOtelProcessor()] });
// Inside an active span, events automatically carry trace_id / span_id / trace_flags.

Compat

Reuse the pino transport ecosystem (pino-elasticsearch, pino-loki, pino-datadog, …) without rewriting code:

import { createAroma } from "@roastery/aroma";
import { createPinoCompatTransport } from "@roastery/aroma/compat";
import pinoElastic from "pino-elasticsearch";

const elastic = pinoElastic({ index: "app", node: "https://es:9200" });

const log = createAroma({
  transports: [createPinoCompatTransport(elastic, { name: "elastic" })],
});

Exceptions

| Class | Raised when | |-------|-------------| | AromaException | A transport's write rejects (delivered to onError) | | BackpressureDropException | A buffered transport drops events under the "drop" policy (carries dropCount) |

import { createAroma } from "@roastery/aroma";
import { BackpressureDropException } from "@roastery/aroma/exceptions";

createAroma({
  onError: (err) => {
    if (err instanceof BackpressureDropException) {
      metrics.increment("logger.drops", { count: err.dropCount });
    }
  },
});

Testing

Attach a NullTransport and assert on the captured events — no need to await anything:

import { Logger } from "@roastery/aroma";
import { NullTransport } from "@roastery/aroma/transports";

const sink = new NullTransport();
const log = new Logger({ transports: [sink] });

log.info({ userId: 42 }, "user registered");

expect(sink.events[0]?.level).toBe("info");
expect(sink.events[0]?.meta).toEqual({ userId: 42 });

Exports reference

// Top-level
import { createAroma, Logger } from "@roastery/aroma";
import type { CreateAromaArgs, LoggerOptions } from "@roastery/aroma";

// Types & contracts
import { LEVEL_NUMERIC } from "@roastery/aroma/types";
import type {
  ILogger, ITransport, IProcessor, ILogEvent, LogLevel, Bindings,
} from "@roastery/aroma/types";

// Transports
import {
  FastStdioTransport, FileTransport, WorkerTransport, ConsoleTransport, NullTransport,
} from "@roastery/aroma/transports";

// Bundled worker entry (target for WorkerTransport)
// @roastery/aroma/transports/worker/file-worker

// Processors
import {
  createRedactProcessor, createEnrichProcessor, createFilterProcessor,
  createSampleProcessor, createEcsProcessor,
} from "@roastery/aroma/processors";

// Context (AsyncLocalStorage propagation)
import { runWithContext, getContext } from "@roastery/aroma/context";

// OpenTelemetry correlation (optional peer: @opentelemetry/api)
import { createOtelProcessor, getActiveTraceContext, primeOtel } from "@roastery/aroma/otel";

// pino transport compat
import { createPinoCompatTransport } from "@roastery/aroma/compat";

// Exceptions
import { AromaException, BackpressureDropException } from "@roastery/aroma/exceptions";

Development

# Run tests
bun run test:unit

# Run tests with coverage
bun run test:coverage

# Throughput benchmarks
bun run bench

# Build for distribution (Biome + Knip + tsup)
bun run build

# Check for unused exports and dependencies
bun run knip

# Full setup (build + bun link)
bun run setup

License

MIT