Python SDK
Package metergraph on PyPI. Apache-2.0. Requires Python 3.10 or later and has
no runtime dependencies. The OpenTelemetry exporter is behind the otel extra.
pip install metergraphpip install "metergraph[otel]" # only if you export OTLP spansSource: github.com/PioneerSquareLabs/metergraphsdk.
The public surface
Section titled “The public surface”Everything in metergraph.__all__, plus one optional submodule.
| Symbol | Kind | One line |
|---|---|---|
init | function | Configure capture. Idempotent, never raises. |
wrap | function | Patch a provider client so its calls are captured. |
track | decorator and context manager | Name the calling function explicitly. |
route | decorator and context manager | Name the unit of work, and optionally its business unit. |
trace | decorator and context manager | Group calls under one logical trace. |
context | decorator and context manager | Set session ID and tags for a scope. |
session | function | context(session_id=...) in one word. |
tags | function | context(tags=...) in one word. |
set_session | function | Set the session ID on the scope you are already in. |
set_tags | function | Merge tags into the scope you are already in. |
set_default_tags | function | Process-wide tags inherited by new scopes. |
wrap_executor | function | Propagate context across a thread pool. |
model_for | function | Sticky per-session model selection. |
record_outcome | function | Send a content-free outcome event. |
flush | function | Block until the queue drains, or the timeout expires. |
shutdown | function | Flush and stop every background thread. |
batch_first | function | Opt-in Batch API execution with a direct fallback. |
BatchFirstResult | dataclass | What batch_first() returns. |
BatchFirstMetadata | dataclass | How it got there. |
LateBatchInfo | dataclass | Telemetry about a batch that lost the race. |
BatchFirstIneligibleError | exception | Raised before any provider call. |
DEFAULT_INGEST_URL | constant | The hosted ingest base URL. |
metergraph.opentelemetry | submodule | GenAI span exporter. |
__version__ is also importable and matches the package version.
metergraph.init( *, token: str | None = None, ingest_url: str | None = None, capture_text: bool | None = None, redact: Callable[[str, str], str] | None = None, app_root: str | None = None, repository: str | None = None, skip_frames: list[str] | None = None, environment: str | None = None, disabled: bool | None = None, text_max_bytes: int | None = None,) -> NoneEvery argument is keyword-only and every one has an environment-variable equivalent. See Configuration for types, defaults and clamping.
Returns None. Raises nothing, ever. Failure is a log line on the
metergraph logger and an uninstrumented process.
Behavior worth knowing:
- Idempotent. The second and later calls log
Metergraph init() was called more than once; the first configuration remains active.once, then return. - No token means not initialized. Without a token it logs
Metergraph capture disabled: token and ingest URL are requiredonce and returns without setting the initialized flag, so a laterinit()that does supply a token still works. disabledis terminal.disabled=TrueorMETERGRAPH_DISABLED=1sets the initialized flag and returns. No later call re-enables capture.- It registers
atexit. On successshutdown()is registered withatexit, which is enough for a normal interpreter exit but not foros._exit(), a signal that bypasses handlers, or a container kill. - Repository identity.
repository, thenMETERGRAPH_REPOSITORY, then an upward walk for.metergraph/config.json(schema version 2, up to 64 levels). When none is found it logs the “repository identity is not configured” warning once and falls back to legacy app-token ingestion.
metergraph.wrap(client: Any, *, provider: str | None = None, gateway: str | None = None) -> AnyPatches the supported methods on a provider client in place, and returns the
same object. Calls init() first if you have not.
Returns the client. Always assign it: client = metergraph.wrap(OpenAI()).
Raises ValueError for an unsupported or contradictory gateway/provider
pair, before any provider call and without echoing your URL or credential. Every
other failure is fail-open: the client comes back unmodified and uninstrumented,
with Metergraph wrap() failed; client is unmodified and uninstrumented logged.
| Argument | Type | Default | Accepted values |
|---|---|---|---|
provider | str | None | auto-detected | "openai", "anthropic", "google", or a Vercel AI Gateway alias: "vercel", "gateway", "vercel-ai-gateway" |
gateway | str | None | auto-detected | "openrouter" only. Requires an OpenAI-compatible client, and provider= may only be "openai" |
Detection, when you pass nothing:
| Condition | Result |
|---|---|
client.models.generate_content exists | google |
client.chat or client.responses exists | openai |
| otherwise | anthropic |
base_url host is exactly ai-gateway.vercel.sh | Vercel AI Gateway handling, provider taken from the creator/model prefix on each request |
base_url host is exactly openrouter.ai over HTTPS, on an OpenAI client | openrouter gateway evidence, provider stays openai |
Host matching is exact, on a parsed HTTPS URL. A path such as /api/v1 does not
affect it, and substrings never match.
The methods it patches:
| Provider | Patched | Endpoint recorded |
|---|---|---|
| OpenAI | chat.completions.create | chat.completions |
| OpenAI | chat.completions.parse | chat.completions.parse |
| OpenAI | beta.chat.completions.parse | chat.completions.parse |
| OpenAI | responses.create, beta.responses.create | responses |
| OpenAI | responses.stream | responses.stream |
| OpenAI | responses.parse | responses.parse |
| OpenAI | files.content, files.retrieve_content | Batch API result reading |
| Anthropic | messages.create | messages |
| Anthropic | messages.stream | messages.stream |
| Anthropic | messages.batches.results, beta.messages.batches.results | Batch API result reading |
models.generate_content, aio.models.generate_content | models.generate_content | |
models.generate_content_stream, aio.models.generate_content_stream | models.generate_content.stream |
A seam that does not exist on your installed client version is skipped silently.
If nothing at all matched you get
Metergraph found no supported methods on <label> client. On success it logs at
INFO: Metergraph patched N seam(s) on <label> client: ....
Attribution
Section titled “Attribution”Every scope below is both a context manager and a decorator, and each decorator
handles async def correctly. Scopes nest, and nested values merge rather than
replace: tags are merged key by key, with the inner scope winning.
metergraph.track(name: str | None = None, *, module: str | None = None)metergraph.track(fn) # bare decorator formOverrides the stack walk that would otherwise decide func and module on the
row. Used bare as @metergraph.track, the name becomes
f"{fn.__module__}:{fn.__qualname__}" and the module becomes fn.__module__.
Reach for this when your function names are unstable, or when the calling frame is inside a library and the stack walk cannot see your code.
metergraph.route( name: str, *, unit: str | None = None, unit_count: float | None = None, tags: Mapping[str, Any] | None = None, capture_text: bool | None = None,)Sets route on every call inside the scope. unit names the business unit this
work produces (“ticket”, “document”) and unit_count says how many. When unit
is given and unit_count is not, unit_count defaults to 1.0.
capture_text=False narrows content capture for the scope. It cannot widen it
past the process-wide setting.
A call with no route is not lost. The server clusters it as
template:<template_hash>, using the content-free structural hash of the
request.
metergraph.trace( name: str, *, trace_id: str | None = None, parent_span_id: str | None = None, capture_text: bool | None = None,)Groups calls under one trace_id. Entering a trace() inside an existing trace
reuses the outer trace_id and trace_name unless you pass a different
trace_id, in which case a new trace starts. With no active trace and no
trace_id, one is generated as 16 random bytes in hex.
Pass parent_span_id to attach the trace under a span your own tracing system
already created.
context
Section titled “context”metergraph.context(*, session_id: str | None = None, tags: Mapping[str, Any] | None = None)Sets both session ID and tags in one scope. Tag keys and values are coerced to
str.
session
Section titled “session”metergraph.session(session_id: str | None) -> contextShorthand for context(session_id=session_id).
metergraph.tags(**values: Any) -> contextShorthand for context(tags=values).
set_session
Section titled “set_session”metergraph.set_session(session_id: str | None) -> NoneSets the session ID on the scope you are already inside. It requires an
active Metergraph scope, because outside one there is nothing to mutate that a
later call would read. Called at depth zero it logs, once,
metergraph.set_session() requires an active Metergraph context; use metergraph.context() or metergraph.session(). and does nothing.
Use it when the session ID only becomes known partway through a request that is
already inside a route() or context().
set_tags
Section titled “set_tags”metergraph.set_tags(**tags: Any) -> NoneMerges tags into the scope you are already inside. Same depth-zero rule and
warning as set_session(), with metergraph.set_tags() and
metergraph.tags() named instead.
set_default_tags
Section titled “set_default_tags”metergraph.set_default_tags(**values: Any) -> NoneReplaces the process-wide default tag set. These are inherited by scopes entered afterwards, and are overridden key by key by any scope-level tag. Calling it again replaces the whole set rather than merging.
wrap_executor
Section titled “wrap_executor”metergraph.wrap_executor(executor: Executor) -> ExecutorPatches executor.submit so each submission runs inside a copy of the caller’s
context. Without it, work handed to a thread pool loses its route, session and
tags. Idempotent: wrapping twice is a no-op. Returns the same executor object.
asyncio tasks and async def code need no equivalent, because the SDK stores
context in a ContextVar.
Canaries and outcomes
Section titled “Canaries and outcomes”model_for
Section titled “model_for”metergraph.model_for(route_name: str, *, default: str, session_key: str | None = None) -> strReturns a model ID for this route, sticky per session. session_key falls back
to the current scope’s session ID.
Returns default on every failure path: no config poller, a config document
older than the hard TTL, no route entry, enabled: false, no challenger, no
session key, or an unparseable traffic percentage.
record_outcome
Section titled “record_outcome”metergraph.record_outcome( route_name: str, *, model: str, task_completed: bool, session_key: str | None = None, feedback_score: float | None = None, turns_to_resolution: int | None = None, escalated: bool | None = None, abandoned: bool | None = None, edit_distance_ratio: float | None = None, regeneration_count: int | None = None, event_id: str | None = None,) -> boolEnqueues a content-free outcome event. Never touches the request path.
Returns True if the event was queued, False if it was rejected or the
writer is not running. It never raises, and every rejection is silent, so check
the return value while you are wiring it up.
| Argument | Type | Validation |
|---|---|---|
route_name | str | Trimmed, truncated to 512 characters, must be non-empty |
model | str | Trimmed, truncated to 512 characters, must be non-empty |
task_completed | bool | Must be a real bool, not a truthy value |
session_key | str | None | Falls back to the scope’s session ID. Trimmed to 512 characters, must be non-empty |
feedback_score | float | None | Finite, -1 <= x <= 1 |
turns_to_resolution | int | None | A real int, not bool. 1 <= x <= 1000000 |
escalated | bool | None | Must be a real bool |
abandoned | bool | None | Must be a real bool |
edit_distance_ratio | float | None | Finite, 0 <= x <= 1 |
regeneration_count | int | None | A real int, not bool. 0 <= x <= 1000000 |
event_id | str | None | Defaults to a UUID4. Trimmed to 128 characters |
The row carries event_type: "outcome" and a UTC ISO-8601 ts. See
Record real outcomes.
Delivery
Section titled “Delivery”metergraph.flush(timeout: float = 3.0) -> boolSignals the writer to send immediately and polls until the queue has no unfinished tasks or the timeout expires.
Returns True when the queue drained, False on timeout. Returns True
immediately when the SDK is not initialized, so a flush() in shared code is
safe.
shutdown
Section titled “shutdown”metergraph.shutdown() -> NoneStops the config poller, flushes with a 3 second timeout, stops the writer
thread, stops the session manager, and clears the runtime. Registered with
atexit by a successful init(), so you rarely call it yourself. Call it
explicitly before os._exit() or in a short-lived job. Safe to call twice.
Batch-first execution
Section titled “Batch-first execution”batch_first
Section titled “batch_first”metergraph.batch_first( client: Any, provider: str, request: Mapping[str, Any], *, deadline_seconds: float, accept_duplicate_provider_execution: bool, allow_duplicate_tool_call_plans: bool = False, on_late_batch_settled: Callable[[LateBatchInfo], None] | None = None,) -> BatchFirstResultSubmits one request through the provider’s Batch API, waits up to
deadline_seconds, and falls back to a single direct call if the batch has not
finished. Synchronous and blocking. Never enabled by wrap(), by capture
defaults, or by an environment variable.
provider is required and explicit. Supported values are "openai",
"anthropic" and "google". Async clients (AsyncOpenAI, AsyncAnthropic,
google-genai’s .aio namespace) are not supported by the current adapters.
Raises BatchFirstIneligibleError before any provider call when:
| Condition | Message fragment |
|---|---|
accept_duplicate_provider_execution is not exactly True | requires accept_duplicate_provider_execution=True |
deadline_seconds is not a positive number | requires a positive deadline_seconds |
request["stream"] is True | does not support streaming requests |
The request has tools and allow_duplicate_tool_call_plans is not True | requires allow_duplicate_tool_call_plans=True for requests with tools |
No adapter for provider | has no adapter for provider "..." yet |
| The adapter rejects the request | request is not eligible for batch-first execution: |
A submission failure raises out directly. It is not a fallback trigger, because no batch was created.
Batch-first types
Section titled “Batch-first types”@dataclass(frozen=True)class BatchFirstResult: source: str # "batch" | "direct" result: Any # the provider response metadata: BatchFirstMetadata
@dataclass(frozen=True)class BatchFirstMetadata: execution_mode: str # always "batch_first" deadline_seconds: float batch_wait_seconds: float batch_outcome: str # "completed" | "failed" | "expired" | "pending_at_deadline" canonical_result: str # "batch" | "direct" duplicate_provider_execution: bool late_batch_completed: bool # always False at return time late_batch_contained_tool_call_plan: bool # always False at return time
@dataclass(frozen=True)class LateBatchInfo: outcome: str # "completed" | "failed" | "expired" contained_tool_call_plan: boollate_batch_completed is False on every returned object by construction. A
batch that settles after the fallback reaches you only through
on_late_batch_settled, which runs on a daemon thread and whose exceptions are
swallowed. Its result content is never returned and never executed. A batch that
reports completed but whose result cannot be read is treated exactly like
failed: one direct fallback, no retry, no exception.
Constants
Section titled “Constants”DEFAULT_INGEST_URL
Section titled “DEFAULT_INGEST_URL”metergraph.DEFAULT_INGEST_URL # "https://d2xus7mp8zdv6t.cloudfront.net"The hosted ingest base URL, used when neither ingest_url nor
METERGRAPH_INGEST_URL is set.
OpenTelemetry exporter
Section titled “OpenTelemetry exporter”from metergraph.opentelemetry import MetergraphGenAIExporter
MetergraphGenAIExporter( include_scopes: Iterable[str] | None = None, exclude_scopes: Iterable[str] | None = None,)A SpanExporter that turns completed GenAI spans into Metergraph rows over the
same transport. Requires the otel extra. The constructor calls
metergraph.init().
Filtering is on span.instrumentation_scope.name. exclude_scopes always wins.
When include_scopes is set, only the listed scopes pass.
export() always returns SpanExportResult.SUCCESS, so a Metergraph problem can
never break your OTel pipeline.
The public skipped dict counts spans that produced no row:
| Key | Meaning |
|---|---|
scope | Filtered out by include_scopes/exclude_scopes |
not-genai | Not a GenAI span at all, the normal case on a shared tracer provider |
ineligible-kind | An expected non-LLM observation |
no-model | An eligible GenAI span with no model attribute. This one also logs a rate-limited warning |
parse-degraded | Not a skip. Spans that captured despite malformed JSON in an attribute |
See OTLP spans.
Transport behavior
Section titled “Transport behavior”Worth knowing, because it explains rows that never arrive.
| Property | Value |
|---|---|
| Queue | Bounded, METERGRAPH_QUEUE_SIZE rows. Full means drop, never block |
| Delivery thread | One daemon thread, named metergraph-writer |
| Config thread | One daemon thread, named metergraph-config |
| Compression | gzip once the JSON body passes 32 KiB |
| Max request body | 4 MiB. A larger batch is split in half and retried, down to a single row, which is dropped |
| Retry | 401 and 403 invalidate a session token, or disable capture for the process when there is no session. 413 splits the batch. 400, 404, 413 and 422 drop the batch. Everything else backs off from 1 second, doubling to 60 |
| Fork safety | os.register_at_fork gives the child a clean queue and a new thread. Rows pending in the parent stay with the parent |
| Self-reporting | Every request carries meta.dropped and meta.transport_errors |