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:
| Package | Registry | What it is |
|---|---|---|
| metergraph | PyPI | Python capture SDK, zero runtime deps |
| metergraph | npm | TypeScript / JavaScript capture SDK, zero runtime deps |
| metergraph-server | GitHub | FastAPI + Postgres ingest, price catalog, usage API |
| dashboard | GitHub | React 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
pip install metergraph
export METERGRAPH_APP_TOKEN=<token>
TypeScript / JavaScript
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
# 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
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
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.
| Provider | Python client | TypeScript client |
|---|---|---|
| OpenAI | OpenAI() / AsyncOpenAI() | new OpenAI() |
| Anthropic | Anthropic() / AsyncAnthropic() | new Anthropic() |
| Gemini | genai.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)
| Variable | Meaning | Default |
|---|---|---|
| METERGRAPH_APP_TOKEN | Per-app token. Capture is off without it. | unset → off |
| METERGRAPH_INGEST_URL | Where rows go. Set for self-hosting. | hosted service |
| METERGRAPH_CAPTURE_TEXT | Opt in to sending truncated content (hosted only). | false |
| METERGRAPH_TEXT_MAX_BYTES | Truncation cap per content field. | 100000 |
| METERGRAPH_ENV | Environment tag on every row (e.g. prod). | unset |
| METERGRAPH_DISABLED | 1 is a hard kill switch, wins over everything. | off |
| METERGRAPH_QUEUE_SIZE | Bounded writer queue; overflow drops and counts. | 2000 |
| METERGRAPH_BATCH_SIZE | Rows per delivery batch. | 100 |
| METERGRAPH_FLUSH_SECONDS | Background 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:
// 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.
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:
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
| Variable | Meaning |
|---|---|
| MG_TOKENS | Comma-separated app tokens the server accepts. |
| MG_PRICES_PATH | Mount 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/captureTextis 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.
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_routeslists your routes with their constraints, eval contracts, call counts, and replay-eligible counts.metergraph_query_tracesreturns content-free trace metadata (route, model, tokens, cost, latency, outcome, unit economics), filtered byroute,status,days, orlimit.metergraph_list_scorecardsreturns 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.
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.
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
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.
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.
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.