Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,27 +77,46 @@ public Agent365ExporterCore(ExportFormatter formatter, ILogger<Agent365ExporterC
}

/// <summary>
/// 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.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="agentId">The agent identifier.</param>
/// <param name="useS2SEndpoint">Whether to use the S2S endpoint.</param>
/// <returns>The endpoint path string.</returns>
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";
}

/// <summary>
/// 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.
/// </summary>
/// <param name="endpoint">The base endpoint.</param>
/// <param name="endpoint">The base endpoint (domain or full HTTPS URL).</param>
/// <param name="endpointPath">The endpoint path.</param>
/// <returns>The full request URI string.</returns>
/// <exception cref="ArgumentException">Thrown when the endpoint uses an http:// (non-TLS) scheme.</exception>
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";
}

/// <summary>
Expand All @@ -122,13 +141,13 @@ public async Task<ExportResult> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -14,24 +13,31 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters
public delegate Task<string?> AsyncAuthTokenResolver(string agentId, string tenantId);

/// <summary>
/// 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").
/// </summary>
public delegate string TenantDomainResolver(string tenantId);

/// <summary>
/// Configuration for Agent365Exporter.
/// Only ClusterCategory and TokenResolver are required for core operation.
/// Only TokenResolver is required for core operation.
/// </summary>
public sealed class Agent365ExporterOptions
{
/// <summary>
/// The default endpoint host for Agent365 observability.
/// </summary>
public const string DefaultEndpointHost = "agent365.svc.cloud.microsoft";

/// <summary>
/// Initializes a new instance of the <see cref="Agent365ExporterOptions"/> class with default settings.
/// </summary>
/// <remarks>The default constructor sets the <c>DomainResolver</c> property to resolve tenant
/// endpoints using the current <c>ClusterCategory</c> value.</remarks>
/// <remarks>The default constructor sets the <c>DomainResolver</c> property to return the default
/// Agent365 endpoint host (<c>agent365.svc.cloud.microsoft</c>).</remarks>
public Agent365ExporterOptions()
{
this.DomainResolver = tenantId => new PowerPlatformApiDiscovery(this.ClusterCategory).GetTenantIslandClusterEndpoint(tenantId);
this.DomainResolver = tenantId => DefaultEndpointHost;
}

/// <summary>
Expand All @@ -45,13 +51,14 @@ public Agent365ExporterOptions()
public AsyncAuthTokenResolver? TokenResolver { get; set; }

/// <summary>
/// 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 <see cref="DefaultEndpointHost"/>.
/// </summary>
public TenantDomainResolver DomainResolver { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool UseS2SEndpoint { get; set; } = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<ArgumentException>().WithParameterName("endpoint");
}

#endregion

private class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
Expand Down