-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(mcp): honor TRACELOOP_TRACE_CONTENT on the client path #4466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c93b63a
4ff0dd4
4bdbabd
fd35087
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,13 @@ | |
| from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE | ||
|
|
||
| from opentelemetry.instrumentation.mcp.version import __version__ | ||
| from opentelemetry.instrumentation.mcp.utils import dont_throw, Config | ||
| from opentelemetry.instrumentation.mcp.utils import ( | ||
| Config, | ||
| dont_throw, | ||
| error_status, | ||
| record_error, | ||
| should_send_prompts, | ||
| ) | ||
| from opentelemetry.instrumentation.mcp.fastmcp_instrumentation import ( | ||
| FastMCPInstrumentor, | ||
| ) | ||
|
|
@@ -216,24 +222,29 @@ def _fastmcp_client_enter_wrapper(self, tracer): | |
| @dont_throw | ||
| async def traced_method(wrapped, instance, args, kwargs): | ||
| # Start a root span for the MCP client session and make it current | ||
| span_context_manager = tracer.start_as_current_span("mcp.client.session") | ||
| span_context_manager = tracer.start_as_current_span( | ||
| "mcp.client.session", | ||
| record_exception=False, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reasoning in the comment at 295-297 holds for the other spans, but not for this one: nothing reports a failure on Base vs. head, with a This happens with Either keep the defaults on this span, or give the exit wrapper's
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, I took the second option rather than restoring the defaults, so this span follows the same rule as the other five instead of being the one exception. The exit wrapper couldn't call record_error as-is: it only had the context manager, not the span. The enter wrapper now also stores the span on the instance ( You were right that nothing covered this path. Side effect worth noting: |
||
| set_status_on_exception=False, | ||
| ) | ||
| span = span_context_manager.__enter__() | ||
| span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, "session") | ||
| span.set_attribute( | ||
| SpanAttributes.TRACELOOP_ENTITY_NAME, "mcp.client.session" | ||
| ) | ||
|
|
||
| # Store the span context manager on the instance to properly exit it later | ||
| # Store the span context manager on the instance to properly exit it | ||
| # later, and the span itself so the exit wrapper can report a | ||
| # teardown failure on it. | ||
| setattr(instance, "_tracing_session_context_manager", span_context_manager) | ||
| setattr(instance, "_tracing_session_span", span) | ||
|
|
||
| try: | ||
| # Call the original method | ||
| result = await wrapped(*args, **kwargs) | ||
| return result | ||
| except Exception as e: | ||
| span.set_attribute(ERROR_TYPE, type(e).__name__) | ||
| span.record_exception(e) | ||
| span.set_status(Status(StatusCode.ERROR, str(e))) | ||
| record_error(span, e) | ||
| raise | ||
|
|
||
| return traced_method | ||
|
|
@@ -256,7 +267,12 @@ async def traced_method(wrapped, instance, args, kwargs): | |
|
|
||
| return result | ||
| except Exception as e: | ||
| # End the session span context manager with exception info | ||
| # Record the teardown failure before __exit__ ends the span -- | ||
| # the span's own exception recording is off, so nothing else | ||
| # would report it. | ||
| span = getattr(instance, "_tracing_session_span", None) | ||
| if span is not None: | ||
| record_error(span, e) | ||
| context_manager = getattr( | ||
| instance, "_tracing_session_context_manager", None | ||
| ) | ||
|
|
@@ -282,15 +298,25 @@ async def _handle_tool_call(self, tracer, method, params, args, kwargs, wrapped) | |
| except Exception: | ||
| pass | ||
|
|
||
| with tracer.start_as_current_span(span_name) as span: | ||
| # _execute_and_handle_result records the failure itself, with the | ||
| # message gated on content capture; OTel's own exception recording | ||
| # would re-add that text ungated on the way out. | ||
| with tracer.start_as_current_span( | ||
| span_name, record_exception=False, set_status_on_exception=False | ||
| ) as span: | ||
| # Set tool-specific attributes | ||
| span.set_attribute( | ||
| SpanAttributes.TRACELOOP_SPAN_KIND, TraceloopSpanKindValues.TOOL.value | ||
| ) | ||
| span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name) | ||
|
|
||
| # Add input | ||
| clean_input = self._extract_clean_input(method, params) | ||
| # Add input. Tool arguments are request content, so they are | ||
| # recorded only when content capture is enabled. | ||
| clean_input = ( | ||
| self._extract_clean_input(method, params) | ||
| if should_send_prompts() | ||
| else None | ||
| ) | ||
| if clean_input: | ||
| try: | ||
| span.set_attribute( | ||
|
|
@@ -307,10 +333,15 @@ async def _handle_tool_call(self, tracer, method, params, args, kwargs, wrapped) | |
|
|
||
| async def _handle_mcp_method(self, tracer, method, args, kwargs, wrapped): | ||
| """Handle non-tool MCP methods with simple serialization""" | ||
| with tracer.start_as_current_span(f"{method}.mcp") as span: | ||
| span.set_attribute( | ||
| SpanAttributes.TRACELOOP_ENTITY_INPUT, f"{serialize(args[0])}" | ||
| ) | ||
| with tracer.start_as_current_span( | ||
| f"{method}.mcp", record_exception=False, set_status_on_exception=False | ||
| ) as span: | ||
| # The serialized request is content: it carries caller-supplied | ||
| # params, so it is recorded only when content capture is enabled. | ||
| if should_send_prompts(): | ||
| span.set_attribute( | ||
| SpanAttributes.TRACELOOP_ENTITY_INPUT, f"{serialize(args[0])}" | ||
| ) | ||
| return await self._execute_and_handle_result( | ||
| span, method, args, kwargs, wrapped, clean_output=False | ||
| ) | ||
|
|
@@ -321,8 +352,11 @@ async def _execute_and_handle_result( | |
| """Execute the wrapped function and handle the result""" | ||
| try: | ||
| result = await wrapped(*args, **kwargs) | ||
| # Add output | ||
| if clean_output: | ||
| # Add output. The response body is content, so it is recorded only | ||
| # when content capture is enabled. | ||
| if not should_send_prompts(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The gate stops here, but ~20 lines down we still do I tried it: with Either gate that one too, or set a fixed description (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Took the "gate it" option over a fixed "tool error" string: Also worth flagging since it came out of this: the sweep in the test helper only walked span.attributes, so none of these leaks could have been caught. It now folds in status descriptions and event attributes, which is what turned your three reports into failing tests |
||
| pass | ||
| elif clean_output: | ||
| clean_output_data = self._extract_clean_output(method, result) | ||
| if clean_output_data: | ||
| try: | ||
|
|
@@ -343,16 +377,12 @@ async def _execute_and_handle_result( | |
| if hasattr(result, "isError") and result.isError: | ||
| span.set_attribute(ERROR_TYPE, "tool_error") | ||
| if len(result.content) > 0: | ||
| span.set_status( | ||
| Status(StatusCode.ERROR, f"{result.content[0].text}") | ||
| ) | ||
| span.set_status(error_status(f"{result.content[0].text}")) | ||
| else: | ||
| span.set_status(Status(StatusCode.OK)) | ||
| return result | ||
| except Exception as e: | ||
| span.set_attribute(ERROR_TYPE, type(e).__name__) | ||
| span.record_exception(e) | ||
| span.set_status(Status(StatusCode.ERROR, str(e))) | ||
| record_error(span, e) | ||
| raise | ||
|
|
||
| def _extract_clean_input(self, method: str, params: Any) -> dict: | ||
|
|
@@ -563,31 +593,44 @@ async def send(self, item: Any) -> Any: | |
| else: | ||
| return await self.__wrapped__.send(item) | ||
|
|
||
| with self._tracer.start_as_current_span("ResponseStreamWriter") as span: | ||
| if hasattr(request, "result"): | ||
| span.set_attribute( | ||
| SpanAttributes.MCP_RESPONSE_VALUE, f"{serialize(request.result)}" | ||
| ) | ||
| if "isError" in request.result: | ||
| if request.result["isError"] is True: | ||
| span.set_status( | ||
| Status( | ||
| StatusCode.ERROR, | ||
| f"{request.result['content'][0]['text']}", | ||
| ) | ||
| # The wrapped send runs inside this span, so a transport failure would | ||
| # be recorded by OTel with its message and stacktrace ungated. The | ||
| # except below reports it instead, with the message gated. | ||
| with self._tracer.start_as_current_span( | ||
| "ResponseStreamWriter", | ||
| record_exception=False, | ||
| set_status_on_exception=False, | ||
| ) as span: | ||
| try: | ||
| if hasattr(request, "result"): | ||
| # The response body is content, and so is the error text | ||
| # below -- it is the same payload. | ||
| if should_send_prompts(): | ||
| span.set_attribute( | ||
| SpanAttributes.MCP_RESPONSE_VALUE, | ||
| f"{serialize(request.result)}", | ||
| ) | ||
| if hasattr(request, "id"): | ||
| span.set_attribute(SpanAttributes.MCP_REQUEST_ID, f"{request.id}") | ||
| if "isError" in request.result: | ||
| if request.result["isError"] is True: | ||
| span.set_status( | ||
| error_status(f"{request.result['content'][0]['text']}") | ||
| ) | ||
| if hasattr(request, "id"): | ||
| span.set_attribute(SpanAttributes.MCP_REQUEST_ID, f"{request.id}") | ||
|
|
||
| if not isinstance(request, JSONRPCRequest): | ||
| if not isinstance(request, JSONRPCRequest): | ||
| return await self.__wrapped__.send(item) | ||
| meta = None | ||
| if not request.params: | ||
| request.params = {} | ||
| meta = request.params.setdefault("_meta", {}) | ||
|
|
||
| propagate.get_global_textmap().inject(meta) | ||
| return await self.__wrapped__.send(item) | ||
| meta = None | ||
| if not request.params: | ||
| request.params = {} | ||
| meta = request.params.setdefault("_meta", {}) | ||
|
|
||
| propagate.get_global_textmap().inject(meta) | ||
| return await self.__wrapped__.send(item) | ||
| except Exception as e: | ||
| record_error(span, e) | ||
| raise | ||
|
|
||
|
|
||
| @dataclass(slots=True, frozen=True) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(File-level since the line is outside the diff.) One more place the gate doesn't reach:
span.record_exception(e)in_execute_and_handle_resultwritesexception.messageand the fullexception.stacktraceas event attributes. On the client path theMcpErrormessage comes from the server, so it is content.fastmcp_instrumentation.pyaround line 114 does the same.With the switch off I still got
exception.message='failure involving <marker>'and the marker inside the stacktrace, on bothboom.toolandmcp.server.Fine by me if exception text is deliberately out of scope, but then let's note it in the PR description — as written the claim is that content is withheld.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It ran deeper than the two explicit record_exception calls.
start_as_current_spandefaults torecord_exception=True,set_status_on_exception=True,so as the exception left each with block OTel re-recorded the message and stacktrace ungated and overwrote the gated status, which is also why every failure was carrying two identical exception events.Both flags are now off at the five spans whose code reports failures itself.
Your follow-up on this thread is in too: dropping the event entirely also cost
exception.typeandexception.stacktrace, so record_error now keeps both and withholds only the message. Your exact case is whattest_error_text_suppressed_when_content_capture_offasserts against, and it fails without the fix.