Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Observability/Hosting/Middleware/BaggageTurnMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Bot Framework middleware that propagates OpenTelemetry baggage context
/// derived from <see cref="ITurnContext"/>.
/// </summary>
/// <remarks>
/// Async replies (ContinueConversation events) are passed through without
/// baggage setup because their context is established by the originating turn.
/// </remarks>
public sealed class BaggageTurnMiddleware : IMiddleware
{
/// <inheritdoc/>
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);
}
}
}
}
219 changes: 219 additions & 0 deletions src/Observability/Hosting/Middleware/OutputLoggingMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// 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
{
/// <summary>
/// Bot Framework middleware that creates <see cref="OutputScope"/> spans
/// for outgoing messages.
/// </summary>
/// <remarks>
/// <para>
/// Links to a parent span when <see cref="A365ParentSpanKey"/> is set in
/// <see cref="ITurnContext.StackState"/>.
/// </para>
/// <para>
/// <b>Privacy note:</b> Outgoing message content is captured verbatim as
/// span attributes and exported to the configured telemetry backend.
/// </para>
/// </remarks>
public sealed class OutputLoggingMiddleware : IMiddleware
{
/// <summary>
/// The <see cref="ITurnContext.StackState"/> key used to store the parent
/// span reference. Set this value to a W3C traceparent string
/// (e.g. <c>"00-{trace_id}-{span_id}-{trace_flags}"</c>) to link
/// <see cref="OutputScope"/> spans as children of an
/// <see cref="InvokeAgentScope"/>.
/// </summary>
public const string A365ParentSpanKey = "A365ParentSpanId";

/// <inheritdoc/>
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();
}
}
Comment on lines +136 to +142

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<string>();
foreach (var a in activities)
{
if (string.Equals(a.Type, ActivityTypes.Message, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(a.Text))
{
messages.Add(a.Text);
}
}
Comment on lines +159 to +166

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.ContainsKey(A365ParentSpanKey))
{
parentId = turnContext.StackState[A365ParentSpanKey]?.ToString();
Comment on lines +175 to +177
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Reading A365ParentSpanKey via ContainsKey(...) followed by the indexer performs two dictionary lookups and can race if the key is removed between calls. Prefer a single read (e.g., TryGetValue) and then ToString() on the retrieved value.

Suggested change
if (turnContext.StackState.ContainsKey(A365ParentSpanKey))
{
parentId = turnContext.StackState[A365ParentSpanKey]?.ToString();
if (turnContext.StackState.TryGetValue(A365ParentSpanKey, out var parentSpanValue) && parentSpanValue is not null)
{
parentId = parentSpanValue.ToString();

Copilot uses AI. Check for mistakes.
}

var outputScope = OutputScope.Start(
agentDetails: agentDetails,
tenantDetails: tenantDetails,
response: new Response(messages),
parentId: parentId);

try
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiConversationIdKey, conversationId);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiExecutionTypeKey, executionType);

if (sourceMetadata != null)
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiChannelNameKey, sourceMetadata.Name);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiChannelLinkKey, sourceMetadata.Description);
}

if (callerDetails != null)
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerIdKey, callerDetails.CallerId);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerUpnKey, callerDetails.CallerUpn);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerNameKey, callerDetails.CallerName);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerTenantIdKey, callerDetails.TenantId);
}

Comment on lines +184 to +204
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

OutputScope.Start(...) (via OpenTelemetryScope) already accepts conversationId, sourceMetadata, and callerDetails and will apply the corresponding tags in one place. The current implementation passes those as null and then manually sets the same tags, which duplicates logic and risks drifting if tag behavior changes. Consider passing these values into OutputScope.Start and only setting tags here for values that aren’t covered by the scope constructor (e.g., execution type).

Suggested change
parentId: parentId);
try
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiConversationIdKey, conversationId);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiExecutionTypeKey, executionType);
if (sourceMetadata != null)
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiChannelNameKey, sourceMetadata.Name);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiChannelLinkKey, sourceMetadata.Description);
}
if (callerDetails != null)
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerIdKey, callerDetails.CallerId);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerUpnKey, callerDetails.CallerUpn);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerNameKey, callerDetails.CallerName);
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiCallerTenantIdKey, callerDetails.TenantId);
}
conversationId: conversationId,
sourceMetadata: sourceMetadata,
callerDetails: callerDetails,
parentId: parentId);
try
{
outputScope.SetTagMaybe(OpenTelemetryConstants.GenAiExecutionTypeKey, executionType);

Copilot uses AI. Check for mistakes.
return await nextSend().ConfigureAwait(false);
}
catch (Exception ex)
{
outputScope.RecordError(ex);
throw;
}
finally
{
outputScope.Dispose();
}
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="coverlet.collector" />
Comment on lines 9 to 12
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

PR metadata says this change only reverts the UseObservabilityMiddleware extension and restores docs, but this PR also introduces new middleware (BaggageTurnMiddleware, OutputLoggingMiddleware) plus new tests and a new Moq dependency. Please update the PR title/description to reflect these additional additions, or split them into a separate PR so the revert can be reviewed/merged independently.

Copilot uses AI. Check for mistakes.
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="MSTest.TestAdapter" />
Expand Down
Loading