From fabc4aefa694c0bfc84a8edf7f02fc4eec96ba42 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 15 May 2026 15:54:23 +0530 Subject: [PATCH 1/4] Wire up A365 observability instrumentation for Agent Framework sample Adds the minimum code needed for the Microsoft.OpenTelemetry distro Agent365 exporter to actually emit traces that show up in the Microsoft Admin Center Advanced Hunting view. Without these changes, the exporter saw gen_ai spans but dropped them all because they had no tenant/agent identity attributes, and even when identity was present no InvokeAgent parent event was emitted, so MAC had nothing to render. Changes in Agent/MyAgent.cs: - Inject IExporterTokenCache so the exporter can obtain an OBO token per (agent, tenant) tuple. - At the start of OnMessageAsync, set tenant + agent baggage via BaggageBuilder so the distro's ActivityProcessor copies the IDs onto every child span (gen_ai/InferenceCall/ExecuteToolBySDK). Tenant comes from Activity.Conversation/Recipient.TenantId, agent from Activity.GetAgenticInstanceId() (for agentic requests). - Call _agentTokenCache.RegisterObservability(agentId, tenantId, ...) so the exporter has a token resolver for this identity, eliminating "No token obtained" warnings for the primary identity. - Wrap the LLM RunStreamingAsync call in InvokeAgentScope.Start(...) with AgentDetails + CallerDetails (from Activity.From). This is what emits the InvokeAgent event that MAC needs as the parent record for the trace UI; RecordInputMessages / RecordOutputMessages capture the user prompt and assistant reply on the scope. - Set ChatClientAgentOptions.Id = chatAgentId and .Name = configured name so the AI SDK's auto-instrumentation tags gen_ai spans with the real agent instance ID instead of a fresh N-format GUID per turn (which previously produced orphan identity groups the exporter couldn't authenticate). Pattern mirrors A365OtelWrapper.InvokeObservedAgentOperation from the official Microsoft.OpenTelemetry.Agent365.Demo example. --- .../sample-agent/Agent/MyAgent.cs | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs index 05c86fbc..eda371c9 100644 --- a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs +++ b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs @@ -2,6 +2,10 @@ // Licensed under the MIT License. using Agent365AgentFrameworkSampleAgent.Tools; +using Microsoft.Agents.A365.Observability.Hosting.Caching; +using Microsoft.Agents.A365.Observability.Runtime.Common; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; using Microsoft.Agents.A365.Runtime.Utils; using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.AI; @@ -15,6 +19,7 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using ObsRequest = Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts.Request; namespace Agent365AgentFrameworkSampleAgent.Agent { @@ -60,6 +65,7 @@ private static string GetAgentInstructions(string? userName) private readonly IConfiguration? _configuration = null; private readonly ILogger? _logger = null; private readonly IMcpToolRegistrationService? _toolService = null; + private readonly IExporterTokenCache? _agentTokenCache = null; // Setup reusable auto sign-in handlers for user authorization (configurable via appsettings.json) private readonly string? AgenticAuthHandlerName; private readonly string? OboAuthHandlerName; @@ -96,11 +102,13 @@ private static bool ShouldSkipToolingOnErrors() public MyAgent(AgentApplicationOptions options, IChatClient chatClient, IConfiguration configuration, + IExporterTokenCache agentTokenCache, IMcpToolRegistrationService toolService, ILogger logger) : base(options) { _chatClient = chatClient; _configuration = configuration; + _agentTokenCache = agentTokenCache; _logger = logger; _toolService = toolService; @@ -194,6 +202,40 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta ToolAuthHandlerName = OboAuthHandlerName; } + // A365 Observability: set tenant + agent baggage on the current activity context so that + // gen_ai LLM child spans inherit identity attributes. Without this, the Agent365 exporter + // logs "spans skipped due to missing tenant or agent ID" and nothing reaches the backend. + // Pattern mirrors A365OtelWrapper.InvokeObservedAgentOperation in the official distro demo. + var resolvedAgentId = turnContext.Activity.IsAgenticRequest() + ? turnContext.Activity.GetAgenticInstanceId() ?? Guid.Empty.ToString() + : Guid.Empty.ToString(); + var resolvedTenantId = turnContext.Activity.Conversation?.TenantId + ?? turnContext.Activity.Recipient?.TenantId + ?? Guid.Empty.ToString(); + using var observabilityBaggage = new BaggageBuilder() + .TenantId(resolvedTenantId) + .AgentId(resolvedAgentId) + .Build(); + + // Register an OBO token resolver for this (agent, tenant) tuple so the Agent365 exporter + // can authenticate when POSTing traces. Eliminates "No token obtained" warnings for spans + // that pick up this baggage. Mirrors the demo's A365OtelWrapper.InvokeObservedAgentOperation. + try + { + _agentTokenCache?.RegisterObservability( + resolvedAgentId, + resolvedTenantId, + new AgenticTokenStruct( + userAuthorization: UserAuthorization, + turnContext: turnContext, + authHandlerName: ToolAuthHandlerName ?? string.Empty), + EnvironmentUtils.GetObservabilityAuthenticationScope()); + } + catch (Exception ex) + { + _logger?.LogWarning("Failed to register observability token: {Message}", ex.Message); + } + // Send an immediate acknowledgment — this arrives as a separate message before the LLM response. // Each SendActivityAsync call produces a discrete Teams message, enabling the multiple-messages pattern. // NOTE: For Teams agentic identities, streaming is buffered into a single message by the SDK; @@ -225,7 +267,7 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta try { var userTextBuilder = new StringBuilder(turnContext.Activity.Text?.Trim() ?? string.Empty); - var _agent = await GetClientAgent(turnContext, turnState, _toolService, ToolAuthHandlerName); + var _agent = await GetClientAgent(turnContext, turnState, _toolService, ToolAuthHandlerName, resolvedAgentId); // Read or Create the conversation session for this conversation. AgentSession? session = await GetConversationSessionAsync(_agent, turnState, cancellationToken); @@ -242,14 +284,57 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta } var userText = userTextBuilder.ToString(); + // A365 Observability: open an InvokeAgentScope so an "InvokeAgent" event is emitted + // (required for MAC portal Advanced Hunting to render the agent turn UI and anchor + // InferenceCall / ExecuteToolBySDK children). CallerDetails is required for visibility. + var obsConfig = _configuration!.GetSection("Agent365Observability"); + var blueprintName = obsConfig["AgentName"] + ?? _configuration["agentBlueprintDisplayName"] + ?? "Agent Blueprint"; + var agentDetails = new AgentDetails( + agentId: resolvedAgentId, + agentName: blueprintName, + agentDescription: obsConfig["AgentDescription"] ?? string.Empty, + agentBlueprintId: obsConfig["AgentBlueprintId"] ?? string.Empty, + tenantId: resolvedTenantId); + + var from = turnContext!.Activity.From; + var callerDetails = new CallerDetails( + userDetails: new UserDetails( + userId: from?.AadObjectId ?? from?.Id ?? "unknown", + userName: from?.Name ?? "unknown", + userEmail: string.Empty)); + + var scopeRequest = new ObsRequest( + content: userText, + sessionId: turnContext.Activity.Conversation?.Id ?? "unknown", + channel: new Channel(turnContext.Activity.ChannelId ?? "msteams"), + conversationId: turnContext.Activity.Conversation?.Id ?? "unknown"); + + var scopeDetails = new InvokeAgentScopeDetails( + endpoint: new Uri($"https://{(obsConfig["AgentName"] ?? "agent").Replace(" ", "-").ToLowerInvariant()}/")); + + using var invokeScope = InvokeAgentScope.Start( + request: scopeRequest, + scopeDetails: scopeDetails, + agentDetails: agentDetails, + callerDetails: callerDetails); + + invokeScope.RecordInputMessages(new[] { userText }); + + var responseBuilder = new StringBuilder(); // Stream the response back to the user as we receive it from the agent. await foreach (var response in _agent!.RunStreamingAsync(userText, session, cancellationToken: cancellationToken)) { if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) { turnContext.StreamingResponse.QueueTextChunk(response.Text); + responseBuilder.Append(response.Text); } } + + invokeScope.RecordOutputMessages(new[] { responseBuilder.ToString() }); + var serializedSession = await _agent!.SerializeSessionAsync(session!); turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(serializedSession)); } @@ -275,7 +360,7 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta /// /// /// - private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string? authHandlerName) + private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string? authHandlerName, string chatAgentId) { AssertionHelpers.ThrowIfNull(_configuration!, nameof(_configuration)); AssertionHelpers.ThrowIfNull(context, nameof(context)); @@ -372,10 +457,18 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta Instructions = GetAgentInstructions(displayName) }; - // Create the chat Client passing in agent instructions and tools: + // Create the chat Client passing in agent instructions and tools. + // Setting Id = chatAgentId (the real Teams agent instance ID) so the auto-instrumentation + // tags gen_ai spans with the same agent.id as our BaggageBuilder/InvokeAgentScope, instead + // of a randomly auto-generated N-format GUID per turn. + var configuredAgentName = _configuration?["Agent365Observability:AgentName"] + ?? _configuration?["agentBlueprintDisplayName"] + ?? "Agent Blueprint"; return new ChatClientAgent(_chatClient!, new ChatClientAgentOptions { + Id = chatAgentId, + Name = configuredAgentName, ChatOptions = toolOptions, ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions { From 05bfe7b386efa28840266dc79f34d25bf31dd391 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 15 May 2026 16:05:44 +0530 Subject: [PATCH 2/4] Address Copilot review: avoid UriFormatException from free-form AgentName Build the InvokeAgentScope endpoint Uri from the AgentBlueprintId (a GUID, always URI-safe) under the RFC 2606 reserved `.invalid` TLD, instead of slugifying the display AgentName. AgentName is free-form configuration text and may contain characters invalid in a hostname (apostrophes, parentheses, slashes, etc.), which would throw UriFormatException during message handling before the LLM call. --- dotnet/agent-framework/sample-agent/Agent/MyAgent.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs index eda371c9..606ba92f 100644 --- a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs +++ b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs @@ -311,8 +311,15 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta channel: new Channel(turnContext.Activity.ChannelId ?? "msteams"), conversationId: turnContext.Activity.Conversation?.Id ?? "unknown"); - var scopeDetails = new InvokeAgentScopeDetails( - endpoint: new Uri($"https://{(obsConfig["AgentName"] ?? "agent").Replace(" ", "-").ToLowerInvariant()}/")); + // Endpoint is metadata for the trace; use the blueprint ID (a GUID, always URI-safe) + // under the RFC 2606 reserved `.invalid` TLD. Avoids UriFormatException risk from + // free-form display names that may contain characters invalid in a hostname. + var blueprintForUri = obsConfig["AgentBlueprintId"]; + var endpointUri = !string.IsNullOrEmpty(blueprintForUri) + ? new Uri($"https://{blueprintForUri}.agent.invalid/") + : new Uri("https://agent.invalid/"); + + var scopeDetails = new InvokeAgentScopeDetails(endpoint: endpointUri); using var invokeScope = InvokeAgentScope.Start( request: scopeRequest, From cf94acd6010fc892129b5ccd8d79fe1d8126309e Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 15 May 2026 16:12:13 +0530 Subject: [PATCH 3/4] Address Copilot review: resolve agent id for non-agentic turns; skip observability when unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, OnMessageAsync fell back to Guid.Empty for non-agentic requests (Playground / WebChat with the OBO auth handler). That meant the baggage, token-cache registration, and ChatClientAgentOptions.Id were all set to a synthetic identity the Agent365 exporter could not authenticate — exactly the orphan "No token obtained" group we set out to fix. Changes in Agent/MyAgent.cs: - Resolve agent id via the OBO token (UserAuthorization.GetTurnTokenAsync + Utility.ResolveAgentIdentity) for non-agentic requests, mirroring A365OtelWrapper.ResolveTenantAndAgentId in the official distro demo. - Compute hasObservabilityIdentity = both agent id AND tenant id are real (non-empty); skip BaggageBuilder, RegisterObservability and InvokeAgentScope when false. This stops polluting traces with Guid.Empty-grouped spans the exporter would drop anyway. - Make chatAgentId parameter to GetClientAgent nullable; only set ChatClientAgentOptions.Id when a real agent id was resolved. - Forward cancellationToken to UserAuthorization.GetTurnTokenAsync. - Tighten Activity null-state with `?.` to satisfy nullable analysis. --- .../sample-agent/Agent/MyAgent.cs | 234 +++++++++++------- 1 file changed, 140 insertions(+), 94 deletions(-) diff --git a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs index 606ba92f..0d9e7cab 100644 --- a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs +++ b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs @@ -202,38 +202,67 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta ToolAuthHandlerName = OboAuthHandlerName; } - // A365 Observability: set tenant + agent baggage on the current activity context so that - // gen_ai LLM child spans inherit identity attributes. Without this, the Agent365 exporter - // logs "spans skipped due to missing tenant or agent ID" and nothing reaches the backend. - // Pattern mirrors A365OtelWrapper.InvokeObservedAgentOperation in the official distro demo. - var resolvedAgentId = turnContext.Activity.IsAgenticRequest() - ? turnContext.Activity.GetAgenticInstanceId() ?? Guid.Empty.ToString() - : Guid.Empty.ToString(); + // A365 Observability: resolve the agent identity for this turn. For agentic requests + // (Teams agent instances), the ID comes from the activity itself. For non-agentic + // requests (Playground / WebChat), decode it from the OBO token via the existing + // Utility.ResolveAgentIdentity helper. Mirrors A365OtelWrapper.ResolveTenantAndAgentId + // in the official distro demo. + string? resolvedAgentId = null; + if (turnContext.Activity.IsAgenticRequest()) + { + resolvedAgentId = turnContext.Activity.GetAgenticInstanceId(); + } + else if (!string.IsNullOrEmpty(ToolAuthHandlerName)) + { + try + { + var oboToken = await UserAuthorization.GetTurnTokenAsync(turnContext, ToolAuthHandlerName, cancellationToken: cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(oboToken)) + { + resolvedAgentId = Utility.ResolveAgentIdentity(turnContext, oboToken); + } + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Could not resolve agent id from OBO token; A365 observability skipped for this turn."); + } + } + var resolvedTenantId = turnContext.Activity.Conversation?.TenantId - ?? turnContext.Activity.Recipient?.TenantId - ?? Guid.Empty.ToString(); - using var observabilityBaggage = new BaggageBuilder() - .TenantId(resolvedTenantId) - .AgentId(resolvedAgentId) - .Build(); + ?? turnContext.Activity.Recipient?.TenantId; + + // Only set baggage / register a token / open InvokeAgentScope when we have a real + // (agent, tenant) tuple. Falling back to Guid.Empty creates a synthetic identity + // group the exporter cannot authenticate and pollutes the trace with orphan spans. + var hasObservabilityIdentity = !string.IsNullOrEmpty(resolvedAgentId) + && !string.IsNullOrEmpty(resolvedTenantId); + + using IDisposable? observabilityBaggage = hasObservabilityIdentity + ? new BaggageBuilder() + .TenantId(resolvedTenantId!) + .AgentId(resolvedAgentId!) + .Build() + : null; // Register an OBO token resolver for this (agent, tenant) tuple so the Agent365 exporter - // can authenticate when POSTing traces. Eliminates "No token obtained" warnings for spans - // that pick up this baggage. Mirrors the demo's A365OtelWrapper.InvokeObservedAgentOperation. - try + // can authenticate when POSTing traces. Mirrors the demo's A365OtelWrapper. + if (hasObservabilityIdentity) { - _agentTokenCache?.RegisterObservability( - resolvedAgentId, - resolvedTenantId, - new AgenticTokenStruct( - userAuthorization: UserAuthorization, - turnContext: turnContext, - authHandlerName: ToolAuthHandlerName ?? string.Empty), - EnvironmentUtils.GetObservabilityAuthenticationScope()); - } - catch (Exception ex) - { - _logger?.LogWarning("Failed to register observability token: {Message}", ex.Message); + try + { + _agentTokenCache?.RegisterObservability( + resolvedAgentId!, + resolvedTenantId!, + new AgenticTokenStruct( + userAuthorization: UserAuthorization, + turnContext: turnContext, + authHandlerName: ToolAuthHandlerName ?? string.Empty), + EnvironmentUtils.GetObservabilityAuthenticationScope()); + } + catch (Exception ex) + { + _logger?.LogWarning("Failed to register observability token: {Message}", ex.Message); + } } // Send an immediate acknowledgment — this arrives as a separate message before the LLM response. @@ -286,61 +315,72 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta // A365 Observability: open an InvokeAgentScope so an "InvokeAgent" event is emitted // (required for MAC portal Advanced Hunting to render the agent turn UI and anchor - // InferenceCall / ExecuteToolBySDK children). CallerDetails is required for visibility. - var obsConfig = _configuration!.GetSection("Agent365Observability"); - var blueprintName = obsConfig["AgentName"] - ?? _configuration["agentBlueprintDisplayName"] - ?? "Agent Blueprint"; - var agentDetails = new AgentDetails( - agentId: resolvedAgentId, - agentName: blueprintName, - agentDescription: obsConfig["AgentDescription"] ?? string.Empty, - agentBlueprintId: obsConfig["AgentBlueprintId"] ?? string.Empty, - tenantId: resolvedTenantId); - - var from = turnContext!.Activity.From; - var callerDetails = new CallerDetails( - userDetails: new UserDetails( - userId: from?.AadObjectId ?? from?.Id ?? "unknown", - userName: from?.Name ?? "unknown", - userEmail: string.Empty)); - - var scopeRequest = new ObsRequest( - content: userText, - sessionId: turnContext.Activity.Conversation?.Id ?? "unknown", - channel: new Channel(turnContext.Activity.ChannelId ?? "msteams"), - conversationId: turnContext.Activity.Conversation?.Id ?? "unknown"); - - // Endpoint is metadata for the trace; use the blueprint ID (a GUID, always URI-safe) - // under the RFC 2606 reserved `.invalid` TLD. Avoids UriFormatException risk from - // free-form display names that may contain characters invalid in a hostname. - var blueprintForUri = obsConfig["AgentBlueprintId"]; - var endpointUri = !string.IsNullOrEmpty(blueprintForUri) - ? new Uri($"https://{blueprintForUri}.agent.invalid/") - : new Uri("https://agent.invalid/"); - - var scopeDetails = new InvokeAgentScopeDetails(endpoint: endpointUri); - - using var invokeScope = InvokeAgentScope.Start( - request: scopeRequest, - scopeDetails: scopeDetails, - agentDetails: agentDetails, - callerDetails: callerDetails); - - invokeScope.RecordInputMessages(new[] { userText }); - - var responseBuilder = new StringBuilder(); - // Stream the response back to the user as we receive it from the agent. - await foreach (var response in _agent!.RunStreamingAsync(userText, session, cancellationToken: cancellationToken)) + // InferenceCall / ExecuteToolBySDK children). Only open the scope when we have a real + // (agent, tenant) identity — otherwise the export would group spans under an identity + // the exporter cannot authenticate. + InvokeAgentScope? invokeScope = null; + if (hasObservabilityIdentity) { - if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + var obsConfig = _configuration!.GetSection("Agent365Observability"); + var blueprintName = obsConfig["AgentName"] + ?? _configuration["agentBlueprintDisplayName"] + ?? "Agent Blueprint"; + var agentDetails = new AgentDetails( + agentId: resolvedAgentId!, + agentName: blueprintName, + agentDescription: obsConfig["AgentDescription"] ?? string.Empty, + agentBlueprintId: obsConfig["AgentBlueprintId"] ?? string.Empty, + tenantId: resolvedTenantId!); + + var from = turnContext.Activity?.From; + var callerDetails = new CallerDetails( + userDetails: new UserDetails( + userId: from?.AadObjectId ?? from?.Id ?? "unknown", + userName: from?.Name ?? "unknown", + userEmail: string.Empty)); + + var scopeRequest = new ObsRequest( + content: userText, + sessionId: turnContext.Activity?.Conversation?.Id ?? "unknown", + channel: new Channel(turnContext.Activity?.ChannelId ?? "msteams"), + conversationId: turnContext.Activity?.Conversation?.Id ?? "unknown"); + + // Endpoint is metadata for the trace; use the blueprint ID (a GUID, always URI-safe) + // under the RFC 2606 reserved `.invalid` TLD. Avoids UriFormatException risk from + // free-form display names that may contain characters invalid in a hostname. + var blueprintForUri = obsConfig["AgentBlueprintId"]; + var endpointUri = !string.IsNullOrEmpty(blueprintForUri) + ? new Uri($"https://{blueprintForUri}.agent.invalid/") + : new Uri("https://agent.invalid/"); + + invokeScope = InvokeAgentScope.Start( + request: scopeRequest, + scopeDetails: new InvokeAgentScopeDetails(endpoint: endpointUri), + agentDetails: agentDetails, + callerDetails: callerDetails); + + invokeScope.RecordInputMessages(new[] { userText }); + } + + try + { + var responseBuilder = new StringBuilder(); + // Stream the response back to the user as we receive it from the agent. + await foreach (var response in _agent!.RunStreamingAsync(userText, session, cancellationToken: cancellationToken)) { - turnContext.StreamingResponse.QueueTextChunk(response.Text); - responseBuilder.Append(response.Text); + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + turnContext.StreamingResponse.QueueTextChunk(response.Text); + responseBuilder.Append(response.Text); + } } - } - invokeScope.RecordOutputMessages(new[] { responseBuilder.ToString() }); + invokeScope?.RecordOutputMessages(new[] { responseBuilder.ToString() }); + } + finally + { + invokeScope?.Dispose(); + } var serializedSession = await _agent!.SerializeSessionAsync(session!); turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(serializedSession)); @@ -367,7 +407,7 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta /// /// /// - private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string? authHandlerName, string chatAgentId) + private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string? authHandlerName, string? chatAgentId) { AssertionHelpers.ThrowIfNull(_configuration!, nameof(_configuration)); AssertionHelpers.ThrowIfNull(context, nameof(context)); @@ -465,25 +505,31 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta }; // Create the chat Client passing in agent instructions and tools. - // Setting Id = chatAgentId (the real Teams agent instance ID) so the auto-instrumentation - // tags gen_ai spans with the same agent.id as our BaggageBuilder/InvokeAgentScope, instead - // of a randomly auto-generated N-format GUID per turn. + // When chatAgentId is provided (i.e. observability identity was resolved), set it as the + // ChatClientAgent.Id so the AI SDK's auto-instrumentation tags gen_ai spans with the same + // agent.id as our BaggageBuilder/InvokeAgentScope, instead of a randomly auto-generated + // N-format GUID per turn. If no real id is available we leave Id null and let the SDK + // handle it (those spans won't be exported to A365 anyway since we skipped baggage). var configuredAgentName = _configuration?["Agent365Observability:AgentName"] ?? _configuration?["agentBlueprintDisplayName"] ?? "Agent Blueprint"; - return new ChatClientAgent(_chatClient!, - new ChatClientAgentOptions - { - Id = chatAgentId, - Name = configuredAgentName, - ChatOptions = toolOptions, - ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions - { + var chatClientOptions = new ChatClientAgentOptions + { + Name = configuredAgentName, + ChatOptions = toolOptions, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions + { #pragma warning disable MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates - ChatReducer = new MessageCountingChatReducer(10) + ChatReducer = new MessageCountingChatReducer(10) #pragma warning restore MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates - }) - }) + }) + }; + if (!string.IsNullOrEmpty(chatAgentId)) + { + chatClientOptions.Id = chatAgentId; + } + + return new ChatClientAgent(_chatClient!, chatClientOptions) .AsBuilder() .UseOpenTelemetry(sourceName: null, (cfg) => cfg.EnableSensitiveData = true) .Build(); From 3ba8efc60ac2faab972110a2b5859578965c8639 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 15 May 2026 16:20:29 +0530 Subject: [PATCH 4/4] Enable Agent365 exporter and surface observability diagnostics in appsettings template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two changes to the appsettings.json template: 1. `EnableAgent365Exporter: true` at the root. The SDK's Microsoft.Agents.A365.Observability.Runtime.Builder defaults this to false when the key is absent — without setting it to true, all the observability code added in this PR is wired up but no traces are POSTed to the Agent365 backend. Required for the sample to actually export traces out of the box. 2. Debug-level logging for the four observability-relevant categories: - OpenTelemetry — SDK pipeline diagnostics - Microsoft.OpenTelemetry — distro startup and instrumentation - Microsoft.Agents.A365.Observability — exporter activity: span partitioning, token resolution, HTTP export status codes. The key signal for verifying traces reach the backend. - Microsoft.Agents.A365.Runtime — auth identity resolution, tool dispatch, and related runtime diagnostics. These make the sample observability-ready out of the box — running it shows exporter batches, identity groups, and HTTP 200 responses in the console, so developers can immediately confirm whether traces are flowing. Skipped: System.Net.Http.HttpClient.Default at Trace level. That setting logs bearer tokens and response bodies in plain text and is not appropriate as a template default — devs can opt in when diagnosing specific HTTP issues. --- dotnet/agent-framework/sample-agent/appsettings.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dotnet/agent-framework/sample-agent/appsettings.json b/dotnet/agent-framework/sample-agent/appsettings.json index 889c45da..97090c85 100644 --- a/dotnet/agent-framework/sample-agent/appsettings.json +++ b/dotnet/agent-framework/sample-agent/appsettings.json @@ -46,7 +46,11 @@ "Microsoft.AspNetCore": "Warning", "Microsoft.Agents": "Warning", "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.SemanticKernel*": "Warning" + "Microsoft.SemanticKernel*": "Warning", + "OpenTelemetry": "Debug", // OpenTelemetry SDK pipeline diagnostics + "Microsoft.OpenTelemetry": "Debug", // Microsoft OpenTelemetry distro startup and instrumentation + "Microsoft.Agents.A365.Observability": "Debug", // Agent365 exporter activity: partitioning, tokens, HTTP exports + "Microsoft.Agents.A365.Runtime": "Debug" // Agent365 runtime diagnostics: auth identity resolution, tool dispatch } }, "AllowedHosts": "*", @@ -85,5 +89,6 @@ "AgentBlueprintId": "{{BLUEPRINT_ID}}", // this is the Blueprint ID for the agent "ClientId": "{{BLUEPRINT_ID}}", // Blueprint App ID — used by ObservabilityTokenService to acquire tokens via the FMI chain "ClientSecret": "<>" - } + }, + "EnableAgent365Exporter": true } \ No newline at end of file