Repository navigation
Fix Datadog LLMObs tool spans (LLMObs.start_span does not exist in ddtrace 4.13) - #168
Merged
Merged
Conversation
…trace 4.13) _llmobs_tool_span called LLMObs.start_span(span_kind="tool", ...), which is not part of the ddtrace 4.13.0 LLMObs API. The AttributeError was swallowed, so no LLMObs tool span was ever created and the LLM Observability widgets stayed empty. - Start the span with the public LLMObs.tool(name=..., session_id=...) API and annotate input, metadata and the user/client/session tags with LLMObs.annotate (tags are also set on the APM span as before). - Finish the span in a finally around the tool call, recording the exception (set_exc_info) on error or cancellation so the span has status "error". - No-op when LLMObs is not enabled (no DD_API_KEY) without touching the SDK. - If LLMObs is enabled but the SDK fails, log one WARNING per process (then DEBUG) instead of silently dropping spans; the tool call is never affected. - Add tests that run the real ddtrace LLMObs SDK and capture the exported span events in-process (no Datadog network). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
… mode In the production export mode (agentless + APM tracing on) ddtrace 4.13 rewrites dots in LLMObs tag keys to underscores, while agent-proxy mode keeps them dotted, so the same tag had different names depending on configuration. Send the LLMObs tags as usr_id, mcp_client_kind, mcp_client_name and mcp_session_id so the stored names are identical in every mode; the APM span keeps the dotted tags unchanged. Tests: capture the LLMObs payload in the prod export mode at the APM trace writer (fake key, outbound HTTP refused), and cover overlapping concurrent calls, worker-thread child spans (asyncio and anyio), and cancellation. Document the LLMObs tag names in observability/datadog/README.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
TelemetryMiddlewarestarts a Datadog LLM Observability tool span for each MCP tool call through_llmobs_tool_spaninsrc/cbioportal_mcp/telemetry.py. That helper calledLLMObs.start_span(span_kind="tool", name=...), but the ddtrace we pin (4.13.0 inuv.lock) has no such method. TheAttributeErrorwas caught by a bareexcept Exception: return None, so no LLMObs tool span has ever been created, and the LLM Observability widgets built onmcp.tool.*spans are empty. DogStatsD metrics and OTel spans are separate code paths and still work.The existing tests never caught it because they all patch
_llmobs_tool_span.Fix
LLMObs.tool(name=f"mcp.tool.{tool}", session_id=...)API and annotated withLLMObs.annotate(span=..., input_data=..., metadata=..., tags=...). The span name, input, and metadata (user_email,mcp_client_*,mcp_session_id) are unchanged.usr_id,mcp_client_kind,mcp_client_name,mcp_session_id.server.py:agentless_enabled=True, APM tracing on, which ddtrace callsAPM_AGENTLESS), ddtrace 4.13 rewrites dots in LLMObs tag keys to underscores. In agent-proxy mode it keeps the dots. Sending the keys already underscored means Datadog stores the same names in every mode.span.set_tag, which LLMObs can't filter on.usr.id,mcp.client_kind,mcp.client.name,mcp.session.id) exactly as before.finallyaround the whole tool call. On an exception, including cancellation, the error is recorded withspan.set_exc_info, so the span hasstatus: "error".LLMObs.enabledis false (noDD_API_KEY), the SDK isn't touched and no ddtrace span is created.WARNINGis logged per process, thenDEBUGafter that. The tool call itself is never affected.observability/datadog/README.mdand in theTelemetryMiddlewaredocstring. No in-repo dashboard, monitor or doc queried LLMObs tags. The in-repo dashboards use OTel/APM span facets, which this PR doesn't change.What error spans now contain
Error spans record
error.type,error.messageanderror.stack.ToolErrortext,Error calling tool '<name>': <original exception message>. FastMCP wraps the exception before middleware sees it, andmask_error_detailsis not set.clickhouse_run_select_query,clickhouse_list_tables,clickhouse_list_table_columns) catch DB errors and return{"error_message": str(e)}as a normal result. ClickHouse error text can quote SQL fragments, so it can end up in the span's output, as a successful call. This is the same output annotation as before this PR. It does not reach the error path.clickhouse_run_select_query.ValueError("study_id cannot be empty"), or invalid table-name messages that can echo the value supplied), or from unexpected errors.Test evidence
tests/test_telemetry_llmobs.pyuses the real ddtrace LLMObs SDK. It has 10 test cases (9 functions, one parametrized over two values). No test makes a Datadog network call.Capture modes:
enqueue.write.http.clientconnects are refused and recorded, and the fixture asserts no connection was attempted.test_tool_call_emits_one_llmobs_tool_spantoolspanmcp.tool.ping, status ok, session_id, underscored LLMObs tags, input/output/metadatatest_prod_mode_llmobs_tags_are_underscored_and_apm_tags_dottedusr_id/mcp_client_kind/mcp_client_name/mcp_session_id(no dotted keys); APM span hasusr.id/mcp.client_kind/mcp.client.name/mcp.session.idtest_erroring_tool_emits_llmobs_span_with_errorerror,error:1, error type/message recordedtest_each_tool_call_gets_its_own_spantest_overlapping_calls_get_distinct_root_spanstest_worker_thread_child_span_is_parented_to_tool_span[asyncio|anyio]asyncio.to_thread/anyio.to_thread.run_syncis a child of the tool spantest_cancelled_call_finishes_span_with_errorerror(CancelledError)test_noop_when_llmobs_not_enabledLLMObs.toolis never called and nothing is logged when LLMObs is disabledtest_span_creation_failure_warns_once_and_never_breaks_toolFail-before / pass-after, same test file
test_tool_call_emits_one_llmobs_tool_spanfails withKeyError: 'usr_id'because agent-proxy mode exported dotted keys.On b29f27d the prod-mode test passes. In that mode ddtrace already rewrote the keys to
usr_idetc., which is what the cross-review captured. So the real defect was that tag names differed by export mode, and the first revision's tests and PR text claimed dotted keys. The concurrency, worker-thread and cancellation tests also pass on b29f27d. They are regression coverage and did not reveal a bug.Gates
uv run pytestcollects 315 test cases: 246 passed, 69 skipped, 0 failed. The skips are pre-existing.ruff check src/cbioportal_mcp/telemetry.py tests/test_telemetry_llmobs.pypasses with no errors. The repo-wideruff check src testsreports 110 errors, the same 110 as the bd2d698 baseline.Notes
main) has the same bug. Once this is approved, an identical PR againstmaincan follow.server.pystill passes the deprecatedml_app=(ddtrace 4.x prefersagent_service). It still works. I left it alone because it's out of scope.🤖 Generated with Claude Code