Documentation · TypeScript SDK 0.2.3 · Python SDK 0.1.0 · engine 0.7.0
Everything the collector does, and what it refuses to do.
Zero runtime dependencies between any of it and the outside world. The whole integration is one configure(), one wrapper at your trace boundary, and attribution you already have — in TypeScript or Python, producing identical rows.
Install
The collector goes in the application you want to measure. It has no runtime dependencies, so it adds nothing to your tree but itself.
pnpm add @kumokodo/tetrameter-sdkThe engine, @kumokodo/tetrameter-core, is an optional peer. You only need it if you want to compute footprints locally rather than send metadata to the hosted service. Nothing installs it for you and nothing breaks without it.
Quick start
Configure once, at module load, where your AI calls are made. Then record each call. That is the whole minimum.
import { configure, HttpSink, record } from "@kumokodo/tetrameter-sdk";
configure({
sink: new HttpSink({
url: process.env.TETRAMETER_ENDPOINT!, // https://tetrameter.ai/api/v1/calls
apiKey: process.env.TETRAMETER_KEY!, // identifies your organisation
}),
});
record({
model: "anthropic/claude-haiku-4-5",
inputTokens: 1_240,
outputTokens: 380,
feature: "contract-review",
});record() never throws. If the sink fails, the network is down, or the filesystem is read-only, the call is dropped and your request continues. Instrumentation that can break the application it observes gets removed, so this one cannot.
Trace boundaries
A trace is one thing your business asked for. Wrap the outer function and every call beneath it joins automatically — there is no id to thread through your call sites, which is the thing that kills most instrumentation projects.
import { withTrace, flush } from "@kumokodo/tetrameter-sdk";
export async function reviewContract(userId: string, doc: Doc) {
return withTrace({ outcome: "contract reviewed", customer: userId }, async () => {
try {
return await runAgent(doc); // any number of calls, one trace
} finally {
await flush(); // serverless returns before timers fire
}
});
}Exactly one withTrace per delivered outcome. A nested one opens a second trace id and splits one piece of work into two, which understates the cost of both. If the boundary is not obvious, put it where you would draw the line for a customer invoice.
A call made outside any trace is not dropped — it gets a trace of its own. Partial instrumentation degrades the number rather than losing it.
Enriching a trace after it starts
The outcome is often unknown when the handler opens. setTraceMeta() fills it in, and applies to calls recorded after it — so call it before the work, not after.
import { setTraceMeta } from "@kumokodo/tetrameter-sdk";
setTraceMeta({ customer: org.id, outcome: "report delivered" });Attribution
Four optional fields decide what your reports can be broken down by. None of them is required, and each one you omit is a question the dashboard cannot answer later.
| Field | Set at | What it buys |
|---|---|---|
| customer | Trace | Per-customer attribution — the number you can hand your own customer. |
| outcome | Trace | The SCI functional unit. Without it, intensity is reported per generic unit rather than per report, per review, per ticket. |
| feature | Call | Which part of the product is spending. Varies within a trace. |
| team | Call | Ownership, for chargeback. |
customer should be an opaque account id, not a name or an email. The field is for grouping, and an identifier is enough to group by.
Adapters
Adapters read usage counters off a provider response and nothing else. They cannot leak a completion because they never read one.
// Vercel AI SDK — also reads gateway.cost, which makes cost exact rather than
// estimated from a price catalogue.
import { recordAiSdkResult } from "@kumokodo/tetrameter-sdk";
recordAiSdkResult(result, { model: "openai/gpt-4o-mini", feature: "triage" });
// Anthropic SDK
import { recordAnthropicMessage } from "@kumokodo/tetrameter-sdk";
recordAnthropicMessage(message, { feature: "summarise" });
// Anything that speaks HTTP: patch fetch once, capture everything under it.
import { register } from "@kumokodo/tetrameter-sdk";
register({ apiKey: process.env.TETRAMETER_KEY! });Mixing an explicit adapter with register() is safe. The adapter marks its async context as claimed and the patched fetch underneath stays quiet, so a call is never counted twice. Deduplicating by content hash would have been the obvious alternative and is wrong: a discovery loop legitimately issues near-identical calls, and dropping those understates.
Anthropic’s three token counters stay apart, and the third is why this matters. input_tokens is ordinary, cache_read_input_tokens is the cheap one, and cache_creation_input_tokens is a write — full prefill work billed at 1.25× input, or 2× on the one-hour TTL. Folding a write into input at 1.0×, which is what every adapter did before SDK 0.2.2, made write turns read cheaper than they were billed while read turns stayed exact — so every measured caching saving came out flattering. Writes now travel as cacheWriteTokens and are priced at the premium.
Python
The same collector, same wire model, same rows. A company running Python and TypeScript gets one shape of data rather than two that nearly agree, which is the whole reason it is a sibling package and not a rewrite.
pip install tetrameterimport tetrameter
tetrameter.configure() # reads TETRAMETER_ENDPOINT and TETRAMETER_KEY
with tetrameter.trace(outcome="contract reviewed", customer=user_id):
message = client.messages.create(...)
tetrameter.record_anthropic_message(message, feature="contract-review")
tetrameter.flush()trace() is a context manager and the trace id rides in a contextvar, so calls beneath it join without an id threaded through your call sites — the same ambient-context arrangement as withTrace. For a runtime that re-enters one logical trace across separate invocations, pass trace_id explicitly: a fresh id per step turns eight steps into eight traces each claiming a whole outcome, which multiplies the outcome count and divides the per-outcome footprint. Both flattering.
configure() returns None when TETRAMETER_ENDPOINT and TETRAMETER_KEY are not both set, and the collector stays inert. That is deliberate rather than defensive: a developer machine with no credentials records nothing instead of writing production-shaped rows into a production organisation. We made that exact mistake once and spent a day untangling which rows were real.
The adapters are record_anthropic_message, record_openai_completion, record_embedding and record_failure. Attribution is keyword arguments on any of them. Zero runtime dependencies, Python 3.9 and up.
Sinks and delivery
import { HttpSink, JsonlFileSink, MemorySink, MultiSink } from "@kumokodo/tetrameter-sdk";
// The usual shape: hosted when configured, a local file otherwise, so dev
// still captures with no backend running.
const sink =
process.env.TETRAMETER_ENDPOINT && process.env.TETRAMETER_KEY
? new HttpSink({ url: process.env.TETRAMETER_ENDPOINT, apiKey: process.env.TETRAMETER_KEY })
: new JsonlFileSink("./.tetrameter.jsonl");Calls are batched, not sent one by one. At scale a request per event would cost more than the measurement is worth. flush() forces a send and is a no-op when the buffer is empty; close() flushes and stops the timer, and waits for in-flight writes rather than abandoning them.
Ingest API
You do not need the SDK. The endpoint takes a batch and a bearer token, and the token alone identifies the organisation — there is no org id in the body, so a caller can only ever write to the organisation whose key they hold.
POST https://tetrameter.ai/api/v1/calls
Authorization: Bearer tm_…
Content-Type: application/json
{ "calls": [
{ "traceId": "review-8f21",
"model": "anthropic/claude-haiku-4-5",
"inputTokens": 1240,
"outputTokens": 380,
"feature": "contract-review",
"customer": "acct_19f",
"outcome": "contract reviewed" }
] }
→ 202 { "accepted": 1 }The schema is strict. An unknown field is a 400, not a silent drop — if something on your side is trying to send prompt content, you should find out immediately rather than discover months later that we stored it. Validation errors return field paths only and never echo the value.
Ingest is idempotent on (organisation, id). Re-sending a batch does not double-count, which matters because a duplicated batch would inflate every figure derived from it.
The engine, directly
If you would rather compute locally and send us nothing, the engine is the same code the hosted product runs.
import { computeTrace, formatQuantity } from "@kumokodo/tetrameter-core";
const footprint = computeTrace({
traceId: "review-8f21",
outcome: "contract reviewed",
calls: [{ model: "openai/gpt-4o-mini", inputTokens: 1_240, outputTokens: 412 }],
});
formatQuantity(footprint.carbon); // "0.162 – 18 gCO2e"
footprint.carbon.tier; // 1 = class average, not our confidence
footprint.carbon.sources; // every factor it came fromEvery quantity carries low, high, a tier and its sources. There is no way to get a bare scalar out, which is deliberate: a figure without a range and an evidence tier is a claim, not a measurement.
A composite is reported at the weakest tier of anything it depends on. See the methodology for what each tier means and where the uncertainty enters.
Self-hosting
The engine and the collector are Apache 2.0 and on npm. What is not open is the pipeline around them: ingest at scale, storage, per-customer attribution, waste detection and the operational record an auditor tests. That is the paid product.
For air-gapped or VPC deployment, the whole thing runs in your infrastructure on the Enterprise plan. Talk to us rather than reverse-engineering it.
Troubleshooting
| Symptom | Cause |
|---|---|
| Ingest returns 401 | No key, a wrong key, or a key belonging to another organisation. Keys begin tm_ and are issued once. We store a hash rather than the key, so a lost or leaked one is rotated, not recovered — ask us and the old key stops working immediately, which means updating TETRAMETER_KEY everywhere before the next batch. |
Ingest returns 400 unrecognized_keys | Something is sending a field we do not accept, which usually means prompt content. Fix the caller. Do not strip the field and move on. |
| Rows arrive with no outcome | Either no withTrace on that path, or setTraceMeta ran after the calls. Outcome is stamped when a call is recorded. |
| Nothing arrives at all from serverless | The invocation returned before the flush timer fired. Await flush() before your handler resolves. |
| Everything reports Tier 1 | Expected for commercial API models — nobody publishes their energy draw. Provider transparency is the ceiling, not our effort. |
| Calls counted twice | An explicit adapter and a manual record() for the same call. The adapter already records; do not do both. |
Something not here? Ask, and we will add it.