@meistrari/usage
v1.8.0
Published
TypeScript SDK for emitting usage events to the usage-api service.
Downloads
7,176
Maintainers
Keywords
Readme
@meistrari/usage
TypeScript SDK for emitting usage events to the usage-api service.
Installation
bun add @meistrari/usageQuick Start
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',
vendor: 'openai',
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
await client.emit(dataToken, {
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
eventType: 'workflow.step',
runId: 'a1b2c3d4-...',
cost: 0.002,
})Batch (emitMany)
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.
Listing Events
const page = await client.listEvents(dataToken, {
eventType: ['canvas.execution', 'workflow.complete'],
start: '2026-04-01T00:00:00Z',
end: '2026-04-09T00:00:00Z',
limit: 50,
offset: 0,
})
for (const event of page.data) {
console.log(event.id, event.eventType, event.effectiveCost)
}
console.log(`Total: ${page.meta.total}`)listEvents calls GET /v1/events and returns { data, meta }. Unlike emit/emitMany, errors are always thrown — there is no fire-and-forget. Transient 5xx and network failures are retried with exponential backoff up to maxRetries; the final failure is thrown as a UsageError.
Each EventDTO mirrors a usage_event row 1:1 — no tree assembly, no parser cost rollup, no synthetic child events. Filter keys (projectId, canvasId, eventType, parentRunId, rootRunId, etc.) map directly to columns on usage_event; pass DB event_type values directly (no aliases).
Array filters (eventType, status, environment, model, tags) accept either a single value or an array; arrays are joined with commas to match the server's CSV parser.
Event Fields
The full reference for every supported field lives on the UsageEvent
interface in src/types.ts — your editor surfaces the
JSDoc on hover.
Fields you'll touch most often:
| Field(s) | When |
|---|---|
| workspaceId, eventType | Every event (required) |
| vendor, model | Whenever you're tracking a paid provider call |
| cost, inputCost, outputCost | Whenever you know the cost |
| inputAmount, outputAmount, inputUnit, outputUnit | Token / byte / request counts |
| runId, stepId, parentRunId, rootRunId | Events belonging to a workflow |
| id | Retry-safe idempotency or linking child events |
Everything else (scope, status, timing, metadata, payload, raw I/O) is documented inline in the interface.
Linking Child Events To A Parent Event
When an event groups several previously emitted children, pre-generate the child IDs, emit those events with those IDs, then emit the parent with childEventIds set to the child IDs. The SDK transports it as payload.child_event_ids on the wire — you don't need to touch payload yourself.
import { randomUUID } from 'node:crypto'
const workspaceId = '550e8400-e29b-41d4-a716-446655440000'
const runId = randomUUID()
const childEventIds: string[] = []
const searchEventId = randomUUID()
childEventIds.push(searchEventId)
await client.emit(dataToken, {
id: searchEventId,
workspaceId,
eventType: 'external.search',
runId,
vendor: 'exa',
inputAmount: 1,
inputUnit: 'requests',
cost: 0.001,
})
const llmEventId = randomUUID()
childEventIds.push(llmEventId)
await client.emit(dataToken, {
id: llmEventId,
workspaceId,
eventType: 'chat.message',
runId,
vendor: 'openai',
model: 'gpt-4o',
inputAmount: 1200,
outputAmount: 300,
inputUnit: 'tokens',
outputUnit: 'tokens',
cost: 0.004,
})
await client.emit(dataToken, {
workspaceId,
eventType: 'workflow.step',
runId,
stepId: 'research-and-answer',
childEventIds,
})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).
// 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/eventType, invalid UUID/timestamp) originconflict 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) inerr.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
eventType must follow the {entity}.{action} convention: lowercase, dot-separated, with at least one dot. Each segment can contain letters, digits, underscores, and hyphens — no spaces, no uppercase, no other punctuation.
Valid: chat.message, my_feature.started, workflow.step.complete, multi-word.action-name.
Invalid: myEvent (no dot), Chat.Message (uppercase), chat. (trailing dot), chat message.x (whitespace).
The SDK throws UsageError for non-conforming names; the server returns 422 with the same rule.
await client.emit(dataToken, {
workspaceId: '550e8400-e29b-41d4-a716-446655440000',
eventType: 'my_feature.started',
})Mapped Event Types
Some event names are recognized by the server and have additional required fields enforced server-side. Using a mapped name without its required fields returns a 422 with the missing field reported in UsageError.details.
| eventType | Required fields |
|---|---|
| canvas.execution | canvasId, vendor, model |
| canvas.* (any other canvas. prefix) | canvasId |
| workflow.step | runId, stepId |
| workflow.complete | runId |
| embedding.generate | vendor, model |
| external.legal | vendor (must equal 'jusbrasil') |
| external.search | vendor |
| chat.message, document.parse | none beyond the global required fields |
Any other eventType that follows the {entity}.{action} convention is accepted, validated only against the global rules (valid UUIDs, valid timestamps, cost != 0 requires vendor).
Testing
This package is Bun-first for development. The official test runner is bun test.
bun run test # unit tests (default)
bun run test:contract # contract tests (HTTP loopback)
bun run test:all # everythingTest 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
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.).
