Skip to content
Merged
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
351 changes: 272 additions & 79 deletions src/splunk_ao/decorator.py

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion src/splunk_ao/exporter/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ class RoutingAttrs:

ExporterFactory = Callable[..., SpanExporter]

RESERVED_ROUTING_RESOURCE_ATTRIBUTES = frozenset(
{
"splunk_ao.project.name",
"splunk_ao.project.id",
"splunk_ao.logstream.name",
"splunk_ao.logstream.id",
"splunk_ao.experiment.id",
}
)


def _resolve_name_or_id(
label: str,
Expand Down Expand Up @@ -149,7 +159,14 @@ def routing_resource_attributes(routing: RoutingAttrs) -> dict[str, str]:

def create_otel_resource(routing: RoutingAttrs) -> Resource:
"""Create a Resource with standard OTel detection and Splunk AO routing."""
return Resource.create(routing_resource_attributes(routing))
detected_resource = Resource.create()
attributes = {
key: value
for key, value in detected_resource.attributes.items()
if key not in RESERVED_ROUTING_RESOURCE_ATTRIBUTES
}
attributes.update(routing_resource_attributes(routing))
return Resource(attributes, schema_url=detected_resource.schema_url)


def build_exporter(
Expand Down
53 changes: 28 additions & 25 deletions src/splunk_ao/handlers/base_async_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,31 +42,34 @@ async def async_commit(self) -> None:
_logger.warning("Unable to add nodes to trace: Root node does not exist")
return

if self._start_new_trace:
self._splunk_ao_logger.start_trace(
input=serialize_to_str(root_node.span_params.get("input", "")),
name=root_node.span_params.get("name"),
metadata=root_node.span_params.get("metadata"),
)

self.log_node_tree(root_node)

# Conclude the trace with the root node's output
root_output = root_node.span_params.get("output", "")

if self._start_new_trace:
# If we started a new trace, we need to conclude it
self._splunk_ao_logger.conclude(
output=serialize_to_str(root_output), status_code=root_node.span_params.get("status_code")
)

if self._flush_on_chain_end:
# Upload the trace to Splunk AO
await self._splunk_ao_logger.async_flush()

# Clear nodes after successful commit
self._nodes.clear()
self._root_node = None
owned_trace = None
try:
if self._start_new_trace:
owned_trace = self._splunk_ao_logger.start_trace(
input=serialize_to_str(root_node.span_params.get("input", "")),
name=root_node.span_params.get("name"),
metadata=root_node.span_params.get("metadata"),
)

self.log_node_tree(root_node)
root_output = root_node.span_params.get("output", "")

if self._start_new_trace:
self._conclude_owned_trace(
owned_trace,
output=serialize_to_str(root_output),
status_code=root_node.span_params.get("status_code"),
)

if self._flush_on_chain_end:
await self._splunk_ao_logger.async_flush()
except Exception:
if owned_trace is not None:
self._conclude_owned_trace(owned_trace, output="", status_code=500)
_logger.warning("Failed to commit async handler telemetry", exc_info=True)
finally:
self._nodes.clear()
self._root_node = None

async def async_end_node(self, run_id: UUID, **kwargs: Any) -> None:
"""
Expand Down
24 changes: 19 additions & 5 deletions src/splunk_ao/handlers/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(
integration: INTEGRATION = "langchain",
splunk_ao_logger: SplunkAOLogger | None = None,
start_new_trace: bool = True,
flush_on_chain_end: bool = True,
flush_on_chain_end: bool | None = None,
ingestion_hook: Callable[[TracesIngestRequest], None] | None = None,
):
self._splunk_ao_logger: SplunkAOLogger = splunk_ao_logger or splunk_ao_context.get_logger_instance(
Expand All @@ -52,7 +52,7 @@ def __init__(
raise ValueError("ingestion_hook can only be used in batch mode")
self._splunk_ao_logger._ingestion_hook = ingestion_hook
self._start_new_trace: bool = start_new_trace
self._flush_on_chain_end: bool = flush_on_chain_end
self._flush_on_chain_end = ingestion_hook is not None if flush_on_chain_end is None else flush_on_chain_end
self._nodes: dict[str, Node] = {}
self._root_node: Node | None = None
self._integration: INTEGRATION = integration
Expand All @@ -73,9 +73,10 @@ def commit(self) -> None:
_logger.warning("Unable to add nodes to trace: Root node does not exist")
return

owned_trace = None
try:
if self._start_new_trace:
self._splunk_ao_logger.start_trace(
owned_trace = self._splunk_ao_logger.start_trace(
input=SplunkAOLogger._coerce_output(root_node.span_params.get("input", "")),
name=root_node.span_params.get("name"),
metadata=root_node.span_params.get("metadata"),
Expand All @@ -87,18 +88,31 @@ def commit(self) -> None:
root_output = root_node.span_params.get("output", "")

if self._start_new_trace:
self._splunk_ao_logger.conclude(
self._conclude_owned_trace(
owned_trace,
output=SplunkAOLogger._coerce_output(root_output),
status_code=root_node.span_params.get("status_code"),
)

if self._flush_on_chain_end:
self._splunk_ao_logger.flush()
except Exception:
if owned_trace is not None:
self._conclude_owned_trace(owned_trace, output="", status_code=500)
_logger.warning("Failed to commit handler telemetry", exc_info=True)
Comment on lines +99 to +102

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): commit() previously let exceptions from start_trace/log_node_tree/conclude/flush propagate (only the finally cleaned up). This new except Exception: block swallows all of them and downgrades to a warning. Silently dropping a partially-built trace is defensible for a telemetry callback, but it's a real change in error-propagation semantics for langchain/crewai integrations. Please confirm this swallowing is intended (and ideally note it) — if a framework relied on on_chain_end surfacing handler errors, it will now go quiet. Same pattern was added in base_async_handler.async_commit and openai_agents/handler.on_trace_end.

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed intentional. These are telemetry callback failures and must not fail the instrumented LangChain/CrewAI operation. The handler logs the failure, cleans up only a trace it owns, and preserves any caller-owned trace.

finally:
# Always clean up, even if trace building or flush fails
self._nodes.clear()
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:
self._splunk_ao_logger.conclude(output=output, status_code=status_code, conclude_all=True)

def log_node_tree(self, node: Node) -> None:
"""
Log a node and its children recursively.
Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/crewai/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def __init__(
self,
splunk_ao_logger: SplunkAOLogger | None = None,
start_new_trace: bool = True,
flush_on_crew_completed: bool = True,
flush_on_crew_completed: bool = False,
):
_resolve_crewai_imports()

Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/langchain/async_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def __init__(
self,
splunk_ao_logger: SplunkAOLogger | None = None,
start_new_trace: bool = True,
flush_on_chain_end: bool = True,
flush_on_chain_end: bool | None = None,
ingestion_hook: Callable[[TracesIngestRequest], None] | None = None,
):
self._handler = SplunkAOAsyncBaseHandler(
Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/langchain/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def __init__(
self,
splunk_ao_logger: SplunkAOLogger | None = None,
start_new_trace: bool = True,
flush_on_chain_end: bool = True,
flush_on_chain_end: bool | None = None,
ingestion_hook: Callable[[TracesIngestRequest], None] | None = None,
):
self._handler = SplunkAOBaseHandler(
Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/langchain/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(
self,
splunk_ao_logger: SplunkAOLogger | None = None,
start_new_trace: bool = True,
flush_on_chain_end: bool = True,
flush_on_chain_end: bool | None = None,
ingestion_hook: Callable[[TracesIngestRequest], None] | None = None,
) -> None:
if not HAS_LANGCHAIN:
Expand Down
43 changes: 30 additions & 13 deletions src/splunk_ao/handlers/openai_agents/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class SplunkAOTracingProcessor(TracingProcessor):
Stores Node objects keyed by their OpenAI span_id or trace_id (for root).
"""

def __init__(self, splunk_ao_logger: SplunkAOLogger | None = None, flush_on_trace_end: bool = True):
def __init__(self, splunk_ao_logger: SplunkAOLogger | None = None, flush_on_trace_end: bool = False):
"""
OpenAI Agents TracingProcessor for logging traces to Splunk AO.

Expand All @@ -58,6 +58,7 @@ def __init__(self, splunk_ao_logger: SplunkAOLogger | None = None, flush_on_trac
self._last_output: Any = None
self._last_status_code: int | None = None
self._first_input: Any = None
self._owned_trace: Any = None

def on_trace_start(self, trace: Trace) -> None:
"""Called when an OpenAI Agent trace starts."""
Expand All @@ -82,17 +83,19 @@ def on_trace_end(self, trace: Trace) -> None:

node.span_params["duration_ns"] = convert_time_delta_to_ns(_get_timestamp() - node.span_params["start_time"])

# Log the trace to Splunk AO (this includes concluding the trace)
self._commit_trace(trace)
self._nodes = {}

self._last_output = None
self._last_status_code = None
self._first_input = None

# Optionally flush the log batch
if self._flush_on_trace_end:
self._splunk_ao_logger.flush()
try:
self._commit_trace(trace)
if self._flush_on_trace_end:
self._splunk_ao_logger.flush()
except Exception:
self._conclude_current_trace_on_failure()
_logger.warning("Failed to commit OpenAI Agents telemetry", exc_info=True)
finally:
self._nodes = {}
self._last_output = None
self._last_status_code = None
self._first_input = None
self._owned_trace = None

def _commit_trace(self, trace: Trace) -> None:
if not self._nodes:
Expand All @@ -106,6 +109,20 @@ def _commit_trace(self, trace: Trace) -> None:
_logger.warning(f"Root node {trace.trace_id} not found")
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:
self._splunk_ao_logger.conclude(output="", status_code=500, conclude_all=True)
Comment on lines +112 to +124

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): _conclude_current_trace_on_failure() is new failure-path logic (root-walk + guarded conclude(conclude_all=True)), but unlike the base/async handlers — which got test_commit_failure_* tests — there's no test exercising a _commit_trace failure for the OpenAI processor. Add a test that patches _commit_trace/_log_node_tree to raise and asserts the owned trace is concluded (and a caller-owned trace is left intact), matching test_commit_failure_concludes_only_handler_owned_trace / test_commit_failure_preserves_caller_owned_trace.

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added coverage.


def _log_node_tree(self, node: Node, first_node: bool = False) -> None:
"""
Log a node and its children recursively.
Expand All @@ -129,7 +146,7 @@ def _log_node_tree(self, node: Node, first_node: bool = False) -> None:
if metadata is not None:
metadata = convert_to_string_dict(metadata)
if first_node:
self._splunk_ao_logger.add_trace(
self._owned_trace = self._splunk_ao_logger.add_trace(
input=self._first_input or "Agent Workflow",
output=self._last_output,
duration_ns=node.span_params.get("duration_ns"),
Expand Down
16 changes: 15 additions & 1 deletion tests/test_async_base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async def test_initialization(self, splunk_ao_logger: SplunkAOLogger) -> None:
callback = SplunkAOAsyncBaseHandler(splunk_ao_logger=splunk_ao_logger)
assert callback._splunk_ao_logger == splunk_ao_logger
assert callback._start_new_trace is True
assert callback._flush_on_chain_end is True
assert callback._flush_on_chain_end is False
assert callback._nodes == {}

# Custom initialization
Expand Down Expand Up @@ -101,3 +101,17 @@ async def test_end_node(self, handler: SplunkAOAsyncBaseHandler, splunk_ao_logge
assert traces[0].spans[0].type == "workflow"
assert traces[0].spans[0].input == '{"query": "test"}'
assert traces[0].spans[0].output == '{"result": "test result"}'

@pytest.mark.asyncio
async def test_commit_failure_concludes_handler_owned_trace(
self, handler: SplunkAOAsyncBaseHandler, splunk_ao_logger: SplunkAOLogger
) -> None:
run_id = uuid.uuid4()
await handler.async_start_node(node_type="chain", parent_run_id=None, run_id=run_id, name="Test", input="test")

with patch.object(handler, "log_node_tree", side_effect=RuntimeError("conversion failed")):
await handler.async_end_node(run_id, output="result")

assert splunk_ao_logger.current_parent() is None
assert handler._nodes == {}
assert handler._root_node is None
29 changes: 28 additions & 1 deletion tests/test_base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_initialization(self, splunk_ao_logger: SplunkAOLogger) -> None:
handler = SplunkAOBaseHandler(splunk_ao_logger=splunk_ao_logger)
assert handler._splunk_ao_logger == splunk_ao_logger
assert handler._start_new_trace is True
assert handler._flush_on_chain_end is True
assert handler._flush_on_chain_end is False
assert handler._nodes == {}

# Custom initialization
Expand Down Expand Up @@ -139,3 +139,30 @@ def test_commit_no_flush_when_disabled(self) -> None:
# Then: neither flush nor terminate is called
mock_logger.flush.assert_not_called()
mock_logger.terminate.assert_not_called()

def test_commit_failure_concludes_only_handler_owned_trace(
self, handler: SplunkAOBaseHandler, splunk_ao_logger: SplunkAOLogger
) -> None:
run_id = uuid.uuid4()
handler.start_node(node_type="chain", parent_run_id=None, run_id=run_id, name="Test", input="test")

with patch.object(handler, "log_node_tree", side_effect=RuntimeError("conversion failed")):
handler.end_node(run_id, output="result")

assert splunk_ao_logger.current_parent() is None
assert handler._nodes == {}
assert handler._root_node is None

def test_commit_failure_preserves_caller_owned_trace(self, splunk_ao_logger: SplunkAOLogger) -> None:
caller_trace = splunk_ao_logger.start_trace(input="request", name="caller")
handler = SplunkAOBaseHandler(
splunk_ao_logger=splunk_ao_logger, start_new_trace=False, flush_on_chain_end=False
)
run_id = uuid.uuid4()
handler.start_node(node_type="chain", parent_run_id=None, run_id=run_id, name="Test", input="test")

with patch.object(handler, "log_node_tree", side_effect=RuntimeError("conversion failed")):
handler.end_node(run_id, output="result")

assert splunk_ao_logger.current_parent() is caller_trace
splunk_ao_logger.conclude(output="done")
Loading
Loading