From c8a75a6782fcdac15424322ae60ce9e25903d41b Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:32:23 +0100 Subject: [PATCH 1/9] fix(observability): force S2S exporter routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../Tracing/Exporters/Agent365Exporter.cs | 3 +- .../Exporters/Agent365ExporterAsync.cs | 3 +- .../Tracing/Exporters/Agent365ExporterCore.cs | 25 ++- .../Exporters/Agent365ExporterOptions.cs | 30 +-- .../Tracing/Exporters/AgentIdentity.cs | 11 +- .../Tracing/Exporters/TokenResolverContext.cs | 3 +- .../Agent365ExporterAsyncE2ETests.cs | 4 +- .../Agent365ExporterE2ETests.cs | 2 + .../BuilderTests.cs | 2 + .../Exporters/Agent365ExporterTests.cs | 172 +++++++++++++++--- .../Exporters/ExportConfigConsistencyTests.cs | 16 +- 11 files changed, 205 insertions(+), 66 deletions(-) diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365Exporter.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365Exporter.cs index bccf8bbe..00d3b010 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365Exporter.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365Exporter.cs @@ -44,7 +44,7 @@ public Agent365Exporter( _options = options ?? throw new ArgumentNullException(nameof(options)); if (_options.TokenResolver == null && _options.ContextualTokenResolver == null) - throw new ArgumentNullException(nameof(options.TokenResolver), "Agent365ExporterOptions.TokenResolver or ContextualTokenResolver must be provided."); + throw new ArgumentNullException(nameof(options.TokenResolver), "Agent365ExporterOptions.TokenResolver or ContextualTokenResolver must provide an app-only OBS token."); _httpClient = httpClient ?? HttpClientFactory.CreateWithTimeout(options.ExporterTimeoutMilliseconds); _resource = resource ?? ResourceBuilder.CreateEmpty().Build(); @@ -87,4 +87,3 @@ public override ExportResult Export(in Batch batch) } } } - diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterAsync.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterAsync.cs index 96b319f6..3fd9f6b0 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterAsync.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterAsync.cs @@ -45,7 +45,7 @@ public Agent365ExporterAsync( this._options = options ?? throw new ArgumentNullException(nameof(options)); if (_options.TokenResolver == null && _options.ContextualTokenResolver == null) - throw new ArgumentNullException(nameof(options.TokenResolver), "Agent365ExporterOptions.TokenResolver or ContextualTokenResolver must be provided."); + throw new ArgumentNullException(nameof(options.TokenResolver), "Agent365ExporterOptions.TokenResolver or ContextualTokenResolver must provide an app-only OBS token."); this._httpClient = httpClient ?? HttpClientFactory.CreateWithTimeout(options.ExporterTimeoutMilliseconds); this._resource = resource ?? ResourceBuilder.CreateEmpty().Build(); @@ -92,4 +92,3 @@ await _core.ExportBatchCoreAsync( } } } - diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs index cc372303..4d8cf8fa 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs @@ -106,20 +106,31 @@ public Agent365ExporterCore(ExportFormatter formatter, ILogger - /// Builds the endpoint path for the trace export request based on tenant ID, agent ID and S2S setting. + /// Builds the S2S OTLP endpoint path for the trace export request based on tenant ID and agent ID. /// /// The tenant identifier. /// The agent identifier. - /// Whether to use the S2S endpoint. /// The endpoint path string. - public string BuildEndpointPath(string tenantId, string agentId, bool useS2SEndpoint) + public string BuildEndpointPath(string tenantId, string agentId) { var encodedTenantId = Uri.EscapeDataString(tenantId); var encodedAgentId = Uri.EscapeDataString(agentId); - return useS2SEndpoint - ? $"/observabilityService/tenants/{encodedTenantId}/otlp/agents/{encodedAgentId}/traces" - : $"/observability/tenants/{encodedTenantId}/otlp/agents/{encodedAgentId}/traces"; + return $"/observabilityService/tenants/{encodedTenantId}/otlp/agents/{encodedAgentId}/traces"; + } + + /// + /// Builds the S2S OTLP endpoint path for the trace export request based on tenant ID and agent ID. + /// The value is ignored for source compatibility. + /// + /// The tenant identifier. + /// The agent identifier. + /// Ignored. OBS export always uses the S2S OTLP endpoint. + /// The endpoint path string. + [Obsolete("Agent 365 OBS export always uses the service-to-service /observabilityService OTLP endpoint; this argument is ignored.", false)] + public string BuildEndpointPath(string tenantId, string agentId, bool useS2SEndpoint) + { + return BuildEndpointPath(tenantId, agentId); } /// @@ -188,7 +199,7 @@ public async Task ExportBatchCoreAsync( ? endpointOverride : options.DomainResolver.Invoke(tenantId); - var endpointPath = BuildEndpointPath(tenantId, agentId, options.UseS2SEndpoint); + var endpointPath = BuildEndpointPath(tenantId, agentId); var requestUri = BuildRequestUri(endpoint, endpointPath); string? token = null; diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs index d4a74e03..725d7b85 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs @@ -1,23 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Threading.Tasks; namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters { /// - /// Async delegate used by the exporter to obtain an auth token for a specific agent + tenant. - /// Must be fast and non-blocking (use internal caching elsewhere). - /// Return null/empty to omit the Authorization header. + /// Async delegate used by the exporter to obtain an app-only OBS auth token for a specific agent and tenant. + /// Must be fast and non-blocking; cache tokens in the resolver and refresh only near expiry. + /// Return null or empty to fail the export batch without sending a request. /// public delegate Task AsyncAuthTokenResolver(string agentId, string tenantId); /// - /// Async delegate used by the exporter to obtain an auth token using rich context. + /// Async delegate used by the exporter to obtain an app-only OBS auth token using rich context. /// Provides additional fields (e.g. ) /// beyond what offers. - /// Must be fast and non-blocking (use internal caching elsewhere). - /// Return null/empty to omit the Authorization header. + /// Must be fast and non-blocking; cache tokens in the resolver and refresh only near expiry. + /// Return null or empty to fail the export batch without sending a request. /// public delegate Task AsyncContextualTokenResolver(TokenResolverContext context); @@ -30,7 +31,7 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters /// /// Configuration for Agent365Exporter. - /// Only TokenResolver is required for core operation. + /// A configured app-only token resolver is required for core operation. /// public sealed class Agent365ExporterOptions { @@ -55,17 +56,19 @@ public Agent365ExporterOptions() public string ClusterCategory { get; set; } = "production"; /// - /// Async delegate used to resolve the auth token. + /// Async delegate used to resolve the app-only OBS auth token. /// Either this or must be set. /// When both are set, takes precedence. + /// The exporter invokes this resolver once per export batch and never falls back to a delegated token. /// public AsyncAuthTokenResolver? TokenResolver { get; set; } /// - /// Async delegate used to resolve the auth token with rich context, which may include the - /// agentic user ID associated with the export batch context. + /// Async delegate used to resolve the app-only OBS auth token with rich context, which may include the + /// agentic user ID associated with the export batch context for cache selection or diagnostics. /// Takes precedence over when set. /// The exporter does not guarantee separate batching or resolver invocation per agentic user ID. + /// This resolver must not perform delegated, OBO, or user_fic authentication for OBS export. /// public AsyncContextualTokenResolver? ContextualTokenResolver { get; set; } @@ -76,10 +79,11 @@ public Agent365ExporterOptions() public TenantDomainResolver DomainResolver { get; set; } /// - /// When true, uses the service-to-service (S2S) endpoint path: /observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces - /// When false (default), uses the standard endpoint path: /observability/tenants/{tenantId}/otlp/agents/{agentId}/traces - /// Default is false. + /// OBS export always uses the service-to-service (S2S) OTLP endpoint path: + /// /observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces. + /// This compatibility switch is ignored even when set to false. /// + [Obsolete("Agent 365 OBS export always uses the service-to-service /observabilityService OTLP endpoint; this option is ignored.", false)] public bool UseS2SEndpoint { get; set; } = false; /// diff --git a/src/Observability/Runtime/Tracing/Exporters/AgentIdentity.cs b/src/Observability/Runtime/Tracing/Exporters/AgentIdentity.cs index 000d6a1b..1aff1faa 100644 --- a/src/Observability/Runtime/Tracing/Exporters/AgentIdentity.cs +++ b/src/Observability/Runtime/Tracing/Exporters/AgentIdentity.cs @@ -6,8 +6,8 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters /// /// Represents the identity of an agent and its acting user. /// - /// In the AI teammate scenario, is 1:1 with . - /// In the S2S scenario, will be null. + /// is contextual metadata only. OBS export must use an app-only + /// token for and must not use this value for delegated token exchange. /// /// public class AgentIdentity @@ -16,7 +16,7 @@ public class AgentIdentity /// Initializes a new instance of the class. /// /// The agent identifier. - /// The agentic user identifier (AAD Object ID), or null in S2S scenarios. + /// The agentic user identifier (AAD Object ID), or null when unavailable. public AgentIdentity(string agentId, string? agenticUserId = null) { AgentId = agentId; @@ -29,9 +29,8 @@ public AgentIdentity(string agentId, string? agenticUserId = null) public string AgentId { get; } /// - /// Gets the agentic user identifier (AAD Object ID). - /// In the AI teammate scenario, this value is 1:1 with . - /// Will be null in the S2S scenario. + /// Gets the agentic user identifier (AAD Object ID), if available. + /// This value is not an OBS authentication input. /// public string? AgenticUserId { get; } } diff --git a/src/Observability/Runtime/Tracing/Exporters/TokenResolverContext.cs b/src/Observability/Runtime/Tracing/Exporters/TokenResolverContext.cs index fbd04a0d..a47469c8 100644 --- a/src/Observability/Runtime/Tracing/Exporters/TokenResolverContext.cs +++ b/src/Observability/Runtime/Tracing/Exporters/TokenResolverContext.cs @@ -8,7 +8,8 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters /// /// provides first-class access to agent identity fields (agent ID, /// agentic user ID). and - /// together identify the cache key. + /// together identify the cache key. Resolvers must still return app-only OBS tokens for + /// the exporting agent; the SDK never exchanges this context for delegated OBS tokens. /// /// public class TokenResolverContext diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterAsyncE2ETests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterAsyncE2ETests.cs index d0897a4d..9cc245b8 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterAsyncE2ETests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterAsyncE2ETests.cs @@ -10,6 +10,8 @@ using System.Net; using System.Text.Json; +#pragma warning disable CS0618 // Tests intentionally pass the ignored legacy S2S compatibility switch. + namespace Microsoft.Agents.A365.Observability.Runtime.Tests.IntegrationTests { [TestClass] @@ -394,7 +396,7 @@ private ServiceProvider CreateTestServiceProvider(HttpClient httpClient) TokenResolver = (_, _) => Task.FromResult("test-token") }; }); - + builder.AddA365Tracing(useOpenTelemetryBuilder: false, agent365ExporterType: Agent365ExporterType.Agent365ExporterAsync); return builder.Services.BuildServiceProvider(); } diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterE2ETests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterE2ETests.cs index 7d3dfefe..d71cd37c 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterE2ETests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.IntegrationTests/Agent365ExporterE2ETests.cs @@ -11,6 +11,8 @@ using System.Net; using System.Text.Json; +#pragma warning disable CS0618 // Tests intentionally pass the ignored legacy S2S compatibility switch. + namespace Microsoft.Agents.A365.Observability.Runtime.Tests.IntegrationTests { [TestClass] diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/BuilderTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/BuilderTests.cs index 8c60b5a3..6b8d1347 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/BuilderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/BuilderTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Trace; +#pragma warning disable CS0618 // Tests intentionally pass the ignored legacy S2S compatibility switch. + namespace Microsoft.Agents.A365.Observability.Tests; /// 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 9f12c9da..94f96f78 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 @@ -12,6 +12,8 @@ using System.Reflection; using System.Net; +#pragma warning disable CS0618 // Tests intentionally cover ignored legacy S2S compatibility switches. + namespace Microsoft.Agents.A365.Observability.Tests.Tracing.Exporters; [TestClass] @@ -106,7 +108,9 @@ private static Agent365Exporter CreateExporter(Func? to { var options = new Agent365ExporterOptions { - TokenResolver = (_, _) => Task.FromResult("token") + TokenResolver = tokenResolver == null + ? (_, _) => Task.FromResult("token") + : (agentId, tenantId) => Task.FromResult(tokenResolver(agentId, tenantId)) }; var resource = ResourceBuilder.CreateEmpty() @@ -296,16 +300,33 @@ public void Agent365ExporterOptions_UseS2SEndpoint_CanBeSetToFalse() #region S2S Endpoint Functional Tests [TestMethod] - public void UseS2SEndpoint_WhenFalse_UsesStandardEndpoint() + public void UseS2SEndpoint_WhenFalse_IsIgnoredAndUsesS2SEndpoint() { // Arrange + string? observedUri = null; + var handler = new TestHttpMessageHandler(req => + { + observedUri = req.RequestUri?.AbsoluteUri; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var httpClient = new HttpClient(handler); var options = new Agent365ExporterOptions { TokenResolver = (_, _) => Task.FromResult("test-token"), UseS2SEndpoint = false }; - var exporter = CreateExporter((_, _) => "test-token"); + var resource = ResourceBuilder.CreateEmpty() + .AddService("unit-test-service", serviceVersion: "1.0.0") + .Build(); + + var exporter = new Agent365Exporter( + Agent365ExporterTests._agent365ExporterCore, + NullLogger.Instance, + options, + resource, + httpClient); + using var activity = CreateActivity(tenantId: "tenant-123", agentId: "agent-456"); var batch = CreateBatch(activity); @@ -314,7 +335,8 @@ public void UseS2SEndpoint_WhenFalse_UsesStandardEndpoint() // Assert options.UseS2SEndpoint.Should().BeFalse(); - result.Should().Be(ExportResult.Failure); // Expected to fail as there's no real endpoint + result.Should().Be(ExportResult.Success); + observedUri.Should().Contain("/observabilityService/tenants/tenant-123/otlp/agents/agent-456/traces"); } [TestMethod] @@ -528,13 +550,22 @@ public void Export_StandardEndpoint_TokenResolverCalled_WithCorrectParameters() capturedTenantId.Should().Be("tenant-123"); } - [TestMethod] - public void Export_S2SEndpoint_NullToken_StillSendsRequest() + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + public void Export_S2SEndpoint_NullOrEmptyToken_FailsWithoutSendingRequest(string? token) { // Arrange + var sendCount = 0; + var handler = new TestHttpMessageHandler(req => + { + Interlocked.Increment(ref sendCount); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var httpClient = new HttpClient(handler); var options = new Agent365ExporterOptions { - TokenResolver = (_, _) => Task.FromResult(null), // Return null token + TokenResolver = (_, _) => Task.FromResult(token), UseS2SEndpoint = true }; @@ -546,7 +577,8 @@ public void Export_S2SEndpoint_NullToken_StillSendsRequest() Agent365ExporterTests._agent365ExporterCore, NullLogger.Instance, options, - resource); + resource, + httpClient); using var activity = CreateActivity(tenantId: "tenant-123", agentId: "agent-456"); var batch = CreateBatch(activity); @@ -555,16 +587,17 @@ public void Export_S2SEndpoint_NullToken_StillSendsRequest() var result = exporter.Export(in batch); // Assert - result.Should().Be(ExportResult.Failure); // Expected to fail due to no real endpoint + result.Should().Be(ExportResult.Failure); + sendCount.Should().Be(0, "empty resolver results must fail before sending a request"); } [TestMethod] - public void Export_S2SEndpoint_EmptyToken_StillSendsRequest() + public void Export_S2SEndpoint_TokenResolverThrows_ReturnsFailure() { // Arrange var options = new Agent365ExporterOptions { - TokenResolver = (_, _) => Task.FromResult(string.Empty), // Return empty token + TokenResolver = (_, _) => Task.FromException(new InvalidOperationException("Token resolver failed")), UseS2SEndpoint = true }; @@ -585,17 +618,17 @@ public void Export_S2SEndpoint_EmptyToken_StillSendsRequest() var result = exporter.Export(in batch); // Assert - result.Should().Be(ExportResult.Failure); // Expected to fail due to no real endpoint + result.Should().Be(ExportResult.Failure); } [TestMethod] - public void Export_S2SEndpoint_TokenResolverThrows_ReturnsFailure() + public void Export_StandardEndpoint_TokenResolverThrows_ReturnsFailure() { // Arrange var options = new Agent365ExporterOptions { TokenResolver = (_, _) => Task.FromException(new InvalidOperationException("Token resolver failed")), - UseS2SEndpoint = true + UseS2SEndpoint = false }; var resource = ResourceBuilder.CreateEmpty() @@ -619,12 +652,67 @@ public void Export_S2SEndpoint_TokenResolverThrows_ReturnsFailure() } [TestMethod] - public void Export_StandardEndpoint_TokenResolverThrows_ReturnsFailure() + public void Export_TokenResolver_IsInvokedForEachExportBatch() { // Arrange + var resolverCalls = 0; + var handler = new TestHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK)); + var httpClient = new HttpClient(handler); var options = new Agent365ExporterOptions { - TokenResolver = (_, _) => Task.FromException(new InvalidOperationException("Token resolver failed")), + TokenResolver = (_, _) => + { + Interlocked.Increment(ref resolverCalls); + return Task.FromResult("test-token"); + } + }; + + var resource = ResourceBuilder.CreateEmpty() + .AddService("unit-test-service", serviceVersion: "1.0.0") + .Build(); + + var exporter = new Agent365Exporter( + Agent365ExporterTests._agent365ExporterCore, + NullLogger.Instance, + options, + resource, + httpClient); + + using var firstActivity = CreateActivity("tenant-123", "agent-456"); + using var secondActivity = CreateActivity("tenant-123", "agent-456"); + var firstBatch = CreateBatch(firstActivity); + var secondBatch = CreateBatch(secondActivity); + + // Act + exporter.Export(in firstBatch).Should().Be(ExportResult.Success); + exporter.Export(in secondBatch).Should().Be(ExportResult.Success); + + // Assert + resolverCalls.Should().Be(2); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + [DataRow(HttpStatusCode.NotFound)] + public void Export_HttpAuthOrNotFoundFailure_DoesNotRetryOrFallback(HttpStatusCode statusCode) + { + // Arrange + var resolverCalls = 0; + var sendCount = 0; + var handler = new TestHttpMessageHandler(req => + { + Interlocked.Increment(ref sendCount); + return new HttpResponseMessage(statusCode); + }); + var httpClient = new HttpClient(handler); + var options = new Agent365ExporterOptions + { + TokenResolver = (_, _) => + { + Interlocked.Increment(ref resolverCalls); + return Task.FromResult("test-token"); + }, UseS2SEndpoint = false }; @@ -636,9 +724,10 @@ public void Export_StandardEndpoint_TokenResolverThrows_ReturnsFailure() Agent365ExporterTests._agent365ExporterCore, NullLogger.Instance, options, - resource); + resource, + httpClient); - using var activity = CreateActivity(tenantId: "tenant-123", agentId: "agent-456"); + using var activity = CreateActivity("tenant-123", "agent-456"); var batch = CreateBatch(activity); // Act @@ -646,6 +735,45 @@ public void Export_StandardEndpoint_TokenResolverThrows_ReturnsFailure() // Assert result.Should().Be(ExportResult.Failure); + resolverCalls.Should().Be(1); + sendCount.Should().Be(1, "401/403/404 must not trigger a delegated retry or alternate endpoint"); + } + + [TestMethod] + public async Task Agent365ExporterAsync_UsesS2SEndpoint_WhenLegacyFlagIsFalse() + { + // Arrange + string? observedUri = null; + var handler = new TestHttpMessageHandler(req => + { + observedUri = req.RequestUri?.AbsoluteUri; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var httpClient = new HttpClient(handler); + var options = new Agent365ExporterOptions + { + TokenResolver = (_, _) => Task.FromResult("test-token"), + UseS2SEndpoint = false + }; + + var resource = ResourceBuilder.CreateEmpty() + .AddService("unit-test-service", serviceVersion: "1.0.0") + .Build(); + + var exporter = new Agent365ExporterAsync( + Agent365ExporterTests._agent365ExporterCore, + NullLogger.Instance, + options, + resource, + httpClient); + + using var activity = CreateActivity("tenant-async", "agent-async"); + + // Act + await exporter.ExportAsync(new[] { activity }, CancellationToken.None).ConfigureAwait(false); + + // Assert + observedUri.Should().Contain("/observabilityService/tenants/tenant-async/otlp/agents/agent-async/traces"); } [TestMethod] @@ -1088,7 +1216,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($"/observability/tenants/tenant-env-overrides/otlp/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observabilityService/tenants/tenant-env-overrides/otlp/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup @@ -1137,7 +1265,7 @@ public void Export_RequestUri_UsesEnvVar_WhenNoResolverSet() result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{overrideDomain}"); - observedUri!.Should().Contain($"/observability/tenants/tenant-env/otlp/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observabilityService/tenants/tenant-env/otlp/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup @@ -1188,7 +1316,7 @@ public void Export_RequestUri_UsesCustomDomainResolver_WhenProvided() result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{resolverDomain}"); - observedUri!.Should().Contain($"/observability/tenants/tenant-resolver/otlp/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observabilityService/tenants/tenant-resolver/otlp/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); // Cleanup Environment.SetEnvironmentVariable("A365_OBSERVABILITY_DOMAIN_OVERRIDE", null); @@ -1236,7 +1364,7 @@ public void Export_RequestUri_UsesDefaultEndpoint_WhenNoResolverAndNoEnvVarSet() result.Should().Be(ExportResult.Success); observedUri.Should().NotBeNull(); observedUri!.Should().StartWith($"https://{Agent365ExporterOptions.DefaultEndpointHost}"); - observedUri!.Should().Contain($"/observability/tenants/{tenantId}/otlp/agents/agent-xyz/traces"); + observedUri!.Should().Contain($"/observabilityService/tenants/{tenantId}/otlp/agents/agent-xyz/traces"); observedUri!.Should().Contain("api-version=1"); } diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs index 3c8ee5ee..1b9f2612 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs @@ -26,8 +26,7 @@ public sealed class ExportConfigConsistencyTests // Pinned production values — update ALL of these together when any one changes. private const string ExpectedScope = "api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite"; - private const string ExpectedStandardUri = "https://agent365.svc.cloud.microsoft/observability/tenants/t1/otlp/agents/a1/traces?api-version=1"; - private const string ExpectedS2SUr = "https://agent365.svc.cloud.microsoft/observabilityService/tenants/t1/otlp/agents/a1/traces?api-version=1"; + private const string ExpectedS2SUri = "https://agent365.svc.cloud.microsoft/observabilityService/tenants/t1/otlp/agents/a1/traces?api-version=1"; [TestInitialize] public void TestInitialize() => Environment.SetEnvironmentVariable(ScopeOverrideEnvVar, null); @@ -44,23 +43,16 @@ public void ExportConfig_Scope_Endpoint_And_Paths_AreConsistent() .Which.Should().Be(ExpectedScope, "ProdObservabilityScope changed — also review DefaultEndpointHost and BuildEndpointPath."); - // Full URIs (standard + S2S) combining DefaultEndpointHost + BuildEndpointPath + BuildRequestUri + // Full URI combining DefaultEndpointHost + BuildEndpointPath + BuildRequestUri. var core = new Agent365ExporterCore( new ExportFormatter(NullLogger.Instance), NullLogger.Instance); - var standardUri = core.BuildRequestUri( - Agent365ExporterOptions.DefaultEndpointHost, - core.BuildEndpointPath("t1", "a1", useS2SEndpoint: false)); - var s2sUri = core.BuildRequestUri( Agent365ExporterOptions.DefaultEndpointHost, - core.BuildEndpointPath("t1", "a1", useS2SEndpoint: true)); - - standardUri.Should().Be(ExpectedStandardUri, - "Standard export URI changed — also review ProdObservabilityScope and DefaultEndpointHost."); + core.BuildEndpointPath("t1", "a1")); - s2sUri.Should().Be(ExpectedS2SUr, + s2sUri.Should().Be(ExpectedS2SUri, "S2S export URI changed — also review ProdObservabilityScope and DefaultEndpointHost."); // Coarse sanity: scope targets Agent365 Observability, endpoint targets agent365 service From 8cc7aa0f03c6550c47a092781fa2fc6a010c302c Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:32:30 +0100 Subject: [PATCH 2/9] fix(hosting): require app-only OBS token resolvers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../Hosting/Caching/AgenticTokenCache.cs | 197 ++++++++++++++---- .../Hosting/Caching/AgenticTokenStruct.cs | 1 + .../Hosting/Caching/IExporterTokenCache.cs | 5 +- .../Caching/ObservabilityTokenResolver.cs | 17 ++ ...bservabilityServiceCollectionExtensions.cs | 7 +- .../Caching/AgenticTokenCacheTests.cs | 151 ++++++++++++++ ...abilityServiceCollectionExtensionsTests.cs | 19 +- .../ObservabilityBuilderExtensionsTests.cs | 2 + 8 files changed, 346 insertions(+), 53 deletions(-) create mode 100644 src/Observability/Hosting/Caching/ObservabilityTokenResolver.cs create mode 100644 src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs diff --git a/src/Observability/Hosting/Caching/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index bfe964e4..39fe5c05 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Azure.Core; +using Microsoft.Agents.A365.Observability.Runtime.Common; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; using System.Threading; using System.Threading.Tasks; @@ -11,21 +12,21 @@ namespace Microsoft.Agents.A365.Observability.Hosting.Caching { /// - /// Caches observability tokens per (agentId, tenantId) using the provided UserAuthorization and TurnContext. + /// Caches app-only observability tokens per (agentId, tenantId) using the provided resolver. /// Includes automatic periodic cleanup of expired tokens for improved memory management. /// - public class AgenticTokenCache : IExporterTokenCache, IDisposable + public class AgenticTokenCache : IExporterTokenCache, IDisposable { private sealed class Entry { - public AgenticTokenStruct AgenticTokenStruct { get; } + public ObservabilityTokenResolver TokenResolver { get; set; } public string? Token { get; set; } - public string[] Scopes { get; } + public string[] Scopes { get; set; } public DateTimeOffset? ExpiresAt { get; set; } - public Entry(AgenticTokenStruct agenticTokenStruct, string[] scopes) + public Entry(ObservabilityTokenResolver tokenResolver, string[] scopes) { - AgenticTokenStruct = agenticTokenStruct; + TokenResolver = tokenResolver; Scopes = scopes; } @@ -42,6 +43,7 @@ public void ClearToken() private readonly ConcurrentDictionary _map = new ConcurrentDictionary(); private readonly Timer? _cleanupTimer; private int _disposed; // Using int for Interlocked operations + private int _removedDelegatedRegistrationLogged; /// /// Default interval for automatic cleanup of expired tokens (5 minutes). @@ -67,25 +69,48 @@ public AgenticTokenCache(TimeSpan? cleanupInterval = null) } /// - /// Registers observability for the specified agent and tenant. + /// Registers an app-only observability token resolver for the specified agent and tenant. /// /// The agent identifier. /// The tenant identifier. - /// The token generator. + /// The app-only token resolver. /// The observability scopes. - public void RegisterObservability(string agentId, string tenantId, AgenticTokenStruct tokenGenerator, string[] observabilityScopes) + public void RegisterObservability(string agentId, string tenantId, ObservabilityTokenResolver tokenGenerator, string[] observabilityScopes) { - if (string.IsNullOrWhiteSpace(agentId)) - throw new ArgumentException("Value cannot be null or whitespace.", nameof(agentId)); - - if (string.IsNullOrWhiteSpace(tenantId)) - throw new ArgumentException("Value cannot be null or whitespace.", nameof(tenantId)); - + ValidateAgentAndTenant(agentId, tenantId); if (tokenGenerator == null) + { throw new ArgumentNullException(nameof(tokenGenerator)); + } - // First registration wins; subsequent calls ignored (idempotent). - _map.TryAdd($"{agentId}:{tenantId}", new Entry(tokenGenerator, observabilityScopes)); + var scopes = ValidateScopes(observabilityScopes); + var entry = new Entry(tokenGenerator, scopes); + _map.AddOrUpdate( + GetKey(agentId, tenantId), + entry, + (_, existing) => + { + existing.TokenResolver = tokenGenerator; + existing.Scopes = scopes; + existing.ClearToken(); + return existing; + }); + } + + /// + /// Delegated OBS token acquisition was removed. This overload is retained only to produce a compile-time error. + /// + /// The agent identifier. + /// The tenant identifier. + /// The removed delegated token generator. + /// The observability scopes. + [Obsolete("Delegated OBS token acquisition has been removed. Register an ObservabilityTokenResolver app-only callback instead.", error: true)] + public void RegisterObservability(string agentId, string tenantId, AgenticTokenStruct tokenGenerator, string[] observabilityScopes) + { + if (Interlocked.Exchange(ref _removedDelegatedRegistrationLogged, 1) == 0) + { + Trace.TraceError("AgenticTokenCache.RegisterObservability with AgenticTokenStruct is removed. OBS export requires an app-only ObservabilityTokenResolver."); + } } /// @@ -98,36 +123,78 @@ public void RegisterObservability(string agentId, string tenantId, AgenticTokenS /// public async Task GetObservabilityToken(string agentId, string tenantId) { - if (!_map.TryGetValue($"{agentId}:{tenantId}", out var entry)) + if (string.IsNullOrWhiteSpace(agentId) || string.IsNullOrWhiteSpace(tenantId)) + { return null; + } - try + if (!_map.TryGetValue(GetKey(agentId, tenantId), out var entry)) { - // Check current entry to avoid unnecessary token exchange calls if the token is still valid. - if (!string.IsNullOrEmpty(entry.Token) && entry.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5)) // Consider token valid if it expires in more than 5 minutes. - { - return entry.Token; - } + return null; + } - // Use sync path; credential handles caching & refresh internally. - var ctx = new TokenRequestContext(entry.Scopes); - var userAuthorization = entry.AgenticTokenStruct.UserAuthorization; - var turnContext = entry.AgenticTokenStruct.TurnContext; + if (IsTokenUsable(entry)) + { + return entry.Token; + } - var token = await userAuthorization.ExchangeTurnTokenAsync(turnContext, - entry.AgenticTokenStruct.AuthHandlerName, - exchangeConnection: entry.AgenticTokenStruct.ConnectionName!, - exchangeScopes: entry.Scopes).ConfigureAwait(false); + return await RefreshObservabilityToken(agentId, tenantId, entry.TokenResolver, entry.Scopes).ConfigureAwait(false); + } - entry.Token = token; - entry.ExpiresAt = GetTokenExpiration(token); + /// + /// Refreshes and caches an app-only observability token for the specified agent and tenant. + /// + /// The agent identifier. + /// The tenant identifier. + /// The app-only token resolver. + /// The refreshed token. + public Task RefreshObservabilityToken(string agentId, string tenantId, ObservabilityTokenResolver tokenResolver) + { + return RefreshObservabilityToken(agentId, tenantId, tokenResolver, EnvironmentUtils.GetObservabilityAuthenticationScope()); + } - return token; + /// + /// Refreshes and caches an app-only observability token for the specified agent and tenant. + /// + /// The agent identifier. + /// The tenant identifier. + /// The app-only token resolver. + /// The observability scopes. + /// The refreshed token. + public async Task RefreshObservabilityToken(string agentId, string tenantId, ObservabilityTokenResolver tokenResolver, string[] observabilityScopes) + { + ValidateAgentAndTenant(agentId, tenantId); + if (tokenResolver == null) + { + throw new ArgumentNullException(nameof(tokenResolver)); } - catch + + var scopes = ValidateScopes(observabilityScopes); + var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) { - return null; + throw new InvalidOperationException("The observability token resolver returned an empty token."); } + + var refreshedEntry = new Entry(tokenResolver, scopes) + { + Token = token, + ExpiresAt = GetTokenExpiration(token!) + }; + + _map.AddOrUpdate( + GetKey(agentId, tenantId), + refreshedEntry, + (_, existing) => + { + existing.TokenResolver = tokenResolver; + existing.Scopes = scopes; + existing.Token = token; + existing.ExpiresAt = refreshedEntry.ExpiresAt; + return existing; + }); + + return token!; } /// @@ -141,7 +208,7 @@ public bool InvalidateToken(string agentId, string tenantId) if (string.IsNullOrWhiteSpace(agentId) || string.IsNullOrWhiteSpace(tenantId)) return false; - var key = $"{agentId}:{tenantId}"; + var key = GetKey(agentId, tenantId); if (_map.TryRemove(key, out var entry)) { // Clear the token value for security @@ -218,14 +285,62 @@ public int RemoveExpiredTokens() { return null; } + + if (token.Split('.').Length < 2) + { + return null; + } + var handler = new JwtSecurityTokenHandler(); - var jwtToken = handler.ReadJwtToken(token); + JwtSecurityToken jwtToken; + try + { + jwtToken = handler.ReadJwtToken(token); + } + catch (ArgumentException) + { + return null; + } + if (jwtToken.Payload.Expiration == null) - return null; + { + return null; + } return new DateTimeOffset(jwtToken.ValidTo, TimeSpan.Zero); } + private static string GetKey(string agentId, string tenantId) => $"{agentId}:{tenantId}"; + + private static bool IsTokenUsable(Entry entry) + { + return !string.IsNullOrEmpty(entry.Token) + && (!entry.ExpiresAt.HasValue || entry.ExpiresAt.Value > DateTimeOffset.UtcNow.AddMinutes(5)); + } + + private static void ValidateAgentAndTenant(string agentId, string tenantId) + { + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(agentId)); + } + + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(tenantId)); + } + } + + private static string[] ValidateScopes(string[] observabilityScopes) + { + if (observabilityScopes == null || observabilityScopes.Length == 0) + { + throw new ArgumentException("Observability scopes cannot be null or empty.", nameof(observabilityScopes)); + } + + return (string[])observabilityScopes.Clone(); + } + /// /// Disposes the cache and stops the automatic cleanup timer. /// diff --git a/src/Observability/Hosting/Caching/AgenticTokenStruct.cs b/src/Observability/Hosting/Caching/AgenticTokenStruct.cs index 2060d32f..e68b46e5 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenStruct.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenStruct.cs @@ -39,6 +39,7 @@ public class AgenticTokenStruct /// /// /// + [Obsolete("Delegated OBS token acquisition has been removed. Use ObservabilityTokenResolver with app-only tokens instead.", error: true)] public AgenticTokenStruct( UserAuthorization userAuthorization, ITurnContext turnContext, diff --git a/src/Observability/Hosting/Caching/IExporterTokenCache.cs b/src/Observability/Hosting/Caching/IExporterTokenCache.cs index f2ac5e5c..a89e9854 100644 --- a/src/Observability/Hosting/Caching/IExporterTokenCache.cs +++ b/src/Observability/Hosting/Caching/IExporterTokenCache.cs @@ -10,12 +10,13 @@ namespace Microsoft.Agents.A365.Observability.Hosting.Caching public interface IExporterTokenCache where T : class { /// - /// Registers (idempotent) a credential to be used for observability token acquisition. + /// Registers or updates a credential or resolver to be used for app-only observability token acquisition. /// void RegisterObservability(string agentId, string tenantId, T tokenGenerator, string[] observabilityScopes); /// - /// Returns an observability token (cached inside the credential) or null on failure/not registered. + /// Returns an observability token or null when not registered. + /// Implementations that acquire a new token may propagate resolver failures to the caller. /// Task GetObservabilityToken(string agentId, string tenantId); } diff --git a/src/Observability/Hosting/Caching/ObservabilityTokenResolver.cs b/src/Observability/Hosting/Caching/ObservabilityTokenResolver.cs new file mode 100644 index 00000000..c24966bf --- /dev/null +++ b/src/Observability/Hosting/Caching/ObservabilityTokenResolver.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.A365.Observability.Hosting.Caching +{ + /// + /// Resolves an app-only OBS token for the exporting agent and tenant. + /// The resolver must not perform delegated, OBO, or user_fic authentication for OBS export. + /// + /// The exporting agent identifier. + /// The tenant identifier. + /// The OBS token scopes to request. + /// The final OBS access token for the exporting agent. + public delegate Task ObservabilityTokenResolver(string agentId, string tenantId, string[] observabilityScopes); +} diff --git a/src/Observability/Hosting/ObservabilityServiceCollectionExtensions.cs b/src/Observability/Hosting/ObservabilityServiceCollectionExtensions.cs index 7e3875c0..7996a808 100644 --- a/src/Observability/Hosting/ObservabilityServiceCollectionExtensions.cs +++ b/src/Observability/Hosting/ObservabilityServiceCollectionExtensions.cs @@ -19,11 +19,11 @@ public static class ObservabilityServiceCollectionExtensions /// The updated service collection. public static IServiceCollection AddAgenticTracingExporter(this IServiceCollection services, string? clusterCategory = "production") { - services.AddSingleton, AgenticTokenCache>(); + services.AddSingleton, AgenticTokenCache>(); services.AddSingleton(sp => { - var cache = sp.GetRequiredService>(); + var cache = sp.GetRequiredService>(); return new Agent365ExporterOptions { ClusterCategory = clusterCategory ?? "production", @@ -51,8 +51,7 @@ public static IServiceCollection AddServiceTracingExporter(this IServiceCollecti return new Agent365ExporterOptions { ClusterCategory = clusterCategory ?? "production", - TokenResolver = async (agentId, tenantId) => await cache.GetObservabilityToken(agentId, tenantId).ConfigureAwait(false), - UseS2SEndpoint = true // Service-to-service uses S2S endpoint + TokenResolver = async (agentId, tenantId) => await cache.GetObservabilityToken(agentId, tenantId).ConfigureAwait(false) }; }); diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs new file mode 100644 index 00000000..3d943e15 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text; +using FluentAssertions; +using Microsoft.Agents.A365.Observability.Hosting.Caching; + +namespace Microsoft.Agents.A365.Observability.Hosting.Tests.Caching; + +[TestClass] +public sealed class AgenticTokenCacheTests +{ + private const string TestAgentId = "test-agent"; + private const string TestTenantId = "test-tenant"; + private static readonly string[] TestScopes = new[] { "api://9b975845-388f-4429-889e-eab1ef63949c/.default" }; + + [TestMethod] + public async Task RegisterObservability_WithAppOnlyResolver_CachesTokenPerAgentTenant() + { + var resolverCalls = 0; + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + ObservabilityTokenResolver resolver = (agentId, tenantId, scopes) => + { + Interlocked.Increment(ref resolverCalls); + agentId.Should().Be(TestAgentId); + tenantId.Should().Be(TestTenantId); + scopes.Should().Equal(TestScopes); + return Task.FromResult("app-only-token"); + }; + + cache.RegisterObservability(TestAgentId, TestTenantId, resolver, TestScopes); + + var firstToken = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + var secondToken = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + + firstToken.Should().Be("app-only-token"); + secondToken.Should().Be("app-only-token"); + resolverCalls.Should().Be(1, "the cache should reuse a usable token for the same agent/tenant key"); + } + + [TestMethod] + public async Task RegisterObservability_MultipleAgentTenantKeys_AreIndependent() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + + cache.RegisterObservability("agent-1", "tenant-1", (_, _, _) => Task.FromResult("token-11"), TestScopes); + cache.RegisterObservability("agent-2", "tenant-1", (_, _, _) => Task.FromResult("token-21"), TestScopes); + cache.RegisterObservability("agent-1", "tenant-2", (_, _, _) => Task.FromResult("token-12"), TestScopes); + + (await cache.GetObservabilityToken("agent-1", "tenant-1")).Should().Be("token-11"); + (await cache.GetObservabilityToken("agent-2", "tenant-1")).Should().Be("token-21"); + (await cache.GetObservabilityToken("agent-1", "tenant-2")).Should().Be("token-12"); + } + + [TestMethod] + public async Task RefreshObservabilityToken_UpdatesCachedTokenAndExpiry() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var firstToken = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(10)); + var secondToken = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(20)); + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(firstToken), TestScopes); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(secondToken), TestScopes); + + var token = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + + token.Should().Be(secondToken); + } + + [TestMethod] + public async Task RefreshObservabilityToken_OpaqueTokenClearsStaleExpiryMetadata() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var expiredJwt = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(-10)); + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(expiredJwt), TestScopes); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult("opaque-token"), TestScopes); + + var removed = cache.RemoveExpiredTokens(); + + removed.Should().Be(0, "refreshing with an opaque token should clear the prior JWT expiry metadata"); + cache.Count.Should().Be(1); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("opaque-token"); + } + + [TestMethod] + public async Task RefreshObservabilityToken_ResolverFailure_PropagatesAndLeavesCachedToken() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult("cached-token"), TestScopes); + + Func act = () => cache.RefreshObservabilityToken( + TestAgentId, + TestTenantId, + (_, _, _) => Task.FromException(new InvalidOperationException("acquisition failed")), + TestScopes); + + await act.Should().ThrowAsync().WithMessage("acquisition failed"); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("cached-token"); + } + + [TestMethod] + public async Task RefreshObservabilityToken_EmptyToken_PropagatesFailure() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + + Func act = () => cache.RefreshObservabilityToken( + TestAgentId, + TestTenantId, + (_, _, _) => Task.FromResult(string.Empty), + TestScopes); + + await act.Should().ThrowAsync() + .WithMessage("The observability token resolver returned an empty token."); + } + + [TestMethod] + public async Task RemovedDelegatedRegisterObservabilityShape_DoesNotRegisterOrThrowWhenInvokedDynamically() + { + var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var removedOverload = typeof(AgenticTokenCache).GetMethod( + nameof(AgenticTokenCache.RegisterObservability), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(string), typeof(string), typeof(AgenticTokenStruct), typeof(string[]) }, + modifiers: null); + + removedOverload.Should().NotBeNull(); + var act = () => removedOverload!.Invoke(cache, new object?[] { TestAgentId, TestTenantId, null, TestScopes }); + + act.Should().NotThrow("untyped callers should get no delegated exchange and no crash"); + cache.Count.Should().Be(0); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().BeNull(); + } + + private static string CreateJwt(DateTimeOffset expiresAt) + { + var header = Base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}"); + var payload = Base64Url($"{{\"exp\":{expiresAt.ToUnixTimeSeconds()}}}"); + return $"{header}.{payload}."; + } + + private static string Base64Url(string value) + { + return Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Extensions/ObservabilityServiceCollectionExtensionsTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Extensions/ObservabilityServiceCollectionExtensionsTests.cs index b8671c5f..6a07b90d 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Extensions/ObservabilityServiceCollectionExtensionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Extensions/ObservabilityServiceCollectionExtensionsTests.cs @@ -22,10 +22,13 @@ public void AddAgenticTracingExporter_RegistersRequiredServices() var serviceProvider = services.BuildServiceProvider(); // Assert - var tokenCache = serviceProvider.GetService>(); + var tokenCache = serviceProvider.GetService>(); tokenCache.Should().NotBeNull(); tokenCache.Should().BeOfType(); + serviceProvider.GetService>() + .Should().BeNull("the delegated AgenticTokenStruct cache contract was removed"); + var options = serviceProvider.GetService(); options.Should().NotBeNull(); options!.TokenResolver.Should().NotBeNull(); @@ -47,7 +50,7 @@ public void AddAgenticTracingExporter_DefaultClusterCategory_IsProduction() } [TestMethod] - public void AddAgenticTracingExporter_UseS2SEndpoint_IsFalse() + public void AddAgenticTracingExporter_UsesIgnoredLegacyEndpointFlag() { // Arrange var services = new ServiceCollection(); @@ -58,7 +61,9 @@ public void AddAgenticTracingExporter_UseS2SEndpoint_IsFalse() // Assert var options = serviceProvider.GetRequiredService(); - options.UseS2SEndpoint.Should().BeFalse("agentic exporter uses standard endpoint"); +#pragma warning disable CS0618 + options.UseS2SEndpoint.Should().BeFalse("the compatibility property default is preserved but ignored"); +#pragma warning restore CS0618 } [TestMethod] @@ -97,18 +102,20 @@ public void AddServiceTracingExporter_DefaultClusterCategory_IsProduction() } [TestMethod] - public void AddServiceTracingExporter_UseS2SEndpoint_IsTrue() + public void AddServiceTracingExporter_UsesIgnoredLegacyEndpointFlag() { // Arrange var services = new ServiceCollection(); // Act - services.AddServiceTracingExporter( ); + services.AddServiceTracingExporter(); var serviceProvider = services.BuildServiceProvider(); // Assert var options = serviceProvider.GetRequiredService(); - options.UseS2SEndpoint.Should().BeTrue("service tracing exporter uses S2S endpoint"); +#pragma warning disable CS0618 + options.UseS2SEndpoint.Should().BeFalse("OBS export always routes to S2S regardless of this compatibility property"); +#pragma warning restore CS0618 } [TestMethod] diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/ObservabilityBuilderExtensionsTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/ObservabilityBuilderExtensionsTests.cs index 6161d7e9..9495d4f3 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/ObservabilityBuilderExtensionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/ObservabilityBuilderExtensionsTests.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting; using OpenTelemetry.Trace; +#pragma warning disable CS0618 // Tests intentionally pass the ignored legacy S2S compatibility switch. + namespace Microsoft.Agents.A365.Observability.Hosting.Tests { [TestClass] From 167215201c9c9679d7d3701d53fb5a6a98802bc7 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:32:37 +0100 Subject: [PATCH 3/9] docs(observability): document S2S-only OBS export Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- CHANGELOG.md | 21 +++++++ src/Observability/Hosting/Caching/README.md | 64 +++++++++++++-------- src/Observability/Hosting/README.md | 14 +++++ src/Observability/Hosting/docs/design.md | 24 ++++++-- src/Observability/README.md | 4 ++ src/Observability/Runtime/README.md | 18 ++++++ src/Observability/Runtime/docs/design.md | 20 +++++++ src/version.json | 2 +- 8 files changed, 137 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c883638..fd8f6250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,27 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. ## [Unreleased] +### Breaking Changes + +- **OBS exports always use `/observabilityService`** — `Microsoft.Agents.A365.Observability.Runtime` + now sends every Agent 365 telemetry export to the S2S OTLP route + `/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`. + `Agent365ExporterOptions.UseS2SEndpoint` is obsolete and ignored, even when `false`; + there is no fallback to `/observability`. +- **OBS export requires a configured app-only resolver** — `TokenResolver` and + `ContextualTokenResolver` must return the final OBS app-only token for the exporting + agent and tenant. Missing resolvers fail exporter construction; empty tokens or resolver + failures fail the export batch before sending a request. Resolvers are invoked per export + batch, so they should cache and refresh tokens near expiry. Workload OBO/MCP/Graph auth is + unchanged. +- **Delegated hosting OBS token acquisition is removed** — + `AgenticTokenCache.RegisterObservability(..., AgenticTokenStruct, ...)` is obsolete with + `error: true`, and `AgenticTokenStruct` construction is obsolete with `error: true`. + Use `ObservabilityTokenResolver` and `AgenticTokenCache.RefreshObservabilityToken(...)` + for app-only token acquisition. `AddAgenticTracingExporter` now registers + `IExporterTokenCache` instead of + `IExporterTokenCache`. + ### Added - **Microsoft.Agents.A365.Tooling** - V1/V2 per-audience token support for MCP servers - `MCPServerConfig` extended with `audience`, `scope`, `publisher`, and `Headers` fields diff --git a/src/Observability/Hosting/Caching/README.md b/src/Observability/Hosting/Caching/README.md index 42d61423..1f612d00 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -1,8 +1,20 @@ -# ServiceTokenCache - Token Expiration and Invalidation +# OBS Token Caches - Token Expiration and Invalidation ## Overview +Agent 365 OBS export is S2S-only. Exporters always send traces to +`/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1` +and require app-only OBS tokens for the exporting agent identity. + `ServiceTokenCache` is a reference implementation of `IExporterTokenCache` that provides secure token caching with built-in expiration and invalidation features for observability exporters. +`AgenticTokenCache` stores app-only tokens resolved by `ObservabilityTokenResolver`; the +former delegated `AgenticTokenStruct` registration is obsolete and no longer performs OBO +or TurnContext token exchange for OBS. + +Resolvers must validate the token they return: accept `idtyp=app`, or, when `idtyp` is +absent, a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other +`idtyp` and reject any token containing `scp`. They must also verify the OBS audience +(`api://9b975845-388f-4429-889e-eab1ef63949c`) and token lifetime. ## Features @@ -41,6 +53,26 @@ cache.RegisterObservability( var token = cache.GetObservabilityToken("my-agent", "my-tenant"); ``` +### App-only Resolver Cache + +```csharp +var cache = new AgenticTokenCache(); + +ObservabilityTokenResolver resolver = async (agentId, tenantId, scopes) => +{ + var token = await AcquireAppOnlyObsTokenAsync(agentId, tenantId, scopes); + ValidateAppOnlyObsToken(token); + return token; +}; + +await cache.RefreshObservabilityToken("my-agent", "my-tenant", resolver, scopes); +var token = await cache.GetObservabilityToken("my-agent", "my-tenant"); +``` + +`RefreshObservabilityToken` propagates acquisition failures. Call it from the exporter's +`TokenResolver` or catch errors on the request path; the exporter will fail the batch +without attempting a delegated fallback. + ### Custom Default Expiration ```csharp @@ -186,16 +218,10 @@ public class TokenCleanupService : BackgroundService - **Never log tokens**: Avoid logging the actual token values - **Use appropriate expiration**: Match expiration time with your security requirements -- **Invalidate on logout**: Call `InvalidateToken` when a user logs out +- **Keep workload auth separate**: OBS export tokens are app-only and independent of MCP/Graph/OBO tokens - **Clear cache on security events**: Use `InvalidateAll()` in response to security events ```csharp -// On user logout -public void OnUserLogout(string agentId, string tenantId) -{ - cache.InvalidateToken(agentId, tenantId); -} - // On security breach detection public void OnSecurityBreach() { @@ -250,25 +276,17 @@ Parallel.For(0, 100, i => ## Migration from Previous Version -If you're upgrading from a previous version without expiration support, no code changes are required. The default behavior maintains backward compatibility: +If you're upgrading from a delegated OBS token flow, replace `AgenticTokenStruct` and +`RegisterObservability(..., AgenticTokenStruct, ...)` with an app-only +`ObservabilityTokenResolver`: ```csharp -// Old code - still works with default 1-hour expiration -var cache = new ServiceTokenCache(); -cache.RegisterObservability("agent", "tenant", "token", scopes); -var token = cache.GetObservabilityToken("agent", "tenant"); -``` - -To opt-in to custom expiration: - -```csharp -// New code - with custom expiration -var cache = new ServiceTokenCache(TimeSpan.FromMinutes(30)); -cache.RegisterObservability("agent", "tenant", "token", scopes, TimeSpan.FromMinutes(10)); +await cache.RefreshObservabilityToken("agent", "tenant", resolver, scopes); +var token = await cache.GetObservabilityToken("agent", "tenant"); ``` ## See Also -- [IExporterTokenCache Interface](../Core/Caching/IExporterTokenCache.cs) -- [AgenticTokenCache](../Core/Caching/AgenticTokenCache.cs) - Alternative implementation for agentic scenarios +- [IExporterTokenCache Interface](IExporterTokenCache.cs) +- [AgenticTokenCache](AgenticTokenCache.cs) - App-only resolver cache - [Observability SDK Documentation](../README.md) diff --git a/src/Observability/Hosting/README.md b/src/Observability/Hosting/README.md index 09b5cb2e..bbdf35de 100644 --- a/src/Observability/Hosting/README.md +++ b/src/Observability/Hosting/README.md @@ -2,6 +2,20 @@ The Hosting package provides ETW (Event Tracing for Windows) integration for the Microsoft Agent 365 Observability SDK. This package enables high-performance event tracing on Windows platforms for production monitoring scenarios. +## OBS Token Caching + +OBS export is S2S-only. Hosting helpers must provide app-only tokens for the exporting +agent identity; delegated TurnContext/UserAuthorization exchange is no longer used for OBS. +`AgenticTokenCache.RegisterObservability(..., AgenticTokenStruct, ...)` and +`AgenticTokenStruct` construction are obsolete compile-time errors. Register or refresh an +`ObservabilityTokenResolver` instead, then wire `Agent365ExporterOptions.TokenResolver` to +`GetObservabilityToken`. + +The resolver is responsible for acquiring and validating the final OBS token. It must reject +delegated `scp` tokens, accept app-only tokens (`idtyp=app`, or no `idtyp` with non-empty +`roles` or `oid == sub`), verify the OBS audience, and ensure the token is not expired. +Workload MCP/Graph/OBO authentication remains separate and unchanged. + ## Installation ```bash diff --git a/src/Observability/Hosting/docs/design.md b/src/Observability/Hosting/docs/design.md index 30114619..8a33ce5d 100644 --- a/src/Observability/Hosting/docs/design.md +++ b/src/Observability/Hosting/docs/design.md @@ -119,25 +119,37 @@ var sourceMetadataPairs = turnContext.GetSourceMetadataBaggagePairs(); ### Token Caching -Token caches for managing authentication tokens used by telemetry exporters. +Token caches for managing app-only authentication tokens used by telemetry exporters. +OBS export always uses the S2S OTLP route and never uses TurnContext or delegated +authorization to acquire OBS tokens. **IExporterTokenCache Interface:** ```csharp -public interface IExporterTokenCache +public interface IExporterTokenCache where T : class { - Task GetTokenAsync(string resource, CancellationToken cancellationToken); - Task SetTokenAsync(string resource, string token, DateTimeOffset expiry, CancellationToken cancellationToken); + void RegisterObservability(string agentId, string tenantId, T tokenGenerator, string[] observabilityScopes); + Task GetObservabilityToken(string agentId, string tenantId); } ``` **AgenticTokenCache:** -Caches tokens for agentic operations, using the user's delegated identity. +Caches app-only OBS tokens per `(agentId, tenantId)` using an `ObservabilityTokenResolver`. +The previous `AgenticTokenStruct`/`UserAuthorization` registration is obsolete with +`error: true`; typed callers must migrate, and dynamic callers do not trigger a delegated +exchange. Use `RefreshObservabilityToken(agentId, tenantId, tokenResolver, scopes)` to +refresh at export time and let acquisition failures propagate to the exporter. **ServiceTokenCache:** -Caches tokens for service-to-service operations, using the application identity. +Caches already-acquired app-only tokens for service-to-service operations. + +Resolvers must validate the final OBS token before caching it: accept `idtyp=app`, or, +when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` equal to `sub`; +reject any other `idtyp` and any `scp` claim. Resolvers must also verify the OBS audience +(`api://9b975845-388f-4429-889e-eab1ef63949c`) and token lifetime. Workload MCP/Graph/OBO +authorization is separate and unchanged. ### BaggageBuilderExtensions diff --git a/src/Observability/README.md b/src/Observability/README.md index cf3db58d..ed84b307 100644 --- a/src/Observability/README.md +++ b/src/Observability/README.md @@ -10,6 +10,10 @@ The Microsoft Agent 365 Observability SDK provides comprehensive monitoring, tracing, and diagnostics capabilities for AI agent applications. This module enables developers to gain deep insights into agent behavior, performance, and execution patterns through industry-standard observability tools. +Agent 365 OBS export uses the S2S OTLP endpoint only and requires an app-only OBS token for +the exporting agent identity. The SDK does not acquire delegated OBS tokens or fall back to +the delegated `/observability` route. + ## Overview Building production-ready AI agents requires robust observability to understand agent behavior, diagnose issues, and optimize performance. This module provides: diff --git a/src/Observability/Runtime/README.md b/src/Observability/Runtime/README.md index 804df586..f8aaa8e9 100644 --- a/src/Observability/Runtime/README.md +++ b/src/Observability/Runtime/README.md @@ -2,6 +2,24 @@ The Runtime package provides runtime components for the Microsoft Agent 365 Observability SDK, including exporters, tracing utilities, DTOs, and scope management. +## Agent 365 Exporter Authentication + +Agent 365 OBS export is S2S-only. The exporter always posts OTLP traces to +`https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`; +the legacy `Agent365ExporterOptions.UseS2SEndpoint` switch is obsolete and ignored. + +Configure either `TokenResolver` or `ContextualTokenResolver` to return the final app-only +OBS token for the exporting agent and tenant. The SDK never reads a delegated request token, +never performs OBO/user_fic authentication for OBS export, and never falls back to +`/observability` on 401, 403, or 404. The resolver is invoked once per export batch, so it +should cache tokens and refresh only near expiry. + +Resolvers must validate the returned token before handing it to the exporter: accept +`idtyp=app`, or, when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` +equal to `sub`; reject any other `idtyp` and any token with an `scp` claim. Also verify the +audience is the Agent 365 OBS resource (`api://9b975845-388f-4429-889e-eab1ef63949c`) and +that the token is not expired. + ## Installation ```bash diff --git a/src/Observability/Runtime/docs/design.md b/src/Observability/Runtime/docs/design.md index 2a926aeb..4699f27c 100644 --- a/src/Observability/Runtime/docs/design.md +++ b/src/Observability/Runtime/docs/design.md @@ -81,6 +81,26 @@ new Builder(services, configuration, useOpenTelemetryBuilder: false) |----------|-------------| | `EnableAgent365Exporter` | Set to `true` to enable Agent365 exporter | +### Agent365Exporter + +The Agent 365 exporter is S2S-only. It always builds +`/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1` +and ignores the obsolete `UseS2SEndpoint` compatibility property. Domain override and +custom tenant domain resolution still control only the host, not the path. + +Exporter authentication comes only from the configured `TokenResolver` or +`ContextualTokenResolver`. Both resolvers must return an app-only OBS token for the +exporting agent identity; the exporter never reads per-request delegated tokens, never +performs OBO/user_fic token exchange, and never retries 401/403/404 on the delegated +`/observability` route. A missing resolver fails configuration, and a null/empty token or +resolver exception fails the export batch before sending data. + +Resolvers are invoked once per export batch and should cache internally. They must validate +the final token before returning it: accept `idtyp=app`, or, when `idtyp` is absent, a +non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other `idtyp` and +any `scp` claim. Also verify the OBS audience +(`api://9b975845-388f-4429-889e-eab1ef63949c`) and token lifetime. + ### InvokeAgentScope **Source**: [InvokeAgentScope.cs](../Tracing/Scopes/InvokeAgentScope.cs) diff --git a/src/version.json b/src/version.json index 8a246bec..cd22f22b 100644 --- a/src/version.json +++ b/src/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.1-preview", + "version": "2.0-preview", "assemblyVersion": { "precision": "revision" }, From 2eead4a71ab9a2266d08c8ef2386d7aba1bb59e0 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:52:15 +0100 Subject: [PATCH 4/9] fix(hosting): align OBS token cache refresh semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../Hosting/Caching/AgenticTokenCache.cs | 100 +++++++++----- src/Observability/Hosting/Caching/README.md | 11 +- ...t.Agents.A365.Observability.Hosting.csproj | 3 + .../Caching/AgenticTokenCacheTests.cs | 129 ++++++++++++++++-- 4 files changed, 192 insertions(+), 51 deletions(-) diff --git a/src/Observability/Hosting/Caching/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index 39fe5c05..4efe211c 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -23,6 +23,7 @@ private sealed class Entry public string? Token { get; set; } public string[] Scopes { get; set; } public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? AcquiredAt { get; set; } public Entry(ObservabilityTokenResolver tokenResolver, string[] scopes) { @@ -37,13 +38,17 @@ public void ClearToken() { Token = null; ExpiresAt = null; + AcquiredAt = null; } } private readonly ConcurrentDictionary _map = new ConcurrentDictionary(); + private readonly Func _utcNow; private readonly Timer? _cleanupTimer; private int _disposed; // Using int for Interlocked operations private int _removedDelegatedRegistrationLogged; + private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MaxOpaqueTokenAge = TimeSpan.FromHours(1); /// /// Default interval for automatic cleanup of expired tokens (5 minutes). @@ -55,7 +60,13 @@ public void ClearToken() /// /// The interval for automatic cleanup of expired tokens. Defaults to 5 minutes if not specified. Set to TimeSpan.Zero to disable automatic cleanup. public AgenticTokenCache(TimeSpan? cleanupInterval = null) + : this(cleanupInterval, () => DateTimeOffset.UtcNow) { + } + + internal AgenticTokenCache(TimeSpan? cleanupInterval, Func utcNow) + { + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); var interval = cleanupInterval ?? DefaultCleanupInterval; if (interval > TimeSpan.Zero) { @@ -75,6 +86,12 @@ public AgenticTokenCache(TimeSpan? cleanupInterval = null) /// The tenant identifier. /// The app-only token resolver. /// The observability scopes. + /// + /// First registration wins. Repeated calls for the same agent and tenant are idempotent + /// and do not replace the resolver or clear the cached token. Use + /// + /// to replace the resolver used by future refreshes. + /// public void RegisterObservability(string agentId, string tenantId, ObservabilityTokenResolver tokenGenerator, string[] observabilityScopes) { ValidateAgentAndTenant(agentId, tenantId); @@ -85,16 +102,7 @@ public void RegisterObservability(string agentId, string tenantId, Observability var scopes = ValidateScopes(observabilityScopes); var entry = new Entry(tokenGenerator, scopes); - _map.AddOrUpdate( - GetKey(agentId, tenantId), - entry, - (_, existing) => - { - existing.TokenResolver = tokenGenerator; - existing.Scopes = scopes; - existing.ClearToken(); - return existing; - }); + _map.TryAdd(GetKey(agentId, tenantId), entry); } /// @@ -170,31 +178,35 @@ public async Task RefreshObservabilityToken(string agentId, string tenan } var scopes = ValidateScopes(observabilityScopes); - var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(token)) + var key = GetKey(agentId, tenantId); + var entry = _map.GetOrAdd(key, _ => new Entry(tokenResolver, scopes)); + entry.TokenResolver = tokenResolver; + entry.Scopes = scopes; + + if (IsTokenUsable(entry)) { - throw new InvalidOperationException("The observability token resolver returned an empty token."); + return entry.Token!; } - var refreshedEntry = new Entry(tokenResolver, scopes) + try { - Token = token, - ExpiresAt = GetTokenExpiration(token!) - }; - - _map.AddOrUpdate( - GetKey(agentId, tenantId), - refreshedEntry, - (_, existing) => + var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) { - existing.TokenResolver = tokenResolver; - existing.Scopes = scopes; - existing.Token = token; - existing.ExpiresAt = refreshedEntry.ExpiresAt; - return existing; - }); - - return token!; + throw new InvalidOperationException("The observability token resolver returned an empty token."); + } + + entry.Token = token; + entry.ExpiresAt = GetTokenExpiration(token!); + entry.AcquiredAt = _utcNow(); + + return token!; + } + catch + { + entry.ClearToken(); + throw; + } } /// @@ -237,13 +249,13 @@ public void InvalidateAll() /// The number of expired tokens that were removed. public int RemoveExpiredTokens() { - var now = DateTimeOffset.UtcNow; + var now = _utcNow(); var expiredKeys = new List(); // Find expired keys without using LINQ foreach (var kvp in _map) { - if (kvp.Value.ExpiresAt.HasValue && now >= kvp.Value.ExpiresAt.Value) + if (IsTokenExpired(kvp.Value, now)) { expiredKeys.Add(kvp.Key); } @@ -312,10 +324,30 @@ public int RemoveExpiredTokens() private static string GetKey(string agentId, string tenantId) => $"{agentId}:{tenantId}"; - private static bool IsTokenUsable(Entry entry) + private bool IsTokenUsable(Entry entry) { return !string.IsNullOrEmpty(entry.Token) - && (!entry.ExpiresAt.HasValue || entry.ExpiresAt.Value > DateTimeOffset.UtcNow.AddMinutes(5)); + && !IsTokenExpired(entry, _utcNow()); + } + + private static bool IsTokenExpired(Entry entry, DateTimeOffset now) + { + if (string.IsNullOrEmpty(entry.Token)) + { + return false; + } + + if (entry.ExpiresAt.HasValue) + { + return now >= entry.ExpiresAt.Value.Subtract(RefreshSkew); + } + + if (entry.AcquiredAt.HasValue) + { + return now >= entry.AcquiredAt.Value.Add(MaxOpaqueTokenAge); + } + + return true; } private static void ValidateAgentAndTenant(string agentId, string tenantId) diff --git a/src/Observability/Hosting/Caching/README.md b/src/Observability/Hosting/Caching/README.md index 1f612d00..ecece67f 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -9,7 +9,9 @@ and require app-only OBS tokens for the exporting agent identity. `ServiceTokenCache` is a reference implementation of `IExporterTokenCache` that provides secure token caching with built-in expiration and invalidation features for observability exporters. `AgenticTokenCache` stores app-only tokens resolved by `ObservabilityTokenResolver`; the former delegated `AgenticTokenStruct` registration is obsolete and no longer performs OBO -or TurnContext token exchange for OBS. +or TurnContext token exchange for OBS. `RegisterObservability` is idempotent: +first registration wins and repeated calls do not replace the resolver or clear a cached +token. Use `RefreshObservabilityToken` to replace the resolver used by future refreshes. Resolvers must validate the token they return: accept `idtyp=app`, or, when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other @@ -69,9 +71,12 @@ await cache.RefreshObservabilityToken("my-agent", "my-tenant", resolver, scopes) var token = await cache.GetObservabilityToken("my-agent", "my-tenant"); ``` -`RefreshObservabilityToken` propagates acquisition failures. Call it from the exporter's +`RefreshObservabilityToken` returns the cached token without calling the resolver while the +cached token is still usable. It propagates acquisition failures and clears stale cached +token state when the resolver fails or returns an empty token. Call it from the exporter's `TokenResolver` or catch errors on the request path; the exporter will fail the batch -without attempting a delegated fallback. +without attempting a delegated fallback. JWT tokens are refreshed near `exp`; opaque tokens +without an `exp` claim use a one-hour fallback max age from acquisition. ### Custom Default Expiration diff --git a/src/Observability/Hosting/Microsoft.Agents.A365.Observability.Hosting.csproj b/src/Observability/Hosting/Microsoft.Agents.A365.Observability.Hosting.csproj index 271f6a1c..a000e655 100644 --- a/src/Observability/Hosting/Microsoft.Agents.A365.Observability.Hosting.csproj +++ b/src/Observability/Hosting/Microsoft.Agents.A365.Observability.Hosting.csproj @@ -21,4 +21,7 @@ + + + diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs index 3d943e15..dae8e370 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -19,7 +19,7 @@ public sealed class AgenticTokenCacheTests public async Task RegisterObservability_WithAppOnlyResolver_CachesTokenPerAgentTenant() { var resolverCalls = 0; - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); ObservabilityTokenResolver resolver = (agentId, tenantId, scopes) => { Interlocked.Increment(ref resolverCalls); @@ -39,10 +39,42 @@ public async Task RegisterObservability_WithAppOnlyResolver_CachesTokenPerAgentT resolverCalls.Should().Be(1, "the cache should reuse a usable token for the same agent/tenant key"); } + [TestMethod] + public async Task RegisterObservability_RepeatedRegistration_DoesNotReplaceResolverOrClearToken() + { + var firstResolverCalls = 0; + var secondResolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + + cache.RegisterObservability(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref firstResolverCalls); + return Task.FromResult("first-token"); + }, TestScopes); + + cache.RegisterObservability(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref secondResolverCalls); + return Task.FromResult("second-token"); + }, TestScopes); + + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("first-token"); + + cache.RegisterObservability(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref secondResolverCalls); + return Task.FromResult("second-token"); + }, TestScopes); + + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("first-token"); + firstResolverCalls.Should().Be(1); + secondResolverCalls.Should().Be(0, "RegisterObservability is idempotent and first registration wins"); + } + [TestMethod] public async Task RegisterObservability_MultipleAgentTenantKeys_AreIndependent() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); cache.RegisterObservability("agent-1", "tenant-1", (_, _, _) => Task.FromResult("token-11"), TestScopes); cache.RegisterObservability("agent-2", "tenant-1", (_, _, _) => Task.FromResult("token-21"), TestScopes); @@ -56,9 +88,10 @@ public async Task RegisterObservability_MultipleAgentTenantKeys_AreIndependent() [TestMethod] public async Task RefreshObservabilityToken_UpdatesCachedTokenAndExpiry() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); - var firstToken = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(10)); - var secondToken = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(20)); + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + var firstToken = CreateJwt(now.AddMinutes(4)); + var secondToken = CreateJwt(now.AddMinutes(20)); await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(firstToken), TestScopes); await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(secondToken), TestScopes); @@ -68,11 +101,43 @@ public async Task RefreshObservabilityToken_UpdatesCachedTokenAndExpiry() token.Should().Be(secondToken); } + [TestMethod] + public async Task RefreshObservabilityToken_ReturnsCachedTokenUntilNearExpiry_AndReplacesFutureResolver() + { + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var firstToken = CreateJwt(now.AddMinutes(10)); + var secondToken = CreateJwt(now.AddMinutes(20)); + var firstResolverCalls = 0; + var secondResolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref firstResolverCalls); + return Task.FromResult(firstToken); + }, TestScopes); + + var cachedToken = await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref secondResolverCalls); + return Task.FromResult(secondToken); + }, TestScopes); + + cachedToken.Should().Be(firstToken); + firstResolverCalls.Should().Be(1); + secondResolverCalls.Should().Be(0, "RefreshObservabilityToken should not call the resolver while a token is usable"); + + now = now.AddMinutes(6); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(secondToken); + secondResolverCalls.Should().Be(1, "the resolver passed to RefreshObservabilityToken replaces the resolver for future refreshes"); + } + [TestMethod] public async Task RefreshObservabilityToken_OpaqueTokenClearsStaleExpiryMetadata() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); - var expiredJwt = CreateJwt(DateTimeOffset.UtcNow.AddMinutes(-10)); + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + var expiredJwt = CreateJwt(now.AddMinutes(-10)); await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(expiredJwt), TestScopes); await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult("opaque-token"), TestScopes); @@ -85,10 +150,35 @@ public async Task RefreshObservabilityToken_OpaqueTokenClearsStaleExpiryMetadata } [TestMethod] - public async Task RefreshObservabilityToken_ResolverFailure_PropagatesAndLeavesCachedToken() + public async Task OpaqueToken_IsUsableBeforeFallbackMaxAge_AndRefreshesAtBoundary() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); - await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult("cached-token"), TestScopes); + var acquiredAt = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var now = acquiredAt; + var resolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + ObservabilityTokenResolver resolver = (_, _, _) => + { + var call = Interlocked.Increment(ref resolverCalls); + return Task.FromResult(call == 1 ? "opaque-token-1" : "opaque-token-2"); + }; + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, resolver, TestScopes); + + now = acquiredAt.AddHours(1).AddTicks(-1); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("opaque-token-1"); + resolverCalls.Should().Be(1); + + now = acquiredAt.AddHours(1); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("opaque-token-2"); + resolverCalls.Should().Be(2); + } + + [TestMethod] + public async Task RefreshObservabilityToken_ResolverFailure_PropagatesAndClearsCachedToken() + { + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(CreateJwt(now.AddMinutes(4))), TestScopes); Func act = () => cache.RefreshObservabilityToken( TestAgentId, @@ -97,13 +187,19 @@ public async Task RefreshObservabilityToken_ResolverFailure_PropagatesAndLeavesC TestScopes); await act.Should().ThrowAsync().WithMessage("acquisition failed"); - (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("cached-token"); + + cache.RemoveExpiredTokens().Should().Be(0, "a refresh failure must clear stale token and expiry metadata"); + Func get = async () => await cache.GetObservabilityToken(TestAgentId, TestTenantId); + await get.Should().ThrowAsync() + .WithMessage("acquisition failed"); } [TestMethod] - public async Task RefreshObservabilityToken_EmptyToken_PropagatesFailure() + public async Task RefreshObservabilityToken_EmptyToken_PropagatesFailureAndClearsCachedToken() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(CreateJwt(now.AddMinutes(4))), TestScopes); Func act = () => cache.RefreshObservabilityToken( TestAgentId, @@ -113,12 +209,17 @@ public async Task RefreshObservabilityToken_EmptyToken_PropagatesFailure() await act.Should().ThrowAsync() .WithMessage("The observability token resolver returned an empty token."); + + cache.RemoveExpiredTokens().Should().Be(0, "an empty resolver result must clear stale token and expiry metadata"); + Func get = async () => await cache.GetObservabilityToken(TestAgentId, TestTenantId); + await get.Should().ThrowAsync() + .WithMessage("The observability token resolver returned an empty token."); } [TestMethod] public async Task RemovedDelegatedRegisterObservabilityShape_DoesNotRegisterOrThrowWhenInvokedDynamically() { - var cache = new AgenticTokenCache(cleanupInterval: TimeSpan.Zero); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); var removedOverload = typeof(AgenticTokenCache).GetMethod( nameof(AgenticTokenCache.RegisterObservability), BindingFlags.Instance | BindingFlags.Public, From 1c2a6a58a1a47e6292f78ab0cd55f22e6fa17eb2 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:49:00 +0100 Subject: [PATCH 5/9] fix(observability): use app-only OBS default scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- CHANGELOG.md | 4 +- src/Observability/Hosting/Caching/README.md | 5 ++- .../Runtime/Common/EnvironmentUtils.cs | 8 ++-- src/Observability/Runtime/README.md | 9 ++-- src/Observability/Runtime/docs/design.md | 10 +++-- .../Caching/AgenticTokenCacheTests.cs | 41 +++++++++++++++++++ .../Common/EnvironmentUtilsTests.cs | 2 +- .../Exporters/ExportConfigConsistencyTests.cs | 4 +- 8 files changed, 65 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8f6250..9f52abcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,9 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. agent and tenant. Missing resolvers fail exporter construction; empty tokens or resolver failures fail the export batch before sending a request. Resolvers are invoked per export batch, so they should cache and refresh tokens near expiry. Workload OBO/MCP/Graph auth is - unchanged. + unchanged. `EnvironmentUtils.GetObservabilityAuthenticationScope()` now returns the OBS + `/.default` scope for app-only S2S export instead of the delegated + `Agent365.Observability.OtelWrite` scope. - **Delegated hosting OBS token acquisition is removed** — `AgenticTokenCache.RegisterObservability(..., AgenticTokenStruct, ...)` is obsolete with `error: true`, and `AgenticTokenStruct` construction is obsolete with `error: true`. diff --git a/src/Observability/Hosting/Caching/README.md b/src/Observability/Hosting/Caching/README.md index ecece67f..953d56d6 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -76,7 +76,10 @@ cached token is still usable. It propagates acquisition failures and clears stal token state when the resolver fails or returns an empty token. Call it from the exporter's `TokenResolver` or catch errors on the request path; the exporter will fail the batch without attempting a delegated fallback. JWT tokens are refreshed near `exp`; opaque tokens -without an `exp` claim use a one-hour fallback max age from acquisition. +without an `exp` claim use a one-hour fallback max age from acquisition. The three-argument +`RefreshObservabilityToken` overload passes the default app-only OBS scope +`api://9b975845-388f-4429-889e-eab1ef63949c/.default` to the resolver unless +`A365_OBSERVABILITY_SCOPE_OVERRIDE` is set. ### Custom Default Expiration diff --git a/src/Observability/Runtime/Common/EnvironmentUtils.cs b/src/Observability/Runtime/Common/EnvironmentUtils.cs index 2c0587ec..0cffb654 100644 --- a/src/Observability/Runtime/Common/EnvironmentUtils.cs +++ b/src/Observability/Runtime/Common/EnvironmentUtils.cs @@ -9,12 +9,12 @@ namespace Microsoft.Agents.A365.Observability.Runtime.Common /// public class EnvironmentUtils { - private const string ProdObservabilityScope = "api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite"; + private const string ProdObservabilityScope = "api://9b975845-388f-4429-889e-eab1ef63949c/.default"; private const string ProdObservabilityClusterCategory = "prod"; private const string DevelopmentEnvironmentName = "development"; /// - /// Returns the scope for authenticating to the observability service based on the current environment. + /// Returns the app-only OBS resource scope for authenticating to the observability service. /// /// The authentication scope. public static string[] GetObservabilityAuthenticationScope() @@ -24,7 +24,7 @@ public static string[] GetObservabilityAuthenticationScope() } /// - /// [Deprecated] Returns the scope for authenticating to the observability service based on the cluster category. + /// [Deprecated] Returns the app-only OBS resource scope for authenticating to the observability service. /// /// Cluster category (deprecated, defaults to production). /// The authentication scope. @@ -77,5 +77,3 @@ private static string GetCurrentEnvironment() } } } - - diff --git a/src/Observability/Runtime/README.md b/src/Observability/Runtime/README.md index f8aaa8e9..5bc84513 100644 --- a/src/Observability/Runtime/README.md +++ b/src/Observability/Runtime/README.md @@ -9,10 +9,11 @@ Agent 365 OBS export is S2S-only. The exporter always posts OTLP traces to the legacy `Agent365ExporterOptions.UseS2SEndpoint` switch is obsolete and ignored. Configure either `TokenResolver` or `ContextualTokenResolver` to return the final app-only -OBS token for the exporting agent and tenant. The SDK never reads a delegated request token, -never performs OBO/user_fic authentication for OBS export, and never falls back to -`/observability` on 401, 403, or 404. The resolver is invoked once per export batch, so it -should cache tokens and refresh only near expiry. +OBS token for the exporting agent and tenant. The default app-only OBS scope is +`api://9b975845-388f-4429-889e-eab1ef63949c/.default`. The SDK never reads a delegated +request token, never performs OBO/user_fic authentication for OBS export, and never falls +back to `/observability` on 401, 403, or 404. The resolver is invoked once per export batch, +so it should cache tokens and refresh only near expiry. Resolvers must validate the returned token before handing it to the exporter: accept `idtyp=app`, or, when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` diff --git a/src/Observability/Runtime/docs/design.md b/src/Observability/Runtime/docs/design.md index 4699f27c..e01b9ea8 100644 --- a/src/Observability/Runtime/docs/design.md +++ b/src/Observability/Runtime/docs/design.md @@ -90,10 +90,12 @@ custom tenant domain resolution still control only the host, not the path. Exporter authentication comes only from the configured `TokenResolver` or `ContextualTokenResolver`. Both resolvers must return an app-only OBS token for the -exporting agent identity; the exporter never reads per-request delegated tokens, never -performs OBO/user_fic token exchange, and never retries 401/403/404 on the delegated -`/observability` route. A missing resolver fails configuration, and a null/empty token or -resolver exception fails the export batch before sending data. +exporting agent identity; the default app-only OBS scope is +`api://9b975845-388f-4429-889e-eab1ef63949c/.default`. The exporter never reads +per-request delegated tokens, never performs OBO/user_fic token exchange, and never retries +401/403/404 on the delegated `/observability` route. A missing resolver fails +configuration, and a null/empty token or resolver exception fails the export batch before +sending data. Resolvers are invoked once per export batch and should cache internally. They must validate the final token before returning it: accept `idtyp=app`, or, when `idtyp` is absent, a diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs index dae8e370..18937298 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -13,8 +13,16 @@ public sealed class AgenticTokenCacheTests { private const string TestAgentId = "test-agent"; private const string TestTenantId = "test-tenant"; + private const string ScopeOverrideEnvVar = "A365_OBSERVABILITY_SCOPE_OVERRIDE"; + private const string DefaultObsScope = "api://9b975845-388f-4429-889e-eab1ef63949c/.default"; private static readonly string[] TestScopes = new[] { "api://9b975845-388f-4429-889e-eab1ef63949c/.default" }; + [TestCleanup] + public void TestCleanup() + { + Environment.SetEnvironmentVariable(ScopeOverrideEnvVar, null); + } + [TestMethod] public async Task RegisterObservability_WithAppOnlyResolver_CachesTokenPerAgentTenant() { @@ -132,6 +140,39 @@ await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => secondResolverCalls.Should().Be(1, "the resolver passed to RefreshObservabilityToken replaces the resolver for future refreshes"); } + [TestMethod] + public async Task RefreshObservabilityToken_ThreeArgumentOverload_UsesDefaultAppOnlyScope() + { + Environment.SetEnvironmentVariable(ScopeOverrideEnvVar, null); + string[]? capturedScopes = null; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, scopes) => + { + capturedScopes = scopes; + return Task.FromResult("app-only-token"); + }); + + capturedScopes.Should().Equal(DefaultObsScope); + } + + [TestMethod] + public async Task RefreshObservabilityToken_ThreeArgumentOverload_HonorsScopeOverride() + { + const string overrideScope = "api://override-resource/.default"; + Environment.SetEnvironmentVariable(ScopeOverrideEnvVar, overrideScope); + string[]? capturedScopes = null; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, scopes) => + { + capturedScopes = scopes; + return Task.FromResult("app-only-token"); + }); + + capturedScopes.Should().Equal(overrideScope); + } + [TestMethod] public async Task RefreshObservabilityToken_OpaqueTokenClearsStaleExpiryMetadata() { 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 index 7babb110..af2e0e2f 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/EnvironmentUtilsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/EnvironmentUtilsTests.cs @@ -45,6 +45,6 @@ public void GetObservabilityAuthenticationScope_ReturnsDefault_WhenEnvVarIsNotSe // Assert Assert.IsNotNull(scopes); Assert.AreEqual(1, scopes.Length); - Assert.AreEqual("api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite", scopes[0]); + Assert.AreEqual("api://9b975845-388f-4429-889e-eab1ef63949c/.default", scopes[0]); } } diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs index 1b9f2612..8ed7e3e6 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs @@ -25,7 +25,7 @@ public sealed class ExportConfigConsistencyTests private const string ScopeOverrideEnvVar = "A365_OBSERVABILITY_SCOPE_OVERRIDE"; // Pinned production values — update ALL of these together when any one changes. - private const string ExpectedScope = "api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite"; + private const string ExpectedScope = "api://9b975845-388f-4429-889e-eab1ef63949c/.default"; private const string ExpectedS2SUri = "https://agent365.svc.cloud.microsoft/observabilityService/tenants/t1/otlp/agents/a1/traces?api-version=1"; [TestInitialize] @@ -56,7 +56,7 @@ public void ExportConfig_Scope_Endpoint_And_Paths_AreConsistent() "S2S export URI changed — also review ProdObservabilityScope and DefaultEndpointHost."); // Coarse sanity: scope targets Agent365 Observability, endpoint targets agent365 service - scopes[0].Should().Contain("Agent365.Observability"); + scopes[0].Should().EndWith("/.default"); Agent365ExporterOptions.DefaultEndpointHost.Should().Contain("agent365"); } } From d2b9ec8d9deb53df0d77076cc390192643274ddb Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:28:54 +0100 Subject: [PATCH 6/9] fix(hosting): serialize OBS token refreshes and keep registrations on cleanup - Serialize AgenticTokenCache.RefreshObservabilityToken per agent and tenant so concurrent callers share one acquisition and a failed refresh cannot clear a token another caller cached while it waited. - RemoveExpiredTokens (and the cleanup timer) now clears expired token values instead of removing entries, so a resolver registered once keeps working after an idle period longer than the token lifetime. Entries with a refresh in flight are skipped. - IExporterTokenCache.RegisterObservability: whether a repeated registration replaces the existing one is implementation-specific. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../Hosting/Caching/AgenticTokenCache.cs | 82 +++++++++++------- .../Hosting/Caching/IExporterTokenCache.cs | 5 +- src/Observability/Hosting/Caching/README.md | 4 + .../Caching/AgenticTokenCacheTests.cs | 83 +++++++++++++++++++ 4 files changed, 141 insertions(+), 33 deletions(-) diff --git a/src/Observability/Hosting/Caching/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index 4efe211c..ee8d121b 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.A365.Observability.Hosting.Caching { /// /// Caches app-only observability tokens per (agentId, tenantId) using the provided resolver. - /// Includes automatic periodic cleanup of expired tokens for improved memory management. + /// Includes automatic periodic cleanup that clears expired token values while keeping resolver registrations. /// public class AgenticTokenCache : IExporterTokenCache, IDisposable { @@ -24,6 +24,7 @@ private sealed class Entry public string[] Scopes { get; set; } public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? AcquiredAt { get; set; } + public SemaphoreSlim RefreshLock { get; } = new SemaphoreSlim(1, 1); public Entry(ObservabilityTokenResolver tokenResolver, string[] scopes) { @@ -180,32 +181,43 @@ public async Task RefreshObservabilityToken(string agentId, string tenan var scopes = ValidateScopes(observabilityScopes); var key = GetKey(agentId, tenantId); var entry = _map.GetOrAdd(key, _ => new Entry(tokenResolver, scopes)); - entry.TokenResolver = tokenResolver; - entry.Scopes = scopes; - - if (IsTokenUsable(entry)) - { - return entry.Token!; - } + // Serialize refreshes per agent and tenant: concurrent callers share one acquisition, and a + // failed refresh cannot clear a token that another caller cached while it was waiting. + await entry.RefreshLock.WaitAsync().ConfigureAwait(false); try { - var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(token)) + entry.TokenResolver = tokenResolver; + entry.Scopes = scopes; + + if (IsTokenUsable(entry)) { - throw new InvalidOperationException("The observability token resolver returned an empty token."); + return entry.Token!; } - entry.Token = token; - entry.ExpiresAt = GetTokenExpiration(token!); - entry.AcquiredAt = _utcNow(); + try + { + var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("The observability token resolver returned an empty token."); + } + + entry.Token = token; + entry.ExpiresAt = GetTokenExpiration(token!); + entry.AcquiredAt = _utcNow(); - return token!; + return token!; + } + catch + { + entry.ClearToken(); + throw; + } } - catch + finally { - entry.ClearToken(); - throw; + entry.RefreshLock.Release(); } } @@ -244,31 +256,37 @@ public void InvalidateAll() } /// - /// Removes all expired tokens from the cache. + /// Clears all expired tokens from the cache. Resolver registrations are kept, so the next + /// call acquires a new token. /// - /// The number of expired tokens that were removed. + /// The number of expired tokens that were cleared. public int RemoveExpiredTokens() { var now = _utcNow(); - var expiredKeys = new List(); + int removedCount = 0; - // Find expired keys without using LINQ foreach (var kvp in _map) { - if (IsTokenExpired(kvp.Value, now)) + var entry = kvp.Value; + + // Skip entries with a refresh in flight; the next cleanup pass re-evaluates them. + if (!entry.RefreshLock.Wait(0)) { - expiredKeys.Add(kvp.Key); + continue; } - } - int removedCount = 0; - foreach (var key in expiredKeys) - { - if (_map.TryRemove(key, out var entry)) + try { - // Clear the token value for security - entry.ClearToken(); - removedCount++; + if (IsTokenExpired(entry, now)) + { + // Clear the token value for security + entry.ClearToken(); + removedCount++; + } + } + finally + { + entry.RefreshLock.Release(); } } diff --git a/src/Observability/Hosting/Caching/IExporterTokenCache.cs b/src/Observability/Hosting/Caching/IExporterTokenCache.cs index a89e9854..7e84e099 100644 --- a/src/Observability/Hosting/Caching/IExporterTokenCache.cs +++ b/src/Observability/Hosting/Caching/IExporterTokenCache.cs @@ -10,7 +10,10 @@ namespace Microsoft.Agents.A365.Observability.Hosting.Caching public interface IExporterTokenCache where T : class { /// - /// Registers or updates a credential or resolver to be used for app-only observability token acquisition. + /// Registers a credential or resolver used for app-only observability token acquisition. + /// Whether a repeated registration for the same agent and tenant replaces the existing one is + /// implementation-specific: keeps the first registration and + /// replaces it. /// void RegisterObservability(string agentId, string tenantId, T tokenGenerator, string[] observabilityScopes); diff --git a/src/Observability/Hosting/Caching/README.md b/src/Observability/Hosting/Caching/README.md index 953d56d6..90c377a7 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -81,6 +81,10 @@ without an `exp` claim use a one-hour fallback max age from acquisition. The thr `api://9b975845-388f-4429-889e-eab1ef63949c/.default` to the resolver unless `A365_OBSERVABILITY_SCOPE_OVERRIDE` is set. +Concurrent refreshes for the same agent and tenant are serialized, so callers share one +acquisition. The automatic cleanup and `RemoveExpiredTokens` clear expired token values but +keep the resolver registration, so the next `GetObservabilityToken` call acquires a new token. + ### Custom Default Expiration ```csharp diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs index 18937298..dd60a0f1 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -257,6 +257,89 @@ await get.Should().ThrowAsync() .WithMessage("The observability token resolver returned an empty token."); } + [TestMethod] + public async Task RefreshObservabilityToken_ConcurrentCallers_ShareOneAcquisition() + { + var resolverCalls = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + ObservabilityTokenResolver resolver = (_, _, _) => + { + Interlocked.Increment(ref resolverCalls); + return release.Task; + }; + + var first = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, resolver, TestScopes); + var second = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, resolver, TestScopes); + release.SetResult("app-only-token"); + + (await Task.WhenAll(first, second)).Should().Equal("app-only-token", "app-only-token"); + resolverCalls.Should().Be(1, "concurrent refreshes for one agent and tenant should share one acquisition"); + } + + [TestMethod] + public async Task RefreshObservabilityToken_ConcurrentFailure_DoesNotClearTokenCachedByAnotherCaller() + { + var failingResolverCalls = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + + var succeeding = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => release.Task, TestScopes); + var failing = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => + { + Interlocked.Increment(ref failingResolverCalls); + return Task.FromException(new InvalidOperationException("acquisition failed")); + }, TestScopes); + release.SetResult("app-only-token"); + + (await succeeding).Should().Be("app-only-token"); + (await failing).Should().Be("app-only-token", "a waiting caller should reuse the token cached by the refresh ahead of it"); + failingResolverCalls.Should().Be(0); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be("app-only-token"); + } + + [TestMethod] + public async Task RemoveExpiredTokens_ClearsExpiredTokenButKeepsResolverRegistration() + { + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var resolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + ObservabilityTokenResolver resolver = (_, _, _) => + { + Interlocked.Increment(ref resolverCalls); + return Task.FromResult(CreateJwt(now.AddHours(1))); + }; + + cache.RegisterObservability(TestAgentId, TestTenantId, resolver, TestScopes); + var firstToken = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + + now = now.AddHours(2); + cache.RemoveExpiredTokens().Should().Be(1); + cache.Count.Should().Be(1, "cleanup must keep the app-only resolver registration"); + + var secondToken = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + secondToken.Should().NotBeNull().And.NotBe(firstToken); + resolverCalls.Should().Be(2); + } + + [TestMethod] + public async Task RemoveExpiredTokens_SkipsEntryWhileRefreshIsInFlight() + { + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(CreateJwt(now.AddMinutes(10))), TestScopes); + + now = now.AddMinutes(30); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var refresh = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => release.Task, TestScopes); + + cache.RemoveExpiredTokens().Should().Be(0, "cleanup must not race an in-flight refresh"); + release.SetResult(CreateJwt(now.AddHours(1))); + + var refreshed = await refresh; + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(refreshed); + } + [TestMethod] public async Task RemovedDelegatedRegisterObservabilityShape_DoesNotRegisterOrThrowWhenInvokedDynamically() { From 03e642d146cacc8b002494e66486102e1ae5366e Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:22:11 +0100 Subject: [PATCH 7/9] fix(hosting): keep an explicitly replaced OBS resolver on cache-driven refresh - GetObservabilityToken no longer snapshots the resolver before taking the per-entry refresh lock. Cache-driven refreshes read the current resolver under the lock and never write it, so a refresh queued behind an explicit RefreshObservabilityToken cannot restore the resolver it replaced. - Add regression tests pinning the opaque one-hour fallback for tokens the JWT handler cannot parse (two segments, invalid base64url, non-JSON, non-numeric exp). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- .../Hosting/Caching/AgenticTokenCache.cs | 24 +++++-- .../Caching/AgenticTokenCacheTests.cs | 68 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/Observability/Hosting/Caching/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index ee8d121b..981df691 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Agents.A365.Observability.Runtime.Common; using System; @@ -147,7 +147,7 @@ public void RegisterObservability(string agentId, string tenantId, AgenticTokenS return entry.Token; } - return await RefreshObservabilityToken(agentId, tenantId, entry.TokenResolver, entry.Scopes).ConfigureAwait(false); + return await RefreshEntryAsync(agentId, tenantId, entry, replacementResolver: null, replacementScopes: null).ConfigureAwait(false); } /// @@ -182,13 +182,27 @@ public async Task RefreshObservabilityToken(string agentId, string tenan var key = GetKey(agentId, tenantId); var entry = _map.GetOrAdd(key, _ => new Entry(tokenResolver, scopes)); + return await RefreshEntryAsync(agentId, tenantId, entry, tokenResolver, scopes).ConfigureAwait(false); + } + + private async Task RefreshEntryAsync( + string agentId, + string tenantId, + Entry entry, + ObservabilityTokenResolver? replacementResolver, + string[]? replacementScopes) + { // Serialize refreshes per agent and tenant: concurrent callers share one acquisition, and a // failed refresh cannot clear a token that another caller cached while it was waiting. await entry.RefreshLock.WaitAsync().ConfigureAwait(false); try { - entry.TokenResolver = tokenResolver; - entry.Scopes = scopes; + // Only explicit refreshes replace the resolver; cache-driven refreshes use the current one. + if (replacementResolver != null && replacementScopes != null) + { + entry.TokenResolver = replacementResolver; + entry.Scopes = replacementScopes; + } if (IsTokenUsable(entry)) { @@ -197,7 +211,7 @@ public async Task RefreshObservabilityToken(string agentId, string tenan try { - var token = await tokenResolver(agentId, tenantId, scopes).ConfigureAwait(false); + var token = await entry.TokenResolver(agentId, tenantId, entry.Scopes).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) { throw new InvalidOperationException("The observability token resolver returned an empty token."); diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs index dd60a0f1..03030d59 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -340,6 +340,74 @@ public async Task RemoveExpiredTokens_SkipsEntryWhileRefreshIsInFlight() (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(refreshed); } + [TestMethod] + public async Task GetObservabilityToken_QueuedBehindExplicitRefresh_DoesNotRestoreReplacedResolver() + { + var now = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var registeredResolverCalls = 0; + var replacementResolverCalls = 0; + var firstToken = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + cache.RegisterObservability(TestAgentId, TestTenantId, (_, _, _) => + Interlocked.Increment(ref registeredResolverCalls) == 1 + ? firstToken.Task + : Task.FromResult(CreateJwt(now.AddHours(1))), TestScopes); + ObservabilityTokenResolver replacementResolver = (_, _, _) => + { + Interlocked.Increment(ref replacementResolverCalls); + return Task.FromResult(CreateJwt(now.AddHours(1))); + }; + + var inFlightGet = cache.GetObservabilityToken(TestAgentId, TestTenantId); + var explicitRefresh = cache.RefreshObservabilityToken(TestAgentId, TestTenantId, replacementResolver, TestScopes); + var queuedGet = cache.GetObservabilityToken(TestAgentId, TestTenantId); + firstToken.SetResult(CreateJwt(now.AddHours(1))); + await Task.WhenAll(new Task[] { inFlightGet, explicitRefresh, queuedGet }); + + now = now.AddHours(2); + await cache.GetObservabilityToken(TestAgentId, TestTenantId); + + replacementResolverCalls.Should().Be(1, "the resolver installed by an explicit refresh is used for the next refresh"); + registeredResolverCalls.Should().Be(1, "a cache-driven refresh must not restore the resolver an explicit refresh replaced"); + } + + [TestMethod] + [DataRow("opaque.token")] + [DataRow("not.a.jwt")] + [DataRow("header.@@@.signature")] + public async Task RefreshObservabilityToken_UnparseableTokenWithPeriods_UsesOpaqueFallback(string unparseableToken) + { + var acquiredAt = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var now = acquiredAt; + var resolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + ObservabilityTokenResolver resolver = (_, _, _) => + { + Interlocked.Increment(ref resolverCalls); + return Task.FromResult(unparseableToken); + }; + + (await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, resolver, TestScopes)).Should().Be(unparseableToken); + + now = acquiredAt.AddMinutes(59); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(unparseableToken); + resolverCalls.Should().Be(1, "tokens the JWT handler cannot parse use the opaque one-hour fallback"); + } + + [TestMethod] + public async Task RefreshObservabilityToken_JwtWithNonNumericExp_UsesOpaqueFallback() + { + var acquiredAt = DateTimeOffset.Parse("2026-09-29T00:00:00Z"); + var now = acquiredAt; + var token = $"{Base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}")}.{Base64Url("{\"exp\":\"soon\"}")}."; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => now); + + (await cache.RefreshObservabilityToken(TestAgentId, TestTenantId, (_, _, _) => Task.FromResult(token), TestScopes)).Should().Be(token); + + now = acquiredAt.AddMinutes(59); + (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(token); + } + [TestMethod] public async Task RemovedDelegatedRegisterObservabilityShape_DoesNotRegisterOrThrowWhenInvokedDynamically() { From a863f2bdfb902189ac8652cf8f0ceec52dc8335c Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:27:32 +0100 Subject: [PATCH 8/9] docs(hosting): use the default-scope overload in cache README snippets The resolver-cache and migration snippets passed an undeclared `scopes` variable. Use the three-argument RefreshObservabilityToken overload, which passes the default app-only OBS scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- src/Observability/Hosting/Caching/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Observability/Hosting/Caching/README.md b/src/Observability/Hosting/Caching/README.md index 90c377a7..5374bdff 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -67,7 +67,7 @@ ObservabilityTokenResolver resolver = async (agentId, tenantId, scopes) => return token; }; -await cache.RefreshObservabilityToken("my-agent", "my-tenant", resolver, scopes); +await cache.RefreshObservabilityToken("my-agent", "my-tenant", resolver); var token = await cache.GetObservabilityToken("my-agent", "my-tenant"); ``` @@ -293,7 +293,7 @@ If you're upgrading from a delegated OBS token flow, replace `AgenticTokenStruct `ObservabilityTokenResolver`: ```csharp -await cache.RefreshObservabilityToken("agent", "tenant", resolver, scopes); +await cache.RefreshObservabilityToken("agent", "tenant", resolver); var token = await cache.GetObservabilityToken("agent", "tenant"); ``` From 90d4d100860f96d201f27415cacff6a193c2ca37 Mon Sep 17 00:00:00 2001 From: Krishnadheeraj <12496535+DheerajPannala@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:01:35 +0100 Subject: [PATCH 9/9] fix(observability): document per-identity resolver calls; use tuple cache keys - The exporter invokes the OBS token resolver once per tenant/agent identity group in each export batch, not once per batch. Correct the options XML doc, Runtime README, design doc and CHANGELOG. - Key AgenticTokenCache entries by an (agentId, tenantId) tuple instead of "agentId:tenantId", so IDs that contain a colon cannot alias another identity's cached token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cbf5f6b-cc40-4b7e-a591-65848db73a12 --- CHANGELOG.md | 5 +++-- .../Hosting/Caching/AgenticTokenCache.cs | 5 +++-- src/Observability/Runtime/README.md | 5 +++-- .../Exporters/Agent365ExporterOptions.cs | 3 ++- src/Observability/Runtime/docs/design.md | 4 +++- .../Caching/AgenticTokenCacheTests.cs | 20 +++++++++++++++++++ 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f52abcf..b1126c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,8 +55,9 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. - **OBS export requires a configured app-only resolver** — `TokenResolver` and `ContextualTokenResolver` must return the final OBS app-only token for the exporting agent and tenant. Missing resolvers fail exporter construction; empty tokens or resolver - failures fail the export batch before sending a request. Resolvers are invoked per export - batch, so they should cache and refresh tokens near expiry. Workload OBO/MCP/Graph auth is + failures fail the export batch before sending a request. Resolvers are invoked once per + tenant/agent identity group in each export batch, so they should cache tokens per agent and + tenant and refresh them near expiry. Workload OBO/MCP/Graph auth is unchanged. `EnvironmentUtils.GetObservabilityAuthenticationScope()` now returns the OBS `/.default` scope for app-only S2S export instead of the delegated `Agent365.Observability.OtelWrite` scope. diff --git a/src/Observability/Hosting/Caching/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index 981df691..c6da07d7 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -43,7 +43,7 @@ public void ClearToken() } } - private readonly ConcurrentDictionary _map = new ConcurrentDictionary(); + private readonly ConcurrentDictionary<(string AgentId, string TenantId), Entry> _map = new ConcurrentDictionary<(string AgentId, string TenantId), Entry>(); private readonly Func _utcNow; private readonly Timer? _cleanupTimer; private int _disposed; // Using int for Interlocked operations @@ -354,7 +354,8 @@ public int RemoveExpiredTokens() return new DateTimeOffset(jwtToken.ValidTo, TimeSpan.Zero); } - private static string GetKey(string agentId, string tenantId) => $"{agentId}:{tenantId}"; + // A tuple key keeps identities apart even when an ID contains a separator character. + private static (string AgentId, string TenantId) GetKey(string agentId, string tenantId) => (agentId, tenantId); private bool IsTokenUsable(Entry entry) { diff --git a/src/Observability/Runtime/README.md b/src/Observability/Runtime/README.md index 5bc84513..9b249c36 100644 --- a/src/Observability/Runtime/README.md +++ b/src/Observability/Runtime/README.md @@ -12,8 +12,9 @@ Configure either `TokenResolver` or `ContextualTokenResolver` to return the fina OBS token for the exporting agent and tenant. The default app-only OBS scope is `api://9b975845-388f-4429-889e-eab1ef63949c/.default`. The SDK never reads a delegated request token, never performs OBO/user_fic authentication for OBS export, and never falls -back to `/observability` on 401, 403, or 404. The resolver is invoked once per export batch, -so it should cache tokens and refresh only near expiry. +back to `/observability` on 401, 403, or 404. The resolver is invoked once per tenant/agent +identity group in each export batch, so a batch that contains several identities invokes it +several times. Cache tokens per agent and tenant and refresh only near expiry. Resolvers must validate the returned token before handing it to the exporter: accept `idtyp=app`, or, when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` diff --git a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs index 725d7b85..d07f71c6 100644 --- a/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs +++ b/src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs @@ -59,7 +59,8 @@ public Agent365ExporterOptions() /// Async delegate used to resolve the app-only OBS auth token. /// Either this or must be set. /// When both are set, takes precedence. - /// The exporter invokes this resolver once per export batch and never falls back to a delegated token. + /// The exporter invokes this resolver once per tenant/agent identity group in each export batch, so a batch + /// that contains several identities invokes it several times. It never falls back to a delegated token. /// public AsyncAuthTokenResolver? TokenResolver { get; set; } diff --git a/src/Observability/Runtime/docs/design.md b/src/Observability/Runtime/docs/design.md index e01b9ea8..ee84efde 100644 --- a/src/Observability/Runtime/docs/design.md +++ b/src/Observability/Runtime/docs/design.md @@ -97,7 +97,9 @@ per-request delegated tokens, never performs OBO/user_fic token exchange, and ne configuration, and a null/empty token or resolver exception fails the export batch before sending data. -Resolvers are invoked once per export batch and should cache internally. They must validate +Resolvers are invoked once per tenant/agent identity group in each export batch, so a batch +that contains several identities invokes them several times; cache tokens per agent and +tenant. They must validate the final token before returning it: accept `idtyp=app`, or, when `idtyp` is absent, a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other `idtyp` and any `scp` claim. Also verify the OBS audience diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs index 03030d59..bd6f3057 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -408,6 +408,26 @@ public async Task RefreshObservabilityToken_JwtWithNonNumericExp_UsesOpaqueFallb (await cache.GetObservabilityToken(TestAgentId, TestTenantId)).Should().Be(token); } + [TestMethod] + public async Task RefreshObservabilityToken_SeparatorBearingIds_DoNotShareCacheEntry() + { + var secondResolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + + (await cache.RefreshObservabilityToken("a:b", "c", (_, _, _) => Task.FromResult("token-for-first"), TestScopes)) + .Should().Be("token-for-first"); + (await cache.RefreshObservabilityToken("a", "b:c", (_, _, _) => + { + Interlocked.Increment(ref secondResolverCalls); + return Task.FromResult("token-for-second"); + }, TestScopes)).Should().Be("token-for-second"); + + secondResolverCalls.Should().Be(1, "a different agent/tenant pair must not reuse another pair's token"); + (await cache.GetObservabilityToken("a:b", "c")).Should().Be("token-for-first"); + (await cache.GetObservabilityToken("a", "b:c")).Should().Be("token-for-second"); + cache.Count.Should().Be(2); + } + [TestMethod] public async Task RemovedDelegatedRegisterObservabilityShape_DoesNotRegisterOrThrowWhenInvokedDynamically() {