microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/docs/Observability-Design.md
371lines · modecode
| 1 | # Observability Design |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | The Teams .NET SDK (`Microsoft.Teams.Core`, `Microsoft.Teams.Apps`, `Microsoft.Teams.Apps.BotBuilder`) emits OpenTelemetry-compatible traces, metrics, and logs so that consuming bots can wire observability through the [Microsoft OpenTelemetry distro](https://github.com/microsoft/opentelemetry-distro-dotnet) and ship telemetry to Azure Monitor, an OTLP collector (Aspire Dashboard, Grafana LGTM, Jaeger), or the console. |
| 6 | |
| 7 | The SDK uses the BCL `System.Diagnostics.ActivitySource` and `System.Diagnostics.Metrics.Meter` for the trace and metric APIs. The OpenTelemetry SDK and exporters are an application concern: the bot project references `Microsoft.OpenTelemetry`, subscribes to the SDK's source/meter by name, and configures exporters. `Microsoft.Teams.Apps` takes a dependency on `OpenTelemetry.Api` so the `TeamsBaggageBuilder` can write to `OpenTelemetry.Baggage.Current` (see "Dependency impact" below). |
| 8 | |
| 9 | ``` |
| 10 | Consuming bot Teams SDK (this design) |
| 11 | ───────────── ─────────────────────── |
| 12 | .UseMicrosoftOpenTelemetry(...) |
| 13 | ActivitySource("Microsoft.Teams.Core") |
| 14 | .WithTracing(t => t ├─ "turn" (BotApplication.ProcessAsync) |
| 15 | .AddSource(CoreTelemetryNames ├─ "middleware" (TurnMiddleware.RunPipelineAsync) |
| 16 | .ActivitySourceName) ├─ "auth.outbound" (BotAuthenticationHandler) |
| 17 | .AddSource( └─ "conversation_client" (ConversationClient send/update/delete) |
| 18 | TeamsBotApplicationTelemetry |
| 19 | .ActivitySourceName)) ActivitySource("Microsoft.Teams.Apps") |
| 20 | └─ "handler" (Router.DispatchAsync) |
| 21 | .WithMetrics(m => m |
| 22 | .AddMeter(CoreTelemetryNames Meter("Microsoft.Teams.Core") |
| 23 | .MeterName) ├─ teams.activities.received (Counter) |
| 24 | .AddMeter( ├─ teams.turn.duration (Histogram, ms) |
| 25 | TeamsBotApplicationTelemetry ├─ teams.handler.errors (Counter) |
| 26 | .MeterName)); ├─ teams.middleware.duration (Histogram, ms) |
| 27 | ├─ teams.outbound.calls (Counter) |
| 28 | └─ teams.outbound.errors (Counter) |
| 29 | |
| 30 | Meter("Microsoft.Teams.Apps") |
| 31 | ├─ teams.handler.dispatched (Counter) |
| 32 | ├─ teams.handler.duration (Histogram, ms) |
| 33 | ├─ teams.handler.failures (Counter) |
| 34 | └─ teams.handler.unmatched (Counter) |
| 35 | ``` |
| 36 | |
| 37 | ## Layering constraints |
| 38 | |
| 39 | The SDK is split across two assemblies that observability must respect: |
| 40 | |
| 41 | - `Microsoft.Teams.Core` is the lower layer. It owns `BotApplication`, the turn pipeline (`TurnMiddleware`), the outbound HTTP clients (`ConversationClient`, `UserTokenClient`), and the auth-handler (`BotAuthenticationHandler`). It must **not reference anything in `Microsoft.Teams.Apps`**, including no string literals or constants tied to the Apps brand. |
| 42 | - `Microsoft.Teams.Apps` depends on Core. It owns the typed activity model, `TeamsBotApplication`, and the `Router` that dispatches to user handlers. |
| 43 | |
| 44 | Telemetry follows the same rule: **each assembly publishes its own ActivitySource and Meter, named after the assembly.** A class named `TeamsBotApplicationTelemetry` describes Apps-level telemetry; it lives in Apps. Core's analogue is `CoreTelemetryNames`. Neither references the other. |
| 45 | |
| 46 | | Layer | Public name class | Source / Meter name | Spans | Metrics | |
| 47 | |---|---|---|---|---| |
| 48 | | `Microsoft.Teams.Core` | `Microsoft.Teams.Core.Diagnostics.CoreTelemetryNames` | `"Microsoft.Teams.Core"` | `turn`, `middleware`, `auth.outbound`, `conversation_client` | `teams.activities.received`, `teams.turn.duration`, `teams.handler.errors`, `teams.middleware.duration`, `teams.outbound.calls`, `teams.outbound.errors` | |
| 49 | | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry` | `"Microsoft.Teams.Apps"` | `handler` | `teams.handler.dispatched`, `teams.handler.duration`, `teams.handler.failures`, `teams.handler.unmatched` | |
| 50 | |
| 51 | Cross-assembly use is one-way: Apps's `Router` may call Core utilities (for example, the public `RecordException` extension on `Activity` defined in `Microsoft.Teams.Core.Diagnostics.ActivityExtensions`), but Core never reaches up into Apps. If a future Core-level helper would need an Apps concept, that helper belongs in Apps, not in Core. |
| 52 | |
| 53 | A consumer that uses both layers (the common case) registers both names. A consumer that only references Core (a minimal `BotApplication` bot without the `TeamsBotApplication` router) registers just `CoreTelemetryNames` and gets the full Core-level signal. |
| 54 | |
| 55 | ## Public surface |
| 56 | |
| 57 | ```csharp |
| 58 | namespace Microsoft.Teams.Core.Diagnostics; |
| 59 | public static class CoreTelemetryNames |
| 60 | { |
| 61 | public const string ActivitySourceName = "Microsoft.Teams.Core"; |
| 62 | public const string MeterName = "Microsoft.Teams.Core"; |
| 63 | } |
| 64 | |
| 65 | namespace Microsoft.Teams.Apps.Diagnostics; |
| 66 | public static class TeamsBotApplicationTelemetry |
| 67 | { |
| 68 | public const string ActivitySourceName = "Microsoft.Teams.Apps"; |
| 69 | public const string MeterName = "Microsoft.Teams.Apps"; |
| 70 | } |
| 71 | ``` |
| 72 | |
| 73 | The matching internal singletons live in each assembly's `Diagnostics/` folder: |
| 74 | - `Microsoft.Teams.Core/Diagnostics/Telemetry.cs` — owned by Core; internal to `Microsoft.Teams.Core` (Apps has its own `AppsTelemetry` class). |
| 75 | - `Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs` — owned by Apps; the class is named `AppsTelemetry` to avoid collision with the Core `Telemetry` class when both namespaces are imported. |
| 76 | |
| 77 | ## Spans |
| 78 | |
| 79 | The auto-instrumented HTTP-server span (from the OTel distro's ASP.NET Core instrumentation) is the parent of `turn`. Outbound HTTP-client spans (from the distro's HttpClient instrumentation) are children of `auth.outbound` and `conversation_client` automatically because the SDK opens the span before the underlying HTTP call. The `handler` span (from Apps) nests inside `turn` (from Core) via the ambient `Activity.Current`, even though the two spans come from different sources. |
| 80 | |
| 81 | | Span | Source | Where | Tags | |
| 82 | |---|---|---|---| |
| 83 | | `turn` | Core | `Microsoft.Teams.Core/BotApplication.cs` `ProcessAsync` body, after the request body has been deserialized into a `CoreActivity` | `activity.type`, `activity.id`, `conversation.id`, `channel.id`, `bot.id`, `service.url` | |
| 84 | | `middleware` | Core | `Microsoft.Teams.Core/TurnMiddleware.cs` `RunPipelineAsync` per-middleware execution | `middleware.name`, `middleware.index` | |
| 85 | | `handler` | Apps | `Microsoft.Teams.Apps/Routing/Router.cs` `DispatchAsync` matched-route invocation | `handler.type` (activity type or invoke name), `handler.dispatch` (`type` / `invoke` / `catchall`) | |
| 86 | | `auth.outbound` | Core | `Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs` `GetAuthorizationHeaderAsync` | `auth.flow` (`agentic` / `app_only` / `managed_identity`) | |
| 87 | | `conversation_client` | Core | `Microsoft.Teams.Core/ConversationClient.cs` `SendActivityAsync` / `UpdateActivityAsync` / `DeleteActivityAsync` | `service.url`, `conversation.id`, `activity.type`, `activity.id` (set after response when known), `operation` | |
| 88 | |
| 89 | On exception every span sets `Status = Error` with the exception message and adds an `exception` span event with `exception.type`, `exception.message`, and `exception.stacktrace` tags. This is done through the `RecordException` extension method in `Microsoft.Teams.Core.Diagnostics.ActivityExtensions`, which is `public` so the Apps layer (`Router`) can use it too. The extension uses manual event tagging on both `net8.0` and `net10.0` to stay consistent across target frameworks; it intentionally does not delegate to the BCL `Activity.AddException` (added in .NET 9), because that API only adds the event without setting `ActivityStatusCode.Error`. |
| 90 | |
| 91 | ### `auth.inbound` is intentionally omitted |
| 92 | |
| 93 | The `auth.inbound` span belongs to the auth middleware, not the bot pipeline. The SDK uses `Microsoft.AspNetCore.Authentication.JwtBearer` for inbound auth, which is already covered by the OTel distro's ASP.NET Core HTTP-server instrumentation. Adding a separate inbound-auth span would duplicate signal without new information; it is out of scope for this design. |
| 94 | |
| 95 | ## Metrics |
| 96 | |
| 97 | Core-meter instruments cover the turn pipeline, middleware, and outbound HTTP clients. Apps-meter instruments cover router dispatch (one observation per matched route). |
| 98 | |
| 99 | ### Core meter (`Microsoft.Teams.Core`) |
| 100 | |
| 101 | | Metric | Kind | Unit | Tags | Where | |
| 102 | |---|---|---|---|---| |
| 103 | | `teams.activities.received` | Counter | — | `activity.type` | top of `BotApplication.ProcessAsync` | |
| 104 | | `teams.turn.duration` | Histogram | ms | `activity.type` | `finally` of the `turn` span | |
| 105 | | `teams.handler.errors` | Counter | — | `activity.type` | catch block in `BotApplication.ProcessAsync` | |
| 106 | | `teams.middleware.duration` | Histogram | ms | `middleware.name` | `finally` of the `middleware` span | |
| 107 | | `teams.outbound.calls` | Counter | — | `operation` ∈ {`sendActivity`, `updateActivity`, `deleteActivity`} | success branch of `ConversationClient` calls | |
| 108 | | `teams.outbound.errors` | Counter | — | `operation` | exception branch of `ConversationClient` calls | |
| 109 | |
| 110 | ### Apps meter (`Microsoft.Teams.Apps`) |
| 111 | |
| 112 | | Metric | Kind | Unit | Tags | Where | |
| 113 | |---|---|---|---|---| |
| 114 | | `teams.handler.dispatched` | Counter | — | `handler.type`, `handler.dispatch` | `Router.DispatchAsync` / `DispatchWithReturnAsync` before each matched-route invocation | |
| 115 | | `teams.handler.duration` | Histogram | ms | `handler.type`, `handler.dispatch` | `finally` block around each matched-route invocation (recorded even on exception) | |
| 116 | | `teams.handler.failures` | Counter | — | `handler.type`, `handler.dispatch` | catch block when a route handler throws | |
| 117 | | `teams.handler.unmatched` | Counter | — | `activity.type` (DispatchAsync) or `activity.type` + `invoke.name` (DispatchWithReturnAsync) | branch where no route selector matched | |
| 118 | |
| 119 | OTLP exposes these names with dots; Prometheus/Mimir maps them to `teams_*_total` (counters) and `teams_*_milliseconds_*` (histograms). |
| 120 | |
| 121 | ## Logs |
| 122 | |
| 123 | The OTel distro's `UseMicrosoftOpenTelemetry()` automatically wires `ILogger` to OTel log records and stamps every record with the active `Activity` trace and span IDs. Existing `BotApplication._logger.BeginActivityScope(...)` already adds `ActivityType` / `ActivityId` / `ServiceUrl` / `MSCV` to the scope dictionary, so those fields ride along on every log record produced inside a turn. **No SDK changes are required for logs.** |
| 124 | |
| 125 | The TODO at `Microsoft.Teams.Core/BotApplication.cs:202` (`// TODO: Replace with structured scope data, ensure it works with OpenTelemetry...`) is resolved by this design and is removed. |
| 126 | |
| 127 | ## Consumer integration |
| 128 | |
| 129 | ```csharp |
| 130 | using Microsoft.OpenTelemetry; |
| 131 | using Microsoft.Teams.Apps.Diagnostics; |
| 132 | using Microsoft.Teams.Core.Diagnostics; |
| 133 | |
| 134 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args); |
| 135 | |
| 136 | builder.Services.AddOpenTelemetry() |
| 137 | .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.AzureMonitor | ExportTarget.Otlp) |
| 138 | .WithTracing(t => t |
| 139 | .AddSource(CoreTelemetryNames.ActivitySourceName) |
| 140 | .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) |
| 141 | .WithMetrics(m => m |
| 142 | .AddMeter(CoreTelemetryNames.MeterName) |
| 143 | .AddMeter(TeamsBotApplicationTelemetry.MeterName)); |
| 144 | |
| 145 | builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); |
| 146 | ``` |
| 147 | |
| 148 | Standard OpenTelemetry environment variables (`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `APPLICATIONINSIGHTS_CONNECTION_STRING`, `OTEL_TRACES_SAMPLER`, `OTEL_TRACES_SAMPLER_ARG`) are honored by the distro without any SDK code. |
| 149 | |
| 150 | A working sample lives at `core/samples/ObservabilityBot/` with a README that documents the local Grafana LGTM container loop. |
| 151 | |
| 152 | ## Span tree per turn |
| 153 | |
| 154 | ``` |
| 155 | HTTP server span (auto, OTel ASP.NET Core) |
| 156 | └─ turn (Microsoft.Teams.Core) |
| 157 | ├─ middleware [n times] (Microsoft.Teams.Core) |
| 158 | ├─ handler (Microsoft.Teams.Apps) |
| 159 | └─ conversation_client (Microsoft.Teams.Core) |
| 160 | ├─ auth.outbound (Microsoft.Teams.Core) |
| 161 | │ └─ HTTP client span (auto, OTel HttpClient — token endpoint) |
| 162 | └─ HTTP client span (auto, OTel HttpClient — Bot Service API) |
| 163 | ``` |
| 164 | |
| 165 | ## Agent365 baggage and the TurnContext mismatch |
| 166 | |
| 167 | When the consuming bot also exports to **Agent365** (`ExportTarget.Agent365` in the Microsoft OpenTelemetry distro), the Agent365 SDK certifies on a fixed set of OpenTelemetry baggage entries that decorate every span emitted from a turn. The distro ships three helpers in `Microsoft.Agents.A365.Observability.Hosting.Extensions` that pull these from a turn context — `BaggageBuilderExtensions.FromTurnContext(ITurnContext)`, `InvokeAgentScopeExtensions.FromTurnContext(ITurnContext)`, and `TurnContextExtensions.InjectObservabilityContext(ITurnContext, OpenTelemetryScope)`. |
| 168 | |
| 169 | **These helpers take `Microsoft.Agents.Builder.ITurnContext`. The Teams SDK does not produce an `ITurnContext`** — the Apps layer hands handlers a `Microsoft.Teams.Apps.Context<TeamsActivity>`, and the Core layer has no per-turn context type at all. The two activity object models are also subtly different (see field map below). A consumer cannot pass a Teams context into `FromTurnContext(...)` directly. |
| 170 | |
| 171 | We deliberately do **not** wrap `ITurnContext`: synthesizing a fake Activity shape (with `ChannelId.Channel` / `ChannelId.SubChannel` sub-properties, `StackState` dictionary, `ServiceUrl` as string) drags in `Microsoft.Agents.Builder` and is brittle to upstream changes. Instead, the Apps layer ships `TeamsBaggageBuilder` that reads directly off Teams types. See "Bridging strategy" below. |
| 172 | |
| 173 | ### Agent365 certification — crisp definition |
| 174 | |
| 175 | Authoritative source: `https://github.com/microsoft/opentelemetry-distro-dotnet/blob/main/docs/agent365-getting-started.md` § "Validate for store publishing". |
| 176 | |
| 177 | Two requirements gate Agent365 store publishing: |
| 178 | |
| 179 | #### (1) Scope coverage |
| 180 | |
| 181 | The agent **must implement** the following scopes via the Agent365 SDK (`Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes`): |
| 182 | |
| 183 | | Scope | When to start | Required for publishing? | |
| 184 | |---|---|---| |
| 185 | | `InvokeAgentScope` | Top of agent processing | **Yes** | |
| 186 | | `InferenceScope` | Around each LLM call | **Yes** | |
| 187 | | `ExecuteToolScope` | Around each tool / function call | **Yes** | |
| 188 | | `OutputScope` | Optional — for async output capture | No | |
| 189 | |
| 190 | Auto-instrumentation (Semantic Kernel, OpenAI, Azure OpenAI, Agent Framework) emits **inference** spans automatically, but `InvokeAgentScope` and `ExecuteToolScope` must be started by the agent. |
| 191 | |
| 192 | #### (2) Attribute coverage |
| 193 | |
| 194 | Every Required attribute on each scope must be **non-null at scope close**. The bulk of these come from baggage (set once per turn via `TeamsBaggageBuilder`); a handful are scope-specific and come from `ScopeDetails` / `Record*` methods. |
| 195 | |
| 196 | **Common Required attributes (all scopes):** |
| 197 | |
| 198 | | Key | Where it comes from | |
| 199 | |---|---| |
| 200 | | `microsoft.tenant.id` | `TeamsBaggageBuilder.TenantId(...)` | |
| 201 | | `gen_ai.agent.id` | `TeamsBaggageBuilder.AgentId(...)` | |
| 202 | | `gen_ai.agent.name` | `TeamsBaggageBuilder.AgentName(...)` | |
| 203 | | `microsoft.a365.agent.blueprint.id` | `TeamsBaggageBuilder.AgentBlueprintId(...)` | |
| 204 | | `microsoft.agent.user.id` | `TeamsBaggageBuilder.AgenticUserId(...)` | |
| 205 | | `microsoft.agent.user.email` | `TeamsBaggageBuilder.AgenticUserEmail(...)` | |
| 206 | | `client.address` | Caller-supplied (HTTP request remote IP) | |
| 207 | | `user.id` | `TeamsBaggageBuilder.UserId(...)` | |
| 208 | | `user.email` | `TeamsBaggageBuilder.UserEmail(...)` | |
| 209 | | `microsoft.channel.name` | `TeamsBaggageBuilder.ChannelName(...)` | |
| 210 | | `gen_ai.conversation.id` | `TeamsBaggageBuilder.ConversationId(...)` | |
| 211 | | `gen_ai.operation.name` | Set by the scope automatically | |
| 212 | |
| 213 | **Scope-specific Required attributes:** |
| 214 | |
| 215 | | Scope | Additional Required attributes | Source | |
| 216 | |---|---|---| |
| 217 | | `InvokeAgentScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `server.address`, `server.port` | `scope.RecordInputMessages(...)` / `RecordOutputMessages(...)` + `TeamsBaggageBuilder.InvokeAgentServer(host, port)` | |
| 218 | | `ExecuteToolScope` | `gen_ai.tool.call.arguments`, `gen_ai.tool.call.id`, `gen_ai.tool.call.result`, `gen_ai.tool.name`, `gen_ai.tool.type` | `ToolCallDetails` + `scope.RecordResponse(...)` | |
| 219 | | `InferenceScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.provider.name`, `gen_ai.request.model` | `InferenceCallDetails` + `RecordInputMessages` / `RecordOutputMessages` | |
| 220 | | `OutputScope` | `gen_ai.output.messages` | `Response` constructor | |
| 221 | |
| 222 | **Optional (recommended but not gating):** `gen_ai.agent.description`, `gen_ai.agent.version`, `microsoft.a365.agent.platform.id`, `microsoft.session.id`, `microsoft.session.description`, `microsoft.conversation.item.link`, `microsoft.channel.link`, all `microsoft.a365.caller.agent.*`, `microsoft.a365.agent.thought.process` (InferenceScope only). |
| 223 | |
| 224 | **Out of scope of this SDK:** the scope objects themselves (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, `OutputScope`) ship in `Microsoft.OpenTelemetry`. The Teams SDK only ships the `TeamsBaggageBuilder` (in Apps) that populates the cert-required baggage; agents create the scopes themselves at the appropriate boundaries. |
| 225 | |
| 226 | ### Required baggage map (Teams activity → Agent365 keys) |
| 227 | |
| 228 | | Group | Key (Agent365 wire) | Required for cert? | Source field on the Teams activity | |
| 229 | |---|---|---|---| |
| 230 | | Tenant | `microsoft.tenant.id` | **Yes** | `Activity.Recipient.TenantId` (typed on Apps's `TeamsConversationAccount`); fallback `Activity.ChannelData.tenant.id` (typed on Apps's `TeamsChannelData`) | |
| 231 | | Conversation | `gen_ai.conversation.id` | **Yes** | `Activity.Conversation.Id` | |
| 232 | | Conversation | `microsoft.conversation.item.link` | Optional | `Activity.ServiceUrl?.ToString()` | |
| 233 | | Channel | `microsoft.channel.name` | **Yes** | `Activity.ChannelId` (the whole string — `"msteams"`, `"webchat"`, …) | |
| 234 | | Channel | `microsoft.channel.link` | Optional | No equivalent on the Teams activity — see "Channel / SubChannel mapping" below | |
| 235 | | Caller (human) | `user.id` | **Yes** | `((TeamsConversationAccount)Activity.From).AadObjectId` (Apps-only) | |
| 236 | | Caller (human) | `user.name` | Optional | `Activity.From.Name` | |
| 237 | | Caller (human) | `user.email` | **Yes** | `((TeamsConversationAccount)Activity.From).Email` (Apps-only) | |
| 238 | | Target agent | `gen_ai.agent.id` | **Yes** | `Activity.Recipient.AgenticAppId ?? Activity.Recipient.Id` | |
| 239 | | Target agent | `gen_ai.agent.name` | **Yes** | `Activity.Recipient.Name` | |
| 240 | | Target agent | `microsoft.agent.user.id` | **Yes** | `Activity.Recipient.AgenticUserId` | |
| 241 | | Target agent | `microsoft.agent.user.email` | **Yes** | `((TeamsConversationAccount)Activity.Recipient).Email` (Apps-only) | |
| 242 | | Target agent | `gen_ai.agent.description` | Optional | `((TeamsConversationAccount)Activity.Recipient).UserRole` (Apps-only) | |
| 243 | | Target agent | `microsoft.a365.agent.blueprint.id` | **Yes** | `Activity.Recipient.AgenticAppBlueprintId` | |
| 244 | | Operation source | `service.name` (set via `TeamsBaggageBuilder.OperationSource`) | **Yes** (server spans) | Caller-supplied constant (e.g. `"teams-bot"`) | |
| 245 | |
| 246 | The fields the Agent365 helpers also access that have **no Teams equivalent**: |
| 247 | |
| 248 | - `turnContext.StackState[O11ySpanId / O11yTraceId]` — Teams's `Context<TActivity>` has no `StackState` dictionary. Reading the active span/trace id later in the turn must go through `Activity.Current?.SpanId` / `Activity.Current?.TraceId` instead. `InjectObservabilityContext` is therefore not portable as-is. |
| 249 | |
| 250 | ### Channel / SubChannel mapping |
| 251 | |
| 252 | The upstream `BaggageBuilderExtensions.FromTurnContext` (in Agent Builder) reads `Activity.ChannelId.Channel` and `Activity.ChannelId.SubChannel` — Agent Builder's `ChannelId` is a complex object. Teams's `ChannelId` is a **plain string** (`"msteams"`, `"webchat"`, …) and has no `SubChannel` concept. Resolution: |
| 253 | |
| 254 | | Agent365 baggage key | Teams source | Auto-populated by `FromTeamsContext`? | |
| 255 | |---|---|---| |
| 256 | | `microsoft.channel.name` (Required) | `Activity.ChannelId` (the whole string) | **Yes** | |
| 257 | | `microsoft.channel.link` (Optional in all four cert scopes) | No equivalent on the Teams activity | **No** — left unset by the extractor | |
| 258 | |
| 259 | `microsoft.channel.link` is **Optional** in every cert-scope manifest, so leaving it unset does not block certification. The `ChannelLink(string?)` fluent setter remains on `TeamsBaggageBuilder` for callers who do have a meaningful sub-channel value (for example, derived in HTTP middleware before the bot pipeline runs, or supplied from configuration). |
| 260 | |
| 261 | We deliberately avoid synthesizing `ChannelLink` from `TeamsChannelData.Channel.Id` (the Teams team/channel id) or from `ServiceUrl`: the upstream semantics of `microsoft.channel.link` is "the sub-channel within the channel" (`M365CopilotSubChannel`-style routing), which is a different concept from a Teams channel id. Misclassifying these would mis-categorize spans in Agent365 dashboards. |
| 262 | |
| 263 | ### `TenantId` on `TeamsConversationAccount` (Apps only) |
| 264 | |
| 265 | `TenantId` is a typed property on Apps's `TeamsConversationAccount`: |
| 266 | |
| 267 | ```csharp |
| 268 | // Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs |
| 269 | [JsonPropertyName("tenantId")] |
| 270 | public string? TenantId { get; set; } |
| 271 | ``` |
| 272 | |
| 273 | Core's `ConversationAccount` does **not** carry `TenantId` — the property lives only in Apps. A future follow-up could promote it to Core to support Core-only bots, but for now Agent365 baggage is only auto-populated at the Apps layer via `TeamsBaggageBuilder`. |
| 274 | |
| 275 | **Wire-format note:** classic Bot Framework Teams traffic carries tenant id in `channelData.tenant.id`, **not** at `from.tenantId` / `recipient.tenantId`. `TeamsBaggageBuilder.FromTeamsContext` falls back to `TeamsActivity.ChannelData?.Tenant?.Id` when the typed `Recipient.TenantId` field is null. |
| 276 | |
| 277 | ### Bridging strategy |
| 278 | |
| 279 | **Single baggage builder in Apps.** After simplification, only `TeamsBaggageBuilder` (in `Microsoft.Teams.Apps`) exists. An earlier design called for a parallel `CoreBaggageBuilder` in Core, but it was descoped: the `OpenTelemetry.Api` dependency lives in Apps (not Core), and all current Agent365-integrated bots use the Apps layer. Core-only bots that need Agent365 baggage can use the `TeamsBaggageBuilder.Set(key, value)` escape hatch or add a Core-level builder in a future iteration. |
| 280 | |
| 281 | `TeamsBaggageBuilder` populates all cert-required keys reachable from `TeamsActivity` + `TeamsConversationAccount`: |
| 282 | |
| 283 | | Field set | Source | |
| 284 | |---|---| |
| 285 | | `microsoft.tenant.id`, `gen_ai.conversation.id`, `microsoft.conversation.item.link`, `microsoft.channel.name`, `gen_ai.agent.id`, `gen_ai.agent.name`, `microsoft.agent.user.id` (from `AgenticUserId`), `microsoft.a365.agent.blueprint.id`, `user.name`, `service.name`, `server.address`, `server.port` | `TeamsActivity` + `ConversationAccount` base fields | |
| 286 | | `user.id` (from `AadObjectId`), `user.email`, `gen_ai.agent.description` (from `UserRole`), `microsoft.agent.user.email` | `TeamsConversationAccount`-only fields | |
| 287 | |
| 288 | #### Public surface |
| 289 | |
| 290 | ```csharp |
| 291 | // Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs (public) |
| 292 | namespace Microsoft.Teams.Apps.Diagnostics; |
| 293 | |
| 294 | public sealed class TeamsBaggageBuilder |
| 295 | { |
| 296 | // Keys reachable from ConversationAccount base fields: |
| 297 | public TeamsBaggageBuilder TenantId(string? v); |
| 298 | public TeamsBaggageBuilder ConversationId(string? v); |
| 299 | public TeamsBaggageBuilder ConversationItemLink(string? v); |
| 300 | public TeamsBaggageBuilder ChannelName(string? v); |
| 301 | public TeamsBaggageBuilder ChannelLink(string? v); |
| 302 | public TeamsBaggageBuilder AgentId(string? v); |
| 303 | public TeamsBaggageBuilder AgentName(string? v); |
| 304 | public TeamsBaggageBuilder AgenticUserId(string? v); |
| 305 | public TeamsBaggageBuilder AgentBlueprintId(string? v); |
| 306 | public TeamsBaggageBuilder UserName(string? v); |
| 307 | public TeamsBaggageBuilder OperationSource(string source); |
| 308 | public TeamsBaggageBuilder InvokeAgentServer(string? address, int? port = null); |
| 309 | public TeamsBaggageBuilder Set(string key, string? value); // escape hatch |
| 310 | |
| 311 | // Keys whose source field only exists on TeamsConversationAccount: |
| 312 | public TeamsBaggageBuilder UserId(string? v); // From.AadObjectId |
| 313 | public TeamsBaggageBuilder UserEmail(string? v); // From.Email |
| 314 | public TeamsBaggageBuilder AgentDescription(string? v); // Recipient.UserRole |
| 315 | public TeamsBaggageBuilder AgenticUserEmail(string? v); // Recipient.Email |
| 316 | |
| 317 | public TeamsBaggageBuilder FromTeamsContext<TActivity>(Context<TActivity> ctx) |
| 318 | where TActivity : TeamsActivity; |
| 319 | |
| 320 | public IDisposable Build(); // applies pairs to OpenTelemetry.Baggage.Current; returns restore-scope |
| 321 | } |
| 322 | ``` |
| 323 | |
| 324 | The Agent365 wire keys are defined as `internal const` strings in `Microsoft.Teams.Apps.Diagnostics.AgentObservabilityKeys`. They are kept in sync against the upstream Agent365 spec and are not part of the public API. |
| 325 | |
| 326 | #### SDK-level wiring |
| 327 | |
| 328 | `TeamsBotApplication.OnActivity` creates the baggage scope automatically for every turn: |
| 329 | |
| 330 | ```csharp |
| 331 | // Microsoft.Teams.Apps/TeamsBotApplication.cs — inside OnActivity override |
| 332 | Context<TeamsActivity> defaultContext = new(this, teamsActivity); |
| 333 | |
| 334 | using IDisposable baggageScope = new TeamsBaggageBuilder() |
| 335 | .FromTeamsContext(defaultContext) |
| 336 | .Build(); |
| 337 | ``` |
| 338 | |
| 339 | This auto-populates all activity-derivable keys. Keys that are **not** auto-populated (because they are not on the activity) must be set by the consumer in handler code: |
| 340 | - `service.name` — via `.OperationSource("my-bot")` |
| 341 | - `client.address` — via `.Set("client.address", remoteIp)` (typically from HTTP middleware) |
| 342 | - `server.address` / `server.port` — via `.InvokeAgentServer(host, port)` |
| 343 | |
| 344 | #### Consumer site (additional enrichment) |
| 345 | |
| 346 | ```csharp |
| 347 | // If the consumer needs to add keys not derivable from the activity, |
| 348 | // they create an additional baggage scope in their handler: |
| 349 | using Microsoft.Teams.Apps.Diagnostics; |
| 350 | |
| 351 | botApp.OnMessage(async (ctx, ct) => |
| 352 | { |
| 353 | using IDisposable scope = new TeamsBaggageBuilder() |
| 354 | .OperationSource("teams-bot") // required-for-cert; not derivable from the activity |
| 355 | .Build(); |
| 356 | |
| 357 | // … handler body — every span emitted from here carries Agent365 baggage. |
| 358 | }); |
| 359 | ``` |
| 360 | |
| 361 | #### Dependency impact |
| 362 | |
| 363 | `Microsoft.Teams.Apps` takes a `PackageReference` on **`OpenTelemetry.Api`** (the lightweight API contract package, no SDK, no exporters — already a transitive dep of every `Microsoft.OpenTelemetry` consumer; conventional dep for libraries that publish OTel signals: Azure SDK, gRPC, MongoDB driver). `Build()` writes to `OpenTelemetry.Baggage.Current`, which is the canonical OTel baggage that the distro propagates onto every span emitted in the scope. |
| 364 | |
| 365 | `Microsoft.Teams.Core` does **not** depend on `OpenTelemetry.Api` — it uses only the BCL `System.Diagnostics` APIs for traces and metrics. |
| 366 | |
| 367 | ## Why no DI plumbing |
| 368 | |
| 369 | `ActivitySource` and `Meter` are process-global by design — `ActivityListener` and `MeterListener` subscribe by source/meter name, not by instance. The SDK therefore owns the singletons as `static readonly` fields and does not register them in DI. Consuming code never receives an `ActivitySource` parameter; it just registers the source name once at startup. |
| 370 | |
| 371 | This keeps the instrumentation completely transparent: a bot that ignores the source name pays no overhead beyond the BCL's already-cheap "no listener attached" fast path. A bot that subscribes gets full traces, metrics, and trace-correlated logs. |
| 372 | |