diff --git a/src/Observability/Runtime/Builder.cs b/src/Observability/Runtime/Builder.cs index c237520d..f8985619 100644 --- a/src/Observability/Runtime/Builder.cs +++ b/src/Observability/Runtime/Builder.cs @@ -98,6 +98,7 @@ private void EnsureBuilt() tracerProviderBuilder.Build(); } + EnvironmentUtils.Initialize(configuration: this.Configuration); _isBuilt = true; } diff --git a/src/Observability/Runtime/Common/Agent365EndpointDiscovery.cs b/src/Observability/Runtime/Common/Agent365EndpointDiscovery.cs new file mode 100644 index 00000000..2d4704ef --- /dev/null +++ b/src/Observability/Runtime/Common/Agent365EndpointDiscovery.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.Agents.A365.Observability.Runtime.Common +{ + /// + /// Provides discovery for Agent365 endpoints. + /// + public sealed class Agent365EndpointDiscovery + { + private readonly string clusterCategory; + + /// + /// Initializes a new instance of the class. + /// + /// The cluster category. + public Agent365EndpointDiscovery(string clusterCategory) + { + this.clusterCategory = clusterCategory ?? "production"; + } + + /// + /// Gets the base host for the specified cluster category. + /// + public string GetHost() + { + switch (this.clusterCategory?.ToLowerInvariant()) + { + case "firstrelease": + case "production": + case "prod": + return "agent365.svc.cloud.microsoft"; + default: + throw new ArgumentException($"Invalid ClusterCategory value: {clusterCategory}"); + } + } + } +} diff --git a/src/Observability/Runtime/Common/EnvironmentUtils.cs b/src/Observability/Runtime/Common/EnvironmentUtils.cs index fb3f7f31..fab0bbfc 100644 --- a/src/Observability/Runtime/Common/EnvironmentUtils.cs +++ b/src/Observability/Runtime/Common/EnvironmentUtils.cs @@ -2,6 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // ------------------------------------------------------------------------------ +using Microsoft.Extensions.Configuration; using System; namespace Microsoft.Agents.A365.Observability.Runtime.Common @@ -14,6 +15,9 @@ public class EnvironmentUtils private const string ProdObservabilityScope = "https://api.powerplatform.com/.default"; private const string ProdObservabilityClusterCategory = "prod"; private const string DevelopmentEnvironmentName = "development"; + private const string Agent365EndpointProdObservabilityScope = "api://9b975845-388f-4429-889e-eab1ef63949c/.default"; + private static bool _initialized; + private static bool _customDomainEnabled; /// /// Returns the scope for authenticating to the observability service based on the current environment. @@ -21,19 +25,7 @@ public class EnvironmentUtils /// The authentication scope. public static string[] GetObservabilityAuthenticationScope() { - return new[] { ProdObservabilityScope }; - } - - /// - /// [Deprecated] Returns the scope for authenticating to the observability service based on the cluster category. - /// - /// Cluster category (deprecated, defaults to production). - /// The authentication scope. - [Obsolete("Cluster category argument is deprecated and will be removed in future versions. Defaults to production.")] - public static string[] GetObservabilityAuthenticationScope(string clusterCategory = ProdObservabilityClusterCategory) - { - // clusterCategory is ignored; always returns production scope - return new[] { ProdObservabilityScope }; + return IsCustomDomainEnabled() ? new[] { Agent365EndpointProdObservabilityScope } : new[] { ProdObservabilityScope }; } /// @@ -66,6 +58,40 @@ public static bool IsDevelopmentEnvironment() return string.Equals(environment, DevelopmentEnvironmentName, StringComparison.OrdinalIgnoreCase); } + /// + /// Initializes the cached configuration values for environment utilities. Should be called once at application startup. + /// + /// The configuration instance. + /// When true, re-initializes even if already initialized. + public static void Initialize(IConfiguration? configuration, bool force = false) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + if (_initialized && !force) + { + return; + } + + string enabled = configuration["EnableAgent365CustomDomain"] ?? string.Empty; + _customDomainEnabled = enabled.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + _initialized = true; + } + + /// + /// Returns true if the custom domain feature is enabled. + /// + public static bool IsCustomDomainEnabled() + { + if (!_initialized) + { + throw new InvalidOperationException("EnvironmentUtils is not initialized. Call Initialize() before using this method."); + } + return _customDomainEnabled; + } + /// /// Gets the current environment name. /// diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs index bab1a631..3615b000 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs @@ -4,6 +4,7 @@ using Microsoft.Agents.A365.Observability.Runtime.Common; using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry; @@ -26,6 +27,7 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters public class Agent365ExporterCore { private const string CorrelationIdHeaderKey = "x-ms-correlation-id"; + private const string TenantIdHeaderKey = "x-ms-tenant-id"; private readonly ExportFormatter _formatter; private readonly ILogger _logger; @@ -86,9 +88,11 @@ public Agent365ExporterCore(ExportFormatter formatter, ILoggerThe endpoint path string. public string BuildEndpointPath(string agentId, bool useS2SEndpoint) { - return useS2SEndpoint - ? $"/maven/agent365/service/agents/{agentId}/traces" - : $"/maven/agent365/agents/{agentId}/traces"; + return EnvironmentUtils.IsCustomDomainEnabled() + ? $"/agents/{agentId}/traces" + : useS2SEndpoint + ? $"/maven/agent365/service/agents/{agentId}/traces" + : $"/maven/agent365/agents/{agentId}/traces"; } /// @@ -97,7 +101,7 @@ public string BuildEndpointPath(string agentId, bool useS2SEndpoint) /// The base endpoint. /// The endpoint path. /// The full request URI string. - public string BuildRequestUri(string endpoint, string endpointPath) + public static string BuildRequestUri(string endpoint, string endpointPath) { return $"https://{endpoint}{endpointPath}?api-version=1"; } @@ -124,11 +128,11 @@ public async Task ExportBatchCoreAsync( var json = _formatter.FormatMany(activities, resource); using var content = new StringContent(json, Encoding.UTF8, "application/json"); - var ppapiDiscovery = new PowerPlatformApiDiscovery(options.ClusterCategory); - var ppapiEndpoint = ppapiDiscovery.GetTenantIslandClusterEndpoint(tenantId); - - var endpointPath = BuildEndpointPath(agentId, options.UseS2SEndpoint); - var requestUri = BuildRequestUri(ppapiEndpoint, endpointPath); + string endpointPath = this.BuildEndpointPath(agentId: agentId, useS2SEndpoint: options.UseS2SEndpoint); + var endpoint = EnvironmentUtils.IsCustomDomainEnabled() + ? new Agent365EndpointDiscovery(options.ClusterCategory).GetHost() + : new PowerPlatformApiDiscovery(options.ClusterCategory).GetTenantIslandClusterEndpoint(tenantId); + var requestUri = Agent365ExporterCore.BuildRequestUri(endpoint, endpointPath); using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { @@ -149,6 +153,11 @@ public async Task ExportBatchCoreAsync( if (!string.IsNullOrEmpty(token)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (EnvironmentUtils.IsCustomDomainEnabled() && !string.IsNullOrEmpty(tenantId)) + { + request.Headers.TryAddWithoutValidation(Agent365ExporterCore.TenantIdHeaderKey, tenantId); + } + HttpResponseMessage? resp = null; try { diff --git a/src/Observability/Runtime/Tracing/Exporters/ObservabilityTracerProviderBuilderExtensions.cs b/src/Observability/Runtime/Tracing/Exporters/ObservabilityTracerProviderBuilderExtensions.cs index ce981d1a..e8cbf23a 100644 --- a/src/Observability/Runtime/Tracing/Exporters/ObservabilityTracerProviderBuilderExtensions.cs +++ b/src/Observability/Runtime/Tracing/Exporters/ObservabilityTracerProviderBuilderExtensions.cs @@ -77,9 +77,11 @@ private static TracerProviderBuilder ConfigureInternal(IServiceProvider serviceP var coreLogger = serviceProvider.GetService>() ?? loggerFactory.CreateLogger(); var formatterLogger = serviceProvider.GetService>() ?? loggerFactory.CreateLogger(); + var configuration = serviceProvider.GetService(); + // Create ExportFormatter and Agent365ExporterCore - var exportFormatter = new ExportFormatter(formatterLogger); - var exporterCore = new Agent365ExporterCore(exportFormatter, coreLogger); + var exportFormatter = new ExportFormatter(logger: formatterLogger); + var exporterCore = new Agent365ExporterCore(formatter: exportFormatter, logger: coreLogger); switch (exporterType) { diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/Agent365EndpointDiscoveryTest.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/Agent365EndpointDiscoveryTest.cs new file mode 100644 index 00000000..9400c7a2 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/Agent365EndpointDiscoveryTest.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + +using Microsoft.Agents.A365.Observability.Runtime.Common; + +namespace Microsoft.Agents.A365.Observability.Runtime.Tests.Common; + + +[TestClass] +public class Agent365EndpointDiscoveryTests +{ + [TestMethod] + public void GetHost_Mapping_IsCorrect() + { + var expected = new Dictionary + { + ["firstrelease"] = "agent365.svc.cloud.microsoft", + ["production"] = "agent365.svc.cloud.microsoft", + ["prod"] = "agent365.svc.cloud.microsoft", + }; + + foreach (var kv in expected) + { + var disc = new Agent365EndpointDiscovery(kv.Key); + Assert.AreEqual(kv.Value, disc.GetHost()); + } + } + + [TestMethod] + public void GetHost_IsCaseInsensitive() + { + var disc1 = new Agent365EndpointDiscovery("PRODUCTION"); + Assert.AreEqual("agent365.svc.cloud.microsoft", disc1.GetHost()); + + var disc2 = new Agent365EndpointDiscovery("PROD"); + Assert.AreEqual("agent365.svc.cloud.microsoft", disc2.GetHost()); + } + + [TestMethod] + public void GetHost_ThrowsForUnknown() + { + var disc = new Agent365EndpointDiscovery("unknown-category"); + Assert.ThrowsException(() => disc.GetHost()); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/EnvironmentUtilsTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/EnvironmentUtilsTests.cs new file mode 100644 index 00000000..88ef839f --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/EnvironmentUtilsTests.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------------ + +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Agents.A365.Observability.Runtime.Common; + +namespace Microsoft.Agents.A365.Observability.Runtime.Tests.Common +{ + [TestClass] + public class EnvironmentUtilsTests + { + private static IConfiguration BuildConfig(IDictionary? values = null) + { + var builder = new ConfigurationBuilder(); + if (values != null) + { + builder.AddInMemoryCollection(values); + } + return builder.Build(); + } + + [TestMethod] + public void GetObservabilityAuthenticationScope_UsesAgent365Scope_WhenCustomDomainEnabled() + { + // Arrange + var configEnabled = BuildConfig(new Dictionary + { + { "EnableAgent365CustomDomain", "true" } + }); + + EnvironmentUtils.Initialize(configuration: configEnabled, force: true); + + // Act + var scope = EnvironmentUtils.GetObservabilityAuthenticationScope(); + + // Assert + scope.Should().ContainSingle().Which.Should().Be("api://9b975845-388f-4429-889e-eab1ef63949c/.default"); + } + + [TestMethod] + public void GetObservabilityAuthenticationScope_UsesProdScope_WhenCustomDomainDisabled() + { + // Arrange + var configDisabled = BuildConfig(new Dictionary + { + { "EnableAgent365CustomDomain", "false" } + }); + + EnvironmentUtils.Initialize(configuration: configDisabled, force: true); + + // Act + var scope = EnvironmentUtils.GetObservabilityAuthenticationScope(); + + // Assert + scope.Should().ContainSingle().Which.Should().Be("https://api.powerplatform.com/.default"); + } + + [TestMethod] + public void GetObservabilityAuthenticationScope_UsesProdScope_WhenCustomDomainSettingMissing() + { + // Arrange + var configMissing = BuildConfig(); + + EnvironmentUtils.Initialize(configuration: configMissing, force: true); + + // Act + var scope = EnvironmentUtils.GetObservabilityAuthenticationScope(); + + // Assert + scope.Should().ContainSingle().Which.Should().Be("https://api.powerplatform.com/.default"); + } + + [TestMethod] + public void IsCustomDomainEnabled_ReturnsTrue_WhenConfigValueTrue() + { + // Arrange + var config = BuildConfig(new Dictionary + { + { "EnableAgent365CustomDomain", "true" } + }); + + EnvironmentUtils.Initialize(configuration: config, force: true); + + // Act + var result = EnvironmentUtils.IsCustomDomainEnabled(); + + // Assert + result.Should().BeTrue(); + } + + [TestMethod] + public void IsCustomDomainEnabled_ReturnsFalse_WhenConfigValueFalse() + { + // Arrange + var config = BuildConfig(new Dictionary + { + { "EnableAgent365CustomDomain", "false" } + }); + + EnvironmentUtils.Initialize(configuration: config, force: true); + + // Act + var result = EnvironmentUtils.IsCustomDomainEnabled(); + + // Assert + result.Should().BeFalse(); + } + + [TestMethod] + public void IsCustomDomainEnabled_ReturnsFalse_WhenConfigValueMissing() + { + // Arrange + var config = BuildConfig(); + + EnvironmentUtils.Initialize(configuration: config, force: true); + + // Act + var result = EnvironmentUtils.IsCustomDomainEnabled(); + + // Assert + result.Should().BeFalse(); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/Agent365ExporterTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/Agent365ExporterTests.cs index 7eaf1a5d..96ddf829 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/Agent365ExporterTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/Agent365ExporterTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Agents.A365.Observability.Runtime.Common; using Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters; @@ -7,6 +7,7 @@ using OpenTelemetry.Resources; using System.Diagnostics; using System.Reflection; +using Microsoft.Extensions.Configuration; namespace Microsoft.Agents.A365.Observability.Tests.Tracing.Exporters; @@ -112,7 +113,17 @@ private static Agent365Exporter CreateExporter(Func? to resource); } - [TestMethod] + private static void SetCustomDomain(bool enabled) + { + var _configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "EnableAgent365CustomDomain", enabled ? bool.TrueString : bool.FalseString } + }) + .Build(); + EnvironmentUtils.Initialize(configuration: _configuration, force: true); + } + public void Constructor_NullLogger_Throws() { var options = new Agent365ExporterOptions @@ -1014,4 +1025,145 @@ public void Export_StandardEndpoint_WithDifferentClusterCategories_ProcessesCorr } #endregion + + #region Build Endpoint and URI Tests + [TestMethod] + public void BuildEndpointPath_CustomDomain_UsesAgentsRoot() + { + Agent365ExporterTests.SetCustomDomain(true); + var path = Agent365ExporterTests._agent365ExporterCore.BuildEndpointPath("agent-123", useS2SEndpoint: true); + path.Should().Be("/agents/agent-123/traces"); + } + + [TestMethod] + public void BuildEndpointPath_NonCustomDomain_UsesServicePathsDependingOnS2S() + { + Agent365ExporterTests.SetCustomDomain(false); + var s2s = Agent365ExporterTests._agent365ExporterCore.BuildEndpointPath("agent-123", useS2SEndpoint: true); + var standard = Agent365ExporterTests._agent365ExporterCore.BuildEndpointPath("agent-123", useS2SEndpoint: false); + s2s.Should().Be("/maven/agent365/service/agents/agent-123/traces"); + standard.Should().Be("/maven/agent365/agents/agent-123/traces"); + } + + [TestMethod] + public void BuildRequestUri_ComposesCorrectly() + { + var uri = Agent365ExporterCore.BuildRequestUri("example.com", "/agents/agent-123/traces"); + uri.Should().Be("https://example.com/agents/agent-123/traces?api-version=1"); + } + #endregion + + #region ExportBatchCoreAsync Request Uri and Headers Tests + [TestMethod] + public async Task ExportBatchCoreAsync_CustomDomain_UsesBaseHostAndAddsTenantHeader() + { + // Arrange + Agent365ExporterTests.SetCustomDomain(true); + var tenantId = "tenant-xyz"; + var agentId = "agent-abc"; + var resource = ResourceBuilder.CreateEmpty().AddService("unit-test-service", serviceVersion: "1.0.0").Build(); + var options = new Agent365ExporterOptions + { + TokenResolver = (_, _) => Task.FromResult(null), + UseS2SEndpoint = true, + ClusterCategory = "prod" + }; + + // Create a fake activity with identity + using var activity = CreateActivity(tenantId: tenantId, agentId: agentId); + var groups = new List<(string TenantId, string AgentId, List Activities)> + { + (tenantId, agentId, new List { activity }) + }; + + // Capture request details + string? capturedHost = null; + string? capturedPathAndQuery = null; + string? capturedTenantHeader = null; + + var expectedHost = new Agent365EndpointDiscovery(options.ClusterCategory).GetHost(); + + Task sendAsync(HttpRequestMessage req) + { + capturedHost = req.RequestUri!.Host; + capturedPathAndQuery = req.RequestUri!.PathAndQuery; + req.Headers.TryGetValues("x-ms-tenant-id", out var vals); + capturedTenantHeader = vals?.FirstOrDefault(); + var response = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError); + response.Headers.Add("x-ms-correlation-id", Guid.NewGuid().ToString()); + return Task.FromResult(response); + } + + // Act + var result = await Agent365ExporterTests._agent365ExporterCore.ExportBatchCoreAsync( + groups, + resource, + options, + (a, t) => Task.FromResult(null), + sendAsync); + + // Assert + result.Should().Be(ExportResult.Failure); // Expected to fail due to no real endpoint + capturedHost.Should().Be(expectedHost); + capturedPathAndQuery.Should().StartWith("/agents/" + agentId + "/traces"); + capturedPathAndQuery.Should().EndWith("?api-version=1"); + capturedTenantHeader.Should().Be(tenantId); + } + + [TestMethod] + public async Task ExportBatchCoreAsync_NonCustomDomain_UsesPowerPlatformEndpoint() + { + // Arrange + Agent365ExporterTests.SetCustomDomain(false); + var tenantId = "tenant-xyz"; + var agentId = "agent-abc"; + var resource = ResourceBuilder.CreateEmpty().AddService("unit-test-service", serviceVersion: "1.0.0").Build(); + var options = new Agent365ExporterOptions + { + TokenResolver = (_, _) => Task.FromResult(null), + UseS2SEndpoint = true, + ClusterCategory = "prod" + }; + + using var activity = CreateActivity(tenantId: tenantId, agentId: agentId); + var groups = new List<(string TenantId, string AgentId, List Activities)> + { + (tenantId, agentId, new List { activity }) + }; + + string? capturedHost = null; + string? capturedPathAndQuery = null; + string? capturedTenantHeader = null; + + var expectedHost = new PowerPlatformApiDiscovery(options.ClusterCategory).GetTenantIslandClusterEndpoint(tenantId); + + Task sendAsync(HttpRequestMessage req) + { + capturedHost = req.RequestUri!.Host; + capturedPathAndQuery = req.RequestUri!.PathAndQuery; + if (req.Headers.TryGetValues("x-ms-tenant-id", out var vals)) + { + capturedTenantHeader = vals.FirstOrDefault(); + } + var response = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError); + response.Headers.Add("x-ms-correlation-id", Guid.NewGuid().ToString()); + return Task.FromResult(response); + } + + // Act + var result = await Agent365ExporterTests._agent365ExporterCore.ExportBatchCoreAsync( + groups, + resource, + options, + (a, t) => Task.FromResult(null), + sendAsync); + + // Assert + result.Should().Be(ExportResult.Failure); // Expected to fail due to no real endpoint + capturedHost.Should().Be(expectedHost); + capturedPathAndQuery.Should().StartWith("/maven/agent365/service/agents/" + agentId + "/traces"); + capturedPathAndQuery.Should().EndWith("?api-version=1"); + capturedTenantHeader.Should().BeNull(); + } + #endregion } \ No newline at end of file