API reference
Full reference for spanly(), wrapFetchHandler(), and the options they accept.
The TypeScript SDK exposes two bindings, spanly() for Node HTTP
frameworks and wrapFetchHandler() for Fetch-shaped handlers, plus the
shared option types and the wire-level packet schemas both bindings
send.
spanly(options)
import express from 'express';
import { spanly } from '@spanly/sdk';
const app = express();
app.use(spanly({ apiKey: process.env.SPANLY_API_KEY }));Returns Node HTTP middleware (req, res, next?) => void. Works with
Express, Koa, or a bare http.createServer handler, anything that
calls (req, res, next?).
Mount this before compression(): once a response carries a
Content-Encoding header, the bytes reaching the middleware are
already compressed, so capture falls back to status and headers only,
with no request or response body.
Works whether it is mounted before or after a JSON body parser like
express.json(). Mounted before it, capture tees the raw request
stream without consuming it, so the parser downstream still sees every
byte. Mounted after it, capture reads the already-parsed req.body
instead of the drained stream.
SpanlyMiddlewareOptions
Extends CaptureEngineOptions with:
| Option | Type | Required | Description |
|---|---|---|---|
paths | string[] | no | Path prefixes to capture, matched against the URL pathname. Requests outside every prefix pass through with zero engine involvement. Defaults to ['/mcp', '/sse']. |
wrapFetchHandler(handler, options)
import { Hono } from 'hono';
import { wrapFetchHandler } from '@spanly/sdk';
const app = new Hono();
export default {
fetch: wrapFetchHandler(app.fetch.bind(app), {
apiKey: process.env.SPANLY_API_KEY,
}),
};Wraps a Fetch-shaped handler (req: Request, ctx?) => Response | Promise<Response>: Hono, Next.js route handlers, Deno, or a
Cloudflare Workers fetch export. The response body is teed with
ReadableStream.tee(), so the caller always gets byte-identical
status, headers, and body.
WrapFetchHandlerOptions
Extends CaptureEngineOptions with:
| Option | Type | Required | Description |
|---|---|---|---|
paths | string[] | no | Path prefixes to inspect, matched against the request pathname. Requests outside these prefixes bypass the engine entirely. Defaults to ['/mcp', '/sse']. |
waitUntil | (promise: Promise<unknown>) => void | no | Registers a promise to keep running after the response is returned. When omitted, a function-valued ctx.waitUntil on the second handler argument is used automatically (Cloudflare Workers style). When neither is available, capture falls back to a keepalive fetch instead. |
See Serverless delivery for the Vercel, Cloudflare Workers, and AWS Lambda specifics.
CaptureEngineOptions
Shared by both bindings.
| Option | Type | Required | Description |
|---|---|---|---|
apiKey | string | no | Your Spanly API key. Falls back to the SPANLY_API_KEY environment variable. Region (us / eu) is auto-detected from the prefix. |
ingestUrl | string | ((region: 'us' | 'eu') => string) | no | Override the ingest endpoint. Useful for tests (point at a local capture server) or custom routing. Defaults to https://ingest.us.spanly.com / https://ingest.eu.spanly.com by region. |
maxCollectAttempts | number | no | Retry budget for /collect posts answered with 503. Defaults to the SPANLY_COLLECT_MAX_ATTEMPTS environment variable, then 4. |
redactHeaders | string[] | no | Additional header names to redact from captured transport context, on top of DEFAULT_REDACTED_HEADERS. Case-insensitive. |
onError | (error: Error) => void | no | Called when capture itself fails (malformed header, a body that will not stringify, a failed ingest post). Never affects the request or response your app serves. |
onWarning | (warnings: CollectWarning[]) => void | no | Called with warnings the ingest endpoint returns for an accepted packet. |
fetchInit | RequestInit | no | Merged into every /collect fetch call. The seam a binding uses to add transport-specific options (for example wrapFetchHandler sets keepalive: true when no waitUntil is available). |
identity | IdentityOptions | no | End-user attribution: a resolve callback, JWT claim decoding, a hosted resolver, and/or the bearer-token fingerprint. See Identity below. |
sessionIdleTimeoutMs | number | no | Idle gap after which a synthetic session (see Session tracking) is considered ended and a new one starts. Defaults to 30 minutes. Only relevant when a transaction carries no real Mcp-Session-Id request header. |
contextHeaders | Record<string, 'projectId' | 'environmentId' | 'organisationId'> | no | Multi-tenant context tagging: maps request header names (case-insensitive) onto packet context fields, the same mechanism as the CLI's --context-header flag. |
The constructor throws if no API key is available, or if the key does
not start with spanly_us_ or spanly_eu_. A throwing engine never
propagates into either binding: the failure is routed to onError
instead.
Identity
Attribute captured traffic to an end user with one (or more) of:
resolve callback
app.use(
spanly({
apiKey: process.env.SPANLY_API_KEY,
identity: {
resolve: ({ headers }) => lookupUserFromSessionCookie(headers['cookie']),
},
}),
);JWT claims
app.use(
spanly({
apiKey: process.env.SPANLY_API_KEY,
identity: { jwtClaims: true },
}),
);jwtClaims: true decodes the bearer token with the default claim
mapping (sub for id, email, name). Pass an object to select
custom dot-path claims: { jwtClaims: { id: 'user.id', email: 'user.email' } }. The token is decoded only, never
signature-verified: your server has already authenticated the request.
Hosted resolver
app.use(
spanly({
apiKey: process.env.SPANLY_API_KEY,
identity: {
resolver: {
url: 'https://internal.example.com/spanly/resolve',
secret: process.env.SPANLY_RESOLVER_SECRET,
},
},
}),
);Spanly POSTs { token, mcpSessionId } to your resolver and caches
results per token (15 minutes on a hit, 60 seconds on a miss).
IdentityOptions
| Option | Type | Required | Description |
|---|---|---|---|
resolve | ResolveCallback | no | Exclusive when set: jwtClaims and resolver are ignored entirely. Receives { headers, mcpSessionId? } (raw, pre-redaction headers). |
jwtClaims | true | JwtClaimMapping | no | Decode the end user straight from a bearer JWT's claims, no network call. Tried before resolver, but only wins when it actually yields a user. |
resolver | { url, secret?, timeoutMs? } | no | POSTs the bearer token to a customer-hosted identity resolver. Header X-Spanly-Identity-Secret is sent when secret is set. Default timeout 3000 ms, no retries. |
fingerprint | boolean | no | Attach the truncated sha256 fingerprint of the bearer token to every packet of a transaction. Defaults to true, independent of the other options. |
Precedence: resolve (when set) is exclusive; otherwise a jwtClaims
decode that actually yields a user wins; otherwise the resolver
result. The bearer token fingerprint is attached independently of all
three, so traffic under the same credential can still be correlated
even when no user resolves. See
Multi-tenant attribution
for a worked example using accountId.
Environment variables
| Variable | Description |
|---|---|
SPANLY_API_KEY | Used when no apiKey option is passed. |
SPANLY_COLLECT_MAX_ATTEMPTS | Max delivery attempts per packet when ingest responds 503 (default 4). |
Types and the wire schema
The package re-exports the capture engine, the packet schemas, and their inferred types:
import {
CaptureEngine,
MAX_INSPECT_BYTES,
SYNTHETIC_SESSION_ID_PREFIX,
DEFAULT_REDACTED_HEADERS,
spanlyPacketSchema,
spanlyPacketContextSchema,
spanlyPacketTransportContextSchema,
spanlyUserSchema,
} from '@spanly/sdk';
import type {
CaptureEngineOptions,
CaptureTransaction,
BeginTransactionRequest,
IdentityOptions,
CollectWarning,
SpanlyRegion,
SpanlyPacket,
SpanlyPacketContext,
SpanlyPacketTransportContext,
SpanlyPacketTransportContextHttp,
SpanlyPacketTransportContextStdio,
SpanlyPacketOversized,
SpanlyUser,
McpPacket,
} from '@spanly/sdk';CaptureEngine: the framework-free capture engine both bindings wrap. Exported so test code can drive it directly; see Tests: capture locally withingestUrl.SpanlyPacket: the envelope sent to ingest, includingcontext,transportContext,mcpPacket, and the optionaluserandauthTokenFingerprintfields.SpanlyPacketContext:spanlyClientIdandspanlyMonitorIdidentify the process and the transaction;projectId,environmentId, andorganisationIdare set by ingest, not by SDK options.SpanlyPacketTransportContext: transport metadata. For HTTP: method, path, headers, remote address and port, status code, and the dual-era fields (mcpProtocolVersion,mcpMethod,mcpName,mcpParamHeaders). For stdio: just the transport type (the TS SDK itself never runs over stdio; this variant exists for wire compatibility with the CLI).SpanlyUser: the shapeidentityresolves to (id, plus optionalemail,name,accountId,accountName).McpPacket: the captured JSON-RPC request, response, or notification. Only thejsonrpcfield is typed statically; everything else passes through as the raw JSON-RPC payload.MAX_INSPECT_BYTES: 16 MiB. Bodies larger than this are forwarded untouched; only their firstMAX_INSPECT_BYTESare buffered for inspection, and the packet'soversized.originalSizefield carries the true wire size.SYNTHETIC_SESSION_ID_PREFIX:'spanly-', the prefix on synthetic session ids the sessionizer mints. See Session tracking.
Trace context propagation
If your infrastructure propagates a W3C traceparent value, it
survives untouched in the captured packet: HTTP headers are forwarded
as captured except for the credential headers on the redact list, and
traceparent is not one of them, so it comes through in
transportContext.headers verbatim. The same holds for a
traceparent a client embeds in params._meta on the JSON-RPC
message itself, since mcpPacket is captured as-is. Pick your APM
provider in the dashboard (Settings, Integrations) and the request
detail view renders a cross-link to the matching trace in Datadog,
Sentry, or New Relic.
There is nothing to configure in the SDK for this: it is a consequence
of not stripping any header beyond the redact list, not a dedicated
tracing feature. If you don't already propagate traceparent through
your stack, the cross-link is simply omitted.
What the SDK does not do
- It never sends a response, mutates a header, or otherwise alters
the bytes your server exchanges with its caller. Unlike the Python
SDK and the CLI, the TypeScript middleware never injects a
synthetic
Mcp-Session-Idon the wire: it groups sessionless traffic for telemetry only (see Session tracking), and there is noinjectSessionIdoption because there is nothing for it to toggle. - It does not offer a hook to drop or rewrite an individual packet
before it is sent. Every packet the engine parses is delivered;
scope what is captured at all with the
pathsoption, or attribute it to a user withidentityinstead. - It does not block your server's request handling. Delivery to
ingest happens asynchronously; failures surface through
onError, never as exceptions in your request path. - It does not intercept traffic outside the configured
paths. If your MCP server is not reachable under/mcpor/sse, pass a matchingpathslist.