npm package: @significo/pulse
Description: TypeScript-first browser logger with source-location preservation and pluggable transports
Author: Significo
Latest version: 1.0.0

# @significo/pulse

TypeScript-first browser logger built around two core ideas:

1. **Logs appear at the caller's source location** in DevTools (via `console.bind()` + double invocation)
2. **A transport system** that routes structured log entries to any destination

## Installation

```bash
yarn add @significo/pulse
```

## Quick Start

```ts
import { LoggerFactory, ConsoleTransport, LogLevel } from '@significo/pulse';

const factory = new LoggerFactory({
  level: LogLevel.Info,
  transports: [new ConsoleTransport()],
  meta: { app: 'my-app' },
});

const logger = factory.getLogger('auth');

logger.info('User logged in', { userId: '123' })();
logger.warn('Session expiring soon')();
logger.error('Login failed', new Error('bad credentials'))();
```

### Why the double `()`?

Each log method returns a **bound console function**. When you invoke it with `()`, the actual `console.*` call happens at *your* call site, so DevTools shows your file and line number -- not Pulse internals. This is the same technique used by [lines-logger](https://github.com/akoidan/lines-logger).

Transports that don't need deferred execution (like `MemoryTransport`) process the entry immediately when the log method is called -- the `()` only triggers console output.

## API Reference

### `LogLevel`

```ts
enum LogLevel {
  Trace = 0,
  Debug = 1,
  Info  = 2,
  Warn  = 3,
  Error = 4,
  Off   = 5,
}
```

### `LogEntry`

Structured object passed to every transport:

```ts
interface LogEntry {
  level: LogLevel;
  tag: string;
  message: string;
  args: unknown[];
  timestamp: Date;
  meta?: Record<string, unknown>;
}
```

### `Transport`

Interface for log destinations:

```ts
interface Transport {
  handle(entry: LogEntry): void | (() => void);
}
```

Return `void` to process immediately. Return `() => void` to defer execution to the caller's stack frame (preserves source location in DevTools).

If a transport throws, it is silently caught -- other transports still receive the entry.

### `AsyncTransport`

Extends `Transport` with a `flush()` method for batching/draining:

```ts
interface AsyncTransport {
  handle(entry: LogEntry): void | (() => void);
  flush(): Promise<void>;
}
```

Call `factory.flush()` to drain all async transports (e.g. before page unload).

### `ConsoleTransport`

Default transport. Uses `console[method].bind()` and returns the bound function for deferred execution, so DevTools shows the caller's file and line.

```ts
const transport = new ConsoleTransport();
// Or with a fixed color for all tags (must be a hex color):
const transport = new ConsoleTransport({ defaultColor: '#e91e63' });
```

Tags get deterministic colors via a cyrb53 hash, rendered as colored pill badges in the console. Colors are dark-range so white text is always readable.

### `MemoryTransport`

Stores log entries in memory. Useful for testing.

```ts
const mem = new MemoryTransport();
const mem = new MemoryTransport({ maxEntries: 100 }); // ring buffer

// After logging...
mem.entries;  // readonly LogEntry[] (defensive copy)
mem.last();   // LogEntry | undefined
mem.clear();  // void
```

### `LoggerFactory`

Creates and manages logger instances.

```ts
const factory = new LoggerFactory({
  level: LogLevel.Info,           // global minimum level
  transports: [new ConsoleTransport()],
  meta: { app: 'my-app' },       // global metadata on every entry
});

const logger = factory.getLogger('payments');
const verbose = factory.getLogger('debug-mod', { level: LogLevel.Debug }); // per-logger level
```

#### Methods

- **`getLogger(tag, options?)`** -- Returns a logger. Same tag returns the same cached instance (options from the first call win; subsequent calls with different options for the same tag are ignored). Options: `{ level?, meta? }`.
- **`setLevel(level)`** -- Changes the global log level at runtime.
- **`getLevel()`** -- Returns the current global log level.
- **`setMeta(meta)`** -- Replaces global metadata entirely.
- **`mergeMeta(meta)`** -- Merges keys into global metadata (overwrites existing keys).
- **`addTransport(transport)`** -- Adds a transport at runtime.
- **`removeTransport(transport)`** -- Removes a transport. Returns `true` if found.
- **`flush()`** -- Calls `flush()` on all `AsyncTransport` instances. Returns a `Promise`.

### Logger Methods

Each logger exposes `trace`, `debug`, `info`, `warn`, `error`, and `child`. Log methods return `() => void`:

```ts
logger.info('message', ...args)();
```

The first call builds the log entry and dispatches to transports. The `()` invokes any deferred console output at the caller's stack frame.

### Child Loggers

Create a child logger that inherits the parent's tag and transports but extends its metadata:

```ts
const logger = factory.getLogger('http', { meta: { service: 'api' } });
const reqLogger = logger.child({ requestId: 'abc-123' });

reqLogger.info('Request received')();
// entry.meta = { service: 'api', requestId: 'abc-123' }
```

Children can be nested. Child metadata overrides parent metadata on key conflicts.

## Writing a Custom Transport

Implement the `Transport` interface:

```ts
import type { Transport, LogEntry } from '@significo/pulse';

class RemoteTransport implements Transport {
  constructor(private url: string) {}

  // Returns void -- processes immediately, no deferred execution needed
  handle(entry: LogEntry): void {
    fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        level: entry.level,
        tag: entry.tag,
        message: entry.message,
        args: entry.args,
        timestamp: entry.timestamp.toISOString(),
        meta: entry.meta,
      }),
    }).catch(() => {
      // Swallow errors to avoid infinite loops
    });
  }
}
```

For transports that batch and flush, implement `AsyncTransport`:

```ts
import type { AsyncTransport, LogEntry } from '@significo/pulse';

class BatchTransport implements AsyncTransport {
  private buffer: LogEntry[] = [];

  handle(entry: LogEntry): void {
    this.buffer.push(entry);
  }

  async flush(): Promise<void> {
    const batch = this.buffer.splice(0);
    if (batch.length > 0) {
      await fetch('/api/logs', {
        method: 'POST',
        body: JSON.stringify(batch),
      });
    }
  }
}
```

Then register and flush before unload:

```ts
const batch = new BatchTransport();
const factory = new LoggerFactory({ transports: [new ConsoleTransport(), batch] });

window.addEventListener('beforeunload', () => { factory.flush(); });
```

## Development

```bash
yarn test            # Run tests
yarn test:watch      # Run tests in watch mode
yarn test:e2e        # Run e2e browser tests
yarn coverage        # Run tests with 100% coverage gate
yarn typecheck       # Type check src + test
yarn lint            # ESLint
yarn build           # Build ESM + CJS + .d.ts
```

## License

Apache 2.0 -- see [LICENSE](LICENSE) for details.