TypeScript SDK
Package metergraph on npm. Apache-2.0. Requires Node 18 or later and has no
runtime dependencies. It ships ESM and CJS builds plus type declarations.
npm install metergraphEvery provider package is an optional peer dependency, so you install only the
ones you already use: openai@>=4 <8, @anthropic-ai/sdk@>=0.30,
@google/genai@>=1, ai@>=5 <8.
Source: github.com/PioneerSquareLabs/metergraphsdk.
The public surface
Section titled “The public surface”| Symbol | Kind | One line |
|---|---|---|
init | function | Configure capture. Idempotent, never throws. |
wrap | function | Patch a provider client so its calls are captured. |
wrapClient | alias | Same function as wrap. |
vercelAISDKMiddleware | function | Capture through the Vercel AI SDK middleware API. |
track | function | Name the calling function explicitly. |
route | function | Name the unit of work, and optionally its business unit. |
trace | function | Group calls under one logical trace. |
withContext | function | Set session ID and tags for a scope. |
withSession | function | withContext({ sessionId }) in one word. |
withTags | function | withContext({ tags }) in one word. |
setSession | function | Set the session ID on the scope you are already in. |
setTags | function | Merge tags into the scope you are already in. |
setDefaultTags | function | Process-wide tags inherited by new scopes. |
modelFor | function | Sticky per-session model selection. |
recordOutcome | function | Send a content-free outcome event. |
flush | function | Await the queue draining, or the timeout. |
shutdown | function | Flush and stop every timer. |
bindWaitUntil | function | Hand delivery to a serverless runtime’s waitUntil. |
wrapHandler | function | Flush after a handler returns. |
batchFirst | function | Opt-in Batch API execution with a direct fallback. |
BatchFirstIneligibleError | class | Thrown before any provider call. |
DEFAULT_INGEST_URL | constant | The hosted ingest base URL. |
Exported types: MetergraphOptions, ModelForOptions, OutcomeOptions,
ContextOptions, RouteOptions, TraceOptions, TransportMode, WrapOptions,
VercelAISDKMiddleware, VercelAISDKMiddlewareOptions,
VercelAISDKSpecificationVersion, BatchFirstMetadata, BatchFirstPolicy,
BatchFirstProvider, BatchFirstRequest, BatchFirstResult,
BatchFirstSource, LateBatchInfo.
function init(options?: MetergraphOptions): void
interface MetergraphOptions { token?: string; ingestUrl?: string; captureText?: boolean; redact?: (text: string, kind: "request" | "response") => string; appRoot?: string; repository?: string; skipFrames?: string[]; environment?: string; disabled?: boolean; transport?: "auto" | "background" | "buffered"; queueSize?: number; batchSize?: number; flushMs?: number; configPollMs?: number; configHardTtlMs?: number; textMaxBytes?: number;}See Configuration for every default, range and clamp.
Returns void. Throws nothing. Failure is a console.warn and an
uninstrumented process.
- Idempotent. Later calls warn once with
Metergraph init() was called more than once; the first configuration remains active.and return. - No token means not initialized. It warns
Metergraph capture disabled: token and ingest URL are requiredonce and returns without setting the initialized flag, so a laterinit()with a token still works. disabledis terminal.disabled: trueorMETERGRAPH_DISABLED=1sets the flag and returns.- No
atexitequivalent. Node does not give the SDK a reliable exit hook, so callshutdown()orflush()yourself in a script or a short-lived job. - Repository identity.
repository, thenMETERGRAPH_REPOSITORY, then an upward walk for.metergraph/config.json.
function wrap<T>(client: T, provider?: "openai" | "anthropic" | "google"): Tfunction wrap<T>(client: T, options: WrapOptions): T
type WrapOptions = | { provider?: "openai" | "anthropic" | "google"; gateway?: never } | { provider?: "openai"; gateway: "openrouter" };Patches the supported methods in place and returns the same object. Calls
init() first if you have not. wrapClient is an alias.
Returns the client. Always assign it: const client = mg.wrap(new OpenAI()).
Throws Error 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 and the client comes back unmodified.
Detection, when you pass nothing:
| Condition | Result |
|---|---|
client.models?.generateContent exists | google |
client.chat or client.responses exists | openai |
| otherwise | anthropic |
baseURL host is exactly openrouter.ai over HTTPS, on an OpenAI client | openrouter gateway evidence, provider stays openai |
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 (openai v4 only) | chat.completions.parse |
| OpenAI | responses.create, beta.responses.create | responses |
| OpenAI | responses.stream | responses.stream |
| OpenAI | responses.parse | responses.parse |
| Anthropic | messages.create | messages |
| Anthropic | messages.stream | messages.stream |
models.generateContent | models.generate_content | |
models.generateContentStream | models.generate_content.stream |
A seam that does not exist on your installed client version is skipped silently.
The @google/genai JS client has no .aio namespace, unlike the Python client,
so there is nothing extra to patch there.
vercelAISDKMiddleware
Section titled “vercelAISDKMiddleware”function vercelAISDKMiddleware( options: MetergraphOptions & { aiSdkVersion: 5 },): VercelAISDKMiddleware<"v2">;
function vercelAISDKMiddleware<TVersion extends "v2" | "v3" | "v4" = "v3">( options?: MetergraphOptions & { specificationVersion?: TVersion },): VercelAISDKMiddleware<TVersion>;Returns a LanguageModelMiddleware you pass to wrapLanguageModel. Calls
init(options) when no runtime exists yet, so the same object carries both the
Metergraph options and the version selector. The returned middleware has no
runtime dependency on the ai package.
| Option | Use it when |
|---|---|
aiSdkVersion: 5 | You are on AI SDK 5. Maps to middleware protocol v2. |
specificationVersion | Advanced. AI SDK 6 uses v3, and AI SDK 7 accepts v3 for compatibility. Defaults to "v3". |
The two are mutually exclusive at the type level.
This is also the Vercel AI Gateway path. The middleware resolves a
gateway/vercel/vercel-ai-gateway provider to the creator in the
creator/model identifier, so a gateway call is attributed to the real
publisher. See Vercel AI SDK and
Vercel AI Gateway.
Attribution
Section titled “Attribution”Context lives in an AsyncLocalStorage, so it follows await naturally and
needs no executor wrapper.
function track<T extends Function>(fn: T): Tfunction track<T extends Function>(name: string, fn: T): TReturns a wrapped function that runs its body with funcName set. Without a
name it uses fn.name, or "<anonymous>".
Throws TypeError("track requires a function") if the second argument is not
a function.
function route<T>( name: string, fn: () => T | Promise<T>, options?: RouteOptions,): Promise<T>
interface RouteOptions { unit?: string; unitCount?: number; tags?: Record<string, unknown>; captureText?: boolean;}Sets route on every call inside fn. When unit is given and unitCount is
not, unitCount defaults to 1. Always returns a promise, even for a
synchronous body.
captureText: false narrows content capture for the scope. It cannot widen it.
function trace<T>( name: string, fn: () => T | Promise<T>, options?: TraceOptions,): T | Promise<T>
interface TraceOptions { traceId?: string; parentSpanId?: string; captureText?: boolean;}Entering a trace() inside an existing trace reuses the outer traceId and
trace name unless you pass a different traceId. With no active trace and no
traceId, one is generated as 16 random bytes in hex. Returns whatever fn
returns, without forcing a promise.
withContext
Section titled “withContext”function withContext<T>(options: ContextOptions, fn: () => T): T
interface ContextOptions { sessionId?: string; tags?: Record<string, unknown>;}Tag values are coerced with String(). Tags merge with the parent scope, inner
wins.
withSession
Section titled “withSession”function withSession<T>(sessionId: string, fn: () => T): TwithTags
Section titled “withTags”function withTags<T>(tags: Record<string, unknown>, fn: () => T): TsetSession
Section titled “setSession”function setSession(sessionId?: string): voidSets the session ID on the scope you are already inside, using
AsyncLocalStorage.enterWith. Outside a Metergraph scope it warns once with
Metergraph setSession() requires an active Metergraph context; use withSession() or withContext(). and does nothing.
setTags
Section titled “setTags”function setTags(tags: Record<string, unknown>): voidMerges tags into the scope you are already inside. Same rule and an equivalent
warning naming setTags(), withTags() and withContext().
setDefaultTags
Section titled “setDefaultTags”function setDefaultTags(tags: Record<string, unknown>): voidReplaces the process-wide default tag set, inherited by scopes entered afterwards and overridden key by key by scope-level tags.
Canaries and outcomes
Section titled “Canaries and outcomes”modelFor
Section titled “modelFor”function modelFor(routeName: string, options: ModelForOptions): string
interface ModelForOptions { default: string; sessionKey?: string;}Returns options.default on every failure path. Throws
TypeError("modelFor requires options.default to be a non-empty string") when
default is missing or empty. This is the one attribution-adjacent function in
the SDK that throws on bad input rather than degrading.
recordOutcome
Section titled “recordOutcome”function recordOutcome(routeName: string, options: OutcomeOptions): boolean
interface OutcomeOptions { model: string; taskCompleted: boolean; sessionKey?: string; feedbackScore?: number; turnsToResolution?: number; escalated?: boolean; abandoned?: boolean; editDistanceRatio?: number; regenerationCount?: number; eventId?: string;}Returns true if queued, false if rejected or the transport is not
running. Never throws, and every rejection is silent, so check the return value
while you wire it up.
| Field | Validation |
|---|---|
routeName | Trimmed, sliced to 512 characters, must be non-empty |
model | Trimmed, sliced to 512 characters, must be non-empty |
taskCompleted | typeof === "boolean" |
sessionKey | Falls back to the scope’s session ID. Trimmed to 512 characters, must be non-empty |
feedbackScore | Finite, -1 <= x <= 1 |
turnsToResolution | Number.isInteger, 1 <= x <= 1000000 |
escalated | typeof === "boolean" |
abandoned | typeof === "boolean" |
editDistanceRatio | Finite, 0 <= x <= 1 |
regenerationCount | Number.isInteger, 0 <= x <= 1000000 |
eventId | Defaults to crypto.randomUUID(), with a timestamp-plus-random fallback. Sliced to 128 characters |
Delivery
Section titled “Delivery”Delivery mode is the thing that most often explains missing rows in serverless code.
| Mode | When it is chosen | Behavior |
|---|---|---|
background | Default on a long-lived process | A setInterval timer, unref’d so it does not hold the event loop open |
buffered | Auto-selected when AWS_LAMBDA_FUNCTION_NAME or VERCEL is set, or navigator.userAgent is exactly Cloudflare-Workers | No timer. Every enqueue schedules a delivery, handed to waitUntil when one is bound |
auto | The default value of transport | Picks one of the two above |
function flush(timeoutMs?: number): Promise<boolean> // default 3000Returns true when the queue is empty afterwards, false on timeout.
Resolves true immediately when the SDK is not initialized.
shutdown
Section titled “shutdown”function shutdown(): Promise<void>Stops the config poller, clears the flush timer, flushes, and clears the runtime. Safe to call twice.
bindWaitUntil
Section titled “bindWaitUntil”function bindWaitUntil( value: { waitUntil(promise: Promise<unknown>): void } | ((p: Promise<unknown>) => void),): voidHands each scheduled delivery to the platform’s waitUntil, so the runtime keeps
the invocation alive until the request completes. Accepts either the context
object or the bare function. A no-op when the SDK is not initialized.
wrapHandler
Section titled “wrapHandler”function wrapHandler<TArgs extends unknown[], TResult>( handler: (...args: TArgs) => TResult | Promise<TResult>,): (...args: TArgs) => Promise<TResult>Returns a handler that awaits flush() in a finally block, so the queue drains
whether the handler resolved or threw, and the original outcome is preserved. Use
it where you have no waitUntil. See
Serverless and short-lived jobs.
Batch-first execution
Section titled “Batch-first execution”batchFirst
Section titled “batchFirst”function batchFirst<TResult = unknown>( client: unknown, provider: "openai" | "anthropic" | "google", request: BatchFirstRequest, policy: BatchFirstPolicy,): Promise<BatchFirstResult<TResult>>
interface BatchFirstPolicy { deadlineMs: number; acceptDuplicateProviderExecution: true; allowDuplicateToolCallPlans?: boolean; onLateBatchSettled?: (info: LateBatchInfo) => void;}Submits one request through the provider’s Batch API, waits up to deadlineMs,
and falls back to a single direct call if the batch has not finished. Never
enabled by wrap(), by capture defaults, or by an environment variable.
Throws BatchFirstIneligibleError before any provider call when:
| Condition | Message fragment |
|---|---|
acceptDuplicateProviderExecution is not exactly true | requires policy.acceptDuplicateProviderExecution: true |
deadlineMs is not a positive finite number | requires a positive policy.deadlineMs |
request.stream === true | does not support streaming requests |
The request has tools and allowDuplicateToolCallPlans is not true | requires policy.allowDuplicateToolCallPlans: true for requests with tools |
No adapter for provider | has no adapter for provider "..." |
| The adapter rejects the request | request is not eligible for batch-first execution: |
A submission failure rejects directly. It is not a fallback trigger, because no batch was created.
Batch-first types
Section titled “Batch-first types”type BatchFirstSource = "batch" | "direct";
interface BatchFirstResult<TResult = unknown> { source: BatchFirstSource; result: TResult; metadata: BatchFirstMetadata;}
interface BatchFirstMetadata { execution_mode: "batch_first"; deadline_ms: number; batch_wait_ms: number; batch_outcome: "completed" | "failed" | "expired" | "pending_at_deadline"; canonical_result: BatchFirstSource; duplicate_provider_execution: boolean; late_batch_completed: boolean; // always false at return time late_batch_contained_tool_call_plan: boolean; // always false at return time}
interface LateBatchInfo { outcome: "completed" | "failed" | "expired"; contained_tool_call_plan: boolean;}The metadata field names are snake_case, and the units are milliseconds, which is
where this differs from the Python dataclasses. A batch that settles after the
fallback reaches you only through onLateBatchSettled, whose exceptions are
swallowed. A batch that reports completed but whose result cannot be read is
treated exactly like failed.
Constants
Section titled “Constants”DEFAULT_INGEST_URL
Section titled “DEFAULT_INGEST_URL”mg.DEFAULT_INGEST_URL // "https://d2xus7mp8zdv6t.cloudfront.net"Transport behavior
Section titled “Transport behavior”| Property | Value |
|---|---|
| Queue | An array bounded at queueSize. Full means drop, never block |
| Compression | gzip via CompressionStream once the JSON body passes 32 KiB. Where CompressionStream is absent the body is sent uncompressed |
| Max request body | 4 MiB. A larger batch is split in half and retried, down to a single row, which is dropped |
keepalive | Set on buffered-mode requests up to 64 KiB |
| 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 |
| Self-reporting | Every request carries meta.dropped and meta.transport_errors |