// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Globalization;
using System.Text.Json;
using Microsoft.Teams.Core.Schema;
using OpenTelemetry;
namespace Microsoft.Teams.Core.Diagnostics;
///
/// Builds OpenTelemetry baggage for Agent365 export from Microsoft.Teams.Core activity types.
///
///
///
/// The Microsoft OpenTelemetry distro's Agent365 exporter stamps every emitted span with the
/// baggage entries set during a turn. This builder populates the cert-required keys
/// (microsoft.tenant.id, gen_ai.conversation.id, microsoft.channel.name, etc.)
/// from a without depending on the Apps-layer
/// TeamsConversationAccount. Use the Apps-layer builder
/// (Microsoft.Teams.Apps.Diagnostics.BaggageBuilder) when you have a
/// Context<TeamsActivity>; it adds the Apps-only keys (user.id, user.email,
/// microsoft.agent.user.email, gen_ai.agent.description).
///
///
/// Call to apply collected pairs to ; the returned
/// restores the previous baggage scope when disposed.
///
///
/// See core/docs/Observability-Design.md § "Agent365 baggage and the TurnContext mismatch"
/// for the full cert-attribute mapping.
///
///
public sealed class BaggageBuilder
{
private readonly Dictionary _pairs = new(StringComparer.Ordinal);
/// Sets the Microsoft Entra tenant id (microsoft.tenant.id). Required for cert.
public BaggageBuilder TenantId(string? v) => Set(AgentObservabilityKeys.TenantId, v);
/// Sets the conversation id (gen_ai.conversation.id). Required for cert.
public BaggageBuilder ConversationId(string? v) => Set(AgentObservabilityKeys.ConversationId, v);
/// Sets the conversation item link (microsoft.conversation.item.link). Optional.
public BaggageBuilder ConversationItemLink(string? v) => Set(AgentObservabilityKeys.ConversationItemLink, v);
/// Sets the channel name (microsoft.channel.name). Required for cert.
public BaggageBuilder ChannelName(string? v) => Set(AgentObservabilityKeys.ChannelName, v);
/// Sets the channel link (microsoft.channel.link). Optional. Not auto-populated by
/// — Teams's flat ChannelId string has no SubChannel concept.
public BaggageBuilder ChannelLink(string? v) => Set(AgentObservabilityKeys.ChannelLink, v);
/// Sets the agent id (gen_ai.agent.id). Required for cert.
public BaggageBuilder AgentId(string? v) => Set(AgentObservabilityKeys.AgentId, v);
/// Sets the agent display name (gen_ai.agent.name). Required for cert.
public BaggageBuilder AgentName(string? v) => Set(AgentObservabilityKeys.AgentName, v);
/// Sets the agentic user id (microsoft.agent.user.id). Required for cert.
public BaggageBuilder AgenticUserId(string? v) => Set(AgentObservabilityKeys.AgenticUserId, v);
/// Sets the agent blueprint id (microsoft.a365.agent.blueprint.id). Required for cert.
public BaggageBuilder AgentBlueprintId(string? v) => Set(AgentObservabilityKeys.AgentBlueprintId, v);
/// Sets the human user name (user.name). Optional.
public BaggageBuilder UserName(string? v) => Set(AgentObservabilityKeys.UserName, v);
/// Sets the operation source (service.name). Required for cert on server spans.
public BaggageBuilder OperationSource(string source) => Set(AgentObservabilityKeys.ServiceName, source);
/// Sets the InvokeAgent server address and (optional) port. Required for InvokeAgentScope cert.
/// The port is recorded only when different from the HTTPS default (443).
public BaggageBuilder InvokeAgentServer(string? address, int? port = null)
{
Set(AgentObservabilityKeys.ServerAddress, address);
if (port.HasValue && port.Value != 443)
{
Set(AgentObservabilityKeys.ServerPort, port.Value.ToString(CultureInfo.InvariantCulture));
}
return this;
}
/// Escape hatch for setting any baggage key not exposed by a typed setter
/// (e.g. user.id / user.email from a non-Apps auth pipeline,
/// or client.address derived in HTTP middleware).
public BaggageBuilder Set(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
{
_pairs[key] = value;
}
return this;
}
///
/// Populates every baggage key reachable from . Falls back to parsing
/// Properties["channelData"] JSON for tenant.id when Recipient.TenantId is null
/// (classic Bot Framework Teams traffic carries tenant id in channelData, not on the recipient).
///
public BaggageBuilder FromCoreActivity(CoreActivity activity)
{
ArgumentNullException.ThrowIfNull(activity);
ConversationId(activity.Conversation?.Id);
ConversationItemLink(activity.ServiceUrl?.ToString());
ChannelName(activity.ChannelId);
UserName(activity.From?.Name);
ConversationAccount? recipient = activity.Recipient;
if (recipient is not null)
{
AgentId(string.IsNullOrWhiteSpace(recipient.AgenticAppId) ? recipient.Id : recipient.AgenticAppId);
AgentName(recipient.Name);
AgenticUserId(recipient.AgenticUserId);
AgentBlueprintId(recipient.AgenticAppBlueprintId);
TenantId(recipient.TenantId);
}
// Tenant fallback: if Recipient.TenantId is empty, try channelData.tenant.id.
if (!_pairs.ContainsKey(AgentObservabilityKeys.TenantId))
{
string? channelTenantId = TryReadChannelDataTenantId(activity);
if (!string.IsNullOrWhiteSpace(channelTenantId))
{
TenantId(channelTenantId);
}
}
return this;
}
///
/// Applies the collected pairs to and returns an
/// that restores the previous baggage when disposed.
///
public IDisposable Build()
{
Baggage previous = Baggage.Current;
foreach (KeyValuePair kvp in _pairs)
{
Baggage.Current = Baggage.Current.SetBaggage(kvp.Key, kvp.Value);
}
return new RestoreScope(previous);
}
private static string? TryReadChannelDataTenantId(CoreActivity activity)
{
if (!activity.Properties.TryGetValue("channelData", out object? channelData) || channelData is null)
{
return null;
}
try
{
JsonElement root = channelData switch
{
JsonElement je => je,
_ => JsonSerializer.SerializeToElement(channelData),
};
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("tenant", out JsonElement tenant) &&
tenant.ValueKind == JsonValueKind.Object &&
tenant.TryGetProperty("id", out JsonElement id) &&
id.ValueKind == JsonValueKind.String)
{
return id.GetString();
}
}
catch (JsonException)
{
// Best-effort fallback; ignore malformed channelData.
}
return null;
}
private sealed class RestoreScope(Baggage previous) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed)
{
return;
}
Baggage.Current = previous;
_disposed = true;
}
}
}