From e7ace9d6c755843ea351ae9d1bd5725b7577d4a9 Mon Sep 17 00:00:00 2001 From: pradystar Date: Fri, 31 Jul 2026 09:53:56 -0700 Subject: [PATCH 1/2] refactor(config): rename O11y token environment variables --- README.md | 16 +++--- splunk-ao-a2a/README.md | 2 +- splunk-ao-a2a/examples/.env.example | 2 +- splunk-ao-adk/tests/test_observer.py | 2 +- src/splunk_ao/config.py | 26 +++++----- src/splunk_ao/deployment.py | 46 +++++++++-------- tests/conftest.py | 2 +- tests/test_deployment.py | 77 +++++++++++++++++----------- tests/test_exporter_o11y.py | 28 +++++----- tests/test_logger_otel_egress.py | 8 +-- tests/test_o11y_config.py | 34 ++++++------ tests/test_otel_native_paths.py | 6 +-- 12 files changed, 133 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 841a78ab..6f4f4919 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/splunk-ao-a2a/README.md b/splunk-ao-a2a/README.md index 973a06c4..87cbc45b 100644 --- a/splunk-ao-a2a/README.md +++ b/splunk-ao-a2a/README.md @@ -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 | diff --git a/splunk-ao-a2a/examples/.env.example b/splunk-ao-a2a/examples/.env.example index 8baa8e19..68ff00d2 100644 --- a/splunk-ao-a2a/examples/.env.example +++ b/splunk-ao-a2a/examples/.env.example @@ -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 diff --git a/splunk-ao-adk/tests/test_observer.py b/splunk-ao-adk/tests/test_observer.py index a25436e6..75df8154 100644 --- a/splunk-ao-adk/tests/test_observer.py +++ b/splunk-ao-adk/tests/test_observer.py @@ -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 diff --git a/src/splunk_ao/config.py b/src/splunk_ao/config.py index c9acdf4c..58cf3917 100644 --- a/src/splunk_ao/config.py +++ b/src/splunk_ao/config.py @@ -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('/')}" @@ -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 @@ -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() @@ -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() @@ -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 @@ -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. @@ -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(). " diff --git a/src/splunk_ao/deployment.py b/src/splunk_ao/deployment.py index 02f3c290..1bfa69c3 100644 --- a/src/splunk_ao/deployment.py +++ b/src/splunk_ao/deployment.py @@ -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") @@ -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 @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index c5ec6831..b05fe3f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_deployment.py b/tests/test_deployment.py index fbaab7eb..86119bd5 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -12,8 +12,8 @@ _DETECTION_ENV_VARS = ( "SPLUNK_AO_REALM", - "SPLUNK_AO_SF_TOKEN", - "SPLUNK_AO_SF_API_TOKEN", + "SPLUNK_AO_O11Y_TOKEN", + "SPLUNK_AO_O11Y_API_TOKEN", "SPLUNK_AO_API_KEY", "SPLUNK_AO_CONSOLE_URL", "SPLUNK_AO_API_URL", @@ -36,13 +36,13 @@ def env(**overrides: str) -> Iterator[None]: os.environ[name] = value -def test_autodetect_o11y_from_realm_and_sf_token() -> None: - with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok"): +def test_autodetect_o11y_from_realm_and_o11y_token() -> None: + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok"): assert SplunkAOConfig.resolve_deployment() == DeploymentMode.O11Y -def test_autodetect_o11y_from_sf_api_token_only() -> None: - with env(SPLUNK_AO_SF_API_TOKEN="tok"): +def test_autodetect_o11y_from_o11y_api_token_only() -> None: + with env(SPLUNK_AO_O11Y_API_TOKEN="tok"): assert SplunkAOConfig.resolve_deployment() == DeploymentMode.O11Y @@ -62,18 +62,20 @@ def test_autodetect_standalone_from_api_url_only() -> None: def test_o11y_and_standalone_api_url_are_ambiguous() -> None: - with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_URL="https://stale-standalone.example.com"): + with env( + SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok", SPLUNK_AO_API_URL="https://stale-standalone.example.com" + ): with pytest.raises(AmbiguousConfigurationError) as exc_info: SplunkAOConfig.resolve_deployment() assert "SPLUNK_AO_API_URL" in str(exc_info.value) def test_ambiguous_raises_when_both_sets_present() -> None: - with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_KEY="key"): + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok", SPLUNK_AO_API_KEY="key"): with pytest.raises(AmbiguousConfigurationError) as exc_info: SplunkAOConfig.resolve_deployment() assert "SPLUNK_AO_REALM" in str(exc_info.value) - assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_TOKEN" in str(exc_info.value) assert "SPLUNK_AO_API_KEY" in str(exc_info.value) @@ -92,39 +94,52 @@ def test_empty_values_do_not_select_a_deployment() -> None: def test_o11y_config_from_env() -> None: - with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="ingest-tok", SPLUNK_AO_SF_API_TOKEN="api-tok"): + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="ingest-tok", SPLUNK_AO_O11Y_API_TOKEN="api-tok"): cfg = O11yConfig.from_env() assert cfg.realm == "us1" - assert cfg.sf_token.get_secret_value() == "ingest-tok" - assert cfg.sf_api_token is not None - assert cfg.sf_api_token.get_secret_value() == "api-tok" + assert cfg.o11y_token.get_secret_value() == "ingest-tok" + assert cfg.o11y_api_token is not None + assert cfg.o11y_api_token.get_secret_value() == "api-tok" def test_o11y_config_from_env_accepts_crud_only_api_token() -> None: - with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_API_TOKEN="api-tok"): + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_API_TOKEN="api-tok"): cfg = O11yConfig.from_env() - assert cfg.sf_token is None + assert cfg.o11y_token is None assert cfg.crud_token.get_secret_value() == "api-tok" +@pytest.mark.parametrize("unsupported_token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"]) +def test_unreleased_sf_token_names_are_not_supported( + monkeypatch: pytest.MonkeyPatch, unsupported_token_var: str +) -> None: + with env(SPLUNK_AO_REALM="us1"): + monkeypatch.setenv(unsupported_token_var, "old-token") + with pytest.raises(MissingConfigurationError) as exc_info: + O11yConfig.from_env() + + assert "SPLUNK_AO_O11Y_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_API_TOKEN" in str(exc_info.value) + + def test_otlp_endpoint_derived_from_realm() -> None: - cfg = O11yConfig(realm="lab0", sf_token="tok") + cfg = O11yConfig(realm="lab0", o11y_token="tok") assert cfg.otlp_endpoint == "https://ingest.lab0.observability.splunkcloud.com/v2/trace/otlp" def test_crud_token_prefers_api_token() -> None: - cfg = O11yConfig(realm="us1", sf_token="ingest-tok", sf_api_token="api-tok") + cfg = O11yConfig(realm="us1", o11y_token="ingest-tok", o11y_api_token="api-tok") assert cfg.crud_token.get_secret_value() == "api-tok" -def test_crud_token_falls_back_to_sf_token() -> None: - cfg = O11yConfig(realm="us1", sf_token="tok", sf_api_token=None) +def test_crud_token_falls_back_to_o11y_token() -> None: + cfg = O11yConfig(realm="us1", o11y_token="tok", o11y_api_token=None) assert cfg.crud_token.get_secret_value() == "tok" -@pytest.mark.parametrize("token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"]) +@pytest.mark.parametrize("token_var", ["SPLUNK_AO_O11Y_TOKEN", "SPLUNK_AO_O11Y_API_TOKEN"]) def test_missing_realm_raises(token_var: str) -> None: with env(**{token_var: "tok"}): with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_REALM"): @@ -135,8 +150,8 @@ def test_missing_both_o11y_tokens_raises() -> None: with env(SPLUNK_AO_REALM="us1"): with pytest.raises(MissingConfigurationError) as exc_info: O11yConfig.from_env() - assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value) - assert "SPLUNK_AO_SF_API_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_API_TOKEN" in str(exc_info.value) def test_missing_o11y_config_names_both_required_variables() -> None: @@ -144,35 +159,35 @@ def test_missing_o11y_config_names_both_required_variables() -> None: with pytest.raises(MissingConfigurationError) as exc_info: O11yConfig.from_env() assert "SPLUNK_AO_REALM" in str(exc_info.value) - assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value) - assert "SPLUNK_AO_SF_API_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_O11Y_API_TOKEN" in str(exc_info.value) -def test_require_ingest_token_returns_sf_token() -> None: - cfg = O11yConfig(realm="us1", sf_token="ingest-tok", sf_api_token="api-tok") +def test_require_ingest_token_returns_o11y_token() -> None: + cfg = O11yConfig(realm="us1", o11y_token="ingest-tok", o11y_api_token="api-tok") assert cfg.require_ingest_token().get_secret_value() == "ingest-tok" def test_require_ingest_token_rejects_crud_only_config() -> None: - cfg = O11yConfig(realm="us1", sf_api_token="api-tok") - with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"): + cfg = O11yConfig(realm="us1", o11y_api_token="api-tok") + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_O11Y_TOKEN"): cfg.require_ingest_token() def test_api_root_derives_from_realm() -> None: - cfg = O11yConfig(realm="lab0", sf_token="tok") + cfg = O11yConfig(realm="lab0", o11y_token="tok") assert cfg.api_root == "https://app.lab0.observability.splunkcloud.com" def test_require_api_url_derives_from_realm() -> None: - cfg = O11yConfig(realm="lab0", sf_token="tok") + cfg = O11yConfig(realm="lab0", o11y_token="tok") assert cfg.require_api_url() == "https://app.lab0.observability.splunkcloud.com/ao/api/" assert cfg.require_api_url() == f"{cfg.api_root}/ao/api/" assert cfg.require_api_url() == f"{cfg.require_console_url()}ao/api/" def test_require_console_url_derives_from_realm() -> None: - cfg = O11yConfig(realm="lab0", sf_token="tok") + cfg = O11yConfig(realm="lab0", o11y_token="tok") assert cfg.require_console_url() == "https://app.lab0.observability.splunkcloud.com/" diff --git a/tests/test_exporter_o11y.py b/tests/test_exporter_o11y.py index bdc329c2..d7e40ada 100644 --- a/tests/test_exporter_o11y.py +++ b/tests/test_exporter_o11y.py @@ -17,23 +17,23 @@ def make_routing(**kwargs: str) -> RoutingAttrs: def test_o11y_exporter_endpoint_derived_from_realm() -> None: - cfg = O11yConfig(realm="lab0", sf_token="tok") + cfg = O11yConfig(realm="lab0", o11y_token="tok") result = resolve_o11y_exporter_config(cfg, routing=make_routing(project_name="proj1")) assert result.endpoint == "https://ingest.lab0.observability.splunkcloud.com/v2/trace/otlp" -def test_o11y_exporter_uses_unmasked_sf_ingest_token_header() -> None: - cfg = O11yConfig(realm="eu0", sf_token="my-sf-token", sf_api_token="crud-only-token") +def test_o11y_exporter_uses_unmasked_o11y_ingest_token_header() -> None: + cfg = O11yConfig(realm="eu0", o11y_token="my-o11y-token", o11y_api_token="crud-only-token") result = resolve_o11y_exporter_config(cfg, routing=make_routing(project_name="proj1")) - assert result.headers["X-SF-Token"] == "my-sf-token" + assert result.headers["X-SF-Token"] == "my-o11y-token" def test_o11y_exporter_config_rejects_crud_only_o11y_config() -> None: - cfg = O11yConfig(realm="eu0", sf_api_token="api-token") + cfg = O11yConfig(realm="eu0", o11y_api_token="api-token") - with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_O11Y_TOKEN"): resolve_o11y_exporter_config(cfg, routing=make_routing()) @@ -45,23 +45,23 @@ def exporter_factory(**kwargs: Any) -> object: factory_calls += 1 return object() - with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_O11Y_TOKEN"): build_o11y_exporter( - O11yConfig(realm="eu0", sf_api_token="api-token"), make_routing(), _exporter_factory=exporter_factory + O11yConfig(realm="eu0", o11y_api_token="api-token"), make_routing(), _exporter_factory=exporter_factory ) assert factory_calls == 0 def test_o11y_exporter_project_header_present() -> None: - cfg = O11yConfig(realm="us1", sf_token="tok") + cfg = O11yConfig(realm="us1", o11y_token="tok") result = resolve_o11y_exporter_config(cfg, routing=make_routing(project_name="proj1")) assert result.headers["project"] == "proj1" def test_o11y_exporter_project_id_header_present() -> None: - cfg = O11yConfig(realm="us1", sf_token="tok") + cfg = O11yConfig(realm="us1", o11y_token="tok") result = resolve_o11y_exporter_config(cfg, routing=make_routing(project_id="pid1")) assert "project" not in result.headers @@ -69,7 +69,7 @@ def test_o11y_exporter_project_id_header_present() -> None: def test_o11y_exporter_logstream_header_absent_when_experiment() -> None: - cfg = O11yConfig(realm="us1", sf_token="tok") + cfg = O11yConfig(realm="us1", o11y_token="tok") result = resolve_o11y_exporter_config( cfg, routing=make_routing(project_name="p", agent_stream_name="ls", experiment_id="exp1") ) @@ -79,7 +79,7 @@ def test_o11y_exporter_logstream_header_absent_when_experiment() -> None: def test_o11y_exporter_no_routing_headers_when_routing_absent() -> None: - cfg = O11yConfig(realm="us1", sf_token="tok") + cfg = O11yConfig(realm="us1", o11y_token="tok") result = resolve_o11y_exporter_config(cfg, routing=make_routing()) for header in ("project", "projectid", "logstream", "logstreamid", "experimentid"): @@ -95,7 +95,7 @@ def exporter_factory(**kwargs: Any) -> object: return expected_exporter exporter = build_o11y_exporter( - O11yConfig(realm="us1", sf_token="tok"), + O11yConfig(realm="us1", o11y_token="tok"), make_routing(project_id="pid", agent_stream_id="lsid"), _exporter_factory=exporter_factory, ) @@ -110,7 +110,7 @@ def exporter_factory(**kwargs: Any) -> object: def test_o11y_exporter_passes_deployment_explicitly_to_diagnostics() -> None: with patch("splunk_ao.exporter.config.DiagnosticOTLPSpanExporter") as diagnostic_exporter: - exporter = build_o11y_exporter(O11yConfig(realm="us1", sf_token="tok"), make_routing()) + exporter = build_o11y_exporter(O11yConfig(realm="us1", o11y_token="tok"), make_routing()) assert exporter.delegate is diagnostic_exporter.return_value assert diagnostic_exporter.call_args.kwargs["deployment"] == DeploymentMode.O11Y diff --git a/tests/test_logger_otel_egress.py b/tests/test_logger_otel_egress.py index 9a8d6666..2a3a976c 100644 --- a/tests/test_logger_otel_egress.py +++ b/tests/test_logger_otel_egress.py @@ -56,11 +56,11 @@ def configure_o11y(monkeypatch: pytest.MonkeyPatch, *, ingest_token: bool = True for name in ("SPLUNK_AO_API_KEY", "SPLUNK_AO_CONSOLE_URL", "SPLUNK_AO_API_URL"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("SPLUNK_AO_REALM", "us1") - monkeypatch.setenv("SPLUNK_AO_SF_API_TOKEN", "api-token") + monkeypatch.setenv("SPLUNK_AO_O11Y_API_TOKEN", "api-token") if ingest_token: - monkeypatch.setenv("SPLUNK_AO_SF_TOKEN", "ingest-token") + monkeypatch.setenv("SPLUNK_AO_O11Y_TOKEN", "ingest-token") else: - monkeypatch.delenv("SPLUNK_AO_SF_TOKEN", raising=False) + monkeypatch.delenv("SPLUNK_AO_O11Y_TOKEN", raising=False) def test_complete_leaf_is_enqueued_before_flush(otlp_logger: SplunkAOLogger, recording_sink: RecordingSink) -> None: @@ -376,7 +376,7 @@ def test_o11y_no_routing_exports_but_explicit_session_fails( def test_o11y_crud_only_token_cannot_construct_telemetry_logger(monkeypatch: pytest.MonkeyPatch) -> None: configure_o11y(monkeypatch, ingest_token=False) - with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_O11Y_TOKEN"): SplunkAOLogger(project="project", agent_stream="stream") diff --git a/tests/test_o11y_config.py b/tests/test_o11y_config.py index aadc2637..f933458b 100644 --- a/tests/test_o11y_config.py +++ b/tests/test_o11y_config.py @@ -18,8 +18,8 @@ _CONFIG_ENV_VARS = ( "SPLUNK_AO_REALM", - "SPLUNK_AO_SF_TOKEN", - "SPLUNK_AO_SF_API_TOKEN", + "SPLUNK_AO_O11Y_TOKEN", + "SPLUNK_AO_O11Y_API_TOKEN", "SPLUNK_AO_API_KEY", "SPLUNK_AO_API_URL", "SPLUNK_AO_CONSOLE_URL", @@ -56,13 +56,13 @@ def config_env(**overrides: str) -> Iterator[None]: def _o11y_client(token: str = "tok") -> O11yApiClient: client = O11yApiClient( - host="https://app.lab0.observability.splunkcloud.com", sf_token=SecretStr(token), jwt_token=SecretStr("") + host="https://app.lab0.observability.splunkcloud.com", o11y_token=SecretStr(token), jwt_token=SecretStr("") ) client.thread_local.client = None return client -def test_o11y_api_client_uses_sf_token_header() -> None: +def test_o11y_api_client_uses_o11y_token_header() -> None: assert _o11y_client("my-token").auth_header == {"X-SF-Token": "my-token"} @@ -131,20 +131,20 @@ def fake_stream_request( assert captured["path"] == "/ao/api/projects" -@pytest.mark.parametrize("token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"]) +@pytest.mark.parametrize("token_var", ["SPLUNK_AO_O11Y_TOKEN", "SPLUNK_AO_O11Y_API_TOKEN"]) def test_o11y_auth_guard_accepts_environment_tokens(token_var: str) -> None: with config_env(SPLUNK_AO_REALM="us1", **{token_var: "tok"}): assert SplunkAOConfig._check_auth_config({}) is None def test_o11y_auth_guard_requires_realm() -> None: - with config_env(SPLUNK_AO_SF_API_TOKEN="tok"): + with config_env(SPLUNK_AO_O11Y_API_TOKEN="tok"): assert "SPLUNK_AO_REALM" in (SplunkAOConfig._check_auth_config({}) or "") def test_o11y_get_with_api_token_but_no_realm_fails_clearly(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SplunkAOConfig, "_instance", None) - with config_env(SPLUNK_AO_SF_API_TOKEN="tok"): + with config_env(SPLUNK_AO_O11Y_API_TOKEN="tok"): with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_REALM"): SplunkAOConfig.get() @@ -152,29 +152,29 @@ def test_o11y_get_with_api_token_but_no_realm_fails_clearly(monkeypatch: pytest. def test_o11y_auth_guard_requires_at_least_one_token() -> None: with config_env(SPLUNK_AO_REALM="us1"): error = SplunkAOConfig._check_auth_config({}) or "" - assert "SPLUNK_AO_SF_TOKEN" in error - assert "SPLUNK_AO_SF_API_TOKEN" in error + assert "SPLUNK_AO_O11Y_TOKEN" in error + assert "SPLUNK_AO_O11Y_API_TOKEN" in error def test_o11y_auth_guard_does_not_accept_token_kwargs() -> None: with config_env(): - assert SplunkAOConfig._check_auth_config({"sf_token": "tok"}) is not None + assert SplunkAOConfig._check_auth_config({"o11y_token": "tok"}) is not None def test_o11y_console_bridge_uses_realm_and_preserves_explicit_legacy_value() -> None: - with config_env(SPLUNK_AO_REALM="lab0", SPLUNK_AO_SF_TOKEN="tok"): + with config_env(SPLUNK_AO_REALM="lab0", SPLUNK_AO_O11Y_TOKEN="tok"): SplunkAOConfig._bridge_env_vars() assert os.environ["GALILEO_CONSOLE_URL"] == "https://app.lab0.observability.splunkcloud.com/" with config_env( - SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", GALILEO_CONSOLE_URL="https://explicit.example.com" + SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok", GALILEO_CONSOLE_URL="https://explicit.example.com" ): SplunkAOConfig._bridge_env_vars() assert os.environ["GALILEO_CONSOLE_URL"] == "https://explicit.example.com" def test_o11y_console_bridge_rederives_after_reset() -> None: - with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok"): + with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok"): SplunkAOConfig._bridge_env_vars() assert os.environ["GALILEO_CONSOLE_URL"] == "https://app.us1.observability.splunkcloud.com/" @@ -197,9 +197,9 @@ def fail(*args: object, **kwargs: object) -> None: async def async_fail(*args: object, **kwargs: object) -> None: fail() - values = {"SPLUNK_AO_REALM": "lab0", "SPLUNK_AO_SF_TOKEN": "ingest-token"} + values = {"SPLUNK_AO_REALM": "lab0", "SPLUNK_AO_O11Y_TOKEN": "ingest-token"} if api_token is not None: - values["SPLUNK_AO_SF_API_TOKEN"] = api_token + values["SPLUNK_AO_O11Y_API_TOKEN"] = api_token values["GALILEO_API_KEY"] = "stale-standalone-key" values["GALILEO_API_URL"] = "https://stale-api.example.com" values["GALILEO_JWT_TOKEN"] = "stale-jwt" @@ -235,7 +235,7 @@ async def async_fail(*args: object, **kwargs: object) -> None: monkeypatch.setattr(ApiClient, "make_request", staticmethod(async_fail)) monkeypatch.setattr(ApiClient, "request", fail) - with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_API_TOKEN="api-token"): + with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_API_TOKEN="api-token"): cfg = SplunkAOConfig.get(ssl_context=False) client = cfg.api_client @@ -246,7 +246,7 @@ async def async_fail(*args: object, **kwargs: object) -> None: def test_ambiguous_environment_fails_before_config_construction(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SplunkAOConfig, "_instance", None) - with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_KEY="key"): + with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_O11Y_TOKEN="tok", SPLUNK_AO_API_KEY="key"): with pytest.raises(AmbiguousConfigurationError): SplunkAOConfig.get() diff --git a/tests/test_otel_native_paths.py b/tests/test_otel_native_paths.py index 8e7a364b..4896ee69 100644 --- a/tests/test_otel_native_paths.py +++ b/tests/test_otel_native_paths.py @@ -162,7 +162,7 @@ def build_exporter(mode: DeploymentMode, factory: RecordingExporterFactory, **ro standalone = StandaloneConfig( api_key="standalone-key", console_url="https://console.example.com", api_url="https://api.example.com" ) - o11y = O11yConfig(realm="us1", sf_token="o11y-token") + o11y = O11yConfig(realm="us1", o11y_token="o11y-token") with ( patch("splunk_ao.otel.SplunkAOConfig.get", return_value=config), patch("splunk_ao.otel.StandaloneConfig.from_env", return_value=standalone), @@ -217,12 +217,12 @@ def test_o11y_exporter_rejects_crud_only_config_before_delegate_construction() - factory = RecordingExporterFactory() config = MagicMock() config.resolve_deployment.return_value = DeploymentMode.O11Y - crud_only = O11yConfig(realm="us1", sf_api_token="api-token") + crud_only = O11yConfig(realm="us1", o11y_api_token="api-token") with ( patch("splunk_ao.otel.SplunkAOConfig.get", return_value=config), patch("splunk_ao.otel.O11yConfig.from_env", return_value=crud_only), - pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"), + pytest.raises(MissingConfigurationError, match="SPLUNK_AO_O11Y_TOKEN"), ): SplunkAOOTLPExporter(_exporter_factory=factory) From a05037067c2af051199ca7e942d77eba0058cf54 Mon Sep 17 00:00:00 2001 From: pradystar Date: Fri, 31 Jul 2026 10:14:38 -0700 Subject: [PATCH 2/2] fix(adk): restore ingestion-hook trace completion compatibility --- .../tests/test_splunk_ao_compatibility.py | 8 +++++--- .../src/splunk_ao_adk/trace_builder.py | 20 ++++++++++++++++++- splunk-ao-adk/tests/test_trace_builder.py | 19 +++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/splunk-ao-a2a/tests/test_splunk_ao_compatibility.py b/splunk-ao-a2a/tests/test_splunk_ao_compatibility.py index 421405f8..987259d8 100644 --- a/splunk-ao-a2a/tests/test_splunk_ao_compatibility.py +++ b/splunk-ao-a2a/tests/test_splunk_ao_compatibility.py @@ -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 @@ -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] = {} @@ -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 diff --git a/splunk-ao-adk/src/splunk_ao_adk/trace_builder.py b/splunk-ao-adk/src/splunk_ao_adk/trace_builder.py index 7feb7d57..00c0ac88 100644 --- a/splunk-ao-adk/src/splunk_ao_adk/trace_builder.py +++ b/splunk-ao-adk/src/splunk_ao_adk/trace_builder.py @@ -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 @@ -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.""" diff --git a/splunk-ao-adk/tests/test_trace_builder.py b/splunk-ao-adk/tests/test_trace_builder.py index c2d62299..62808655 100644 --- a/splunk-ao-adk/tests/test_trace_builder.py +++ b/splunk-ao-adk/tests/test_trace_builder.py @@ -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 @@ -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."""