// 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; } } }