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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ CI supports Python 3.11–3.14; root CI also spans Linux, macOS, and Windows.
code; sanitize and rate-limit diagnostics.
- Preserve standard `gen_ai.*` attributes. New SDK-owned attributes use `splunk_ao.*`; do not introduce new proprietary
`galileo.*` wire attributes.
- Successful agent/workflow decorators returning `None` inherit the last child's output through the logger. Preserve
explicit empty outputs and do not substitute successful child output for an exception. Capture provider finish reasons
before enqueue; enclosing output may inherit a terminal LLM reason only when their normalized outputs match.
- Explicit session selection is ambient within the current thread or async execution context and is shared by logger
instances in that context. An explicit clear masks inbound baggage in that context. Independent simultaneous sessions
require separate execution contexts.
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ matching end callback, so a child can enter `BatchSpanProcessor` while its paren
- Completion, emission, and state release are separate operations. Preserve idempotency under retries and cleanup.
- Telemetry failures are diagnostic events, not reasons to fail the instrumented application.

Successful decorated agent/workflow calls that return `None` preserve the logger's last-child output fallback.
Explicit empty strings remain explicit outputs; exceptions do not substitute a successful child's answer.
Manual LLM logging and the OpenAI Chat Completions wrapper preserve provider-reported finish reasons before enqueue.
Conversion uses a terminal child's reason on an enclosing agent/workflow output only when the normalized outputs match.
Missing reasons remain unknown, including streams closed before a terminal provider chunk.

OTel `ReadableSpan` data is effectively immutable after end. `exporter/span_transform.py` creates normalized copies;
never change private span fields in place. This protects concurrent exporters and caller-owned processors.

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Successful `@log` agent/workflow calls returning `None` now inherit their last
child's output, matching manual logger behavior. Explicit empty answers and
exception handling remain distinct from missing output.
- The OpenAI Chat Completions wrapper preserves provider finish reasons for
normal and streaming responses. Manual LLM logger methods accept
`finish_reason`; enclosing agent/workflow messages retain the terminal LLM's
reason when their outputs match.

### Added

- `SplunkAOLogger.add_llm_span()` and `add_single_llm_span_trace()` accept
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ make_nested_call()
If a decorated function, synchronous generator, or asynchronous generator
raises, the SDK finalizes its telemetry and re-raises the original exception.

A decorated agent or workflow that returns `None` uses its last child's output
when available. This supports an operation that prints an instrumented model's
answer without returning it; the SDK does not capture arbitrary standard output.
Explicit return values, including an empty string, take precedence. Failed
operations do not inherit a successful child's answer.

The OpenAI Chat Completions wrapper preserves provider-reported finish reasons,
including terminal streaming chunks. Manual `add_llm_span()` and
`add_single_llm_span_trace()` calls can supply `finish_reason="stop"` (or another
provider-reported value). Enclosing agent and workflow messages use the terminal
LLM's finish reason when their output matches that child's answer. Without a
reported reason, the SDK retains `unknown` rather than assuming completion.

Create a retriever span:

```python
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
| Example | Framework | Description |
|---|---|---|
| [healthcare-assistant](agent/healthcare-assistant/README.md) | LangGraph + Streamlit | Full-stack chat app with RAG, text-to-SQL, hallucination demo, and Splunk AO tracing |
| [banking-agent](logging-samples/agent-streams/README.md) | OpenAI | Tool-using banking agent with Agent Stream routing and a shared session |

## Preconditions

Expand Down
14 changes: 14 additions & 0 deletions examples/logging-samples/banking-agent-sample/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
OPENAI_API_KEY=your-openai-api-key
OPENAI_MODEL=
SPLUNK_AO_PROJECT=your-splunk-ao-project
SPLUNK_AO_AGENT_STREAM=your-agentstream

# Select exactly one deployment block. Do not mix o11y and standalone variables.
# # O11y Cloud
# SPLUNK_AO_REALM=your-o11y-realm
# SPLUNK_AO_O11Y_TOKEN=your-o11y-ingest-or-ingest+api-token
# # SPLUNK_AO_O11Y_API_TOKEN=your-o11y-api-token

# # Standalone
# SPLUNK_AO_API_KEY=your-splunk-ao-api-key
# SPLUNK_AO_CONSOLE_URL=your-console-url
39 changes: 39 additions & 0 deletions examples/logging-samples/banking-agent-sample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Banking agent with Agent Streams

The sample runs three banking queries in one named session. `@log` captures the
agent workflow and simulated tools; the wrapped OpenAI client captures LLM calls.
The code follows the original sample's structure, including a module-level client,
`load_dotenv("../.env")`, and the original context manager around the three queries.
The tools use mock accounts and a demonstration eligibility rule.

## Configuration

Create `examples/logging-samples/.env` with:

```dotenv
OPENAI_API_KEY=your-openai-api-key
SPLUNK_AO_PROJECT=your-project-name
SPLUNK_AO_AGENT_STREAM=your-agent-stream-name
```

Add the credentials for **one** deployment:

- **Standalone:** `SPLUNK_AO_API_KEY` and `SPLUNK_AO_CONSOLE_URL`.
- **O11y Cloud:** `SPLUNK_AO_REALM` and `SPLUNK_AO_O11Y_TOKEN`.
Session creation requires CRUD permissions on the ingest token or a separate
`SPLUNK_AO_O11Y_API_TOKEN` with those permissions.

See the repository's [setup guide](../../../README.md#setup) for deployment details.

## Run

Run from the sample directory:

```shell
cd examples/logging-samples/banking-agent-sample
uv run sample_app.py
```

Running the example calls OpenAI and sends telemetry to the configured Splunk
Agent Observability deployment. Inspect the session in your project's Agent Stream
to compare tool selection and context adherence across the three queries.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
splunk-ao[openai]
openai
python-dotenv
156 changes: 156 additions & 0 deletions examples/logging-samples/banking-agent-sample/sample_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["splunk-ao[openai]", "python-dotenv"]
# ///
"""
Agent Observability with Splunk AO — Sample Application
Topic: Agent Streams

This script demonstrates a simple tool-using agent instrumented with Splunk AO.
Splunk AO captures every LLM call, tool invocation, and span so you can see
exactly what the agent did, why, and where things went wrong.

This script assumes the .env file is located
in the parent directory, and it's properly configured.
See README.md for setup and run instructions.
"""

import json
import os

from dotenv import load_dotenv

from splunk_ao import log, splunk_ao_context
from splunk_ao.openai import OpenAI

load_dotenv()
project = os.environ.get("SPLUNK_AO_PROJECT")
agent_stream = os.environ.get("SPLUNK_AO_AGENT_STREAM")

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

# --- Simple tools the agent can call ---


@log(span_type="tool")
def get_account_balance(account_id: str) -> dict:
"""Simulated tool: fetch account balance."""
# Simulated data — in a real app this would call your backend
balances = {"ACC-001": {"balance": 4200.00, "currency": "USD"}, "ACC-002": {"balance": 150.75, "currency": "USD"}}
return balances.get(account_id, {"error": f"Account {account_id} not found"})


@log(span_type="tool")
def check_loan_eligibility(account_id: str, loan_amount: float) -> dict:
"""Simulated tool: check if an account is eligible for a loan."""
balance = get_account_balance(account_id).get("balance", 0)
eligible = balance >= loan_amount * 0.2 # Simple rule: need 20% of loan as balance
return {
"eligible": eligible,
"account_id": account_id,
"requested_amount": loan_amount,
"reason": "Sufficient balance" if eligible else "Insufficient balance for loan",
}


# --- Tool definitions for the LLM ---
TOOLS = [
{
"type": "function",
"function": {
"name": "get_account_balance",
"description": "Get the current balance for a bank account",
"parameters": {
"type": "object",
"properties": {"account_id": {"type": "string", "description": "The account ID, e.g. ACC-001"}},
"required": ["account_id"],
},
},
},
{
"type": "function",
"function": {
"name": "check_loan_eligibility",
"description": "Check if an account is eligible for a loan of a given amount",
"parameters": {
"type": "object",
"properties": {
"account_id": {"type": "string", "description": "The account ID"},
"loan_amount": {"type": "number", "description": "The requested loan amount in USD"},
},
"required": ["account_id", "loan_amount"],
},
},
},
]
TOOL_MAP = {"get_account_balance": get_account_balance, "check_loan_eligibility": check_loan_eligibility}
# --- Agent loop instrumented with Splunk AO ---


@log(span_type="agent", name="Agent Workflow")
def run_agent(user_query: str) -> None:
"""
Run a simple tool-using agent and log every step to Splunk AO.
This demonstrates agent observability: every LLM call, tool call,
and final response is captured as a trace with spans.
"""
print(f"\n{'=' * 60}")
print(f"User query: {user_query}")
print(f"{'=' * 60}")
messages = [
{
"role": "system",
"content": (
"You are a helpful banking assistant. "
"Use the available tools to answer questions about accounts and loans. "
"Always use the exact account ID provided by the user."
),
},
{"role": "user", "content": user_query},
]
# Agentic loop — runs until the model stops calling tools
max_steps = 5
for step in range(max_steps):
print(f"\n[Step {step + 1}] Calling LLM...")

response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS, tool_choice="auto"
)

message = response.choices[0].message

# If no tool calls, we're done
if not message.tool_calls:
print(f"\nFinal answer: {message.content}")
break

# Otherwise, process each tool call
messages.append(message)
for tool_call in message.tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)

print(f" -> Tool call: {fn_name}({fn_args})")
# Execute the tool
result = TOOL_MAP[fn_name](**fn_args)
print(f" <- Tool result: {result}")

# Append tool result to message history
messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result)})


# --- Run sample queries ---
if __name__ == "__main__":
with splunk_ao_context(project=project, agent_stream=agent_stream):
splunk_ao_context.start_session(name="Lab 02 - Agent Streams")

# Query 1: Normal flow — should work fine
run_agent("What is the balance for account ACC-001?")

# Query 2: Loan eligibility check
run_agent("Can account ACC-002 get a loan of $5000?")
# Query 3: Ambiguous query — watch how the agent handles it
run_agent("Can I get a loan?")

print("\n\nOpen your Splunk Agent Observability dashboard to see the traces for these 3 runs.")
print("Look for differences in tool selection quality and context adherence across the queries.")
25 changes: 23 additions & 2 deletions src/splunk_ao/converter/attribute_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
from opentelemetry.util.types import AttributeValue
from pydantic import BaseModel

from galileo_core.schemas.logging.span import AgentSpan, LlmSpan, RetrieverSpan, ToolSpan, WorkflowSpan
from galileo_core.schemas.logging.span import (
AgentSpan,
LlmSpan,
RetrieverSpan,
StepWithChildSpans,
ToolSpan,
WorkflowSpan,
)
from galileo_core.schemas.logging.step import BaseStep, StepType
from splunk_ao.logger.control import ControlSpan

Expand Down Expand Up @@ -449,6 +456,19 @@ def set_retriever_attributes(attrs: MutableMapping[str, AttributeValue], span: R
_set_if_present(attrs, "gen_ai.retrieval.top_k", requested_top_k)


def _matching_child_finish_reason(span: WorkflowSpan | AgentSpan, output_messages: list[dict[str, Any]]) -> str | None:
"""Use a terminal LLM's reason only for an enclosing operation with the same output."""
child: BaseStep = span
while isinstance(child, StepWithChildSpans) and child.spans:
child = child.spans[-1]
child_messages, _ = _orchestration_messages(child.output, "assistant")
if child_messages != output_messages:
return None
if isinstance(child, LlmSpan):
return child.finish_reason
return None


def _set_orchestration_content(attrs: MutableMapping[str, AttributeValue], span: WorkflowSpan | AgentSpan) -> None:
input_messages, _ = _orchestration_messages(span.input, "user")
if input_messages is not None:
Expand Down Expand Up @@ -478,7 +498,8 @@ def _set_orchestration_content(attrs: MutableMapping[str, AttributeValue], span:
)
if terminal is not None:
output_messages = output_messages[terminal : terminal + 1]
attrs["gen_ai.output.messages"] = _json_string(_with_finish_reasons(output_messages))
finish_reason = _matching_child_finish_reason(span, output_messages)
attrs["gen_ai.output.messages"] = _json_string(_with_finish_reasons(output_messages, finish_reason))


def set_workflow_attributes(attrs: MutableMapping[str, AttributeValue], span: WorkflowSpan) -> None:
Expand Down
19 changes: 13 additions & 6 deletions src/splunk_ao/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,12 +893,16 @@ def _complete_call(
error: BaseException | None = None,
) -> Any:
final_params = span_params
if error is not None and span_params.get("status_code") is None:
final_params = {**span_params, "status_code": 500}
if error is not None:
final_params = {**span_params}
if final_params.get("status_code") is None:
final_params["status_code"] = 500
if final_params.get("output") is None:
final_params["output"] = ""

output = final_params.get("output")
if output is None:
output = result if result is not None else ""
output = result

try:
self._handle_call_result(span_type, final_params, result, logger=call_state.logger)
Expand Down Expand Up @@ -927,7 +931,7 @@ def _conclude_owned_trace(self, call_state: _CallState, output: Any, status_code
return

try:
trace_output = self._serialize_output(output, None)
trace_output = self._serialize_output(output, None) if output is not None else None
except Exception:
trace_output = ""

Expand Down Expand Up @@ -1006,14 +1010,17 @@ def _handle_call_result(
# Serialize output and redacted_output - set to None if serialization fails
output = span_params.get("output")
if output is None:
output = result if result is not None else ""
output = result
if output is None and span_type and not is_concludable_span_type(span_type):
output = ""

redacted_output = span_params.get("redacted_output")
span_name = span_params.get("name", "unknown")

try:
# Serialize output and redacted_output
output = self._serialize_output(output, span_type)
if output is not None:
output = self._serialize_output(output, span_type)
if redacted_output is not None:
redacted_output = self._serialize_output(redacted_output, span_type)

Expand Down
Loading
Loading