From 1a50ba8d473cfd28acb0efe71e7ffd0eb04e805a Mon Sep 17 00:00:00 2001 From: pradystar Date: Thu, 6 Aug 2026 10:17:41 -0700 Subject: [PATCH 1/2] fix(config): derive standalone API URLs from custom console hosts --- CHANGELOG.md | 3 +++ README.md | 9 +++++-- src/splunk_ao/config.py | 8 +++++-- src/splunk_ao/deployment.py | 19 ++++++++++++++- tests/test_config.py | 45 +++++++++++++++++++++++++++-------- tests/test_deployment.py | 24 ++++++++++++++++++- tests/test_exporter_config.py | 2 +- tests/test_prompts_global.py | 22 +++++++++-------- 8 files changed, 105 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8335195..3b933727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Standalone custom console domains now derive a consistent `api.` hostname + for both CRUD operations and OTLP trace export unless `SPLUNK_AO_API_URL` is + set explicitly. - Agent Control spans exported over OTLP now include the control discriminator and complete `agent_control.*` field set required for backend classification and Controls-card rendering. diff --git a/README.md b/README.md index cfbe1f37..0ffc4704 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,19 @@ export SPLUNK_AO_API_KEY="your-agent-observability-api-key" export SPLUNK_AO_CONSOLE_URL="https://console.subdomain.yourcompany.com" ``` +For standalone custom domains, the SDK derives the API hostname by replacing a +leading `console.` or `app.` label with `api.`, or by adding an `api.` prefix +when neither label is present. Set `SPLUNK_AO_API_URL` explicitly when your API +does not follow this convention. + > [!TIP] -> Logging your first trace to on-premises Agent Observability? [Visit this guide](https://agent-observability-docs.splunk.com/sdk-redirect/on-prem-first-trace). +> Logging your first trace to on-premises Agent Observability? [Visit this guide](https://agent-observability-docs.splunk.com/sdk-redirect/on-prem-first-trace). > > Learn how to find each environment variable in [this guide](https://agent-observability-docs.splunk.com/sdk-redirect/on-prem-keys). #### Splunk Observability (O11y) Cloud -> [!NOTE] +> [!NOTE] > As of August 2026, Agent Observability on Splunk Observability Cloud is not yet generally available. | Environment variable | Description | diff --git a/src/splunk_ao/config.py b/src/splunk_ao/config.py index 58cf3917..76ba6c17 100644 --- a/src/splunk_ao/config.py +++ b/src/splunk_ao/config.py @@ -12,7 +12,7 @@ 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.deployment import DeploymentMode, O11yConfig +from splunk_ao.deployment import DeploymentMode, O11yConfig, resolve_standalone_api_url from splunk_ao.deployment import resolve_deployment as _resolve_deployment from splunk_ao.shared.exceptions import ConfigurationError, MissingConfigurationError @@ -105,7 +105,11 @@ 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) + + console_url_value = str(info.data["console_url"]) + api_url_value = str(api_url) if api_url is not None else None + resolved_api_url = resolve_standalone_api_url(console_url_value, api_url_value) + return super().set_api_url(resolved_api_url, info) @model_validator(mode="after") def set_jwt_token(self) -> "SplunkAOConfig": diff --git a/src/splunk_ao/deployment.py b/src/splunk_ao/deployment.py index 1bfa69c3..3f15d87e 100644 --- a/src/splunk_ao/deployment.py +++ b/src/splunk_ao/deployment.py @@ -46,6 +46,23 @@ def resolve_deployment() -> DeploymentMode: ) +def resolve_standalone_api_url(console_url: str, api_url: str | None = None) -> str: + """Return the explicit or console-derived standalone API base URL.""" + if api_url: + return api_url + + if "localhost" in console_url or "127.0.0.1" in console_url: + return "http://localhost:8088" + + base_url = console_url.rstrip("/") + base_url = base_url.replace("://console.", "://api.", 1).replace("://app.", "://api.", 1) + scheme, host = base_url.split("://", 1) + if not host.startswith("api."): + host = f"api.{host}" + + return f"{scheme}://{host}" + + @dataclass class O11yConfig: """Configuration for a Splunk Observability Cloud deployment.""" @@ -150,5 +167,5 @@ def from_env(cls) -> "StandaloneConfig": @property def otlp_endpoint(self) -> str: """Return the explicit or console-derived OTLP trace endpoint.""" - base = self.api_url or self.console_url.replace("://console.", "://api.", 1).replace("://app.", "://api.", 1) + base = resolve_standalone_api_url(self.console_url, self.api_url) return f"{base.rstrip('/')}/otel/v1/traces" diff --git a/tests/test_config.py b/tests/test_config.py index ce2407ab..63b2809d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ from test_support.config import fast_config_validation from splunk_ao.config import _BRIDGE, SplunkAOConfig +from splunk_ao.deployment import StandaloneConfig from splunk_ao.shared.exceptions import ConfigurationError # Auth env vars cleared in tests that exercise the missing-auth guard. @@ -81,9 +82,7 @@ def test_bridge_env_vars_propagates_splunk_ao_to_galileo(splunk_key, galileo_key with patch.dict(os.environ, {splunk_key: value}, clear=False): os.environ.pop(galileo_key, None) SplunkAOConfig._bridge_env_vars() - assert os.environ.get(galileo_key) == value, ( - f"Expected {galileo_key}={value!r} after bridging {splunk_key}" - ) + assert os.environ.get(galileo_key) == value, f"Expected {galileo_key}={value!r} after bridging {splunk_key}" @pytest.mark.parametrize("splunk_key,galileo_key", _CANONICAL_BRIDGE_PAIRS) @@ -107,9 +106,7 @@ def test_bridge_env_vars_skips_absent_splunk_ao_keys() -> None: with patch.dict(os.environ, clean_env, clear=True): SplunkAOConfig._bridge_env_vars() for _, galileo_key in _ALL_BRIDGE_PAIRS: - assert galileo_key not in os.environ, ( - f"{galileo_key} must not be set when its SPLUNK_AO_* source is absent" - ) + assert galileo_key not in os.environ, f"{galileo_key} must not be set when its SPLUNK_AO_* source is absent" # --------------------------------------------------------------------------- @@ -127,6 +124,36 @@ def test_default_console_url() -> None: assert str(config.api_url) == "https://api.galileo.ai/" +def test_standalone_crud_and_otlp_share_custom_api_url_derivation(monkeypatch: pytest.MonkeyPatch) -> None: + # Given: a standalone custom console host without an explicit API URL + console_url = "https://customer.example.com" + monkeypatch.setattr(SplunkAOConfig, "_instance", None) + + # When: CRUD configuration and OTLP configuration resolve their endpoints + with patch.dict(os.environ, {}, clear=True), fast_config_validation(): + config = SplunkAOConfig.get(console_url=console_url, api_key="key", ssl_context=False) + otlp_endpoint = StandaloneConfig(api_key="key", console_url=console_url).otlp_endpoint + + # Then: both consumers use the same derived standalone API base URL + assert str(config.api_url) == "https://api.customer.example.com/" + assert otlp_endpoint == f"{str(config.api_url).rstrip('/')}/otel/v1/traces" + + +def test_standalone_crud_preserves_explicit_api_url(monkeypatch: pytest.MonkeyPatch) -> None: + # Given: a standalone deployment with an explicit API URL + monkeypatch.setattr(SplunkAOConfig, "_instance", None) + explicit_api_url = "https://backend.example.com/custom/" + + # When: CRUD configuration resolves its API URL with health checks mocked + with patch.dict(os.environ, {}, clear=True), fast_config_validation(): + config = SplunkAOConfig.get( + console_url="https://customer.example.com", api_url=explicit_api_url, api_key="key", ssl_context=False + ) + + # Then: the explicit URL remains authoritative + assert str(config.api_url) == explicit_api_url + + def test_no_auth_configured_raises_with_full_options_listed(monkeypatch) -> None: """When no auth is configured anywhere, the error lists every supported method.""" # Given: no auth env vars and no cached instance @@ -271,8 +298,7 @@ def test_reset_clears_bridged_galileo_env_vars() -> None: for galileo_key in galileo_keys: assert galileo_key not in os.environ, ( - f"reset() must remove {galileo_key} from os.environ; " - f"found stale value '{os.environ.get(galileo_key)}'" + f"reset() must remove {galileo_key} from os.environ; found stale value '{os.environ.get(galileo_key)}'" ) @@ -306,8 +332,7 @@ def test_bridge_picks_up_new_credential_after_reset(monkeypatch) -> None: # Second bridge — must pick up the new key now that reset() cleared the old one. SplunkAOConfig._bridge_env_vars() assert os.environ.get("GALILEO_API_KEY") == "key-rotated", ( - "After reset() + credential rotation, bridge must copy the new key; " - "got stale value instead" + "After reset() + credential rotation, bridge must copy the new key; got stale value instead" ) # Cleanup: monkeypatch will restore SPLUNK_AO_API_KEY, but the bridge wrote # GALILEO_API_KEY directly to os.environ — remove it so it doesn't leak. diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 86119bd5..6fa23c40 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -7,7 +7,7 @@ import pytest from splunk_ao.config import SplunkAOConfig -from splunk_ao.deployment import DeploymentMode, O11yConfig, StandaloneConfig +from splunk_ao.deployment import DeploymentMode, O11yConfig, StandaloneConfig, resolve_standalone_api_url from splunk_ao.shared.exceptions import AmbiguousConfigurationError, MissingConfigurationError _DETECTION_ENV_VARS = ( @@ -218,6 +218,28 @@ def test_missing_standalone_config_names_both_required_variables() -> None: assert "SPLUNK_AO_CONSOLE_URL" in str(exc_info.value) +@pytest.mark.parametrize( + ("console_url", "expected"), + [ + ("https://customer.example.com", "https://api.customer.example.com"), + ("https://api.customer.example.com", "https://api.customer.example.com"), + ("http://customer.example.com:8443", "http://api.customer.example.com:8443"), + ("http://localhost:3000", "http://localhost:8088"), + ("https://127.0.0.1:9090", "http://localhost:8088"), + ("https://customer-console.example.com", "https://api.customer-console.example.com"), + ], + ids=["custom-host", "existing-api-prefix", "scheme-and-port", "localhost", "loopback", "console-substring"], +) +def test_resolve_standalone_api_url(console_url: str, expected: str) -> None: + # Given: a standalone console URL requiring API-host resolution + + # When: the standalone API base URL is derived + resolved = resolve_standalone_api_url(console_url) + + # Then: the expected standalone API base URL is returned + assert resolved == expected + + def test_otlp_endpoint_derived_from_console_url_when_api_url_unset() -> None: cfg = StandaloneConfig(api_key="key", console_url="https://console.demo.galileocloud.io") assert cfg.otlp_endpoint == "https://api.demo.galileocloud.io/otel/v1/traces" diff --git a/tests/test_exporter_config.py b/tests/test_exporter_config.py index ed0c9c94..a78438b9 100644 --- a/tests/test_exporter_config.py +++ b/tests/test_exporter_config.py @@ -23,7 +23,7 @@ def test_standalone_exporter_endpoint() -> None: cfg = StandaloneConfig(api_key="key", console_url="https://ao.example.com") result = resolve_standalone_exporter_config(cfg, routing=make_routing(project_name="proj1")) - assert result.endpoint == cfg.otlp_endpoint == "https://ao.example.com/otel/v1/traces" + assert result.endpoint == cfg.otlp_endpoint == "https://api.ao.example.com/otel/v1/traces" def test_standalone_exporter_endpoint_uses_explicit_api_url() -> None: diff --git a/tests/test_prompts_global.py b/tests/test_prompts_global.py index ad2f3e94..b398c424 100644 --- a/tests/test_prompts_global.py +++ b/tests/test_prompts_global.py @@ -11,6 +11,8 @@ from splunk_ao import Message, MessageRole from splunk_ao.prompts import create_prompt, delete_prompt, get_prompt, get_prompts +_API_URL = "http://api.fake.test:8088" + @pytest.fixture def prompt_template_response(): @@ -62,12 +64,12 @@ class TestGlobalPromptTemplates: def test_create_global_prompt(self, respx_mock: MockRouter, prompt_template_response): """Test creating a global prompt template.""" # Mock the query API (for uniqueness check) - query_route = respx_mock.post("http://fake.test:8088/templates/query").mock( + query_route = respx_mock.post(f"{_API_URL}/templates/query").mock( return_value=httpx.Response(200, json={"templates": []}) ) # Mock the create API - create_route = respx_mock.post("http://fake.test:8088/templates").mock( + create_route = respx_mock.post(f"{_API_URL}/templates").mock( return_value=httpx.Response(200, json=prompt_template_response) ) @@ -80,7 +82,7 @@ def test_create_global_prompt(self, respx_mock: MockRouter, prompt_template_resp def test_get_global_prompt_by_id(self, respx_mock: MockRouter, prompt_template_response): """Test retrieving a global prompt template by ID.""" - get_route = respx_mock.get(f"http://fake.test:8088/templates/{prompt_template_response['id']}").mock( + get_route = respx_mock.get(f"{_API_URL}/templates/{prompt_template_response['id']}").mock( return_value=httpx.Response(200, json=prompt_template_response) ) @@ -92,7 +94,7 @@ def test_get_global_prompt_by_id(self, respx_mock: MockRouter, prompt_template_r def test_get_global_prompt_by_name(self, respx_mock: MockRouter, prompt_template_response): """Test retrieving a global prompt template by name.""" - query_route = respx_mock.post("http://fake.test:8088/templates/query").mock( + query_route = respx_mock.post(f"{_API_URL}/templates/query").mock( return_value=httpx.Response( 200, json={"templates": [prompt_template_response], "next_starting_token": None} ) @@ -106,7 +108,7 @@ def test_get_global_prompt_by_name(self, respx_mock: MockRouter, prompt_template def test_list_global_prompts(self, respx_mock: MockRouter, prompt_template_response): """Test listing global prompt templates.""" - query_route = respx_mock.post("http://fake.test:8088/templates/query").mock( + query_route = respx_mock.post(f"{_API_URL}/templates/query").mock( return_value=httpx.Response( 200, json={"templates": [prompt_template_response], "next_starting_token": None} ) @@ -120,7 +122,7 @@ def test_list_global_prompts(self, respx_mock: MockRouter, prompt_template_respo def test_delete_global_prompt_by_id(self, respx_mock: MockRouter): """Test deleting a global prompt template by ID.""" - delete_route = respx_mock.delete("http://fake.test:8088/templates/template-id-123").mock( + delete_route = respx_mock.delete(f"{_API_URL}/templates/template-id-123").mock( return_value=httpx.Response(200, json={"message": "Template deleted successfully"}) ) @@ -131,14 +133,14 @@ def test_delete_global_prompt_by_id(self, respx_mock: MockRouter): def test_delete_global_prompt_by_name(self, respx_mock: MockRouter, prompt_template_response): """Test deleting a global prompt template by name.""" # Mock query to find template by name - query_route = respx_mock.post("http://fake.test:8088/templates/query").mock( + query_route = respx_mock.post(f"{_API_URL}/templates/query").mock( return_value=httpx.Response( 200, json={"templates": [prompt_template_response], "next_starting_token": None} ) ) # Mock delete - delete_route = respx_mock.delete(f"http://fake.test:8088/templates/{prompt_template_response['id']}").mock( + delete_route = respx_mock.delete(f"{_API_URL}/templates/{prompt_template_response['id']}").mock( return_value=httpx.Response(200, json={"message": "Template deleted successfully"}) ) @@ -151,13 +153,13 @@ def test_create_prompt_with_unique_name(self, respx_mock: MockRouter, prompt_tem """Test that duplicate names get auto-incremented.""" # Mock query to find existing template existing_template = {**prompt_template_response, "name": "test-template"} - query_route = respx_mock.post("http://fake.test:8088/templates/query").mock( + query_route = respx_mock.post(f"{_API_URL}/templates/query").mock( return_value=httpx.Response(200, json={"templates": [existing_template], "next_starting_token": None}) ) # Mock create with new unique name new_template = {**prompt_template_response, "name": "test-template (1)"} - create_route = respx_mock.post("http://fake.test:8088/templates").mock( + create_route = respx_mock.post(f"{_API_URL}/templates").mock( return_value=httpx.Response(200, json=new_template) ) From 60508062cee56dadc134043ba70d59929a492149 Mon Sep 17 00:00:00 2001 From: pradystar Date: Thu, 6 Aug 2026 16:42:19 -0700 Subject: [PATCH 2/2] address review comments --- src/splunk_ao/config.py | 6 +++++- src/splunk_ao/deployment.py | 2 ++ tests/test_config.py | 9 +++++++++ tests/test_deployment.py | 31 +++++++++++++++++++++---------- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/splunk_ao/config.py b/src/splunk_ao/config.py index 76ba6c17..fd558a7b 100644 --- a/src/splunk_ao/config.py +++ b/src/splunk_ao/config.py @@ -106,7 +106,11 @@ def set_api_url(cls, api_url: str | Url | None, info: ValidationInfo) -> Url: if cls._is_o11y_env(): return Url(O11yConfig.from_env().require_api_url()) - console_url_value = str(info.data["console_url"]) + console_url = info.data.get("console_url") + if console_url is None: + return super().set_api_url(api_url, info) + + console_url_value = str(console_url) api_url_value = str(api_url) if api_url is not None else None resolved_api_url = resolve_standalone_api_url(console_url_value, api_url_value) return super().set_api_url(resolved_api_url, info) diff --git a/src/splunk_ao/deployment.py b/src/splunk_ao/deployment.py index 3f15d87e..a9422383 100644 --- a/src/splunk_ao/deployment.py +++ b/src/splunk_ao/deployment.py @@ -55,6 +55,8 @@ def resolve_standalone_api_url(console_url: str, api_url: str | None = None) -> return "http://localhost:8088" base_url = console_url.rstrip("/") + if "://" not in base_url: + base_url = f"https://{base_url}" base_url = base_url.replace("://console.", "://api.", 1).replace("://app.", "://api.", 1) scheme, host = base_url.split("://", 1) if not host.startswith("api."): diff --git a/tests/test_config.py b/tests/test_config.py index 63b2809d..9c8c0ede 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import pytest +from pydantic import ValidationError from test_support.config import fast_config_validation from splunk_ao.config import _BRIDGE, SplunkAOConfig @@ -154,6 +155,14 @@ def test_standalone_crud_preserves_explicit_api_url(monkeypatch: pytest.MonkeyPa assert str(config.api_url) == explicit_api_url +def test_invalid_console_url_preserves_validation_error() -> None: + # Given: a console URL that fails its own field validation + + # When/Then: model construction reports a validation error instead of leaking a KeyError + with pytest.raises(ValidationError, match="console_url"): + SplunkAOConfig(console_url="http://[bad", api_key="key") + + def test_no_auth_configured_raises_with_full_options_listed(monkeypatch) -> None: """When no auth is configured anywhere, the error lists every supported method.""" # Given: no auth env vars and no cached instance diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 6fa23c40..f7d72313 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -219,22 +219,33 @@ def test_missing_standalone_config_names_both_required_variables() -> None: @pytest.mark.parametrize( - ("console_url", "expected"), + ("console_url", "api_url", "expected"), [ - ("https://customer.example.com", "https://api.customer.example.com"), - ("https://api.customer.example.com", "https://api.customer.example.com"), - ("http://customer.example.com:8443", "http://api.customer.example.com:8443"), - ("http://localhost:3000", "http://localhost:8088"), - ("https://127.0.0.1:9090", "http://localhost:8088"), - ("https://customer-console.example.com", "https://api.customer-console.example.com"), + ("https://customer.example.com", None, "https://api.customer.example.com"), + ("customer.example.com", None, "https://api.customer.example.com"), + ("https://api.customer.example.com", None, "https://api.customer.example.com"), + ("http://customer.example.com:8443", None, "http://api.customer.example.com:8443"), + ("http://localhost:3000", None, "http://localhost:8088"), + ("https://127.0.0.1:9090", None, "http://localhost:8088"), + ("https://customer-console.example.com", None, "https://api.customer-console.example.com"), + ("https://customer.example.com", "https://backend.example.com/", "https://backend.example.com/"), + ], + ids=[ + "custom-host", + "custom-host-without-scheme", + "existing-api-prefix", + "scheme-and-port", + "localhost", + "loopback", + "console-substring", + "explicit-api-url", ], - ids=["custom-host", "existing-api-prefix", "scheme-and-port", "localhost", "loopback", "console-substring"], ) -def test_resolve_standalone_api_url(console_url: str, expected: str) -> None: +def test_resolve_standalone_api_url(console_url: str, api_url: str | None, expected: str) -> None: # Given: a standalone console URL requiring API-host resolution # When: the standalone API base URL is derived - resolved = resolve_standalone_api_url(console_url) + resolved = resolve_standalone_api_url(console_url, api_url) # Then: the expected standalone API base URL is returned assert resolved == expected