SIGN IN SIGN UP

feat: span for outbound A2A calls, emitted at the call site (#14520)

* feat: spans for MCP tool calls, emitted at the call site

An MCP tool call was invisible to the operator's APM, so a flow that spends
its time in a slow tool looked like a slow flow with no explanation.

Not done by instrumenting httpx, which is the obvious route and does not
work. MCP runs over stdio as well as SSE and streamable-http, and stdio is a
subprocess over stdin and stdout that makes no HTTP requests at all, so httpx
instrumentation would have missed the transport most local MCP servers use.
It would also have produced one span per outbound LLM provider call, because
httpx is the transport those SDKs ride on and they instrument it globally
against whichever provider is global. The export filter cannot separate those
from ours: it allowlists by instrumentation scope name and both carry
opentelemetry.instrumentation.httpx.

Emitting the span ourselves under the application tracer makes the scope name
the discriminator by construction, and makes the span identical across all
three transports.

One span per logical tool call, retries included, carrying the tool name and
the transport. No arguments and no results: those carry flow data, and the
error attribute is the exception type rather than its message for the same
reason.

The test drives a real MCP server over real stdio rather than a stub, since
the stdio path is the whole reason for the approach.

* fix: report MCP tool failures, the real transport, and which server

Five things the span got wrong, found reviewing it.

A failed tool call exported as a success. MCP reports failure as isError on the
result rather than by raising, and the caller only converts that to an exception
after the span has closed, so the except arm never saw it and the outbound error
rate was zero however many tools were failing. The span now takes the outcome
from the result. Only the boolean crosses the boundary: the failure text a server
returns embeds the arguments it was called with.

The transport was a literal. MCPSseClient is an alias for the streamable-http
client and that client also falls back to SSE at runtime, so every legacy-SSE
server reported streamable_http. Read from the session manager, which already
records which transport the session actually used.

No server on the span, so two servers exposing a tool of the same name were
indistinguishable, which is the question the attributes exist to answer. Host for
HTTP, command plus first argument for stdio. Never the URL, which can carry
credentials in its query string, and not the later argv, which can carry tokens.

A cancelled call ended UNSET and read as a success, because CancelledError is a
BaseException and the arm caught Exception. And error.type was ValueError for
every failure, because both retry loops wrap the real error before it escapes;
walking __cause__ recovers it without recording any message.

Tests drive the real flow_execution_span rather than a stand-in parent, and
serialize span events as well as attributes: a failing tool echoes its argument
back inside the error text, so the leak assertion was running against a shape
that could not hold what it was guarding.

The make_current=False path is left as a strict xfail. async_start opens the flow
span detached, so nothing downstream can see it and the tool span starts its own
trace. That flag is False deliberately, and carrying the span context out of band
is a design decision rather than a fix to fold in here.

* feat: span for outbound A2A calls, emitted at the call site

An A2A call left no trace in the operator's APM, so a flow that hangs or fails on a
remote agent looked like a flow that was simply slow. This is the other outbound call
the runtime makes itself, and it gets the same treatment as the MCP one: the span is
emitted at the call site under the application tracer, not by instrumenting httpx,
because httpx is the transport the LLM vendor SDKs ride on and the export filter can
only tell scopes apart by name.

The span covers the card resolve as well as the send, since both are part of the call
and either can fail. That means the agent name is only known partway through, so the
scope handed to the caller can now record an attribute mid-call.

Only the external path is instrumented. An internal agent runs a sub-flow locally and
already has its own flow span.

Identifiers only: the host and the card name, never the URL (its query string can carry
credentials) and never the message or the reply. A remote task ending in any
non-completed state raises, so the failure is recorded as an exception type by the
existing error arm, with no message attached.

The test drives a real A2A server over real HTTP with the real SDK client, in a
subprocess because the tracer provider is process-global. It lives in the langflow test
tree because the a2a SDK is a langflow-base dependency the lfx test env cannot import.

* [autofix.ci] apply automated fixes

* fix: omit the agent host attribute when the URL has none

httpx accepts a URL with no authority, and "agent.example.com/path" typed
without a scheme is exactly that, so raw_host comes back empty and the span
carried a2a.agent.host=''.

Omitted rather than set to a placeholder. An operator filters a dashboard on
this attribute, so an empty or invented value is worse than its absence, and
it is the rule protocol and client already follow: a missing attribute is an
honest 'nobody said' and a guessed one is a lie.

The span itself still happens, because the call was attempted and failed and
that is worth seeing. Measured before: {'a2a.agent.host': '',
'error.type': 'UnsupportedProtocol'}.

Found by CodeRabbit on this PR.

* fix: attribute the span to the host that served the call

a2a.agent.host came from the configured discovery URL, but a card names the
interface that serves the calls and this path deliberately supports one on
another origin. So a directory at one host and an agent at another had the
agent's latency and failures recorded against the directory, which is not a
smaller version of the truth, it is the wrong machine.

The call host is now read from the transport the SDK built, after
create_client has done its own interface selection. Reading it back rather
than reimplementing that selection on purpose: it weighs client preference
and protocol bindings and would drift from the SDK the moment either changes.
The transport's url is public; the attribute holding the transport is not, so
it degrades to the discovery host if the SDK rearranges itself.

The discovery host is kept as its own attribute rather than dropped. An
operator seeing both can tell a directory and its agent apart; seeing one
cannot tell whether they are the same machine.

Regression drives two real servers, the card on one port and the JSON-RPC it
points at on another, and asserts each attribute names its own port. It fails
without the fix. Loopback is allowlisted for that probe because an off-origin
hop goes through the strict validator, where loopback is blocked by design,
and this test is about attribution rather than SSRF policy.

Also merges release-1.12.0 and regenerates the component index, which was the
conflict on this branch.

Found by erichare in review.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
G
Gabriel Luiz Freitas Almeida committed
7b35e88cc19e0420d2038d778c7af895ce97d746
Parent: cde696e
Committed by GitHub <noreply@github.com> on 8/13/2026, 8:03:34 PM