diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs index a26e23a5..f5ee0f79 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs @@ -77,27 +77,46 @@ public Agent365ExporterCore(ExportFormatter formatter, ILogger - /// Builds the endpoint path for the trace export request based on agent ID and S2S setting. + /// Builds the endpoint path for the trace export request based on tenant ID, agent ID and S2S setting. /// + /// The tenant identifier. /// The agent identifier. /// Whether to use the S2S endpoint. /// The endpoint path string. - public string BuildEndpointPath(string agentId, bool useS2SEndpoint) + public string BuildEndpointPath(string tenantId, string agentId, bool useS2SEndpoint) { + var encodedTenantId = Uri.EscapeDataString(tenantId); + var encodedAgentId = Uri.EscapeDataString(agentId); + return useS2SEndpoint - ? $"/maven/agent365/service/agents/{agentId}/traces" - : $"/maven/agent365/agents/{agentId}/traces"; + ? $"/observabilityService/tenants/{encodedTenantId}/agents/{encodedAgentId}/traces" + : $"/observability/tenants/{encodedTenantId}/agents/{encodedAgentId}/traces"; } /// /// Builds the full request URI for the trace export request. + /// If the endpoint already includes a scheme (https://), it is used as-is. + /// Otherwise, https:// is prepended. Plaintext http:// is not supported. /// - /// The base endpoint. + /// The base endpoint (domain or full HTTPS URL). /// The endpoint path. /// The full request URI string. + /// Thrown when the endpoint uses an http:// (non-TLS) scheme. public string BuildRequestUri(string endpoint, string endpointPath) { - return $"https://{endpoint}{endpointPath}?api-version=1"; + var normalizedEndpoint = endpoint.TrimEnd('/'); + + if (normalizedEndpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Plaintext HTTP endpoints are not supported. Use HTTPS to protect credentials in transit.", nameof(endpoint)); + } + + if (normalizedEndpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return $"{normalizedEndpoint}{endpointPath}?api-version=1"; + } + + return $"https://{normalizedEndpoint}{endpointPath}?api-version=1"; } /// @@ -122,13 +141,13 @@ public async Task ExportBatchCoreAsync( var json = _formatter.FormatMany(activities, resource); using var content = new StringContent(json, Encoding.UTF8, "application/json"); - var ppapiEndpointOverride = Environment.GetEnvironmentVariable("A365_OBSERVABILITY_DOMAIN_OVERRIDE"); - var ppapiEndpoint = !string.IsNullOrEmpty(ppapiEndpointOverride) - ? ppapiEndpointOverride + var endpointOverride = Environment.GetEnvironmentVariable("A365_OBSERVABILITY_DOMAIN_OVERRIDE"); + var endpoint = !string.IsNullOrEmpty(endpointOverride) + ? endpointOverride : options.DomainResolver.Invoke(tenantId); - var endpointPath = BuildEndpointPath(agentId, options.UseS2SEndpoint); - var requestUri = BuildRequestUri(ppapiEndpoint, endpointPath); + var endpointPath = BuildEndpointPath(tenantId, agentId, options.UseS2SEndpoint); + var requestUri = BuildRequestUri(endpoint, endpointPath); using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs index 22feea58..e692576f 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Threading.Tasks; -using Microsoft.Agents.A365.Observability.Runtime.Common; namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters { @@ -14,24 +13,31 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters public delegate Task AsyncAuthTokenResolver(string agentId, string tenantId); /// - /// Delegate used by the exporter to resolve the island tenant domain for a given tenant id. + /// Delegate used by the exporter to resolve the endpoint host or URL for a given tenant id. + /// The return value may be a bare host name (e.g. "agent365.svc.cloud.microsoft") or a full URL + /// (e.g. "https://agent365.svc.cloud.microsoft"). /// public delegate string TenantDomainResolver(string tenantId); /// /// Configuration for Agent365Exporter. - /// Only ClusterCategory and TokenResolver are required for core operation. + /// Only TokenResolver is required for core operation. /// public sealed class Agent365ExporterOptions { + /// + /// The default endpoint host for Agent365 observability. + /// + public const string DefaultEndpointHost = "agent365.svc.cloud.microsoft"; + /// /// Initializes a new instance of the class with default settings. /// - /// The default constructor sets the DomainResolver property to resolve tenant - /// endpoints using the current ClusterCategory value. + /// The default constructor sets the DomainResolver property to return the default + /// Agent365 endpoint host (agent365.svc.cloud.microsoft). public Agent365ExporterOptions() { - this.DomainResolver = tenantId => new PowerPlatformApiDiscovery(this.ClusterCategory).GetTenantIslandClusterEndpoint(tenantId); + this.DomainResolver = tenantId => DefaultEndpointHost; } /// @@ -45,13 +51,14 @@ public Agent365ExporterOptions() public AsyncAuthTokenResolver? TokenResolver { get; set; } /// - /// Delegate used to resolve the island tenant domain for a given tenant id. + /// Delegate used to resolve the endpoint host or URL for a given tenant id. + /// Defaults to returning . /// public TenantDomainResolver DomainResolver { get; set; } /// - /// When true, uses the service-to-service (S2S) endpoint path: /maven/agent365/service/agents/{agentId}/traces - /// When false (default), uses the standard endpoint path: /maven/agent365/agents/{agentId}/traces + /// When true, uses the service-to-service (S2S) endpoint path: /observabilityService/tenants/{tenantId}/agents/{agentId}/traces + /// When false (default), uses the standard endpoint path: /observability/tenants/{tenantId}/agents/{agentId}/traces /// Default is false. /// public bool UseS2SEndpoint { get; set; } = false; 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 ca6320a3..27af125b 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 @@ -1062,7 +1062,7 @@ public void Export_RequestUri_EnvVar_Overrides_CustomDomainResolver_WhenBothSet( result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{overrideDomain}"); - observedUri!.Should().Contain($"/maven/agent365/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observability/tenants/tenant-env-overrides/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup @@ -1111,7 +1111,7 @@ public void Export_RequestUri_UsesEnvVar_WhenNoResolverSet() result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{overrideDomain}"); - observedUri!.Should().Contain($"/maven/agent365/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observability/tenants/tenant-env/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup @@ -1162,14 +1162,14 @@ public void Export_RequestUri_UsesCustomDomainResolver_WhenProvided() result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{resolverDomain}"); - observedUri!.Should().Contain($"/maven/agent365/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observability/tenants/tenant-resolver/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup Environment.SetEnvironmentVariable("A365_OBSERVABILITY_DOMAIN_OVERRIDE", null); } [TestMethod] - public void Export_RequestUri_UsesPpapiDiscovery_WhenNoResolverAndNoEnvVarSet() + public void Export_RequestUri_UsesDefaultEndpoint_WhenNoResolverAndNoEnvVarSet() { // Arrange Environment.SetEnvironmentVariable("A365_OBSERVABILITY_DOMAIN_OVERRIDE", null); @@ -1203,21 +1203,58 @@ public void Export_RequestUri_UsesPpapiDiscovery_WhenNoResolverAndNoEnvVarSet() using var activity = CreateActivity(tenantId: tenantId, agentId: "agent-xyz"); var batch = CreateBatch(activity); - var expectedDomain = new PowerPlatformApiDiscovery(options.ClusterCategory).GetTenantIslandClusterEndpoint(tenantId); - // Act var result = exporter.Export(in batch); // Assert result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); - observedUri!.Should().StartWith($"https://{expectedDomain}"); - observedUri!.Should().Contain($"/maven/agent365/agents/agent-xyz/traces"); + observedUri!.Should().StartWith($"https://{Agent365ExporterOptions.DefaultEndpointHost}"); + observedUri!.Should().Contain($"/observability/tenants/{tenantId}/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); } #endregion + #region BuildRequestUri Tests + + [TestMethod] + public void BuildRequestUri_BareHost_PrependsHttps() + { + var uri = _agent365ExporterCore.BuildRequestUri("agent365.svc.cloud.microsoft", "/observability/tenants/t1/agents/a1/traces"); + uri.Should().Be("https://agent365.svc.cloud.microsoft/observability/tenants/t1/agents/a1/traces?api-version=1"); + } + + [TestMethod] + public void BuildRequestUri_HttpsEndpoint_DoesNotDoublePrependScheme() + { + var uri = _agent365ExporterCore.BuildRequestUri("https://custom.example.com", "/observability/tenants/t1/agents/a1/traces"); + uri.Should().Be("https://custom.example.com/observability/tenants/t1/agents/a1/traces?api-version=1"); + } + + [TestMethod] + public void BuildRequestUri_TrailingSlash_IsNormalized() + { + var uri = _agent365ExporterCore.BuildRequestUri("https://custom.example.com/", "/observability/tenants/t1/agents/a1/traces"); + uri.Should().Be("https://custom.example.com/observability/tenants/t1/agents/a1/traces?api-version=1"); + } + + [TestMethod] + public void BuildRequestUri_BareHostWithTrailingSlash_IsNormalized() + { + var uri = _agent365ExporterCore.BuildRequestUri("agent365.svc.cloud.microsoft/", "/observability/tenants/t1/agents/a1/traces"); + uri.Should().Be("https://agent365.svc.cloud.microsoft/observability/tenants/t1/agents/a1/traces?api-version=1"); + } + + [TestMethod] + public void BuildRequestUri_HttpEndpoint_ThrowsArgumentException() + { + Action act = () => _agent365ExporterCore.BuildRequestUri("http://insecure.example.com", "/observability/tenants/t1/agents/a1/traces"); + act.Should().Throw().WithParameterName("endpoint"); + } + + #endregion + private class TestHttpMessageHandler : HttpMessageHandler { private readonly Func _handler;