Skip to content
1 change: 1 addition & 0 deletions src/Observability/Runtime/Builder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private void EnsureBuilt()
tracerProviderBuilder.Build();
}

EnvironmentUtils.Initialize(configuration: this.Configuration);
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception: this.Configuration can be null (as indicated by the nullable type on line 30), but EnvironmentUtils.Initialize() throws ArgumentNullException if configuration is null (line 68-71 in EnvironmentUtils.cs).

Either:

  1. Add a null check before calling Initialize, or
  2. Pass a default empty configuration if Configuration is null, or
  3. Make Configuration non-nullable if it's always required
Suggested change
EnvironmentUtils.Initialize(configuration: this.Configuration);
EnvironmentUtils.Initialize(configuration: this.Configuration ?? new ConfigurationBuilder().Build());

Copilot uses AI. Check for mistakes.
_isBuilt = true;
}

Expand Down
41 changes: 41 additions & 0 deletions src/Observability/Runtime/Common/Agent365EndpointDiscovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------------

using System;

namespace Microsoft.Agents.A365.Observability.Runtime.Common
{
/// <summary>
/// Provides discovery for Agent365 endpoints.
/// </summary>
public sealed class Agent365EndpointDiscovery
{
private readonly string clusterCategory;
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field name clusterCategory should follow C# naming conventions and use PascalCase with an underscore prefix for private fields (e.g., _clusterCategory), consistent with the naming pattern used elsewhere in the codebase.

Copilot uses AI. Check for mistakes.

/// <summary>
/// Initializes a new instance of the <see cref="Agent365EndpointDiscovery"/> class.
/// </summary>
/// <param name="clusterCategory">The cluster category.</param>
public Agent365EndpointDiscovery(string clusterCategory)
{
this.clusterCategory = clusterCategory ?? "production";
}

/// <summary>
/// Gets the base host for the specified cluster category.
/// </summary>
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}");
}
}
}
}
52 changes: 39 additions & 13 deletions src/Observability/Runtime/Common/EnvironmentUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------------

using Microsoft.Extensions.Configuration;
using System;

namespace Microsoft.Agents.A365.Observability.Runtime.Common
Expand All @@ -14,26 +15,17 @@ 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;

/// <summary>
/// Returns the scope for authenticating to the observability service based on the current environment.
/// </summary>
/// <returns>The authentication scope.</returns>
public static string[] GetObservabilityAuthenticationScope()
{
return new[] { ProdObservabilityScope };
}

/// <summary>
/// [Deprecated] Returns the scope for authenticating to the observability service based on the cluster category.
/// </summary>
/// <param name="clusterCategory">Cluster category (deprecated, defaults to production).</param>
/// <returns>The authentication scope.</returns>
[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 };
}

/// <summary>
Expand Down Expand Up @@ -66,6 +58,40 @@ public static bool IsDevelopmentEnvironment()
return string.Equals(environment, DevelopmentEnvironmentName, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Initializes the cached configuration values for environment utilities. Should be called once at application startup.
/// </summary>
/// <param name="configuration">The configuration instance.</param>
/// <param name="force">When true, re-initializes even if already initialized.</param>
public static void Initialize(IConfiguration? configuration, bool force = false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead forcing this to be static and use this Initialize method, do you think it would make sense to convert this class to instantiable? Meaning we make the methods non-static and do this IConfiguration in the constructor?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can but it would be a breaking change for consumers. We will register and they will have to resolve EnvironmentUtils from their service collection. Or they create an instance of EnvironmentUtils and supply IConfiguration. All this temporarily (because eventually we just want to use the custom domain, no need of the configuration at all, right?)

Whereas today they simply invoke EnvironmentUtils.GetAuthenticationScope()

{
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;
}

/// <summary>
/// Returns true if the custom domain feature is enabled.
/// </summary>
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML documentation should include the parameter description. Add:

/// <param name="configuration">The configuration instance to check for custom domain settings. If null, returns false.</param>
/// <returns>True if custom domain is enabled; otherwise, false.</returns>
Suggested change
/// </summary>
/// </summary>
/// <param name="configuration">The configuration instance to check for custom domain settings. If null, returns false.</param>
/// <returns>True if custom domain is enabled; otherwise, false.</returns>

Copilot uses AI. Check for mistakes.
public static bool IsCustomDomainEnabled()
{
if (!_initialized)
{
throw new InvalidOperationException("EnvironmentUtils is not initialized. Call Initialize() before using this method.");
}
return _customDomainEnabled;
}

/// <summary>
/// Gets the current environment name.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Agent365ExporterCore> _logger;

Expand Down Expand Up @@ -86,9 +88,11 @@ public Agent365ExporterCore(ExportFormatter formatter, ILogger<Agent365ExporterC
/// <returns>The endpoint path string.</returns>
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";
}

/// <summary>
Expand All @@ -97,7 +101,7 @@ public string BuildEndpointPath(string agentId, bool useS2SEndpoint)
/// <param name="endpoint">The base endpoint.</param>
/// <param name="endpointPath">The endpoint path.</param>
/// <returns>The full request URI string.</returns>
public string BuildRequestUri(string endpoint, string endpointPath)
public static string BuildRequestUri(string endpoint, string endpointPath)
{
return $"https://{endpoint}{endpointPath}?api-version=1";
}
Expand All @@ -124,11 +128,11 @@ public async Task<ExportResult> 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)
{
Expand All @@ -149,6 +153,11 @@ public async Task<ExportResult> 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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ private static TracerProviderBuilder ConfigureInternal(IServiceProvider serviceP
var coreLogger = serviceProvider.GetService<ILogger<Agent365ExporterCore>>() ?? loggerFactory.CreateLogger<Agent365ExporterCore>();
var formatterLogger = serviceProvider.GetService<ILogger<ExportFormatter>>() ?? loggerFactory.CreateLogger<ExportFormatter>();

var configuration = serviceProvider.GetService<Microsoft.Extensions.Configuration.IConfiguration>();

Comment on lines +80 to +81
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configuration variable is retrieved from the service provider but never used. If this was intended to initialize EnvironmentUtils, it should be done here. Otherwise, this variable should be removed to avoid confusion.

Suggested change
var configuration = serviceProvider.GetService<Microsoft.Extensions.Configuration.IConfiguration>();

Copilot uses AI. Check for mistakes.
// 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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class name should be Agent365EndpointDiscoveryTests (plural) to match the naming convention used elsewhere in the test suite (e.g., Agent365ExporterTests, EnvironmentUtilsTests).

Copilot uses AI. Check for mistakes.
{
[TestMethod]
public void GetHost_Mapping_IsCorrect()
{
var expected = new Dictionary<string, string>
{
["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<System.ArgumentException>(() => disc.GetHost());
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string?>? 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<string, string?>
{
{ "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<string, string?>
{
{ "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<string, string?>
{
{ "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<string, string?>
{
{ "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();
}
}
}
Loading