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
4 changes: 3 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,9 @@ 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:
if isinstance(source_parts, list) and not source_parts:
parts = []
elif source_parts is not None:
parts = _content_parts(source_parts)
Comment on lines +175 to 178

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

elif role == "tool":
response = {"type": "tool_call_response", "response": _parse_json_value(content)}
Expand Down
7 changes: 1 addition & 6 deletions src/splunk_ao/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,12 +903,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
4 changes: 4 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 Down Expand Up @@ -247,6 +248,9 @@ 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():
parsed = int(value)
return parsed if parsed > 0 else None
Comment on lines +251 to +253

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): Integral floats above 2^53 are accepted and converted to a misleading exact integer. _positive_json_integer(1e30) is finite and is_integer(), so it returns 1000000000000000019884624838656 and the operator sees Rejected spans: 1000000000000000019884624838656. — digits the payload never contained. The 512-char message cap keeps the log line bounded, but the number itself is fabricated by float→int conversion.

The protobuf path is naturally bounded by int64, so requiring exact float representability keeps the JSON path consistent with it:

if isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2**53:

The new parametrized test is a good place to pin this — adding 1e30 to test_invalid_json_rejected_span_counts_are_ignored would lock in the behaviour either way.

Suggested change
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
parsed = int(value)
return parsed if parsed > 0 else None
if isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2**53:
parsed = int(value)
return parsed if parsed > 0 else None

🤖 Generated by the Astra agent

if isinstance(value, str) and value.isdecimal():
parsed = int(value)
return parsed if parsed > 0 else None
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)

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)

def _log_node_tree(self, node: Node, first_node: bool = False) -> None:
Expand Down
16 changes: 15 additions & 1 deletion 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,6 +415,18 @@ def _set_current_parent(self, parent: StepWithChildSpans | None) -> None:
super()._set_current_parent(parent)
self._sync_otel_context(parent)

def _is_current_root(self, trace: Trace | None) -> bool:
"""Return whether trace owns the current proprietary parent chain."""
if trace is None:
return False

root = self.current_parent()
if root is None:
return False
while root._parent is not None:
root = root._parent
return root is trace
Comment on lines +418 to +428

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 PR's stated goal is to centralize the current-root ownership check, but only the boolean form was centralized — three copies of the same parent-chain walk remain:

  • logger.py:432-435 (reset_parent_tracking)
  • decorator.py:759-762 (resolving the trace root in _prepare_call)
  • handlers/agent_control/bridge.py:248-250 (_active_context)

Each of those needs the root object rather than a boolean, so they can't call _is_current_root as written. Extracting the walk once and layering the predicate on top would actually finish the centralization and leave a single place to fix if the chain representation changes:

def _current_root(self) -> StepWithChildSpans | None:
    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

Separately: _is_current_root is underscore-private but is now called from handlers/base_handler.py, handlers/openai_agents/handler.py, and decorator.py. Since base_handler accepts a caller-supplied splunk_ao_logger, this private method is effectively part of the contract that a substituted logger must satisfy. Consider dropping the underscore, or noting in the docstring that handlers depend on it.

Suggested change
def _is_current_root(self, trace: Trace | None) -> bool:
"""Return whether trace owns the current proprietary parent chain."""
if trace is None:
return False
root = self.current_parent()
if root is None:
return False
while root._parent is not None:
root = root._parent
return root is trace
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

🤖 Generated by the Astra agent


def reset_parent_tracking(self) -> None:
"""Clear proprietary and OTel tracking for the current request context."""
current_parent = self.current_parent()
Expand Down
39 changes: 32 additions & 7 deletions tests/test_attribute_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,36 @@ 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_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 +588,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 +627,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
39 changes: 39 additions & 0 deletions tests/test_decorator_operation_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from splunk_ao import log, splunk_ao_context
from splunk_ao.schema.logged import LoggedTrace
from tests.testutils.setup import setup_mock_logstreams_client, setup_mock_projects_client, setup_mock_traces_client


Expand Down Expand Up @@ -88,6 +89,44 @@ def failing_operation() -> None:
assert splunk_ao_context.get_current_trace() is None


@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



def test_logger_identifies_current_root_ownership(initialized_context: None) -> None:
# Given: an empty logger and an unrelated proprietary trace
logger = splunk_ao_context.get_logger_instance()
unrelated_trace = LoggedTrace(input="unrelated")

# Then: absent inputs and an empty parent chain are never owned
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._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._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_sync_generator_concludes_on_close_and_preserves_errors(initialized_context: None) -> None:
@log(span_type="workflow")
def stream(fail: bool = False):
Expand Down
45 changes: 42 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,57 @@ 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("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


@pytest.mark.parametrize(
("body", "content_type"),
[
Expand Down
Loading