Skip to content

Commit f47b62b

Browse files
CopilotsergioescaleraCopilotnikhilc-microsoft
authored
[SDK Parity] Add scope and domain overrides for observability pre-prod testing (#107)
* Initial plan * Add scope and domain override support for observability Co-authored-by: sergioescalera <8428450+sergioescalera@users.noreply.github.com> * Fix code review feedback: move imports to top of file Co-authored-by: sergioescalera <8428450+sergioescalera@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address code review feedback: add validation, improve tests, optimize env var reading Co-authored-by: sergioescalera <8428450+sergioescalera@users.noreply.github.com> * fix comments * fix formatting --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergioescalera <8428450+sergioescalera@users.noreply.github.com> Co-authored-by: Sergio Escalera <sergio.escalera.c@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com>
1 parent b42d522 commit f47b62b

5 files changed

Lines changed: 253 additions & 4 deletions

File tree

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from opentelemetry.trace import StatusCode
1919

2020
from .utils import (
21+
get_validated_domain_override,
2122
hex_span_id,
2223
hex_trace_id,
2324
kind_name,
@@ -60,6 +61,8 @@ def __init__(
6061
self._token_resolver = token_resolver
6162
self._cluster_category = cluster_category
6263
self._use_s2s_endpoint = use_s2s_endpoint
64+
# Read domain override once at initialization
65+
self._domain_override = get_validated_domain_override()
6366

6467
# ------------- SpanExporter API -----------------
6568

@@ -86,8 +89,11 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
8689
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
8790

8891
# Resolve endpoint + token
89-
discovery = PowerPlatformApiDiscovery(self._cluster_category)
90-
endpoint = discovery.get_tenant_island_cluster_endpoint(tenant_id)
92+
if self._domain_override:
93+
endpoint = self._domain_override
94+
else:
95+
discovery = PowerPlatformApiDiscovery(self._cluster_category)
96+
endpoint = discovery.get_tenant_island_cluster_endpoint(tenant_id)
9197
endpoint_path = (
9298
f"/maven/agent365/service/agents/{agent_id}/traces"
9399
if self._use_s2s_endpoint
@@ -142,6 +148,8 @@ def shutdown(self) -> None:
142148
def force_flush(self, timeout_millis: int = 30000) -> bool:
143149
return True
144150

151+
# ------------- Helper methods -------------------
152+
145153
# ------------- HTTP helper ----------------------
146154

147155
@staticmethod

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ def partition_by_identity(
142142
return groups
143143

144144

145+
def get_validated_domain_override() -> str | None:
146+
"""
147+
Get and validate the domain override from environment variable.
148+
149+
Returns:
150+
The validated domain override, or None if not set or invalid.
151+
"""
152+
domain_override = os.getenv("A365_OBSERVABILITY_DOMAIN_OVERRIDE", "").strip()
153+
if not domain_override:
154+
return None
155+
156+
# Basic validation: ensure domain doesn't contain protocol or path separators
157+
if "://" in domain_override or "/" in domain_override:
158+
logger.warning(
159+
f"Invalid domain override '{domain_override}': "
160+
"domain should not contain protocol (://) or path separators (/)"
161+
)
162+
return None
163+
164+
return domain_override
165+
166+
145167
def is_agent365_exporter_enabled() -> bool:
146168
"""Check if Agent 365 exporter is enabled."""
147169
# Check environment variable

libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ def get_observability_authentication_scope() -> list[str]:
2121
"""
2222
Returns the scope for authenticating to the observability service based on the current environment.
2323
24+
The scope can be overridden via the A365_OBSERVABILITY_SCOPE_OVERRIDE environment variable
25+
to enable testing against pre-production environments.
26+
2427
Returns:
2528
list[str]: The authentication scope for the current environment.
2629
"""
27-
return [PROD_OBSERVABILITY_SCOPE]
30+
override_scope = os.getenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", "").strip()
31+
return [override_scope] if override_scope else [PROD_OBSERVABILITY_SCOPE]
2832

2933

3034
def is_development_environment() -> bool:

tests/observability/core/test_agent365_exporter.py

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import json
4+
import os
45
import unittest
56
from unittest.mock import Mock, patch
67

@@ -20,11 +21,25 @@ def setUp(self):
2021
self.mock_token_resolver = Mock()
2122
self.mock_token_resolver.return_value = "test_token_123"
2223

23-
# Don't patch the class in setUp, do it per test
24+
# Store original environment variable values for cleanup
25+
self._original_domain_override = os.environ.get("A365_OBSERVABILITY_DOMAIN_OVERRIDE")
26+
27+
# Ensure no override is set by default for most tests
28+
os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None)
29+
30+
# Create default exporter for tests that don't need special setup
2431
self.exporter = _Agent365Exporter(
2532
token_resolver=self.mock_token_resolver, cluster_category="test"
2633
)
2734

35+
def tearDown(self):
36+
"""Clean up test environment."""
37+
# Restore original environment variable value
38+
if self._original_domain_override is None:
39+
os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None)
40+
else:
41+
os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = self._original_domain_override
42+
2843
def _create_mock_span(
2944
self,
3045
name: str = "test_span",
@@ -384,6 +399,172 @@ def test_exporter_is_internal(self):
384399
"Exporter class should be prefixed with underscore to indicate it's private/internal",
385400
)
386401

402+
def test_export_uses_domain_override_when_env_var_set(self):
403+
"""Test that domain override is used when A365_OBSERVABILITY_DOMAIN_OVERRIDE is set."""
404+
# Arrange
405+
override_domain = "override.example.com"
406+
os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = override_domain
407+
408+
# Create exporter after setting environment variable so it reads the override
409+
exporter = _Agent365Exporter(
410+
token_resolver=self.mock_token_resolver, cluster_category="test"
411+
)
412+
413+
spans = [self._create_mock_span("override_test_span")]
414+
415+
# Mock the PowerPlatformApiDiscovery class (should not be called when override is set)
416+
with patch(
417+
"microsoft_agents_a365.observability.core.exporters.agent365_exporter.PowerPlatformApiDiscovery"
418+
) as mock_discovery_class:
419+
# Mock the _post_with_retries method
420+
with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post:
421+
# Act
422+
result = exporter.export(spans)
423+
424+
# Assert
425+
self.assertEqual(result, SpanExportResult.SUCCESS)
426+
mock_post.assert_called_once()
427+
428+
# Verify the call arguments - should use override domain with complete URL
429+
args, kwargs = mock_post.call_args
430+
url, body, headers = args
431+
432+
expected_url = f"https://{override_domain}/maven/agent365/agents/test-agent-456/traces?api-version=1"
433+
self.assertEqual(url, expected_url)
434+
435+
# Verify PowerPlatformApiDiscovery was not instantiated
436+
mock_discovery_class.assert_not_called()
437+
438+
def test_export_uses_default_domain_when_no_override(self):
439+
"""Test that default domain resolution is used when no override is set."""
440+
# Arrange
441+
# Ensure override is not set
442+
os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None)
443+
444+
# Create exporter after clearing environment variable
445+
exporter = _Agent365Exporter(
446+
token_resolver=self.mock_token_resolver, cluster_category="test"
447+
)
448+
449+
spans = [self._create_mock_span("default_domain_span")]
450+
451+
# Mock the PowerPlatformApiDiscovery class
452+
with patch(
453+
"microsoft_agents_a365.observability.core.exporters.agent365_exporter.PowerPlatformApiDiscovery"
454+
) as mock_discovery_class:
455+
mock_discovery = Mock()
456+
mock_discovery.get_tenant_island_cluster_endpoint.return_value = "default-endpoint.com"
457+
mock_discovery_class.return_value = mock_discovery
458+
459+
# Mock the _post_with_retries method
460+
with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post:
461+
# Act
462+
result = exporter.export(spans)
463+
464+
# Assert
465+
self.assertEqual(result, SpanExportResult.SUCCESS)
466+
mock_post.assert_called_once()
467+
468+
# Verify the call arguments - should use default domain
469+
args, kwargs = mock_post.call_args
470+
url, body, headers = args
471+
472+
self.assertIn("default-endpoint.com", url)
473+
self.assertIn("/maven/agent365/agents/test-agent-456/traces", url)
474+
475+
# Verify PowerPlatformApiDiscovery was called
476+
mock_discovery_class.assert_called_once_with("test")
477+
mock_discovery.get_tenant_island_cluster_endpoint.assert_called_once_with(
478+
"test-tenant-123"
479+
)
480+
481+
def test_export_ignores_empty_domain_override(self):
482+
"""Test that empty or whitespace-only domain override is ignored."""
483+
# Arrange
484+
os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = " " # whitespace only
485+
486+
# Create exporter after setting environment variable
487+
exporter = _Agent365Exporter(
488+
token_resolver=self.mock_token_resolver, cluster_category="test"
489+
)
490+
491+
spans = [self._create_mock_span("test_span")]
492+
493+
# Mock the PowerPlatformApiDiscovery class (should be called since override is invalid)
494+
with patch(
495+
"microsoft_agents_a365.observability.core.exporters.agent365_exporter.PowerPlatformApiDiscovery"
496+
) as mock_discovery_class:
497+
mock_discovery = Mock()
498+
mock_discovery.get_tenant_island_cluster_endpoint.return_value = "default-endpoint.com"
499+
mock_discovery_class.return_value = mock_discovery
500+
501+
with patch.object(exporter, "_post_with_retries", return_value=True):
502+
# Act
503+
result = exporter.export(spans)
504+
505+
# Assert
506+
self.assertEqual(result, SpanExportResult.SUCCESS)
507+
# Verify PowerPlatformApiDiscovery was called (override was ignored)
508+
mock_discovery_class.assert_called_once_with("test")
509+
510+
def test_export_ignores_invalid_domain_with_protocol(self):
511+
"""Test that domain override containing protocol is ignored."""
512+
# Arrange
513+
os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "https://invalid.example.com"
514+
515+
# Create exporter after setting environment variable
516+
exporter = _Agent365Exporter(
517+
token_resolver=self.mock_token_resolver, cluster_category="test"
518+
)
519+
520+
spans = [self._create_mock_span("test_span")]
521+
522+
# Mock the PowerPlatformApiDiscovery class (should be called since override is invalid)
523+
with patch(
524+
"microsoft_agents_a365.observability.core.exporters.agent365_exporter.PowerPlatformApiDiscovery"
525+
) as mock_discovery_class:
526+
mock_discovery = Mock()
527+
mock_discovery.get_tenant_island_cluster_endpoint.return_value = "default-endpoint.com"
528+
mock_discovery_class.return_value = mock_discovery
529+
530+
with patch.object(exporter, "_post_with_retries", return_value=True):
531+
# Act
532+
result = exporter.export(spans)
533+
534+
# Assert
535+
self.assertEqual(result, SpanExportResult.SUCCESS)
536+
# Verify PowerPlatformApiDiscovery was called (override was ignored)
537+
mock_discovery_class.assert_called_once_with("test")
538+
539+
def test_export_ignores_invalid_domain_with_path(self):
540+
"""Test that domain override containing path separator is ignored."""
541+
# Arrange
542+
os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "invalid.example.com/path"
543+
544+
# Create exporter after setting environment variable
545+
exporter = _Agent365Exporter(
546+
token_resolver=self.mock_token_resolver, cluster_category="test"
547+
)
548+
549+
spans = [self._create_mock_span("test_span")]
550+
551+
# Mock the PowerPlatformApiDiscovery class (should be called since override is invalid)
552+
with patch(
553+
"microsoft_agents_a365.observability.core.exporters.agent365_exporter.PowerPlatformApiDiscovery"
554+
) as mock_discovery_class:
555+
mock_discovery = Mock()
556+
mock_discovery.get_tenant_island_cluster_endpoint.return_value = "default-endpoint.com"
557+
mock_discovery_class.return_value = mock_discovery
558+
559+
with patch.object(exporter, "_post_with_retries", return_value=True):
560+
# Act
561+
result = exporter.export(spans)
562+
563+
# Assert
564+
self.assertEqual(result, SpanExportResult.SUCCESS)
565+
# Verify PowerPlatformApiDiscovery was called (override was ignored)
566+
mock_discovery_class.assert_called_once_with("test")
567+
387568

388569
if __name__ == "__main__":
389570
unittest.main()

tests/runtime/test_environment_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,40 @@ def test_get_observability_authentication_scope():
1818
assert result == [PROD_OBSERVABILITY_SCOPE]
1919

2020

21+
def test_get_observability_authentication_scope_with_override(monkeypatch):
22+
"""Test get_observability_authentication_scope returns override when env var is set."""
23+
override_scope = "https://override.example.com/.default"
24+
monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", override_scope)
25+
26+
result = get_observability_authentication_scope()
27+
assert result == [override_scope]
28+
29+
30+
def test_get_observability_authentication_scope_ignores_empty_override(monkeypatch):
31+
"""Test get_observability_authentication_scope ignores empty string override."""
32+
monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", "")
33+
34+
result = get_observability_authentication_scope()
35+
assert result == [PROD_OBSERVABILITY_SCOPE]
36+
37+
38+
def test_get_observability_authentication_scope_ignores_whitespace_override(monkeypatch):
39+
"""Test get_observability_authentication_scope ignores whitespace-only override."""
40+
monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", " ")
41+
42+
result = get_observability_authentication_scope()
43+
assert result == [PROD_OBSERVABILITY_SCOPE]
44+
45+
46+
def test_get_observability_authentication_scope_trims_whitespace(monkeypatch):
47+
"""Test get_observability_authentication_scope trims whitespace from override."""
48+
override_scope = " https://override.example.com/.default "
49+
monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", override_scope)
50+
51+
result = get_observability_authentication_scope()
52+
assert result == [override_scope.strip()]
53+
54+
2155
@pytest.mark.parametrize(
2256
"env_value,expected",
2357
[

0 commit comments

Comments
 (0)