npm package: @mburchard/bit-log
Description: A Node library for logging
Author: Martin Burchard
Latest version: 1.2.3

# bit-log: Yet another logging library for TypeScript (and JavaScript)

![lang: Typescript](https://img.shields.io/badge/crafted_with-Typescript-blue?logo=typescript)
[![License](https://img.shields.io/badge/license-MIT-green)](./License)
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
[![NPM Version](https://img.shields.io/npm/v/%40mburchard%2Fbit-log?logo=npm)](https://www.npmjs.com/package/@mburchard/bit-log)

A lightweight, zero-dependency logging library for TypeScript and JavaScript, in Node.js and the browser.

- **Hierarchical loggers** with dot-separated namespaces and level inheritance
- **Pluggable appenders**: Console, File (rolling) and SQLite included, easy to extend
- **Dynamic reconfiguration** at runtime, no restart needed
- **Call-site capture** with automatic source map resolution for browser bundles
- **Lazy evaluation**: pass a function instead of a string to defer expensive computations
- **Customisable formatting**: override timestamp, log level or the entire prefix per appender

## Usage

```javascript
const log = useLog('foo.bar');
log.debug('Here we are, a debug log');
log.info('Here we are, an info log');
try {
  // ...
} catch (e) {
  log.error('error in method ...', e);
}
```

### Lazy Evaluation

When a log argument is expensive to compute, pass a function instead. It is only evaluated when the
log level is active, and the return value is treated as a single payload element:

```javascript
log.debug(() => `User ${user.id}: ${JSON.stringify(expensiveData)}`);
```

### Configuration

The logging system can be reconfigured at any time during execution. Repeated calls allow dynamic changes to the
configuration.

Logging is configured as follows by default:

```javascript
configureLogging({
  appender: {
    CONSOLE: {
      Class: ConsoleAppender,
    },
  },
  root: {
    appender: ['CONSOLE'],
    level: 'INFO',
  },
});
```

#### The Call Site

In some environments one wants to know where the log event was created. Since this is a bit expensive, as the
stack trace must be analysed, it is not activated by default. To enable it, use the property `includeCallSite`.

```javascript
configureLogging({
  appender: {
    CONSOLE: {
      Class: ConsoleAppender,
    },
  },
  root: {
    appender: ['CONSOLE'],
    includeCallSite: true,
    level: 'DEBUG',
  },
});
```

As always, it can be set at any time and for any Logger. The choice is yours.

##### Call Site Offset for Logger Wrappers

When you wrap the logger in a custom class or utility function, the call site points to the wrapper code instead
of the actual caller. Use `callSiteOffset` to skip additional stack frames:

```javascript
configureLogging({
  root: {
    appender: ['CONSOLE'],
    callSiteOffset: 1,
    includeCallSite: true,
    level: 'DEBUG',
  },
});
```

The offset can also be set per logger. It is inherited from parent loggers, just like `includeCallSite`.

Note: The offset only applies to Tier 2 (captureStackTrace) and Tier 3 (fallback). When the log payload
contains a real `Error`, the call site always points to where the error was thrown, regardless of the offset.

Each log event then carries a `callSite` object with `file`, `line` and `column` properties. The resolution
strategy depends on the environment:

1. If the log payload contains a real `Error`, its stack trace is used (points to where the error was thrown).
2. In V8 (Chrome, Node.js, Electron) and WebKit/JSC (Safari, Tauri's WebView), `Error.captureStackTrace`
   produces a clean stack starting after the logger-internal frames.
3. As a fallback, a synthetic `Error` is created and logger-internal frames are skipped heuristically.

##### Source Map Resolution ![optional](https://img.shields.io/badge/optional-peer_dependency-blue)

Wherever code is bundled or compiled (Vite, Webpack, esbuild, etc.), the line and column numbers from stack traces
point into the compiled output rather than the original source. This affects all environments: browsers, Electron
(both renderer and main process), Tauri (WebView), and Node.js alike.

To get accurate call-site positions in DOM environments (browsers, Electron renderer, Tauri's WebView), install
[`@jridgewell/trace-mapping`](https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping)
and configure the resolver explicitly:

```bash
pnpm add @jridgewell/trace-mapping
```

```typescript
import {originalPositionFor, TraceMap} from '@jridgewell/trace-mapping';
import {configureSourceMapResolver} from '@mburchard/bit-log';

configureSourceMapResolver(TraceMap, originalPositionFor);
```

Call `configureSourceMapResolver()` before `configureLogging()` so that the resolver is active from the first log
event.

The explicit setup ensures that bundlers do not pull `@jridgewell/trace-mapping` into your production bundle
unless you actually use it.

Without this configuration, call sites still work but show compiled positions instead of the original source
locations. In Node.js and Electron main processes, the runtime can resolve source maps natively via the
`--enable-source-maps` flag, so `configureSourceMapResolver()` is typically not needed there.

#### Additional Loggers

You can configure any number of additional hierarchical loggers.

```javascript
configureLogging({
  logger: {
    'foo.bar': {
      level: 'DEBUG',
    },
  },
});
```

After this configuration you have three loggers, all of which can be used as required.

```javascript
const log = useLog(); // get the root logger
const fooLogger = useLog('foo');
const barLogger = useLog('foo.bar');
```

All three loggers are using the existing ConsoleAppender, which is registered on the root logger.

However, you do not have to preconfigure the loggers. You can get new hierarchical loggers at any time, which then take
over the configuration from existing parents. If nothing else is available, then at the end from the root logger.

You can also change the level when accessing a logger. However, it is not recommended to do this, as this distributes
the configuration across the entire code base. Log levels should be configured centrally, in other words by calling
`configureLogging`.

It is of course also possible to completely overwrite the default configuration, i.e. to customize the root logger and
register a different appender than the `ConsoleAppender`.

#### Additional Appender

Just like the loggers, you can also configure additional appender. These must then be registered on a logger.
You can also register them on several loggers.
If you use one of the logging methods of a logger, a LogEvent is created. This is bubbled up the hierarchy until an
appender takes care of it. If this has happened, it is not passed up further.

You could add the SQLiteAppender (see below) to the root logger this way:

```javascript
configureLogging({
  appender: {
    CONSOLE: {
      Class: ConsoleAppender,
    },
    SQLITE: {
      Class: SQLiteAppender,
      level: 'WARN',
    },
  },
  root: {
    appender: ['CONSOLE', 'SQLITE'],
    level: 'INFO',
  },
});
```

#### Overwrite Formatting

Bit-Log is designed to be straightforward to use and extremely flexible. It is therefore possible to influence the
formatting of the output for each appender.

```typescript
configureLogging({
  appender: {
    CONSOLE: {
      Class: ConsoleAppender,
      formatLogLevel: (level: LogLevel, colored: boolean) => {
        return 'whatever you want';
      },
      formatPrefix: (event: ILogEvent, colored: boolean) => {
        return 'whatever you want';
      },
      formatTimestamp: (date: Date) => {
        return 'whatever you want';
      }
    },
  },
  root: {
    appender: ['CONSOLE'],
    level: 'INFO',
  },
});
```

The method names are almost self-explanatory except perhaps `formatPrefix`. Here is the default implementation
from `AbstractBaseAppender` showing how the formatting methods interlock:

```typescript
function formatPrefix(event: ILogEvent, colored: boolean = false): string {
  const levelStr = this.formatLogLevel(event.level, colored);
  const paddedLevel = colored ? levelStr.padStart(13, ' ') : levelStr.padStart(5, ' ');
  const name = truncateOrExtend(event.loggerName, 20);
  const timestamp = this.formatTimestamp(event.timestamp);

  let callSite = '';
  if (event.callSite) {
    const line = String(event.callSite.line).padStart(4, ' ');
    const path = truncateOrExtendLeft(event.callSite.file, 50);
    callSite = ` (${path}:${line})`;
  }

  return `${timestamp} ${paddedLevel} [${name}]${callSite}:`;
}
```

#### Handling Secrets

Sensitive values like API keys, tokens or passwords should never appear in plain text in log output. Since bit-log
converts log arguments to strings via `toString()`, you can create a simple wrapper class that masks itself
automatically:

```typescript
class Secret {
  constructor(private readonly value: string) {}
  toString() {
    return '********';
  }

  toJSON() {
    return '********';
  }

  unwrap() {
    return this.value;
  }
}
```

Use it anywhere you would pass a sensitive string:

```typescript
const apiKey = new Secret(process.env.API_KEY);
log.info('Connecting with key:', apiKey);
// Output: Connecting with key: ********
```

The masking works across all appenders, including `ConsoleAppender`, `FileAppender` and `SQLiteAppender`, because
they all rely on string conversion internally. Call `unwrap()` when you need the real value in your application
logic.

bit-log intentionally does not ship a `Secret` class, because masking requirements vary between projects (mask
length, partial reveal, different types of sensitive data). The pattern above is a starting point you can adapt
to your needs.

### `ConsoleAppender`

As the name states, this appender writes to the console.\
It has three properties.

`colored: boolean`\
Specifies whether logs should be formatted with colours. By default, this property is set to `false`.

`pretty: boolean`\
Specifies whether objects to be output should be formatted nicely, i.e. with indents and breaks.
By default, this property is set to `false`.

`useSpecificMethods: boolean`\
The JavaScript console has specific methods that match the log levels, such as `console.info` or `console.error`.
You can use these or `console.log`.\
The specific methods may not appear in the browser console, such as `console.debug`.\
By default, this property is set to `false`.

### `FileAppender`

This appender, of course, writes to a file and cannot be used in the browser environment.

This implementation is *rolling*, as the name of the output file is calculated from the timestamp for each log event.
This means that the appender switches to a new file after midnight.\
If you do not want this, you can overwrite the `getTimestamp` method as described above. You can also implement an
hourly rolling output in the same way.

The FileAppender has the following properties.

`baseName: string`\
Specifies a base name for the output file. By default, this property is set to an empty string.

The baseName can be empty as long as the `getTimestamp` method does not return an empty string.\
You can therefore combine both or use both individually.

```text
combined: MyLog-2024-05-13.log
baseName only: MyLog.log
timestamp only: 2024-05-13.log
```

`colored: boolean`\
Specifies whether logs should be formatted with colours. By default, this property is set to `false`.

`extension: string`\
Specifies the file extension. By default, this property is set to `log`.

`filePath: string`\
Specifies the file path. By default, this property is set to the OS default temp folder plus `bit.log`.

**Attention:** For security reasons, the FileAppender does not create directories.

`pretty: boolean`\
Specifies whether objects to be output should be formatted nicely, i.e. with indents and breaks.
By default, this property is set to `false`.

### `SQLiteAppender` ![optional](https://img.shields.io/badge/optional-peer_dependency-blue)

This appender stores log events in an SQLite database. It can be used as-is or serve as a template for other
database-backed appenders.

It requires [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) as an **optional peer dependency**.
The module is loaded dynamically, so importing the appender will not fail when the dependency is absent. If you
want to use it, install the dependency yourself:

```bash
pnpm add better-sqlite3
```

And of course, this appender cannot be used in the browser either.

The SQLiteAppender has the following properties.

`baseName: string`\
Specifies a base name for the database file. By default, this property is set to `logging`.

`extension: string`\
Specifies the file extension. By default, this property is set to `db`.

`filePath: string`\
Specifies the file path. By default, this property is set to the OS default temp folder plus `bit.log`.

The appender creates a `Logs` table with columns `id`, `timestamp`, `level`, `loggerName` and `payload`.

Call the `close()` method to cleanly release the database connection when you no longer need the appender.

## Reporting Issues

Found a bug or have a feature request? Please open an issue on the
[GitHub issue tracker](https://github.com/MBurchard/bit-log/issues/new).