npm package: @meistrari/usage
Description: TypeScript SDK for emitting usage events to the usage-api service.
Latest version: 1.1.0

# @meistrari/usage

TypeScript SDK for emitting usage events to the usage-api service.

## Installation

```sh
bun add @meistrari/usage
```

## Quick Start

```typescript
import { UsageClient } from '@meistrari/usage'

const client = new UsageClient({
    baseUrl: 'https://usage.example.com',
    origin: 'my-service',
})

await client.emit(dataToken, {
    workspaceId: '550e8400-e29b-41d4-a716-446655440000',
    eventType: 'chat.message',
    model: 'gpt-4o',
    inputAmount: 1024,
    outputAmount: 256,
    cost: 0.005,
})
```

The `dataToken` is an auth token passed as `Authorization: Bearer {token}` on every request. It is not stored on the client.

## Configuration

| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| `baseUrl` | `string` | Yes | — | Base URL of the usage-api service |
| `origin` | `string` | Yes | — | Identifies the calling service. Injected into every event. |
| `timeoutMs` | `number` | No | `5000` | Request timeout in milliseconds. Must be > 0. |
| `maxRetries` | `number` | No | `3` | Total attempts (1 initial + N-1 retries). Must be an integer >= 1. |

## Emitting Events

### Single event

```typescript
await client.emit(dataToken, {
    workspaceId: '550e8400-e29b-41d4-a716-446655440000',
    eventType: 'workflow.step',
    runId: 'a1b2c3d4-...',
    cost: 0.002,
})
```

### Batch (emitMany)

```typescript
await client.emitMany(dataToken, [
    { workspaceId: '...', eventType: 'workflow.step', runId: '...' },
    { workspaceId: '...', eventType: 'workflow.complete', runId: '...' },
])
```

Batches must contain between 1 and 500 events. An empty array or a batch exceeding 500 events throws immediately.

## Origin Injection

The `origin` value set in the constructor is automatically injected into every event before sending. The original event objects passed by the caller are never mutated.

If an event already has an `origin` field set to a different value, the call throws a `UsageError` immediately (before any network request).

```typescript
// This throws: event origin "other-service" conflicts with client origin "my-service"
await client.emit(dataToken, {
    workspaceId: '...',
    eventType: 'chat.message',
    origin: 'other-service',
})
```

## Wire Format

The SDK's public interface uses **camelCase** property names (TypeScript convention), but the HTTP payload sent to the usage-api uses **snake_case** (matching the Go server's JSON format). The transformation is handled internally — callers only interact with camelCase.

## Error Semantics

### Deterministic errors — always throws `UsageError`

These represent bugs or invalid input and are never retried:

- Validation failure (missing `workspaceId`, unknown `eventType`, invalid UUID/timestamp)
- `origin` conflict between event and client
- Batch is empty or exceeds 500 events
- HTTP 4xx response from the server
- Server quarantined one or more events (response includes per-event details in `err.details`)
- Protocol error (response body could not be parsed)

### Error field names

- **Local SDK validation** errors report field names in **camelCase** (e.g., `workspaceId`, `eventType`) — matching the public interface.
- **Server-side errors** (422, quarantine) preserve the server's **snake_case** field names (e.g., `workspace_id`, `run_id`) in `err.details`.

### Transient errors — retry then fire-and-forget

These are retried with exponential backoff. After all attempts are exhausted, the failure is logged and the call returns without throwing:

- HTTP 5xx response
- HTTP 429 (rate limited) — see retry policy below
- Network error
- Request timeout

## Retry Policy

| Scenario | Behavior |
|---|---|
| 5xx / network / timeout | Exponential backoff: `2^attempt * 1000ms` between attempts |
| 429 rate limited | Sleeps for the value in the `Retry-After` header (seconds); falls back to 5 s if the header is absent or invalid. Does **not** count as an attempt. |
| 429 cap | After 3 consecutive 429 responses, gives up and logs the failure. The counter resets on any non-429 response. |
| Retries exhausted | Logs the failure (with masked token) and returns. Does **not** throw. |

Default retry schedule for 5xx (with `maxRetries: 3`):

| Attempt | Delay before next |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 (last) | — |

## Event Types

```typescript
type UsageEventType =
    | 'canvas.execution'
    | 'workflow.step'
    | 'workflow.complete'
    | 'chat.message'
    | 'document.parse'
    | 'embedding.generate'
    | 'external.legal'
    | 'external.search'
```

## Testing

This package is **Bun-first** for development. The official test runner is `bun test`.

```sh
bun run test              # unit tests (default)
bun run test:contract     # contract tests (HTTP loopback)
bun run test:all          # everything
```

### Test categories

| File | Type | Description |
|---|---|---|
| `validate.test.ts` | Unit | Event validation rules (required fields, formats, enums) |
| `transform.test.ts` | Unit | camelCase-to-snake_case wire format transformation |
| `client.test.ts` | Unit | Client logic: batching, retries, backoff, error handling (uses mocked fetch) |
| `contract.test.ts` | Contract | Spins up a local HTTP server and exercises the full emit/emitMany flow over loopback |

Contract tests use `server.listen(0)` for a random port. In restricted sandboxes this may fail with `EADDRINUSE`. Use `test:contract` separately if needed.

## Error Class

```typescript
import { UsageError } from '@meistrari/usage'

try {
    await client.emit(dataToken, event)
}
catch (err) {
    if (err instanceof UsageError) {
        console.error(err.message, err.details)
    }
}
```

`UsageError` extends `Error` with an optional `details` field that carries structured error information (validation errors, server quarantine details, etc.).