Spanly Docs

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:

OptionTypeRequiredDescription
pathsstring[]noPath 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:

OptionTypeRequiredDescription
pathsstring[]noPath prefixes to inspect, matched against the request pathname. Requests outside these prefixes bypass the engine entirely. Defaults to ['/mcp', '/sse'].
waitUntil(promise: Promise<unknown>) => voidnoRegisters 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.

OptionTypeRequiredDescription
apiKeystringnoYour Spanly API key. Falls back to the SPANLY_API_KEY environment variable. Region (us / eu) is auto-detected from the prefix.
ingestUrlstring | ((region: 'us' | 'eu') => string)noOverride 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.
maxCollectAttemptsnumbernoRetry budget for /collect posts answered with 503. Defaults to the SPANLY_COLLECT_MAX_ATTEMPTS environment variable, then 4.
redactHeadersstring[]noAdditional header names to redact from captured transport context, on top of DEFAULT_REDACTED_HEADERS. Case-insensitive.
onError(error: Error) => voidnoCalled 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[]) => voidnoCalled with warnings the ingest endpoint returns for an accepted packet.
fetchInitRequestInitnoMerged 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).
identityIdentityOptionsnoEnd-user attribution: a resolve callback, JWT claim decoding, a hosted resolver, and/or the bearer-token fingerprint. See Identity below.
sessionIdleTimeoutMsnumbernoIdle 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.
contextHeadersRecord<string, 'projectId' | 'environmentId' | 'organisationId'>noMulti-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

OptionTypeRequiredDescription
resolveResolveCallbacknoExclusive when set: jwtClaims and resolver are ignored entirely. Receives { headers, mcpSessionId? } (raw, pre-redaction headers).
jwtClaimstrue | JwtClaimMappingnoDecode 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? }noPOSTs 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.
fingerprintbooleannoAttach 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

VariableDescription
SPANLY_API_KEYUsed when no apiKey option is passed.
SPANLY_COLLECT_MAX_ATTEMPTSMax 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 with ingestUrl.
  • SpanlyPacket: the envelope sent to ingest, including context, transportContext, mcpPacket, and the optional user and authTokenFingerprint fields.
  • SpanlyPacketContext: spanlyClientId and spanlyMonitorId identify the process and the transaction; projectId, environmentId, and organisationId are 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 shape identity resolves to (id, plus optional email, name, accountId, accountName).
  • McpPacket: the captured JSON-RPC request, response, or notification. Only the jsonrpc field 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 first MAX_INSPECT_BYTES are buffered for inspection, and the packet's oversized.originalSize field 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-Id on the wire: it groups sessionless traffic for telemetry only (see Session tracking), and there is no injectSessionId option 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 paths option, or attribute it to a user with identity instead.
  • 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 /mcp or /sse, pass a matching paths list.

On this page