Spanly Docs

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)

OptionTypeDefaultDescription
api_keystr | NoneNoneYour Spanly API key. Falls back to SPANLY_API_KEY. Region (us / eu) is auto-detected from the prefix.
ingest_urlstr | Callable[[SpanlyRegion], str] | NoneNoneOverride the ingest endpoint. Useful for tests (point at a local capture server) or custom routing.
pathsSequence[str]("/mcp", "/sse")Path prefixes to capture, matched against the request path. Requests outside every prefix pass through with zero engine involvement.
redact_headersSequence[str] | NoneNoneAdditional header names to redact from captured transport context, on top of DEFAULT_REDACTED_HEADERS. Case-insensitive.
on_errorCallable[[Exception], None] | NoneNoneCalled when capture itself fails. When omitted, failures are logged through the spanly logger instead. Never affects the request or response your app serves.
on_warningCallable[[list[CollectWarning]], None] | NoneNoneCalled with warnings the ingest endpoint returns for an accepted packet.
inject_session_idboolTrueInjects 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_attemptsint | NoneNoneRetry budget for /collect posts answered with 503. Defaults to the SPANLY_COLLECT_MAX_ATTEMPTS environment variable, then 4.
identityIdentityOptions | NoneNoneEnd-user attribution: a resolve callable, JWT claim decoding, a hosted resolver, and/or the bearer-token fingerprint. See Identity below.
session_idle_timeout_secondsfloat1800.0Idle 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_headersMapping[str, str] | NoneNoneMulti-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

FieldTypeDefaultDescription
resolveResolveCallback | NoneNoneExclusive when set: jwt_claims and resolver are ignored entirely. Receives a ResolveContext(headers, mcp_session_id) (raw, pre-redaction headers).
jwt_claimsbool | JwtClaimMapping | NoneNoneDecode 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.
resolverResolverOptions | NoneNonePOSTs the bearer token to a customer-hosted identity resolver.
fingerprintboolTrueAttach the truncated sha256 fingerprint of the bearer token to every packet of a transaction, independent of the other options.

ResolverOptions

FieldTypeDefaultDescription
urlstrrequiredThe resolver endpoint Spanly POSTs to.
secretstr | NoneNoneSent as the X-Spanly-Identity-Secret header.
timeout_secondsfloat3.0Request 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

VariableDescription
SPANLY_API_KEYUsed when no api_key argument is passed.
SPANLY_COLLECT_MAX_ATTEMPTSMax 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_id and spanly_monitor_id identify the process and the transaction; project_id, environment_id, and organisation_id are 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. StdioTransportContext exists for wire compatibility with the CLI; the Python SDK itself only runs over ASGI.
  • SpanlyUser: the shape identity resolves to (id, plus optional email, 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 with identity instead.
  • 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_id response header on sessionless initialize responses (and answering a synthetic session's DELETE itself), it does not modify the bodies or headers your server returns.

On this page