From 4ad3ff544f124cdb5eb5c7a4d85d1b9bc6cc9658 Mon Sep 17 00:00:00 2001 From: Rido Date: Mon, 4 May 2026 18:15:50 +0000 Subject: [PATCH 01/24] feat: add OpenTelemetry instrumentation + Agent365 baggage support Emits per-turn ActivitySource spans (turn / middleware / handler / auth.outbound / conversation_client) and Meter instruments (teams.activities.received, teams.turn.duration, teams.handler.errors, teams.middleware.duration, teams.outbound.calls, teams.outbound.errors) from the Teams SDK pipeline so consuming bots can wire telemetry through the Microsoft OpenTelemetry distro. Layering: Core publishes "Microsoft.Teams.Core" source/meter (turn / middleware / auth.outbound / conversation_client); Apps publishes "Microsoft.Teams.Apps" source for the handler span. Each layer owns its own ActivitySource without cross-references. Adds two layer-specific BaggageBuilder classes for Agent365 export (same name in different namespaces, no inheritance): Core's reads from CoreActivity / ConversationAccount with channelData.tenant.id JSON fallback; Apps's adds the keys backed by TeamsConversationAccount (user.id, user.email, microsoft.agent.user.email, gen_ai.agent.description) and uses typed TeamsChannelData for the tenant fallback. Promotes TenantId to a typed property on Core's ConversationAccount so core/observability stays free of Apps-layer dependencies for cert attribute coverage. Adds the design doc at core/docs/Observability-Design.md (layering constraints, span/metric maps, crisp Agent365 cert definition with per-scope attribute tables, channel/sub-channel resolution) and a runnable sample at core/samples/ObservabilityBot/ wiring UseMicrosoftOpenTelemetry with OTLP export. Picks up one new package dep on Microsoft.Teams.Core: OpenTelemetry.Api 1.15.3 (lightweight contract package needed for OpenTelemetry.Baggage.Current). Co-Authored-By: Claude Opus 4.7 (1M context) --- core/core.slnx | 3 +- core/docs/Observability-Design.md | 398 ++++++++++++++++++ .../ObservabilityBot/ObservabilityBot.csproj | 17 + core/samples/ObservabilityBot/Program.cs | 58 +++ core/samples/ObservabilityBot/README.md | 67 +++ .../samples/ObservabilityBot/appsettings.json | 9 + .../Diagnostics/AgentObservabilityKeys.cs | 49 +++ .../Diagnostics/AppsTelemetry.cs | 37 ++ .../Diagnostics/BaggageBuilder.cs | 178 ++++++++ .../TeamsBotApplicationTelemetry.cs | 36 ++ .../GlobalSuppressions.cs | 6 + .../Microsoft.Teams.Apps/Routing/Router.cs | 53 ++- .../Schema/TeamsConversationAccount.cs | 10 +- .../Microsoft.Teams.Core/BotApplication.cs | 23 +- .../ConversationClient.cs | 132 ++++-- .../Diagnostics/ActivityExtensions.cs | 35 ++ .../Diagnostics/AgentObservabilityKeys.cs | 48 +++ .../Diagnostics/BaggageBuilder.cs | 193 +++++++++ .../Diagnostics/TeamsCoreTelemetry.cs | 35 ++ .../Diagnostics/Telemetry.cs | 73 ++++ .../Hosting/BotAuthenticationHandler.cs | 84 ++-- .../Microsoft.Teams.Core.csproj | 1 + .../Schema/ConversationAccount.cs | 14 + .../Microsoft.Teams.Core/TurnMiddleware.cs | 46 +- .../AssemblyInfo.cs | 9 + .../Diagnostics/BaggageBuilderTests.cs | 160 +++++++ .../RouterTelemetryTests.cs | 120 ++++++ .../AssemblyInfo.cs | 10 + .../Diagnostics/BaggageBuilderTests.cs | 185 ++++++++ .../Diagnostics/TelemetryTests.cs | 262 ++++++++++++ 30 files changed, 2274 insertions(+), 77 deletions(-) create mode 100644 core/docs/Observability-Design.md create mode 100644 core/samples/ObservabilityBot/ObservabilityBot.csproj create mode 100644 core/samples/ObservabilityBot/Program.cs create mode 100644 core/samples/ObservabilityBot/README.md create mode 100644 core/samples/ObservabilityBot/appsettings.json create mode 100644 core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs create mode 100644 core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs create mode 100644 core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs create mode 100644 core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/AssemblyInfo.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs diff --git a/core/core.slnx b/core/core.slnx index 44428d029..f3a4ca2dc 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -19,8 +19,9 @@ + - + diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md new file mode 100644 index 000000000..c291519b7 --- /dev/null +++ b/core/docs/Observability-Design.md @@ -0,0 +1,398 @@ +# Observability Design + +## Overview + +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. + +The SDK takes **no new package dependencies**. It uses the BCL `System.Diagnostics.ActivitySource` and `System.Diagnostics.Metrics.Meter`. 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. + +``` +Consuming bot Teams SDK (this design) +───────────── ─────────────────────── +.UseMicrosoftOpenTelemetry(...) + ActivitySource("Microsoft.Teams.Core") +.WithTracing(t => t ├─ "turn" (BotApplication.ProcessAsync) + .AddSource(TeamsCoreTelemetry ├─ "middleware" (TurnMiddleware.RunPipelineAsync) + .ActivitySourceName) ├─ "auth.outbound" (BotAuthenticationHandler) + .AddSource( └─ "conversation_client" (ConversationClient send/update/delete) + TeamsBotApplicationTelemetry + .ActivitySourceName)) ActivitySource("Microsoft.Teams.Apps") + └─ "handler" (Router.DispatchAsync) +.WithMetrics(m => m + .AddMeter(TeamsCoreTelemetry Meter("Microsoft.Teams.Core") + .MeterName) ├─ teams.activities.received (Counter) + .AddMeter( ├─ teams.turn.duration (Histogram, ms) + TeamsBotApplicationTelemetry ├─ teams.handler.errors (Counter) + .MeterName)); ├─ teams.middleware.duration (Histogram, ms) + ├─ teams.outbound.calls (Counter) + └─ teams.outbound.errors (Counter) + + Meter("Microsoft.Teams.Apps") + └─ (no instruments yet — reserved for Apps-level metrics) +``` + +## Layering constraints + +The SDK is split across two assemblies that observability must respect: + +- `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. +- `Microsoft.Teams.Apps` depends on Core. It owns the typed activity model, `TeamsBotApplication`, and the `Router` that dispatches to user handlers. + +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 `TeamsCoreTelemetry`. Neither references the other. + +| Layer | Public name class | Source / Meter name | Spans | Metrics | +|---|---|---|---|---| +| `Microsoft.Teams.Core` | `Microsoft.Teams.Core.Diagnostics.TeamsCoreTelemetry` | `"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` | +| `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry` | `"Microsoft.Teams.Apps"` | `handler` | (none yet) | + +Cross-assembly use is one-way: Apps's `Router` may call Core utilities (for example, the `RecordException` extension on `Activity` defined in `Microsoft.Teams.Core.Diagnostics`), 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. + +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 `TeamsCoreTelemetry` and gets the full Core-level signal. + +## Public surface + +```csharp +namespace Microsoft.Teams.Core.Diagnostics; +public static class TeamsCoreTelemetry +{ + public const string ActivitySourceName = "Microsoft.Teams.Core"; + public const string MeterName = "Microsoft.Teams.Core"; +} + +namespace Microsoft.Teams.Apps.Diagnostics; +public static class TeamsBotApplicationTelemetry +{ + public const string ActivitySourceName = "Microsoft.Teams.Apps"; + public const string MeterName = "Microsoft.Teams.Apps"; +} +``` + +The matching internal singletons live in each assembly's `Diagnostics/` folder: +- `Microsoft.Teams.Core/Diagnostics/Telemetry.cs` — owned by Core; visible to Apps and BotBuilder via `InternalsVisibleTo` (used by Core types only — Apps does not call into it). +- `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. + +## Spans + +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. + +| Span | Source | Where | Tags | +|---|---|---|---| +| `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` | +| `middleware` | Core | `Microsoft.Teams.Core/TurnMiddleware.cs` `RunPipelineAsync` per-middleware execution | `middleware.name`, `middleware.index` | +| `handler` | Apps | `Microsoft.Teams.Apps/Routing/Router.cs` `DispatchAsync` matched-route invocation | `handler.type` (activity type or invoke name), `handler.dispatch` (`type` / `invoke` / `catchall`) | +| `auth.outbound` | Core | `Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs` `GetAuthorizationHeaderAsync` | `auth.flow` (`agentic` / `app_only` / `managed_identity` / `client_credentials`), `auth.scope` | +| `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` | + +On exception every span sets `Status = Error` with the exception message, and the exception is recorded as a span event (`exception.type`, `exception.message`, `exception.stacktrace`) — `Activity.AddException` on net9+, manual event tagging on net8.0. The helper extension lives in Core (`ActivityExtensions.RecordException`) and is consumed from Apps. + +### `auth.inbound` is intentionally omitted + +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. + +## Metrics + +All metrics emit on the **Core** meter. Apps does not yet have its own instruments; the Apps meter is published symmetrically with the Apps source so a future Apps-level metric can be added without changing the public surface. + +| Metric | Kind | Unit | Tags | Where | +|---|---|---|---|---| +| `teams.activities.received` | Counter | — | `activity.type` | top of `BotApplication.ProcessAsync` | +| `teams.turn.duration` | Histogram | ms | `activity.type` | `finally` of the `turn` span | +| `teams.handler.errors` | Counter | — | `activity.type` | catch block in `BotApplication.ProcessAsync` | +| `teams.middleware.duration` | Histogram | ms | `middleware.name` | `finally` of the `middleware` span | +| `teams.outbound.calls` | Counter | — | `operation` ∈ {`sendActivity`, `updateActivity`, `deleteActivity`} | success branch of `ConversationClient` calls | +| `teams.outbound.errors` | Counter | — | `operation` | exception branch of `ConversationClient` calls | + +OTLP exposes these names with dots; Prometheus/Mimir maps them to `teams_*_total` (counters) and `teams_*_milliseconds_*` (histograms). + +## Logs + +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.** + +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. + +## Consumer integration + +```csharp +using Microsoft.OpenTelemetry; +using Microsoft.Teams.Apps.Diagnostics; +using Microsoft.Teams.Core.Diagnostics; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenTelemetry() + .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.AzureMonitor | ExportTarget.Otlp) + .WithTracing(t => t + .AddSource(TeamsCoreTelemetry.ActivitySourceName) + .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) + .WithMetrics(m => m + .AddMeter(TeamsCoreTelemetry.MeterName) + .AddMeter(TeamsBotApplicationTelemetry.MeterName)); + +builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); +``` + +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. + +A working sample lives at `core/samples/ObservabilityBot/` with a README that documents the local Grafana LGTM container loop. + +## Span tree per turn + +``` +HTTP server span (auto, OTel ASP.NET Core) +└─ turn (Microsoft.Teams.Core) + ├─ middleware [n times] (Microsoft.Teams.Core) + ├─ handler (Microsoft.Teams.Apps) + └─ conversation_client (Microsoft.Teams.Core) + ├─ auth.outbound (Microsoft.Teams.Core) + │ └─ HTTP client span (auto, OTel HttpClient — token endpoint) + └─ HTTP client span (auto, OTel HttpClient — Bot Service API) +``` + +## Agent365 baggage and the TurnContext mismatch + +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)`. + +**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`, 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. + +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, each Teams SDK layer ships its own `BaggageBuilder` that reads directly off Teams types. See "Bridging strategy" below. + +### Agent365 certification — crisp definition + +Authoritative source: `https://github.com/microsoft/opentelemetry-distro-dotnet/blob/main/docs/agent365-getting-started.md` § "Validate for store publishing". + +Two requirements gate Agent365 store publishing: + +#### (1) Scope coverage + +The agent **must implement** the following scopes via the Agent365 SDK (`Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes`): + +| Scope | When to start | Required for publishing? | +|---|---|---| +| `InvokeAgentScope` | Top of agent processing | **Yes** | +| `InferenceScope` | Around each LLM call | **Yes** | +| `ExecuteToolScope` | Around each tool / function call | **Yes** | +| `OutputScope` | Optional — for async output capture | No | + +Auto-instrumentation (Semantic Kernel, OpenAI, Azure OpenAI, Agent Framework) emits **inference** spans automatically, but `InvokeAgentScope` and `ExecuteToolScope` must be started by the agent. + +#### (2) Attribute coverage + +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 `BaggageBuilder`); a handful are scope-specific and come from `ScopeDetails` / `Record*` methods. + +**Common Required attributes (all scopes):** + +| Key | Where it comes from | +|---|---| +| `microsoft.tenant.id` | `BaggageBuilder.TenantId(...)` | +| `gen_ai.agent.id` | `BaggageBuilder.AgentId(...)` | +| `gen_ai.agent.name` | `BaggageBuilder.AgentName(...)` | +| `microsoft.a365.agent.blueprint.id` | `BaggageBuilder.AgentBlueprintId(...)` | +| `microsoft.agent.user.id` | `BaggageBuilder.AgenticUserId(...)` | +| `microsoft.agent.user.email` | `BaggageBuilder.AgenticUserEmail(...)` | +| `client.address` | Caller-supplied (HTTP request remote IP) | +| `user.id` | `BaggageBuilder.UserId(...)` | +| `user.email` | `BaggageBuilder.UserEmail(...)` | +| `microsoft.channel.name` | `BaggageBuilder.ChannelName(...)` | +| `gen_ai.conversation.id` | `BaggageBuilder.ConversationId(...)` | +| `gen_ai.operation.name` | Set by the scope automatically | + +**Scope-specific Required attributes:** + +| Scope | Additional Required attributes | Source | +|---|---|---| +| `InvokeAgentScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `server.address`, `server.port` | `scope.RecordInputMessages(...)` / `RecordOutputMessages(...)` + `BaggageBuilder.InvokeAgentServer(host, port)` | +| `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(...)` | +| `InferenceScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.provider.name`, `gen_ai.request.model` | `InferenceCallDetails` + `RecordInputMessages` / `RecordOutputMessages` | +| `OutputScope` | `gen_ai.output.messages` | `Response` constructor | + +**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). + +**Out of scope of this SDK:** the scope objects themselves (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, `OutputScope`) ship in `Microsoft.OpenTelemetry`. The Teams SDK only ships the `BaggageBuilder` that populates the cert-required baggage; agents create the scopes themselves at the appropriate boundaries. + +### Required baggage map (Teams activity → Agent365 keys) + +| Group | Key (Agent365 wire) | Required for cert? | Source field on the Teams activity | +|---|---|---|---| +| Tenant | `microsoft.tenant.id` | **Yes** | `Activity.Recipient.TenantId` (typed on Core's `ConversationAccount` — see "Schema change" below); fallback `Activity.ChannelData.tenant.id` (typed on Apps's `TeamsChannelData`, JSON-parsed on Core) | +| Conversation | `gen_ai.conversation.id` | **Yes** | `Activity.Conversation.Id` | +| Conversation | `microsoft.conversation.item.link` | Optional | `Activity.ServiceUrl?.ToString()` | +| Channel | `microsoft.channel.name` | **Yes** | `Activity.ChannelId` (the whole string — `"msteams"`, `"webchat"`, …) | +| Channel | `microsoft.channel.link` | Optional | No equivalent on the Teams activity — see "Channel / SubChannel mapping" below | +| Caller (human) | `user.id` | **Yes** | `((TeamsConversationAccount)Activity.From).AadObjectId` (Apps-only) | +| Caller (human) | `user.name` | Optional | `Activity.From.Name` | +| Caller (human) | `user.email` | **Yes** | `((TeamsConversationAccount)Activity.From).Email` (Apps-only) | +| Target agent | `gen_ai.agent.id` | **Yes** | `Activity.Recipient.AgenticAppId ?? Activity.Recipient.Id` | +| Target agent | `gen_ai.agent.name` | **Yes** | `Activity.Recipient.Name` | +| Target agent | `microsoft.agent.user.id` | **Yes** | `Activity.Recipient.AgenticUserId ?? ((TeamsConversationAccount)Activity.Recipient).AadObjectId` | +| Target agent | `microsoft.agent.user.email` | **Yes** | `((TeamsConversationAccount)Activity.Recipient).Email` (Apps-only) | +| Target agent | `gen_ai.agent.description` | Optional | `((TeamsConversationAccount)Activity.Recipient).UserRole` (Apps-only) | +| Target agent | `microsoft.a365.agent.blueprint.id` | **Yes** | `Activity.Recipient.AgenticAppBlueprintId` | +| Operation source | `service.name` (set via `BaggageBuilder.OperationSource`) | **Yes** (server spans) | Caller-supplied constant (e.g. `"teams-bot"`) | + +The fields the Agent365 helpers also access that have **no Teams equivalent**: + +- `turnContext.StackState[O11ySpanId / O11yTraceId]` — Teams's `Context` 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. + +### Channel / SubChannel mapping + +The upstream `BaggageBuilderExtensions.FromTurnContext` 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: + +| Agent365 baggage key | Teams source | Auto-populated by `FromCoreActivity` / `FromTeamsContext`? | +|---|---|---| +| `microsoft.channel.name` (Required) | `Activity.ChannelId` (the whole string) | **Yes** | +| `microsoft.channel.link` (Optional in all four cert scopes) | No equivalent on the Teams activity | **No** — left unset by the extractor | + +`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 both `BaggageBuilder` classes 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). + +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. + +### Schema change: `TenantId` on Core's `ConversationAccount` + +To let Core-only bots populate `microsoft.tenant.id` (a Required cert key) without depending on Apps's `TeamsConversationAccount`, we promote `TenantId` to a typed property on Core's `ConversationAccount`: + +```csharp +// Microsoft.Teams.Core/Schema/ConversationAccount.cs +[JsonPropertyName("tenantId")] +public string? TenantId { get; set; } +``` + +`TeamsConversationAccount` (Apps) loses its custom `Properties["tenantId"]` shim — the inherited typed property replaces it. `tenantId` is a cross-channel concept (the M365 tenant the conversation belongs to), not a Teams-specific extension; promoting it is consistent with how Agent Builder's schema treats it. + +**Wire-format note:** classic Bot Framework Teams traffic carries tenant id in `channelData.tenant.id`, **not** at `from.tenantId` / `recipient.tenantId`. The schema change does not auto-populate `Recipient.TenantId` from such activities — both `BaggageBuilder.FromCoreActivity` and `BaggageBuilder.FromTeamsContext` therefore fall back to `channelData.tenant.id` when the typed field is null. In Apps, the fallback uses the typed `TeamsActivity.ChannelData?.Tenant?.Id`. In Core, it parses `Activity.Properties["channelData"]` as JSON and extracts `tenant.id`. + +### Bridging strategy + +**Two layer-specific baggage builders, one per assembly.** Each layer ships its own independent `BaggageBuilder` class shaped by the activity model that layer owns. Same name in different namespaces, no inheritance, no cross-references — each is self-contained. This honors the layering rule: neither builder downcasts to types it doesn't own. + +The two field-set partitions: + +| Field set | Source | Lives on which layer's builder | +|---|---|---| +| `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` | `CoreActivity` + `ConversationAccount` (post-schema-change) | Core | +| Everything in Core **plus** `user.id` (from `AadObjectId`), `user.email`, `gen_ai.agent.description` (from `UserRole`), `microsoft.agent.user.email` | `TeamsActivity` + `TeamsConversationAccount` | Apps | + +Apps's class duplicates Core's setter bodies (each is `Set(key, value); return this;`). The duplication is acceptable because the surface is small, the wire keys are part of an external (Agent365) contract that we cannot accidentally drift on without breaking exports, and it preserves clean independence between the layers. + +#### Proposed surface + +```csharp +// Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs (NEW, public) +namespace Microsoft.Teams.Core.Diagnostics; + +public sealed class BaggageBuilder +{ + // Keys reachable from CoreActivity / ConversationAccount. + public BaggageBuilder TenantId(string? v); + public BaggageBuilder ConversationId(string? v); + public BaggageBuilder ConversationItemLink(string? v); // from ServiceUrl + public BaggageBuilder ChannelName(string? v); // from ChannelId (string) + public BaggageBuilder ChannelLink(string? v); // caller-supplied — no auto source + public BaggageBuilder AgentId(string? v); // Recipient.AgenticAppId ?? Recipient.Id + public BaggageBuilder AgentName(string? v); // Recipient.Name + public BaggageBuilder AgenticUserId(string? v); // Recipient.AgenticUserId + public BaggageBuilder AgentBlueprintId(string? v); // Recipient.AgenticAppBlueprintId + public BaggageBuilder UserName(string? v); // From.Name + public BaggageBuilder OperationSource(string source); // service.name — caller-supplied + public BaggageBuilder InvokeAgentServer(string? address, int? port = null); + public BaggageBuilder Set(string key, string? value); // escape hatch + + /// Populates every setter above whose source field is non-null on . + /// Falls back to parsing Properties["channelData"] JSON for tenant id when + /// Recipient.TenantId is empty. + public BaggageBuilder FromCoreActivity(CoreActivity activity); + + public IDisposable Build(); // applies pairs to OpenTelemetry.Baggage.Current; returns restore-scope +} +``` + +```csharp +// Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs (NEW, public — separate class, same name in a different namespace) +namespace Microsoft.Teams.Apps.Diagnostics; + +public sealed class BaggageBuilder +{ + // Same setters as Core's class … + public BaggageBuilder TenantId(string? v); + public BaggageBuilder ConversationId(string? v); + public BaggageBuilder ConversationItemLink(string? v); + public BaggageBuilder ChannelName(string? v); + public BaggageBuilder ChannelLink(string? v); + public BaggageBuilder AgentId(string? v); + public BaggageBuilder AgentName(string? v); + public BaggageBuilder AgenticUserId(string? v); + public BaggageBuilder AgentBlueprintId(string? v); + public BaggageBuilder UserName(string? v); + public BaggageBuilder OperationSource(string source); + public BaggageBuilder InvokeAgentServer(string? address, int? port = null); + public BaggageBuilder Set(string key, string? value); + + // … plus setters whose source field only exists on TeamsConversationAccount: + public BaggageBuilder UserId(string? v); // From.AadObjectId + public BaggageBuilder UserEmail(string? v); // From.Email + public BaggageBuilder AgentDescription(string? v); // Recipient.UserRole + public BaggageBuilder AgenticUserEmail(string? v); // Recipient.Email + + /// Populates every setter above whose source field is non-null on ctx.Activity, + /// reading TeamsConversationAccount-only fields without any downcast (the activity already + /// types From / Recipient as TeamsConversationAccount). Tenant fallback uses the typed + /// TeamsActivity.ChannelData?.Tenant?.Id. + public BaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity; + + public IDisposable Build(); +} +``` + +The same-name-in-different-namespaces shape is intentional: a Core-only bot writes `using Microsoft.Teams.Core.Diagnostics; new BaggageBuilder()…`, a Teams-router bot writes `using Microsoft.Teams.Apps.Diagnostics; new BaggageBuilder()…`. The chosen `using` directive disambiguates; no caller ever sees both at once. + +The Agent365 wire keys are duplicated as `internal const` strings in each assembly (`Microsoft.Teams.Core.Diagnostics.AgentObservabilityKeys` and the Apps equivalent) — same string values on both sides, kept in sync against the upstream Agent365 spec. They are not part of the public API of either assembly. + +#### Consumer site + +```csharp +// Apps-layer (Teams router) bot — this is the recommended path for Agent365. +using Microsoft.Teams.Apps.Diagnostics; + +botApp.OnMessage(async (ctx, ct) => +{ + using IDisposable scope = new BaggageBuilder() + .FromTeamsContext(ctx) + .OperationSource("teams-bot") // required-for-cert; not derivable from the activity + .Build(); + + // … handler body — every span emitted from here carries Agent365 baggage. +}); +``` + +```csharp +// Core-only bot (BotApplication.OnActivity, no Apps router). +// All Required cert keys are reachable from Core after the TenantId schema change, +// EXCEPT user.id, user.email, microsoft.agent.user.email (those need Apps's +// TeamsConversationAccount). A Core-only bot can either set them manually: +using Microsoft.Teams.Core.Diagnostics; + +botApp.OnActivity = async (activity, ct) => +{ + using IDisposable scope = new BaggageBuilder() + .FromCoreActivity(activity) + .Set(/* user.id */ "user.id", myAadObjectIdFromAuth) + .Set(/* user.email */ "user.email", myUserEmailFromAuth) + .Set(/* agent.email */ "microsoft.agent.user.email", myAgentEmailFromConfig) + .OperationSource("teams-bot") + .Build(); + // … +}; +``` + +The Apps router builder is the intended path for full cert; Core's builder covers most of the surface and provides the `Set(key, value)` escape hatch for the remainder. + +#### Dependency impact + +`Microsoft.Teams.Core` picks up one new `PackageReference`: **`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. + +`Microsoft.Teams.Apps` takes no new direct deps — `OpenTelemetry.Api` flows through transitively via Core's project reference. + +## Why no DI plumbing + +`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. + +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. diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj new file mode 100644 index 000000000..ff964b617 --- /dev/null +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs new file mode 100644 index 000000000..4f1d28db4 --- /dev/null +++ b/core/samples/ObservabilityBot/Program.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.OpenTelemetry; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Diagnostics; +using Microsoft.Teams.Core.Hosting; +using Microsoft.Teams.Core.Schema; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +// Each Teams SDK layer publishes its own ActivitySource / Meter; register all you use. +// Microsoft.Teams.Core -> turn / middleware / auth.outbound / conversation_client +// Microsoft.Teams.Apps -> handler (only if you use the Apps router; not in this sample) +string[] activitySources = [TeamsCoreTelemetry.ActivitySourceName]; +string[] meterNames = [TeamsCoreTelemetry.MeterName]; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddBotApplication(); + +// Wire the Microsoft OpenTelemetry distro and subscribe to the Teams SDK's +// ActivitySource and Meter. Exporters are auto-detected from environment: +// APPLICATIONINSIGHTS_CONNECTION_STRING -> Azure Monitor +// OTEL_EXPORTER_OTLP_ENDPOINT -> OTLP collector (Aspire / Grafana LGTM / Jaeger) +// Console export is enabled below for local debugging. +builder.Services.AddOpenTelemetry() + .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.Otlp) + .WithTracing(t => t.AddSource(activitySources)) + .WithMetrics(m => m.AddMeter(meterNames)); + +builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); + +WebApplication app = builder.Build(); + +app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + TeamsCoreTelemetry.ActivitySourceName); + +BotApplication botApp = app.UseBotApplication(); + +botApp.OnActivity = async (activity, cancellationToken) => +{ + ArgumentNullException.ThrowIfNull(activity.Conversation); + + CoreActivity reply = CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithChannelId(activity.ChannelId) + .WithServiceUrl(activity.ServiceUrl) + .WithConversation(activity.Conversation) + .WithFrom(activity.Recipient) + .WithProperty("text", $"ObservabilityBot received `{activity.Type}` (SDK {BotApplication.Version}).") + .Build(); + + await botApp.SendActivityAsync(reply, cancellationToken: cancellationToken); +}; + +app.Run(); diff --git a/core/samples/ObservabilityBot/README.md b/core/samples/ObservabilityBot/README.md new file mode 100644 index 000000000..1313a0066 --- /dev/null +++ b/core/samples/ObservabilityBot/README.md @@ -0,0 +1,67 @@ +# ObservabilityBot + +Minimal Teams bot wired to the [`Microsoft.OpenTelemetry`](https://github.com/microsoft/opentelemetry-distro-dotnet) distro. Demonstrates how a consuming app subscribes to the Teams SDK's `ActivitySource` and `Meter` so that turn / middleware / handler / auth.outbound / conversation_client spans and the `teams.*` metrics flow to configured exporters alongside auto-instrumented HTTP server / client / Azure SDK spans. + +## What it shows + +```csharp +builder.Services.AddOpenTelemetry() + .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.Console | ExportTarget.Otlp) + .WithTracing(t => t.AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) + .WithMetrics(m => m.AddMeter(TeamsBotApplicationTelemetry.MeterName)); + +builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); +``` + +The two `.AddSource` / `.AddMeter` calls are the only Teams-specific OTel wiring. Everything else is standard distro setup. + +## Run locally with Grafana LGTM (traces + metrics + logs) + +[`grafana/otel-lgtm`](https://github.com/grafana/docker-otel-lgtm) is a single container that bundles Tempo (traces), Mimir (metrics), Loki (logs), and Grafana, and accepts OTLP on ports 4317 (gRPC) and 4318 (HTTP). + +```bash +docker run --rm -d --name lgtm \ + -p 3000:3000 -p 4317:4317 -p 4318:4318 \ + grafana/otel-lgtm + +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +export OTEL_SERVICE_NAME=teams-observability-bot +export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=local,service.version=dev" + +dotnet run --project core/samples/ObservabilityBot +``` + +Open http://localhost:3000 (`admin` / `admin`) and explore Tempo, Mimir, and Loki. + +## Send a test activity + +To exercise the pipeline you need to POST a Bot Framework activity payload (with a valid bearer token) to the bot's `/api/messages` endpoint. Reasonable options: + +- Use the `core/test/ABSTokenServiceClient` helper to mint a token, then `curl` a JSON activity. +- Drive the bot from one of the harnesses under `core/test/IntegrationTests`. +- Deploy the bot to a Teams tenant and chat with it. + +## Export targets + +- Set `APPLICATIONINSIGHTS_CONNECTION_STRING` to additionally export to Azure Monitor / Application Insights. +- Remove `ExportTarget.Console` for production. +- See the [Microsoft OpenTelemetry distro README](https://github.com/microsoft/opentelemetry-distro-dotnet#readme) for the full set of `ExportTarget` values, sampling, and Azure Monitor options. + +## What you should see + +Per turn, the trace has the shape: + +``` +HTTP server span (auto, OTel ASP.NET Core) +└─ turn (Microsoft.Teams.Apps) + ├─ middleware [n times] (Microsoft.Teams.Apps) + ├─ handler (Microsoft.Teams.Apps) + └─ conversation_client (Microsoft.Teams.Apps) + ├─ auth.outbound (Microsoft.Teams.Apps) + │ └─ HTTP client span (auto — token endpoint) + └─ HTTP client span (auto — Bot Service API) +``` + +Metrics (Prometheus / Mimir names): `teams_activities_received_total`, `teams_turn_duration_milliseconds_bucket/sum/count`, `teams_handler_errors_total`, `teams_middleware_duration_milliseconds_*`, `teams_outbound_calls_total`, `teams_outbound_errors_total`. + +Logs: every `ILogger` record produced inside a turn carries the active `TraceId` / `SpanId` so Loki queries can pivot from a slow trace to its log lines. diff --git a/core/samples/ObservabilityBot/appsettings.json b/core/samples/ObservabilityBot/appsettings.json new file mode 100644 index 000000000..5febf4fe3 --- /dev/null +++ b/core/samples/ObservabilityBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs new file mode 100644 index 000000000..88dfe2bc1 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Apps.Diagnostics; + +/// +/// Agent365 observability baggage and attribute keys, duplicated from +/// Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes.OpenTelemetryConstants. +/// Same wire values as Microsoft.Teams.Core.Diagnostics.AgentObservabilityKeys; duplicated +/// per layer to keep Apps independent of Core's internals (see Layering constraints in +/// core/docs/Observability-Design.md). +/// +internal static class AgentObservabilityKeys +{ + public const string TenantId = "microsoft.tenant.id"; + public const string ConversationId = "gen_ai.conversation.id"; + public const string ConversationItemLink = "microsoft.conversation.item.link"; + public const string ChannelName = "microsoft.channel.name"; + public const string ChannelLink = "microsoft.channel.link"; + + public const string UserId = "user.id"; + public const string UserEmail = "user.email"; + public const string UserName = "user.name"; + public const string ClientAddress = "client.address"; + + public const string AgentId = "gen_ai.agent.id"; + public const string AgentName = "gen_ai.agent.name"; + public const string AgentDescription = "gen_ai.agent.description"; + public const string AgentVersion = "gen_ai.agent.version"; + public const string AgenticUserId = "microsoft.agent.user.id"; + public const string AgenticUserEmail = "microsoft.agent.user.email"; + public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; + public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; + + public const string CallerAgentName = "microsoft.a365.caller.agent.name"; + public const string CallerAgentId = "microsoft.a365.caller.agent.id"; + public const string CallerAgentBlueprintId = "microsoft.a365.caller.agent.blueprint.id"; + public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; + public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; + public const string CallerAgentPlatformId = "microsoft.a365.caller.agent.platform.id"; + public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; + + public const string SessionId = "microsoft.session.id"; + public const string SessionDescription = "microsoft.session.description"; + + public const string ServiceName = "service.name"; + public const string ServerAddress = "server.address"; + public const string ServerPort = "server.port"; +} diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs new file mode 100644 index 000000000..aff318734 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace Microsoft.Teams.Apps.Diagnostics; + +/// +/// Singletons for the Apps-level and . +/// Internal to Microsoft.Teams.Apps. +/// +internal static class AppsTelemetry +{ + private static readonly string s_version = + typeof(AppsTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(AppsTelemetry).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + public static readonly ActivitySource Source = + new(TeamsBotApplicationTelemetry.ActivitySourceName, s_version); + + public static readonly Meter Meter = + new(TeamsBotApplicationTelemetry.MeterName, s_version); + + public static class Spans + { + public const string Handler = "handler"; + } + + public static class Tags + { + public const string HandlerType = "handler.type"; + public const string HandlerDispatch = "handler.dispatch"; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs new file mode 100644 index 000000000..fb5168db7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; +using OpenTelemetry; + +namespace Microsoft.Teams.Apps.Diagnostics; + +/// +/// Builds OpenTelemetry baggage for Agent365 export from Microsoft.Teams.Apps turn types. +/// +/// +/// +/// This class is independent from Microsoft.Teams.Core.Diagnostics.BaggageBuilder — same name +/// in a different namespace, no inheritance. Apps's builder exposes the **superset** of the cert-relevant +/// setters: everything Core's builder has plus the keys backed by +/// (user.id, user.email, microsoft.agent.user.email, gen_ai.agent.description). +/// +/// +/// Setter bodies are duplicated from Core's class. The duplication is the intentional trade-off for layer +/// independence — see core/docs/Observability-Design.md § "Bridging strategy". +/// +/// +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; + } + + /// Sets the human user id (user.id). Required for cert. Apps-only — backed by + /// . + public BaggageBuilder UserId(string? v) => Set(AgentObservabilityKeys.UserId, v); + + /// Sets the human user email (user.email). Required for cert. Apps-only. + public BaggageBuilder UserEmail(string? v) => Set(AgentObservabilityKeys.UserEmail, v); + + /// Sets the agent description (gen_ai.agent.description). Optional. Apps-only — + /// backed by . + public BaggageBuilder AgentDescription(string? v) => Set(AgentObservabilityKeys.AgentDescription, v); + + /// Sets the agentic user email (microsoft.agent.user.email). Required for cert. Apps-only. + public BaggageBuilder AgenticUserEmail(string? v) => Set(AgentObservabilityKeys.AgenticUserEmail, v); + + /// Escape hatch for setting any baggage key not exposed by a typed setter + /// (e.g. 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 ctx.Activity — including the Apps-only keys + /// backed by . Tenant fallback uses the typed + /// when is null. + /// + public BaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity + { + ArgumentNullException.ThrowIfNull(ctx); + TActivity activity = ctx.Activity; + + ConversationId(activity.Conversation?.Id); + ConversationItemLink(activity.ServiceUrl?.ToString()); + ChannelName(activity.ChannelId); + + UserName(activity.From?.Name); + if (activity.From is TeamsConversationAccount fromTcc) + { + UserId(fromTcc.AadObjectId); + UserEmail(fromTcc.Email); + } + + 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); + } + if (recipient is TeamsConversationAccount recTcc) + { + AgenticUserEmail(recTcc.Email); + AgentDescription(recTcc.UserRole); + } + + // Tenant fallback: typed channelData on TeamsActivity (no JSON parse needed). + if (!_pairs.ContainsKey(AgentObservabilityKeys.TenantId)) + { + string? channelTenantId = activity.ChannelData?.Tenant?.Id; + 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 sealed class RestoreScope(Baggage previous) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + Baggage.Current = previous; + _disposed = true; + } + } +} diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs new file mode 100644 index 000000000..a60068fe4 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Apps.Diagnostics; + +/// +/// Names of the and +/// emitted by Microsoft.Teams.Apps. +/// +/// +/// Consumers register these names with their OpenTelemetry tracer and meter providers so that the +/// Teams-application-level spans (handler) flow to configured exporters. Lower-level layers +/// (Microsoft.Teams.Core) publish their own source/meter; register them all to capture the full +/// bot pipeline. +/// +/// builder.Services.AddOpenTelemetry() +/// .WithTracing(t => t +/// .AddSource(TeamsCoreTelemetry.ActivitySourceName) +/// .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) +/// .WithMetrics(m => m +/// .AddMeter(TeamsCoreTelemetry.MeterName) +/// .AddMeter(TeamsBotApplicationTelemetry.MeterName)); +/// +/// +public static class TeamsBotApplicationTelemetry +{ + /// + /// Name of the that emits Apps-level spans. + /// + public const string ActivitySourceName = "Microsoft.Teams.Apps"; + + /// + /// Name of the that emits Apps-level metrics. + /// + public const string MeterName = "Microsoft.Teams.Apps"; +} diff --git a/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs index 215073623..4e4e83793 100644 --- a/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs +++ b/core/src/Microsoft.Teams.Apps/GlobalSuppressions.cs @@ -20,3 +20,9 @@ Justification = "", Scope = "namespaceanddescendants", Target = "~N:Microsoft.Teams.Apps")] + +[assembly: SuppressMessage("Naming", + "CA1724:Type names should not match namespaces", + Justification = "Microsoft.Teams.Apps.Context is part of the public API and predates the OpenTelemetry.Api dep. The OpenTelemetry.Context namespace clash is benign because consumers always disambiguate via using directives.", + Scope = "type", + Target = "~T:Microsoft.Teams.Apps.Context`1")] diff --git a/core/src/Microsoft.Teams.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Apps/Routing/Router.cs index 1d43dfc71..9d52d65d8 100644 --- a/core/src/Microsoft.Teams.Apps/Routing/Router.cs +++ b/core/src/Microsoft.Teams.Apps/Routing/Router.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using Microsoft.Extensions.Logging; +using Microsoft.Teams.Apps.Diagnostics; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Diagnostics; namespace Microsoft.Teams.Apps.Routing; @@ -92,7 +95,24 @@ public async Task DispatchAsync(Context ctx, CancellationToken ca { _logger.LogInformation("Dispatching '{Type}' activity to route '{Name}'.", ctx.Activity.Type, route.Name); _logger.LogTrace("Dispatching activity to route '{Name}': {Activity}", route.Name, ctx.Activity.ToJson()); - await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); + + (string handlerType, string dispatch) = GetHandlerTags(route.Name); + using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); + if (span is not null) + { + span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); + span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); + } + try + { + await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + span.RecordException(ex); + throw; + } + _logger.LogDebug("Completed route '{Name}' for '{Type}' activity.", route.Name, ctx.Activity.Type); } } @@ -132,11 +152,40 @@ public async Task DispatchWithReturnAsync(Context _logger.LogInformation("Dispatching invoke activity with name '{Name}' to route '{Route}'.", name, matchingRoutes[0].Name); _logger.LogTrace("Dispatching invoke activity to route '{Route}': {Activity}", matchingRoutes[0].Name, ctx.Activity.ToJson()); - InvokeResponse response = await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false); + (string handlerType, string dispatch) = GetHandlerTags(matchingRoutes[0].Name); + using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); + if (span is not null) + { + span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); + span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); + } + InvokeResponse response; + try + { + response = await matchingRoutes[0].InvokeRouteWithReturn(ctx, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + span.RecordException(ex); + throw; + } _logger.LogDebug("Completed invoke route '{Route}' for '{Name}' with status {Status}.", matchingRoutes[0].Name, name, response.Status); return response; } + private static (string handlerType, string dispatch) GetHandlerTags(string routeName) + { + const string invokePrefix = TeamsActivityType.Invoke + "/"; + if (string.Equals(routeName, TeamsActivityType.Invoke, StringComparison.Ordinal)) + { + return (routeName, "catchall"); + } + if (routeName.StartsWith(invokePrefix, StringComparison.Ordinal)) + { + return (routeName[invokePrefix.Length..], "invoke"); + } + return (routeName, "type"); + } } diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs index 566e0d336..b9ff1186b 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs @@ -105,15 +105,7 @@ public string? UserRole set => Properties["userRole"] = value; } - /// - /// Gets or sets the TenantId. - /// - [JsonIgnore] - public string? TenantId - { - get => GetStringProperty("tenantId"); - set => Properties["tenantId"] = value; - } + // TenantId is inherited as a typed property from ConversationAccount. private string? GetStringProperty(string key) { diff --git a/core/src/Microsoft.Teams.Core/BotApplication.cs b/core/src/Microsoft.Teams.Core/BotApplication.cs index 18d144af4..bd3487ad1 100644 --- a/core/src/Microsoft.Teams.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Core/BotApplication.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Diagnostics; using Microsoft.Teams.Core.Hosting; using Microsoft.Teams.Core.Schema; @@ -199,7 +200,21 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke _logger.ReceivedActivityJson(activity.ToJson()); } - // TODO: Replace with structured scope data, ensure it works with OpenTelemetry and other logging providers + KeyValuePair activityTypeTag = new(Telemetry.Tags.ActivityType, activity.Type); + Telemetry.ActivitiesReceived.Add(1, activityTypeTag); + + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.Turn, ActivityKind.Internal); + if (span is not null) + { + span.SetTag(Telemetry.Tags.ActivityType, activity.Type); + span.SetTag(Telemetry.Tags.ActivityId, activity.Id); + span.SetTag(Telemetry.Tags.ConversationId, activity.Conversation?.Id); + span.SetTag(Telemetry.Tags.ChannelId, activity.ChannelId); + span.SetTag(Telemetry.Tags.BotId, AppId); + span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl?.ToString()); + } + + long startTimestamp = Stopwatch.GetTimestamp(); using (_logger.BeginActivityScope(activity.Type, activity.Id, activity.ServiceUrl, correlationVector)) { // Use a dedicated timeout instead of the HTTP request's cancellation token. @@ -214,15 +229,21 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke catch (OperationCanceledException) when (cts.IsCancellationRequested) { _logger.ActivityTimedOut(_processActivityTimeout, activity.Id); + Telemetry.HandlerErrors.Add(1, activityTypeTag); + span?.SetStatus(ActivityStatusCode.Error, "timeout"); } catch (Exception ex) { _logger.ActivityProcessingError(ex, activity.Id); + Telemetry.HandlerErrors.Add(1, activityTypeTag); + span.RecordException(ex); throw new BotHandlerException("Error processing activity", ex, activity); } finally { _logger.ActivityProcessingFinished(activity.Id); + double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + Telemetry.TurnDuration.Record(elapsedMs, activityTypeTag); } } } diff --git a/core/src/Microsoft.Teams.Core/ConversationClient.cs b/core/src/Microsoft.Teams.Core/ConversationClient.cs index de51562b9..21530935e 100644 --- a/core/src/Microsoft.Teams.Core/ConversationClient.cs +++ b/core/src/Microsoft.Teams.Core/ConversationClient.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Text.Json; using Microsoft.Extensions.Logging; +using Microsoft.Teams.Core.Diagnostics; using Microsoft.Teams.Core.Http; using Microsoft.Teams.Core.Schema; @@ -75,12 +77,33 @@ public class ConversationClient(HttpClient httpClient, ILogger( - HttpMethod.Post, - url, - body, - CreateRequestOptions(agenticIdentity, "sending activity", customHeaders), - cancellationToken).ConfigureAwait(false); + KeyValuePair opTag = new(Telemetry.Tags.Operation, Telemetry.Operations.SendActivity); + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.ConversationClient, ActivityKind.Client); + if (span is not null) + { + span.SetTag(Telemetry.Tags.Operation, Telemetry.Operations.SendActivity); + span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl.ToString()); + span.SetTag(Telemetry.Tags.ConversationId, conversationId); + span.SetTag(Telemetry.Tags.ActivityType, activity.Type); + } + try + { + SendActivityResponse? response = await _botHttpClient.SendAsync( + HttpMethod.Post, + url, + body, + CreateRequestOptions(agenticIdentity, "sending activity", customHeaders), + cancellationToken).ConfigureAwait(false); + span?.SetTag(Telemetry.Tags.ActivityId, response?.Id); + Telemetry.OutboundCalls.Add(1, opTag); + return response; + } + catch (Exception ex) + { + span.RecordException(ex); + Telemetry.OutboundErrors.Add(1, opTag); + throw; + } } /// @@ -113,12 +136,33 @@ public virtual async Task UpdateActivityAsync(string con logger.UpdatingActivity(url, body); - return (await _botHttpClient.SendAsync( - HttpMethod.Put, - url, - body, - CreateRequestOptions(agenticIdentity, "updating activity", customHeaders), - cancellationToken).ConfigureAwait(false))!; + KeyValuePair opTag = new(Telemetry.Tags.Operation, Telemetry.Operations.UpdateActivity); + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.ConversationClient, ActivityKind.Client); + if (span is not null) + { + span.SetTag(Telemetry.Tags.Operation, Telemetry.Operations.UpdateActivity); + span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl.ToString()); + span.SetTag(Telemetry.Tags.ConversationId, conversationId); + span.SetTag(Telemetry.Tags.ActivityId, activityId); + span.SetTag(Telemetry.Tags.ActivityType, activity.Type); + } + try + { + UpdateActivityResponse response = (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(agenticIdentity, "updating activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + Telemetry.OutboundCalls.Add(1, opTag); + return response; + } + catch (Exception ex) + { + span.RecordException(ex); + Telemetry.OutboundErrors.Add(1, opTag); + throw; + } } @@ -147,12 +191,33 @@ public virtual async Task UpdateTargetedActivityAsync(st logger.UpdatingTargetedActivity(url, body); - return (await _botHttpClient.SendAsync( - HttpMethod.Put, - url, - body, - CreateRequestOptions(agenticIdentity, "updating targeted activity", customHeaders), - cancellationToken).ConfigureAwait(false))!; + KeyValuePair opTag = new(Telemetry.Tags.Operation, Telemetry.Operations.UpdateActivity); + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.ConversationClient, ActivityKind.Client); + if (span is not null) + { + span.SetTag(Telemetry.Tags.Operation, Telemetry.Operations.UpdateActivity); + span.SetTag(Telemetry.Tags.ServiceUrl, activity.ServiceUrl.ToString()); + span.SetTag(Telemetry.Tags.ConversationId, conversationId); + span.SetTag(Telemetry.Tags.ActivityId, activityId); + span.SetTag(Telemetry.Tags.ActivityType, activity.Type); + } + try + { + UpdateActivityResponse response = (await _botHttpClient.SendAsync( + HttpMethod.Put, + url, + body, + CreateRequestOptions(agenticIdentity, "updating targeted activity", customHeaders), + cancellationToken).ConfigureAwait(false))!; + Telemetry.OutboundCalls.Add(1, opTag); + return response; + } + catch (Exception ex) + { + span.RecordException(ex); + Telemetry.OutboundErrors.Add(1, opTag); + throw; + } } /// @@ -208,12 +273,31 @@ public async Task DeleteActivityAsync(string conversationId, string activityId, url += "?isTargetedActivity=true"; } - await _botHttpClient.SendAsync( - HttpMethod.Delete, - url, - body: null, - CreateRequestOptions(agenticIdentity, "deleting activity", customHeaders), - cancellationToken).ConfigureAwait(false); + KeyValuePair opTag = new(Telemetry.Tags.Operation, Telemetry.Operations.DeleteActivity); + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.ConversationClient, ActivityKind.Client); + if (span is not null) + { + span.SetTag(Telemetry.Tags.Operation, Telemetry.Operations.DeleteActivity); + span.SetTag(Telemetry.Tags.ServiceUrl, serviceUrl.ToString()); + span.SetTag(Telemetry.Tags.ConversationId, conversationId); + span.SetTag(Telemetry.Tags.ActivityId, activityId); + } + try + { + await _botHttpClient.SendAsync( + HttpMethod.Delete, + url, + body: null, + CreateRequestOptions(agenticIdentity, "deleting activity", customHeaders), + cancellationToken).ConfigureAwait(false); + Telemetry.OutboundCalls.Add(1, opTag); + } + catch (Exception ex) + { + span.RecordException(ex); + Telemetry.OutboundErrors.Add(1, opTag); + throw; + } } /// diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs b/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs new file mode 100644 index 000000000..6fd359619 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Helpers for setting standardized tags and recording exceptions on instances +/// emitted by the Teams SDK's bot pipeline. +/// +internal static class ActivityExtensions +{ + /// + /// Records an exception on the span: sets status to and + /// adds an exception event with type/message/stacktrace tags. Mirrors the shape that + /// uses on .NET 9+ but works on net8.0 as well. + /// + public static void RecordException(this Activity? activity, Exception exception) + { + if (activity is null || exception is null) + { + return; + } + + ActivityTagsCollection tags = new() + { + { "exception.type", exception.GetType().FullName }, + { "exception.message", exception.Message }, + { "exception.stacktrace", exception.ToString() }, + }; + activity.AddEvent(new ActivityEvent("exception", tags: tags)); + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + } +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs b/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs new file mode 100644 index 000000000..2c67dd571 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Agent365 observability baggage and attribute keys, duplicated from +/// Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes.OpenTelemetryConstants. +/// Same wire values; kept in sync with the upstream cert spec +/// (https://github.com/microsoft/opentelemetry-distro-dotnet). +/// +internal static class AgentObservabilityKeys +{ + public const string TenantId = "microsoft.tenant.id"; + public const string ConversationId = "gen_ai.conversation.id"; + public const string ConversationItemLink = "microsoft.conversation.item.link"; + public const string ChannelName = "microsoft.channel.name"; + public const string ChannelLink = "microsoft.channel.link"; + + public const string UserId = "user.id"; + public const string UserEmail = "user.email"; + public const string UserName = "user.name"; + public const string ClientAddress = "client.address"; + + public const string AgentId = "gen_ai.agent.id"; + public const string AgentName = "gen_ai.agent.name"; + public const string AgentDescription = "gen_ai.agent.description"; + public const string AgentVersion = "gen_ai.agent.version"; + public const string AgenticUserId = "microsoft.agent.user.id"; + public const string AgenticUserEmail = "microsoft.agent.user.email"; + public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; + public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; + + public const string CallerAgentName = "microsoft.a365.caller.agent.name"; + public const string CallerAgentId = "microsoft.a365.caller.agent.id"; + public const string CallerAgentBlueprintId = "microsoft.a365.caller.agent.blueprint.id"; + public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; + public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; + public const string CallerAgentPlatformId = "microsoft.a365.caller.agent.platform.id"; + public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; + + public const string SessionId = "microsoft.session.id"; + public const string SessionDescription = "microsoft.session.description"; + + public const string ServiceName = "service.name"; + public const string ServerAddress = "server.address"; + public const string ServerPort = "server.port"; +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs b/core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs new file mode 100644 index 000000000..2f61edbba --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs @@ -0,0 +1,193 @@ +// 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; + } + } +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs new file mode 100644 index 000000000..fcab8be81 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Names of the and +/// emitted by Microsoft.Teams.Core. +/// +/// +/// Consumers register these names with their OpenTelemetry tracer and meter providers so that the bot +/// pipeline spans (turn, middleware, auth.outbound, conversation_client) and metrics +/// (teams.activities.received, teams.turn.duration, teams.handler.errors, +/// teams.middleware.duration, teams.outbound.calls, teams.outbound.errors) flow to +/// configured exporters. Higher-level layers publish their own sources (for example, +/// Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry); register them all to capture +/// the full bot pipeline. +/// +/// builder.Services.AddOpenTelemetry() +/// .WithTracing(t => t.AddSource(TeamsCoreTelemetry.ActivitySourceName)) +/// .WithMetrics(m => m.AddMeter(TeamsCoreTelemetry.MeterName)); +/// +/// +public static class TeamsCoreTelemetry +{ + /// + /// Name of the that emits Core pipeline spans. + /// + public const string ActivitySourceName = "Microsoft.Teams.Core"; + + /// + /// Name of the that emits Core pipeline metrics. + /// + public const string MeterName = "Microsoft.Teams.Core"; +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs new file mode 100644 index 000000000..6dbb530db --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Singletons for the SDK's , , and instruments. +/// Internal to Microsoft.Teams.Core; visible to Microsoft.Teams.Apps +/// and Microsoft.Teams.Apps.BotBuilder via InternalsVisibleTo. +/// +internal static class Telemetry +{ + private const string s_version = ThisAssembly.NuGetPackageVersion; + + public static readonly ActivitySource Source = + new(TeamsCoreTelemetry.ActivitySourceName, s_version); + + public static readonly Meter Meter = + new(TeamsCoreTelemetry.MeterName, s_version); + + public static readonly Counter ActivitiesReceived = + Meter.CreateCounter("teams.activities.received", description: "Total activities received by the bot."); + + public static readonly Histogram TurnDuration = + Meter.CreateHistogram("teams.turn.duration", unit: "ms", description: "Duration of full turn processing."); + + public static readonly Counter HandlerErrors = + Meter.CreateCounter("teams.handler.errors", description: "Total exceptions thrown during turn processing."); + + public static readonly Histogram MiddlewareDuration = + Meter.CreateHistogram("teams.middleware.duration", unit: "ms", description: "Duration of individual middleware execution."); + + public static readonly Counter OutboundCalls = + Meter.CreateCounter("teams.outbound.calls", description: "Total outbound Bot Service API calls."); + + public static readonly Counter OutboundErrors = + Meter.CreateCounter("teams.outbound.errors", description: "Total outbound Bot Service API call errors."); + + // Span name constants — kept here so callers don't drift on naming. + public static class Spans + { + public const string Turn = "turn"; + public const string Middleware = "middleware"; + public const string AuthOutbound = "auth.outbound"; + public const string ConversationClient = "conversation_client"; + } + + public static class Tags + { + public const string ActivityType = "activity.type"; + public const string ActivityId = "activity.id"; + public const string ConversationId = "conversation.id"; + public const string ChannelId = "channel.id"; + public const string BotId = "bot.id"; + public const string ServiceUrl = "service.url"; + public const string MiddlewareName = "middleware.name"; + public const string MiddlewareIndex = "middleware.index"; + public const string AuthFlow = "auth.flow"; + public const string AuthScope = "auth.scope"; + public const string Operation = "operation"; + } + + public static class Operations + { + public const string SendActivity = "sendActivity"; + public const string UpdateActivity = "updateActivity"; + public const string DeleteActivity = "deleteActivity"; + } +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs index 08e66b1f7..d3446de6e 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs @@ -1,12 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Web; +using Microsoft.Teams.Core.Diagnostics; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Core.Hosting; @@ -77,49 +79,67 @@ protected override async Task SendAsync(HttpRequestMessage /// The authorization header value. private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) { - AuthorizationHeaderProviderOptions options = new() - { - AcquireTokenOptions = new AcquireTokenOptions() - { - AuthenticationOptionsName = authenticationOptionsName ?? MsalConfigurationExtensions.MsalConfigKey, - } - }; + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.AuthOutbound, ActivityKind.Client); + span?.SetTag(Telemetry.Tags.AuthScope, _scope); - // Conditionally apply ManagedIdentity configuration if registered - if (_managedIdentityOptions is not null) + try { - ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; - - if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + AuthorizationHeaderProviderOptions options = new() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = authenticationOptionsName ?? MsalConfigurationExtensions.MsalConfigKey, + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) { - _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); - options.AcquireTokenOptions.ManagedIdentity = miOptions; + ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); + options.AcquireTokenOptions.ManagedIdentity = miOptions; + span?.SetTag(Telemetry.Tags.AuthFlow, "managed_identity"); + } } - } - if (agenticIdentity is not null && - !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && - !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) - { - _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null); - - if (!Guid.TryParse(agenticIdentity.AgenticUserId, out Guid agenticUserGuid)) + if (agenticIdentity is not null && + !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) && + !string.IsNullOrEmpty(agenticIdentity.AgenticUserId)) { - _logInvalidAgenticUserId(_logger, agenticIdentity.AgenticUserId, null); + _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null); + + if (!Guid.TryParse(agenticIdentity.AgenticUserId, out Guid agenticUserGuid)) + { + _logInvalidAgenticUserId(_logger, agenticIdentity.AgenticUserId, null); + } + else + { + span?.SetTag(Telemetry.Tags.AuthFlow, "agentic"); + options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, agenticUserGuid); + string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken).ConfigureAwait(false); + return token; + } } - else + + _logAppOnlyToken(_logger, _scope, null); + // Don't overwrite a more specific flow (managed_identity) already set above. + if (span is not null && !span.TagObjects.Any(t => t.Key == Telemetry.Tags.AuthFlow)) { - options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, agenticUserGuid); - string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([_scope], options, null, cancellationToken).ConfigureAwait(false); - return token; + span.SetTag(Telemetry.Tags.AuthFlow, "app_only"); } - } - - _logAppOnlyToken(_logger, _scope, null); - string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken).ConfigureAwait(false); + string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(_scope, options, cancellationToken).ConfigureAwait(false); - return appToken; + return appToken; + } + catch (Exception ex) + { + span.RecordException(ex); + throw; + } } private void LogTokenClaims(string token) diff --git a/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj b/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj index fa0fe5c17..42cd8f2aa 100644 --- a/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj +++ b/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj @@ -25,6 +25,7 @@ + diff --git a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs index c0758112c..fda735ffc 100644 --- a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs +++ b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs @@ -48,6 +48,20 @@ public class ConversationAccount() [JsonPropertyName("agenticAppBlueprintId")] public string? AgenticAppBlueprintId { get; set; } + /// + /// Gets or sets the Microsoft Entra tenant ID associated with the conversation account. + /// + /// + /// Surfaced at the conversation-account level so cross-channel observability (Agent365 baggage, + /// telemetry enrichment) can populate microsoft.tenant.id without requiring the Apps-layer + /// TeamsConversationAccount. Classic Bot Framework activities still carry tenant id in + /// channelData.tenant.id; consumers that need the channel-data fallback should use + /// BaggageBuilder.FromCoreActivity / FromTeamsContext, which transparently fall + /// back when this property is null. + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } + /// /// Gets the extension data dictionary for storing additional properties not defined in the schema. /// diff --git a/core/src/Microsoft.Teams.Core/TurnMiddleware.cs b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs index 807d30a31..d28e8ffcc 100644 --- a/core/src/Microsoft.Teams.Core/TurnMiddleware.cs +++ b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs @@ -2,8 +2,10 @@ // Licensed under the MIT License. using System.Collections; +using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Diagnostics; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Core; @@ -59,7 +61,7 @@ public async Task OnTurnAsync(BotApplication botApplication, CoreActivity activi /// The index of the next middleware to execute in the pipeline. /// A cancellation token that can be used to cancel the operation. /// A task that represents the asynchronous pipeline execution. - public Task RunPipelineAsync(BotApplication botApplication, CoreActivity activity, Func? callback, int nextMiddlewareIndex, CancellationToken cancellationToken) + public async Task RunPipelineAsync(BotApplication botApplication, CoreActivity activity, Func? callback, int nextMiddlewareIndex, CancellationToken cancellationToken) { if (nextMiddlewareIndex == _middlewares.Count) { @@ -67,15 +69,43 @@ public Task RunPipelineAsync(BotApplication botApplication, CoreActivity activit { _logger.MiddlewarePipelineCompleted(nextMiddlewareIndex); } - return callback is not null ? callback!(activity, cancellationToken) ?? Task.CompletedTask : Task.CompletedTask; + if (callback is not null) + { + await callback(activity, cancellationToken).ConfigureAwait(false); + } + return; } + ITurnMiddleware nextMiddleware = _middlewares[nextMiddlewareIndex]; - _logger.MiddlewareExecuting(nextMiddleware.GetType().Name, nextMiddlewareIndex + 1, _middlewares.Count); - return nextMiddleware.OnTurnAsync( - botApplication, - activity, - (ct) => RunPipelineAsync(botApplication, activity, callback, nextMiddlewareIndex + 1, ct), - cancellationToken); + string middlewareName = nextMiddleware.GetType().Name; + _logger.MiddlewareExecuting(middlewareName, nextMiddlewareIndex + 1, _middlewares.Count); + + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.Middleware, ActivityKind.Internal); + if (span is not null) + { + span.SetTag(Telemetry.Tags.MiddlewareName, middlewareName); + span.SetTag(Telemetry.Tags.MiddlewareIndex, nextMiddlewareIndex); + } + + KeyValuePair mwTag = new(Telemetry.Tags.MiddlewareName, middlewareName); + long start = Stopwatch.GetTimestamp(); + try + { + await nextMiddleware.OnTurnAsync( + botApplication, + activity, + (ct) => RunPipelineAsync(botApplication, activity, callback, nextMiddlewareIndex + 1, ct), + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + span.RecordException(ex); + throw; + } + finally + { + Telemetry.MiddlewareDuration.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds, mwTag); + } } public IEnumerator GetEnumerator() diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs b/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs new file mode 100644 index 000000000..0b3e41fe9 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +// Tests in this assembly use process-global state (System.Diagnostics.ActivitySource listeners, +// OpenTelemetry.Baggage.Current). Running them in parallel causes captures from one test to +// observe spans/metrics started in another. Disabling parallelization keeps the captures isolated. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs new file mode 100644 index 000000000..abf421b4f --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Apps.Diagnostics; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core.Schema; +using OpenTelemetry; + +namespace Microsoft.Teams.Apps.UnitTests.Diagnostics; + +public class BaggageBuilderTests +{ + [Fact] + public void FromTeamsContext_PopulatesAppsOnlyKeysFromTeamsConversationAccount() + { + TeamsConversationAccount from = new() { Id = "from-id", Name = "User One" }; + from.AadObjectId = "aad-from"; + from.Email = "user@contoso.com"; + + TeamsConversationAccount recipient = new() + { + Id = "agent-id", + Name = "Agent", + AgenticAppId = "agentic-app-1", + AgenticUserId = "auid-1", + AgenticAppBlueprintId = "blueprint-1", + TenantId = "tenant-1", + }; + recipient.UserRole = "agent"; + recipient.Email = "agent@contoso.com"; + + MessageActivity activity = new() + { + Id = "act-1", + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new TeamsConversation { Id = "conv-1" }, + From = from, + Recipient = recipient, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromTeamsContext(BuildCtx(activity))); + + // Apps-only keys + Assert.Equal("aad-from", baggage["user.id"]); + Assert.Equal("user@contoso.com", baggage["user.email"]); + Assert.Equal("agent@contoso.com", baggage["microsoft.agent.user.email"]); + Assert.Equal("agent", baggage["gen_ai.agent.description"]); + + // Inherited from CoreActivity-shaped fields + Assert.Equal("tenant-1", baggage["microsoft.tenant.id"]); + Assert.Equal("conv-1", baggage["gen_ai.conversation.id"]); + Assert.Equal("https://smba.example/", baggage["microsoft.conversation.item.link"]); + Assert.Equal("msteams", baggage["microsoft.channel.name"]); + Assert.Equal("agentic-app-1", baggage["gen_ai.agent.id"]); + Assert.Equal("Agent", baggage["gen_ai.agent.name"]); + Assert.Equal("auid-1", baggage["microsoft.agent.user.id"]); + Assert.Equal("blueprint-1", baggage["microsoft.a365.agent.blueprint.id"]); + Assert.Equal("User One", baggage["user.name"]); + } + + [Fact] + public void FromTeamsContext_FallsBackToTypedChannelDataTenantId() + { + MessageActivity activity = new() + { + Id = "act-1", + ChannelId = "msteams", + Conversation = new TeamsConversation { Id = "conv-1" }, + Recipient = new TeamsConversationAccount { Id = "agent" /* no TenantId */ }, + ChannelData = new TeamsChannelData + { + Tenant = new TeamsChannelDataTenant { Id = "tenant-from-channeldata" }, + }, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromTeamsContext(BuildCtx(activity))); + + Assert.Equal("tenant-from-channeldata", baggage["microsoft.tenant.id"]); + } + + [Fact] + public void FromTeamsContext_DoesNotEmitChannelLink() + { + MessageActivity activity = new() + { + Id = "act-1", + ChannelId = "msteams", + Conversation = new TeamsConversation { Id = "conv-1" }, + Recipient = new TeamsConversationAccount { Id = "agent", TenantId = "t" }, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromTeamsContext(BuildCtx(activity))); + + Assert.False(baggage.ContainsKey("microsoft.channel.link")); + } + + [Fact] + public void Build_DisposeRestoresPreviousBaggage() + { + Baggage previous = Baggage.Current; + Baggage.Current = default; + try + { + using (new BaggageBuilder().UserId("u").UserEmail("u@example.com").Build()) + { + Assert.Equal("u", Baggage.GetBaggage("user.id")); + Assert.Equal("u@example.com", Baggage.GetBaggage("user.email")); + } + + Assert.Null(Baggage.GetBaggage("user.id")); + Assert.Null(Baggage.GetBaggage("user.email")); + } + finally + { + Baggage.Current = previous; + } + } + + [Fact] + public void AppsOnlySetters_SetExpectedKeys() + { + Dictionary baggage = ApplyAndCapture(b => b + .UserId("u-id") + .UserEmail("u@x.com") + .AgentDescription("agent") + .AgenticUserEmail("a@x.com")); + + Assert.Equal("u-id", baggage["user.id"]); + Assert.Equal("u@x.com", baggage["user.email"]); + Assert.Equal("agent", baggage["gen_ai.agent.description"]); + Assert.Equal("a@x.com", baggage["microsoft.agent.user.email"]); + } + + private static Context BuildCtx(TeamsActivity activity) => new(null!, activity); + + private static Dictionary ApplyAndCapture(Action configure) + { + Baggage previous = Baggage.Current; + Baggage.Current = default; + try + { + BaggageBuilder builder = new(); + configure(builder); + using (builder.Build()) + { + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (KeyValuePair kvp in Baggage.Current.GetBaggage()) + { + snapshot[kvp.Key] = kvp.Value; + } + return snapshot; + } + } + finally + { + Baggage.Current = previous; + } + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs new file mode 100644 index 000000000..030598bfb --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Diagnostics; + +namespace Microsoft.Teams.Apps.UnitTests; + +public class RouterTelemetryTests +{ + [Fact] + public async Task DispatchAsync_EmitsHandlerSpanWithTypeDispatch() + { + using SpanCapture capture = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = (_, _) => Task.CompletedTask, + }); + + await router.DispatchAsync(BuildCtx(new MessageActivity { Type = TeamsActivityType.Message })); + + Activity span = Assert.Single(capture.Stopped, a => a.OperationName == "handler"); + Assert.Equal("message", span.GetTagItem("handler.type")); + Assert.Equal("type", span.GetTagItem("handler.dispatch")); + } + + [Fact] + public async Task DispatchWithReturnAsync_InvokeCatchallEmitsCatchallDispatch() + { + using SpanCapture capture = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Invoke, + Selector = _ => true, + HandlerWithReturn = (_, _) => Task.FromResult(new InvokeResponse(200)), + }); + + InvokeResponse response = await router.DispatchWithReturnAsync(BuildCtx(new InvokeActivity { Type = TeamsActivityType.Invoke, Name = "tab/fetch" })); + Assert.Equal(200, response.Status); + + Activity span = Assert.Single(capture.Stopped, a => a.OperationName == "handler"); + Assert.Equal("invoke", span.GetTagItem("handler.type")); + Assert.Equal("catchall", span.GetTagItem("handler.dispatch")); + } + + [Fact] + public async Task DispatchWithReturnAsync_SpecificInvokeEmitsInvokeDispatch() + { + using SpanCapture capture = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = $"{TeamsActivityType.Invoke}/tab/fetch", + Selector = _ => true, + HandlerWithReturn = (_, _) => Task.FromResult(new InvokeResponse(200)), + }); + + await router.DispatchWithReturnAsync(BuildCtx(new InvokeActivity { Type = TeamsActivityType.Invoke, Name = "tab/fetch" })); + + Activity span = Assert.Single(capture.Stopped, a => a.OperationName == "handler"); + Assert.Equal("tab/fetch", span.GetTagItem("handler.type")); + Assert.Equal("invoke", span.GetTagItem("handler.dispatch")); + } + + [Fact] + public async Task DispatchAsync_HandlerThrows_RecordsExceptionOnSpan() + { + using SpanCapture capture = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = (_, _) => throw new InvalidOperationException("handler failed"), + }); + + await Assert.ThrowsAsync(() => + router.DispatchAsync(BuildCtx(new MessageActivity { Type = TeamsActivityType.Message }))); + + Activity span = Assert.Single(capture.Stopped, a => a.OperationName == "handler"); + Assert.Equal(ActivityStatusCode.Error, span.Status); + Assert.Contains(span.Events, e => e.Name == "exception"); + } + + private static Context BuildCtx(TeamsActivity activity) => new(null!, activity); + + private sealed class SpanCapture : IDisposable + { + private readonly ActivityListener _listener; + public List Stopped { get; } = []; + + public SpanCapture() + { + _listener = new ActivityListener + { + ShouldListenTo = src => src.Name == TeamsBotApplicationTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => + { + lock (Stopped) { Stopped.Add(a); } + }, + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() => _listener.Dispose(); + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/AssemblyInfo.cs b/core/test/Microsoft.Teams.Core.UnitTests/AssemblyInfo.cs new file mode 100644 index 000000000..eeb5ae778 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +// Tests in this assembly use process-global state (System.Diagnostics.ActivitySource listeners, +// System.Diagnostics.Metrics.Meter listeners, OpenTelemetry.Baggage.Current). Running them in +// parallel causes captures from one test to observe spans/metrics started in another. Disabling +// parallelization keeps the captures isolated. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs new file mode 100644 index 000000000..66c6bef3b --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Core.Diagnostics; +using Microsoft.Teams.Core.Schema; +using OpenTelemetry; + +namespace Microsoft.Teams.Core.UnitTests.Diagnostics; + +public class BaggageBuilderTests +{ + [Fact] + public void FromCoreActivity_PopulatesExpectedKeysFromTypedFields() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act-1", + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new("conv-1"), + From = new() { Id = "from-1", Name = "User One" }, + Recipient = new() + { + Id = "agent-id", + Name = "Agent", + AgenticAppId = "agentic-app-1", + AgenticUserId = "auid-1", + AgenticAppBlueprintId = "blueprint-1", + TenantId = "tenant-1", + }, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromCoreActivity(activity)); + + Assert.Equal("tenant-1", baggage["microsoft.tenant.id"]); + Assert.Equal("conv-1", baggage["gen_ai.conversation.id"]); + Assert.Equal("https://smba.example/", baggage["microsoft.conversation.item.link"]); + Assert.Equal("msteams", baggage["microsoft.channel.name"]); + Assert.Equal("agentic-app-1", baggage["gen_ai.agent.id"]); // AgenticAppId wins over Id + Assert.Equal("Agent", baggage["gen_ai.agent.name"]); + Assert.Equal("auid-1", baggage["microsoft.agent.user.id"]); + Assert.Equal("blueprint-1", baggage["microsoft.a365.agent.blueprint.id"]); + Assert.Equal("User One", baggage["user.name"]); + } + + [Fact] + public void FromCoreActivity_AgentIdFallsBackToRecipientIdWhenNoAgenticAppId() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "msteams", + Conversation = new("conv-x"), + Recipient = new() { Id = "plain-recipient-id", Name = "Bot" }, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromCoreActivity(activity)); + + Assert.Equal("plain-recipient-id", baggage["gen_ai.agent.id"]); + } + + [Fact] + public void FromCoreActivity_FallsBackToChannelDataTenantIdWhenRecipientTenantIdIsNull() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "msteams", + Conversation = new("conv-1"), + Recipient = new() { Id = "r", Name = "R" /* no TenantId */ }, + }; + // Plant a channelData object with tenant.id, simulating classic Teams Bot Framework JSON. + JsonElement channelData = JsonSerializer.SerializeToElement(new + { + tenant = new { id = "tenant-from-channeldata" }, + }); + activity.Properties["channelData"] = channelData; + + Dictionary baggage = ApplyAndCapture(b => b.FromCoreActivity(activity)); + + Assert.Equal("tenant-from-channeldata", baggage["microsoft.tenant.id"]); + } + + [Fact] + public void FromCoreActivity_DoesNotEmitChannelLink() + { + CoreActivity activity = new() + { + Type = ActivityType.Message, + ChannelId = "msteams", + Conversation = new("conv-1"), + Recipient = new() { Id = "r" }, + }; + + Dictionary baggage = ApplyAndCapture(b => b.FromCoreActivity(activity)); + + Assert.False(baggage.ContainsKey("microsoft.channel.link")); + } + + [Fact] + public void Build_NullAndWhitespaceValuesAreSkipped() + { + Dictionary baggage = ApplyAndCapture(b => b + .ConversationId(null) + .ConversationId(" ") + .ConversationId("conv-keep") + .TenantId("")); + + Assert.Equal("conv-keep", baggage["gen_ai.conversation.id"]); + Assert.False(baggage.ContainsKey("microsoft.tenant.id")); + } + + [Fact] + public void Build_DisposeRestoresPreviousBaggage() + { + Baggage initial = Baggage.Current.SetBaggage("preexisting", "yes"); + Baggage.Current = initial; + try + { + using (new BaggageBuilder().TenantId("tenant-x").Build()) + { + Assert.Equal("tenant-x", Baggage.GetBaggage("microsoft.tenant.id")); + Assert.Equal("yes", Baggage.GetBaggage("preexisting")); + } + + Assert.Null(Baggage.GetBaggage("microsoft.tenant.id")); + Assert.Equal("yes", Baggage.GetBaggage("preexisting")); + } + finally + { + Baggage.Current = default; + } + } + + [Fact] + public void OperationSource_SetsServiceName() + { + Dictionary baggage = ApplyAndCapture(b => b.OperationSource("teams-bot")); + Assert.Equal("teams-bot", baggage["service.name"]); + } + + [Fact] + public void InvokeAgentServer_OmitsPortWhen443() + { + Dictionary baggage443 = ApplyAndCapture(b => b.InvokeAgentServer("api.example.com", 443)); + Assert.Equal("api.example.com", baggage443["server.address"]); + Assert.False(baggage443.ContainsKey("server.port")); + + Dictionary baggage8080 = ApplyAndCapture(b => b.InvokeAgentServer("api.example.com", 8080)); + Assert.Equal("8080", baggage8080["server.port"]); + } + + [Fact] + public void Set_EscapeHatchAcceptsAnyKey() + { + Dictionary baggage = ApplyAndCapture(b => b.Set("user.id", "aad-123")); + Assert.Equal("aad-123", baggage["user.id"]); + } + + private static Dictionary ApplyAndCapture(Action configure) + { + Baggage previous = Baggage.Current; + Baggage.Current = default; + try + { + BaggageBuilder builder = new(); + configure(builder); + using (builder.Build()) + { + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (KeyValuePair kvp in Baggage.Current.GetBaggage()) + { + snapshot[kvp.Key] = kvp.Value; + } + return snapshot; + } + } + finally + { + Baggage.Current = previous; + } + } +} diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs new file mode 100644 index 000000000..7957118cf --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Teams.Core.Diagnostics; +using Microsoft.Teams.Core.Schema; +using Moq; +using Moq.Protected; + +namespace Microsoft.Teams.Core.UnitTests.Diagnostics; + +public class TelemetryTests +{ + [Fact] + public void TeamsCoreTelemetry_ConstantsHaveExpectedValues() + { + Assert.Equal("Microsoft.Teams.Core", TeamsCoreTelemetry.ActivitySourceName); + Assert.Equal("Microsoft.Teams.Core", TeamsCoreTelemetry.MeterName); + } + + [Fact] + public async Task ProcessAsync_EmitsTurnSpanWithExpectedTags() + { + using SpanCapture capture = new(); + + BotApplication botApp = CreateBotApplication(); + botApp.OnActivity = (_, _) => Task.CompletedTask; + + CoreActivity activity = new() + { + Type = ActivityType.Message, + Id = "act-1", + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new("conv-1"), + }; + + await botApp.ProcessAsync(BuildHttpContext(activity)); + + Activity turn = Assert.Single(capture.Stopped, a => a.OperationName == "turn"); + Assert.Equal("act-1", turn.GetTagItem("activity.id")); + Assert.Equal("msteams", turn.GetTagItem("channel.id")); + Assert.Equal("conv-1", turn.GetTagItem("conversation.id")); + Assert.Equal("https://smba.example/", turn.GetTagItem("service.url")); + Assert.Equal(ActivityStatusCode.Unset, turn.Status); + } + + [Fact] + public async Task ProcessAsync_NestsMiddlewareSpansUnderTurn() + { + using SpanCapture capture = new(); + + BotApplication botApp = CreateBotApplication(); + Mock mw = new(); + mw.Setup(m => m.OnTurnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, next, ct) => next(ct)); + botApp.UseMiddleware(mw.Object); + botApp.OnActivity = (_, _) => Task.CompletedTask; + + await botApp.ProcessAsync(BuildHttpContext(NewActivity())); + + Activity turn = Assert.Single(capture.Stopped, a => a.OperationName == "turn"); + Activity middleware = Assert.Single(capture.Stopped, a => a.OperationName == "middleware"); + Assert.Equal(turn.SpanId, middleware.ParentSpanId); + Assert.Equal(0, middleware.GetTagItem("middleware.index")); + Assert.NotNull(middleware.GetTagItem("middleware.name")); + } + + [Fact] + public async Task ProcessAsync_RecordsExceptionOnTurnSpanAndIncrementsErrorCounter() + { + using SpanCapture spanCapture = new(); + using MetricCapture metricCapture = new(); + + BotApplication botApp = CreateBotApplication(); + botApp.OnActivity = (_, _) => throw new InvalidOperationException("boom"); + + await Assert.ThrowsAsync(() => + botApp.ProcessAsync(BuildHttpContext(NewActivity()))); + + Activity turn = Assert.Single(spanCapture.Stopped, a => a.OperationName == "turn"); + Assert.Equal(ActivityStatusCode.Error, turn.Status); + Assert.Contains(turn.Events, e => e.Name == "exception"); + + Assert.True(metricCapture.GetCounterTotal("teams.handler.errors") >= 1); + Assert.True(metricCapture.GetCounterTotal("teams.activities.received") >= 1); + Assert.True(metricCapture.HistogramSampleCount("teams.turn.duration") >= 1); + } + + [Fact] + public async Task ConversationClient_SendActivityAsync_EmitsConversationClientSpanAndOutboundCallsCounter() + { + using SpanCapture spanCapture = new(); + using MetricCapture metricCapture = new(); + + Mock handler = new(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"sent-1\"}"), + }); + + ConversationClient client = new(new HttpClient(handler.Object)); + + SendActivityResponse? response = await client.SendActivityAsync(new CoreActivity + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new("conv-1"), + }); + + Assert.NotNull(response); + Activity span = Assert.Single(spanCapture.Stopped, a => a.OperationName == "conversation_client"); + Assert.Equal("sendActivity", span.GetTagItem("operation")); + Assert.Equal("conv-1", span.GetTagItem("conversation.id")); + Assert.Equal("sent-1", span.GetTagItem("activity.id")); + + Assert.Equal(1, metricCapture.GetCounterTotal("teams.outbound.calls")); + Assert.Equal(0, metricCapture.GetCounterTotal("teams.outbound.errors")); + } + + [Fact] + public async Task ConversationClient_SendActivityAsync_RecordsErrorOnFailure() + { + using SpanCapture spanCapture = new(); + using MetricCapture metricCapture = new(); + + Mock handler = new(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("network down")); + + ConversationClient client = new(new HttpClient(handler.Object)); + + await Assert.ThrowsAsync(() => client.SendActivityAsync(new CoreActivity + { + Type = ActivityType.Message, + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new("conv-1"), + })); + + Activity span = Assert.Single(spanCapture.Stopped, a => a.OperationName == "conversation_client"); + Assert.Equal(ActivityStatusCode.Error, span.Status); + Assert.Equal(1, metricCapture.GetCounterTotal("teams.outbound.errors")); + Assert.Equal(0, metricCapture.GetCounterTotal("teams.outbound.calls")); + } + + private static CoreActivity NewActivity() => new() + { + Type = ActivityType.Message, + Id = "act-test", + ChannelId = "msteams", + ServiceUrl = new Uri("https://smba.example/"), + Conversation = new("conv-test"), + }; + + private static DefaultHttpContext BuildHttpContext(CoreActivity activity) + { + DefaultHttpContext ctx = new(); + ctx.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(activity.ToJson())); + ctx.Request.ContentType = "application/json"; + return ctx; + } + + private static BotApplication CreateBotApplication() + { + ConversationClient cc = new(new HttpClient(Mock.Of())); + UserTokenClient ut = new(new HttpClient(Mock.Of()), Mock.Of(), NullLogger.Instance); + return new BotApplication(cc, ut, NullLogger.Instance); + } + + /// + /// Test harness: subscribes an to the SDK's source and records every span. + /// + private sealed class SpanCapture : IDisposable + { + private readonly ActivityListener _listener; + public List Stopped { get; } = []; + + public SpanCapture() + { + _listener = new ActivityListener + { + ShouldListenTo = src => src.Name == TeamsCoreTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => + { + lock (Stopped) { Stopped.Add(a); } + }, + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() => _listener.Dispose(); + } + + /// + /// Test harness: subscribes a to the SDK's meter and aggregates emitted measurements. + /// + private sealed class MetricCapture : IDisposable + { + private readonly MeterListener _listener; + private readonly Dictionary _counterTotals = new(StringComparer.Ordinal); + private readonly Dictionary _histogramSamples = new(StringComparer.Ordinal); + + public MetricCapture() + { + _listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == TeamsCoreTelemetry.MeterName) + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + _listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + lock (_counterTotals) + { + _counterTotals.TryGetValue(instrument.Name, out long total); + _counterTotals[instrument.Name] = total + value; + } + }); + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + lock (_histogramSamples) + { + _histogramSamples.TryGetValue(instrument.Name, out int count); + _histogramSamples[instrument.Name] = count + 1; + } + }); + _listener.Start(); + } + + public long GetCounterTotal(string name) + { + lock (_counterTotals) + { + return _counterTotals.TryGetValue(name, out long total) ? total : 0; + } + } + + public int HistogramSampleCount(string name) + { + lock (_histogramSamples) + { + return _histogramSamples.TryGetValue(name, out int count) ? count : 0; + } + } + + public void Dispose() => _listener.Dispose(); + } +} From 1e8f6969d9f5e8bb92d4572960e6b97d8225d3e7 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 4 May 2026 14:37:55 -0700 Subject: [PATCH 02/24] Refactor telemetry naming; add OpenTelemetry & OpenAI Replaces TeamsCoreTelemetry with CoreTelemetryNames for ActivitySource and Meter names. Integrates OpenTelemetry tracing/metrics and Azure OpenAI chat client in Program.cs. Adds ModelContextProtocol tools and citation processing. Updates dependencies and unit tests to use new telemetry naming. --- .../ObservabilityBot/ObservabilityBot.csproj | 13 +- core/samples/ObservabilityBot/Program.cs | 184 ++++++++++++++---- ...CoreTelemetry.cs => CoreTelemetryNames.cs} | 6 +- .../Diagnostics/Telemetry.cs | 4 +- .../Diagnostics/TelemetryTests.cs | 8 +- 5 files changed, 171 insertions(+), 44 deletions(-) rename core/src/Microsoft.Teams.Core/Diagnostics/{TeamsCoreTelemetry.cs => CoreTelemetryNames.cs} (89%) diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index ff964b617..1724ae480 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -7,11 +7,20 @@ - + + + + + + + + + + - + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 4f1d28db4..d159ef7b6 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -1,58 +1,176 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; +using System.Text.Json; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; using Microsoft.OpenTelemetry; -using Microsoft.Teams.Core; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Diagnostics; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; using Microsoft.Teams.Core.Diagnostics; -using Microsoft.Teams.Core.Hosting; -using Microsoft.Teams.Core.Schema; +using ModelContextProtocol.Client; using OpenTelemetry; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; +using OpenTelemetry.Resources; -// Each Teams SDK layer publishes its own ActivitySource / Meter; register all you use. -// Microsoft.Teams.Core -> turn / middleware / auth.outbound / conversation_client -// Microsoft.Teams.Apps -> handler (only if you use the Apps router; not in this sample) -string[] activitySources = [TeamsCoreTelemetry.ActivitySourceName]; -string[] meterNames = [TeamsCoreTelemetry.MeterName]; -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +string[] activitySources = [CoreTelemetryNames.ActivitySourceName, TeamsBotApplicationTelemetry.ActivitySourceName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"]; +string[] meterNames = [CoreTelemetryNames.MeterName, TeamsBotApplicationTelemetry.MeterName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"]; -builder.Services.AddBotApplication(); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -// Wire the Microsoft OpenTelemetry distro and subscribe to the Teams SDK's -// ActivitySource and Meter. Exporters are auto-detected from environment: -// APPLICATIONINSIGHTS_CONNECTION_STRING -> Azure Monitor -// OTEL_EXPORTER_OTLP_ENDPOINT -> OTLP collector (Aspire / Grafana LGTM / Jaeger) -// Console export is enabled below for local debugging. builder.Services.AddOpenTelemetry() - .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.Otlp) + .ConfigureResource(r => r + .AddService(serviceName: "ObservabilityBot", serviceVersion: "0.0.1") + .AddAttributes(new Dictionary + { + ["deployment.environment"] = builder.Environment.EnvironmentName, + ["service.namespace"] = "Microsoft.Teams" + })) + .UseMicrosoftOpenTelemetry(o => { + o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; + o.Instrumentation.EnableAspNetCoreInstrumentation = true; + o.Instrumentation.EnableHttpClientInstrumentation = true; + }) .WithTracing(t => t.AddSource(activitySources)) .WithMetrics(m => m.AddMeter(meterNames)); builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidDataException("AZURE_OPENAI_ENDPOINT not found"); +var azoai_key = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY") ?? throw new InvalidDataException("AZURE_OPENAI_KEY not found"); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? throw new InvalidDataException("AZURE_OPENAI_DEPLOYMENT not found"); + + +IChatClient client = + new ChatClientBuilder( + new AzureOpenAIClient(new Uri(endpoint), new System.ClientModel.ApiKeyCredential(azoai_key)) + .GetChatClient(deploymentName) + .AsIChatClient()) + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName: "Experimental.Microsoft.Extensions.AI") + .UseLogging(LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information))) + .Build(); + +var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport(new() + { + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "msdocs" + })); + +var tools = await mcpClient.ListToolsAsync(); +Console.WriteLine("Tools Found: " + string.Join(", ", tools.Select(t => t.Name))); + +var chatOptions = new ChatOptions +{ + AllowMultipleToolCalls = true, + Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information.", + Tools = [.. tools] +}; + +builder.Services.AddTeamsBotApplication(); WebApplication app = builder.Build(); -app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + TeamsCoreTelemetry.ActivitySourceName); +app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + CoreTelemetryNames.ActivitySourceName); -BotApplication botApp = app.UseBotApplication(); +var teamsApp = app.UseTeamsBotApplication(); -botApp.OnActivity = async (activity, cancellationToken) => +var chatHistories = new ConcurrentDictionary>(); +teamsApp.OnMessage(async (context, ct) => { - ArgumentNullException.ThrowIfNull(activity.Conversation); - - CoreActivity reply = CoreActivity.CreateBuilder() - .WithType(ActivityType.Message) - .WithChannelId(activity.ChannelId) - .WithServiceUrl(activity.ServiceUrl) - .WithConversation(activity.Conversation) - .WithFrom(activity.Recipient) - .WithProperty("text", $"ObservabilityBot received `{activity.Type}` (SDK {BotApplication.Version}).") + ArgumentNullException.ThrowIfNull(context.Activity); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation.Id); + + if (context.Activity.TextWithoutMentions == "--diag") return; + + await context.Typing(string.Empty, ct); + + var conversationId = context.Activity.Conversation.Id; + var history = chatHistories.GetOrAdd(conversationId, _ => []); + + lock (history) + { + history.Add(new ChatMessage(ChatRole.User, context.Activity.Text)); + } + + var (responseText, citations) = await GetChatResponseAsync(history); + + var responseMsg = TeamsActivity.CreateBuilder() + .WithText(responseText, TextFormats.Markdown) + .AddMention(context.Activity?.From!) .Build(); - await botApp.SendActivityAsync(reply, cancellationToken: cancellationToken); -}; + responseMsg.AddAIGenerated(); + + for (int i = 0; i < citations.Count; i++) + { + var citation = citations[i]; + var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; + responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); + } + + await context.Send(responseMsg, ct); +}); app.Run(); + +async Task<(string ResponseText, List<(string Title, string Url, string Content)> Citations)> GetChatResponseAsync(List history) +{ + List snapshot; + lock (history) + { + snapshot = [.. history]; + } + + ChatResponse response = await client.GetResponseAsync(snapshot, chatOptions); + + lock (history) + { + history.AddRange(response.Messages); + } + + var toolsUsed = response.Messages.SelectMany(m => m.Contents.OfType()); + Console.WriteLine("Tools used " + toolsUsed.Count()); + + var citations = response.Messages + .SelectMany(m => m.Contents.OfType()) + .Where(frc => frc.Result is not null) + .SelectMany(frc => + { + try + { + var json = JsonSerializer.Deserialize(frc.Result!.ToString()!); + if (json.TryGetProperty("structuredContent", out var sc) && + sc.TryGetProperty("results", out var results)) + { + return results.EnumerateArray() + .Where(r => r.TryGetProperty("contentUrl", out _)) + .Select(r => ( + Title: r.GetProperty("title").GetString() ?? "", + Url: r.GetProperty("contentUrl").GetString() ?? "", + Content: r.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "" + )); + } + } + catch { } + return []; + }) + .DistinctBy(c => c.Url) + .Take(5).ToList(); + + var responseText = response.Text; + + for (int i = 1; i < citations.Count; i++) + { + responseText += $"[{i}] "; + } + + return (responseText, citations); +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/CoreTelemetryNames.cs similarity index 89% rename from core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs rename to core/src/Microsoft.Teams.Core/Diagnostics/CoreTelemetryNames.cs index fcab8be81..242aaa896 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/TeamsCoreTelemetry.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreTelemetryNames.cs @@ -17,11 +17,11 @@ namespace Microsoft.Teams.Core.Diagnostics; /// the full bot pipeline. /// /// builder.Services.AddOpenTelemetry() -/// .WithTracing(t => t.AddSource(TeamsCoreTelemetry.ActivitySourceName)) -/// .WithMetrics(m => m.AddMeter(TeamsCoreTelemetry.MeterName)); +/// .WithTracing(t => t.AddSource(CoreTelemetryNames.ActivitySourceName)) +/// .WithMetrics(m => m.AddMeter(CoreTelemetryNames.MeterName)); /// /// -public static class TeamsCoreTelemetry +public static class CoreTelemetryNames { /// /// Name of the that emits Core pipeline spans. diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs index 6dbb530db..18817c8f9 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -17,10 +17,10 @@ internal static class Telemetry private const string s_version = ThisAssembly.NuGetPackageVersion; public static readonly ActivitySource Source = - new(TeamsCoreTelemetry.ActivitySourceName, s_version); + new(CoreTelemetryNames.ActivitySourceName, s_version); public static readonly Meter Meter = - new(TeamsCoreTelemetry.MeterName, s_version); + new(CoreTelemetryNames.MeterName, s_version); public static readonly Counter ActivitiesReceived = Meter.CreateCounter("teams.activities.received", description: "Total activities received by the bot."); diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs index 7957118cf..86df6a3fd 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs @@ -20,8 +20,8 @@ public class TelemetryTests [Fact] public void TeamsCoreTelemetry_ConstantsHaveExpectedValues() { - Assert.Equal("Microsoft.Teams.Core", TeamsCoreTelemetry.ActivitySourceName); - Assert.Equal("Microsoft.Teams.Core", TeamsCoreTelemetry.MeterName); + Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.ActivitySourceName); + Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.MeterName); } [Fact] @@ -188,7 +188,7 @@ public SpanCapture() { _listener = new ActivityListener { - ShouldListenTo = src => src.Name == TeamsCoreTelemetry.ActivitySourceName, + ShouldListenTo = src => src.Name == CoreTelemetryNames.ActivitySourceName, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStopped = a => { @@ -216,7 +216,7 @@ public MetricCapture() { InstrumentPublished = (instrument, listener) => { - if (instrument.Meter.Name == TeamsCoreTelemetry.MeterName) + if (instrument.Meter.Name == CoreTelemetryNames.MeterName) { listener.EnableMeasurementEvents(instrument); } From b1612cb7c19e9d244f103b0dc99dc48eb9bd36f9 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 4 May 2026 18:53:34 -0700 Subject: [PATCH 03/24] configuring token manager --- core/samples/ObservabilityBot/Program.cs | 24 +++++++++++++++---- .../samples/ObservabilityBot/appsettings.json | 3 ++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index d159ef7b6..98a53d5a3 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -5,6 +5,8 @@ using System.Text.Json; using Azure.AI.OpenAI; using Microsoft.Extensions.AI; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; using Microsoft.OpenTelemetry; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Diagnostics; @@ -21,6 +23,8 @@ string[] meterNames = [CoreTelemetryNames.MeterName, TeamsBotApplicationTelemetry.MeterName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"]; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +IServiceProvider? rootProvider = null; +builder.Services.AddTeamsBotApplication(); builder.Services.AddOpenTelemetry() .ConfigureResource(r => r @@ -31,9 +35,18 @@ ["service.namespace"] = "Microsoft.Teams" })) .UseMicrosoftOpenTelemetry(o => { + o.Instrumentation.EnableHttpClientInstrumentation = true; o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableAspNetCoreInstrumentation = true; - o.Instrumentation.EnableHttpClientInstrumentation = true; + o.Agent365.Exporter.TokenResolver = async (agentId, tenantId) => + { + var provider = rootProvider!.GetRequiredService(); + var options = new AuthorizationHeaderProviderOptions { AcquireTokenOptions = new() { AuthenticationOptionsName = "AzureAd", Tenant = tenantId } }; + options.WithAgentIdentity(agentId); + var token = await provider.CreateAuthorizationHeaderForAppAsync( + "9b975845-388f-4429-889e-eab1ef63949c/.default", options); + return token; + }; }) .WithTracing(t => t.AddSource(activitySources)) .WithMetrics(m => m.AddMeter(meterNames)); @@ -74,9 +87,9 @@ Tools = [.. tools] }; -builder.Services.AddTeamsBotApplication(); -WebApplication app = builder.Build(); +WebApplication app = builder.Build(); +rootProvider = app.Services; app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + CoreTelemetryNames.ActivitySourceName); var teamsApp = app.UseTeamsBotApplication(); @@ -88,7 +101,10 @@ ArgumentNullException.ThrowIfNull(context.Activity.Conversation); ArgumentNullException.ThrowIfNull(context.Activity.Conversation.Id); - if (context.Activity.TextWithoutMentions == "--diag") return; + using var baggageScope = new Microsoft.Teams.Apps.Diagnostics.BaggageBuilder() + .FromTeamsContext(context) + .OperationSource("ObservabilityBot") + .Build(); await context.Typing(string.Empty, ct); diff --git a/core/samples/ObservabilityBot/appsettings.json b/core/samples/ObservabilityBot/appsettings.json index 5febf4fe3..a8a4c49fb 100644 --- a/core/samples/ObservabilityBot/appsettings.json +++ b/core/samples/ObservabilityBot/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Teams": "Information" + "Microsoft.Teams": "Trace", + "Microsoft.Agents.A365.Observability": "Debug" } }, "AllowedHosts": "*" From 3a483e7743358ef1794b468d933ee17d9a4328d3 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 6 May 2026 18:32:53 -0700 Subject: [PATCH 04/24] Update to .NET 10, OpenTelemetry 1.0.2, improve Agent365 Updated target framework to net10.0 and upgraded Microsoft.OpenTelemetry to 1.0.2. Enabled S2S endpoint for Agent365 exporter and adjusted TokenResolver to use the correct API scope and return the token without the "Bearer" prefix. --- core/samples/ObservabilityBot/ObservabilityBot.csproj | 4 ++-- core/samples/ObservabilityBot/Program.cs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index 1724ae480..46e0e3a50 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 98a53d5a3..32d0162c2 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -38,14 +38,15 @@ o.Instrumentation.EnableHttpClientInstrumentation = true; o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableAspNetCoreInstrumentation = true; + o.Agent365.Exporter.UseS2SEndpoint = true; o.Agent365.Exporter.TokenResolver = async (agentId, tenantId) => { var provider = rootProvider!.GetRequiredService(); var options = new AuthorizationHeaderProviderOptions { AcquireTokenOptions = new() { AuthenticationOptionsName = "AzureAd", Tenant = tenantId } }; options.WithAgentIdentity(agentId); var token = await provider.CreateAuthorizationHeaderForAppAsync( - "9b975845-388f-4429-889e-eab1ef63949c/.default", options); - return token; + "api://9b975845-388f-4429-889e-eab1ef63949c/.default", options); + return token.Substring("Bearer".Length).Trim(); }; }) .WithTracing(t => t.AddSource(activitySources)) From 37ab50c5f359dac852479781bd67ddd5e8c5a842 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 7 May 2026 10:54:03 -0700 Subject: [PATCH 05/24] Add InvokeAgentScope for Agent365 telemetry integration Introduce InvokeAgentScope to emit Agent365-compatible OpenTelemetry spans for each bot turn, including required tags and message schema. Move BaggageBuilder usage to TeamsBotApplication for consistent baggage propagation. Remove redundant baggage setup in Program.cs. Minor formatting update in AppsTelemetry.cs. Improves observability and telemetry partitioning for Agent365. --- core/samples/ObservabilityBot/Program.cs | 5 - .../Diagnostics/AppsTelemetry.cs | 2 +- .../TeamsBotApplication.cs | 7 + .../Microsoft.Teams.Core/BotApplication.cs | 7 + .../Diagnostics/InvokeAgentScope.cs | 276 ++++++++++++++++++ 5 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 32d0162c2..042a17e4c 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -102,11 +102,6 @@ ArgumentNullException.ThrowIfNull(context.Activity.Conversation); ArgumentNullException.ThrowIfNull(context.Activity.Conversation.Id); - using var baggageScope = new Microsoft.Teams.Apps.Diagnostics.BaggageBuilder() - .FromTeamsContext(context) - .OperationSource("ObservabilityBot") - .Build(); - await context.Typing(string.Empty, ct); var conversationId = context.Activity.Conversation.Id; diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs index aff318734..2014cd6ad 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -13,7 +13,7 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// internal static class AppsTelemetry { - private static readonly string s_version = + private static readonly string s_version = typeof(AppsTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion ?? typeof(AppsTelemetry).Assembly.GetName().Version?.ToString() ?? "0.0.0"; diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index 79bcff879..1e9224c82 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Diagnostics; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.OAuth; using Microsoft.Teams.Apps.Routing; @@ -123,6 +124,12 @@ public TeamsBotApplication( Context defaultContext = new(this, teamsActivity); + // Agent365: set baggage (user.id, user.email, agent details, etc.) for all + // child spans. The invoke_agent scope itself is created in Core's ProcessAsync. + using var baggageScope = new BaggageBuilder() + .FromTeamsContext(defaultContext) + .Build(); + if (teamsActivity.Type != TeamsActivityType.Invoke) { await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); diff --git a/core/src/Microsoft.Teams.Core/BotApplication.cs b/core/src/Microsoft.Teams.Core/BotApplication.cs index bd3487ad1..146c3f854 100644 --- a/core/src/Microsoft.Teams.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Core/BotApplication.cs @@ -215,6 +215,12 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke } long startTimestamp = Stopwatch.GetTimestamp(); + + // Agent365: open an invoke_agent scope so the Agent365 exporter can partition + // and export this turn's telemetry. No-op when no listener subscribes to + // ActivitySource("Agent365Sdk"). + using var invokeScope = InvokeAgentScope.Start(activity); + using (_logger.BeginActivityScope(activity.Type, activity.Id, activity.ServiceUrl, correlationVector)) { // Use a dedicated timeout instead of the HTTP request's cancellation token. @@ -236,6 +242,7 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke { _logger.ActivityProcessingError(ex, activity.Id); Telemetry.HandlerErrors.Add(1, activityTypeTag); + invokeScope.RecordError(ex); span.RecordException(ex); throw new BotHandlerException("Error processing activity", ex, activity); } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs new file mode 100644 index 000000000..e38894234 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Lightweight InvokeAgentScope that emits an Activity on the Agent365Sdk +/// ActivitySource with the tags the Agent365 exporter requires. This avoids a heavyweight +/// dependency on Microsoft.OpenTelemetry while producing the same span shape. +/// +/// +/// +/// The Agent365 exporter filters spans by gen_ai.operation.name and partitions +/// by microsoft.tenant.id + gen_ai.agent.id. This scope ensures the +/// Teams SDK's turn processing emits a span that passes both gates. +/// +/// +/// Fields reachable only from the Apps-layer TeamsConversationAccount +/// (user.id, user.email, microsoft.agent.user.email, +/// gen_ai.agent.description) are not set here. They reach the exporter via +/// baggage set by the Apps-layer BaggageBuilder. +/// +/// +internal sealed class InvokeAgentScope : IDisposable +{ + private const string SourceName = "Agent365Sdk"; + private const string OperationName = "invoke_agent"; + private const string ActivityName = "invoke_agent"; + private const string GenAiOperationNameKey = "gen_ai.operation.name"; + private const string GenAiInputMessagesKey = "gen_ai.input.messages"; + private const string GenAiOutputMessagesKey = "gen_ai.output.messages"; + private const string ErrorTypeKey = "error.type"; + private const string DurationMetricName = "gen_ai.client.operation.duration"; + private const string MessageSchemaVersion = "0.1.0"; + + private static readonly ActivitySource s_source = new(SourceName); + private static readonly Meter s_meter = new(SourceName); + private static readonly Histogram s_duration = s_meter.CreateHistogram( + DurationMetricName, "s", "Measures GenAI operation duration."); + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; + + private readonly Activity? _activity; + private readonly long _startTimestamp; + private readonly TagList _metricTags; + private string? _errorType; + private int _disposed; + + private InvokeAgentScope(Activity? activity, TagList metricTags) + { + _activity = activity; + _metricTags = metricTags; + _startTimestamp = Stopwatch.GetTimestamp(); + _activity?.Start(); + } + + /// + /// Starts an invoke_agent scope populated from a . + /// Returns a disposable scope; the underlying Activity is null (no-op) when no listener + /// is subscribed to the Agent365Sdk source. + /// + public static InvokeAgentScope Start(CoreActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + TagList metricTags = new() + { + { GenAiOperationNameKey, OperationName }, + }; + + Activity? otelActivity = s_source.CreateActivity(ActivityName, ActivityKind.Server); + if (otelActivity is null) + { + return new InvokeAgentScope(null, metricTags); + } + + // Operation name — required by Agent365 exporter filter. + otelActivity.SetTag(GenAiOperationNameKey, OperationName); + + // Request-level tags. + SetTagMaybe(otelActivity, AgentObservabilityKeys.ConversationId, activity.Conversation?.Id); + SetTagMaybe(otelActivity, AgentObservabilityKeys.ConversationItemLink, activity.ServiceUrl?.ToString()); + SetTagMaybe(otelActivity, AgentObservabilityKeys.ChannelName, activity.ChannelId); + + if (activity.ServiceUrl is not null) + { + SetTagMaybe(otelActivity, AgentObservabilityKeys.ServerAddress, activity.ServiceUrl.Host); + int port = activity.ServiceUrl.Port; + if (port != 443 && port != -1) + { + otelActivity.SetTag(AgentObservabilityKeys.ServerPort, port); + } + } + + // Target agent (Recipient). + ConversationAccount? recipient = activity.Recipient; + if (recipient is not null) + { + string? agentId = string.IsNullOrWhiteSpace(recipient.AgenticAppId) ? recipient.Id : recipient.AgenticAppId; + SetTagMaybe(otelActivity, AgentObservabilityKeys.AgentId, agentId); + SetTagMaybe(otelActivity, AgentObservabilityKeys.AgentName, recipient.Name); + SetTagMaybe(otelActivity, AgentObservabilityKeys.AgenticUserId, recipient.AgenticUserId); + SetTagMaybe(otelActivity, AgentObservabilityKeys.AgentBlueprintId, recipient.AgenticAppBlueprintId); + SetTagMaybe(otelActivity, AgentObservabilityKeys.TenantId, recipient.TenantId); + } + + // Tenant fallback: parse channelData.tenant.id from extension data (same as Core BaggageBuilder). + if (otelActivity.GetTagItem(AgentObservabilityKeys.TenantId) is null) + { + string? channelTenantId = TryReadChannelDataTenantId(activity); + SetTagMaybe(otelActivity, AgentObservabilityKeys.TenantId, channelTenantId); + } + + // Caller (human user via From) — only Name is available on Core's ConversationAccount. + // user.id, user.email are Apps-only (TeamsConversationAccount) and arrive via baggage. + SetTagMaybe(otelActivity, AgentObservabilityKeys.UserName, activity.From?.Name); + + // Input message from extension-data dictionary. + string? inputText = activity.Properties.TryGetValue("text", out object? textVal) ? textVal?.ToString() : null; + if (!string.IsNullOrEmpty(inputText)) + { + otelActivity.SetTag(GenAiInputMessagesKey, SerializeInputMessages(inputText)); + } + + return new InvokeAgentScope(otelActivity, metricTags); + } + + /// + /// Records output messages on the scope. Call before disposal. + /// + public void RecordOutputMessages(params string[] messages) + { + if (_activity is null || messages.Length == 0) + { + return; + } + + _activity.SetTag(GenAiOutputMessagesKey, SerializeOutputMessages(messages)); + } + + /// + /// Records an error on the scope. + /// + public void RecordError(Exception exception) + { + if (_activity is null) + { + return; + } + + _errorType = exception.GetType().FullName; + _activity.SetStatus(ActivityStatusCode.Error, exception.Message); + _activity.SetTag(ErrorTypeKey, _errorType); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + double durationSeconds = Stopwatch.GetElapsedTime(_startTimestamp).TotalSeconds; + + TagList finalTags = _metricTags; + if (_errorType is not null) + { + finalTags.Add(ErrorTypeKey, _errorType); + } + + s_duration.Record(durationSeconds, finalTags); + _activity?.Dispose(); + } + + private static void SetTagMaybe(Activity activity, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + activity.SetTag(key, value); + } + } + + 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 static string SerializeInputMessages(string text) + { + var envelope = new MessageEnvelope + { + Version = MessageSchemaVersion, + Messages = + [ + new MessageEntry + { + Role = "user", + Parts = [new TextPart { Content = text }], + }, + ], + }; + return JsonSerializer.Serialize(envelope, s_jsonOptions); + } + + private static string SerializeOutputMessages(string[] texts) + { + var messages = new MessageEntry[texts.Length]; + for (int i = 0; i < texts.Length; i++) + { + messages[i] = new MessageEntry + { + Role = "assistant", + Parts = [new TextPart { Content = texts[i] }], + }; + } + + var envelope = new MessageEnvelope { Version = MessageSchemaVersion, Messages = messages }; + return JsonSerializer.Serialize(envelope, s_jsonOptions); + } + + // Minimal DTOs matching the Agent365 message schema. + private sealed class MessageEnvelope + { + public string? Version { get; set; } + public MessageEntry[]? Messages { get; set; } + } + + private sealed class MessageEntry + { + public string? Role { get; set; } + public TextPart[]? Parts { get; set; } + } + + private sealed class TextPart + { + public string Type { get; } = "text"; + public string? Content { get; set; } + } +} From 6318b7401f469b952fcf5c8c565db0f270cd9017 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 7 May 2026 18:50:08 -0700 Subject: [PATCH 06/24] temp otel debugging --- core/core.slnx | 1 + core/samples/ObservabilityBot/ObservabilityBot.csproj | 3 ++- core/samples/ObservabilityBot/Program.cs | 3 ++- core/samples/PABot/PABot.csproj | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/core.slnx b/core/core.slnx index f3a4ca2dc..c5d2ed5b3 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -47,6 +47,7 @@ + diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index 46e0e3a50..b76c4bf82 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -7,7 +7,7 @@ - + @@ -20,6 +20,7 @@ + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 042a17e4c..1772e4200 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -34,7 +34,8 @@ ["deployment.environment"] = builder.Environment.EnvironmentName, ["service.namespace"] = "Microsoft.Teams" })) - .UseMicrosoftOpenTelemetry(o => { + .UseMicrosoftOpenTelemetry(async o => { + o.Instrumentation.EnableHttpClientInstrumentation = true; o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableAspNetCoreInstrumentation = true; diff --git a/core/samples/PABot/PABot.csproj b/core/samples/PABot/PABot.csproj index 59ace680d..171c8d243 100644 --- a/core/samples/PABot/PABot.csproj +++ b/core/samples/PABot/PABot.csproj @@ -12,7 +12,8 @@ - + + From f246f1acbd1196489bb8f1b856dcdbebe530b0c4 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 11 May 2026 10:32:28 -0700 Subject: [PATCH 07/24] Refactor bot logic into ObservabilityBotApp class Moved Teams bot message handling and chat logic from Program.cs into a new ObservabilityBotApp class for better separation of concerns. Updated dependency injection for OpenTelemetry and MCP client. Modified core.slnx to disable building certain projects by default in Debug configuration. --- core/core.slnx | 12 +- .../ObservabilityBot/ObservabilityBotApp.cs | 126 +++++++++++++++ core/samples/ObservabilityBot/Program.cs | 150 ++++-------------- 3 files changed, 167 insertions(+), 121 deletions(-) create mode 100644 core/samples/ObservabilityBot/ObservabilityBotApp.cs diff --git a/core/core.slnx b/core/core.slnx index c5d2ed5b3..d803a84fa 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -19,7 +19,9 @@ - + + + @@ -44,10 +46,14 @@ - + + + - + + + diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs new file mode 100644 index 000000000..dcd0558d3 --- /dev/null +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Teams.Apps; +using Microsoft.Teams.Apps.Api.Clients; +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Apps.Schema.Entities; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Hosting; + +namespace ObservabilityBot; + +public class ObservabilityBotApp : TeamsBotApplication +{ + private readonly IChatClient _chatClient; + private readonly ChatOptions _chatOptions; + private readonly ConcurrentDictionary> _chatHistories = new(); + + public ObservabilityBotApp( + ConversationClient conversationClient, + UserTokenClient userTokenClient, + ApiClient teamsApiClient, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + IChatClient chatClient, + ChatOptions chatOptions, + BotApplicationOptions? options = null, + TeamsBotApplicationOptions? teamsOptions = null) + : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options, teamsOptions) + { + _chatClient = chatClient; + _chatOptions = chatOptions; + + this.OnMessage(HandleMessageAsync); + } + + private async Task HandleMessageAsync(Context context, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(context.Activity); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation); + ArgumentNullException.ThrowIfNull(context.Activity.Conversation.Id); + + await context.Typing(string.Empty, ct); + + var conversationId = context.Activity.Conversation.Id; + var history = _chatHistories.GetOrAdd(conversationId, _ => []); + + lock (history) + { + history.Add(new ChatMessage(ChatRole.User, context.Activity.Text)); + } + + var (responseText, citations) = await GetChatResponseAsync(history); + + var responseMsg = TeamsActivity.CreateBuilder() + .WithText(responseText, TextFormats.Markdown) + .AddMention(context.Activity?.From!) + .Build(); + + responseMsg.AddAIGenerated(); + + for (int i = 0; i < citations.Count; i++) + { + var citation = citations[i]; + var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; + responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); + } + + await context.Send(responseMsg, ct); + } + + private async Task<(string ResponseText, List<(string Title, string Url, string Content)> Citations)> GetChatResponseAsync(List history) + { + List snapshot; + lock (history) + { + snapshot = [.. history]; + } + + ChatResponse response = await _chatClient.GetResponseAsync(snapshot, _chatOptions); + + lock (history) + { + history.AddRange(response.Messages); + } + + var citations = response.Messages + .SelectMany(m => m.Contents.OfType()) + .Where(frc => frc.Result is not null) + .SelectMany(frc => + { + try + { + var json = JsonSerializer.Deserialize(frc.Result!.ToString()!); + if (json.TryGetProperty("structuredContent", out var sc) && + sc.TryGetProperty("results", out var results)) + { + return results.EnumerateArray() + .Where(r => r.TryGetProperty("contentUrl", out _)) + .Select(r => ( + Title: r.GetProperty("title").GetString() ?? "", + Url: r.GetProperty("contentUrl").GetString() ?? "", + Content: r.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "" + )); + } + } + catch { } + return []; + }) + .DistinctBy(c => c.Url) + .Take(5).ToList(); + + var responseText = response.Text; + + for (int i = 1; i < citations.Count; i++) + { + responseText += $"[{i}] "; + } + + return (responseText, citations); + } +} diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 1772e4200..9d58b8ae3 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Concurrent; -using System.Text.Json; using Azure.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Identity.Abstractions; @@ -10,11 +8,9 @@ using Microsoft.OpenTelemetry; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Diagnostics; -using Microsoft.Teams.Apps.Handlers; -using Microsoft.Teams.Apps.Schema; -using Microsoft.Teams.Apps.Schema.Entities; using Microsoft.Teams.Core.Diagnostics; using ModelContextProtocol.Client; +using ObservabilityBot; using OpenTelemetry; using OpenTelemetry.Resources; @@ -24,7 +20,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); IServiceProvider? rootProvider = null; -builder.Services.AddTeamsBotApplication(); +builder.Services.AddTeamsBotApplication(); builder.Services.AddOpenTelemetry() .ConfigureResource(r => r @@ -35,7 +31,7 @@ ["service.namespace"] = "Microsoft.Teams" })) .UseMicrosoftOpenTelemetry(async o => { - + o.Instrumentation.EnableHttpClientInstrumentation = true; o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableAspNetCoreInstrumentation = true; @@ -55,135 +51,53 @@ builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); +// Register MCP client +builder.Services.AddSingleton(async sp => +{ + var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport(new() + { + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "msdocs" + })); + + return mcpClient; +}); +// Register IChatClient var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidDataException("AZURE_OPENAI_ENDPOINT not found"); var azoai_key = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY") ?? throw new InvalidDataException("AZURE_OPENAI_KEY not found"); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? throw new InvalidDataException("AZURE_OPENAI_DEPLOYMENT not found"); - -IChatClient client = +builder.Services.AddSingleton(sp => new ChatClientBuilder( new AzureOpenAIClient(new Uri(endpoint), new System.ClientModel.ApiKeyCredential(azoai_key)) .GetChatClient(deploymentName) .AsIChatClient()) .UseFunctionInvocation() .UseOpenTelemetry(sourceName: "Experimental.Microsoft.Extensions.AI") - .UseLogging(LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information))) - .Build(); - -var mcpClient = await McpClient.CreateAsync( - new HttpClientTransport(new() - { - Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), - TransportMode = HttpTransportMode.AutoDetect, - Name = "msdocs" - })); - -var tools = await mcpClient.ListToolsAsync(); -Console.WriteLine("Tools Found: " + string.Join(", ", tools.Select(t => t.Name))); + .UseLogging(sp.GetRequiredService()) + .Build()); -var chatOptions = new ChatOptions +builder.Services.AddSingleton(async sp => { - AllowMultipleToolCalls = true, - Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information.", - Tools = [.. tools] -}; + var mcpClient = sp.GetRequiredService(); + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine("Tools Found: " + string.Join(", ", tools.Select(t => t.Name))); + return new ChatOptions + { + AllowMultipleToolCalls = true, + Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information.", + Tools = [.. tools] + }; +}); WebApplication app = builder.Build(); rootProvider = app.Services; app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + CoreTelemetryNames.ActivitySourceName); -var teamsApp = app.UseTeamsBotApplication(); - -var chatHistories = new ConcurrentDictionary>(); -teamsApp.OnMessage(async (context, ct) => -{ - ArgumentNullException.ThrowIfNull(context.Activity); - ArgumentNullException.ThrowIfNull(context.Activity.Conversation); - ArgumentNullException.ThrowIfNull(context.Activity.Conversation.Id); - - await context.Typing(string.Empty, ct); - - var conversationId = context.Activity.Conversation.Id; - var history = chatHistories.GetOrAdd(conversationId, _ => []); - - lock (history) - { - history.Add(new ChatMessage(ChatRole.User, context.Activity.Text)); - } - - var (responseText, citations) = await GetChatResponseAsync(history); - - var responseMsg = TeamsActivity.CreateBuilder() - .WithText(responseText, TextFormats.Markdown) - .AddMention(context.Activity?.From!) - .Build(); - - responseMsg.AddAIGenerated(); - - for (int i = 0; i < citations.Count; i++) - { - var citation = citations[i]; - var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; - responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); - } - - await context.Send(responseMsg, ct); -}); +app.UseTeamsBotApplication(); app.Run(); - -async Task<(string ResponseText, List<(string Title, string Url, string Content)> Citations)> GetChatResponseAsync(List history) -{ - List snapshot; - lock (history) - { - snapshot = [.. history]; - } - - ChatResponse response = await client.GetResponseAsync(snapshot, chatOptions); - - lock (history) - { - history.AddRange(response.Messages); - } - - var toolsUsed = response.Messages.SelectMany(m => m.Contents.OfType()); - Console.WriteLine("Tools used " + toolsUsed.Count()); - - var citations = response.Messages - .SelectMany(m => m.Contents.OfType()) - .Where(frc => frc.Result is not null) - .SelectMany(frc => - { - try - { - var json = JsonSerializer.Deserialize(frc.Result!.ToString()!); - if (json.TryGetProperty("structuredContent", out var sc) && - sc.TryGetProperty("results", out var results)) - { - return results.EnumerateArray() - .Where(r => r.TryGetProperty("contentUrl", out _)) - .Select(r => ( - Title: r.GetProperty("title").GetString() ?? "", - Url: r.GetProperty("contentUrl").GetString() ?? "", - Content: r.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "" - )); - } - } - catch { } - return []; - }) - .DistinctBy(c => c.Url) - .Take(5).ToList(); - - var responseText = response.Text; - - for (int i = 1; i < citations.Count; i++) - { - responseText += $"[{i}] "; - } - - return (responseText, citations); -} From ff6c4f594fc41697b2955f5a98c852efd83fc6f8 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 11 May 2026 13:50:57 -0700 Subject: [PATCH 08/24] wip --- core/core.slnx | 8 +--- core/samples/ObservabilityBot/Program.cs | 51 ++++++++++++++++++------ 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/core/core.slnx b/core/core.slnx index d803a84fa..0ad96fd50 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -19,9 +19,7 @@ - - - + @@ -51,9 +49,7 @@ - - - + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 9d58b8ae3..8d703fd29 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -51,19 +51,36 @@ builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); -// Register MCP client -builder.Services.AddSingleton(async sp => -{ - var mcpClient = await McpClient.CreateAsync( +// Register MCP clients +builder.Services.AddKeyedSingleton("msdocs", (sp, key) => + McpClient.CreateAsync( new HttpClientTransport(new() { Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), TransportMode = HttpTransportMode.AutoDetect, Name = "msdocs" - })); + }))); + +var tenantId = builder.Configuration["AzureAd:TenantId"] ?? throw new InvalidDataException("AzureAd:TenantId not found"); + +builder.Services.AddKeyedSingleton("calendarTools", (sp, key) => + McpClient.CreateAsync( + new HttpClientTransport(new() + { + Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "calendarTools" + }))); + +builder.Services.AddKeyedSingleton("disco", (sp, key) => + McpClient.CreateAsync( + new HttpClientTransport(new() + { + Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/discoverMCPServers"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "disco" + }))); - return mcpClient; -}); // Register IChatClient var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidDataException("AZURE_OPENAI_ENDPOINT not found"); @@ -80,17 +97,25 @@ .UseLogging(sp.GetRequiredService()) .Build()); -builder.Services.AddSingleton(async sp => +builder.Services.AddSingleton(sp => { - var mcpClient = sp.GetRequiredService(); - var tools = await mcpClient.ListToolsAsync(); - Console.WriteLine("Tools Found: " + string.Join(", ", tools.Select(t => t.Name))); + var msdocsClient = sp.GetRequiredKeyedService>("msdocs").GetAwaiter().GetResult(); + //var calendarClient = sp.GetRequiredKeyedService>("calendarTools").GetAwaiter().GetResult(); + var discoClient = sp.GetRequiredKeyedService>("disco").GetAwaiter().GetResult(); + + var msdocsTools = msdocsClient.ListToolsAsync().GetAwaiter().GetResult(); + //var calendarTools = calendarClient.ListToolsAsync().GetAwaiter().GetResult(); + var discoTools = discoClient.ListToolsAsync().GetAwaiter().GetResult(); + + //var allTools = msdocsTools.Concat(calendarTools).Concat(discoTools).ToList(); + var allTools = msdocsTools.Concat(discoTools).ToList(); + Console.WriteLine("Tools Found: " + string.Join(", ", allTools.Select(t => t.Name))); return new ChatOptions { AllowMultipleToolCalls = true, - Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information.", - Tools = [.. tools] + Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information. Use calendar tools for scheduling-related queries.", + Tools = [.. allTools] }; }); From 4228e9141ac28311f003506235b7e06fa8ec1f4c Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 13 May 2026 15:10:28 -0700 Subject: [PATCH 09/24] fix after merge --- core/src/Microsoft.Teams.Apps/Routing/Router.cs | 4 ++-- .../Hosting/BotAuthenticationHandler.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/Microsoft.Teams.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Apps/Routing/Router.cs index 9d52d65d8..37868fc27 100644 --- a/core/src/Microsoft.Teams.Apps/Routing/Router.cs +++ b/core/src/Microsoft.Teams.Apps/Routing/Router.cs @@ -109,7 +109,7 @@ public async Task DispatchAsync(Context ctx, CancellationToken ca } catch (Exception ex) { - span.RecordException(ex); + span?.AddException(ex); throw; } @@ -166,7 +166,7 @@ public async Task DispatchWithReturnAsync(Context } catch (Exception ex) { - span.RecordException(ex); + span?.AddException(ex); throw; } diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs index e3d607f0a..598fc8f2b 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs @@ -81,7 +81,7 @@ private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticI { string optionsName = authenticationOptionsName ?? BotConfig.DefaultSectionName; using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.AuthOutbound, ActivityKind.Client); - span?.SetTag(Telemetry.Tags.AuthScope, _scope); + // span?.SetTag(Telemetry.Tags.AuthScope, _scope); //TODO: review try { @@ -96,11 +96,11 @@ private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticI // Conditionally apply ManagedIdentity configuration if registered if (_managedIdentityOptions is not null) { - ManagedIdentityOptions miOptions = _managedIdentityOptions.Value; + ManagedIdentityOptions miOptions = _managedIdentityOptions.CurrentValue; if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) { - _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); + // _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); // TODO: review options.AcquireTokenOptions.ManagedIdentity = miOptions; span?.SetTag(Telemetry.Tags.AuthFlow, "managed_identity"); } From 54f6f90d5480c5b5305af14ec74871e7a9e53c71 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 14 May 2026 13:44:22 -0700 Subject: [PATCH 10/24] wip --- .../ObservabilityBot/ObservabilityBotApp.cs | 106 ++++++++++++++---- core/samples/ObservabilityBot/Program.cs | 22 ++-- 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs index dcd0558d3..5db95fc06 100644 --- a/core/samples/ObservabilityBot/ObservabilityBotApp.cs +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -3,6 +3,8 @@ using System.Collections.Concurrent; using System.Text.Json; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; using Microsoft.Extensions.AI; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Api.Clients; @@ -19,6 +21,7 @@ public class ObservabilityBotApp : TeamsBotApplication private readonly IChatClient _chatClient; private readonly ChatOptions _chatOptions; private readonly ConcurrentDictionary> _chatHistories = new(); + private readonly string _deploymentName; public ObservabilityBotApp( ConversationClient conversationClient, @@ -34,6 +37,7 @@ public ObservabilityBotApp( { _chatClient = chatClient; _chatOptions = chatOptions; + _deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "unknown"; this.OnMessage(HandleMessageAsync); } @@ -54,41 +58,76 @@ private async Task HandleMessageAsync(Context context, Cancella history.Add(new ChatMessage(ChatRole.User, context.Activity.Text)); } - var (responseText, citations) = await GetChatResponseAsync(history); + // Build Agent365 scope contracts from the turn context. + var recipient = context.Activity.Recipient; + var agentDetails = new AgentDetails( + agentId: recipient?.AgenticAppId ?? recipient?.Id, + agentName: recipient?.Name, + agenticUserId: recipient?.AgenticUserId, + agentBlueprintId: recipient?.AgenticAppBlueprintId, + tenantId: recipient?.TenantId); + + var request = new Request( + content: context.Activity.Text, + conversationId: conversationId, + channel: new Channel(context.Activity.ChannelId)); + + // === InferenceScope: wraps the LLM + tool-call loop === + var inferenceDetails = new InferenceCallDetails( + InferenceOperationType.Chat, + model: _deploymentName, + providerName: "AzureOpenAI"); - var responseMsg = TeamsActivity.CreateBuilder() - .WithText(responseText, TextFormats.Markdown) - .AddMention(context.Activity?.From!) - .Build(); - - responseMsg.AddAIGenerated(); + List snapshot; + lock (history) { snapshot = [.. history]; } - for (int i = 0; i < citations.Count; i++) + ChatResponse chatResponse; + using (var inferenceScope = InferenceScope.Start(request, inferenceDetails, agentDetails)) { - var citation = citations[i]; - var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; - responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); - } + chatResponse = await _chatClient.GetResponseAsync(snapshot, _chatOptions, ct); - await context.Send(responseMsg, ct); - } + if (chatResponse.Usage is { } usage) + { + if (usage.InputTokenCount is { } inputTokens) + inferenceScope.RecordInputTokens((int)inputTokens); + if (usage.OutputTokenCount is { } outputTokens) + inferenceScope.RecordOutputTokens((int)outputTokens); + } + + var finishReason = chatResponse.FinishReason?.Value ?? "stop"; + inferenceScope.RecordFinishReasons([finishReason]); + } - private async Task<(string ResponseText, List<(string Title, string Url, string Content)> Citations)> GetChatResponseAsync(List history) - { - List snapshot; lock (history) { - snapshot = [.. history]; + history.AddRange(chatResponse.Messages); } - ChatResponse response = await _chatClient.GetResponseAsync(snapshot, _chatOptions); + // === ExecuteToolScope: record each tool invocation === + var toolCalls = chatResponse.Messages + .SelectMany(m => m.Contents.OfType()) + .GroupBy(fc => fc.CallId ?? fc.Name ?? "") + .ToDictionary(g => g.Key, g => g.First()); - lock (history) + foreach (var funcResult in chatResponse.Messages + .SelectMany(m => m.Contents.OfType())) { - history.AddRange(response.Messages); + toolCalls.TryGetValue(funcResult.CallId ?? "", out var matchingCall); + + var toolDetails = new ToolCallDetails( + toolName: matchingCall?.Name ?? "unknown", + arguments: matchingCall?.Arguments is { } args ? JsonSerializer.Serialize(args) : null, + toolCallId: funcResult.CallId); + + using var toolScope = ExecuteToolScope.Start(request, toolDetails, agentDetails); + if (funcResult.Result is not null) + { + toolScope.RecordResponse(funcResult.Result.ToString()!); + } } - var citations = response.Messages + // Extract citations from tool results. + var citations = chatResponse.Messages .SelectMany(m => m.Contents.OfType()) .Where(frc => frc.Result is not null) .SelectMany(frc => @@ -114,13 +153,32 @@ private async Task HandleMessageAsync(Context context, Cancella .DistinctBy(c => c.Url) .Take(5).ToList(); - var responseText = response.Text; + var responseText = chatResponse.Text; for (int i = 1; i < citations.Count; i++) { responseText += $"[{i}] "; } - return (responseText, citations); + // === OutputScope: record the agent's reply === + using (OutputScope.Start(request, new Response([responseText]), agentDetails)) + { + } + + var responseMsg = TeamsActivity.CreateBuilder() + .WithText(responseText, TextFormats.Markdown) + .AddMention(context.Activity?.From!) + .Build(); + + responseMsg.AddAIGenerated(); + + for (int i = 0; i < citations.Count; i++) + { + var citation = citations[i]; + var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; + responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); + } + + await context.Send(responseMsg, ct); } } diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 8d703fd29..8c0d3f53b 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -31,9 +31,8 @@ ["service.namespace"] = "Microsoft.Teams" })) .UseMicrosoftOpenTelemetry(async o => { - - o.Instrumentation.EnableHttpClientInstrumentation = true; o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; + o.Instrumentation.EnableHttpClientInstrumentation = true; o.Instrumentation.EnableAspNetCoreInstrumentation = true; o.Agent365.Exporter.UseS2SEndpoint = true; o.Agent365.Exporter.TokenResolver = async (agentId, tenantId) => @@ -61,7 +60,6 @@ Name = "msdocs" }))); -var tenantId = builder.Configuration["AzureAd:TenantId"] ?? throw new InvalidDataException("AzureAd:TenantId not found"); builder.Services.AddKeyedSingleton("calendarTools", (sp, key) => McpClient.CreateAsync( @@ -72,13 +70,13 @@ Name = "calendarTools" }))); -builder.Services.AddKeyedSingleton("disco", (sp, key) => +builder.Services.AddKeyedSingleton("teams", (sp, key) => McpClient.CreateAsync( new HttpClientTransport(new() { - Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/discoverMCPServers"), + Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer"), TransportMode = HttpTransportMode.AutoDetect, - Name = "disco" + Name = "teams" }))); @@ -100,15 +98,15 @@ builder.Services.AddSingleton(sp => { var msdocsClient = sp.GetRequiredKeyedService>("msdocs").GetAwaiter().GetResult(); - //var calendarClient = sp.GetRequiredKeyedService>("calendarTools").GetAwaiter().GetResult(); - var discoClient = sp.GetRequiredKeyedService>("disco").GetAwaiter().GetResult(); + var calendarClient = sp.GetRequiredKeyedService>("calendarTools").GetAwaiter().GetResult(); + var teamsClient = sp.GetRequiredKeyedService>("teams").GetAwaiter().GetResult(); var msdocsTools = msdocsClient.ListToolsAsync().GetAwaiter().GetResult(); - //var calendarTools = calendarClient.ListToolsAsync().GetAwaiter().GetResult(); - var discoTools = discoClient.ListToolsAsync().GetAwaiter().GetResult(); + var calendarTools = calendarClient.ListToolsAsync().GetAwaiter().GetResult(); + var teamsTools = teamsClient.ListToolsAsync().GetAwaiter().GetResult(); - //var allTools = msdocsTools.Concat(calendarTools).Concat(discoTools).ToList(); - var allTools = msdocsTools.Concat(discoTools).ToList(); + var allTools = msdocsTools.Concat(calendarTools).Concat(teamsTools).Concat(calendarTools).ToList(); + Console.WriteLine("Tools Found: " + string.Join(", ", allTools.Select(t => t.Name))); return new ChatOptions From 452375b34ad64737d0a0bf13c0e1552337d6bb82 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 12:30:13 -0700 Subject: [PATCH 11/24] Remove calendarTools/teams clients and local OpenTelemetry ref Cleaned up core.slnx and ObservabilityBot.csproj by removing the project reference to the local Microsoft.OpenTelemetry and adding a direct NuGet package reference instead. In Program.cs, removed registration and usage of "calendarTools" and "teams" McpClient services, updating ChatOptions to use only "msdocs" tools and instructions. --- core/core.slnx | 3 +- .../ObservabilityBot/ObservabilityBot.csproj | 2 +- core/samples/ObservabilityBot/Program.cs | 33 ++----------------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/core/core.slnx b/core/core.slnx index ad5b8f169..bc7966248 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -24,7 +24,7 @@ - + @@ -54,6 +54,5 @@ - diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index b76c4bf82..2aedf1076 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -8,6 +8,7 @@ + @@ -20,7 +21,6 @@ - diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 8c0d3f53b..f2b8f0eec 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -60,26 +60,6 @@ Name = "msdocs" }))); - -builder.Services.AddKeyedSingleton("calendarTools", (sp, key) => - McpClient.CreateAsync( - new HttpClientTransport(new() - { - Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools"), - TransportMode = HttpTransportMode.AutoDetect, - Name = "calendarTools" - }))); - -builder.Services.AddKeyedSingleton("teams", (sp, key) => - McpClient.CreateAsync( - new HttpClientTransport(new() - { - Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer"), - TransportMode = HttpTransportMode.AutoDetect, - Name = "teams" - }))); - - // Register IChatClient var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidDataException("AZURE_OPENAI_ENDPOINT not found"); var azoai_key = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY") ?? throw new InvalidDataException("AZURE_OPENAI_KEY not found"); @@ -95,25 +75,16 @@ .UseLogging(sp.GetRequiredService()) .Build()); -builder.Services.AddSingleton(sp => +builder.Services.AddSingleton(sp => { var msdocsClient = sp.GetRequiredKeyedService>("msdocs").GetAwaiter().GetResult(); - var calendarClient = sp.GetRequiredKeyedService>("calendarTools").GetAwaiter().GetResult(); - var teamsClient = sp.GetRequiredKeyedService>("teams").GetAwaiter().GetResult(); - var msdocsTools = msdocsClient.ListToolsAsync().GetAwaiter().GetResult(); - var calendarTools = calendarClient.ListToolsAsync().GetAwaiter().GetResult(); - var teamsTools = teamsClient.ListToolsAsync().GetAwaiter().GetResult(); - - var allTools = msdocsTools.Concat(calendarTools).Concat(teamsTools).Concat(calendarTools).ToList(); - - Console.WriteLine("Tools Found: " + string.Join(", ", allTools.Select(t => t.Name))); return new ChatOptions { AllowMultipleToolCalls = true, Instructions = "Use the following tools to answer the user's question. If you don't know the answer, use the 'Search Microsoft Docs' tool to find relevant information. Use calendar tools for scheduling-related queries.", - Tools = [.. allTools] + Tools = [..msdocsTools] }; }); From 4f188fdd3b4faba1990d1d620bb99a5624f888e1 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 12:45:06 -0700 Subject: [PATCH 12/24] fix: set Error status on router span exceptions; preserve TenantId in TeamsConversationAccount.FromConversationAccount - Router.DispatchAsync/DispatchWithReturnAsync: replace span?.AddException(ex) with span.RecordException(ex). The BCL Activity.AddException only adds an exception event; it does not set ActivityStatusCode.Error, leaving handler failures unflagged in spans. RecordException is the project-local extension already used by every other catch in the SDK and matches the documented contract. - TeamsConversationAccount.FromConversationAccount: copy the new typed TenantId from the source ConversationAccount. After TenantId was promoted from a Properties-backed accessor to a typed [JsonPropertyName(`tenantId`)] property on ConversationAccount, the conversion (used by TeamsActivity ctor and From/Recipient lazy getters) silently dropped the tenant id, breaking BaggageBuilder.FromTeamsContext's microsoft.tenant.id population. - Diagnostics.ActivityExtensions: promote from internal to public so the Apps-layer Router can use RecordException without InternalsVisibleTo. - Add regression test FromConversationAccount_PreservesTenantId. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Teams.Apps/Routing/Router.cs | 4 ++-- .../Schema/TeamsConversationAccount.cs | 1 + .../Diagnostics/ActivityExtensions.cs | 2 +- .../TeamsActivityBuilderTests.cs | 22 +++++++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/core/src/Microsoft.Teams.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Apps/Routing/Router.cs index 37868fc27..9d52d65d8 100644 --- a/core/src/Microsoft.Teams.Apps/Routing/Router.cs +++ b/core/src/Microsoft.Teams.Apps/Routing/Router.cs @@ -109,7 +109,7 @@ public async Task DispatchAsync(Context ctx, CancellationToken ca } catch (Exception ex) { - span?.AddException(ex); + span.RecordException(ex); throw; } @@ -166,7 +166,7 @@ public async Task DispatchWithReturnAsync(Context } catch (Exception ex) { - span?.AddException(ex); + span.RecordException(ex); throw; } diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs index c4ce8a620..1659bbbc9 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs @@ -43,6 +43,7 @@ public TeamsConversationAccount() result.AgenticAppId = conversationAccount.AgenticAppId; result.AgenticUserId = conversationAccount.AgenticUserId; result.AgenticAppBlueprintId = conversationAccount.AgenticAppBlueprintId; + result.TenantId = conversationAccount.TenantId; result.Properties = conversationAccount.Properties; return result; } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs b/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs index 6fd359619..af0c3660d 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/ActivityExtensions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Teams.Core.Diagnostics; /// Helpers for setting standardized tags and recording exceptions on instances /// emitted by the Teams SDK's bot pipeline. /// -internal static class ActivityExtensions +public static class ActivityExtensions { /// /// Records an exception on the span: sets status to and diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs index 3649da2f0..0a6b15621 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs @@ -136,6 +136,28 @@ public void WithRecipient_SetsRecipientAccount() Assert.Equal("Recipient Name", activity.Recipient?.Name); } + [Fact] + public void FromConversationAccount_PreservesTenantId() + { + ConversationAccount source = new() + { + Id = "user-id", + Name = "User Name", + TenantId = "tenant-abc", + AgenticAppId = "app-1", + AgenticUserId = "user-1", + AgenticAppBlueprintId = "bp-1", + }; + + TeamsConversationAccount? result = TeamsConversationAccount.FromConversationAccount(source); + + Assert.NotNull(result); + Assert.Equal("tenant-abc", result.TenantId); + Assert.Equal("app-1", result.AgenticAppId); + Assert.Equal("user-1", result.AgenticUserId); + Assert.Equal("bp-1", result.AgenticAppBlueprintId); + } + [Fact] public void WithConversation_SetsConversationInfo() { From a0cb054dd3d99e9fd2017356b3ec7a1ae9ce9283 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 12:51:34 -0700 Subject: [PATCH 13/24] docs: align Observability-Design.md with actual code - Replace TeamsCoreTelemetry references with the actual public class name CoreTelemetryNames (10+ doc references plus a stale XMLDoc example in TeamsBotApplicationTelemetry.cs that would not have compiled if used as written). - Drop the `no new package dependencies` overview claim; Core takes a single new dep on OpenTelemetry.Api so BaggageBuilder can write to OpenTelemetry.Baggage.Current (already documented under Dependency impact). - auth.outbound span tags: drop auth.scope (commented out as TODO in BotAuthenticationHandler.cs:84) and drop client_credentials from auth.flow values; only agentic / app_only / managed_identity are emitted. - Rewrite the exception-recording paragraph: RecordException always uses manual event tagging on both net8.0 and net10.0; the SDK never delegates to the BCL Activity.AddException because that API does not set ActivityStatusCode.Error. Note that ActivityExtensions is now public so the Apps Router can use it. - Required baggage map: drop the AadObjectId fallback claim for microsoft.agent.user.id; both BaggageBuilder.FromCoreActivity and FromTeamsContext only read recipient.AgenticUserId. - Rename test method TeamsCoreTelemetry_ConstantsHaveExpectedValues to CoreTelemetryNames_ConstantsHaveExpectedValues to match the class under test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/docs/Observability-Design.md | 26 +++++++++---------- .../TeamsBotApplicationTelemetry.cs | 4 +-- .../Diagnostics/TelemetryTests.cs | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md index c291519b7..3c6acb7cd 100644 --- a/core/docs/Observability-Design.md +++ b/core/docs/Observability-Design.md @@ -4,7 +4,7 @@ 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. -The SDK takes **no new package dependencies**. It uses the BCL `System.Diagnostics.ActivitySource` and `System.Diagnostics.Metrics.Meter`. 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. +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.Core` takes a single new package dependency on `OpenTelemetry.Api` so the `BaggageBuilder` can write to `OpenTelemetry.Baggage.Current` (see "Dependency impact" below). ``` Consuming bot Teams SDK (this design) @@ -12,14 +12,14 @@ Consuming bot Teams SDK (this design) .UseMicrosoftOpenTelemetry(...) ActivitySource("Microsoft.Teams.Core") .WithTracing(t => t ├─ "turn" (BotApplication.ProcessAsync) - .AddSource(TeamsCoreTelemetry ├─ "middleware" (TurnMiddleware.RunPipelineAsync) + .AddSource(CoreTelemetryNames ├─ "middleware" (TurnMiddleware.RunPipelineAsync) .ActivitySourceName) ├─ "auth.outbound" (BotAuthenticationHandler) .AddSource( └─ "conversation_client" (ConversationClient send/update/delete) TeamsBotApplicationTelemetry .ActivitySourceName)) ActivitySource("Microsoft.Teams.Apps") └─ "handler" (Router.DispatchAsync) .WithMetrics(m => m - .AddMeter(TeamsCoreTelemetry Meter("Microsoft.Teams.Core") + .AddMeter(CoreTelemetryNames Meter("Microsoft.Teams.Core") .MeterName) ├─ teams.activities.received (Counter) .AddMeter( ├─ teams.turn.duration (Histogram, ms) TeamsBotApplicationTelemetry ├─ teams.handler.errors (Counter) @@ -38,22 +38,22 @@ The SDK is split across two assemblies that observability must respect: - `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. - `Microsoft.Teams.Apps` depends on Core. It owns the typed activity model, `TeamsBotApplication`, and the `Router` that dispatches to user handlers. -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 `TeamsCoreTelemetry`. Neither references the other. +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. | Layer | Public name class | Source / Meter name | Spans | Metrics | |---|---|---|---|---| -| `Microsoft.Teams.Core` | `Microsoft.Teams.Core.Diagnostics.TeamsCoreTelemetry` | `"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` | +| `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` | | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry` | `"Microsoft.Teams.Apps"` | `handler` | (none yet) | -Cross-assembly use is one-way: Apps's `Router` may call Core utilities (for example, the `RecordException` extension on `Activity` defined in `Microsoft.Teams.Core.Diagnostics`), 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. +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. -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 `TeamsCoreTelemetry` and gets the full Core-level signal. +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. ## Public surface ```csharp namespace Microsoft.Teams.Core.Diagnostics; -public static class TeamsCoreTelemetry +public static class CoreTelemetryNames { public const string ActivitySourceName = "Microsoft.Teams.Core"; public const string MeterName = "Microsoft.Teams.Core"; @@ -80,10 +80,10 @@ The auto-instrumented HTTP-server span (from the OTel distro's ASP.NET Core inst | `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` | | `middleware` | Core | `Microsoft.Teams.Core/TurnMiddleware.cs` `RunPipelineAsync` per-middleware execution | `middleware.name`, `middleware.index` | | `handler` | Apps | `Microsoft.Teams.Apps/Routing/Router.cs` `DispatchAsync` matched-route invocation | `handler.type` (activity type or invoke name), `handler.dispatch` (`type` / `invoke` / `catchall`) | -| `auth.outbound` | Core | `Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs` `GetAuthorizationHeaderAsync` | `auth.flow` (`agentic` / `app_only` / `managed_identity` / `client_credentials`), `auth.scope` | +| `auth.outbound` | Core | `Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs` `GetAuthorizationHeaderAsync` | `auth.flow` (`agentic` / `app_only` / `managed_identity`) | | `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` | -On exception every span sets `Status = Error` with the exception message, and the exception is recorded as a span event (`exception.type`, `exception.message`, `exception.stacktrace`) — `Activity.AddException` on net9+, manual event tagging on net8.0. The helper extension lives in Core (`ActivityExtensions.RecordException`) and is consumed from Apps. +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`. ### `auth.inbound` is intentionally omitted @@ -122,10 +122,10 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenTelemetry() .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.AzureMonitor | ExportTarget.Otlp) .WithTracing(t => t - .AddSource(TeamsCoreTelemetry.ActivitySourceName) + .AddSource(CoreTelemetryNames.ActivitySourceName) .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) .WithMetrics(m => m - .AddMeter(TeamsCoreTelemetry.MeterName) + .AddMeter(CoreTelemetryNames.MeterName) .AddMeter(TeamsBotApplicationTelemetry.MeterName)); builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); @@ -223,7 +223,7 @@ Every Required attribute on each scope must be **non-null at scope close**. The | Caller (human) | `user.email` | **Yes** | `((TeamsConversationAccount)Activity.From).Email` (Apps-only) | | Target agent | `gen_ai.agent.id` | **Yes** | `Activity.Recipient.AgenticAppId ?? Activity.Recipient.Id` | | Target agent | `gen_ai.agent.name` | **Yes** | `Activity.Recipient.Name` | -| Target agent | `microsoft.agent.user.id` | **Yes** | `Activity.Recipient.AgenticUserId ?? ((TeamsConversationAccount)Activity.Recipient).AadObjectId` | +| Target agent | `microsoft.agent.user.id` | **Yes** | `Activity.Recipient.AgenticUserId` | | Target agent | `microsoft.agent.user.email` | **Yes** | `((TeamsConversationAccount)Activity.Recipient).Email` (Apps-only) | | Target agent | `gen_ai.agent.description` | Optional | `((TeamsConversationAccount)Activity.Recipient).UserRole` (Apps-only) | | Target agent | `microsoft.a365.agent.blueprint.id` | **Yes** | `Activity.Recipient.AgenticAppBlueprintId` | diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs index a60068fe4..c873d85c7 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBotApplicationTelemetry.cs @@ -15,10 +15,10 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// /// builder.Services.AddOpenTelemetry() /// .WithTracing(t => t -/// .AddSource(TeamsCoreTelemetry.ActivitySourceName) +/// .AddSource(CoreTelemetryNames.ActivitySourceName) /// .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) /// .WithMetrics(m => m -/// .AddMeter(TeamsCoreTelemetry.MeterName) +/// .AddMeter(CoreTelemetryNames.MeterName) /// .AddMeter(TeamsBotApplicationTelemetry.MeterName)); /// /// diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs index 86df6a3fd..aa9010fb1 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests.cs @@ -18,7 +18,7 @@ namespace Microsoft.Teams.Core.UnitTests.Diagnostics; public class TelemetryTests { [Fact] - public void TeamsCoreTelemetry_ConstantsHaveExpectedValues() + public void CoreTelemetryNames_ConstantsHaveExpectedValues() { Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.ActivitySourceName); Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.MeterName); From 17646ad782e671949ba6776aaeaba1dd26503eb5 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 13:03:00 -0700 Subject: [PATCH 14/24] feat(apps): add router-level handler metrics to Microsoft.Teams.Apps meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires four instruments into the previously-empty `Microsoft.Teams.Apps` meter, observed by `Router.DispatchAsync` and `DispatchWithReturnAsync`: - `teams.handler.dispatched` (Counter) — one per matched-route invocation; tags: `handler.type`, `handler.dispatch`. - `teams.handler.duration` (Histogram, ms) — recorded in `finally` so exceptions are still observed; same tags. - `teams.handler.failures` (Counter) — bumped in the catch block. - `teams.handler.unmatched` (Counter) — bumped when no selector matches; tagged with `activity.type` (DispatchAsync) or `activity.type` + `invoke.name` (DispatchWithReturnAsync 501 branch). Adds matching tag/metric-name constants on `AppsTelemetry` and a `MetricCapture` test harness in `RouterTelemetryTests` (mirrors the existing pattern in `Microsoft.Teams.Core.UnitTests/Diagnostics/TelemetryTests`). Updates `Observability-Design.md` to document the new instruments and splits the metrics table by meter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/docs/Observability-Design.md | 20 +- .../Diagnostics/AppsTelemetry.cs | 26 ++- .../Microsoft.Teams.Apps/Routing/Router.cs | 39 ++++ .../RouterTelemetryTests.cs | 215 ++++++++++++++++++ 4 files changed, 295 insertions(+), 5 deletions(-) diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md index 3c6acb7cd..3788f42d0 100644 --- a/core/docs/Observability-Design.md +++ b/core/docs/Observability-Design.md @@ -28,7 +28,10 @@ Consuming bot Teams SDK (this design) └─ teams.outbound.errors (Counter) Meter("Microsoft.Teams.Apps") - └─ (no instruments yet — reserved for Apps-level metrics) + ├─ teams.handler.dispatched (Counter) + ├─ teams.handler.duration (Histogram, ms) + ├─ teams.handler.failures (Counter) + └─ teams.handler.unmatched (Counter) ``` ## Layering constraints @@ -43,7 +46,7 @@ Telemetry follows the same rule: **each assembly publishes its own ActivitySourc | Layer | Public name class | Source / Meter name | Spans | Metrics | |---|---|---|---|---| | `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` | -| `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry` | `"Microsoft.Teams.Apps"` | `handler` | (none yet) | +| `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Diagnostics.TeamsBotApplicationTelemetry` | `"Microsoft.Teams.Apps"` | `handler` | `teams.handler.dispatched`, `teams.handler.duration`, `teams.handler.failures`, `teams.handler.unmatched` | 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. @@ -91,7 +94,9 @@ The `auth.inbound` span belongs to the auth middleware, not the bot pipeline. Th ## Metrics -All metrics emit on the **Core** meter. Apps does not yet have its own instruments; the Apps meter is published symmetrically with the Apps source so a future Apps-level metric can be added without changing the public surface. +Core-meter instruments cover the turn pipeline, middleware, and outbound HTTP clients. Apps-meter instruments cover router dispatch (one observation per matched route). + +### Core meter (`Microsoft.Teams.Core`) | Metric | Kind | Unit | Tags | Where | |---|---|---|---|---| @@ -102,6 +107,15 @@ All metrics emit on the **Core** meter. Apps does not yet have its own instrumen | `teams.outbound.calls` | Counter | — | `operation` ∈ {`sendActivity`, `updateActivity`, `deleteActivity`} | success branch of `ConversationClient` calls | | `teams.outbound.errors` | Counter | — | `operation` | exception branch of `ConversationClient` calls | +### Apps meter (`Microsoft.Teams.Apps`) + +| Metric | Kind | Unit | Tags | Where | +|---|---|---|---|---| +| `teams.handler.dispatched` | Counter | — | `handler.type`, `handler.dispatch` | `Router.DispatchAsync` / `DispatchWithReturnAsync` before each matched-route invocation | +| `teams.handler.duration` | Histogram | ms | `handler.type`, `handler.dispatch` | `finally` block around each matched-route invocation (recorded even on exception) | +| `teams.handler.failures` | Counter | — | `handler.type`, `handler.dispatch` | catch block when a route handler throws | +| `teams.handler.unmatched` | Counter | — | `activity.type` (DispatchAsync) or `activity.type` + `invoke.name` (DispatchWithReturnAsync) | branch where no route selector matched | + OTLP exposes these names with dots; Prometheus/Mimir maps them to `teams_*_total` (counters) and `teams_*_milliseconds_*` (histograms). ## Logs diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs index 2014cd6ad..d6b131e43 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -8,12 +8,12 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// -/// Singletons for the Apps-level and . +/// Singletons for the Apps-level , , and instruments. /// Internal to Microsoft.Teams.Apps. /// internal static class AppsTelemetry { - private static readonly string s_version = + private static readonly string s_version = typeof(AppsTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion ?? typeof(AppsTelemetry).Assembly.GetName().Version?.ToString() ?? "0.0.0"; @@ -24,6 +24,18 @@ internal static class AppsTelemetry public static readonly Meter Meter = new(TeamsBotApplicationTelemetry.MeterName, s_version); + public static readonly Counter HandlerDispatched = + Meter.CreateCounter(Metrics.HandlerDispatched, description: "Total handler invocations dispatched by the router."); + + public static readonly Histogram HandlerDuration = + Meter.CreateHistogram(Metrics.HandlerDuration, unit: "ms", description: "Duration of individual handler invocations."); + + public static readonly Counter HandlerFailures = + Meter.CreateCounter(Metrics.HandlerFailures, description: "Total handler invocations that threw an exception."); + + public static readonly Counter HandlerUnmatched = + Meter.CreateCounter(Metrics.HandlerUnmatched, description: "Total activities that found no matching route."); + public static class Spans { public const string Handler = "handler"; @@ -33,5 +45,15 @@ public static class Tags { public const string HandlerType = "handler.type"; public const string HandlerDispatch = "handler.dispatch"; + public const string ActivityType = "activity.type"; + public const string InvokeName = "invoke.name"; + } + + public static class Metrics + { + public const string HandlerDispatched = "teams.handler.dispatched"; + public const string HandlerDuration = "teams.handler.duration"; + public const string HandlerFailures = "teams.handler.failures"; + public const string HandlerUnmatched = "teams.handler.unmatched"; } } diff --git a/core/src/Microsoft.Teams.Apps/Routing/Router.cs b/core/src/Microsoft.Teams.Apps/Routing/Router.cs index 9d52d65d8..d3452d4b7 100644 --- a/core/src/Microsoft.Teams.Apps/Routing/Router.cs +++ b/core/src/Microsoft.Teams.Apps/Routing/Router.cs @@ -82,6 +82,7 @@ public async Task DispatchAsync(Context ctx, CancellationToken ca if (matchingRoutes.Count == 0 && _routes.Count > 0) { + AppsTelemetry.HandlerUnmatched.Add(1, new KeyValuePair(AppsTelemetry.Tags.ActivityType, ctx.Activity.Type)); _logger.LogWarning( "No routes matched activity of type '{Type}'.", ctx.Activity.Type @@ -97,21 +98,37 @@ public async Task DispatchAsync(Context ctx, CancellationToken ca _logger.LogTrace("Dispatching activity to route '{Name}': {Activity}", route.Name, ctx.Activity.ToJson()); (string handlerType, string dispatch) = GetHandlerTags(route.Name); + TagList handlerTags = new() + { + { AppsTelemetry.Tags.HandlerType, handlerType }, + { AppsTelemetry.Tags.HandlerDispatch, dispatch }, + }; + + AppsTelemetry.HandlerDispatched.Add(1, handlerTags); + using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); if (span is not null) { span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); } + + long startTimestamp = Stopwatch.GetTimestamp(); try { await route.InvokeRoute(ctx, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { + AppsTelemetry.HandlerFailures.Add(1, handlerTags); span.RecordException(ex); throw; } + finally + { + double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags); + } _logger.LogDebug("Completed route '{Name}' for '{Type}' activity.", route.Name, ctx.Activity.Type); } @@ -145,6 +162,12 @@ public async Task DispatchWithReturnAsync(Context if (matchingRoutes.Count == 0 && _routes.Count > 0) { + TagList unmatchedTags = new() + { + { AppsTelemetry.Tags.ActivityType, ctx.Activity.Type }, + { AppsTelemetry.Tags.InvokeName, name ?? string.Empty }, + }; + AppsTelemetry.HandlerUnmatched.Add(1, unmatchedTags); _logger.LogWarning("No routes matched invoke activity with name '{Name}'; returning 501.", name); return new InvokeResponse(501); } @@ -153,12 +176,22 @@ public async Task DispatchWithReturnAsync(Context _logger.LogTrace("Dispatching invoke activity to route '{Route}': {Activity}", matchingRoutes[0].Name, ctx.Activity.ToJson()); (string handlerType, string dispatch) = GetHandlerTags(matchingRoutes[0].Name); + TagList handlerTags = new() + { + { AppsTelemetry.Tags.HandlerType, handlerType }, + { AppsTelemetry.Tags.HandlerDispatch, dispatch }, + }; + + AppsTelemetry.HandlerDispatched.Add(1, handlerTags); + using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.Handler, ActivityKind.Internal); if (span is not null) { span.SetTag(AppsTelemetry.Tags.HandlerType, handlerType); span.SetTag(AppsTelemetry.Tags.HandlerDispatch, dispatch); } + + long startTimestamp = Stopwatch.GetTimestamp(); InvokeResponse response; try { @@ -166,9 +199,15 @@ public async Task DispatchWithReturnAsync(Context } catch (Exception ex) { + AppsTelemetry.HandlerFailures.Add(1, handlerTags); span.RecordException(ex); throw; } + finally + { + double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + AppsTelemetry.HandlerDuration.Record(elapsedMs, handlerTags); + } _logger.LogDebug("Completed invoke route '{Route}' for '{Name}' with status {Status}.", matchingRoutes[0].Name, name, response.Status); diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs index 030598bfb..965b0189d 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Diagnostics.Metrics; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.Routing; @@ -94,6 +95,148 @@ await Assert.ThrowsAsync(() => Assert.Contains(span.Events, e => e.Name == "exception"); } + [Fact] + public async Task DispatchAsync_RecordsDispatchedAndDurationMetrics() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = (_, _) => Task.CompletedTask, + }); + + await router.DispatchAsync(BuildCtx(new MessageActivity { Type = TeamsActivityType.Message })); + + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.dispatched")); + Assert.Equal(1, metrics.HistogramSampleCount("teams.handler.duration")); + Assert.Equal(0, metrics.GetCounterTotal("teams.handler.failures")); + Assert.Equal(0, metrics.GetCounterTotal("teams.handler.unmatched")); + + IReadOnlyList> dispatchedTags = metrics.GetCounterTags("teams.handler.dispatched"); + Assert.Contains(new KeyValuePair("handler.type", "message"), dispatchedTags); + Assert.Contains(new KeyValuePair("handler.dispatch", "type"), dispatchedTags); + } + + [Fact] + public async Task DispatchAsync_HandlerThrows_RecordsFailureMetric() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Message, + Selector = _ => true, + Handler = (_, _) => throw new InvalidOperationException("handler failed"), + }); + + await Assert.ThrowsAsync(() => + router.DispatchAsync(BuildCtx(new MessageActivity { Type = TeamsActivityType.Message }))); + + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.dispatched")); + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.failures")); + // Duration is recorded even on exception (via finally block). + Assert.Equal(1, metrics.HistogramSampleCount("teams.handler.duration")); + + IReadOnlyList> failureTags = metrics.GetCounterTags("teams.handler.failures"); + Assert.Contains(new KeyValuePair("handler.type", "message"), failureTags); + Assert.Contains(new KeyValuePair("handler.dispatch", "type"), failureTags); + } + + [Fact] + public async Task DispatchAsync_NoMatchingRoute_RecordsUnmatchedMetric() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = TeamsActivityType.Message, + Selector = _ => false, + Handler = (_, _) => Task.CompletedTask, + }); + + await router.DispatchAsync(BuildCtx(new MessageActivity { Type = TeamsActivityType.Message })); + + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.unmatched")); + Assert.Equal(0, metrics.GetCounterTotal("teams.handler.dispatched")); + + IReadOnlyList> unmatchedTags = metrics.GetCounterTags("teams.handler.unmatched"); + Assert.Contains(new KeyValuePair("activity.type", "message"), unmatchedTags); + } + + [Fact] + public async Task DispatchWithReturnAsync_RecordsDispatchedAndDurationMetrics() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = $"{TeamsActivityType.Invoke}/tab/fetch", + Selector = _ => true, + HandlerWithReturn = (_, _) => Task.FromResult(new InvokeResponse(200)), + }); + + await router.DispatchWithReturnAsync(BuildCtx(new InvokeActivity { Type = TeamsActivityType.Invoke, Name = "tab/fetch" })); + + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.dispatched")); + Assert.Equal(1, metrics.HistogramSampleCount("teams.handler.duration")); + Assert.Equal(0, metrics.GetCounterTotal("teams.handler.failures")); + + IReadOnlyList> dispatchedTags = metrics.GetCounterTags("teams.handler.dispatched"); + Assert.Contains(new KeyValuePair("handler.type", "tab/fetch"), dispatchedTags); + Assert.Contains(new KeyValuePair("handler.dispatch", "invoke"), dispatchedTags); + } + + [Fact] + public async Task DispatchWithReturnAsync_HandlerThrows_RecordsFailureMetric() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = $"{TeamsActivityType.Invoke}/tab/fetch", + Selector = _ => true, + HandlerWithReturn = (_, _) => throw new InvalidOperationException("invoke failed"), + }); + + await Assert.ThrowsAsync(() => + router.DispatchWithReturnAsync(BuildCtx(new InvokeActivity { Type = TeamsActivityType.Invoke, Name = "tab/fetch" }))); + + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.dispatched")); + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.failures")); + Assert.Equal(1, metrics.HistogramSampleCount("teams.handler.duration")); + } + + [Fact] + public async Task DispatchWithReturnAsync_NoMatchingInvoke_RecordsUnmatchedMetric() + { + using MetricCapture metrics = new(); + + Router router = new(NullLogger.Instance); + router.Register(new Route + { + Name = $"{TeamsActivityType.Invoke}/tab/fetch", + Selector = _ => false, + HandlerWithReturn = (_, _) => Task.FromResult(new InvokeResponse(200)), + }); + + InvokeResponse response = await router.DispatchWithReturnAsync(BuildCtx(new InvokeActivity { Type = TeamsActivityType.Invoke, Name = "tab/fetch" })); + + Assert.Equal(501, response.Status); + Assert.Equal(1, metrics.GetCounterTotal("teams.handler.unmatched")); + Assert.Equal(0, metrics.GetCounterTotal("teams.handler.dispatched")); + + IReadOnlyList> unmatchedTags = metrics.GetCounterTags("teams.handler.unmatched"); + Assert.Contains(new KeyValuePair("activity.type", "invoke"), unmatchedTags); + Assert.Contains(new KeyValuePair("invoke.name", "tab/fetch"), unmatchedTags); + } + private static Context BuildCtx(TeamsActivity activity) => new(null!, activity); private sealed class SpanCapture : IDisposable @@ -117,4 +260,76 @@ public SpanCapture() public void Dispose() => _listener.Dispose(); } + + /// + /// Test harness: subscribes a to the Apps meter and aggregates + /// emitted measurements (counter totals, histogram sample counts, and the most recent tag set + /// per instrument). + /// + private sealed class MetricCapture : IDisposable + { + private readonly MeterListener _listener; + private readonly Dictionary _counterTotals = new(StringComparer.Ordinal); + private readonly Dictionary _histogramSamples = new(StringComparer.Ordinal); + private readonly Dictionary[]> _lastTags = new(StringComparer.Ordinal); + + public MetricCapture() + { + _listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == TeamsBotApplicationTelemetry.MeterName) + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + lock (_counterTotals) + { + _counterTotals.TryGetValue(instrument.Name, out long total); + _counterTotals[instrument.Name] = total + value; + _lastTags[instrument.Name] = tags.ToArray(); + } + }); + _listener.SetMeasurementEventCallback((instrument, _, tags, _) => + { + lock (_histogramSamples) + { + _histogramSamples.TryGetValue(instrument.Name, out int count); + _histogramSamples[instrument.Name] = count + 1; + _lastTags[instrument.Name] = tags.ToArray(); + } + }); + _listener.Start(); + } + + public long GetCounterTotal(string name) + { + lock (_counterTotals) + { + return _counterTotals.TryGetValue(name, out long total) ? total : 0; + } + } + + public int HistogramSampleCount(string name) + { + lock (_histogramSamples) + { + return _histogramSamples.TryGetValue(name, out int count) ? count : 0; + } + } + + public IReadOnlyList> GetCounterTags(string name) + { + lock (_counterTotals) + { + return _lastTags.TryGetValue(name, out KeyValuePair[]? tags) ? tags : []; + } + } + + public void Dispose() => _listener.Dispose(); + } } From 174130ca5b2aa07284b8efc09c18246d99be9604 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 20:40:05 -0700 Subject: [PATCH 15/24] fix: address PR review comments for observability instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix named options regression in BotAuthenticationHandler (use Get(optionsName) instead of CurrentValue) - Move InvokeAgentScope inside baggage scope in TeamsBotApplication so span inherits Apps-only baggage - Record output messages on InvokeAgentScope for Agent365 certification completeness - Restore null-safe callback invocation in TurnMiddleware - Copy TenantId in CoreActivity.CloneConversationAccount - Fix activity source name mismatch in ObservabilityBot sample - Add Core telemetry sources to ObservabilityBot README example - Rename BaggageBuilder → CoreBaggageBuilder / TeamsBaggageBuilder to avoid namespace collisions - Make InvokeAgentScope public, removing need for InternalsVisibleTo Co-Authored-By: Claude Opus 4.6 (1M context) --- core/docs/Observability-Design.md | 118 +++++++++--------- core/samples/ObservabilityBot/Program.cs | 2 +- core/samples/ObservabilityBot/README.md | 8 +- ...ggageBuilder.cs => TeamsBaggageBuilder.cs} | 40 +++--- .../TeamsBotApplication.cs | 45 ++++--- .../Microsoft.Teams.Core/BotApplication.cs | 6 - ...aggageBuilder.cs => CoreBaggageBuilder.cs} | 32 ++--- .../Diagnostics/InvokeAgentScope.cs | 7 +- .../Hosting/BotAuthenticationHandler.cs | 2 +- .../Schema/ConversationAccount.cs | 2 +- .../Schema/CoreActivity.cs | 1 + .../Microsoft.Teams.Core/TurnMiddleware.cs | 2 +- .../Diagnostics/BaggageBuilderTests.cs | 8 +- .../Diagnostics/BaggageBuilderTests.cs | 8 +- 14 files changed, 149 insertions(+), 132 deletions(-) rename core/src/Microsoft.Teams.Apps/Diagnostics/{BaggageBuilder.cs => TeamsBaggageBuilder.cs} (77%) rename core/src/Microsoft.Teams.Core/Diagnostics/{BaggageBuilder.cs => CoreBaggageBuilder.cs} (82%) diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md index 3788f42d0..e26d12e9a 100644 --- a/core/docs/Observability-Design.md +++ b/core/docs/Observability-Design.md @@ -4,7 +4,7 @@ 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. -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.Core` takes a single new package dependency on `OpenTelemetry.Api` so the `BaggageBuilder` can write to `OpenTelemetry.Baggage.Current` (see "Dependency impact" below). +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.Core` takes a single new package dependency on `OpenTelemetry.Api` so the `CoreBaggageBuilder` can write to `OpenTelemetry.Baggage.Current` (see "Dependency impact" below). ``` Consuming bot Teams SDK (this design) @@ -168,7 +168,7 @@ When the consuming bot also exports to **Agent365** (`ExportTarget.Agent365` in **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`, 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. -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, each Teams SDK layer ships its own `BaggageBuilder` that reads directly off Teams types. See "Bridging strategy" below. +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, each Teams SDK layer ships its own baggage builder (`CoreBaggageBuilder` / `TeamsBaggageBuilder`) that reads directly off Teams types. See "Bridging strategy" below. ### Agent365 certification — crisp definition @@ -191,37 +191,37 @@ Auto-instrumentation (Semantic Kernel, OpenAI, Azure OpenAI, Agent Framework) em #### (2) Attribute coverage -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 `BaggageBuilder`); a handful are scope-specific and come from `ScopeDetails` / `Record*` methods. +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 `CoreBaggageBuilder` / `TeamsBaggageBuilder`); a handful are scope-specific and come from `ScopeDetails` / `Record*` methods. **Common Required attributes (all scopes):** | Key | Where it comes from | |---|---| -| `microsoft.tenant.id` | `BaggageBuilder.TenantId(...)` | -| `gen_ai.agent.id` | `BaggageBuilder.AgentId(...)` | -| `gen_ai.agent.name` | `BaggageBuilder.AgentName(...)` | -| `microsoft.a365.agent.blueprint.id` | `BaggageBuilder.AgentBlueprintId(...)` | -| `microsoft.agent.user.id` | `BaggageBuilder.AgenticUserId(...)` | -| `microsoft.agent.user.email` | `BaggageBuilder.AgenticUserEmail(...)` | +| `microsoft.tenant.id` | `CoreBaggageBuilder.TenantId(...)` / `TeamsBaggageBuilder.TenantId(...)` | +| `gen_ai.agent.id` | `CoreBaggageBuilder.AgentId(...)` / `TeamsBaggageBuilder.AgentId(...)` | +| `gen_ai.agent.name` | `CoreBaggageBuilder.AgentName(...)` / `TeamsBaggageBuilder.AgentName(...)` | +| `microsoft.a365.agent.blueprint.id` | `CoreBaggageBuilder.AgentBlueprintId(...)` / `TeamsBaggageBuilder.AgentBlueprintId(...)` | +| `microsoft.agent.user.id` | `CoreBaggageBuilder.AgenticUserId(...)` / `TeamsBaggageBuilder.AgenticUserId(...)` | +| `microsoft.agent.user.email` | `TeamsBaggageBuilder.AgenticUserEmail(...)` | | `client.address` | Caller-supplied (HTTP request remote IP) | -| `user.id` | `BaggageBuilder.UserId(...)` | -| `user.email` | `BaggageBuilder.UserEmail(...)` | -| `microsoft.channel.name` | `BaggageBuilder.ChannelName(...)` | -| `gen_ai.conversation.id` | `BaggageBuilder.ConversationId(...)` | +| `user.id` | `TeamsBaggageBuilder.UserId(...)` | +| `user.email` | `TeamsBaggageBuilder.UserEmail(...)` | +| `microsoft.channel.name` | `CoreBaggageBuilder.ChannelName(...)` / `TeamsBaggageBuilder.ChannelName(...)` | +| `gen_ai.conversation.id` | `CoreBaggageBuilder.ConversationId(...)` / `TeamsBaggageBuilder.ConversationId(...)` | | `gen_ai.operation.name` | Set by the scope automatically | **Scope-specific Required attributes:** | Scope | Additional Required attributes | Source | |---|---|---| -| `InvokeAgentScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `server.address`, `server.port` | `scope.RecordInputMessages(...)` / `RecordOutputMessages(...)` + `BaggageBuilder.InvokeAgentServer(host, port)` | +| `InvokeAgentScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `server.address`, `server.port` | `scope.RecordInputMessages(...)` / `RecordOutputMessages(...)` + `CoreBaggageBuilder.InvokeAgentServer(host, port)` | | `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(...)` | | `InferenceScope` | `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.provider.name`, `gen_ai.request.model` | `InferenceCallDetails` + `RecordInputMessages` / `RecordOutputMessages` | | `OutputScope` | `gen_ai.output.messages` | `Response` constructor | **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). -**Out of scope of this SDK:** the scope objects themselves (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, `OutputScope`) ship in `Microsoft.OpenTelemetry`. The Teams SDK only ships the `BaggageBuilder` that populates the cert-required baggage; agents create the scopes themselves at the appropriate boundaries. +**Out of scope of this SDK:** the scope objects themselves (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, `OutputScope`) ship in `Microsoft.OpenTelemetry`. The Teams SDK only ships the `CoreBaggageBuilder` / `TeamsBaggageBuilder` that populates the cert-required baggage; agents create the scopes themselves at the appropriate boundaries. ### Required baggage map (Teams activity → Agent365 keys) @@ -241,7 +241,7 @@ Every Required attribute on each scope must be **non-null at scope close**. The | Target agent | `microsoft.agent.user.email` | **Yes** | `((TeamsConversationAccount)Activity.Recipient).Email` (Apps-only) | | Target agent | `gen_ai.agent.description` | Optional | `((TeamsConversationAccount)Activity.Recipient).UserRole` (Apps-only) | | Target agent | `microsoft.a365.agent.blueprint.id` | **Yes** | `Activity.Recipient.AgenticAppBlueprintId` | -| Operation source | `service.name` (set via `BaggageBuilder.OperationSource`) | **Yes** (server spans) | Caller-supplied constant (e.g. `"teams-bot"`) | +| Operation source | `service.name` (set via `CoreBaggageBuilder.OperationSource` / `TeamsBaggageBuilder.OperationSource`) | **Yes** (server spans) | Caller-supplied constant (e.g. `"teams-bot"`) | The fields the Agent365 helpers also access that have **no Teams equivalent**: @@ -249,14 +249,14 @@ The fields the Agent365 helpers also access that have **no Teams equivalent**: ### Channel / SubChannel mapping -The upstream `BaggageBuilderExtensions.FromTurnContext` 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: +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: | Agent365 baggage key | Teams source | Auto-populated by `FromCoreActivity` / `FromTeamsContext`? | |---|---|---| | `microsoft.channel.name` (Required) | `Activity.ChannelId` (the whole string) | **Yes** | | `microsoft.channel.link` (Optional in all four cert scopes) | No equivalent on the Teams activity | **No** — left unset by the extractor | -`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 both `BaggageBuilder` classes 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). +`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 both `CoreBaggageBuilder` and `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). 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. @@ -272,11 +272,11 @@ public string? TenantId { get; set; } `TeamsConversationAccount` (Apps) loses its custom `Properties["tenantId"]` shim — the inherited typed property replaces it. `tenantId` is a cross-channel concept (the M365 tenant the conversation belongs to), not a Teams-specific extension; promoting it is consistent with how Agent Builder's schema treats it. -**Wire-format note:** classic Bot Framework Teams traffic carries tenant id in `channelData.tenant.id`, **not** at `from.tenantId` / `recipient.tenantId`. The schema change does not auto-populate `Recipient.TenantId` from such activities — both `BaggageBuilder.FromCoreActivity` and `BaggageBuilder.FromTeamsContext` therefore fall back to `channelData.tenant.id` when the typed field is null. In Apps, the fallback uses the typed `TeamsActivity.ChannelData?.Tenant?.Id`. In Core, it parses `Activity.Properties["channelData"]` as JSON and extracts `tenant.id`. +**Wire-format note:** classic Bot Framework Teams traffic carries tenant id in `channelData.tenant.id`, **not** at `from.tenantId` / `recipient.tenantId`. The schema change does not auto-populate `Recipient.TenantId` from such activities — both `CoreBaggageBuilder.FromCoreActivity` and `TeamsBaggageBuilder.FromTeamsContext` therefore fall back to `channelData.tenant.id` when the typed field is null. In Apps, the fallback uses the typed `TeamsActivity.ChannelData?.Tenant?.Id`. In Core, it parses `Activity.Properties["channelData"]` as JSON and extracts `tenant.id`. ### Bridging strategy -**Two layer-specific baggage builders, one per assembly.** Each layer ships its own independent `BaggageBuilder` class shaped by the activity model that layer owns. Same name in different namespaces, no inheritance, no cross-references — each is self-contained. This honors the layering rule: neither builder downcasts to types it doesn't own. +**Two layer-specific baggage builders, one per assembly.** Each layer ships its own independent baggage builder class shaped by the activity model that layer owns: `CoreBaggageBuilder` in Core, `TeamsBaggageBuilder` in Apps. Distinct names, no inheritance, no cross-references — each is self-contained. This honors the layering rule: neither builder downcasts to types it doesn't own. The two field-set partitions: @@ -290,73 +290,73 @@ Apps's class duplicates Core's setter bodies (each is `Set(key, value); return t #### Proposed surface ```csharp -// Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs (NEW, public) +// Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs (public) namespace Microsoft.Teams.Core.Diagnostics; -public sealed class BaggageBuilder +public sealed class CoreBaggageBuilder { // Keys reachable from CoreActivity / ConversationAccount. - public BaggageBuilder TenantId(string? v); - public BaggageBuilder ConversationId(string? v); - public BaggageBuilder ConversationItemLink(string? v); // from ServiceUrl - public BaggageBuilder ChannelName(string? v); // from ChannelId (string) - public BaggageBuilder ChannelLink(string? v); // caller-supplied — no auto source - public BaggageBuilder AgentId(string? v); // Recipient.AgenticAppId ?? Recipient.Id - public BaggageBuilder AgentName(string? v); // Recipient.Name - public BaggageBuilder AgenticUserId(string? v); // Recipient.AgenticUserId - public BaggageBuilder AgentBlueprintId(string? v); // Recipient.AgenticAppBlueprintId - public BaggageBuilder UserName(string? v); // From.Name - public BaggageBuilder OperationSource(string source); // service.name — caller-supplied - public BaggageBuilder InvokeAgentServer(string? address, int? port = null); - public BaggageBuilder Set(string key, string? value); // escape hatch + public CoreBaggageBuilder TenantId(string? v); + public CoreBaggageBuilder ConversationId(string? v); + public CoreBaggageBuilder ConversationItemLink(string? v); // from ServiceUrl + public CoreBaggageBuilder ChannelName(string? v); // from ChannelId (string) + public CoreBaggageBuilder ChannelLink(string? v); // caller-supplied — no auto source + public CoreBaggageBuilder AgentId(string? v); // Recipient.AgenticAppId ?? Recipient.Id + public CoreBaggageBuilder AgentName(string? v); // Recipient.Name + public CoreBaggageBuilder AgenticUserId(string? v); // Recipient.AgenticUserId + public CoreBaggageBuilder AgentBlueprintId(string? v); // Recipient.AgenticAppBlueprintId + public CoreBaggageBuilder UserName(string? v); // From.Name + public CoreBaggageBuilder OperationSource(string source); // service.name — caller-supplied + public CoreBaggageBuilder InvokeAgentServer(string? address, int? port = null); + public CoreBaggageBuilder Set(string key, string? value); // escape hatch /// Populates every setter above whose source field is non-null on . /// Falls back to parsing Properties["channelData"] JSON for tenant id when /// Recipient.TenantId is empty. - public BaggageBuilder FromCoreActivity(CoreActivity activity); + public CoreBaggageBuilder FromCoreActivity(CoreActivity activity); public IDisposable Build(); // applies pairs to OpenTelemetry.Baggage.Current; returns restore-scope } ``` ```csharp -// Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs (NEW, public — separate class, same name in a different namespace) +// Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs (public — separate class) namespace Microsoft.Teams.Apps.Diagnostics; -public sealed class BaggageBuilder +public sealed class TeamsBaggageBuilder { // Same setters as Core's class … - public BaggageBuilder TenantId(string? v); - public BaggageBuilder ConversationId(string? v); - public BaggageBuilder ConversationItemLink(string? v); - public BaggageBuilder ChannelName(string? v); - public BaggageBuilder ChannelLink(string? v); - public BaggageBuilder AgentId(string? v); - public BaggageBuilder AgentName(string? v); - public BaggageBuilder AgenticUserId(string? v); - public BaggageBuilder AgentBlueprintId(string? v); - public BaggageBuilder UserName(string? v); - public BaggageBuilder OperationSource(string source); - public BaggageBuilder InvokeAgentServer(string? address, int? port = null); - public BaggageBuilder Set(string key, string? value); + public TeamsBaggageBuilder TenantId(string? v); + public TeamsBaggageBuilder ConversationId(string? v); + public TeamsBaggageBuilder ConversationItemLink(string? v); + public TeamsBaggageBuilder ChannelName(string? v); + public TeamsBaggageBuilder ChannelLink(string? v); + public TeamsBaggageBuilder AgentId(string? v); + public TeamsBaggageBuilder AgentName(string? v); + public TeamsBaggageBuilder AgenticUserId(string? v); + public TeamsBaggageBuilder AgentBlueprintId(string? v); + public TeamsBaggageBuilder UserName(string? v); + public TeamsBaggageBuilder OperationSource(string source); + public TeamsBaggageBuilder InvokeAgentServer(string? address, int? port = null); + public TeamsBaggageBuilder Set(string key, string? value); // … plus setters whose source field only exists on TeamsConversationAccount: - public BaggageBuilder UserId(string? v); // From.AadObjectId - public BaggageBuilder UserEmail(string? v); // From.Email - public BaggageBuilder AgentDescription(string? v); // Recipient.UserRole - public BaggageBuilder AgenticUserEmail(string? v); // Recipient.Email + public TeamsBaggageBuilder UserId(string? v); // From.AadObjectId + public TeamsBaggageBuilder UserEmail(string? v); // From.Email + public TeamsBaggageBuilder AgentDescription(string? v); // Recipient.UserRole + public TeamsBaggageBuilder AgenticUserEmail(string? v); // Recipient.Email /// Populates every setter above whose source field is non-null on ctx.Activity, /// reading TeamsConversationAccount-only fields without any downcast (the activity already /// types From / Recipient as TeamsConversationAccount). Tenant fallback uses the typed /// TeamsActivity.ChannelData?.Tenant?.Id. - public BaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity; + public TeamsBaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity; public IDisposable Build(); } ``` -The same-name-in-different-namespaces shape is intentional: a Core-only bot writes `using Microsoft.Teams.Core.Diagnostics; new BaggageBuilder()…`, a Teams-router bot writes `using Microsoft.Teams.Apps.Diagnostics; new BaggageBuilder()…`. The chosen `using` directive disambiguates; no caller ever sees both at once. +The distinct names (`CoreBaggageBuilder` / `TeamsBaggageBuilder`) eliminate ambiguity when both namespaces are imported: a Core-only bot writes `new CoreBaggageBuilder()…`, a Teams-router bot writes `new TeamsBaggageBuilder()…`. The Agent365 wire keys are duplicated as `internal const` strings in each assembly (`Microsoft.Teams.Core.Diagnostics.AgentObservabilityKeys` and the Apps equivalent) — same string values on both sides, kept in sync against the upstream Agent365 spec. They are not part of the public API of either assembly. @@ -368,7 +368,7 @@ using Microsoft.Teams.Apps.Diagnostics; botApp.OnMessage(async (ctx, ct) => { - using IDisposable scope = new BaggageBuilder() + using IDisposable scope = new TeamsBaggageBuilder() .FromTeamsContext(ctx) .OperationSource("teams-bot") // required-for-cert; not derivable from the activity .Build(); @@ -386,7 +386,7 @@ using Microsoft.Teams.Core.Diagnostics; botApp.OnActivity = async (activity, ct) => { - using IDisposable scope = new BaggageBuilder() + using IDisposable scope = new CoreBaggageBuilder() .FromCoreActivity(activity) .Set(/* user.id */ "user.id", myAadObjectIdFromAuth) .Set(/* user.email */ "user.email", myUserEmailFromAuth) diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index f2b8f0eec..ffae5874e 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -15,7 +15,7 @@ using OpenTelemetry.Resources; -string[] activitySources = [CoreTelemetryNames.ActivitySourceName, TeamsBotApplicationTelemetry.ActivitySourceName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"]; +string[] activitySources = [CoreTelemetryNames.ActivitySourceName, TeamsBotApplicationTelemetry.ActivitySourceName, "Experimental.Microsoft.Extensions.AI", "ModelContextProtocol"]; string[] meterNames = [CoreTelemetryNames.MeterName, TeamsBotApplicationTelemetry.MeterName, "Experimental.Microsoft.Agents.AI", "ModelContextProtocol"]; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); diff --git a/core/samples/ObservabilityBot/README.md b/core/samples/ObservabilityBot/README.md index 1313a0066..e20b17ccf 100644 --- a/core/samples/ObservabilityBot/README.md +++ b/core/samples/ObservabilityBot/README.md @@ -7,8 +7,12 @@ Minimal Teams bot wired to the [`Microsoft.OpenTelemetry`](https://github.com/mi ```csharp builder.Services.AddOpenTelemetry() .UseMicrosoftOpenTelemetry(o => o.Exporters = ExportTarget.Console | ExportTarget.Otlp) - .WithTracing(t => t.AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) - .WithMetrics(m => m.AddMeter(TeamsBotApplicationTelemetry.MeterName)); + .WithTracing(t => t + .AddSource(CoreTelemetryNames.ActivitySourceName) + .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) + .WithMetrics(m => m + .AddMeter(CoreTelemetryNames.MeterName) + .AddMeter(TeamsBotApplicationTelemetry.MeterName)); builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); ``` diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs similarity index 77% rename from core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs rename to core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs index fb5168db7..5f778eb89 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/BaggageBuilder.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs @@ -13,7 +13,7 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// /// /// -/// This class is independent from Microsoft.Teams.Core.Diagnostics.BaggageBuilder — same name +/// This class is independent from Microsoft.Teams.Core.Diagnostics.TeamsBaggageBuilder — same name /// in a different namespace, no inheritance. Apps's builder exposes the **superset** of the cert-relevant /// setters: everything Core's builder has plus the keys backed by /// (user.id, user.email, microsoft.agent.user.email, gen_ai.agent.description). @@ -23,47 +23,47 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// independence — see core/docs/Observability-Design.md § "Bridging strategy". /// /// -public sealed class BaggageBuilder +public sealed class TeamsBaggageBuilder { 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder AgentBlueprintId(string? v) => Set(AgentObservabilityKeys.AgentBlueprintId, v); /// Sets the human user name (user.name). Optional. - public BaggageBuilder UserName(string? v) => Set(AgentObservabilityKeys.UserName, v); + public TeamsBaggageBuilder 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); + public TeamsBaggageBuilder 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) + public TeamsBaggageBuilder InvokeAgentServer(string? address, int? port = null) { Set(AgentObservabilityKeys.ServerAddress, address); if (port.HasValue && port.Value != 443) @@ -75,21 +75,21 @@ public BaggageBuilder InvokeAgentServer(string? address, int? port = null) /// Sets the human user id (user.id). Required for cert. Apps-only — backed by /// . - public BaggageBuilder UserId(string? v) => Set(AgentObservabilityKeys.UserId, v); + public TeamsBaggageBuilder UserId(string? v) => Set(AgentObservabilityKeys.UserId, v); /// Sets the human user email (user.email). Required for cert. Apps-only. - public BaggageBuilder UserEmail(string? v) => Set(AgentObservabilityKeys.UserEmail, v); + public TeamsBaggageBuilder UserEmail(string? v) => Set(AgentObservabilityKeys.UserEmail, v); /// Sets the agent description (gen_ai.agent.description). Optional. Apps-only — /// backed by . - public BaggageBuilder AgentDescription(string? v) => Set(AgentObservabilityKeys.AgentDescription, v); + public TeamsBaggageBuilder AgentDescription(string? v) => Set(AgentObservabilityKeys.AgentDescription, v); /// Sets the agentic user email (microsoft.agent.user.email). Required for cert. Apps-only. - public BaggageBuilder AgenticUserEmail(string? v) => Set(AgentObservabilityKeys.AgenticUserEmail, v); + public TeamsBaggageBuilder AgenticUserEmail(string? v) => Set(AgentObservabilityKeys.AgenticUserEmail, v); /// Escape hatch for setting any baggage key not exposed by a typed setter /// (e.g. client.address derived in HTTP middleware). - public BaggageBuilder Set(string key, string? value) + public TeamsBaggageBuilder Set(string key, string? value) { if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) { @@ -103,7 +103,7 @@ public BaggageBuilder Set(string key, string? value) /// backed by . Tenant fallback uses the typed /// when is null. /// - public BaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity + public TeamsBaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(ctx); TActivity activity = ctx.Activity; diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index f1e2322ef..f246aec3b 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -10,6 +10,7 @@ using Microsoft.Teams.Apps.Routing; using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Diagnostics; using Microsoft.Teams.Core.Hosting; using Microsoft.Teams.Core.Schema; @@ -126,27 +127,41 @@ public TeamsBotApplication( Context defaultContext = new(this, teamsActivity); // Agent365: set baggage (user.id, user.email, agent details, etc.) for all - // child spans. The invoke_agent scope itself is created in Core's ProcessAsync. - using var baggageScope = new BaggageBuilder() + // child spans, then open the invoke_agent scope inside the baggage scope so + // the span inherits Apps-only required baggage. + using var baggageScope = new TeamsBaggageBuilder() .FromTeamsContext(defaultContext) .Build(); - if (teamsActivity.Type != TeamsActivityType.Invoke) - { - await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); - } - else // invokes + using var invokeScope = InvokeAgentScope.Start(activity); + + try { - InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken).ConfigureAwait(false); - HttpContext? httpContext = httpContextAccessor.HttpContext; - if (httpContext is not null && invokeResponse is not null) + if (teamsActivity.Type != TeamsActivityType.Invoke) { - httpContext.Response.StatusCode = invokeResponse.Status; - logger.LogDebug("Sending invoke response with status {Status}", invokeResponse.Status); - logger.LogTrace("Sending invoke response with status {Status} and Body {Body}", invokeResponse.Status, invokeResponse.Body); - if (invokeResponse.Body is not null) - await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); + await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); } + else // invokes + { + InvokeResponse invokeResponse = await Router.DispatchWithReturnAsync(defaultContext, cancellationToken).ConfigureAwait(false); + HttpContext? httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null && invokeResponse is not null) + { + httpContext.Response.StatusCode = invokeResponse.Status; + logger.LogDebug("Sending invoke response with status {Status}", invokeResponse.Status); + logger.LogTrace("Sending invoke response with status {Status} and Body {Body}", invokeResponse.Status, invokeResponse.Body); + if (invokeResponse.Body is not null) + { + invokeScope.RecordOutputMessages(invokeResponse.Body.ToString()!); + await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); + } + } + } + } + catch (Exception ex) + { + invokeScope.RecordError(ex); + throw; } }; } diff --git a/core/src/Microsoft.Teams.Core/BotApplication.cs b/core/src/Microsoft.Teams.Core/BotApplication.cs index 146c3f854..26c2501ec 100644 --- a/core/src/Microsoft.Teams.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Core/BotApplication.cs @@ -216,11 +216,6 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke long startTimestamp = Stopwatch.GetTimestamp(); - // Agent365: open an invoke_agent scope so the Agent365 exporter can partition - // and export this turn's telemetry. No-op when no listener subscribes to - // ActivitySource("Agent365Sdk"). - using var invokeScope = InvokeAgentScope.Start(activity); - using (_logger.BeginActivityScope(activity.Type, activity.Id, activity.ServiceUrl, correlationVector)) { // Use a dedicated timeout instead of the HTTP request's cancellation token. @@ -242,7 +237,6 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke { _logger.ActivityProcessingError(ex, activity.Id); Telemetry.HandlerErrors.Add(1, activityTypeTag); - invokeScope.RecordError(ex); span.RecordException(ex); throw new BotHandlerException("Error processing activity", ex, activity); } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs similarity index 82% rename from core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs rename to core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs index 2f61edbba..3db41f322 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/BaggageBuilder.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs @@ -18,7 +18,7 @@ namespace Microsoft.Teams.Core.Diagnostics; /// (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 +/// (Microsoft.Teams.Apps.Diagnostics.CoreBaggageBuilder) when you have a /// Context<TeamsActivity>; it adds the Apps-only keys (user.id, user.email, /// microsoft.agent.user.email, gen_ai.agent.description). /// @@ -31,47 +31,47 @@ namespace Microsoft.Teams.Core.Diagnostics; /// for the full cert-attribute mapping. /// /// -public sealed class BaggageBuilder +public sealed class CoreBaggageBuilder { 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder AgentBlueprintId(string? v) => Set(AgentObservabilityKeys.AgentBlueprintId, v); /// Sets the human user name (user.name). Optional. - public BaggageBuilder UserName(string? v) => Set(AgentObservabilityKeys.UserName, v); + public CoreBaggageBuilder 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); + public CoreBaggageBuilder 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) + public CoreBaggageBuilder InvokeAgentServer(string? address, int? port = null) { Set(AgentObservabilityKeys.ServerAddress, address); if (port.HasValue && port.Value != 443) @@ -84,7 +84,7 @@ public BaggageBuilder InvokeAgentServer(string? address, int? port = null) /// 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) + public CoreBaggageBuilder Set(string key, string? value) { if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) { @@ -98,7 +98,7 @@ public BaggageBuilder Set(string key, string? value) /// 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) + public CoreBaggageBuilder FromCoreActivity(CoreActivity activity) { ArgumentNullException.ThrowIfNull(activity); diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs index e38894234..5a88dc03d 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs @@ -24,10 +24,10 @@ namespace Microsoft.Teams.Core.Diagnostics; /// Fields reachable only from the Apps-layer TeamsConversationAccount /// (user.id, user.email, microsoft.agent.user.email, /// gen_ai.agent.description) are not set here. They reach the exporter via -/// baggage set by the Apps-layer BaggageBuilder. +/// baggage set by the Apps-layer TeamsBaggageBuilder. /// /// -internal sealed class InvokeAgentScope : IDisposable +public sealed class InvokeAgentScope : IDisposable { private const string SourceName = "Agent365Sdk"; private const string OperationName = "invoke_agent"; @@ -141,6 +141,7 @@ public static InvokeAgentScope Start(CoreActivity activity) /// public void RecordOutputMessages(params string[] messages) { + ArgumentNullException.ThrowIfNull(messages); if (_activity is null || messages.Length == 0) { return; @@ -154,6 +155,7 @@ public void RecordOutputMessages(params string[] messages) /// public void RecordError(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); if (_activity is null) { return; @@ -164,6 +166,7 @@ public void RecordError(Exception exception) _activity.SetTag(ErrorTypeKey, _errorType); } + /// public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs index 598fc8f2b..11adbae68 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs @@ -96,7 +96,7 @@ private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticI // Conditionally apply ManagedIdentity configuration if registered if (_managedIdentityOptions is not null) { - ManagedIdentityOptions miOptions = _managedIdentityOptions.CurrentValue; + ManagedIdentityOptions miOptions = _managedIdentityOptions.Get(optionsName); if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) { diff --git a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs index dd64c81d8..3bdd75bf5 100644 --- a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs +++ b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs @@ -57,7 +57,7 @@ public class ConversationAccount() /// telemetry enrichment) can populate microsoft.tenant.id without requiring the Apps-layer /// TeamsConversationAccount. Classic Bot Framework activities still carry tenant id in /// channelData.tenant.id; consumers that need the channel-data fallback should use - /// BaggageBuilder.FromCoreActivity / FromTeamsContext, which transparently fall + /// CoreBaggageBuilder.FromCoreActivity / TeamsBaggageBuilder.FromTeamsContext, which transparently fall /// back when this property is null. /// [JsonPropertyName("tenantId")] diff --git a/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs b/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs index 67c414611..2aff819a2 100644 --- a/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs +++ b/core/src/Microsoft.Teams.Core/Schema/CoreActivity.cs @@ -183,6 +183,7 @@ protected CoreActivity(CoreActivity activity) AgenticAppId = source.AgenticAppId, AgenticUserId = source.AgenticUserId, AgenticAppBlueprintId = source.AgenticAppBlueprintId, + TenantId = source.TenantId, Properties = new ExtendedPropertiesDictionary(source.Properties) }; #pragma warning restore ExperimentalTeamsTargeted diff --git a/core/src/Microsoft.Teams.Core/TurnMiddleware.cs b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs index d28e8ffcc..aa7309d30 100644 --- a/core/src/Microsoft.Teams.Core/TurnMiddleware.cs +++ b/core/src/Microsoft.Teams.Core/TurnMiddleware.cs @@ -71,7 +71,7 @@ public async Task RunPipelineAsync(BotApplication botApplication, CoreActivity a } if (callback is not null) { - await callback(activity, cancellationToken).ConfigureAwait(false); + await (callback(activity, cancellationToken) ?? Task.CompletedTask).ConfigureAwait(false); } return; } diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs index abf421b4f..fa08a98db 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Diagnostics/BaggageBuilderTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Teams.Apps.UnitTests.Diagnostics; -public class BaggageBuilderTests +public class TeamsBaggageBuilderTests { [Fact] public void FromTeamsContext_PopulatesAppsOnlyKeysFromTeamsConversationAccount() @@ -102,7 +102,7 @@ public void Build_DisposeRestoresPreviousBaggage() Baggage.Current = default; try { - using (new BaggageBuilder().UserId("u").UserEmail("u@example.com").Build()) + using (new TeamsBaggageBuilder().UserId("u").UserEmail("u@example.com").Build()) { Assert.Equal("u", Baggage.GetBaggage("user.id")); Assert.Equal("u@example.com", Baggage.GetBaggage("user.email")); @@ -134,13 +134,13 @@ public void AppsOnlySetters_SetExpectedKeys() private static Context BuildCtx(TeamsActivity activity) => new(null!, activity); - private static Dictionary ApplyAndCapture(Action configure) + private static Dictionary ApplyAndCapture(Action configure) { Baggage previous = Baggage.Current; Baggage.Current = default; try { - BaggageBuilder builder = new(); + TeamsBaggageBuilder builder = new(); configure(builder); using (builder.Build()) { diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs index 66c6bef3b..ef90bb888 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Diagnostics/BaggageBuilderTests.cs @@ -8,7 +8,7 @@ namespace Microsoft.Teams.Core.UnitTests.Diagnostics; -public class BaggageBuilderTests +public class CoreBaggageBuilderTests { [Fact] public void FromCoreActivity_PopulatesExpectedKeysFromTypedFields() @@ -119,7 +119,7 @@ public void Build_DisposeRestoresPreviousBaggage() Baggage.Current = initial; try { - using (new BaggageBuilder().TenantId("tenant-x").Build()) + using (new CoreBaggageBuilder().TenantId("tenant-x").Build()) { Assert.Equal("tenant-x", Baggage.GetBaggage("microsoft.tenant.id")); Assert.Equal("yes", Baggage.GetBaggage("preexisting")); @@ -159,13 +159,13 @@ public void Set_EscapeHatchAcceptsAnyKey() Assert.Equal("aad-123", baggage["user.id"]); } - private static Dictionary ApplyAndCapture(Action configure) + private static Dictionary ApplyAndCapture(Action configure) { Baggage previous = Baggage.Current; Baggage.Current = default; try { - BaggageBuilder builder = new(); + CoreBaggageBuilder builder = new(); configure(builder); using (builder.Build()) { From 7a06dd2423f2a98a373e7e9c63622b4b6e0ace6b Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 15 May 2026 20:52:08 -0700 Subject: [PATCH 16/24] fix: address remaining PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused async from UseMicrosoftOpenTelemetry lambda (CS1998) - Serialize invoke response body as JSON for RecordOutputMessages - Add required Azure OpenAI env vars to README run instructions - Fix trace tree source names (Core spans → Microsoft.Teams.Core) - Fix cross-references in CoreBaggageBuilder/TeamsBaggageBuilder XML docs - Remove unused System.Reflection using from Telemetry.cs - Revert unrelated PABot dependency bump (Graph/Kiota versions) - Fix citation marker off-by-one (start loop at 0, append i+1) - Clamp CitationAppearance.Abstract to 160-char limit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../samples/ObservabilityBot/ObservabilityBotApp.cs | 6 +++--- core/samples/ObservabilityBot/Program.cs | 2 +- core/samples/ObservabilityBot/README.md | 13 +++++++++---- core/samples/PABot/PABot.csproj | 4 ++-- .../Diagnostics/TeamsBaggageBuilder.cs | 4 ++-- .../src/Microsoft.Teams.Apps/TeamsBotApplication.cs | 3 ++- .../Diagnostics/CoreBaggageBuilder.cs | 2 +- .../Microsoft.Teams.Core/Diagnostics/Telemetry.cs | 1 - 8 files changed, 20 insertions(+), 15 deletions(-) diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs index 5db95fc06..f13b15d23 100644 --- a/core/samples/ObservabilityBot/ObservabilityBotApp.cs +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -155,9 +155,9 @@ private async Task HandleMessageAsync(Context context, Cancella var responseText = chatResponse.Text; - for (int i = 1; i < citations.Count; i++) + for (int i = 0; i < citations.Count; i++) { - responseText += $"[{i}] "; + responseText += $"[{i + 1}] "; } // === OutputScope: record the agent's reply === @@ -175,7 +175,7 @@ private async Task HandleMessageAsync(Context context, Cancella for (int i = 0; i < citations.Count; i++) { var citation = citations[i]; - var abstract_ = citation.Content.Length > 400 ? citation.Content[..200] + "..." : citation.Content; + var abstract_ = citation.Content.Length > 160 ? citation.Content[..157] + "..." : citation.Content; responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); } diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index ffae5874e..2bf5ae49e 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -30,7 +30,7 @@ ["deployment.environment"] = builder.Environment.EnvironmentName, ["service.namespace"] = "Microsoft.Teams" })) - .UseMicrosoftOpenTelemetry(async o => { + .UseMicrosoftOpenTelemetry(o => { o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableHttpClientInstrumentation = true; o.Instrumentation.EnableAspNetCoreInstrumentation = true; diff --git a/core/samples/ObservabilityBot/README.md b/core/samples/ObservabilityBot/README.md index e20b17ccf..366678aed 100644 --- a/core/samples/ObservabilityBot/README.md +++ b/core/samples/ObservabilityBot/README.md @@ -32,6 +32,11 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 export OTEL_SERVICE_NAME=teams-observability-bot export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=local,service.version=dev" +# Required for the AI chat client (Azure OpenAI) +export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +export AZURE_OPENAI_KEY=your-key +export AZURE_OPENAI_DEPLOYMENT=your-deployment-name + dotnet run --project core/samples/ObservabilityBot ``` @@ -57,11 +62,11 @@ Per turn, the trace has the shape: ``` HTTP server span (auto, OTel ASP.NET Core) -└─ turn (Microsoft.Teams.Apps) - ├─ middleware [n times] (Microsoft.Teams.Apps) +└─ turn (Microsoft.Teams.Core) + ├─ middleware [n times] (Microsoft.Teams.Core) ├─ handler (Microsoft.Teams.Apps) - └─ conversation_client (Microsoft.Teams.Apps) - ├─ auth.outbound (Microsoft.Teams.Apps) + └─ conversation_client (Microsoft.Teams.Core) + ├─ auth.outbound (Microsoft.Teams.Core) │ └─ HTTP client span (auto — token endpoint) └─ HTTP client span (auto — Bot Service API) ``` diff --git a/core/samples/PABot/PABot.csproj b/core/samples/PABot/PABot.csproj index 171c8d243..426c8d09e 100644 --- a/core/samples/PABot/PABot.csproj +++ b/core/samples/PABot/PABot.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs index 5f778eb89..b8b16a6b3 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs @@ -13,8 +13,8 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// /// /// -/// This class is independent from Microsoft.Teams.Core.Diagnostics.TeamsBaggageBuilder — same name -/// in a different namespace, no inheritance. Apps's builder exposes the **superset** of the cert-relevant +/// This class is independent from Microsoft.Teams.Core.Diagnostics.CoreBaggageBuilder — +/// no inheritance. Apps's builder exposes the **superset** of the cert-relevant /// setters: everything Core's builder has plus the keys backed by /// (user.id, user.email, microsoft.agent.user.email, gen_ai.agent.description). /// diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index f246aec3b..5cba54a96 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Api.Clients; @@ -152,7 +153,7 @@ public TeamsBotApplication( logger.LogTrace("Sending invoke response with status {Status} and Body {Body}", invokeResponse.Status, invokeResponse.Body); if (invokeResponse.Body is not null) { - invokeScope.RecordOutputMessages(invokeResponse.Body.ToString()!); + invokeScope.RecordOutputMessages(JsonSerializer.Serialize(invokeResponse.Body)); await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); } } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs index 3db41f322..1ae72c799 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs @@ -18,7 +18,7 @@ namespace Microsoft.Teams.Core.Diagnostics; /// (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.CoreBaggageBuilder) when you have a +/// (Microsoft.Teams.Apps.Diagnostics.TeamsBaggageBuilder) when you have a /// Context<TeamsActivity>; it adds the Apps-only keys (user.id, user.email, /// microsoft.agent.user.email, gen_ai.agent.description). /// diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs index 18817c8f9..5178f3a5c 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Diagnostics.Metrics; -using System.Reflection; namespace Microsoft.Teams.Core.Diagnostics; From 41a5e93872e956425865ad501c0f81ec0b38a476 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 04:00:24 +0000 Subject: [PATCH 17/24] fix: revert unrelated PABot dependency changes Agent-Logs-Url: https://github.com/microsoft/teams.net/sessions/1bf29a76-58a6-4f2f-b1df-30d3bd241ffb Co-authored-by: rido-min <14916339+rido-min@users.noreply.github.com> --- core/samples/PABot/PABot.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/samples/PABot/PABot.csproj b/core/samples/PABot/PABot.csproj index 426c8d09e..171c8d243 100644 --- a/core/samples/PABot/PABot.csproj +++ b/core/samples/PABot/PABot.csproj @@ -12,8 +12,8 @@ - - + + From 6bc1e3769e830195542a9710970cd0b1d763b4a8 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 21 May 2026 08:56:44 -0700 Subject: [PATCH 18/24] Replace runtime version lookup with compile-time constant Switched s_version initialization from reflection-based runtime assembly version retrieval to using the compile-time constant ThisAssembly.NuGetPackageVersion for improved reliability and performance. --- core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs index d6b131e43..42cbf7999 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -13,10 +13,7 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// internal static class AppsTelemetry { - private static readonly string s_version = - typeof(AppsTelemetry).Assembly.GetCustomAttribute()?.InformationalVersion - ?? typeof(AppsTelemetry).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; + private const string s_version = ThisAssembly.NuGetPackageVersion; public static readonly ActivitySource Source = new(TeamsBotApplicationTelemetry.ActivitySourceName, s_version); From 6d38558439ef7d5de037961e5e05480ba07478d8 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 21 May 2026 09:01:21 -0700 Subject: [PATCH 19/24] fix: resolve TenantId hiding after merge with main Add `new` keyword to TeamsConversationAccount.TenantId to fix CS0108 (hides inherited ConversationAccount.TenantId). Property proxies to base so both static and virtual dispatch see the same value. Also fall back to source.TenantId when Properties doesn't contain the key. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Schema/TeamsConversationAccount.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs index 0fee860e9..0ccb45001 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs @@ -49,7 +49,7 @@ public TeamsConversationAccount() result.Email = result.Properties.Extract("email"); result.UserPrincipalName = result.Properties.Extract("userPrincipalName"); result.UserRole = result.Properties.Extract("userRole"); - result.TenantId = result.Properties.Extract("tenantId"); + result.TenantId = result.Properties.Extract("tenantId") ?? conversationAccount.TenantId; return result; } @@ -93,5 +93,9 @@ public TeamsConversationAccount() /// Gets or sets the TenantId. /// [JsonPropertyName("tenantId")] - public string? TenantId { get; set; } + public new string? TenantId + { + get => base.TenantId; + set => base.TenantId = value; + } } From 098ab0e779a036ecc1af4581e4cb6a11d6e30ce4 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 21 May 2026 09:16:10 -0700 Subject: [PATCH 20/24] code clean up --- .../Api/Clients/ConversationApiClient.cs | 1 - core/src/Microsoft.Teams.Apps/Context.cs | 3 +- .../Diagnostics/AgentObservabilityKeys.cs | 52 +++++++++---------- .../Diagnostics/AppsTelemetry.cs | 1 - .../Entities/CitationEntity.Extensions.cs | 2 - .../Entities/ClientInfoEntity.Extensions.cs | 2 - .../Schema/Entities/Entity.cs | 2 - .../Entities/OMessageEntity.Extensions.cs | 2 - .../Entities/ProductInfoEntity.Extensions.cs | 2 - .../Entities/QuotedReplyEntity.Extensions.cs | 1 - .../SensitiveUsageEntity.Extensions.cs | 2 - .../Entities/StreamInfoEntity.Extensions.cs | 2 - .../TargetedMessageInfoEntity.Extensions.cs | 1 - .../Schema/MessageActivityExtensions.cs | 4 +- .../Schema/TeamsActivityJsonContext.cs | 2 - .../Schema/TeamsChannelData.cs | 1 - .../Schema/TeamsConversationAccount.cs | 2 - .../TeamsBotApplication.cs | 4 +- .../Diagnostics/AgentObservabilityKeys.cs | 52 +++++++++---------- .../Diagnostics/InvokeAgentScope.cs | 7 ++- .../Diagnostics/Telemetry.cs | 2 +- .../Hosting/AddBotApplicationExtensions.cs | 2 +- .../Schema/ConversationExtensions.cs | 6 +-- 23 files changed, 65 insertions(+), 90 deletions(-) diff --git a/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs index 7f13dd6f8..45715a65e 100644 --- a/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs +++ b/core/src/Microsoft.Teams.Apps/Api/Clients/ConversationApiClient.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using Microsoft.Teams.Core; using Microsoft.Teams.Core.Schema; diff --git a/core/src/Microsoft.Teams.Apps/Context.cs b/core/src/Microsoft.Teams.Apps/Context.cs index b9ae9787f..fbd8fc4a8 100644 --- a/core/src/Microsoft.Teams.Apps/Context.cs +++ b/core/src/Microsoft.Teams.Apps/Context.cs @@ -92,12 +92,11 @@ public class Context(TeamsBotApplication botApplication, TActivity ac public Task ReplyAsync(TeamsActivity activity, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(activity); -#pragma warning disable ExperimentalTeamsQuotedReplies if (!string.IsNullOrWhiteSpace(Activity.Id)) { return Quote(Activity.Id, activity, cancellationToken); } -#pragma warning restore ExperimentalTeamsQuotedReplies + return SendActivityAsync(activity, cancellationToken); } diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs index 88dfe2bc1..60a3221e8 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AgentObservabilityKeys.cs @@ -12,38 +12,38 @@ namespace Microsoft.Teams.Apps.Diagnostics; /// internal static class AgentObservabilityKeys { - public const string TenantId = "microsoft.tenant.id"; - public const string ConversationId = "gen_ai.conversation.id"; + public const string TenantId = "microsoft.tenant.id"; + public const string ConversationId = "gen_ai.conversation.id"; public const string ConversationItemLink = "microsoft.conversation.item.link"; - public const string ChannelName = "microsoft.channel.name"; - public const string ChannelLink = "microsoft.channel.link"; + public const string ChannelName = "microsoft.channel.name"; + public const string ChannelLink = "microsoft.channel.link"; - public const string UserId = "user.id"; - public const string UserEmail = "user.email"; - public const string UserName = "user.name"; - public const string ClientAddress = "client.address"; + public const string UserId = "user.id"; + public const string UserEmail = "user.email"; + public const string UserName = "user.name"; + public const string ClientAddress = "client.address"; - public const string AgentId = "gen_ai.agent.id"; - public const string AgentName = "gen_ai.agent.name"; - public const string AgentDescription = "gen_ai.agent.description"; - public const string AgentVersion = "gen_ai.agent.version"; - public const string AgenticUserId = "microsoft.agent.user.id"; - public const string AgenticUserEmail = "microsoft.agent.user.email"; - public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; - public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; + public const string AgentId = "gen_ai.agent.id"; + public const string AgentName = "gen_ai.agent.name"; + public const string AgentDescription = "gen_ai.agent.description"; + public const string AgentVersion = "gen_ai.agent.version"; + public const string AgenticUserId = "microsoft.agent.user.id"; + public const string AgenticUserEmail = "microsoft.agent.user.email"; + public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; + public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; - public const string CallerAgentName = "microsoft.a365.caller.agent.name"; - public const string CallerAgentId = "microsoft.a365.caller.agent.id"; + public const string CallerAgentName = "microsoft.a365.caller.agent.name"; + public const string CallerAgentId = "microsoft.a365.caller.agent.id"; public const string CallerAgentBlueprintId = "microsoft.a365.caller.agent.blueprint.id"; - public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; - public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; + public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; + public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; public const string CallerAgentPlatformId = "microsoft.a365.caller.agent.platform.id"; - public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; + public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; - public const string SessionId = "microsoft.session.id"; - public const string SessionDescription = "microsoft.session.description"; + public const string SessionId = "microsoft.session.id"; + public const string SessionDescription = "microsoft.session.description"; - public const string ServiceName = "service.name"; - public const string ServerAddress = "server.address"; - public const string ServerPort = "server.port"; + public const string ServiceName = "service.name"; + public const string ServerAddress = "server.address"; + public const string ServerPort = "server.port"; } diff --git a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs index 42cbf7999..0c80702c0 100644 --- a/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Diagnostics.Metrics; -using System.Reflection; namespace Microsoft.Teams.Apps.Diagnostics; diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.Extensions.cs index fb402ef87..f180e8ec4 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/CitationEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.Extensions.cs index bdbe2be20..45959199b 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/ClientInfoEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs index 9b37a6c15..285f66bbf 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/Entity.cs @@ -70,9 +70,7 @@ public class EntityList : List "ProductInfo" => item.Deserialize(options), "streaminfo" => item.Deserialize(options), "quotedReply" => item.Deserialize(options), -#pragma warning disable ExperimentalTeamsTargeted "targetedMessageInfo" => item.Deserialize(options), -#pragma warning restore ExperimentalTeamsTargeted _ => item.Deserialize(options) }; if (entity != null) diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.Extensions.cs index 37defdb32..039f5ecda 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/OMessageEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.Extensions.cs index 05165f8d3..02949ac72 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/ProductInfoEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.Extensions.cs index 770ce33d5..e955afbf5 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/QuotedReplyEntity.Extensions.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Security; -using System.Text.RegularExpressions; namespace Microsoft.Teams.Apps.Schema.Entities; diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.Extensions.cs index 557f54759..1c25903a9 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/SensitiveUsageEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.Extensions.cs index cf277a1e7..d460f1e64 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/StreamInfoEntity.Extensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Core.Schema; - namespace Microsoft.Teams.Apps.Schema.Entities; /// diff --git a/core/src/Microsoft.Teams.Apps/Schema/Entities/TargetedMessageInfoEntity.Extensions.cs b/core/src/Microsoft.Teams.Apps/Schema/Entities/TargetedMessageInfoEntity.Extensions.cs index c52b5f9c4..4f1d0e251 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/Entities/TargetedMessageInfoEntity.Extensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/Entities/TargetedMessageInfoEntity.Extensions.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; -using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Apps.Schema.Entities; diff --git a/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs b/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs index e40dfef1c..c45ed16ce 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/MessageActivityExtensions.cs @@ -344,8 +344,8 @@ public static MessageActivity PrependQuote(this MessageActivity message, string message.Entities ??= []; message.Entities.Insert(0, new QuotedReplyEntity { QuotedReply = new QuotedReplyData { MessageId = messageId } }); - var placeholder = QuotedReplyEntityExtensions.QuotedPlaceholder(messageId); - var text = message.Text?.Trim() ?? ""; + string placeholder = QuotedReplyEntityExtensions.QuotedPlaceholder(messageId); + string text = message.Text?.Trim() ?? ""; message.Text = string.IsNullOrEmpty(text) ? placeholder : $"{placeholder} {text}"; return message; diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs index c91fd0608..38a8f3f47 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsActivityJsonContext.cs @@ -31,9 +31,7 @@ namespace Microsoft.Teams.Apps.Schema; [JsonSerializable(typeof(CitationEntity))] [JsonSerializable(typeof(QuotedReplyEntity))] [JsonSerializable(typeof(QuotedReplyData))] -#pragma warning disable ExperimentalTeamsTargeted [JsonSerializable(typeof(TargetedMessageInfoEntity))] -#pragma warning restore ExperimentalTeamsTargeted [JsonSerializable(typeof(CitationClaim))] [JsonSerializable(typeof(CitationAppearanceDocument))] [JsonSerializable(typeof(CitationImageObject))] diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs index 37a75ef9c..9ab55769d 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsChannelData.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Core.Schema; diff --git a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs index 0ccb45001..a1f62bd11 100644 --- a/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs +++ b/core/src/Microsoft.Teams.Apps/Schema/TeamsConversationAccount.cs @@ -36,9 +36,7 @@ public TeamsConversationAccount() TeamsConversationAccount result = new(); result.Id = conversationAccount.Id; result.Name = conversationAccount.Name; -#pragma warning disable ExperimentalTeamsTargeted result.IsTargeted = conversationAccount.IsTargeted; -#pragma warning restore ExperimentalTeamsTargeted result.AgenticAppId = conversationAccount.AgenticAppId; result.AgenticUserId = conversationAccount.AgenticUserId; result.AgenticAppBlueprintId = conversationAccount.AgenticAppBlueprintId; diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index 5cba54a96..7c3f15ffb 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -130,11 +130,11 @@ public TeamsBotApplication( // Agent365: set baggage (user.id, user.email, agent details, etc.) for all // child spans, then open the invoke_agent scope inside the baggage scope so // the span inherits Apps-only required baggage. - using var baggageScope = new TeamsBaggageBuilder() + using IDisposable baggageScope = new TeamsBaggageBuilder() .FromTeamsContext(defaultContext) .Build(); - using var invokeScope = InvokeAgentScope.Start(activity); + using InvokeAgentScope invokeScope = InvokeAgentScope.Start(activity); try { diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs b/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs index 2c67dd571..467cc6d7f 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/AgentObservabilityKeys.cs @@ -11,38 +11,38 @@ namespace Microsoft.Teams.Core.Diagnostics; /// internal static class AgentObservabilityKeys { - public const string TenantId = "microsoft.tenant.id"; - public const string ConversationId = "gen_ai.conversation.id"; + public const string TenantId = "microsoft.tenant.id"; + public const string ConversationId = "gen_ai.conversation.id"; public const string ConversationItemLink = "microsoft.conversation.item.link"; - public const string ChannelName = "microsoft.channel.name"; - public const string ChannelLink = "microsoft.channel.link"; + public const string ChannelName = "microsoft.channel.name"; + public const string ChannelLink = "microsoft.channel.link"; - public const string UserId = "user.id"; - public const string UserEmail = "user.email"; - public const string UserName = "user.name"; - public const string ClientAddress = "client.address"; + public const string UserId = "user.id"; + public const string UserEmail = "user.email"; + public const string UserName = "user.name"; + public const string ClientAddress = "client.address"; - public const string AgentId = "gen_ai.agent.id"; - public const string AgentName = "gen_ai.agent.name"; - public const string AgentDescription = "gen_ai.agent.description"; - public const string AgentVersion = "gen_ai.agent.version"; - public const string AgenticUserId = "microsoft.agent.user.id"; - public const string AgenticUserEmail = "microsoft.agent.user.email"; - public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; - public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; + public const string AgentId = "gen_ai.agent.id"; + public const string AgentName = "gen_ai.agent.name"; + public const string AgentDescription = "gen_ai.agent.description"; + public const string AgentVersion = "gen_ai.agent.version"; + public const string AgenticUserId = "microsoft.agent.user.id"; + public const string AgenticUserEmail = "microsoft.agent.user.email"; + public const string AgentBlueprintId = "microsoft.a365.agent.blueprint.id"; + public const string AgentPlatformId = "microsoft.a365.agent.platform.id"; - public const string CallerAgentName = "microsoft.a365.caller.agent.name"; - public const string CallerAgentId = "microsoft.a365.caller.agent.id"; + public const string CallerAgentName = "microsoft.a365.caller.agent.name"; + public const string CallerAgentId = "microsoft.a365.caller.agent.id"; public const string CallerAgentBlueprintId = "microsoft.a365.caller.agent.blueprint.id"; - public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; - public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; + public const string CallerAgentUserId = "microsoft.a365.caller.agent.user.id"; + public const string CallerAgentUserEmail = "microsoft.a365.caller.agent.user.email"; public const string CallerAgentPlatformId = "microsoft.a365.caller.agent.platform.id"; - public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; + public const string CallerAgentVersion = "microsoft.a365.caller.agent.version"; - public const string SessionId = "microsoft.session.id"; - public const string SessionDescription = "microsoft.session.description"; + public const string SessionId = "microsoft.session.id"; + public const string SessionDescription = "microsoft.session.description"; - public const string ServiceName = "service.name"; - public const string ServerAddress = "server.address"; - public const string ServerPort = "server.port"; + public const string ServiceName = "service.name"; + public const string ServerAddress = "server.address"; + public const string ServerPort = "server.port"; } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs index 5a88dc03d..a3737a7dc 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Teams.Core.Schema; namespace Microsoft.Teams.Core.Diagnostics; @@ -227,7 +226,7 @@ private static void SetTagMaybe(Activity activity, string key, string? value) private static string SerializeInputMessages(string text) { - var envelope = new MessageEnvelope + MessageEnvelope envelope = new() { Version = MessageSchemaVersion, Messages = @@ -244,7 +243,7 @@ private static string SerializeInputMessages(string text) private static string SerializeOutputMessages(string[] texts) { - var messages = new MessageEntry[texts.Length]; + MessageEntry[] messages = new MessageEntry[texts.Length]; for (int i = 0; i < texts.Length; i++) { messages[i] = new MessageEntry @@ -254,7 +253,7 @@ private static string SerializeOutputMessages(string[] texts) }; } - var envelope = new MessageEnvelope { Version = MessageSchemaVersion, Messages = messages }; + MessageEnvelope envelope = new() { Version = MessageSchemaVersion, Messages = messages }; return JsonSerializer.Serialize(envelope, s_jsonOptions); } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs index 5178f3a5c..1ea2a263b 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -14,7 +14,7 @@ namespace Microsoft.Teams.Core.Diagnostics; internal static class Telemetry { private const string s_version = ThisAssembly.NuGetPackageVersion; - + public static readonly ActivitySource Source = new(CoreTelemetryNames.ActivitySourceName, s_version); diff --git a/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs index 39e2fe3ca..40b7a53ff 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/core/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs b/core/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs index 89bae826e..2d095ce94 100644 --- a/core/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Schema/ConversationExtensions.cs @@ -14,7 +14,7 @@ public static class ConversationExtensions public static string ThreadId(this Conversation conversation) { ArgumentNullException.ThrowIfNull(conversation); - var parts = conversation.Id.Split(';'); + string[] parts = conversation.Id.Split(';'); return parts.Length > 1 ? parts[0] : conversation.Id; } @@ -33,12 +33,12 @@ public static string ToThreadedConversationId(string conversationId, string mess throw new ArgumentException("conversationId must be a non-empty string", nameof(conversationId)); } - if (string.IsNullOrEmpty(messageId) || !ulong.TryParse(messageId, out var parsed) || parsed == 0) + if (string.IsNullOrEmpty(messageId) || !ulong.TryParse(messageId, out ulong parsed) || parsed == 0) { throw new ArgumentException($"Invalid messageId \"{messageId}\": must be a non-zero numeric value", nameof(messageId)); } - var baseId = conversationId.Split(';')[0]; + string baseId = conversationId.Split(';')[0]; return $"{baseId};messageid={messageId}"; } } From 9ed8e8da3ce398d0a20daeb51be91ad200573de1 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 21 May 2026 09:41:25 -0700 Subject: [PATCH 21/24] fix: address PR review comments from latest Copilot review - Fix inaccurate InternalsVisibleTo claims in Telemetry.cs XML doc and Observability-Design.md (Core's Telemetry is internal to Core only) - Extract duplicated TryReadChannelDataTenantId into shared ChannelDataHelper to reduce drift risk - Mention MeterListener in Apps test AssemblyInfo parallelization comment - Narrow bare catch to catch (JsonException) in ObservabilityBot sample - Remove commented-out PackageReference in ObservabilityBot.csproj - Fix ObservabilityBot AddCitation build error after main merge (use builder API instead of extension on built TeamsActivity) Co-Authored-By: Claude Opus 4.6 (1M context) --- core/docs/Observability-Design.md | 2 +- .../ObservabilityBot/ObservabilityBot.csproj | 1 - .../ObservabilityBot/ObservabilityBotApp.cs | 12 ++--- .../Diagnostics/ChannelDataHelper.cs | 49 +++++++++++++++++++ .../Diagnostics/CoreBaggageBuilder.cs | 32 +----------- .../Diagnostics/InvokeAgentScope.cs | 32 +----------- .../Diagnostics/Telemetry.cs | 3 +- .../AssemblyInfo.cs | 5 +- 8 files changed, 63 insertions(+), 73 deletions(-) create mode 100644 core/src/Microsoft.Teams.Core/Diagnostics/ChannelDataHelper.cs diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md index e26d12e9a..4a36ded3f 100644 --- a/core/docs/Observability-Design.md +++ b/core/docs/Observability-Design.md @@ -71,7 +71,7 @@ public static class TeamsBotApplicationTelemetry ``` The matching internal singletons live in each assembly's `Diagnostics/` folder: -- `Microsoft.Teams.Core/Diagnostics/Telemetry.cs` — owned by Core; visible to Apps and BotBuilder via `InternalsVisibleTo` (used by Core types only — Apps does not call into it). +- `Microsoft.Teams.Core/Diagnostics/Telemetry.cs` — owned by Core; internal to `Microsoft.Teams.Core` (Apps has its own `AppsTelemetry` class). - `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. ## Spans diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index 2aedf1076..46e0e3a50 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -7,7 +7,6 @@ - diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs index f13b15d23..ade17f96a 100644 --- a/core/samples/ObservabilityBot/ObservabilityBotApp.cs +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -147,7 +147,7 @@ private async Task HandleMessageAsync(Context context, Cancella )); } } - catch { } + catch (JsonException) { } return []; }) .DistinctBy(c => c.Url) @@ -165,20 +165,18 @@ private async Task HandleMessageAsync(Context context, Cancella { } - var responseMsg = TeamsActivity.CreateBuilder() + var builder = TeamsActivity.CreateBuilder() .WithText(responseText, TextFormats.Markdown) .AddMention(context.Activity?.From!) - .Build(); - - responseMsg.AddAIGenerated(); + .AddAIGenerated(); for (int i = 0; i < citations.Count; i++) { var citation = citations[i]; var abstract_ = citation.Content.Length > 160 ? citation.Content[..157] + "..." : citation.Content; - responseMsg.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); + builder.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); } - await context.Send(responseMsg, ct); + await context.Send(builder.Build(), ct); } } diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/ChannelDataHelper.cs b/core/src/Microsoft.Teams.Core/Diagnostics/ChannelDataHelper.cs new file mode 100644 index 000000000..df7d06b22 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/ChannelDataHelper.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Shared helpers for extracting values from the untyped channelData property bag. +/// +internal static class ChannelDataHelper +{ + /// + /// Best-effort extraction of channelData.tenant.id from the activity's + /// dictionary. Returns + /// when the property is missing or malformed. + /// + internal static string? TryReadTenantId(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; + } +} diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs index 1ae72c799..1c37349ed 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs @@ -145,36 +145,8 @@ public IDisposable Build() 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 static string? TryReadChannelDataTenantId(CoreActivity activity) => + ChannelDataHelper.TryReadTenantId(activity); private sealed class RestoreScope(Baggage previous) : IDisposable { diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs index a3737a7dc..ac01e42a6 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs @@ -193,36 +193,8 @@ private static void SetTagMaybe(Activity activity, string key, string? value) } } - 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 static string? TryReadChannelDataTenantId(CoreActivity activity) => + ChannelDataHelper.TryReadTenantId(activity); private static string SerializeInputMessages(string text) { diff --git a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs index 1ea2a263b..f1584679d 100644 --- a/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -8,8 +8,7 @@ namespace Microsoft.Teams.Core.Diagnostics; /// /// Singletons for the SDK's , , and instruments. -/// Internal to Microsoft.Teams.Core; visible to Microsoft.Teams.Apps -/// and Microsoft.Teams.Apps.BotBuilder via InternalsVisibleTo. +/// Internal to Microsoft.Teams.Core. /// internal static class Telemetry { diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs b/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs index 0b3e41fe9..d76a115e2 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/AssemblyInfo.cs @@ -4,6 +4,7 @@ using Xunit; // Tests in this assembly use process-global state (System.Diagnostics.ActivitySource listeners, -// OpenTelemetry.Baggage.Current). Running them in parallel causes captures from one test to -// observe spans/metrics started in another. Disabling parallelization keeps the captures isolated. +// System.Diagnostics.Metrics.MeterListener, OpenTelemetry.Baggage.Current). Running them in +// parallel causes captures from one test to observe spans/metrics started in another. +// Disabling parallelization keeps the captures isolated. [assembly: CollectionBehavior(DisableTestParallelization = true)] From 8b9a1b5cdbe632853f7b5bb5bfe573bb6ddaa129 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Wed, 27 May 2026 15:26:16 -0700 Subject: [PATCH 22/24] Update dependencies and refactor Agent365 token resolver Updated NuGet packages: Microsoft.OpenTelemetry, Microsoft.Extensions.AI, Microsoft.Extensions.AI.OpenAI, and ModelContextProtocol. Refactored Agent365 exporter in Program.cs to use ContextualTokenResolver with new token acquisition logic requiring AgenticUserId and updated method calls. --- .../ObservabilityBot/ObservabilityBot.csproj | 8 ++++---- core/samples/ObservabilityBot/Program.cs | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/core/samples/ObservabilityBot/ObservabilityBot.csproj b/core/samples/ObservabilityBot/ObservabilityBot.csproj index 46e0e3a50..bbe76f8df 100644 --- a/core/samples/ObservabilityBot/ObservabilityBot.csproj +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -7,15 +7,15 @@ - + - - + + - + diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs index 2bf5ae49e..1c553ccb2 100644 --- a/core/samples/ObservabilityBot/Program.cs +++ b/core/samples/ObservabilityBot/Program.cs @@ -34,14 +34,15 @@ o.Exporters = ExportTarget.Otlp | ExportTarget.Agent365 | ExportTarget.AzureMonitor; o.Instrumentation.EnableHttpClientInstrumentation = true; o.Instrumentation.EnableAspNetCoreInstrumentation = true; - o.Agent365.Exporter.UseS2SEndpoint = true; - o.Agent365.Exporter.TokenResolver = async (agentId, tenantId) => + + o.Agent365.ContextualTokenResolver = async trctx => { var provider = rootProvider!.GetRequiredService(); - var options = new AuthorizationHeaderProviderOptions { AcquireTokenOptions = new() { AuthenticationOptionsName = "AzureAd", Tenant = tenantId } }; - options.WithAgentIdentity(agentId); - var token = await provider.CreateAuthorizationHeaderForAppAsync( - "api://9b975845-388f-4429-889e-eab1ef63949c/.default", options); + var options = new AuthorizationHeaderProviderOptions { AcquireTokenOptions = new() { AuthenticationOptionsName = "AzureAd", Tenant = trctx.TenantId } }; + ArgumentNullException.ThrowIfNull(trctx.Identity.AgenticUserId); + options.WithAgentUserIdentity(trctx.Identity.AgentId, new Guid(trctx.Identity.AgenticUserId)); + var token = await provider.CreateAuthorizationHeaderAsync( + ["api://9b975845-388f-4429-889e-eab1ef63949c/.default"], options); return token.Substring("Bearer".Length).Trim(); }; }) From 3b2b090592f9e2d052874d9cc47890839f592dc3 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Thu, 28 May 2026 14:33:25 -0700 Subject: [PATCH 23/24] Add launchSettings template with env vars for ElCanario Introduced launchSettings.TEMPLATE.json to define a development launch profile for the ElCanario project. The profile includes environment variables for ASP.NET Core, Azure AD, Application Insights, and Azure OpenAI, with a default deployment set to "gpt-5.4-mini". --- .../Properties/launchSettings.TEMPLATE.json | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 core/samples/ObservabilityBot/Properties/launchSettings.TEMPLATE.json diff --git a/core/samples/ObservabilityBot/Properties/launchSettings.TEMPLATE.json b/core/samples/ObservabilityBot/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 000000000..5ea519f33 --- /dev/null +++ b/core/samples/ObservabilityBot/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,21 @@ +{ + "profiles": { + "ElCanario": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AzureAd__TenantId": "", + "AzureAd__ClientId": "", + "AzureAd__ClientCredentials__0__SourceType": "ClientSecret", + "AzureAd__ClientCredentials__0__ClientSecret": "", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_KEY": "", + "AZURE_OPENAI_DEPLOYMENT": "gpt-5.4-mini" + } + } + } +} From dc5e5c4029f24f4cf0e464f1e3e27ea5a2d573df Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Fri, 29 May 2026 13:39:57 -0700 Subject: [PATCH 24/24] Refactor ObservabilityBotApp constructor and citation loop Refactored ObservabilityBotApp constructor to remove unused parameters and updated the base class call accordingly. Improved citation handling by using destructuring assignment for Title, Url, and Content in the citation loop. --- core/samples/ObservabilityBot/ObservabilityBotApp.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs index ade17f96a..70f234169 100644 --- a/core/samples/ObservabilityBot/ObservabilityBotApp.cs +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -24,16 +24,13 @@ public class ObservabilityBotApp : TeamsBotApplication private readonly string _deploymentName; public ObservabilityBotApp( - ConversationClient conversationClient, - UserTokenClient userTokenClient, ApiClient teamsApiClient, IHttpContextAccessor httpContextAccessor, ILogger logger, IChatClient chatClient, ChatOptions chatOptions, - BotApplicationOptions? options = null, TeamsBotApplicationOptions? teamsOptions = null) - : base(conversationClient, userTokenClient, teamsApiClient, httpContextAccessor, logger, options, teamsOptions) + : base(teamsApiClient, httpContextAccessor, logger, teamsOptions) { _chatClient = chatClient; _chatOptions = chatOptions; @@ -172,9 +169,9 @@ private async Task HandleMessageAsync(Context context, Cancella for (int i = 0; i < citations.Count; i++) { - var citation = citations[i]; - var abstract_ = citation.Content.Length > 160 ? citation.Content[..157] + "..." : citation.Content; - builder.AddCitation(i + 1, new CitationAppearance() { Name = citation.Title, Url = new Uri(citation.Url), Abstract = abstract_, Icon = CitationIcon.Text }); + var (Title, Url, Content) = citations[i]; + var abstract_ = Content.Length > 160 ? Content[..157] + "..." : Content; + builder.AddCitation(i + 1, new CitationAppearance() { Name = Title, Url = new Uri(Url), Abstract = abstract_, Icon = CitationIcon.Text }); } await context.Send(builder.Build(), ct);