diff --git a/src/Observability/Hosting/Middleware/BaggageTurnMiddleware.cs b/src/Observability/Hosting/Middleware/BaggageTurnMiddleware.cs new file mode 100644 index 00000000..9d5111a9 --- /dev/null +++ b/src/Observability/Hosting/Middleware/BaggageTurnMiddleware.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.A365.Observability.Hosting.Extensions; +using Microsoft.Agents.A365.Observability.Runtime.Common; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; + +namespace Microsoft.Agents.A365.Observability.Hosting.Middleware +{ + /// + /// Bot Framework middleware that propagates OpenTelemetry baggage context + /// derived from . + /// + /// + /// Async replies (ContinueConversation events) are passed through without + /// baggage setup because their context is established by the originating turn. + /// + public sealed class BaggageTurnMiddleware : IMiddleware + { + /// + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + var activity = turnContext.Activity; + bool isAsyncReply = activity != null + && activity.Type == ActivityTypes.Event + && activity.Name == ActivityEventNames.ContinueConversation; + + if (isAsyncReply) + { + await next(cancellationToken).ConfigureAwait(false); + return; + } + + var builder = new BaggageBuilder(); + builder.FromTurnContext(turnContext); + + using (builder.Build()) + { + await next(cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/Observability/Hosting/Middleware/OutputLoggingMiddleware.cs b/src/Observability/Hosting/Middleware/OutputLoggingMiddleware.cs new file mode 100644 index 00000000..f60756d3 --- /dev/null +++ b/src/Observability/Hosting/Middleware/OutputLoggingMiddleware.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.A365.Observability.Hosting.Extensions; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; + +namespace Microsoft.Agents.A365.Observability.Hosting.Middleware +{ + /// + /// Bot Framework middleware that creates spans + /// for outgoing messages. + /// + /// + /// + /// Links to a parent span when is set in + /// . + /// + /// + /// Privacy note: Outgoing message content is captured verbatim as + /// span attributes and exported to the configured telemetry backend. + /// + /// + public sealed class OutputLoggingMiddleware : IMiddleware + { + /// + /// The key used to store the parent + /// span reference. Set this value to a W3C traceparent string + /// (e.g. "00-{trace_id}-{span_id}-{trace_flags}") to link + /// spans as children of an + /// . + /// + public const string A365ParentSpanKey = "A365ParentSpanId"; + + /// + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + var agentDetails = DeriveAgentDetails(turnContext); + var tenantDetails = DeriveTenantDetails(turnContext); + + if (agentDetails == null || tenantDetails == null) + { + await next(cancellationToken).ConfigureAwait(false); + return; + } + + var callerDetails = DeriveCallerDetails(turnContext); + var conversationId = turnContext.Activity?.Conversation?.Id; + var sourceMetadata = DeriveSourceMetadata(turnContext); + var executionType = DeriveExecutionType(turnContext); + + turnContext.OnSendActivities(CreateSendHandler( + turnContext, + agentDetails, + tenantDetails, + callerDetails, + conversationId, + sourceMetadata, + executionType)); + + await next(cancellationToken).ConfigureAwait(false); + } + + private static AgentDetails? DeriveAgentDetails(ITurnContext turnContext) + { + var recipient = turnContext.Activity?.Recipient; + if (recipient == null) + { + return null; + } + + // Gate on the recipient having an agentic identity + var agentId = recipient.AgenticAppId ?? recipient.Id; + if (string.IsNullOrEmpty(agentId)) + { + return null; + } + + return new AgentDetails( + agentId: agentId, + agentName: recipient.Name, + agentAUID: recipient.AadObjectId, + agentUPN: recipient.AgenticUserId, + agentDescription: recipient.Role, + tenantId: recipient.TenantId); + } + + private static TenantDetails? DeriveTenantDetails(ITurnContext turnContext) + { + var tenantId = turnContext.Activity?.Recipient?.TenantId; + if (string.IsNullOrWhiteSpace(tenantId) || !Guid.TryParse(tenantId, out var tenantGuid)) + { + return null; + } + + return new TenantDetails(tenantGuid); + } + + private static CallerDetails? DeriveCallerDetails(ITurnContext turnContext) + { + var from = turnContext.Activity?.From; + if (from == null) + { + return null; + } + + return new CallerDetails( + callerId: from.Id ?? string.Empty, + callerName: from.Name ?? string.Empty, + callerUpn: from.AgenticUserId ?? string.Empty, + tenantId: from.TenantId); + } + + private static SourceMetadata? DeriveSourceMetadata(ITurnContext turnContext) + { + var channelId = turnContext.Activity?.ChannelId; + if (channelId == null) + { + return null; + } + + return new SourceMetadata( + name: channelId.Channel, + description: channelId.SubChannel); + } + + private static string? DeriveExecutionType(ITurnContext turnContext) + { + var pairs = turnContext.GetExecutionTypePair(); + foreach (var pair in pairs) + { + if (pair.Key == OpenTelemetryConstants.GenAiExecutionTypeKey) + { + return pair.Value?.ToString(); + } + } + + return null; + } + + private static SendActivitiesHandler CreateSendHandler( + ITurnContext turnContext, + AgentDetails agentDetails, + TenantDetails tenantDetails, + CallerDetails? callerDetails, + string? conversationId, + SourceMetadata? sourceMetadata, + string? executionType) + { + return async (ctx, activities, nextSend) => + { + var messages = new List(); + foreach (var a in activities) + { + if (string.Equals(a.Type, ActivityTypes.Message, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(a.Text)) + { + messages.Add(a.Text); + } + } + + if (messages.Count == 0) + { + return await nextSend().ConfigureAwait(false); + } + + // Read parent span lazily so the agent handler can set it during logic() + string? parentId = null; + if (turnContext.StackState.TryGetValue(A365ParentSpanKey, out var parentSpanValue) && parentSpanValue != null) + { + parentId = parentSpanValue.ToString(); + } + + var outputScope = OutputScope.Start( + agentDetails: agentDetails, + tenantDetails: tenantDetails, + response: new Response(messages), + conversationId: conversationId, + sourceMetadata: sourceMetadata, + callerDetails: callerDetails, + parentId: parentId); + + try + { + outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiExecutionTypeKey, executionType); + return await nextSend().ConfigureAwait(false); + } + catch (Exception ex) + { + outputScope.RecordError(ex); + throw; + } + finally + { + outputScope.Dispose(); + } + }; + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Microsoft.Agents.A365.Observability.Hosting.Tests.csproj b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Microsoft.Agents.A365.Observability.Hosting.Tests.csproj index 4c414d33..a8168905 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Microsoft.Agents.A365.Observability.Hosting.Tests.csproj +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Microsoft.Agents.A365.Observability.Hosting.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs new file mode 100644 index 00000000..538ea909 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.Observability.Hosting.Middleware; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using Moq; +using OpenTelemetry; + +namespace Microsoft.Agents.A365.Observability.Hosting.Tests.Middleware; + +[TestClass] +public class BaggageTurnMiddlewareTests +{ + [TestMethod] + public async Task OnTurnAsync_SetsOpenTelemetryBaggage() + { + // Arrange + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext(); + + string? capturedTenantId = null; + string? capturedCallerId = null; + + NextDelegate next = (ct) => + { + capturedTenantId = Baggage.Current.GetBaggage(OpenTelemetryConstants.TenantIdKey); + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.GenAiCallerIdKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + capturedTenantId.Should().Be("tenant-123"); + capturedCallerId.Should().Be("caller-id"); + } + + [TestMethod] + public async Task OnTurnAsync_SkipsBaggageForContinueConversation() + { + // Arrange + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext( + activityType: ActivityTypes.Event, + activityName: ActivityEventNames.ContinueConversation); + + bool logicCalled = false; + string? capturedCallerId = null; + + NextDelegate next = (ct) => + { + logicCalled = true; + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.GenAiCallerIdKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + logicCalled.Should().BeTrue(); + // Baggage should NOT be set because the middleware skipped it + capturedCallerId.Should().BeNull(); + } + + [TestMethod] + public async Task OnTurnAsync_CallsNextDelegate() + { + // Arrange + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext(); + + bool nextCalled = false; + NextDelegate next = (ct) => + { + nextCalled = true; + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + nextCalled.Should().BeTrue(); + } + + [TestMethod] + public async Task OnTurnAsync_RestoresBaggageAfterNext() + { + // Arrange + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext(); + + string? baggageBeforeMiddleware = Baggage.Current.GetBaggage(OpenTelemetryConstants.TenantIdKey); + + NextDelegate next = (ct) => Task.CompletedTask; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert – the baggage scope should be disposed after OnTurnAsync returns + string? baggageAfterMiddleware = Baggage.Current.GetBaggage(OpenTelemetryConstants.TenantIdKey); + baggageAfterMiddleware.Should().Be(baggageBeforeMiddleware); + } + + private static ITurnContext CreateTurnContext( + string activityType = "message", + string? activityName = null) + { + var mockActivity = new Mock(); + mockActivity.Setup(a => a.Type).Returns(activityType); + if (activityName != null) + { + mockActivity.Setup(a => a.Name).Returns(activityName); + } + mockActivity.Setup(a => a.Text).Returns("Hello"); + mockActivity.Setup(a => a.From).Returns(new ChannelAccount + { + Id = "caller-id", + Name = "Caller", + AadObjectId = "caller-aad", + }); + mockActivity.Setup(a => a.Recipient).Returns(new ChannelAccount + { + Id = "agent-id", + Name = "Agent", + TenantId = "tenant-123", + Role = "user", + }); + mockActivity.Setup(a => a.Conversation).Returns(new ConversationAccount { Id = "conv-id" }); + mockActivity.Setup(a => a.ServiceUrl).Returns("https://example.com"); + mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId("test-channel")); + + var mockTurnContext = new Mock(); + mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object); + + return mockTurnContext.Object; + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/OutputLoggingMiddlewareTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/OutputLoggingMiddlewareTests.cs new file mode 100644 index 00000000..e714917c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/OutputLoggingMiddlewareTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.Observability.Hosting.Middleware; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using Moq; + +namespace Microsoft.Agents.A365.Observability.Hosting.Tests.Middleware; + +[TestClass] +public class OutputLoggingMiddlewareTests +{ + [TestMethod] + public async Task OnTurnAsync_CallsNextDelegate() + { + // Arrange + var middleware = new OutputLoggingMiddleware(); + var turnContext = CreateTurnContext(); + + bool nextCalled = false; + NextDelegate next = (ct) => + { + nextCalled = true; + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + nextCalled.Should().BeTrue(); + } + + [TestMethod] + public async Task OnTurnAsync_RegistersSendHandler_WhenRecipientHasDetails() + { + // Arrange + var middleware = new OutputLoggingMiddleware(); + var mockTurnContext = new Mock(); + SetupTurnContext(mockTurnContext); + + NextDelegate next = (ct) => Task.CompletedTask; + + // Act + await middleware.OnTurnAsync(mockTurnContext.Object, next); + + // Assert + mockTurnContext.Verify(tc => tc.OnSendActivities(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task OnTurnAsync_PassesThrough_WhenRecipientIsNull() + { + // Arrange + var middleware = new OutputLoggingMiddleware(); + var mockActivity = new Mock(); + mockActivity.Setup(a => a.Recipient).Returns((ChannelAccount)null!); + mockActivity.Setup(a => a.Type).Returns("message"); + + var mockTurnContext = new Mock(); + mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object); + + bool nextCalled = false; + NextDelegate next = (ct) => + { + nextCalled = true; + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(mockTurnContext.Object, next); + + // Assert + nextCalled.Should().BeTrue(); + mockTurnContext.Verify(tc => tc.OnSendActivities(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task OnTurnAsync_PassesThrough_WhenTenantIdIsMissing() + { + // Arrange + var middleware = new OutputLoggingMiddleware(); + var mockActivity = new Mock(); + mockActivity.Setup(a => a.Type).Returns("message"); + mockActivity.Setup(a => a.Recipient).Returns(new ChannelAccount + { + Id = "agent-id", + Name = "Agent", + // No TenantId set + }); + + var mockTurnContext = new Mock(); + mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object); + + bool nextCalled = false; + NextDelegate next = (ct) => + { + nextCalled = true; + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(mockTurnContext.Object, next); + + // Assert + nextCalled.Should().BeTrue(); + mockTurnContext.Verify(tc => tc.OnSendActivities(It.IsAny()), Times.Never); + } + + private static ITurnContext CreateTurnContext() + { + var mockTurnContext = new Mock(); + SetupTurnContext(mockTurnContext); + return mockTurnContext.Object; + } + + private static void SetupTurnContext(Mock mockTurnContext) + { + var mockActivity = new Mock(); + mockActivity.Setup(a => a.Type).Returns("message"); + mockActivity.Setup(a => a.Text).Returns("Hello"); + mockActivity.Setup(a => a.From).Returns(new ChannelAccount + { + Id = "caller-id", + Name = "Caller", + AadObjectId = "caller-aad", + }); + mockActivity.Setup(a => a.Recipient).Returns(new ChannelAccount + { + Id = "agent-id", + Name = "Agent", + TenantId = "badf1f56-284d-4dc5-ac59-0dd53900e743", + Role = "agenticAppInstance", + }); + mockActivity.Setup(a => a.Conversation).Returns(new ConversationAccount { Id = "conv-id" }); + mockActivity.Setup(a => a.ServiceUrl).Returns("https://example.com"); + mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId("test-channel")); + + mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object); + mockTurnContext.Setup(tc => tc.StackState).Returns(new TurnContextStateCollection()); + mockTurnContext.Setup(tc => tc.OnSendActivities(It.IsAny())) + .Returns(mockTurnContext.Object); + } +}