Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Agent Control spans exported over OTLP now include the control discriminator
and complete `agent_control.*` field set required for backend classification
and Controls-card rendering.
- Explicitly empty message parts are preserved in OTLP telemetry instead of
being serialized as the text `"[]"`.
- OTLP partial-success acknowledgements recognize positive integral JSON-number
rejection counts such as `3.0`.

## [0.1.1] - 2026-08-03

Expand Down
5 changes: 4 additions & 1 deletion src/splunk_ao/converter/attribute_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ def _mapped_message(source: dict[str, Any], default_role: str) -> dict[str, Any]
tool_call_id = source.pop("tool_call_id", None)
tool_calls = source.pop("tool_calls", None)

if source_parts is not None:
# An explicitly supplied parts field is authoritative over legacy content.
if isinstance(source_parts, list) and not source_parts:
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)
Comment on lines +176 to 179

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor (bug): When a message carries an explicitly empty parts and a non-empty content, this now silently drops the content entirely.

Input {"role": "user", "parts": [], "content": "hello"} maps to {"role": "user", "parts": []}"hello" is gone. The old code was also lossy here (it emitted the bogus {"type": "text", "content": "[]"} and dropped content too), so this isn't a regression, but the fix is the natural place to decide the precedence. Since the point of the change is to stop losing/garbling content, having parts: [] win over real content seems like the wrong tiebreak — an adapter that initializes parts=[] by default and puts the payload in content would produce a message with no content at all.

Suggest only honouring the empty list when there is nothing else to fall back on:

if isinstance(source_parts, list) and not source_parts and content in (None, ""):
    parts = []
elif source_parts is not None:
    parts = _content_parts(source_parts)

If parts: [] is meant to be authoritative regardless of content, that's a defensible call — worth a short comment saying so, plus a test pinning the both-present case so the precedence isn't accidentally flipped later.

Suggested change
if isinstance(source_parts, list) and not source_parts:
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)
if isinstance(source_parts, list) and not source_parts and content in (None, ""):
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)

🤖 Generated by the Astra agent

Comment on lines +175 to 179

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major (bug): parts: [] winning over a non-empty content silently discards the content — and the new test now pins that as intended.

This was raised in the prior review. The response was to add the comment on line 175 and test_orchestration_treats_explicit_empty_parts_as_authoritative (tests/test_attribute_mapping.py:418), which asserts that {"role":"user","parts":[],"content":"ignored input"} maps to {"role": "user", "parts": []}. That is a legitimate way to resolve the ambiguity — declaring the precedence and pinning it beats leaving it accidental — but I want to push back on the direction chosen, because it conflicts with the PR's own stated goal.

The changelog entry says the fix is so that empty parts are "preserved in OTLP telemetry instead of being serialized as the text \"[]\"" — i.e. the motivation is stop garbling content. But for the both-present case the new code still loses data, just more quietly than before: the old code emitted a bogus {"type": "text", "content": "[]"} part (visibly wrong, so diagnosable in the field), whereas the new code emits an empty parts list (indistinguishable from a genuinely empty message). Silent loss is worse than loud garbage for field debugging.

The realistic failure mode is an adapter that initializes parts=[] as a default and puts the payload in content. Under this change every such message exports with no content at all, and nothing in the span indicates anything was dropped.

The narrower rule only honours the empty list when there is genuinely nothing to fall back on, which still fixes the "[]" bug without introducing a new loss path:

if isinstance(source_parts, list) and not source_parts and content in (None, ""):
    parts = []
elif source_parts is not None:
    parts = _content_parts(source_parts)

Note this preserves the three new tests' intent except test_orchestration_treats_explicit_empty_parts_as_authoritative, which would need to flip to assert the content is retained.

If parts: [] really is meant to be authoritative regardless of content — e.g. because a known producer emits both and the content is stale — please say so explicitly in the comment ("producer X emits stale content alongside authoritative parts"), since "legacy" alone doesn't justify discarding a non-empty payload.

Suggested change
# An explicitly supplied parts field is authoritative over legacy content.
if isinstance(source_parts, list) and not source_parts:
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)
# An explicitly supplied parts field is authoritative over legacy content.
if isinstance(source_parts, list) and not source_parts and content in (None, ""):
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design question here is fair, and I'll answer it directly below. But the suggested patch can't be applied as written — it reintroduces the exact "[]" bug this PR fixes.

I applied the suggestion verbatim and ran the mapper:

{'role': 'user', 'parts': [], 'content': 'hello'}  ->  {'role': 'user', 'parts': [{'type': 'text', 'content': '[]'}]}

The reason is the elif: with content="hello", the new first branch is False, so control falls through to elif source_parts is not None_content_parts([]). [] is falsy, so the Sequence branch is skipped and it lands on return [_text_part(value)]{"type": "text", "content": "[]"}.

So for the both-present case the suggestion doesn't preserve content — it produces the literal string "[]" and still drops "hello". That's strictly worse than both the current code and the pre-PR behavior. To actually retain the content it needs a third branch routing to _content_parts(content), not a guard on the empty-parts branch.

For the record, applying it fails only test_orchestration_treats_explicit_empty_parts_as_authoritative (42 passed, 1 failed) — so you were right that that's the one test that flips. The problem isn't the blast radius, it's that the replacement output is wrong.

On the actual design question — why parts: [] wins:

parts is the canonical OTel field; content is the legacy compatibility shim. The precedence rule is "canonical field wins when explicitly present," not a claim about which payload is more likely to be real. test_orchestration_keeps_missing_parts_and_empty_content_distinct (tests/test_attribute_mapping.py:437) pins the related distinction: absent parts and empty parts are different signals. An explicit parts: [] is a producer saying "this message has no parts" — deliberately, since the key had to be written to appear at all. Absent parts falls back to content and always will.

I take the point about silent-vs-loud loss for field debugging. But I'd rather not special-case the canonical field's precedence on the contents of the legacy one — that makes the rule "parts is authoritative, except when content is non-empty, in which case parts: [] is reinterpreted as absent," which is harder to reason about and means a producer can't express "empty" at all once it also sets content.

I don't have a named producer that emits stale content alongside authoritative parts, so I'll soften the comment to state the rule rather than imply a known offender. If a real adapter shows up defaulting parts=[] with the payload in content, that's a concrete bug report and I'll revisit the tiebreak then.

Keeping current behavior. Open to the narrower rule if you want to argue for it, but it'd need a correct patch — the posted one regresses the bug the PR is fixing.

elif role == "tool":
response = {"type": "tool_call_response", "response": _parse_json_value(content)}
Expand Down
11 changes: 2 additions & 9 deletions src/splunk_ao/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,9 +757,7 @@ def _prepare_call(
set_trace_context = False
if not existing_trace:
if current_parent is not None:
trace = current_parent
while trace._parent is not None:
trace = trace._parent
trace = client_instance._current_root()
if not isinstance(trace, Trace):
raise RuntimeError("Active Splunk AO operation does not have a trace root")
else:
Expand Down Expand Up @@ -903,12 +901,7 @@ def _complete_call(
return result

def _conclude_owned_trace(self, call_state: _CallState, output: Any, status_code: int | None) -> None:
current_parent = call_state.logger.current_parent()
root = current_parent
while root is not None and root._parent is not None:
root = root._parent

if root is not call_state.trace:
if not call_state.logger._is_current_root(call_state.trace):
return

try:
Expand Down
10 changes: 10 additions & 0 deletions src/splunk_ao/exporter/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
import math
import re
import threading
import time
Expand All @@ -28,6 +29,7 @@
_MAX_RESPONSE_BYTES = 64 * 1024
_MAX_MESSAGE_LENGTH = 512
_MAX_REJECTION_KEYS = 8
_MAX_SAFE_JSON_INTEGER = 2**53 - 1
_DEFAULT_LOG_INTERVAL_SECONDS = 60.0
_SAFE_KEY = re.compile(r"[^a-zA-Z0-9_.-]+")
_PROTOBUF_CONTENT_TYPES = frozenset(
Expand Down Expand Up @@ -247,6 +249,14 @@ def _positive_json_integer(value: object) -> int | None:
return None
if isinstance(value, int):
return value if value > 0 else None
if (
isinstance(value, float)
and math.isfinite(value)
and value.is_integer()
and abs(value) <= _MAX_SAFE_JSON_INTEGER
):
parsed = int(value)
return parsed if parsed > 0 else None
if isinstance(value, str) and value.isdecimal():
parsed = int(value)
return parsed if parsed > 0 else None
Expand Down
7 changes: 2 additions & 5 deletions src/splunk_ao/handlers/agent_control/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,8 @@ def _active_context(self) -> _ActiveContext | None:
if current_parent is None or current_parent.id is None:
return None

root_parent = current_parent
while getattr(root_parent, "_parent", None) is not None:
root_parent = root_parent._parent

if getattr(root_parent, "id", None) is None:
root_parent = self._splunk_ao_logger._current_root()
if root_parent is None or root_parent.id is None:
return None

return _ActiveContext(trace_id=str(root_parent.id), span_id=str(current_parent.id))
Expand Down
7 changes: 1 addition & 6 deletions src/splunk_ao/handlers/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,7 @@ def commit(self) -> None:
self._root_node = None

def _conclude_owned_trace(self, trace: Any, output: Any, status_code: int | None) -> None:
current_parent = self._splunk_ao_logger.current_parent()
root = current_parent
while root is not None and root._parent is not None:
root = root._parent

if root is trace:
if self._splunk_ao_logger._is_current_root(trace):
self._splunk_ao_logger.conclude(output=output, status_code=status_code, conclude_all=True)
Comment on lines 107 to 109

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major (bug): This refactor changes behavior when trace is None, turning a cleanup path into a silent no-op.

The old code compared the computed root against trace directly:

root = current_parent
while root is not None and root._parent is not None:
    root = root._parent
if root is trace:
    self._splunk_ao_logger.conclude(..., conclude_all=True)

When trace is None and the parent chain was also empty, root is trace evaluated to None is NoneTrue, and conclude(conclude_all=True) ran (harmlessly, since its while self.current_parent() is not None loop body never executes). More importantly, when trace is None and a parent chain did exist, root is trace was False — so the old code was already correct there.

The new _is_current_root short-circuits on trace is not None first, so trace=None now always returns False. The behavioral difference is narrow, but it interacts badly with how owned_trace is produced:

commit() (line 79) assigns owned_trace = self._splunk_ao_logger.start_trace(...). start_trace is decorated with @nop_sync and @warn_catch_exception() (logger.py:1300-1301), so it returns None when logging is disabled or when it raises internally. In the @warn_catch_exception case, add_trace may have already run self._set_current_parent(trace) before a later step threw — leaving a live parent chain with owned_trace = None.

The except Exception: block at line 100 guards with if owned_trace is not None, so it skips cleanup; and finally only clears self._nodes / self._root_node, never the logger's parent chain. Net effect: the chain stays open and leaks into the next commit() on the same context, where add_trace will raise ValueError("You must conclude the existing trace before adding a new one.").

This is reachable today, is not covered by a test (test_commit_failure_concludes_only_handler_owned_trace only exercises the owned_trace is not None path), and the same shape exists in base_async_handler.py:67.

Suggested fix — make the failure path unconditionally reclaim a chain the handler owns, rather than relying on a possibly-None handle:

except Exception:
    if self._start_new_trace:
        self._conclude_owned_trace(owned_trace or self._splunk_ao_logger._current_root(), output="", status_code=500)
    _logger.warning("Failed to commit handler telemetry", exc_info=True)

Alternatively, keep _is_current_root strict and have commit()/async_commit() verify that start_trace actually returned a trace before proceeding, bailing out early (and resetting parent tracking) if it did not. Either way, please add a regression test that forces start_trace to return None after mutating parent state.

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed trace, but I don't think this one holds up — I tried to reproduce the described state and couldn't reach it. Verified against fbb8e15.

The premise is that start_trace can return None after add_trace already ran _set_current_parent(trace). I don't think that state is reachable.

1. @warn_catch_exception() here does not catch Exception. start_trace uses the bare form (logger.py:1300-1301), so exceptions defaults to INFRASTRUCTURE_EXCEPTIONS:

httpx.HTTPError, httpx.TimeoutException, httpx.ConnectError, httpx.ReadError,
httpx.WriteError, ConnectionError, TimeoutError, OSError

Note the contrast with siblings like add_single_llm_span_trace, which explicitly pass exceptions=(Exception,). So "returns None … when it raises internally" only covers infrastructure errors, not arbitrary ones.

2. No exception of any kind can escape add_trace after the parent is set. The only three statements following _set_current_parent(trace) are:

self._record_otel_ids(trace)
self._sync_otel_context(trace)
return trace

_record_otel_ids (logger.py:473) and _sync_otel_context (logger.py:492) each wrap their whole body in except Exception: → log-and-continue. return trace can't throw. Everything that can throw — the ValueError guard, LoggedTrace(...) validation, traces.append — happens strictly before the assignment, and leaves no parent state behind.

3. The logging-disabled path leaves no chain. nop_sync skips the body entirely, so no parent is ever set:

start_trace() -> None   |   current_parent() -> None

4. add_trace's ValueError propagates rather than returning None — it isn't an infrastructure exception. Confirmed: with a chain already open, start_trace raises ValueError("You must conclude the existing trace before adding a new one.") and current_parent() still is the caller's trace.

So owned_trace is None implies current_parent() is None, and the if owned_trace is not None guard is skipping cleanup that would be a no-op anyway. This matches the conclusion your earlier review reached on this exact call site ("Behaviour preserved, and the new version is clearer about intent") — I don't see new evidence that overturns it.

Separately: the suggested fix would introduce a real bug. owned_trace or self._splunk_ao_logger._current_root() falls back to whatever root is current — which, when a caller owns the chain and start_new_trace=True, is the caller's trace. I simulated it:

fallback target is the CALLER's trace: True
caller status_code after fallback: 500
current_parent now: None

That stamps someone else's trace as a 500 and clears their parent chain — exactly the invariant test_commit_failure_preserves_caller_owned_trace (tests/test_base_handler.py:156) exists to protect.

Leaving as-is. Happy to reopen if you can show a concrete path where start_trace returns None with a non-None current_parent() — that's the specific step I couldn't construct.


def log_node_tree(self, node: Node) -> None:
Expand Down
12 changes: 1 addition & 11 deletions src/splunk_ao/handlers/openai_agents/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,7 @@ def _commit_trace(self, trace: Trace) -> None:
self._splunk_ao_logger.conclude(output=self._last_output, status_code=self._last_status_code)

def _conclude_current_trace_on_failure(self) -> None:
if self._owned_trace is None:
return

current_parent = self._splunk_ao_logger.current_parent()
if current_parent is None:
return

root = current_parent
while root._parent is not None:
root = root._parent
if root is self._owned_trace:
if self._splunk_ao_logger._is_current_root(self._owned_trace):
self._splunk_ao_logger.conclude(output="", status_code=500, conclude_all=True)
Comment on lines 112 to 114

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor (bug): The old code had an explicit if self._owned_trace is None: return guard, so _conclude_current_trace_on_failure was a documented no-op when the processor did not own a trace. That guard is now implicit inside _is_current_root, which reads fine — but it means the only way this method reclaims a chain is via the self._owned_trace handle.

_owned_trace is assigned at line 139 from add_trace(...), which is reached inside _log_node_tree(root_node, first_node=True) — itself called from _commit_trace inside the try at line 87. If add_trace succeeds (setting the logger's current parent) but the assignment's enclosing statement is interrupted, or if a later add_workflow_span throws before _owned_trace is observed, _conclude_current_trace_on_failure runs with _owned_trace = None and does nothing — then finally (line 98) resets _owned_trace = None, discarding the last handle to an open chain.

This is the same shape as the base_handler issue and is likely low-frequency in practice, but since the whole purpose of this method is failure cleanup, it would be worth either asserting the invariant or falling back to _current_root() when _owned_trace is unexpectedly None.

🤖 Generated by the Astra agent


def _log_node_tree(self, node: Node, first_node: bool = False) -> None:
Expand Down
20 changes: 15 additions & 5 deletions src/splunk_ao/logger/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,9 @@ def __init__(
"User must provide project_name or project_id to SplunkAOLogger, or set it as an environment variable."
)
if self.experiment_id is None and self.agent_stream_name is None and self.agent_stream_id is None:
raise SplunkAOLoggerException("agent_stream or agent_stream_id is required to initialize SplunkAOLogger.")
raise SplunkAOLoggerException(
"agent_stream or agent_stream_id is required to initialize SplunkAOLogger."
)

if local_metrics:
self.local_metrics = local_metrics
Expand Down Expand Up @@ -413,12 +415,20 @@ def _set_current_parent(self, parent: StepWithChildSpans | None) -> None:
super()._set_current_parent(parent)
self._sync_otel_context(parent)

def reset_parent_tracking(self) -> None:
"""Clear proprietary and OTel tracking for the current request context."""
current_parent = self.current_parent()
root = current_parent
def _current_root(self) -> StepWithChildSpans | None:
"""Return the root of the current proprietary parent chain."""
root = self.current_parent()
while root is not None and root._parent is not None:
root = root._parent
return root

def _is_current_root(self, trace: Trace | None) -> bool:
"""Return whether trace owns the current proprietary parent chain."""
return trace is not None and self._current_root() is trace
Comment on lines +425 to +427

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor (design): The trace: Trace | None annotation is narrower than the actual call sites, which weakens the type checking this signature is supposed to provide.

  • base_handler.py:107 declares trace: Any and forwards it straight through.
  • openai_agents/handler.py:113 passes self._owned_trace, which is declared Any (line 61) and assigned from add_trace(...).

So mypy cannot actually verify that callers pass a Trace. Meanwhile _current_root() returns StepWithChildSpans | None — the broader type — and the identity comparison works for any object, so the narrow annotation buys nothing at runtime either.

Consider widening to match what _current_root returns, which makes the contract honest and would let base_handler/openai_agents tighten their own annotations from Any in a follow-up:

def _is_current_root(self, trace: StepWithChildSpans | None) -> bool:
Suggested change
def _is_current_root(self, trace: Trace | None) -> bool:
"""Return whether trace owns the current proprietary parent chain."""
return trace is not None and self._current_root() is trace
def _is_current_root(self, trace: StepWithChildSpans | None) -> bool:
"""Return whether trace owns the current proprietary parent chain."""
return trace is not None and self._current_root() is trace

🤖 Generated by the Astra agent


def reset_parent_tracking(self) -> None:
"""Clear proprietary and OTel tracking for the current request context."""
root = self._current_root()

self._set_current_parent(None)
if root is not None:
Expand Down
57 changes: 50 additions & 7 deletions tests/test_attribute_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,54 @@ def test_orchestration_preserves_schema_valid_parts_and_tool_calls() -> None:
]


def test_orchestration_preserves_explicit_empty_message_parts() -> None:
# Given: role-bearing input and output messages with explicitly empty parts
span = WorkflowSpan(
name="empty-parts-workflow", input='{"role":"user","parts":[]}', output='{"role":"assistant","parts":[]}'
)

# When: the messages are mapped to canonical OTel attributes
attrs = build_span_attributes(span)

# Then: empty parts remain empty instead of becoming a text part containing "[]"
assert json.loads(attrs["gen_ai.input.messages"]) == [{"role": "user", "parts": []}]
assert json.loads(attrs["gen_ai.output.messages"]) == [
{"role": "assistant", "parts": [], "finish_reason": "unknown"}
]


def test_orchestration_treats_explicit_empty_parts_as_authoritative() -> None:
# Given: role-bearing messages with both explicit empty parts and legacy content
span = WorkflowSpan(
name="both-content-fields-workflow",
input='{"role":"user","parts":[],"content":"ignored input"}',
output='{"role":"assistant","parts":[],"content":"ignored output"}',
)

# When: the messages are mapped to canonical OTel attributes
attrs = build_span_attributes(span)

# Then: the explicit OTel parts field takes precedence over legacy content
assert json.loads(attrs["gen_ai.input.messages"]) == [{"role": "user", "parts": []}]
assert json.loads(attrs["gen_ai.output.messages"]) == [
{"role": "assistant", "parts": [], "finish_reason": "unknown"}
]


def test_orchestration_keeps_missing_parts_and_empty_content_distinct() -> None:
# Given: role-bearing messages with no parts field and explicit empty content
span = WorkflowSpan(
name="empty-content-workflow", input='{"role":"user","content":""}', output='{"role":"assistant","content":""}'
)

# When: the messages are mapped to canonical OTel attributes
attrs = build_span_attributes(span)

# Then: existing empty-content behavior remains a typed empty text part
assert json.loads(attrs["gen_ai.input.messages"]) == [_text_message("user", "")]
assert json.loads(attrs["gen_ai.output.messages"]) == [_text_message("assistant", "", finish_reason="unknown")]


@pytest.mark.parametrize(
"span",
[
Expand Down Expand Up @@ -558,11 +606,7 @@ def test_control_mapping_omits_unpopulated_optional_fields() -> None:


def test_control_mapping_accepts_schema_compatible_control_span() -> None:
source = ControlSpan(
name="guardrail",
output=ControlResult(action="observe", matched=True),
control_id=42,
)
source = ControlSpan(name="guardrail", output=ControlResult(action="observe", matched=True), control_id=42)
alternate = SimpleNamespace(
**{field_name: getattr(source, field_name) for field_name in type(source).model_fields}, model_extra={}
)
Expand Down Expand Up @@ -601,8 +645,7 @@ def test_control_mapping_tolerates_span_without_control_fields() -> None:

def test_control_mapping_exports_error_result_without_dropping_false() -> None:
span = ControlSpan(
name="guardrail",
output=ControlResult(action="observe", matched=False, error_message="evaluator unavailable"),
name="guardrail", output=ControlResult(action="observe", matched=False, error_message="evaluator unavailable")
)

attrs = build_span_attributes(span)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_decorator_operation_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ def failing_operation() -> None:
logger = splunk_ao_context.get_logger_instance()
assert logger.current_parent() is None
assert splunk_ao_context.get_current_trace() is None
assert (logger._sink.spans[-1].attributes or {})["splunk_ao.status_code"] == 500


@pytest.mark.asyncio
async def test_async_coroutine_exception_is_preserved_and_owned_trace_is_concluded(initialized_context: None) -> None:
# Given: a decorated async operation that raises an application exception
@log(span_type="workflow")
async def failing_operation() -> None:
await asyncio.sleep(0)
raise RuntimeError("async application failure")

# When: the operation is awaited
with pytest.raises(RuntimeError, match="async application failure"):
await failing_operation()

# Then: the original exception is re-raised and both telemetry contexts are released
logger = splunk_ao_context.get_logger_instance()
assert logger.current_parent() is None
assert splunk_ao_context.get_current_trace() is None
Comment on lines +100 to +107

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor (testing): The test name promises owned_trace_is_concluded, but the assertions only check that both contexts were released. It does catch a skipped _conclude_owned_trace (current_parent() would be non-None), so it isn't vacuous — but it passes regardless of whether the trace was concluded with the failure status, which is the part the async path is most likely to get wrong.

test_commit_failure_concludes_handler_owned_trace in tests/test_openai_agents.py:196 sets the bar here by asserting owned_traces[0].status_code == 500. Suggest matching it, e.g. capture the trace via logger.traces[-1] and assert status_code == 500, or assert the exported span in logger._sink.spans carries the error status — otherwise a regression that concludes the trace with a success status would slip through.

(The sync sibling at line 79 has the same gap; no need to fix it here, but it would be worth strengthening both together.)

🤖 Generated by the Astra agent

assert (logger._sink.spans[-1].attributes or {})["splunk_ao.status_code"] == 500


def test_sync_generator_concludes_on_close_and_preserves_errors(initialized_context: None) -> None:
Expand Down
58 changes: 55 additions & 3 deletions tests/test_exporter_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DiagnosticOTLPSpanExporter,
ExportHealth,
_ExportHealthTracker,
_positive_json_integer,
_RejectionDetail,
)
from splunk_ao.exporter.sink import BatchConfig, build_span_sink
Expand Down Expand Up @@ -108,19 +109,70 @@ def test_positive_otlp_partial_success_returns_failure_without_retry() -> None:
assert exporter._attempt_local.response is None


@pytest.mark.parametrize("rejected_spans", [3, "3"])
def test_positive_json_otlp_partial_success_returns_failure_without_backend_detail(rejected_spans: int | str) -> None:
@pytest.mark.parametrize("rejected_spans", [3, "3", 3.0])
def test_positive_json_otlp_partial_success_returns_failure_without_backend_detail(
rejected_spans: int | float | str,
) -> None:
# Given: a successful OTLP JSON response with an unambiguous positive rejection count
body = json.dumps(
{"partialSuccess": {"rejectedSpans": rejected_spans, "errorMessage": "do not retain this backend detail"}}
).encode()
exporter = diagnostic_exporter(FakeSession(response(body=body)))

assert exporter.export(()) == SpanExportResult.FAILURE
# When: the response is classified
result = exporter.export(())

# Then: each supported JSON representation produces the same bounded count without backend detail
assert result == SpanExportResult.FAILURE
assert exporter.export_health.last_failure is not None
assert "Rejected spans: 3" in exporter.export_health.last_failure.message
assert "backend detail" not in exporter.export_health.last_failure.message


@pytest.mark.parametrize(
"value",
[
True,
False,
0,
-1,
0.0,
-3.0,
3.5,
float(2**53),
1e30,
float("nan"),
float("inf"),
float("-inf"),
"",
"+3",
"-3",
"3.0",
"3e0",
{},
[],
],
)
def test_invalid_json_rejected_span_counts_are_ignored(value: object) -> None:
# Given: a value that does not unambiguously represent a positive integer
# When: the acknowledgement count is parsed
result = _positive_json_integer(value)

# Then: the value is ignored without raising
assert result is None


def test_max_safe_json_float_rejected_span_count_is_accepted() -> None:
# Given: the largest integer-valued float that JSON can represent unambiguously
value = float(2**53 - 1)

# When: the acknowledgement count is parsed
result = _positive_json_integer(value)

# Then: the exact safe integer value is retained
assert result == 2**53 - 1


@pytest.mark.parametrize(
("body", "content_type"),
[
Expand Down
24 changes: 24 additions & 0 deletions tests/test_logger_otel_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from splunk_ao.logger import SplunkAOLogger
from splunk_ao.logger.logger import _otel_context_state
from splunk_ao.schema.logged import LoggedTrace


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -56,6 +57,29 @@ def test_start_trace_assigns_and_activates_fresh_context(make_logger: Callable[[
assert logger._otel_ids == {}


def test_logger_identifies_current_root_ownership(make_logger: Callable[[], SplunkAOLogger]) -> None:
# Given: an empty logger and an unrelated proprietary trace
logger = make_logger()
unrelated_trace = LoggedTrace(input="unrelated")

# Then: absent inputs and an empty parent chain are never owned
assert logger._current_root() is None
assert logger._is_current_root(None) is False
assert logger._is_current_root(unrelated_trace) is False

# When: an owned root and nested current child are created
owned_trace = logger.start_trace(input="request", name="owned")
assert logger._current_root() is owned_trace
assert logger._is_current_root(owned_trace) is True
logger.add_workflow_span(input="nested", name="nested")

# Then: the root is discovered by identity and an unrelated trace is rejected
assert logger._current_root() is owned_trace
assert logger._is_current_root(owned_trace) is True
assert logger._is_current_root(unrelated_trace) is False
logger.conclude(output="done", conclude_all=True)


def test_every_path1_step_gets_stable_ids_and_actual_parent(make_logger: Callable[[], SplunkAOLogger]) -> None:
logger = make_logger()
root = logger.start_trace(input="q")
Expand Down