Developer docs

Instrument your LLM costs in one line.

Metergraph is a content-blind capture SDK plus a dashboard you self-host. Wrap the client you already have, and every LLM call gets attributed to the function and route that made it: tokens in and out, cache and reasoning tokens, latency, and server-priced cost. Prompts and completions stay in your process. These docs cover the SDK, the self-hosted server, the hosted engine, and copy-paste prompts a coding agent can run to wire it all up.

Install the SDK Prompts for coding agents Server on GitHub ↗

Overview

Metergraph answers one question your dashboards can't: which function in your codebase is spending what on LLM calls, and is it worth it. It has two layers, and they share the same SDK.

  • Open source (free, Apache-2.0). A zero-dependency capture SDK for Python and TypeScript, plus a FastAPI + Postgres server and React dashboard you run with one docker compose up. It tracks cost, tokens, and latency by function and route.
  • Hosted engine. Point the same SDK at the hosted tier and it continuously shadow-tests your live traffic against cheaper models on your own keys, scores quality with confidence intervals, and opens evidence-backed pull requests.

Switching between them is one environment variable. The pieces:

PackageRegistryWhat it is
metergraphPyPIPython capture SDK, zero runtime deps
metergraphnpmTypeScript / JavaScript capture SDK, zero runtime deps
metergraph-serverGitHubFastAPI + Postgres ingest, price catalog, usage API
dashboardGitHubReact SPA served by the server

Never in your request path. The SDK captures asynchronously and is fail-open: transport problems never break or slow an LLM call. Without a token, capture is off completely, and the SDK sends nothing until you set one.

Install

Install the SDK for your language and set one environment variable. The SDK has no runtime dependencies; the provider SDKs (openai, anthropic, @google/genai) are optional peer dependencies you already have.

Python

bash
pip install metergraph
export METERGRAPH_APP_TOKEN=<token>

TypeScript / JavaScript

bash
npm install metergraph
export METERGRAPH_APP_TOKEN=<token>

Get a free token by creating a workspace at app.metergraph.dev, or set one yourself when self-hosting (MG_TOKENS=dev-token docker compose up). With METERGRAPH_APP_TOKEN unset, the SDK does nothing, so it's safe to leave in every environment, including CI.

Quickstart

Two steps: wrap the client, name the function. wrap() returns the same client object and initializes itself from the environment on first use. Call sites, arguments, streaming, and async all work unchanged.

Python

python
# wrap any OpenAI, Anthropic, or Gemini client
import metergraph
from openai import OpenAI

client = metergraph.wrap(OpenAI())

# attribution is automatic; @track pins a stable name
@metergraph.track
def summarize_invoice(invoice):
    return client.chat.completions.create(
        model="gpt-5.6-luna", messages=[...]
    )

Every call lands as yourapp.billing:summarize_invoice in the dashboard, priced from an effective-dated catalog.

TypeScript

typescript
import * as mg from "metergraph";
import OpenAI from "openai";

const openai = mg.wrap(new OpenAI());

// use track() for names that survive bundlers & minifiers
const summarizeInvoice = mg.track("billing.summarize_invoice", async (invoice) => {
  return openai.chat.completions.create({ model: "gpt-5.6-luna", messages: [...] });
});

Attribution & routes

Every call carries two labels: the function that made it, and the route (product surface) it belongs to.

Function attribution

  • Python is automatic: the SDK walks the stack at call time and attributes each LLM call to the nearest function under your app root. Decorate the key functions with @metergraph.track (or @metergraph.track("billing.summarize")) to pin an explicit, stable name.
  • TypeScript uses track("stable.name", fn). Stack parsing is unreliable under bundlers, so wrap every function that calls a wrapped client. Best-effort stack attribution is only the fallback.

Routes

route() groups calls by product surface, independent of which function made them. Use it to see cost per ticket-classifier across every function that touches that flow. It's also the unit the hosted engine shadow-tests against.

python / typescript
# Python
with metergraph.route("ticket-classifier"):
    client.chat.completions.create(...)

// TypeScript
mg.route("ticket-classifier", () => openai.chat.completions.create(...));

Providers & what's captured

The SDK intercepts the official provider SDKs. Calls made through anything else (raw requests/fetch, community wrappers) are not captured.

ProviderPython clientTypeScript client
OpenAIOpenAI() / AsyncOpenAI()new OpenAI()
AnthropicAnthropic() / AsyncAnthropic()new Anthropic()
Geminigenai.Client()new GoogleGenAI({})

Sync and async both work, streaming included. It intercepts the chat and generation calls (chat.completions.create, responses.create, messages.create, generate_content) plus their streaming and batch-result variants.

What a row contains, and what it never does

Captured: timestamp, function/module, route, provider, model, input/output/cache-read/cache-write/reasoning token counts, latency, TTFT, status, stream/batch flags, session id, a content-free structural template hash, tool-call names, tags, environment, SDK version.

Never stored by the open-source server: prompts, completions, tool-call arguments or results. Rows pass through a fixed column allowlist at ingest, so content can't reach the database.

Configuration

Everything is environment-variable driven. To configure in code instead, call metergraph.init(...) (Python) or mg.init({...}) (TypeScript) before the first wrap(). Explicit arguments win over env vars.

Environment variables (SDK)

VariableMeaningDefault
METERGRAPH_APP_TOKENPer-app token. Capture is off without it.unset → off
METERGRAPH_INGEST_URLWhere rows go. Set for self-hosting.hosted service
METERGRAPH_CAPTURE_TEXTOpt in to sending truncated content (hosted only).false
METERGRAPH_TEXT_MAX_BYTESTruncation cap per content field.100000
METERGRAPH_ENVEnvironment tag on every row (e.g. prod).unset
METERGRAPH_DISABLED1 is a hard kill switch, wins over everything.off
METERGRAPH_QUEUE_SIZEBounded writer queue; overflow drops and counts.2000
METERGRAPH_BATCH_SIZERows per delivery batch.100
METERGRAPH_FLUSH_SECONDSBackground flush interval (Py). TS: METERGRAPH_FLUSH_MS, default 5000.5

init() options

Same fields in both SDKs (Python snake_case, TypeScript camelCase): token, ingest_url/ingestUrl, capture_text/captureText, redact (a (text, kind) => text hook), app_root/appRoot, skip_frames/skipFrames, environment, disabled, plus queue/batch/flush tuning. TypeScript adds transport, configPollMs, and configHardTtlMs.

Serverless & flushing

Long-running servers and scripts need nothing extra. A background flusher delivers rows on its own. In serverless environments (AWS Lambda, Cloudflare Workers, Vercel) the runtime can freeze before delivery, so ensure the queue drains before returning. Pick one:

typescript
// A) wrap the handler; flushes automatically after it resolves
export const handler = mg.wrapHandler(async (event) => { ... });

// B) bind the platform's waitUntil once per request
mg.bindWaitUntil(ctx);

// C) await a flush before you return
await mg.flush();

Python exposes metergraph.flush(timeout=3.0) and metergraph.shutdown() for the same purpose.

Self-host the server

The server and dashboard are Apache-2.0 and run with one command. No API keys needed to try it.

bash
git clone https://github.com/PioneerSquareLabs/metergraph && cd metergraph
MG_TOKENS=dev-token docker compose up
# dashboard + ingest → http://localhost:8787 (enter the same token)

Then point the SDK at it:

bash
export METERGRAPH_INGEST_URL=http://localhost:8787
export METERGRAPH_APP_TOKEN=dev-token

No traffic yet? Seed the dashboard with demo rows, no provider keys required: MG_TOKEN=dev-token python scripts/seed_demo.py.

Server configuration

VariableMeaning
MG_TOKENSComma-separated app tokens the server accepts.
MG_PRICES_PATHMount a newer price catalog without rebuilding.

Price catalog

Prices live in server/src/metergraph_server/prices.yaml, effective-dated so history reprices correctly when a model's price changes. To update: close the old window with effective_to, add a new entry with effective_from and a source_url, and open a PR. Self-hosters can mount a newer file with MG_PRICES_PATH.

Content-blindness

Content-blindness is enforced in the code, not just promised.

  • The SDK sends usage metadata only. By default, prompts and completions stay in your process.
  • The open-source server strips every content field at ingest through a column allowlist, so content can't reach the database no matter what the SDK sends.
  • capture_text / captureText is an explicit opt-in that only has any effect against the hosted service, and only when the workspace has separately enabled content capture. It's inert on free/self-hosted workspaces.

Hosted engine & MCP

Point the same SDK at the hosted tier (leave METERGRAPH_INGEST_URL unset) and capture becomes a standing loop: it fingerprints traffic into routes, watches for spend and reliability anomalies, shadow-replays sampled production inputs against challenger models on your own keys, scores quality with confidence intervals, and opens evidence-backed pull requests. It never sits in your request path.

Route config & canaries

The SDK polls a dynamic-config flag with feature-flag semantics. Use model_for() / modelFor() to let a route pick incumbent vs. challenger per session, and record_outcome() / recordOutcome() to feed real outcomes back for scoring. Enforcement uses that flag too, so nothing gets proxied.

python
model = metergraph.model_for("ticket-classifier", default="gpt-5.6-luna", session_key=user_id)
resp = client.chat.completions.create(model=model, messages=[...])
metergraph.record_outcome("ticket-classifier", model=model, task_completed=True, session_key=user_id)

Query routes, traces & scorecards over MCP

The hosted tier ships a read-only stdio MCP server so your coding agent can query everything (never prompts or outputs). It exposes three tools:

  • metergraph_list_routes lists your routes with their constraints, eval contracts, call counts, and replay-eligible counts.
  • metergraph_query_traces returns content-free trace metadata (route, model, tokens, cost, latency, outcome, unit economics), filtered by route, status, days, or limit.
  • metergraph_list_scorecards returns evidence summaries, confidence intervals, cost and latency deltas, judge reliability, and recommendation status.

Run it with two environment variables, METERGRAPH_URL and METERGRAPH_AGENT_TOKEN. See the connect prompt below.

Prompts for coding agents

The fastest way to instrument a codebase is to hand the work to your agent. Paste these into Claude Code, Codex, Cursor, or whatever you use, from inside the repo you want instrumented. Each is self-contained.

1. Instrument this codebase

The main one. It finds every provider client, wraps it in place, and wires up attribution and config without touching a single call site.

Agent prompt
Instrument this codebase's LLM API costs with Metergraph
(https://github.com/PioneerSquareLabs/metergraphsdk). It captures per-call
token usage (in/out, cached, reasoning), latency, and model, attributed to the
application function that made the call. Metadata only: no prompt or
completion content.

1. Install the SDK. Python: `pip install metergraph` (or the project's
   installer: poetry/uv/pipenv). TypeScript/JavaScript: add the `metergraph`
   package with whatever package manager this repo already uses, detected from
   the lockfile (package-lock.json -> npm, pnpm-lock.yaml -> pnpm, yarn.lock ->
   yarn, bun.lockb -> bun). Do not introduce a new package manager. Zero
   runtime dependencies.
2. Find every place an OpenAI, Anthropic, or Google Gemini client is
   constructed (OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(),
   genai.Client(), new OpenAI(), new Anthropic(), new GoogleGenAI()) and wrap
   it in place:
   - Python: `client = metergraph.wrap(OpenAI())` after `import metergraph`
   - TypeScript: `const client = mg.wrap(new OpenAI())` after
     `import * as mg from "metergraph"`
   wrap() returns the same client and initializes itself from the environment.
   Do not change any call sites, arguments, or error handling; streaming and
   async work unchanged.
3. Configuration is env-var based: METERGRAPH_APP_TOKEN is required (capture
   is silently off without it), and METERGRAPH_INGEST_URL is only needed when
   self-hosting the server from https://github.com/PioneerSquareLabs/metergraph.
   Add both to .env.example or the deployment config; never commit a real
   token.
4. Attribution:
   - Python: automatic via stack walk. Optionally decorate key LLM-calling
     functions with @metergraph.track to pin a stable name.
   - TypeScript: wrap each LLM-calling function with
     mg.track("stable.name", fn), because stack-based attribution is
     unreliable under bundlers. Do this for every function that calls a
     wrapped client.
5. Serverless only (Lambda / Cloudflare Workers / Vercel): ensure delivery
   before the runtime freezes. Wrap handlers with mg.wrapHandler(handler),
   or call mg.bindWaitUntil(ctx) once per request, or await mg.flush() before
   returning. Long-running servers and scripts need nothing extra.
6. The SDK is fail-open: transport problems never break or slow LLM calls, so
   do not add defensive try/except around wrapping or the wrapped calls.

When done, list every client you wrapped and where, and flag any LLM calls
made through libraries other than the official openai / anthropic /
@google/genai (google-genai) SDKs, since those are not captured.

2. Group calls into routes

Run this after instrumenting, once you know which product surfaces you care about. Routes are the unit the hosted engine shadow-tests.

Agent prompt
This repo is already instrumented with Metergraph (metergraph.wrap on the
provider clients). Now add ROUTE grouping so cost rolls up by product surface,
not just by function.

- A route is a recurring call site / product flow, e.g. "ticket-classifier",
  "rag-answers", "report-writer".
- Identify the top-level entry points that drive LLM calls and wrap their
  LLM-calling region in a route:
    Python:     `with metergraph.route("ticket-classifier"): ...`
    TypeScript: `mg.route("ticket-classifier", () => ...)`
- Use short, stable, kebab-case names. Reuse the same name everywhere a flow
  runs, even across different functions.
- Do not change model choice, arguments, or control flow. Only add the route
  wrapper around existing calls.

When done, list the routes you created and which entry points map to each.

3. Stand up the self-hosted dashboard

Agent prompt
Set up a local self-hosted Metergraph server + dashboard so I can see this
app's LLM cost by function.

1. Clone https://github.com/PioneerSquareLabs/metergraph and start it:
   `MG_TOKENS=dev-token docker compose up`. It serves the ingest API and the
   dashboard on http://localhost:8787 (log in with the same token).
2. Point this app's SDK at it by setting, in the app's env / .env:
     METERGRAPH_INGEST_URL=http://localhost:8787
     METERGRAPH_APP_TOKEN=dev-token
3. If there's no live traffic yet, seed demo rows (no API keys needed):
   `MG_TOKEN=dev-token python scripts/seed_demo.py` from the server repo.
4. Verify: run the app so it makes at least one LLM call, then confirm the
   app and its functions appear in the dashboard's picker.

Report the exact commands you ran and the URL to open. Do not commit the
dev token.

4. Audit capture coverage

Catches the calls that slip past the SDK: raw HTTP, community wrappers, and unwrapped clients.

Agent prompt
Audit this repo's Metergraph capture coverage. Metergraph only captures LLM
calls made through the official openai, anthropic, and @google/genai
(google-genai) SDK clients AFTER those clients have been passed through
metergraph.wrap().

Find and report, as a checklist with file:line:
1. Every provider client construction, and whether it is wrapped.
2. Any LLM calls that bypass the official SDKs: raw requests/httpx/fetch to
   api.openai.com / api.anthropic.com / generativelanguage.googleapis.com,
   LangChain / LlamaIndex / litellm / other wrappers, or Bedrock/Vertex
   SDKs. These are NOT captured, so flag each one.
3. TypeScript only: LLM-calling functions not wrapped in mg.track(), since
   stack attribution is unreliable under bundlers.
4. Serverless handlers that call LLMs without mg.wrapHandler / bindWaitUntil /
   an awaited flush(). Rows can be lost when the runtime freezes.

Do not change any code. Just produce the coverage report and a prioritized
list of gaps.

5. Connect the hosted MCP server to your agent

Gives your agent read-only access to your routes, traces, and scorecards. It's content-free and can't see prompts or outputs.

Agent prompt
Add the Metergraph MCP server to my coding-agent config so I can query my
LLM routes, trace metadata, and evaluation scorecards. It is a read-only
stdio MCP server and never returns prompts or completions.

- Command: run the Metergraph MCP entrypoint over stdio.
- Required environment variables:
    METERGRAPH_URL         = the Metergraph API base URL (the hosted origin)
    METERGRAPH_AGENT_TOKEN = my agent bearer token (keep it out of source)
- It exposes three tools:
    metergraph_list_routes      (no args)
    metergraph_query_traces     (route?, status?, days?, limit?)
    metergraph_list_scorecards  (route?, limit?)

Wire this into the MCP servers section of my agent's config (e.g. Claude Code
`.mcp.json` / `claude mcp add`, or the equivalent), passing the two env vars.
Then call metergraph_list_routes to confirm the connection works, and show me
the result.

Troubleshooting

Nothing shows up in the dashboard

Capture is off unless METERGRAPH_APP_TOKEN is set, and the SDK never sends without one. Confirm the token, and that self-hosting sets METERGRAPH_INGEST_URL to your server. Check for a METERGRAPH_DISABLED=1 kill switch. Rows arrive asynchronously; in scripts and serverless, flush before exit.

A call site isn't being tracked

Only the official openai / anthropic / @google/genai clients are intercepted, and only after wrap(). Calls through raw HTTP or third-party wrappers aren't captured, so run the coverage-audit prompt above.

Function names look wrong (TypeScript)

Bundlers rename functions, so stack attribution is unreliable. Wrap each LLM-calling function in mg.track("stable.name", fn).

Is any of this in my request path?

No. Capture is asynchronous and fail-open; an outage on the collector can't touch your traffic. Hosted canary and promotion ride a polled config flag with feature-flag semantics, and nothing is proxied.

Still stuck? Open an issue on GitHub, or sign in to your dashboard for workspace-specific help.