API reference
Full reference for SpanlyMiddleware and the options it accepts.
The Python SDK exposes a single ASGI3 middleware class,
SpanlyMiddleware, plus the supporting dataclasses for identity and
the wire-level packet types.
SpanlyMiddleware
import os
from spanly import SpanlyMiddleware
app.add_middleware(SpanlyMiddleware, api_key=os.environ["SPANLY_API_KEY"])add_middleware defers construction until the app builds its
middleware stack, so SpanlyMiddleware receives your ASGI app as its
first positional argument automatically. Wrap a bare ASGI app directly
instead when your framework has no add_middleware:
app = SpanlyMiddleware(app, api_key=os.environ["SPANLY_API_KEY"])SpanlyMiddleware(app, **options)
| Option | Type | Default | Description |
|---|---|---|---|
api_key | str | None | None | Your Spanly API key. Falls back to SPANLY_API_KEY. Region (us / eu) is auto-detected from the prefix. |
ingest_url | str | Callable[[SpanlyRegion], str] | None | None | Override the ingest endpoint. Useful for tests (point at a local capture server) or custom routing. |
paths | Sequence[str] | ("/mcp", "/sse") | Path prefixes to capture, matched against the request path. Requests outside every prefix pass through with zero engine involvement. |
redact_headers | Sequence[str] | None | None | Additional header names to redact from captured transport context, on top of DEFAULT_REDACTED_HEADERS. Case-insensitive. |
on_error | Callable[[Exception], None] | None | None | Called when capture itself fails. When omitted, failures are logged through the spanly logger instead. Never affects the request or response your app serves. |
on_warning | Callable[[list[CollectWarning]], None] | None | None | Called with warnings the ingest endpoint returns for an accepted packet. |
inject_session_id | bool | True | Injects a synthetic Mcp-Session-Id response header (prefixed spanly-) on sessionless initialize responses, and owns the DELETE handshake that terminates a synthetic session. A real, on-the-wire header. See Session tracking. |
max_collect_attempts | int | None | None | Retry budget for /collect posts answered with 503. Defaults to the SPANLY_COLLECT_MAX_ATTEMPTS environment variable, then 4. |
identity | IdentityOptions | None | None | End-user attribution: a resolve callable, JWT claim decoding, a hosted resolver, and/or the bearer-token fingerprint. See Identity below. |
session_idle_timeout_seconds | float | 1800.0 | Idle gap after which a synthetic session (see Session tracking) is considered ended and a new one starts. Only relevant when a transaction carries no real Mcp-Session-Id request header. |
context_headers | Mapping[str, str] | None | None | Multi-tenant context tagging: maps request header names (case-insensitive) onto the packet context fields project_id, environment_id or organisation_id, the same mechanism as the CLI's --context-header flag. |
Identity
Attribute captured traffic to an end user with one (or more) of:
resolve callback
from spanly import IdentityOptions, ResolveContext, SpanlyMiddleware
def resolve(ctx: ResolveContext):
return lookup_user_from_session_cookie(ctx.headers.get("cookie"))
app.add_middleware(
SpanlyMiddleware,
api_key=os.environ["SPANLY_API_KEY"],
identity=IdentityOptions(resolve=resolve),
)JWT claims
from spanly import IdentityOptions, SpanlyMiddleware
app.add_middleware(
SpanlyMiddleware,
api_key=os.environ["SPANLY_API_KEY"],
identity=IdentityOptions(jwt_claims=True),
)jwt_claims=True decodes the bearer token with the default claim
mapping (sub for id, email, name). Pass a dict to select custom
dot-path claims: IdentityOptions(jwt_claims={"id": "user.id", "email": "user.email"}). The token is decoded only, never signature-verified:
your server has already authenticated the request.
Hosted resolver
from spanly import IdentityOptions, ResolverOptions, SpanlyMiddleware
app.add_middleware(
SpanlyMiddleware,
api_key=os.environ["SPANLY_API_KEY"],
identity=IdentityOptions(
resolver=ResolverOptions(
url="https://internal.example.com/spanly/resolve",
secret=os.environ["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
| Field | Type | Default | Description |
|---|---|---|---|
resolve | ResolveCallback | None | None | Exclusive when set: jwt_claims and resolver are ignored entirely. Receives a ResolveContext(headers, mcp_session_id) (raw, pre-redaction headers). |
jwt_claims | bool | JwtClaimMapping | None | None | 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 | ResolverOptions | None | None | POSTs the bearer token to a customer-hosted identity resolver. |
fingerprint | bool | True | Attach the truncated sha256 fingerprint of the bearer token to every packet of a transaction, independent of the other options. |
ResolverOptions
| Field | Type | Default | Description |
|---|---|---|---|
url | str | required | The resolver endpoint Spanly POSTs to. |
secret | str | None | None | Sent as the X-Spanly-Identity-Secret header. |
timeout_seconds | float | 3.0 | Request timeout. No retries. |
Precedence: resolve (when set) is exclusive; otherwise a jwt_claims
decode that actually yields a user wins; otherwise the resolver
result. The bearer token fingerprint is attached independently of all
three. See
Multi-tenant attribution
for a worked example using account_id.
Environment variables
| Variable | Description |
|---|---|
SPANLY_API_KEY | Used when no api_key argument is passed. |
SPANLY_COLLECT_MAX_ATTEMPTS | Max delivery attempts per packet when ingest responds 503 (default 4). |
Types
The package re-exports the types you'd want for typed hooks:
from spanly import (
SpanlyMiddleware,
SpanlyPacket,
SpanlyPacketContext,
HttpTransportContext,
StdioTransportContext,
TransportContext,
DEFAULT_REDACTED_HEADERS,
SESSION_TERMINATED_METHOD,
SYNTHETIC_SESSION_ID_PREFIX,
IdentityOptions,
ResolverOptions,
ResolveContext,
SpanlyUser,
)SpanlyPacket: the envelope sent to ingest.SpanlyPacketContext:spanly_client_idandspanly_monitor_ididentify the process and the transaction;project_id,environment_id, andorganisation_idare set by ingest, not by middleware options.HttpTransportContext/StdioTransportContext: transport metadata. HTTP carries method, path, headers, remote address and port, status code, and the dual-era fields.StdioTransportContextexists for wire compatibility with the CLI; the Python SDK itself only runs over ASGI.SpanlyUser: the shapeidentityresolves to (id, plus optionalemail,name,accountId,accountName).SYNTHETIC_SESSION_ID_PREFIX:'spanly-', the prefix on synthetic session ids. 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 the
transport context's headers verbatim. The same holds for a
traceparent a client embeds in params._meta on the JSON-RPC
message itself, since the packet 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 does not offer a hook to drop or rewrite an individual packet
before it is sent. Every packet the middleware parses is delivered;
scope what is captured at all with
paths, or attribute it to a user withidentityinstead. - It does not block your server on its network call to ingest. Each
packet is delivered on a background task; delivery failures surface
through
on_error, never as exceptions in your request path. - Aside from the
inject_session_idresponse header on sessionless initialize responses (and answering a synthetic session's DELETE itself), it does not modify the bodies or headers your server returns.