This is the observability install: your product already calls a model. Assay records each unit of work beside that call. Labelling is optional. You need Node ≥ 20 and the Assay package. The collector binary is the capture path; this page is the library path.

Record spend

import { createMeter, SqliteSpanStore } from 'assay';

const store = new SqliteSpanStore('./assay.sqlite');
const meter = createMeter({ store });

const { span, created } = await meter.record({
  producerRef: 'gateway:sess-1:1', // idempotency key — re-delivery is a no-op
  principal: 'acct_42',
  sessionId: 'sess-1',
  taskId: 'turn-1',
  provider: 'anthropic',
  model: 'claude-sonnet-4-6',
  lane: 'frontier',
  tokens: { input: 1200, output: 800 },
});
created is false when the same producerRef already exists. That is the exactly-once signal a wallet debit can key on.

Record the conversation

import { createTranscriptRecorder, SqliteTranscriptStore } from 'assay/transcripts';

const transcripts = new SqliteTranscriptStore('./assay.sqlite');
const recorder = createTranscriptRecorder({
  store: transcripts,
  principalKeys: transcripts,
});

await recorder.recordTurn({
  producerRef: 'gateway:sess-1:1:msg',
  principal: 'acct_42',
  sessionId: 'sess-1',
  kind: 'message',
  role: 'human',
  content: userText,
  spanRef: 'gateway:sess-1:1',
});
Pass principal keys so one user can later be shredded without sealing everyone under one key. Join spanRef to the producerRef you used on meter.record. That is how Debug console prints cost next to the turn.

Declare what the sitting was for

A producer that knows the job should say so, instead of waiting for a guess:
await recorder.declareSession({
  sessionId: 'sess-1',
  principal: 'acct_42',
  goal: 'build:feature',
  vocabulary: 'yourapp:goal-vocabulary@1',
});
Assay admits or refuses the label. It does not rewrite it into a near miss. Register your own closed vocabulary. Assay ships the contract, not your taxonomy. Rank is declared > judge > heuristic. A store with no declarations behaves as before.

Postgres

Assay does not depend on pg. You inject a pool and migrate explicitly:
import { Pool } from 'pg';
import { applyAssayMigrations, createMeter, PostgresSpanStore } from 'assay';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await applyAssayMigrations(pool);
const meter = createMeter({ store: new PostgresSpanStore(pool) });

Language-agnostic alternative

If the loop is not Node, export OpenTelemetry gen_ai traces to assay serve.

Read it back

assay turns --session sess-1 --json
assay dashboard
To put the same panels inside your app: Embed.