Skip to content

Commit 106ab6d

Browse files
Cueclaude
authored andcommitted
fix(tracing): gate raw diagnostics behind content opt-in
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cd821f6 commit 106ab6d

13 files changed

Lines changed: 478 additions & 112 deletions

CHANGELOG.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
turn, chat, and tool spans end with `cubepi.run.outcome="suspended"`; observer
1616
failures cannot hide the terminal event, cancellation still propagates, and
1717
`respond()` opens a distinct correlated activation trace.
18-
- **Raw provider error details now follow the content-recording opt-in.** With
19-
`record_content=False`, traces retain typed error classification and ERROR
20-
status but use bounded generic descriptions and omit exception messages and
21-
stack traces. `record_content=True` preserves the prior diagnostic detail.
18+
- **Raw tracing diagnostics now follow the content-recording opt-in.** With
19+
`record_content=False`, provider, turn, one-shot, and MCP spans retain typed
20+
error classification and ERROR status but use bounded generic descriptions
21+
and omit exception messages and stack traces. Stream logs retain structural
22+
timing/size fields while omitting tool-argument previews and raw error text.
23+
`record_content=True` preserves the prior diagnostic detail for failures.
24+
- **One-shot cancellation now matches Agent and MCP cancellation semantics.** It
25+
records `cubepi.aborted=true` with status UNSET and does not emit an exception
26+
event, because cancellation is a control signal rather than a failure.
2227

2328
## [0.13.3] - 2026-08-02
2429

cubepi/mcp/_tracing.py

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,17 @@
6565
# multiple agents (each attach pushes a token; each detach pops just
6666
# its own entry) and supports detaches in any order without clearing
6767
# routing for the still-attached agents.
68-
_provider_stack: list[tuple[object, Any]] = []
68+
_provider_stack: list[tuple[object, Any, bool]] = []
6969

7070

71-
def register_provider(provider: Any) -> object:
72-
"""Push ``provider`` onto the routing stack as the preferred source
73-
for MCP spans. Returns an opaque token that
74-
:func:`unregister_provider` uses to remove this exact entry.
71+
def register_provider(provider: Any, *, record_content: bool = False) -> object:
72+
"""Push ``provider`` and its content policy onto the routing stack.
7573
76-
Called by :meth:`cubepi.tracing.Tracer.attach`.
74+
Returns an opaque token that :func:`unregister_provider` uses to remove
75+
this exact entry. Called by :meth:`cubepi.tracing.Tracer.attach`.
7776
"""
7877
token = object()
79-
_provider_stack.append((token, provider))
78+
_provider_stack.append((token, provider, record_content))
8079
return token
8180

8281

@@ -93,26 +92,28 @@ def unregister_provider(token: object | None = None) -> None:
9392
if token is None:
9493
_provider_stack.pop()
9594
return
96-
for i, (t, _p) in enumerate(_provider_stack):
95+
for i, (t, _p, _record_content) in enumerate(_provider_stack):
9796
if t is token:
9897
_provider_stack.pop(i)
9998
return
10099

101100

102-
def _get_tracer(scope_name: str) -> Any:
103-
"""Resolve the tracer to use for emitting an MCP span.
104-
105-
Prefers the most recently-registered provider over OTel's global
106-
default (which is a no-op unless the user separately called
107-
``set_tracer_provider``).
108-
"""
101+
def _get_tracer_route(scope_name: str) -> tuple[Any, bool]:
102+
"""Resolve the tracer and content policy for an MCP span."""
109103
if _provider_stack:
110-
return _provider_stack[-1][1].get_tracer(scope_name)
111-
return _otel_trace.get_tracer(scope_name)
104+
_token, provider, record_content = _provider_stack[-1]
105+
return provider.get_tracer(scope_name), record_content
106+
return _otel_trace.get_tracer(scope_name), False
107+
108+
109+
def _get_tracer(scope_name: str) -> Any:
110+
"""Resolve the tracer to use for emitting an MCP span."""
111+
tracer, _record_content = _get_tracer_route(scope_name)
112+
return tracer
112113

113114

114115
# When the cubepi Recorder opens an ``execute_tool`` span, it publishes
115-
# ``(span, owning_provider)`` here so an MCP tool call running inside
116+
# ``(span, owning_provider, record_content)`` here so an MCP tool call running inside
116117
# the AgentTool body can make its CLIENT span a child of this span
117118
# (rather than starting an orphan root trace — recorder doesn't bother
118119
# installing ``execute_tool`` as the OTel current span; see
@@ -122,7 +123,7 @@ def _get_tracer(scope_name: str) -> Any:
122123
#
123124
# Lookup uses a per-task ``ContextVar`` holding a STACK of opaque
124125
# handles (outermost first, innermost last), with the actual
125-
# ``(span, provider)`` payload stored in a module-level
126+
# ``(span, provider, record_content)`` payload stored in a module-level
126127
# ``_active_entries`` dict. The dict is the source of truth for which
127128
# handles are still live; the contextvar stack records nesting order
128129
# per task.
@@ -148,16 +149,21 @@ def _get_tracer(scope_name: str) -> Any:
148149
# is found. Dead handles linger only in a task's local stack tuple and
149150
# are GC'd when that task ends; ``_active_entries`` stays bounded by
150151
# live registrations.
151-
_active_entries: dict[object, tuple[Any, Any]] = {}
152+
_active_entries: dict[object, tuple[Any, Any, bool]] = {}
152153
_handle_stack: contextvars.ContextVar[tuple[object, ...]] = contextvars.ContextVar(
153154
"_cubepi_mcp_tool_handle_stack", default=()
154155
)
156+
_record_content_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
157+
"_cubepi_mcp_record_content", default=False
158+
)
155159

156160

157161
def register_tool_span(
158162
tool_call_id: str,
159163
span: Any,
160164
provider: Any = None,
165+
*,
166+
record_content: bool = False,
161167
) -> tuple[object, contextvars.Token[tuple[object, ...]]]:
162168
"""Publish ``span`` (and its owning ``provider``) as the current
163169
``execute_tool`` parent for the calling task.
@@ -173,7 +179,7 @@ def register_tool_span(
173179
"""
174180
del tool_call_id
175181
handle = object()
176-
_active_entries[handle] = (span, provider)
182+
_active_entries[handle] = (span, provider, record_content)
177183
cv_token = _handle_stack.set(_handle_stack.get() + (handle,))
178184
return (handle, cv_token)
179185

@@ -205,9 +211,10 @@ def unregister_tool_span(
205211
pass
206212

207213

208-
def _get_tool_span_entry() -> tuple[Any, Any] | None:
209-
"""Return the (span, provider) entry for the current task, or
210-
``None`` when no live ``execute_tool`` is in scope.
214+
def _get_tool_span_entry() -> tuple[Any, Any, bool] | None:
215+
"""Return the active span, provider, and content policy for this task.
216+
217+
Returns ``None`` when no live ``execute_tool`` is in scope.
211218
212219
Walks the per-task handle stack inner→outer and returns the first
213220
handle whose payload is still live in ``_active_entries``. A nested
@@ -287,16 +294,19 @@ async def mcp_client_span(
287294
del parent_tool_call_id
288295
entry = _get_tool_span_entry()
289296
if entry is not None:
290-
parent_span, parent_provider = entry
297+
parent_span, parent_provider, record_content = entry
291298
parent_context = _otel_trace.set_span_in_context(parent_span)
292-
tracer = (
293-
parent_provider.get_tracer(_SCOPE_NAME)
294-
if parent_provider is not None
295-
else _get_tracer(_SCOPE_NAME)
296-
)
299+
if parent_provider is not None:
300+
tracer = parent_provider.get_tracer(_SCOPE_NAME)
301+
else:
302+
tracer, _fallback_record_content = _get_tracer_route(_SCOPE_NAME)
297303
else:
298304
parent_context = None
299305
tracer = _get_tracer(_SCOPE_NAME)
306+
# Without an execute_tool parent there is no task-scoped owner.
307+
# The provider stack is process-global, so borrowing its content flag
308+
# could leak details from a different concurrent Tracer. Fail closed.
309+
record_content = False
300310
attrs: dict[str, Any] = {
301311
_MCP_METHOD_NAME: method,
302312
_GEN_AI_OPERATION_NAME: "execute_tool",
@@ -318,35 +328,48 @@ async def mcp_client_span(
318328
attributes=attrs,
319329
context=parent_context,
320330
)
331+
content_token = _record_content_context.set(record_content)
321332
try:
322-
# Disable use_span's default record_exception / set_status_on_exception
323-
# so we are the single source of the exception event and ERROR
324-
# status — otherwise OTel would auto-record on context exit AND
325-
# this ``except`` block would record again, double-counting.
326-
with _otel_trace.use_span(
327-
span,
328-
record_exception=False,
329-
set_status_on_exception=False,
330-
):
331-
yield span
332-
except BaseException as exc:
333333
try:
334-
error_type = _error_type_for(exc)
335-
span.set_attribute(_ERROR_TYPE, error_type)
336-
# Cancellation is a control signal, not a failure — match the
337-
# convention from the chat / turn / invoke_agent spans: leave
338-
# Status UNSET and mark cubepi.aborted=true, do NOT record an
339-
# exception event.
340-
if error_type == "cubepi.aborted":
341-
span.set_attribute("cubepi.aborted", True)
342-
else:
343-
span.set_status(Status(StatusCode.ERROR, str(exc)[:256]))
344-
span.record_exception(exc)
345-
finally:
334+
# Disable use_span's default record_exception / set_status_on_exception
335+
# so we are the single source of the exception event and ERROR
336+
# status — otherwise OTel would auto-record on context exit AND
337+
# this ``except`` block would record again, double-counting.
338+
with _otel_trace.use_span(
339+
span,
340+
record_exception=False,
341+
set_status_on_exception=False,
342+
):
343+
yield span
344+
except BaseException as exc:
345+
try:
346+
error_type = _error_type_for(exc)
347+
span.set_attribute(_ERROR_TYPE, error_type)
348+
# Cancellation is a control signal, not a failure — match the
349+
# convention from the chat / turn / invoke_agent spans: leave
350+
# Status UNSET and mark cubepi.aborted=true, do NOT record an
351+
# exception event.
352+
if error_type == "cubepi.aborted":
353+
span.set_attribute("cubepi.aborted", True)
354+
else:
355+
description = (
356+
str(exc)[:256] if record_content else "mcp client error"
357+
)
358+
span.set_status(Status(StatusCode.ERROR, description))
359+
if record_content:
360+
span.record_exception(exc)
361+
else:
362+
span.add_event(
363+
"exception",
364+
attributes={"exception.type": type(exc).__name__},
365+
)
366+
finally:
367+
span.end()
368+
raise
369+
else:
346370
span.end()
347-
raise
348-
else:
349-
span.end()
371+
finally:
372+
_record_content_context.reset(content_token)
350373

351374

352375
def mark_span_mcp_error(span: Any, message: str) -> None:
@@ -362,7 +385,10 @@ def mark_span_mcp_error(span: Any, message: str) -> None:
362385
"""
363386
if span is None or not _OTEL_AVAILABLE:
364387
return
365-
span.set_status(Status(StatusCode.ERROR, message[:256]))
388+
description = (
389+
message[:256] if _record_content_context.get() else "mcp protocol error"
390+
)
391+
span.set_status(Status(StatusCode.ERROR, description))
366392
span.set_attribute(_ERROR_TYPE, "mcp.is_error")
367393

368394

cubepi/tracing/recorder.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ def _on_agent_start(self) -> None:
585585

586586
entry = _mcp_tracing._get_tool_span_entry()
587587
if entry is not None:
588-
tool_span, _tool_provider = entry
588+
tool_span, _tool_provider, _record_content = entry
589589
parent_ctx = trace.set_span_in_context(tool_span)
590590
except ImportError: # pragma: no cover — mcp module always present
591591
parent_ctx = None
@@ -845,7 +845,7 @@ def _on_turn_end(self, event: TurnEndEvent) -> None:
845845
# cubepi.aborted on the invoke_agent root.
846846
if stop_reason == "error":
847847
err_msg = (
848-
getattr(msg, "error_message", None) or "model error"
848+
(getattr(msg, "error_message", None) or "model error")
849849
if self._record_content
850850
else "model error"
851851
)
@@ -907,6 +907,7 @@ def _on_tool_exec_start(self, event: ToolExecutionStartEvent) -> None:
907907
event.tool_call_id,
908908
span,
909909
provider=self._tracer._provider,
910+
record_content=self._record_content,
910911
)
911912
run.tool_span_tokens[event.tool_call_id] = cv_token
912913
except ImportError: # pragma: no cover — mcp module always present
@@ -1170,9 +1171,10 @@ def _write_stream_event(self, run: "_RunState", event: StreamEvent) -> None:
11701171
"ci": ci,
11711172
"chars": len(delta),
11721173
"accumulated": run.stream_tool_accumulated[ci],
1173-
"preview": delta[:60],
11741174
}
11751175
)
1176+
if self._record_content:
1177+
rec["preview"] = delta[:60]
11761178

11771179
elif event.type == "toolcall_end":
11781180
id_, name, args_str = "", "", ""
@@ -1188,15 +1190,20 @@ def _write_stream_event(self, run: "_RunState", event: StreamEvent) -> None:
11881190
"id": id_,
11891191
"name": name,
11901192
"args_chars": total,
1191-
"args_preview": args_str[:80],
11921193
}
11931194
)
1195+
if self._record_content:
1196+
rec["args_preview"] = args_str[:80]
11941197

11951198
elif event.type in ("text_delta", "thinking_delta"):
11961199
rec["chars"] = len(event.delta or "")
11971200

11981201
elif event.type == "error":
1199-
rec["error_message"] = event.error_message or ""
1202+
rec["error_message"] = (
1203+
(event.error_message or "provider error")
1204+
if self._record_content
1205+
else "provider error"
1206+
)
12001207

12011208
try:
12021209
run.stream_file.write(json.dumps(rec) + "\n") # type: ignore[union-attr]

cubepi/tracing/tracer.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,10 @@ def attach(self, agent: "Agent") -> Callable[[], Any]:
289289
from cubepi.mcp import _tracing as mcp_tracing
290290

291291
assert mcp_tracing is not None
292-
mcp_token = mcp_tracing.register_provider(self._provider)
292+
mcp_token = mcp_tracing.register_provider(
293+
self._provider,
294+
record_content=self._record_content,
295+
)
293296
except ImportError: # pragma: no cover — mcp module always present
294297
pass
295298
except BaseException:
@@ -612,9 +615,18 @@ async def oneshot(
612615
root_span.set_attribute(CUBEPI_ABORTED, True)
613616
root_span.set_attribute(ERROR_TYPE, "cubepi.aborted")
614617
else:
615-
root_span.set_status(Status(StatusCode.ERROR, str(_exc)[:256]))
618+
description = str(_exc)[:256] if do_record else "oneshot error"
619+
root_span.set_status(Status(StatusCode.ERROR, description))
616620
root_span.set_attribute(ERROR_TYPE, type(_exc).__name__)
617-
root_span.record_exception(_exc)
621+
if do_record:
622+
root_span.record_exception(_exc)
623+
else:
624+
root_span.add_event(
625+
"exception",
626+
attributes={
627+
"exception.type": type(_exc).__name__,
628+
},
629+
)
618630
except Exception: # pragma: no cover — defensive
619631
pass
620632
raise

dev/plans/2026-08-04-tracing-hitl-suspension.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ OpenTelemetry SDK, pytest, Ruff, mypy, uv.
4949
- Sweep tool-span registrations, close stream state, and clear the active run.
5050
- Move assistant output accumulation to `MessageEndEvent` so suspension before
5151
`TurnEndEvent` still records the partial output.
52-
- Do not change `_close_open_spans()` cancellation semantics.
52+
- Do not change `_close_open_spans()` cancellation semantics; make one-shot
53+
cancellation match the same aborted-without-exception-event contract.
5354
- When `record_content=False`, preserve typed ERROR classification while using
54-
generic status descriptions and omitting exception messages/stack traces.
55+
generic status descriptions and omitting exception messages/stack traces from
56+
provider, turn, one-shot, and MCP spans; keep stream telemetry structural by
57+
omitting raw argument/error previews.
5558

5659
## Task 4: Document the lifecycle contract
5760

dev/specs/2026-08-04-tracing-hitl-suspension.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ left the old `_RunState` retained in that task's context.
3333
cancellation priority.
3434
- Clear the pending-request snapshot on every owning activation failure/exit and
3535
avoid unhandled `HitlDetached` future warnings.
36-
- Preserve the existing run cancellation cleanup and classification.
36+
- Preserve run cancellation cleanup and classification; align one-shot
37+
cancellation with Agent/MCP semantics by recording aborted control flow
38+
without an exception event.
3739
- Remove tool-span and MCP-provider registrations during normal tracing detach.
3840
- Count the assistant tool-call message as partial activation output.
3941
- Keep tracing observational: recorder failures must not affect the agent.
40-
- Keep raw provider error messages and stack traces behind `record_content=True`;
41-
privacy-default traces retain only typed error classification.
42+
- Keep raw provider, turn, one-shot, MCP, and stream-log diagnostics behind
43+
`record_content=True`; privacy-default traces retain only typed error
44+
classification and structural stream timing/size evidence.
4245

4346
## Non-goals
4447

tests/tracing/test_attach_atomicity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def test_tracer_attach_unwinds_recorder_on_mcp_register_failure(monkeypatc
7373
try:
7474
import cubepi.mcp._tracing as mcp_tracing
7575

76-
def _boom(_provider): # noqa: ANN001, ANN202
76+
def _boom(_provider, **_kwargs): # noqa: ANN001, ANN202
7777
raise RuntimeError("register_provider failed")
7878

7979
monkeypatch.setattr(mcp_tracing, "register_provider", _boom)
@@ -129,7 +129,7 @@ def _raising_unsub(): # noqa: ANN202
129129

130130
import cubepi.mcp._tracing as mcp_tracing
131131

132-
def _boom(_provider): # noqa: ANN001, ANN202
132+
def _boom(_provider, **_kwargs): # noqa: ANN001, ANN202
133133
raise RuntimeError("register_provider failed")
134134

135135
monkeypatch.setattr(mcp_tracing, "register_provider", _boom)

0 commit comments

Comments
 (0)