Skip to content

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 metergraph
pip install "metergraph[otel]" # only if you export OTLP spans

Source: github.com/PioneerSquareLabs/metergraphsdk.

Everything in metergraph.__all__, plus one optional submodule.

SymbolKindOne line
initfunctionConfigure capture. Idempotent, never raises.
wrapfunctionPatch a provider client so its calls are captured.
trackdecorator and context managerName the calling function explicitly.
routedecorator and context managerName the unit of work, and optionally its business unit.
tracedecorator and context managerGroup calls under one logical trace.
contextdecorator and context managerSet session ID and tags for a scope.
sessionfunctioncontext(session_id=...) in one word.
tagsfunctioncontext(tags=...) in one word.
set_sessionfunctionSet the session ID on the scope you are already in.
set_tagsfunctionMerge tags into the scope you are already in.
set_default_tagsfunctionProcess-wide tags inherited by new scopes.
wrap_executorfunctionPropagate context across a thread pool.
model_forfunctionSticky per-session model selection.
record_outcomefunctionSend a content-free outcome event.
flushfunctionBlock until the queue drains, or the timeout expires.
shutdownfunctionFlush and stop every background thread.
batch_firstfunctionOpt-in Batch API execution with a direct fallback.
BatchFirstResultdataclassWhat batch_first() returns.
BatchFirstMetadatadataclassHow it got there.
LateBatchInfodataclassTelemetry about a batch that lost the race.
BatchFirstIneligibleErrorexceptionRaised before any provider call.
DEFAULT_INGEST_URLconstantThe hosted ingest base URL.
metergraph.opentelemetrysubmoduleGenAI 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,
) -> None

Every 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 required once and returns without setting the initialized flag, so a later init() that does supply a token still works.
  • disabled is terminal. disabled=True or METERGRAPH_DISABLED=1 sets the initialized flag and returns. No later call re-enables capture.
  • It registers atexit. On success shutdown() is registered with atexit, which is enough for a normal interpreter exit but not for os._exit(), a signal that bypasses handlers, or a container kill.
  • Repository identity. repository, then METERGRAPH_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) -> Any

Patches 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.

ArgumentTypeDefaultAccepted values
providerstr | Noneauto-detected"openai", "anthropic", "google", or a Vercel AI Gateway alias: "vercel", "gateway", "vercel-ai-gateway"
gatewaystr | Noneauto-detected"openrouter" only. Requires an OpenAI-compatible client, and provider= may only be "openai"

Detection, when you pass nothing:

ConditionResult
client.models.generate_content existsgoogle
client.chat or client.responses existsopenai
otherwiseanthropic
base_url host is exactly ai-gateway.vercel.shVercel 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 clientopenrouter 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:

ProviderPatchedEndpoint recorded
OpenAIchat.completions.createchat.completions
OpenAIchat.completions.parsechat.completions.parse
OpenAIbeta.chat.completions.parsechat.completions.parse
OpenAIresponses.create, beta.responses.createresponses
OpenAIresponses.streamresponses.stream
OpenAIresponses.parseresponses.parse
OpenAIfiles.content, files.retrieve_contentBatch API result reading
Anthropicmessages.createmessages
Anthropicmessages.streammessages.stream
Anthropicmessages.batches.results, beta.messages.batches.resultsBatch API result reading
Googlemodels.generate_content, aio.models.generate_contentmodels.generate_content
Googlemodels.generate_content_stream, aio.models.generate_content_streammodels.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: ....

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 form

Overrides 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.

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.

metergraph.session(session_id: str | None) -> context

Shorthand for context(session_id=session_id).

metergraph.tags(**values: Any) -> context

Shorthand for context(tags=values).

metergraph.set_session(session_id: str | None) -> None

Sets 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().

metergraph.set_tags(**tags: Any) -> None

Merges 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.

metergraph.set_default_tags(**values: Any) -> None

Replaces 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.

metergraph.wrap_executor(executor: Executor) -> Executor

Patches 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.

metergraph.model_for(route_name: str, *, default: str, session_key: str | None = None) -> str

Returns 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.

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,
) -> bool

Enqueues 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.

ArgumentTypeValidation
route_namestrTrimmed, truncated to 512 characters, must be non-empty
modelstrTrimmed, truncated to 512 characters, must be non-empty
task_completedboolMust be a real bool, not a truthy value
session_keystr | NoneFalls back to the scope’s session ID. Trimmed to 512 characters, must be non-empty
feedback_scorefloat | NoneFinite, -1 <= x <= 1
turns_to_resolutionint | NoneA real int, not bool. 1 <= x <= 1000000
escalatedbool | NoneMust be a real bool
abandonedbool | NoneMust be a real bool
edit_distance_ratiofloat | NoneFinite, 0 <= x <= 1
regeneration_countint | NoneA real int, not bool. 0 <= x <= 1000000
event_idstr | NoneDefaults to a UUID4. Trimmed to 128 characters

The row carries event_type: "outcome" and a UTC ISO-8601 ts. See Record real outcomes.

metergraph.flush(timeout: float = 3.0) -> bool

Signals 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.

metergraph.shutdown() -> None

Stops 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.

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,
) -> BatchFirstResult

Submits 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:

ConditionMessage fragment
accept_duplicate_provider_execution is not exactly Truerequires accept_duplicate_provider_execution=True
deadline_seconds is not a positive numberrequires a positive deadline_seconds
request["stream"] is Truedoes not support streaming requests
The request has tools and allow_duplicate_tool_call_plans is not Truerequires allow_duplicate_tool_call_plans=True for requests with tools
No adapter for providerhas no adapter for provider "..." yet
The adapter rejects the requestrequest is not eligible for batch-first execution:

A submission failure raises out directly. It is not a fallback trigger, because no batch was created.

@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: bool

late_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.

metergraph.DEFAULT_INGEST_URL # "https://d2xus7mp8zdv6t.cloudfront.net"

The hosted ingest base URL, used when neither ingest_url nor METERGRAPH_INGEST_URL is set.

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:

KeyMeaning
scopeFiltered out by include_scopes/exclude_scopes
not-genaiNot a GenAI span at all, the normal case on a shared tracer provider
ineligible-kindAn expected non-LLM observation
no-modelAn eligible GenAI span with no model attribute. This one also logs a rate-limited warning
parse-degradedNot a skip. Spans that captured despite malformed JSON in an attribute

See OTLP spans.

Worth knowing, because it explains rows that never arrive.

PropertyValue
QueueBounded, METERGRAPH_QUEUE_SIZE rows. Full means drop, never block
Delivery threadOne daemon thread, named metergraph-writer
Config threadOne daemon thread, named metergraph-config
Compressiongzip once the JSON body passes 32 KiB
Max request body4 MiB. A larger batch is split in half and retried, down to a single row, which is dropped
Retry401 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 safetyos.register_at_fork gives the child a clean queue and a new thread. Rows pending in the parent stay with the parent
Self-reportingEvery request carries meta.dropped and meta.transport_errors