diff --git a/core/core.slnx b/core/core.slnx index 081560cea..b83585430 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -22,6 +22,7 @@ + @@ -50,6 +51,7 @@ + diff --git a/core/docs/Observability-Design.md b/core/docs/Observability-Design.md new file mode 100644 index 000000000..4a36ded3f --- /dev/null +++ b/core/docs/Observability-Design.md @@ -0,0 +1,412 @@ +# 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 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) +───────────── ─────────────────────── +.UseMicrosoftOpenTelemetry(...) + ActivitySource("Microsoft.Teams.Core") +.WithTracing(t => t ├─ "turn" (BotApplication.ProcessAsync) + .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(CoreTelemetryNames 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") + ├─ teams.handler.dispatched (Counter) + ├─ teams.handler.duration (Histogram, ms) + ├─ teams.handler.failures (Counter) + └─ teams.handler.unmatched (Counter) +``` + +## 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 `CoreTelemetryNames`. Neither references the other. + +| 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` | `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. + +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 CoreTelemetryNames +{ + 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; 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 + +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`) | +| `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 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 + +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 + +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 | +|---|---|---|---|---| +| `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 | + +### 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 + +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(CoreTelemetryNames.ActivitySourceName) + .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) + .WithMetrics(m => m + .AddMeter(CoreTelemetryNames.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 baggage builder (`CoreBaggageBuilder` / `TeamsBaggageBuilder`) 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 `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` | `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` | `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(...)` + `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 `CoreBaggageBuilder` / `TeamsBaggageBuilder` 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` | +| 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 `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**: + +- `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` (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 `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. + +### 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 `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 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: + +| 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/CoreBaggageBuilder.cs (public) +namespace Microsoft.Teams.Core.Diagnostics; + +public sealed class CoreBaggageBuilder +{ + // Keys reachable from CoreActivity / ConversationAccount. + 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 CoreBaggageBuilder FromCoreActivity(CoreActivity activity); + + public IDisposable Build(); // applies pairs to OpenTelemetry.Baggage.Current; returns restore-scope +} +``` + +```csharp +// Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs (public — separate class) +namespace Microsoft.Teams.Apps.Diagnostics; + +public sealed class TeamsBaggageBuilder +{ + // Same setters as Core's class … + 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 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 TeamsBaggageBuilder FromTeamsContext(Context ctx) where TActivity : TeamsActivity; + + public IDisposable Build(); +} +``` + +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. + +#### 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 TeamsBaggageBuilder() + .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 CoreBaggageBuilder() + .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..bbe76f8df --- /dev/null +++ b/core/samples/ObservabilityBot/ObservabilityBot.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/core/samples/ObservabilityBot/ObservabilityBotApp.cs b/core/samples/ObservabilityBot/ObservabilityBotApp.cs new file mode 100644 index 000000000..70f234169 --- /dev/null +++ b/core/samples/ObservabilityBot/ObservabilityBotApp.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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; +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(); + private readonly string _deploymentName; + + public ObservabilityBotApp( + ApiClient teamsApiClient, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + IChatClient chatClient, + ChatOptions chatOptions, + TeamsBotApplicationOptions? teamsOptions = null) + : base(teamsApiClient, httpContextAccessor, logger, teamsOptions) + { + _chatClient = chatClient; + _chatOptions = chatOptions; + _deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "unknown"; + + 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)); + } + + // 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"); + + List snapshot; + lock (history) { snapshot = [.. history]; } + + ChatResponse chatResponse; + using (var inferenceScope = InferenceScope.Start(request, inferenceDetails, agentDetails)) + { + chatResponse = await _chatClient.GetResponseAsync(snapshot, _chatOptions, 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]); + } + + lock (history) + { + history.AddRange(chatResponse.Messages); + } + + // === 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()); + + foreach (var funcResult in chatResponse.Messages + .SelectMany(m => m.Contents.OfType())) + { + 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()!); + } + } + + // Extract citations from tool results. + var citations = chatResponse.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 (JsonException) { } + return []; + }) + .DistinctBy(c => c.Url) + .Take(5).ToList(); + + var responseText = chatResponse.Text; + + for (int i = 0; i < citations.Count; i++) + { + responseText += $"[{i + 1}] "; + } + + // === OutputScope: record the agent's reply === + using (OutputScope.Start(request, new Response([responseText]), agentDetails)) + { + } + + var builder = TeamsActivity.CreateBuilder() + .WithText(responseText, TextFormats.Markdown) + .AddMention(context.Activity?.From!) + .AddAIGenerated(); + + for (int i = 0; i < citations.Count; i++) + { + 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); + } +} diff --git a/core/samples/ObservabilityBot/Program.cs b/core/samples/ObservabilityBot/Program.cs new file mode 100644 index 000000000..1c553ccb2 --- /dev/null +++ b/core/samples/ObservabilityBot/Program.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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; +using Microsoft.Teams.Core.Diagnostics; +using ModelContextProtocol.Client; +using ObservabilityBot; +using OpenTelemetry; +using OpenTelemetry.Resources; + + +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); +IServiceProvider? rootProvider = null; +builder.Services.AddTeamsBotApplication(); + +builder.Services.AddOpenTelemetry() + .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.EnableHttpClientInstrumentation = true; + o.Instrumentation.EnableAspNetCoreInstrumentation = true; + + o.Agent365.ContextualTokenResolver = async trctx => + { + var provider = rootProvider!.GetRequiredService(); + 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(); + }; + }) + .WithTracing(t => t.AddSource(activitySources)) + .WithMetrics(m => m.AddMeter(meterNames)); + +builder.Logging.AddOpenTelemetry(o => o.IncludeFormattedMessage = true); + +// 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" + }))); + +// 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"); + +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(sp.GetRequiredService()) + .Build()); + +builder.Services.AddSingleton(sp => +{ + var msdocsClient = sp.GetRequiredKeyedService>("msdocs").GetAwaiter().GetResult(); + var msdocsTools = msdocsClient.ListToolsAsync().GetAwaiter().GetResult(); + + 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 = [..msdocsTools] + }; +}); + +WebApplication app = builder.Build(); +rootProvider = app.Services; +app.MapGet("/", () => "ObservabilityBot is running. Telemetry source: " + CoreTelemetryNames.ActivitySourceName); + +app.UseTeamsBotApplication(); + +app.Run(); 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" + } + } + } +} diff --git a/core/samples/ObservabilityBot/README.md b/core/samples/ObservabilityBot/README.md new file mode 100644 index 000000000..366678aed --- /dev/null +++ b/core/samples/ObservabilityBot/README.md @@ -0,0 +1,76 @@ +# 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(CoreTelemetryNames.ActivitySourceName) + .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) + .WithMetrics(m => m + .AddMeter(CoreTelemetryNames.MeterName) + .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" + +# 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 +``` + +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.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 — 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..a8a4c49fb --- /dev/null +++ b/core/samples/ObservabilityBot/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Trace", + "Microsoft.Agents.A365.Observability": "Debug" + } + }, + "AllowedHosts": "*" +} 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 @@ - - + + 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 new file mode 100644 index 000000000..60a3221e8 --- /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..0c80702c0 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/AppsTelemetry.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Microsoft.Teams.Apps.Diagnostics; + +/// +/// Singletons for the Apps-level , , and instruments. +/// Internal to Microsoft.Teams.Apps. +/// +internal static class AppsTelemetry +{ + private const string s_version = ThisAssembly.NuGetPackageVersion; + + public static readonly ActivitySource Source = + new(TeamsBotApplicationTelemetry.ActivitySourceName, s_version); + + 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"; + } + + 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/Diagnostics/TeamsBaggageBuilder.cs b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.cs new file mode 100644 index 000000000..b8b16a6b3 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Diagnostics/TeamsBaggageBuilder.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.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). +/// +/// +/// 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 TeamsBaggageBuilder +{ + private readonly Dictionary _pairs = new(StringComparer.Ordinal); + + /// Sets the Microsoft Entra tenant id (microsoft.tenant.id). Required for cert. + public TeamsBaggageBuilder TenantId(string? v) => Set(AgentObservabilityKeys.TenantId, v); + + /// Sets the conversation id (gen_ai.conversation.id). Required for cert. + public TeamsBaggageBuilder ConversationId(string? v) => Set(AgentObservabilityKeys.ConversationId, v); + + /// Sets the conversation item link (microsoft.conversation.item.link). Optional. + public TeamsBaggageBuilder ConversationItemLink(string? v) => Set(AgentObservabilityKeys.ConversationItemLink, v); + + /// Sets the channel name (microsoft.channel.name). Required for cert. + 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 TeamsBaggageBuilder ChannelLink(string? v) => Set(AgentObservabilityKeys.ChannelLink, v); + + /// Sets the agent id (gen_ai.agent.id). Required for cert. + public TeamsBaggageBuilder AgentId(string? v) => Set(AgentObservabilityKeys.AgentId, v); + + /// Sets the agent display name (gen_ai.agent.name). Required for cert. + public TeamsBaggageBuilder AgentName(string? v) => Set(AgentObservabilityKeys.AgentName, v); + + /// Sets the agentic user id (microsoft.agent.user.id). Required for cert. + public TeamsBaggageBuilder AgenticUserId(string? v) => Set(AgentObservabilityKeys.AgenticUserId, v); + + /// Sets the agent blueprint id (microsoft.a365.agent.blueprint.id). Required for cert. + public TeamsBaggageBuilder AgentBlueprintId(string? v) => Set(AgentObservabilityKeys.AgentBlueprintId, v); + + /// Sets the human user name (user.name). Optional. + public TeamsBaggageBuilder UserName(string? v) => Set(AgentObservabilityKeys.UserName, v); + + /// Sets the operation source (service.name). Required for cert on server spans. + 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 TeamsBaggageBuilder 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 TeamsBaggageBuilder UserId(string? v) => Set(AgentObservabilityKeys.UserId, v); + + /// Sets the human user email (user.email). Required for cert. Apps-only. + public TeamsBaggageBuilder UserEmail(string? v) => Set(AgentObservabilityKeys.UserEmail, v); + + /// Sets the agent description (gen_ai.agent.description). Optional. Apps-only — + /// backed by . + public TeamsBaggageBuilder AgentDescription(string? v) => Set(AgentObservabilityKeys.AgentDescription, v); + + /// Sets the agentic user email (microsoft.agent.user.email). Required for cert. Apps-only. + 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 TeamsBaggageBuilder 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 TeamsBaggageBuilder 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..c873d85c7 --- /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(CoreTelemetryNames.ActivitySourceName) +/// .AddSource(TeamsBotApplicationTelemetry.ActivitySourceName)) +/// .WithMetrics(m => m +/// .AddMeter(CoreTelemetryNames.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..d3452d4b7 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; @@ -79,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 @@ -92,7 +96,40 @@ 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); + 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); } } @@ -125,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); } @@ -132,11 +175,56 @@ 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); + 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 + { + response = await matchingRoutes[0].InvokeRouteWithReturn(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 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/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 0fee860e9..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; @@ -49,7 +47,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 +91,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; + } } diff --git a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs index 1f2bbfaf0..81d7a6851 100644 --- a/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Apps/TeamsBotApplication.cs @@ -1,14 +1,17 @@ // 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; +using Microsoft.Teams.Apps.Diagnostics; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.OAuth; 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; @@ -138,22 +141,42 @@ public TeamsBotApplication( Context defaultContext = new(this, teamsActivity); - if (teamsActivity.Type != TeamsActivityType.Invoke) - { - await Router.DispatchAsync(defaultContext, cancellationToken).ConfigureAwait(false); - } - else // invokes + // 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 IDisposable baggageScope = new TeamsBaggageBuilder() + .FromTeamsContext(defaultContext) + .Build(); + + using InvokeAgentScope 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(JsonSerializer.Serialize(invokeResponse.Body)); + await httpContext.Response.WriteAsJsonAsync(invokeResponse.Body, cancellationToken).ConfigureAwait(false); + } + } + } + } + catch (Exception ex) + { + invokeScope.RecordError(ex); + throw; } }; logger.LogDebug("TeamsBotApplication version {Version}", Version); diff --git a/core/src/Microsoft.Teams.Core/BotApplication.cs b/core/src/Microsoft.Teams.Core/BotApplication.cs index a550fab4a..500561ff2 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,22 @@ 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 +230,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 bc8ff5379..d02e6c73b 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; @@ -77,12 +79,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; + } } /// @@ -115,12 +138,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; + } } @@ -149,12 +193,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; + } } /// @@ -210,12 +275,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..af0c3660d --- /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. +/// +public 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..467cc6d7f --- /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/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 new file mode 100644 index 000000000..1c37349ed --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreBaggageBuilder.cs @@ -0,0 +1,165 @@ +// 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.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). +/// +/// +/// 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 CoreBaggageBuilder +{ + private readonly Dictionary _pairs = new(StringComparer.Ordinal); + + /// Sets the Microsoft Entra tenant id (microsoft.tenant.id). Required for cert. + public CoreBaggageBuilder TenantId(string? v) => Set(AgentObservabilityKeys.TenantId, v); + + /// Sets the conversation id (gen_ai.conversation.id). Required for cert. + public CoreBaggageBuilder ConversationId(string? v) => Set(AgentObservabilityKeys.ConversationId, v); + + /// Sets the conversation item link (microsoft.conversation.item.link). Optional. + public CoreBaggageBuilder ConversationItemLink(string? v) => Set(AgentObservabilityKeys.ConversationItemLink, v); + + /// Sets the channel name (microsoft.channel.name). Required for cert. + 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 CoreBaggageBuilder ChannelLink(string? v) => Set(AgentObservabilityKeys.ChannelLink, v); + + /// Sets the agent id (gen_ai.agent.id). Required for cert. + public CoreBaggageBuilder AgentId(string? v) => Set(AgentObservabilityKeys.AgentId, v); + + /// Sets the agent display name (gen_ai.agent.name). Required for cert. + public CoreBaggageBuilder AgentName(string? v) => Set(AgentObservabilityKeys.AgentName, v); + + /// Sets the agentic user id (microsoft.agent.user.id). Required for cert. + public CoreBaggageBuilder AgenticUserId(string? v) => Set(AgentObservabilityKeys.AgenticUserId, v); + + /// Sets the agent blueprint id (microsoft.a365.agent.blueprint.id). Required for cert. + public CoreBaggageBuilder AgentBlueprintId(string? v) => Set(AgentObservabilityKeys.AgentBlueprintId, v); + + /// Sets the human user name (user.name). Optional. + public CoreBaggageBuilder UserName(string? v) => Set(AgentObservabilityKeys.UserName, v); + + /// Sets the operation source (service.name). Required for cert on server spans. + 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 CoreBaggageBuilder 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 CoreBaggageBuilder 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 CoreBaggageBuilder 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) => + ChannelDataHelper.TryReadTenantId(activity); + + 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/CoreTelemetryNames.cs b/core/src/Microsoft.Teams.Core/Diagnostics/CoreTelemetryNames.cs new file mode 100644 index 000000000..242aaa896 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/CoreTelemetryNames.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(CoreTelemetryNames.ActivitySourceName)) +/// .WithMetrics(m => m.AddMeter(CoreTelemetryNames.MeterName)); +/// +/// +public static class CoreTelemetryNames +{ + /// + /// 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/InvokeAgentScope.cs b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs new file mode 100644 index 000000000..ac01e42a6 --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/InvokeAgentScope.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Text.Json; +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 TeamsBaggageBuilder. +/// +/// +public 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) + { + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(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) => + ChannelDataHelper.TryReadTenantId(activity); + + private static string SerializeInputMessages(string text) + { + MessageEnvelope envelope = new() + { + Version = MessageSchemaVersion, + Messages = + [ + new MessageEntry + { + Role = "user", + Parts = [new TextPart { Content = text }], + }, + ], + }; + return JsonSerializer.Serialize(envelope, s_jsonOptions); + } + + private static string SerializeOutputMessages(string[] texts) + { + MessageEntry[] 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] }], + }; + } + + MessageEnvelope envelope = new() { 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; } + } +} 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..f1584679d --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Diagnostics/Telemetry.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Microsoft.Teams.Core.Diagnostics; + +/// +/// Singletons for the SDK's , , and instruments. +/// Internal to Microsoft.Teams.Core. +/// +internal static class Telemetry +{ + private const string s_version = ThisAssembly.NuGetPackageVersion; + + public static readonly ActivitySource Source = + new(CoreTelemetryNames.ActivitySourceName, s_version); + + public static readonly Meter Meter = + new(CoreTelemetryNames.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/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/AddBotApplicationExtensions.cs index 36549753f..1cd547c8c 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.Logging.Abstractions; diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs index f0d187a69..11adbae68 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; @@ -78,42 +80,67 @@ protected override async Task SendAsync(HttpRequestMessage private async Task GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken) { string optionsName = authenticationOptionsName ?? BotConfig.DefaultSectionName; - AuthorizationHeaderProviderOptions options = new() + using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.AuthOutbound, ActivityKind.Client); + // span?.SetTag(Telemetry.Tags.AuthScope, _scope); //TODO: review + + try { - AcquireTokenOptions = new AcquireTokenOptions() + AuthorizationHeaderProviderOptions options = new() { - AuthenticationOptionsName = optionsName, + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = optionsName, + } + }; + + // Conditionally apply ManagedIdentity configuration if registered + if (_managedIdentityOptions is not null) + { + ManagedIdentityOptions miOptions = _managedIdentityOptions.Get(optionsName); + + if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId)) + { + // _logger.ApplyingManagedIdentity(miOptions.UserAssignedClientId); // TODO: review + options.AcquireTokenOptions.ManagedIdentity = miOptions; + span?.SetTag(Telemetry.Tags.AuthFlow, "managed_identity"); + } } - }; - - if (_managedIdentityOptions?.Get(optionsName) is { UserAssignedClientId.Length: > 0 } miOptions) - { - options.AcquireTokenOptions.ManagedIdentity = miOptions; - } - - 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([AgenticScope], options, null, cancellationToken).ConfigureAwait(false); + return token; + } } - else + + _logAppOnlyToken(_logger, BotAppScope, 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([AgenticScope], options, null, cancellationToken).ConfigureAwait(false); - return token; + span.SetTag(Telemetry.Tags.AuthFlow, "app_only"); } - } - - _logAppOnlyToken(_logger, BotAppScope, null); - string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(BotAppScope, options, cancellationToken).ConfigureAwait(false); + string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(BotAppScope, 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 f229a03ab..1a25b2156 100644 --- a/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj +++ b/core/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj @@ -33,6 +33,7 @@ + diff --git a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs index 0b8f4c6c3..3bdd75bf5 100644 --- a/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs +++ b/core/src/Microsoft.Teams.Core/Schema/ConversationAccount.cs @@ -49,6 +49,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 + /// CoreBaggageBuilder.FromCoreActivity / TeamsBaggageBuilder.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/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}"; } } 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 807d30a31..aa7309d30 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) ?? Task.CompletedTask).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..d76a115e2 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.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.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)] 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..fa08a98db --- /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 TeamsBaggageBuilderTests +{ + [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 TeamsBaggageBuilder().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 + { + TeamsBaggageBuilder 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..965b0189d --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/RouterTelemetryTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. +// 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; +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"); + } + + [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 + { + 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(); + } + + /// + /// 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(); + } +} diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs index e65f5fd15..06caff979 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/TeamsActivityBuilderTests.cs @@ -192,6 +192,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() { 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..ef90bb888 --- /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 CoreBaggageBuilderTests +{ + [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 CoreBaggageBuilder().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 + { + CoreBaggageBuilder 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..aa9010fb1 --- /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 CoreTelemetryNames_ConstantsHaveExpectedValues() + { + Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.ActivitySourceName); + Assert.Equal("Microsoft.Teams.Core", CoreTelemetryNames.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 == CoreTelemetryNames.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 == CoreTelemetryNames.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(); + } +}