diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c883638..b1126c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,30 @@ 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 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. +- **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/AgenticTokenCache.cs b/src/Observability/Hosting/Caching/AgenticTokenCache.cs index bfe964e4..c6da07d7 100644 --- a/src/Observability/Hosting/Caching/AgenticTokenCache.cs +++ b/src/Observability/Hosting/Caching/AgenticTokenCache.cs @@ -1,9 +1,10 @@ -// Copyright (c) Microsoft Corporation. +// 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,23 @@ namespace Microsoft.Agents.A365.Observability.Hosting.Caching { /// - /// Caches observability tokens per (agentId, tenantId) using the provided UserAuthorization and TurnContext. - /// Includes automatic periodic cleanup of expired tokens for improved memory management. + /// Caches app-only observability tokens per (agentId, tenantId) using the provided resolver. + /// Includes automatic periodic cleanup that clears expired token values while keeping resolver registrations. /// - 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 DateTimeOffset? AcquiredAt { get; set; } + public SemaphoreSlim RefreshLock { get; } = new SemaphoreSlim(1, 1); - public Entry(AgenticTokenStruct agenticTokenStruct, string[] scopes) + public Entry(ObservabilityTokenResolver tokenResolver, string[] scopes) { - AgenticTokenStruct = agenticTokenStruct; + TokenResolver = tokenResolver; Scopes = scopes; } @@ -36,12 +39,17 @@ public void ClearToken() { Token = null; ExpiresAt = null; + AcquiredAt = null; } } - 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 + 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). @@ -53,7 +61,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) { @@ -67,25 +81,45 @@ 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) + /// + /// 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) { - 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.TryAdd(GetKey(agentId, tenantId), entry); + } + + /// + /// 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,35 +132,106 @@ 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; + } + if (!_map.TryGetValue(GetKey(agentId, tenantId), out var entry)) + { + return null; + } + + if (IsTokenUsable(entry)) + { + return entry.Token; + } + + return await RefreshEntryAsync(agentId, tenantId, entry, replacementResolver: null, replacementScopes: null).ConfigureAwait(false); + } + + /// + /// 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()); + } + + /// + /// 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)); + } + + var scopes = ValidateScopes(observabilityScopes); + 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 { - // 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. + // Only explicit refreshes replace the resolver; cache-driven refreshes use the current one. + if (replacementResolver != null && replacementScopes != null) { - return entry.Token; + entry.TokenResolver = replacementResolver; + entry.Scopes = replacementScopes; } - // 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); + try + { + 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."); + } - entry.Token = token; - entry.ExpiresAt = GetTokenExpiration(token); + entry.Token = token; + entry.ExpiresAt = GetTokenExpiration(token!); + entry.AcquiredAt = _utcNow(); - return token; + return token!; + } + catch + { + entry.ClearToken(); + throw; + } } - catch + finally { - return null; + entry.RefreshLock.Release(); } } @@ -141,7 +246,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 @@ -165,31 +270,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 = DateTimeOffset.UtcNow; - var expiredKeys = new List(); + var now = _utcNow(); + int removedCount = 0; - // Find expired keys without using LINQ foreach (var kvp in _map) { - if (kvp.Value.ExpiresAt.HasValue && now >= kvp.Value.ExpiresAt.Value) + 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(); } } @@ -218,14 +329,83 @@ 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); } + // 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) + { + return !string.IsNullOrEmpty(entry.Token) + && !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) + { + 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..7e84e099 100644 --- a/src/Observability/Hosting/Caching/IExporterTokenCache.cs +++ b/src/Observability/Hosting/Caching/IExporterTokenCache.cs @@ -10,12 +10,16 @@ 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 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); /// - /// 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/Caching/README.md b/src/Observability/Hosting/Caching/README.md index 42d61423..5374bdff 100644 --- a/src/Observability/Hosting/Caching/README.md +++ b/src/Observability/Hosting/Caching/README.md @@ -1,8 +1,22 @@ -# 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. `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 +`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 +55,36 @@ 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); +var token = await cache.GetObservabilityToken("my-agent", "my-tenant"); +``` + +`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. JWT tokens are refreshed near `exp`; opaque tokens +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. + +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 @@ -186,16 +230,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 +288,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: - -```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: +If you're upgrading from a delegated OBS token flow, replace `AgenticTokenStruct` and +`RegisterObservability(..., AgenticTokenStruct, ...)` with an app-only +`ObservabilityTokenResolver`: ```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); +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/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/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/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/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 804df586..9b249c36 100644 --- a/src/Observability/Runtime/README.md +++ b/src/Observability/Runtime/README.md @@ -2,6 +2,26 @@ 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 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 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` +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/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..d07f71c6 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,20 @@ 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 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; } /// - /// 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 +80,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/Observability/Runtime/docs/design.md b/src/Observability/Runtime/docs/design.md index 2a926aeb..ee84efde 100644 --- a/src/Observability/Runtime/docs/design.md +++ b/src/Observability/Runtime/docs/design.md @@ -81,6 +81,30 @@ 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 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 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 +(`api://9b975845-388f-4429-889e-eab1ef63949c`) and token lifetime. + ### InvokeAgentScope **Source**: [InvokeAgentScope.cs](../Tracing/Scopes/InvokeAgentScope.cs) 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..bd6f3057 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Caching/AgenticTokenCacheTests.cs @@ -0,0 +1,464 @@ +// 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 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() + { + var resolverCalls = 0; + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + 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_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(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); + 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 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); + + var token = await cache.GetObservabilityToken(TestAgentId, TestTenantId); + + 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_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() + { + 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); + + 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 OpaqueToken_IsUsableBeforeFallbackMaxAge_AndRefreshesAtBoundary() + { + 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, + TestTenantId, + (_, _, _) => Task.FromException(new InvalidOperationException("acquisition failed")), + TestScopes); + + await act.Should().ThrowAsync().WithMessage("acquisition failed"); + + 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_PropagatesFailureAndClearsCachedToken() + { + 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, + TestTenantId, + (_, _, _) => Task.FromResult(string.Empty), + TestScopes); + + 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 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 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 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() + { + var cache = new AgenticTokenCache(TimeSpan.Zero, () => DateTimeOffset.UtcNow); + 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] 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/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/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..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,9 +25,8 @@ 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 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 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] public void TestInitialize() => Environment.SetEnvironmentVariable(ScopeOverrideEnvVar, null); @@ -44,27 +43,20 @@ 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 - scopes[0].Should().Contain("Agent365.Observability"); + scopes[0].Should().EndWith("/.default"); Agent365ExporterOptions.DefaultEndpointHost.Should().Contain("agent365"); } } 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" },