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
64 changes: 58 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,66 @@

### Setup

Set the following environment variables:
Configure the environment for your Agent Observability deployment. Do not mix
O11y Cloud and standalone deployment variables; the SDK detects the deployment
from the variables that are present and rejects ambiguous configurations.

- `SPLUNK_AO_API_KEY`: Your Agent Observability API key
- `SPLUNK_AO_PROJECT`: (Optional) Project name
- `SPLUNK_AO_LOG_STREAM`: (Optional) Log stream name
- `SPLUNK_AO_LOGGING_DISABLED`: (Optional) Disable collecting and sending logs to Agent Observability.
#### O11y Cloud user

Note: if you would like to point to an environment other than `app.galileo.ai`, you'll need to set the `SPLUNK_AO_CONSOLE_URL` environment variable.
Set your Splunk Observability Cloud realm and access token:

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

`SPLUNK_AO_SF_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"
```

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.

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
Cloud.

#### Standalone Agent Observability user

Set your Agent Observability API key and console URL:

```shell
export SPLUNK_AO_API_KEY="your-agent-observability-api-key"
export SPLUNK_AO_CONSOLE_URL="https://app.galileo.ai"
```

The SDK derives the API endpoint from the console URL. Set
`SPLUNK_AO_API_URL` only when your deployment uses a separate API URL that
cannot be derived from the console URL:

```shell
export SPLUNK_AO_API_URL="https://api.galileo.ai"
```

For either deployment, project and log-stream routing can be supplied through
the SDK APIs or environment variables:

```shell
export SPLUNK_AO_PROJECT="your-project-name"
export SPLUNK_AO_LOG_STREAM="your-log-stream-name"
```

Set `SPLUNK_AO_LOGGING_DISABLED=true` to disable telemetry collection and
export.

### Usage

Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ openai = { version = ">=2.8.0,<3.0.0", optional = true }
openai-agents = { version = ">=0.4.0,<1.0.0", optional = true }
litellm = { version = ">=1.83.14,<2.0.0", optional = true, python = ">=3.11,<3.14" }
galileo-core = "^4.4.0"
httpx = ">=0.27.0,<0.29.0"
starlette = { version = ">=0.27.0", optional = true }
backoff = "^2.2.1"
crewai = { version = ">=0.152.0,<2.0.0", optional = true, python = ">=3.11,<3.14" }
Expand Down
10 changes: 8 additions & 2 deletions src/splunk_ao/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from splunk_ao.collaborator import Collaborator, CollaboratorRole
from splunk_ao.configuration import Configuration
from splunk_ao.dataset import Dataset
from splunk_ao.decorator import SplunkAODecorator, splunk_ao_context, log, start_session
from splunk_ao.decorator import SplunkAODecorator, log, splunk_ao_context, start_session
from splunk_ao.exceptions import (
AuthenticationError,
BadRequestError,
Expand Down Expand Up @@ -47,10 +47,13 @@
from splunk_ao.schema.metrics import SplunkAOMetrics
from splunk_ao.shared.base import SyncState
from splunk_ao.shared.exceptions import (
AmbiguousConfigurationError,
APIError,
ConfigurationError,
MissingConfigurationError,
ResourceConflictError,
ResourceNotFoundError,
SplunkAOConfigError,
SplunkAOFutureError,
ValidationError,
)
Expand All @@ -65,6 +68,7 @@
"AgentControlTarget",
"AgentControlTargetUnresolvedError",
"AgentSpan",
"AmbiguousConfigurationError",
"AnthropicProvider",
"AuthenticationError",
"AzureProvider",
Expand Down Expand Up @@ -93,6 +97,7 @@
"MessageRole",
"Metric",
"MetricSpec",
"MissingConfigurationError",
"Model",
"NotFoundError",
"OpenAIProvider",
Expand All @@ -108,6 +113,7 @@
"Span",
"SplunkAOAPIError",
"SplunkAOAgentControlBridge",
"SplunkAOConfigError",
"SplunkAODecorator",
"SplunkAOFutureError",
"SplunkAOLogger",
Expand All @@ -126,12 +132,12 @@
"create_api_key",
"delete_api_key",
"enable_console_logging",
"splunk_ao_context",
"get_agent_control_target",
"get_tracing_headers",
"is_dependency_available",
"list_api_keys",
"log",
"setup_agent_control_bridge",
"splunk_ao_context",
"start_session",
]
121 changes: 114 additions & 7 deletions src/splunk_ao/config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
# mypy: disable-error-code=syntax
# We need to ignore syntax errors until https://github.com/python/mypy/issues/17535 is resolved.
import os
from collections.abc import Iterator
from typing import Any, ClassVar, Optional

from httpx import Response
from pydantic import SecretStr, ValidationInfo, field_validator, model_validator
from pydantic_core import Url

from galileo_core.constants.request_method import RequestMethod
from galileo_core.helpers.api_client import ApiClient
from galileo_core.schemas.base_config import GalileoConfig
from splunk_ao.constants import DEFAULT_CONSOLE_URL
from splunk_ao.shared.exceptions import ConfigurationError
from splunk_ao.deployment import DeploymentMode, O11yConfig
from splunk_ao.deployment import resolve_deployment as _resolve_deployment
from splunk_ao.shared.exceptions import ConfigurationError, MissingConfigurationError


class O11yApiClient(ApiClient):
"""API client for Splunk Observability Cloud AO endpoints."""

sf_token: SecretStr
path_prefix: str = "/v2/ao"

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

def _prefixed(self, path: str) -> str:
normalized_path = f"/{path.lstrip('/')}"
prefix = self.path_prefix.strip("/")
if not prefix:
return normalized_path

normalized_prefix = f"/{prefix}"
if normalized_path == normalized_prefix or normalized_path.startswith(f"{normalized_prefix}/"):
return normalized_path
return f"{normalized_prefix}{normalized_path}"

async def arequest(self, method: RequestMethod, path: str, *args: Any, **kwargs: Any) -> Any:
return await super().arequest(method, self._prefixed(path), *args, **kwargs)

def stream_request(self, method: RequestMethod, path: str, *args: Any, **kwargs: Any) -> Iterator[Response]:
return super().stream_request(method, self._prefixed(path), *args, **kwargs)


# Mapping of SPLUNK_AO_* → GALILEO_* env var pairs used by the bridge.
# Defined at module level so both _bridge_env_vars() and reset() can reference
Expand All @@ -30,6 +66,8 @@


class SplunkAOConfig(GalileoConfig):
"""Configure authentication and endpoints for standalone and O11y deployments."""

# Config file for this project.
config_filename: str = "galileo-python-config.json"
console_url: Url = DEFAULT_CONSOLE_URL
Expand All @@ -47,8 +85,60 @@ def reset(self) -> None:
SplunkAOConfig._instance = None
super().reset()

@classmethod
def resolve_deployment(cls) -> DeploymentMode:
"""Infer the deployment mode from configured environment variables."""
return _resolve_deployment()

@classmethod
def _is_o11y_env(cls) -> bool:
try:
return cls.resolve_deployment() == DeploymentMode.O11Y
except MissingConfigurationError:
return False

@field_validator("api_url", mode="before")
@classmethod
def set_api_url(cls, api_url: str | Url | None, info: ValidationInfo) -> Url:
"""Derive the O11y API URL from its realm and preserve standalone validation."""
if cls._is_o11y_env():
return Url(O11yConfig.from_env().require_api_url())
return super().set_api_url(api_url, info)

@model_validator(mode="after")
def set_jwt_token(self) -> "SplunkAOConfig":
"""Skip standalone JWT exchange when O11y uses direct SF-token authentication."""
if self._is_o11y_env():
self.jwt_token = None
self.refresh_token = None
return self
super().set_jwt_token()
return self

@model_validator(mode="after")
def set_validated_api_client(self) -> "SplunkAOConfig":
"""Use the SF-token aware API client for O11y deployments."""
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
)
return self
super().set_validated_api_client()
return self

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."""
if self._uses_o11y_api_client():
return
super().refresh_jwt_token()

@classmethod
def get(cls, **kwargs: Any) -> "SplunkAOConfig":
"""Initialize the shared config with deployment-aware auth validation."""
if cls._instance is None:
cls._bridge_env_vars()
error_message = cls._check_auth_config(kwargs)
Expand All @@ -58,8 +148,8 @@ def get(cls, **kwargs: Any) -> "SplunkAOConfig":
assert cls._instance is not None, "Failed to initialize SplunkAOConfig"
return cls._instance

@staticmethod
def _bridge_env_vars() -> None:
@classmethod
def _bridge_env_vars(cls) -> None:
"""Bridge SPLUNK_AO_* env vars into GALILEO_* for galileo-core compatibility.

galileo-core still reads GALILEO_* env vars. Until galileo-core is updated,
Expand All @@ -70,6 +160,9 @@ def _bridge_env_vars() -> None:
reset() clears any previously-bridged GALILEO_* keys so that this guard
cannot return a stale value after a credential rotation.
"""
if cls._is_o11y_env() and "GALILEO_CONSOLE_URL" not in os.environ:
os.environ["GALILEO_CONSOLE_URL"] = O11yConfig.from_env().require_console_url()

for new_key, old_key in _BRIDGE:
if new_key in os.environ and old_key not in os.environ:
os.environ[old_key] = os.environ[new_key]
Expand All @@ -83,13 +176,16 @@ 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
- 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
- Username/password (paired): username + password, both kwargs and env vars

Kwargs and env vars are interchangeable — e.g. sso_id_token can come
from a kwarg while sso_provider comes from the environment.
For standalone auth, kwargs and env vars are interchangeable — e.g.
sso_id_token can come from a kwarg while sso_provider comes from the
environment. O11y tokens are environment-only.
"""

def _val(kwarg_name: str, env_name: str) -> str | None:
Expand All @@ -98,6 +194,16 @@ def _val(kwarg_name: str, env_name: str) -> str | None:
return str(value)
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:
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."
return None

# Standalone methods — either alone is sufficient.
if _val("api_key", "SPLUNK_AO_API_KEY"):
return None
Expand Down Expand Up @@ -141,8 +247,9 @@ def _val(kwarg_name: str, env_name: str) -> str | None:

# Nothing configured anywhere.
return (
"No Splunk AO authentication detected. Set one of: "
"SPLUNK_AO_API_KEY; SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; "
"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_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; "
"or SPLUNK_AO_USERNAME with SPLUNK_AO_PASSWORD. "
"Alternatively, pass the equivalent kwargs to SplunkAOConfig.get(). "
"See https://docs.splunk.com for setup instructions."
Expand Down
Loading
Loading