@meistrari/usage
v1.27.0
Published
TypeScript SDK for emitting usage events to the usage-api service.
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',
groupEvents: true,
limit: 50,
offset: 0,
})
for (const event of page.data) {
console.log(event.id, event.type, event.cost)
console.log(event.steps?.length ?? 0)
}
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.
By default, listEvents returns the Tela-style tree: roots are paginated and descendants are returned under steps, with rolled-up cost and discountedCost. meta.total is the count of root events. Pass { groupEvents: false } to read raw flat usage_event rows instead.
Filter keys (projectId, canvasId, productKey, productReferenceId, 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.
Executions
// Paginated roots-only listing.
const page = await client.listExecutions(dataToken, {
projectId: 'proj-1',
depth: 0,
limit: 50,
})
// Full nested tree for one execution, by root event ID.
const execution = await client.getExecution(dataToken, page.data[0]!.id)
console.log(execution.id, execution.steps?.length ?? 0)listExecutions calls GET /v1/executions and returns { data, meta }, where each data entry is a root execution with its nested steps (or, with depth: 0, roots carrying childCount and no steps).
getExecution calls GET /v1/executions/{id} and returns a single execution tree (a root node with its steps), not the { data, meta } envelope. This route is served from ClickHouse: the server answers 503 when ClickHouse reads are disabled and 404 when no execution matches the id. Both are thrown as UsageError (a 503, being transient, is retried first). Error handling otherwise mirrors listEvents.
Execution nodes (from both listExecutions and getExecution) use the tree shape: type, startedAt/finishedAt, duration, and a rolled-up cost — not the flat eventType/timestamp fields returned by listEvents. All are represented by the same permissive EventDTO type.
Event Metrics
const cost = await client.getEventCostMetrics(dataToken, {
eventType: ['canvas.execution', 'document.parse'],
productKey: 'calculo',
productReferenceId: 'case-123',
start: '2026-04-01',
end: '2026-05-01',
})
const workflowCost = await client.getEventCostMetrics(dataToken, {
eventType: 'workflow.complete',
start: '2026-05-15',
})
const overview = await client.getEventOverviewMetrics(dataToken)
const completions = await client.getEventCompletionMetrics(dataToken)
// Paginated breakdown ranked by attributed billable cost.
const breakdown = await client.getEventBreakdownMetrics(dataToken, {
start: '2026-05-01',
end: '2026-06-01',
limit: 20,
})
// Filter to a subset of projects (e.g. results of a name-search). Max 100.
const filtered = await client.getEventBreakdownMetrics(dataToken, {
projectIds: ['1db4368e-98a7-4dae-9759-7ff6b14992ac', '10000000-0000-4000-8000-000000000001'],
})Metric eventType accepts literal event types. Normal cost queries sum persisted effective_cost from billable rows. When eventType is solely workflow.complete, the returned cost is attributed per workflow root. Breakdown metrics group attributed billable cost by project, product, then event type.
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, timestamp | SDK retry idempotency, cross-process idempotency, or event occurrence time |
| executionId, parentEventId | Events inside an execution tree (see Execution Attribution) |
| originalEventId | Reuse events referencing an event that already billed |
Everything else (scope, status, timing, metadata, payload, raw I/O) is documented inline in the interface.
Execution Attribution
Attribution links events by event id, independently of the legacy
runId / parentRunId / rootRunId correlation strings (which keep working
unchanged).
- Roots inform
id+executionIdwith the same UUID. The SDK marks the event as Attribution V2 only when execution context is declared. Events withoutexecutionIdorrootEventIdremain legacy and eligible for backfill. - Children inform
executionId+parentEventId: the execution root and the direct caller, both event ids (UUIDs).rootEventIdis a compatibility alias ofexecutionId— accepted alone, and must be equal when both are set. - Reuse emits a new event with
originalEventIdpointing at the original andbillable: false. The original event is never modified.
Invalid combinations (a root carrying parentEventId, a non-root without it,
a parentEventId pointing at the event itself, a reuse event still billable)
throw a UsageError before any request is sent.
Event I/O And Vault References
inputData and outputData accept inline JSON for ergonomics, but usage-api
treats them as externalized content:
- On ingest, usage-api uploads inline
inputData/outputDatato Vault and stores a durablevault://...reference on the event. - If you already hold a
vault://...reference, pass it through — usage-api preserves it as-is and does not re-upload. - Event read endpoints return the stored value. For new events that is normally
a
vault://...reference. To read the full content, resolve the reference directly against Vault using workspace-scoped authentication — usage-api does not proxy the payload back. - Very large payloads can still stress usage-api on the way in; uploading
directly to Vault and emitting the resulting
vault://...reference remains the recommended path for oversized I/O.
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
Idempotency And Retries
UsageClient.emit and emitMany materialize missing id and timestamp
once before validation, wire-format conversion, and the retry loop. The same
payload is reused for SDK-internal retries, so transient 5xx, network errors,
and request timeouts do not create a new (id, timestamp) pair inside a
single SDK call.
Caller-provided values are preserved. Values that are omitted, null, or ''
are treated as absent. The original event objects passed to the SDK are not
mutated.
This protects retries inside one emit or emitMany call. If you need
idempotency across processes, queue redelivery, job/lambda reexecution, manual
retries, or separate SDK calls, generate and persist both id and timestamp
yourself and reuse the same pair. The server dedupes on (id, timestamp).
When timestamp is omitted in the SDK, it becomes the SDK materialization time,
not the server ingestion time. Set timestamp explicitly when the event
occurred earlier than the SDK call.
requestId remains correlation data for filtering and tracing. Do not use it as
the event idempotency key; use the (id, timestamp) pair for that.
Runtime Target
This SDK targets Node >=18 and Bun. It uses Node-compatible runtime APIs such as
node:crypto for UUID generation. Direct browser bundle support would require
a separate browser entrypoint and is outside this change.
Retry Policy
| Scenario | Behavior |
|---|---|
| 5xx / network / timeout | Exponential backoff: 2^attempt * 1000ms between attempts. The same materialized event payload is reused across 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.).
FCC agent usage by session
List sessions with listAgentSessions (GET /v1/agent-sessions) and read one session with getAgentSession (GET /v1/agent-sessions/{id}). The cost, overview and completions metrics take aggregateBy: 'session'; without it, metric behavior is unchanged. listExecutions does not aggregate sessions: its params type rejects aggregateBy, and a value that still reaches it at runtime (for example, spread from metric params) throws a UsageError before any request. Summary keeps its existing groupBy dimensions; breakdown does not support sessions.
import type { ListSessionsParams, SessionDTO, SessionUsageParams } from '@meistrari/usage'
const selection: ListSessionsParams = { canvasId: agentId, start, end }
const sessions: SessionDTO[] = []
let cursor: string | undefined
let hasMore: boolean
do {
const page = await client.listAgentSessions(dataToken, { ...selection, limit: 500, cursor })
sessions.push(...page.data)
cursor = page.meta.nextCursor
hasMore = page.meta.hasMore
} while (hasMore)
const metricParams: SessionUsageParams = { ...selection, aggregateBy: 'session', eventType: 'agent.execution' }
const [cost, overview, completions] = await Promise.all([
client.getEventCostMetrics(dataToken, metricParams),
client.getEventOverviewMetrics(dataToken, metricParams),
client.getEventCompletionMetrics(dataToken, metricParams),
])Keep the same explicit period and filters for metrics and every export page. The root timestamp window is [start,end); dates accept RFC3339 or YYYY-MM-DD. For metrics, an omitted end defaults to now and an omitted start to end minus 30 days. listAgentSessions without both dates lists the whole history, each session with all its runs; with one date it applies the same defaults. Pagination defaults to 50, caps at 500, and orders by first run time descending, then session ID descending. A cursor overrides offset. meta.currentFilters echoes supplied filters; it is not a snapshot or an effective default-window handshake.
Only FCC root agent executions with a session ID contribute; agents inside workflows are excluded. canvasId is required. The only other selection fields are start, end, status and model (the latter two accept CSV strings or arrays); listAgentSessions also takes limit, offset and cursor. The route implies agent.execution, so listAgentSessions never sends aggregateBy or eventType; the metrics require both (aggregateBy: 'session', eventType: 'agent.execution'). Other filters receive 400; unavailable ClickHouse reads receive 503. Workspace comes from the token.
Session rows have kind: 'session' and sessionId, without execution id or steps. sessionId is the agent-api session ID; pass it to getAgentSession. runCount counts eligible runs in the window. firstRunAt determines the session's daily metric bucket. Status comes from the latest created run in the window, with execution ID breaking timestamp ties; cancellation emitted as failed stays failed. Status filtering happens after aggregation. Model filtering selects sessions using the full execution model sets, including internal steps, while retaining all eligible runs of those sessions in the window.
cost and credits sum already calculated effective execution amounts, including zero amounts; they do not reprice a session. Metric unit follows workspace billing mode. durationMs sums valid run intervals, excludes absent/epoch/end-before-start finishes and is null if none are known. Metric averages ignore unknown durations. lastFinishedAt is the latest valid finish in the window, not an operational session termination guarantee.
totalTokens is inputAmount + outputAmount. Cache token fields are informational and must not be added again. models is the sorted union used for selection. modelCounts follows execution model frequencies, excluding cache-hit roots under the existing view convention; model is the most frequent name, with lexical ties, or null. Overview model counts are run frequencies, not session counts; they may differ from the previous table that counted every run's model. Session completion counts use sessions, and environment distributions are empty.
One session
const session = await client.getAgentSession(dataToken, sessionId, { start, end })
console.log(session.runCount, session.cost, session.totalTokens, session.durationMs)getAgentSession returns one SessionDTO (the shape of a listAgentSessions row), not the { data, meta } envelope. start and end are required, have no defaults and are the only parameters sent; there is no canvasId, since the token's workspace and the session ID identify the session. Pass the session's known start and last finish: the server widens both by 24 hours to absorb clock skew between services, then selects runs by their root (run start) timestamp in [start - 24h, end + 24h) and does not count events at or after end + 24h; there is no status or model filter. Runs outside that widened window are silently left out. The ID is URL-encoded, so opaque IDs containing / or : are safe.
The server answers 400 for a missing or invalid window (including end 48 hours or more before start), 404 when no run of the session starts in the widened window, and 503 when ClickHouse reads are disabled. 4xx are thrown as UsageError immediately, without retry; a 503, being transient, is retried first.
Publication depends on the pipelimes agent_session_id column with one row per execution: pipelimes → usage-api/SDK → Tela.
