npm package: @komori-live/bonsai
Description: Komori Bonsai SDK (Node.js / TypeScript)
Latest version: 0.0.8

# Komori Bonsai TypeScript SDK

A simple yet powerful logger for sending structured logs to [Komori Live](https://komori.live) (the official cloud platform for Bonsai) or to your own self-hosted Bonsai instance. This SDK is designed for both server-side (Node.js) and client-side (Browsers, React, Vue, Angular) applications, with a strong focus on type safety, security, and ease of use.

## ✨ Features

- **Type-Safe Structured Logging**: Send rich, strongly-typed log messages with generic payloads (`IBonsaiLogger<TPayload>`) for compile-time safety.
- **Efficient Log Batching**: Automatically queue logs and send them in batches to reduce network overhead and improve performance.
- **Flexible Authentication**: Supports both long-lived API keys and short-lived JWTs with automatic auth scheme detection.
- **Resilient Delivery**: Automatically retries failed requests using exponential backoff with jitter.
- **Secure by Design**: Provides a clear pattern for protecting API keys in client-side applications via a `tokenProvider`.
- **Asynchronous**: All logging operations are non-blocking `Promise`-based calls.
- **Isomorphic**: Works seamlessly in both Node.js and browser environments.
- **Interface-Based**: Exported `IBonsaiLogger<TPayload>` interface allows for easy mocking in unit tests.

## 📦 Installation

```bash
npm install @komori-live/bonsai
# or
yarn add @komori-live/bonsai
```

## 🚀 Basic Usage

For most server-side use cases, you can create a logger without specifying a payload type. It will default to `BonsaiDefaultPayload`, which accepts any valid JSON object.

```typescript
import { createBonsaiLogger, BonsaiLogLevel } from '@komori-live/bonsai';

// Create a logger with the default payload type
const logger = createBonsaiLogger({
  projectId: process.env.BONSAI_PROJECT_ID!,
  apiKey: process.env.BONSAI_API_KEY!,
  source: 'my-simple-service',
});

// Use the new semantic methods for cleaner logging
await logger.info('User signed in', {
  userId: 'usr_123',
  from: 'google',
});

// Old way (still supported):
// await logger.log('User signed in', BonsaiLogLevel.Info, {
//   userId: 'usr_123',
//   from: 'google',
// });
```

## 💪 Advanced Usage (Custom Payload Types)

For stricter type-safety, you can define an interface or type for your log payload and provide it as a generic argument to the factory function.

```typescript
import {
  createBonsaiLogger,
  IBonsaiLogger,
  BonsaiLogLevel,
} from '@komori-live/bonsai';

// 1. Define your structured payload
interface PurchasePayload {
  userId: string;
  productId: string;
  amount: number;
}

// 2. Create a strongly-typed logger instance
const logger = createBonsaiLogger<PurchasePayload>({
  projectId: process.env.BONSAI_PROJECT_ID!,
  apiKey: process.env.BONSAI_API_KEY!,
  source: 'my-backend-service',
});

// 3. Log with a type-safe payload
const payload: PurchasePayload = {
  userId: 'usr_1234',
  productId: 'prod_5678',
  amount: 99.99,
};

await logger.info('User completed purchase', payload);

// This would cause a compile-time error:
// await logger.error('User failed purchase', { userId: 'usr_1234' });
```

## ⚡ High-Performance Logging with Batching

For applications that generate a high volume of logs, such as a busy web server or a chatty client application, sending each log as a separate HTTP request can be inefficient. The SDK provides an optional log batching feature to address this.

When enabled, the logger adds logs to an in-memory queue. A background timer then flushes this queue periodically, sending multiple logs in a single request to a dedicated batching endpoint (`/api/logs/batch`). This significantly reduces network overhead and can improve the performance of both your application and the logging service.

### Enabling Batching

To enable batching, set `enableBatching` to `true` in your `BonsaiLoggerOptions`. You can also customize the `batchSize` and `flushIntervalMs`.

```typescript
const logger = createBonsaiLogger({
  projectId: process.env.BONSAI_PROJECT_ID!,
  apiKey: process.env.BONSAI_API_KEY!,
  source: 'my-high-volume-app',
  
  // Enable and configure batching
  enableBatching: true,
  batchSize: 200, // Default: 100
  flushIntervalMs: 10000, // Default: 5000 (10 seconds)
});
```

### Manual Flushing and Cleanup

The background timer automatically flushes the queue. However, in some cases, you might want to manually trigger a flush to ensure all buffered logs are sent immediately. This is especially important in serverless functions or before a script exits.

The `IBonsaiLogger` interface provides a `flush()` method for this purpose. For long-lived applications (like a Node.js server), it's also important to clean up the timer when the logger is no longer needed. The `destroy()` method handles this.

```typescript
// Manually flush the queue
await logger.flush();

// In a long-running application, clean up when you're done
process.on('beforeExit', async () => {
  await logger.destroy();
});
```

## 🛡️ Secure Client-Side Logging (Recommended)

**Never expose your API key in a client-side application.** The correct pattern is to use a secure backend to "vend" short-lived JWTs to the client.

The Bonsai SDK for TypeScript makes this easy with the `tokenProvider` option. You provide a function that knows how to fetch a token, and the SDK manages the entire token lifecycle for you, including initial fetching, renewal on expiry, and retrying failed logs.

### The `tokenProvider` Pattern

1.  **Your Backend API**: Create an endpoint that uses its long-lived API key to ask Bonsai for a short-lived JWT.
2.  **Your Frontend App**: Configure the `BonsaiLogger` with a `tokenProvider` function that calls your backend endpoint to get the token. The SDK handles the rest.

### Example: Angular `LogService` with `tokenProvider`

This example shows how to set up a type-safe logging service in a modern Angular application.

1.  **Define Payloads and Create a `LogService`:**

    ```typescript
    // src/app/core/logging/log-payloads.ts
    export interface PageViewPayload {
      route: string;
      durationMs: number;
    }

    export interface ErrorPayload {
      error: string;
      component: string;
    }

    // A union type for all possible log payloads in the app
    export type AppLogPayload = PageViewPayload | ErrorPayload;

    // src/app/core/services/log.service.ts
    import { HttpClient } from '@angular/common/http';
    import { inject, Injectable, OnDestroy } from '@angular/core';
    import {
      createBonsaiLogger,
      IBonsaiLogger,
      BonsaiLogLevel,
      BonsaiLoggingTokenResponse,
    } from '@komori-live/bonsai';
    import { firstValueFrom } from 'rxjs';
    import { environment } from '../../../environments/environment';
    import { AppLogPayload } from '../logging/log-payloads';

    @Injectable({ providedIn: 'root' })
    export class LogService implements OnDestroy {
      // The logger is strongly-typed with our AppLogPayload union type
      private readonly logger: IBonsaiLogger<AppLogPayload>;
      private readonly http = inject(HttpClient);

      constructor() {
        this.logger = createBonsaiLogger<AppLogPayload>({
          projectId: environment.bonsai.projectId,
          tokenProvider: this.getToken.bind(this),
          source: 'my-angular-app',
          // Enable batching for performance in a SPA
          enableBatching: true,
        });
      }

      ngOnDestroy(): void {
        // Clean up the logger when the service is destroyed.
        this.logger.destroy();
      }

      private async getToken(): Promise<string> {
        const { token } = await firstValueFrom(
          this.http.get<BonsaiLoggingTokenResponse>('/api/logging/token'),
        );
        return token;
      }

      // Public logging methods can now directly use the new semantic methods.
      public info<TPayload extends AppLogPayload>(
        message: string,
        payload: TPayload,
      ): void {
        this.logger.info(message, payload);
      }

      public warn<TPayload extends AppLogPayload>(
        message: string,
        payload: TPayload,
      ): void {
        this.logger.warn(message, payload);
      }

      public error<TPayload extends AppLogPayload>(
        message: string,
        payload: TPayload,
      ): void {
        this.logger.error(message, payload);
      }
    }
    ```

2.  **Create a Backend Endpoint to Vend Tokens:**

    You'll need an API endpoint that your `getToken` function can call. Here's an example using a C#/.NET backend, which integrates seamlessly with the `KomoriLive.Bonsai` .NET SDK:

    ```csharp
    // In your .NET API controller (e.g., LoggingController.cs)
    using KomoriLive.Bonsai;
    using Microsoft.AspNetCore.Mvc;

    [ApiController]
    [Route("api/logging")]
    public class LoggingController : ControllerBase
    {
        private readonly IBonsaiTokenService _tokenService;

        // Use the IBonsaiTokenService, which can be registered
        // in Program.cs with `services.AddBonsaiTokenService()`.
        public LoggingController(IBonsaiTokenService tokenService)
        {
            _tokenService = tokenService;
        }

        [HttpGet("token")]
        public async Task<IActionResult> GetToken()
        {
            var token = await _tokenService.GetTokenAsync();
            if (token is null)
            {
                return Unauthorized("Could not retrieve Bonsai token.");
            }
            return Ok(new { Token = token });
        }
    }
    ```

## 🔌 Integrating with Other Loggers

You can easily integrate Bonsai with your existing logging libraries like `winston` or `pino` by creating a custom "transport" or "stream".

### Example: Winston Transport

```typescript
import {
  createBonsaiLogger,
  IBonsaiLogger,
  BonsaiLogLevel,
  BonsaiLoggerOptions,
} from '@komori-live/bonsai';
import Transport from 'winston-transport';
import winston from 'winston';

// 1. Create the Bonsai Transport
class BonsaiTransport extends Transport {
  // The underlying logger can accept any object-based payload from Winston
  private bonsaiLogger: IBonsaiLogger;

  constructor(
    opts: Transport.TransportStreamOptions & {
      bonsaiConfig: BonsaiLoggerOptions;
    },
  ) {
    super(opts);
    this.bonsaiLogger = createBonsaiLogger(opts.bonsaiConfig);
  }

  // Map Winston log levels to Bonsai log levels
  private mapLevel = (level: string): BonsaiLogLevel =>
    ({
      error: BonsaiLogLevel.Error,
      warn: BonsaiLogLevel.Warning,
      info: BonsaiLogLevel.Info,
      debug: BonsaiLogLevel.Debug,
    })[level] ?? BonsaiLogLevel.Info;

  log(info: any, callback: () => void) {
    setImmediate(() => this.emit('logged', info));

    const { level, message, ...meta } = info;
    const bonsaiLevel = this.mapLevel(level);

    // Use a switch to call the correct semantic method
    switch (bonsaiLevel) {
      case BonsaiLogLevel.Debug:
        this.bonsaiLogger.debug(message, meta);
        break;
      case BonsaiLogLevel.Info:
        this.bonsaiLogger.info(message, meta);
        break;
      case BonsaiLogLevel.Warning:
        this.bonsaiLogger.warn(message, meta);
        break;
      case BonsaiLogLevel.Error:
        this.bonsaiLogger.error(message, meta);
        break;
      case BonsaiLogLevel.Critical:
        this.bonsaiLogger.critical(message, meta);
        break;
    }

    callback();
  }

  // Make sure to destroy the underlying logger to clean up timers.
  close() {
    this.bonsaiLogger.destroy().then(() => this.emit('finish'));
  }
}

// 2. Configure Winston to use the transport
const winstonLogger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new BonsaiTransport({
      bonsaiConfig: {
        projectId: process.env.BONSAI_PROJECT_ID!,
        apiKey: process.env.BONSAI_API_KEY!,
        source: 'my-winston-app',
        enableBatching: true, // Batching is great for transports
      },
    }),
  ],
});
```

## 🔧 Configuration (`BonsaiLoggerOptions`)

| Name              | Type                     | Required | Default               | Description                                                                                                                                                        |
| ----------------- | ------------------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId`       | `string`                 | Yes      | -                     | Your Bonsai project ID.                                                                                                                                            |
| `apiKey`          | `string`                 | **(\*)** | -                     | Your project's long-lived API key (for server-side use).                                                                                                           |
| `tokenProvider`   | `() => Promise<string>`  | **(\*)** | -                     | An async function that returns a JWT. The SDK will manage the token lifecycle for you. See [Secure Client-Side Logging](#-secure-client-side-logging-recommended). |
| `source`          | `string`                 | No       | `bonsai-js`           | A string identifying the source of the logs (e.g., `my-app-name`).                                                                                                 |
| `endpoint`        | `string`                 | No       | `https://komori.live` | The URL of the Bonsai ingestion endpoint.                                                                                                                          |
| `retryCount`      | `number`                 | No       | `2`                   | The number of times to retry a failed log request.                                                                                                                 |
| `retryDelayMs`    | `number`                 | No       | `5000`                | The base delay in milliseconds between retries. Uses exponential backoff with jitter.                                                                              |
| `timeoutMs`       | `number`                 | No       | `10000`               | The timeout in milliseconds for a log request.                                                                                                                     |
| `onError`         | `(err: unknown) => void` | No       | `undefined`           | A callback function to handle errors that occur during logging.                                                                                                    |
| `enableBatching`  | `boolean`                | No       | `false`               | Enables or disables log batching.                                                                                                                                  |
| `batchSize`       | `number`                 | No       | `100`                 | The maximum number of logs to include in a single batch. Only used when `enableBatching` is `true`.                                                               |
| `flushIntervalMs` | `number`                 | No       | `5000`                | The maximum time in milliseconds to wait before sending a batch. Only used when `enableBatching` is `true`.                                                        |

**(\*):** You must provide either an `apiKey` (for servers) or a `tokenProvider` (for clients), but not both.

---

## API

### `createBonsaiLogger<TPayload>(options)`

Factory function to create a new Bonsai logger.

It accepts an optional generic type argument, `TPayload`, to define the structure of your log payloads for strong type-safety.

- **Default:** If omitted, `TPayload` defaults to `BonsaiDefaultPayload`, which allows any valid JSON object.
- **Custom Type:** Provide your own interface for strict type-checking: `createBonsaiLogger<MyPayload>(...)`.
- **Any Object:** To allow any object structure (less strict than the default), use `createBonsaiLogger<Record<string, unknown>>(...)`.
- **No Payload:** To disallow payloads entirely, use `createBonsaiLogger<Record<string, never>>(...)`.

### `IBonsaiLogger<TPayload>.log(...)`

The `log` method has two overloads:

1.  **`log(message: string, level: BonsaiLogLevel, payload?: TPayload): Promise<void>`**

    Sends a log with a simple message string and a strongly-typed payload.

2.  **`log(message: BonsaiLogMessage<TPayload>): Promise<void>`**

    Sends a pre-constructed `BonsaiLogMessage<TPayload>` object.

### `IBonsaiLogger<TPayload>` Semantic Methods

The logger also includes several convenience methods that map directly to `BonsaiLogLevel` values. These are the recommended way to send logs.

- **`debug(message, [payload])`**
- **`info(message, [payload])`**
- **`warn(message, [payload])`**
- **`error(message, [payload])`**
- **`critical(message, [payload])`**

Each of these methods also supports an object-based overload for specifying a `source` override:

```typescript
// Simple usage
logger.info('User logged in', { id: '123' });

// Advanced usage with source override
logger.info({
  message: 'User logged in',
  source: 'auth-module',
  payload: { id: '123' },
});
```

### `IBonsaiLogger<TPayload>.flush(): Promise<void>`

Manually triggers a flush of the log queue. This is useful in scenarios where you want to ensure all buffered logs are sent immediately, such as before application shutdown. This method is only active when `enableBatching` is `true`.

### `IBonsaiLogger<TPayload>.destroy(): Promise<void>`

Cleans up any resources used by the logger, such as timers for batching. It also performs a final flush to ensure no logs are lost. Call this method when the logger is no longer needed, for example, during application shutdown.

### `getLoggingToken(): Promise<string | undefined>`

**This method is only available when using a static `apiKey` on the server.**

Exchanges your long-lived API key for a short-lived JWT. This is the primary mechanism for securely providing a logging token to a client-side application from your backend.

It will return the JWT string on success or `undefined` if an error occurs. Any errors will be passed to the configured `onError` handler.

> **Note:** This method cannot be used when the logger is configured with a `tokenProvider`.