Skip to content

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 metergraph

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

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 required once and returns without setting the initialized flag, so a later init() with a token still works.
  • disabled is terminal. disabled: true or METERGRAPH_DISABLED=1 sets the flag and returns.
  • No atexit equivalent. Node does not give the SDK a reliable exit hook, so call shutdown() or flush() yourself in a script or a short-lived job.
  • Repository identity. repository, then METERGRAPH_REPOSITORY, then an upward walk for .metergraph/config.json.
function wrap<T>(client: T, provider?: "openai" | "anthropic" | "google"): T
function 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
Google models.generateContent models.generate_content
Google 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.

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.

Context lives in an AsyncLocalStorage, so it follows await naturally and needs no executor wrapper.

function track<T extends Function>(fn: T): T
function track<T extends Function>(name: string, fn: T): T

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

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.

function withSession<T>(sessionId: string, fn: () => T): T
function withTags<T>(tags: Record<string, unknown>, fn: () => T): T
function setSession(sessionId?: string): void

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

function setTags(tags: Record<string, unknown>): void

Merges tags into the scope you are already inside. Same rule and an equivalent warning naming setTags(), withTags() and withContext().

function setDefaultTags(tags: Record<string, unknown>): void

Replaces the process-wide default tag set, inherited by scopes entered afterwards and overridden key by key by scope-level tags.

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.

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 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 3000

Returns true when the queue is empty afterwards, false on timeout. Resolves true immediately when the SDK is not initialized.

function shutdown(): Promise<void>

Stops the config poller, clears the flush timer, flushes, and clears the runtime. Safe to call twice.

function bindWaitUntil(
value: { waitUntil(promise: Promise<unknown>): void } | ((p: Promise<unknown>) => void),
): void

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

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.

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.

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.

mg.DEFAULT_INGEST_URL // "https://d2xus7mp8zdv6t.cloudfront.net"
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