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
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,24 @@ Set your Splunk Observability Cloud realm and access token:

```shell
export SPLUNK_AO_REALM="us1"
export SPLUNK_AO_SF_TOKEN="your-splunk-ingest-token"
export SPLUNK_AO_O11Y_TOKEN="your-splunk-ingest-token"
```

`SPLUNK_AO_SF_TOKEN` is required to export telemetry. It is also used for CRUD
`SPLUNK_AO_O11Y_TOKEN` is required to export telemetry. It is also used for CRUD
operations when it contains the necessary API permissions and no dedicated API
token is configured.

You may configure a separate token for CRUD operations:

```shell
export SPLUNK_AO_SF_API_TOKEN="your-splunk-api-token"
export SPLUNK_AO_O11Y_API_TOKEN="your-splunk-api-token"
```

When both tokens are set, `SPLUNK_AO_SF_API_TOKEN` is preferred for CRUD and
`SPLUNK_AO_SF_TOKEN` is used for telemetry ingestion. For CRUD only use, you
may set `SPLUNK_AO_REALM` and `SPLUNK_AO_SF_API_TOKEN` without setting
`SPLUNK_AO_SF_TOKEN`. Note that attempting to export telemetry without
`SPLUNK_AO_SF_TOKEN` raises a configuration error.
When both tokens are set, `SPLUNK_AO_O11Y_API_TOKEN` is preferred for CRUD and
`SPLUNK_AO_O11Y_TOKEN` is used for telemetry ingestion. For CRUD only use, you
may set `SPLUNK_AO_REALM` and `SPLUNK_AO_O11Y_API_TOKEN` without setting
`SPLUNK_AO_O11Y_TOKEN`. Note that attempting to export telemetry without
`SPLUNK_AO_O11Y_TOKEN` raises a configuration error.

The SDK derives the console, API and OTLP ingest endpoints from the
realm. Do not set `SPLUNK_AO_CONSOLE_URL` or `SPLUNK_AO_API_URL` for O11y
Expand Down
2 changes: 1 addition & 1 deletion splunk-ao-a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ For Splunk Observability Cloud:
| Environment Variable | Description |
|---------------------|-------------|
| `SPLUNK_AO_REALM` | Observability Cloud realm (required) |
| `SPLUNK_AO_SF_TOKEN` | SignalFlow ingest token used for OTLP export (required) |
| `SPLUNK_AO_O11Y_TOKEN` | Splunk O11y ingest token used for OTLP export (required) |
| `SPLUNK_AO_PROJECT` / `SPLUNK_AO_PROJECT_ID` | Optional project routing |
| `SPLUNK_AO_AGENT_STREAM` / `SPLUNK_AO_AGENT_STREAM_ID` | Optional agent-stream routing |

Expand Down
2 changes: 1 addition & 1 deletion splunk-ao-a2a/examples/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPLUNK_AO_LOG_STREAM=dev

# O11y cloud
# SPLUNK_AO_REALM=us1
# SPLUNK_AO_SF_TOKEN=your-sf-ingest-token
# SPLUNK_AO_O11Y_TOKEN=your-o11y-ingest-token
# SPLUNK_AO_PROJECT=a2a-distributed-tracing-demo
# SPLUNK_AO_LOG_STREAM=dev

Expand Down
8 changes: 5 additions & 3 deletions splunk-ao-a2a/tests/test_splunk_ao_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult

Expand All @@ -23,7 +24,8 @@ def shutdown(self) -> None:
pass


def test_a2a_native_span_uses_user_wired_deployment_aware_processor() -> None:
def test_a2a_native_span_uses_user_wired_deployment_aware_processor(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SPLUNK_AO_DEV_ENABLE_ATTRIBUTE_NORMALIZATION", raising=False)
delegate = RecordingExporter()
captured_config: dict[str, object] = {}

Expand Down Expand Up @@ -78,9 +80,9 @@ def exporter_factory(**kwargs: object) -> RecordingExporter:
assert exported.attributes["a2a.rpc.method"] == "SendMessage"
assert "splunk_ao.a2a.rpc.method" not in exported.attributes
assert exported.attributes["gen_ai.conversation.id"] == "context-id"
assert exported.attributes["splunk_ao.session.id"] == "context-id"
assert "splunk_ao.session.id" not in exported.attributes
assert exported.attributes["gen_ai.operation.name"] == "invoke_agent"
assert exported.attributes["splunk_ao.operation.name"] == "invoke_agent"
assert "splunk_ao.operation.name" not in exported.attributes
assert exported.resource.attributes["splunk_ao.project.name"] == "a2a-project"
assert exported.resource.attributes["splunk_ao.logstream.name"] == "a2a-agent-stream"
assert "splunk_ao.project.name" not in exported.attributes
20 changes: 19 additions & 1 deletion splunk-ao-adk/src/splunk_ao_adk/trace_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
from typing import Any

from galileo_core.schemas.logging.agent import AgentType
from galileo_core.schemas.logging.span import LlmMetrics, RetrieverSpan, ToolSpan
from galileo_core.schemas.logging.span import LlmMetrics, RetrieverSpan, StepWithChildSpans, ToolSpan
from galileo_core.schemas.logging.step import Metrics
from galileo_core.schemas.shared.traces_logger import TracesLogger
from pydantic import PrivateAttr

from splunk_ao.schema.logged import LoggedAgentSpan, LoggedLlmSpan, LoggedTrace, LoggedWorkflowSpan
from splunk_ao.schema.trace import TracesIngestRequest
from splunk_ao.utils.retrievers import convert_to_documents
Expand Down Expand Up @@ -125,6 +126,23 @@ def add_trace(
self._set_current_parent(trace)
return trace

def conclude(
self,
output: str | None = None,
redacted_output: str | None = None,
duration_ns: int | None = None,
status_code: int | None = None,
conclude_all: bool = False,
) -> StepWithChildSpans | None:
"""Conclude the current step, optionally closing its full trace hierarchy."""
if not conclude_all:
return super().conclude(output, redacted_output, duration_ns, status_code)

current_parent = None
while self.current_parent() is not None:
current_parent = super().conclude(output, redacted_output, duration_ns, status_code)
return current_parent

@staticmethod
def _convert_metadata_value(v: Any) -> str:
"""Convert a metadata value to string."""
Expand Down
2 changes: 1 addition & 1 deletion splunk-ao-adk/tests/test_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ def test_o11y_without_routing_skips_session_crud_and_exports_telemetry(
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("SPLUNK_AO_REALM", "us1")
monkeypatch.setenv("SPLUNK_AO_SF_TOKEN", "ingest-token")
monkeypatch.setenv("SPLUNK_AO_O11Y_TOKEN", "ingest-token")

sink = MagicMock()
sink.force_flush.return_value = True
Expand Down
19 changes: 18 additions & 1 deletion splunk-ao-adk/tests/test_trace_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from unittest.mock import MagicMock

import pytest
from splunk_ao.schema.trace import TracesIngestRequest

from splunk_ao.schema.trace import TracesIngestRequest
from splunk_ao_adk.trace_builder import TraceBuilder


Expand Down Expand Up @@ -70,6 +70,23 @@ def test_conclude_clears_current_parent(self) -> None:
# Then: current parent is cleared
assert builder.current_parent() is None

def test_conclude_all_closes_nested_trace(self) -> None:
builder = TraceBuilder(ingestion_hook=MagicMock())
trace = builder.add_trace(input="trace input")
workflow = builder.add_workflow_span(input="workflow input")
agent = builder.add_agent_span(input="agent input")

result = builder.conclude(output="failed", status_code=500, conclude_all=True)

assert result is None
assert builder.current_parent() is None
assert trace.output == "failed"
assert workflow.output == "failed"
assert agent.output == "failed"
assert trace.status_code == 500
assert workflow.status_code == 500
assert agent.status_code == 500


class TestTraceBuilderSpans:
"""Tests for span methods inherited from TracesLogger."""
Expand Down
26 changes: 13 additions & 13 deletions src/splunk_ao/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
class O11yApiClient(ApiClient):
"""API client for Splunk Observability Cloud AO endpoints."""

sf_token: SecretStr
o11y_token: SecretStr
path_prefix: str = "/ao/api"

@property
def auth_header(self) -> dict[str, str]:
return {"X-SF-Token": self.sf_token.get_secret_value()}
return {"X-SF-Token": self.o11y_token.get_secret_value()}

def _prefixed(self, path: str) -> str:
normalized_path = f"/{path.lstrip('/')}"
Expand Down Expand Up @@ -109,7 +109,7 @@ def set_api_url(cls, api_url: str | Url | None, info: ValidationInfo) -> Url:

@model_validator(mode="after")
def set_jwt_token(self) -> "SplunkAOConfig":
"""Skip standalone JWT exchange when O11y uses direct SF-token authentication."""
"""Skip standalone JWT exchange when O11y uses direct token authentication."""
if self._is_o11y_env():
self.jwt_token = None
self.refresh_token = None
Expand All @@ -123,7 +123,7 @@ def set_validated_api_client(self) -> "SplunkAOConfig":
if self._is_o11y_env():
o11y = O11yConfig.from_env()
self.validated_api_client = O11yApiClient(
host=o11y.api_root, sf_token=o11y.crud_token, jwt_token=SecretStr(""), ssl_context=self.ssl_context
host=o11y.api_root, o11y_token=o11y.crud_token, jwt_token=SecretStr(""), ssl_context=self.ssl_context
)
return self
super().set_validated_api_client()
Expand All @@ -133,7 +133,7 @@ def _uses_o11y_api_client(self) -> bool:
return isinstance(self.validated_api_client, O11yApiClient)

def refresh_jwt_token(self) -> None:
"""Skip JWT refresh when authenticating directly with an O11y SF token."""
"""Skip JWT refresh when authenticating directly with an O11y token."""
if self._uses_o11y_api_client():
return
super().refresh_jwt_token()
Expand Down Expand Up @@ -178,8 +178,8 @@ def _check_auth_config(kwargs: dict) -> str | None:
message identifying what's missing.

Auth methods supported by the underlying config model:
- SF tokens (o11y): SPLUNK_AO_REALM and at least one of
SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN env vars
- O11y tokens: SPLUNK_AO_REALM and at least one of
SPLUNK_AO_O11Y_TOKEN or SPLUNK_AO_O11Y_API_TOKEN env vars
- API key (standalone): api_key kwarg or SPLUNK_AO_API_KEY env
- Pre-exchanged JWT (standalone): jwt_token or SPLUNK_AO_JWT_TOKEN
- SSO (paired): sso_id_token + sso_provider, both kwargs and env vars
Expand All @@ -197,13 +197,13 @@ def _val(kwarg_name: str, env_name: str) -> str | None:
return os.environ.get(env_name)

realm = os.environ.get("SPLUNK_AO_REALM")
sf_token = os.environ.get("SPLUNK_AO_SF_TOKEN")
sf_api_token = os.environ.get("SPLUNK_AO_SF_API_TOKEN")
if realm or sf_token or sf_api_token:
o11y_token = os.environ.get("SPLUNK_AO_O11Y_TOKEN")
o11y_api_token = os.environ.get("SPLUNK_AO_O11Y_API_TOKEN")
if realm or o11y_token or o11y_api_token:
if not realm:
return "O11y authentication requires SPLUNK_AO_REALM to be set."
if not sf_token and not sf_api_token:
return "O11y authentication requires SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN to be set."
if not o11y_token and not o11y_api_token:
return "O11y authentication requires SPLUNK_AO_O11Y_TOKEN or SPLUNK_AO_O11Y_API_TOKEN to be set."
return None

# Standalone methods — either alone is sufficient.
Expand Down Expand Up @@ -250,7 +250,7 @@ def _val(kwarg_name: str, env_name: str) -> str | None:
# Nothing configured anywhere.
return (
"No Splunk AO authentication detected. Set one of: SPLUNK_AO_REALM with "
"SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN; SPLUNK_AO_API_KEY; "
"SPLUNK_AO_O11Y_TOKEN or SPLUNK_AO_O11Y_API_TOKEN; SPLUNK_AO_API_KEY; "
"SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; "
"or SPLUNK_AO_USERNAME with SPLUNK_AO_PASSWORD. "
"Alternatively, pass the equivalent kwargs to SplunkAOConfig.get(). "
Expand Down
46 changes: 24 additions & 22 deletions src/splunk_ao/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from splunk_ao.shared.exceptions import AmbiguousConfigurationError, MissingConfigurationError

_O11Y_ENV_VARS = ("SPLUNK_AO_REALM", "SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN")
_O11Y_ENV_VARS = ("SPLUNK_AO_REALM", "SPLUNK_AO_O11Y_TOKEN", "SPLUNK_AO_O11Y_API_TOKEN")
_STANDALONE_ENV_VARS = ("SPLUNK_AO_API_KEY", "SPLUNK_AO_CONSOLE_URL", "SPLUNK_AO_API_URL")


Expand Down Expand Up @@ -51,33 +51,33 @@ class O11yConfig:
"""Configuration for a Splunk Observability Cloud deployment."""

realm: str
sf_token: SecretStr | None = None
sf_api_token: SecretStr | None = None
o11y_token: SecretStr | None = None
o11y_api_token: SecretStr | None = None

def __post_init__(self) -> None:
missing = []
if not self.realm:
missing.append("SPLUNK_AO_REALM")
if self.sf_token is None and self.sf_api_token is None:
missing.append("one of SPLUNK_AO_SF_TOKEN or SPLUNK_AO_SF_API_TOKEN")
if self.o11y_token is None and self.o11y_api_token is None:
missing.append("one of SPLUNK_AO_O11Y_TOKEN or SPLUNK_AO_O11Y_API_TOKEN")
if missing:
raise MissingConfigurationError(f"O11y deployment requires {' and '.join(missing)} to be set.")

if self.sf_token is not None and not isinstance(self.sf_token, SecretStr):
self.sf_token = SecretStr(self.sf_token)
if self.sf_api_token is not None and not isinstance(self.sf_api_token, SecretStr):
self.sf_api_token = SecretStr(self.sf_api_token)
if self.o11y_token is not None and not isinstance(self.o11y_token, SecretStr):
self.o11y_token = SecretStr(self.o11y_token)
if self.o11y_api_token is not None and not isinstance(self.o11y_api_token, SecretStr):
self.o11y_api_token = SecretStr(self.o11y_api_token)

@classmethod
def from_env(cls) -> "O11yConfig":
"""Load and validate o11y configuration from the environment."""
realm = _env("SPLUNK_AO_REALM")
sf_token = _env("SPLUNK_AO_SF_TOKEN")
sf_api_token = _env("SPLUNK_AO_SF_API_TOKEN")
o11y_token = _env("SPLUNK_AO_O11Y_TOKEN")
o11y_api_token = _env("SPLUNK_AO_O11Y_API_TOKEN")
return cls(
realm=realm or "",
sf_token=SecretStr(sf_token) if sf_token else None,
sf_api_token=SecretStr(sf_api_token) if sf_api_token else None,
o11y_token=SecretStr(o11y_token) if o11y_token else None,
o11y_api_token=SecretStr(o11y_api_token) if o11y_api_token else None,
)

@property
Expand All @@ -88,20 +88,22 @@ def otlp_endpoint(self) -> str:
@property
def crud_token(self) -> SecretStr:
"""Return the API token when set, otherwise the ingest token."""
if self.sf_api_token is not None:
return self.sf_api_token
if self.sf_token is not None:
return self.sf_token
raise MissingConfigurationError("O11y CRUD requires SPLUNK_AO_SF_API_TOKEN or SPLUNK_AO_SF_TOKEN to be set.")
if self.o11y_api_token is not None:
return self.o11y_api_token
if self.o11y_token is not None:
return self.o11y_token
raise MissingConfigurationError(
"O11y CRUD requires SPLUNK_AO_O11Y_API_TOKEN or SPLUNK_AO_O11Y_TOKEN to be set."
)

def require_ingest_token(self) -> SecretStr:
"""Return the token required for OTLP trace export."""
if self.sf_token is None:
if self.o11y_token is None:
raise MissingConfigurationError(
"O11y OTLP trace export requires SPLUNK_AO_SF_TOKEN. "
"SPLUNK_AO_SF_API_TOKEN supports CRUD operations only."
"O11y OTLP trace export requires SPLUNK_AO_O11Y_TOKEN. "
"SPLUNK_AO_O11Y_API_TOKEN supports CRUD operations only."
)
return self.sf_token
return self.o11y_token

@property
def api_root(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def set_validated_config(
) -> Generator[None, None, None]:
"""Automatically set up validated config for tests."""
SplunkAOLoggerSingleton().reset_all()
for name in ("SPLUNK_AO_REALM", "SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"):
for name in ("SPLUNK_AO_REALM", "SPLUNK_AO_O11Y_TOKEN", "SPLUNK_AO_O11Y_API_TOKEN"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("SPLUNK_AO_CONSOLE_URL", "http://fake.test:8088")
monkeypatch.setenv("SPLUNK_AO_API_KEY", "api-1234567890")
Expand Down
Loading
Loading