npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@meistrari/usage

v1.8.0

Published

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

Downloads

7,176

Readme

@meistrari/usage

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

Installation

bun add @meistrari/usage

Quick 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)
  • 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

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          # 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

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.).