# Roarr is a JSON logger for code that cannot configure the application

> Roarr is a small TypeScript logger that produces structured JSON in Node.js and the browser, does nothing until an environment variable turns it on, and is meant to be usable from a library as well as from an application. Its distinguishing feature is that the filtering and the pretty printing happen outside the process, in a separate CLI.

**gajus/roarr** — JSON logger for Node.js and browser.

- Repository: https://github.com/gajus/roarr
- Stars: 1,138 · Forks: 41
- Language: TypeScript
- License: NOASSERTION
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/gajus-roarr

## The design starts from a problem with the usual loggers

The motivation section is unusually specific about why this library exists, and it is worth reading as the design brief.

The author liked `debug` for a long time, because it is simple to use, works in Node.js and the browser, requires no configuration and is fast. The problem is parsing. Anything but one line text messages cannot be parsed in a safe way, and if a log line is going to be processed by a machine, a text format is the wrong shape.

For structured data, the author used Winston and Bunyan, and preferred Bunyan because of its CLI program for pretty printing logs. The problem there is program-level configuration. When you construct an instance of a logger you have to define the transport and the log level, and that makes these packages unsuitable for use in code designed to be consumed by other applications. A library that logs through Winston is making a decision that belongs to the application.

pino was the next candidate: fast, with a CLI program equivalent to Bunyan, decoupled transports and sane default configuration. It still requires instantiating a logger instance at the application level, so it is more suitable for application-level logging, just like Winston and Bunyan.

The list of requirements that follows is the specification. A logger that does not block the event cycle, does not require initialization, produces structured data, decouples transports, has a CLI program, works in Node.js and the browser, and is configurable using environment variables.

The last three lines of the motivation section say why that matters. A logger usable in application code and in dependencies, one that allows logs to be correlated between the main application and the dependency code, and one that works well with transports in external processes.

## In Node.js nothing is written until ROARR_LOG is set

The producing side of the API is the same in Node.js and the browser, and it is deliberately tiny.

```ts
import { Roarr as log } from "roarr";

log("foo");
```

The consuming side differs, and the Node.js behaviour is the one that surprises people. Logging in Node.js is disabled by default. To enable it you must start the program with the environment variable `ROARR_LOG` set to `true`.

```bash
ROARR_LOG=true node ./index.js
```

All logs are then written to stdout.

This is the whole configuration model. There is no transport to pick, no level to set, no logger instance to create, and no call to initialise. A dependency can call `log()` at import time or in a hot path, and if the application has not opted in, the call costs a branch and produces nothing.

The consequence for the library author is the point of the whole exercise. The dependency emits the same structured messages whether it is running inside a large application, inside a test, or in a browser, and the decision about whether to see them belongs to whoever runs the process.

The same property is why the environment variable, rather than a programmatic option, is the switch. A library cannot set an environment variable for the process that loads it, and it cannot know whether it is the entry point, so the only place the decision can live is the environment.

## In the browser you implement ROARR.write

The browser side has no stdout, so the contract is a function you provide. In a browser you must implement the `ROARR.write` method to read logs.

```ts
import { ROARR } from "roarr";

ROARR.write = (message) => {
  console.log(JSON.parse(message));
};
```

The API of that method is a single parameter, a message string, and a void return.

There is a second case. If you are initialising `ROARR.write` before `roarr` is loaded, you set it on `globalThis` first so that the module finds it when it initialises.

```ts
// Ensure that `globalThis.ROARR` is configured.
const ROARR = (globalThis.ROARR = globalThis.ROARR || {});

ROARR.write = (message) => {
  console.log(JSON.parse(message));
};
```

If the platform does not support `globalThis`, the documentation points at the `globalthis` polyfill.

For a browser setup that is more than a placeholder, there is a separate package, `@roarr/browser-log-writer`, which implements an opinionated browser logger with Liqe query support for filtering logs.

The design here mirrors the Node.js behaviour in reverse. In Node.js the library writes to stdout and the environment variable decides whether. In the browser the library has nowhere to write, so it hands the message to a function the page provides, and the page decides what to do with it. In both cases the library does not decide.

## The message format is five fields and a version

Every log message Roarr produces has the same shape, and the format is documented as a table of properties.

The `context` field holds arbitrary, user-provided structured data. The `message` is the user-provided message, formatted using printf. The `sequence` is an incremental sequence ID, with the format and its meaning described under the `adopt` API. The `time` is a Unix timestamp in milliseconds. The `version` is the log message format version.

```json
{
  "context": {
    "application": "task-runner",
    "hostname": "curiosity.local",
    "instanceId": "01BVBK4ZJQ182ZWF6FK4EC8FEY",
    "taskId": 1
  },
  "message": "starting task ID 1",
  "sequence": "0",
  "time": 1506776210000,
  "version": "1.0.0"
}
```

The example is worth reading for what it implies about correlation. The context carries an application name, a hostname, an instance id and a task id, and the sequence and time fields are added by the library. That is enough for a log line to be traced back to the process, the instance and the unit of work that produced it, which is the correlation property the motivation section asks for.

The `version` field is the one that matters for a consumer. It is the log message format version, which means a parser can tell whether it understands the record it has been handed, rather than guessing from the presence of a field.

The API itself is a single exported function with two possible shapes. The first parameter can be a string, the message, or an object, the context, in which case the second parameter must be the message string. The arguments after the message enable printf formatting, and the printf arguments must be of a primitive type, that is a string, number, boolean or null. There can be up to nine printf arguments, or eight when the first parameter is the context object.

## Filtering happens in a separate process, not in the logger

This is the part of the design that separates Roarr from the loggers it was built as an alternative to.

In Node.js, Roarr prints all or none logs, which is what the `ROARR_LOG` environment variable controls. There is no level, no filter and no sampling inside the library. To filter, you pipe the output through the `@roarr/cli` program.

```bash
ROARR_LOG=true node ./index.js | roarr --filter 'context.logLevel:>30'
```

So the filter expression lives in the shell pipeline rather than in the application, and the same process can emit at every level while a consumer decides what matters. That is what makes the correlation story work: a dependency emits structured records, the application emits its own, and one filter in the pipeline applies to both.

In the browser, there is no pipeline, so the filtering is implemented inside the `ROARR.write` function you provide.

```ts
globalThis.ROARR.write = (message) => {
  const payload = JSON.parse(message);

  if (payload.context.logLevel > 30) {
    console.log(payload);
  }
};
```

The trade is explicit. You get one configuration decision, made at the process boundary, and the cost is that the library offers no level filtering of its own and no per-logger configuration. A team that wants different levels for different modules has to do it in the filter expression or in the browser writer.

The dependencies of the package are correspondingly small, with `fast-printf` for the message formatting, `safe-stable-stringify` for serialising the context, and `semver-compare`.

## Where a single global logger is the wrong shape

The limitations follow directly from the design decisions, and they are worth naming before an adoption.

The first is that there is one logger and one configuration. There is no way to give a particular module a different transport, a different destination or a different level inside the program. Everything is either on or off, and everything goes to stdout. For an application that needs to send audit logs to one place and debug logs to another, the answer is to filter in the pipeline, which is workable but is not the same thing as a per-logger transport.

The second is that the log level is a convention rather than a feature. The example filter uses `context.logLevel`, and the level is something the caller puts into the context. The library does not interpret it, does not compare it against a threshold, and does not drop messages below it. Any consumer that wants levels has to know the convention.

The third is a packaging one. The published files are `src` and `dist`, with the browser build selected through the `browser` field and the main entry through `main`, and the package requires Node.js 18 or newer. That is a normal modern package, but it does mean the library assumes a bundler or a modern Node runtime, and a project supporting an older environment is out of scope.

The alternative to consider, and the difference in approach is real, is to use one of the application level loggers the motivation section discusses, Winston, Bunyan or pino, and accept that a library that logs through them has to configure a transport. Roarr's argument is that the library should not decide, and that decision belongs at the process boundary. For a team with a single application and a single logging destination, the application level approach is more flexible. For a library published to npm and consumed by strangers, the trade Roarr makes is the right one.

## A small dependency footprint and a fast moving version line

The maintenance picture is straightforward to read from the repository.

The published version line is in the 7.x range, with v7.21.7 released on 2026-07-26, v7.21.6 on 2026-06-16 and v7.21.5 on 2026-05-13. The repository is not archived and the last push was on 2026-10-12, so the work is current even though the most recent tagged release is a few months back.

The tooling in the manifest says something about how the project is run. Tests are written for `ava` and run with `tsx` to load TypeScript directly, the build is a plain `tsc` invocation against `tsconfig.build.json`, and linting runs `eslint` over `src` and `test` followed by `tsc` and `knip`, which is the tool that finds unused files and exports. Releases are handled by `semantic-release` with the commit analyzer, GitHub and npm plugins, and there is a `husky` configuration for commit hooks, plus a `cspell.yaml` for spelling.

There is also a benchmark script, and it is run with logging enabled, which tells you that the performance claim in the motivation section, that the logger does not block the event cycle, is something the project measures rather than asserts.

The licence is the one thing that needs a second look. The manifest declares BSD-3-Clause, while the repository's licence metadata is not classified as a standard licence by GitHub, and the licensing file at the root of the repository is the text to read. The upstream project `roarr` is published on npm under the name `roarr`, and the CLI used for filtering is a separate package, `@roarr/cli`.

## Conclusion

Roarr suits a library author who wants structured, correlatable logs from their dependency without forcing the consuming application to configure a transport, and a team that prefers to filter logs in a separate process rather than in application code. It is the wrong choice for an application that wants per-logger transports and log levels configured in code, since the model here deliberately moves that decision out of the program. Check first that your environment can set ROARR_LOG for the process, since in Node.js the logger writes nothing at all without it, and read the message format before parsing it, because the sequence and version fields are part of the contract.

## FAQ

### How do I enable Roarr logging in Node.js?

Logging in Node.js is disabled by default. You enable it by starting the program with the `ROARR_LOG` environment variable set to `true`, for example `ROARR_LOG=true node ./index.js`, and all logs are written to stdout.

### How do I read Roarr logs in a browser?

You implement the `ROARR.write` method, whose API takes a message string and returns nothing. If you set it before roarr is loaded you assign it on `globalThis` first, and the `globalthis` polyfill is suggested for platforms without `globalThis`.

### How do I filter Roarr log output?

In Node.js the logger prints all or none logs, so filtering is done by piping through the `@roarr/cli` program, for example `ROARR_LOG=true node ./index.js | roarr --filter 'context.logLevel:>30'`. In a browser the filtering goes inside your own `ROARR.write` implementation.

### What does a Roarr log message contain?

Five properties: `context` for arbitrary user-provided structured data, `message` formatted with printf, `sequence` for an incremental sequence ID, `time` as a Unix timestamp in milliseconds, and `version` which is the log message format version.

## Sources

- [gajus/roarr on GitHub](https://github.com/gajus/roarr)
- [Issues](https://github.com/gajus/roarr/issues)
- [README](https://github.com/gajus/roarr/blob/main/README.md)
- [Releases](https://github.com/gajus/roarr/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/gajus-roarr
