diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e2f0ea4e..688bd777 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,63 @@ # GitHub Copilot Instructions for Agent365-dotnet +The Microsoft Agent 365 SDK (C#/.NET) extends the Microsoft 365 Agents SDK with enterprise capabilities across four modules: **Observability**, **Notifications**, **Runtime**, and **Tooling**. Packages publish to NuGet under the `Microsoft.Agents.A365.*` prefix. + +## Build, Test, and Lint + +All commands run from the repository root unless noted. Requires the .NET 8.0.100 SDK (pinned in `src/global.json`). + +```bash +# Build / test the whole solution +dotnet build src/Microsoft.Agents.A365.Sdk.sln +dotnet test src/Microsoft.Agents.A365.Sdk.sln + +# Build script (traversal build via src/dirs.proj) — preferred for full runs +./build/build.ps1 # Release build +./build/build.ps1 -Clean -Restore -Test # clean rebuild + tests +./build/build.ps1 -Pack # produce NuGet packages + +# Run all tests in ONE project +dotnet test src/Tests/Microsoft.Agents.A365.Runtime.Tests/Microsoft.Agents.A365.Runtime.Tests.csproj + +# Run a SINGLE test or test class (xUnit primary; some MSTest) +dotnet test src/Microsoft.Agents.A365.Sdk.sln --filter "FullyQualifiedName~TenantContextHelperTests" +dotnet test src/Microsoft.Agents.A365.Sdk.sln --filter "Name=Extract_Returns_TenantId" + +# Format check (enforced as a pre-commit hook) +dotnet format src/Microsoft.Agents.A365.Sdk.sln --verify-no-changes +``` + +CI (`.github/workflows/ci.yml`) builds and tests in **Release** with `--no-restore`/`--no-build`, then packs. Some Observability/Tooling tests read `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, and `AZURE_OPENAI_DEPLOYMENT` from the environment. + +Pre-commit hooks (`.pre-commit-config.yaml`, install with `pip install pre-commit && pre-commit install`) run gitleaks (secret scanning), whitespace/EOL fixers, and `dotnet format`. + +## Architecture + +The SDK follows a consistent **Core + Extensions** pattern. Each module has a `Core`/`Runtime` package with base functionality and per-framework extension packages (SemanticKernel, AgentFramework, AzureAIFoundry, OpenAI). Source lives under `src//`: + +- **Runtime** (`src/Runtime/`) — multi-tenant context extraction (`TenantContextHelper` pulls tenant/worker IDs from `HttpContext` claims/headers/items) and the result pattern (`OperationResult` / `OperationError`). +- **Observability** (`src/Observability/`) — OpenTelemetry distributed tracing. Configured via a fluent `Builder` API; tracing uses disposable scope classes (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`) that auto-end spans on dispose. `BaggageMiddleware` seeds tenant/agent context into OTel baggage. A custom `Agent365Exporter` (gated by the `EnableAgent365Exporter` env var) exports spans. +- **Notifications** (`src/Notification/`) — event routing for M365 (Teams, email, Office) via `AgentNotification`; sub-channels like `agents:email`, `agents:word`, `agents:excel`, `agents:powerpoint`. +- **Tooling** (`src/Tooling/`) — Model Context Protocol (MCP) server discovery and tool registration via `IMcpToolServerConfigurationService`, with framework-specific registration extensions. + +Build is a **traversal build**: `src/dirs.proj` (Microsoft.Build.Traversal) references each module's `dirs.proj`. Projects are auto-discovered, but the solution file is still maintained manually (see rules below). Versioning is automatic via Nerdbank.GitVersioning (nbgv) from git history — never hardcode versions. + +Detailed design docs: `docs/design.md`; build internals: `build/BUILD.md`. + +## Key Conventions + +- **Central package management**: ALL NuGet versions live in `src/Directory.Packages.props`. Add a version there, then reference the package without a `Version` attribute in the `.csproj`. Never put versions in project files. +- **Common build props** (`src/Directory.Build.props`): `TreatWarningsAsErrors=true`, `Nullable=enable`, and `GenerateDocumentationFile=true` are global. Warnings — including invalid XML-doc `cref`s (CS1574) — fail the build. Write valid XML doc comments. +- **Target frameworks**: most packages target `net8.0`; some Runtime/Hosting packages target `netstandard2.0` (no implicit usings; `LangVersion` 8.0). Guard framework-specific code accordingly. +- **Observability export config — coordinated change required**: these three must stay in sync. If you change ONE, verify the other two: + | Constant | Location | + |---|---| + | `ProdObservabilityScope` (via `GetObservabilityAuthenticationScope()`) | `src/Observability/Runtime/Common/EnvironmentUtils.cs` | + | `DefaultEndpointHost` | `src/Observability/Runtime/Tracing/Exporters/Agent365ExporterOptions.cs` | + | Export URL path (`BuildEndpointPath()`) | `src/Observability/Runtime/Tracing/Exporters/Agent365ExporterCore.cs` | + Snapshot tests in `src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Tracing/Exporters/ExportConfigConsistencyTests.cs` catch accidental drift but not intentional-but-incomplete updates — confirm values are correct for the target environment. +- **Tests** mirror source under `src/Tests/` (e.g. `Microsoft.Agents.A365.Runtime.Tests`). xUnit is primary (some MSTest), with Moq for mocking and FluentAssertions for assertions. + ## Coding agent rules - Before committing changes, ensure that the solution `src/Microsoft.Agents.A365.Sdk.sln` builds: `dotnet build src/Microsoft.Agents.A365.Sdk.sln` - Before committing changes, ensure that all tests pass: `dotnet test src/Microsoft.Agents.A365.Sdk.sln` diff --git a/src/Observability/Runtime/Common/ExportFormatter.cs b/src/Observability/Runtime/Common/ExportFormatter.cs index 3dabfbd7..a69fdd66 100644 --- a/src/Observability/Runtime/Common/ExportFormatter.cs +++ b/src/Observability/Runtime/Common/ExportFormatter.cs @@ -149,7 +149,10 @@ public string FormatLogData(IDictionary data) SpanId = data["SpanId"], ParentSpanId = data["ParentSpanId"], TraceId = data.TryGetValue("TraceId", out var traceIdObj) ? traceIdObj : null, - Kind = data.TryGetValue("SpanKind", out var spanKindObj) && spanKindObj != null ? spanKindObj : SpanKindConstants.Client + Kind = data.TryGetValue("SpanKind", out var spanKindObj) && spanKindObj != null ? spanKindObj : SpanKindConstants.Client, + Status = data.TryGetValue("Status", out var statusObj) && statusObj != null + ? statusObj + : new Dictionary { { "code", 0 }, { "message", "" } } }; return SerializePayload(payload); diff --git a/src/Observability/Runtime/DTOs/BaseData.cs b/src/Observability/Runtime/DTOs/BaseData.cs index fef04b8e..9c016497 100644 --- a/src/Observability/Runtime/DTOs/BaseData.cs +++ b/src/Observability/Runtime/DTOs/BaseData.cs @@ -80,6 +80,18 @@ public BaseData( /// public string? TraceId { get; } + /// + /// Gets or sets the OpenTelemetry status code for the operation. Defaults to + /// (0); set to (2) + /// when an error is recorded. + /// + public SpanStatusCode StatusCode { get; set; } = SpanStatusCode.Unset; + + /// + /// Gets or sets the OpenTelemetry status message. Per the OTel spec this is only populated for an error status. + /// + public string? StatusMessage { get; set; } + /// /// Gets the duration of the operation if both start and end times are provided. /// @@ -102,7 +114,14 @@ public BaseData( { "ParentSpanId", ParentSpanId }, { "TraceId", TraceId }, { "SpanKind", SpanKind }, - { "Duration", Duration } + { "Duration", Duration }, + { + "Status", new Dictionary + { + { "code", (int)StatusCode }, + { "message", StatusMessage ?? "" } + } + } }; return dict; diff --git a/src/Observability/Runtime/DTOs/Builders/ApplyGuardrailDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/ApplyGuardrailDataBuilder.cs index 761bfbe7..fd234fb2 100644 --- a/src/Observability/Runtime/DTOs/Builders/ApplyGuardrailDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/ApplyGuardrailDataBuilder.cs @@ -29,6 +29,7 @@ public class ApplyGuardrailDataBuilder : BaseDataBuilder /// Optional dictionary of extra attributes. /// Optional span kind override. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. /// An ApplyGuardrailData object containing all telemetry data. public static ApplyGuardrailData Build( GuardrailDetails guardrailDetails, @@ -42,11 +43,12 @@ public static ApplyGuardrailData Build( CallerDetails? callerDetails = null, IDictionary? extraAttributes = null, string? spanKind = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var attributes = BuildAttributes(guardrailDetails, agentDetails, conversationId, channel, callerDetails, extraAttributes); - return new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId); + return ApplyStatus(new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId), error); } private static Dictionary BuildAttributes( diff --git a/src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs index c6683330..7b9bcafb 100644 --- a/src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs @@ -15,6 +15,22 @@ namespace Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders public abstract class BaseDataBuilder where T : BaseData { + /// + /// Applies an OpenTelemetry status to the built data based on an optional error, and records + /// the error.type attribute when an error is present. When is + /// null the status is left as . + /// + /// The telemetry data to annotate. + /// Optional exception describing a failure for the operation. + /// The same instance, for fluent use. + protected static T ApplyStatus(T data, Exception? error) + { + var status = SpanStatusBuilder.FromError(error, data.Attributes); + data.StatusCode = status.Code; + data.StatusMessage = status.Message; + return data; + } + /// /// Adds attributes for input messages. /// diff --git a/src/Observability/Runtime/DTOs/Builders/ExecuteInferenceDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/ExecuteInferenceDataBuilder.cs index a8898d08..68027c2f 100644 --- a/src/Observability/Runtime/DTOs/Builders/ExecuteInferenceDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/ExecuteInferenceDataBuilder.cs @@ -30,6 +30,7 @@ public class ExecuteInferenceDataBuilder : BaseDataBuilder /// Optional details about the caller. /// Optional dictionary of extra attributes. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. /// An ExecuteInferenceData object containing all telemetry data. public static ExecuteInferenceData Build( InferenceCallDetails inferenceCallDetails, @@ -45,7 +46,8 @@ public static ExecuteInferenceData Build( string? thoughtProcess = null, CallerDetails? callerDetails = null, IDictionary? extraAttributes = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var attributes = BuildAttributes( inferenceCallDetails, @@ -58,7 +60,7 @@ public static ExecuteInferenceData Build( callerDetails, extraAttributes); - return new ExecuteInferenceData(attributes, startTime, endTime, spanId, parentSpanId, traceId); + return ApplyStatus(new ExecuteInferenceData(attributes, startTime, endTime, spanId, parentSpanId, traceId), error); } private static Dictionary BuildAttributes( diff --git a/src/Observability/Runtime/DTOs/Builders/ExecuteToolDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/ExecuteToolDataBuilder.cs index 34f90618..5fe87b6e 100644 --- a/src/Observability/Runtime/DTOs/Builders/ExecuteToolDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/ExecuteToolDataBuilder.cs @@ -33,6 +33,7 @@ public class ExecuteToolDataBuilder : BaseDataBuilder /// Optional dictionary of extra attributes. /// Optional span kind override. Use or as appropriate. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. /// An ExecuteToolData object containing all telemetry data. public static ExecuteToolData Build( ToolCallDetails toolCallDetails, @@ -47,11 +48,12 @@ public static ExecuteToolData Build( CallerDetails? callerDetails = null, IDictionary? extraAttributes = null, string? spanKind = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var attributes = BuildAttributes(toolCallDetails, agentDetails, conversationId, responseContent, channel, callerDetails, extraAttributes); - return new ExecuteToolData(attributes, startTime, endTime, spanId, parentSpanId, spanKind, traceId); + return ApplyStatus(new ExecuteToolData(attributes, startTime, endTime, spanId, parentSpanId, spanKind, traceId), error); } private static Dictionary BuildAttributes( diff --git a/src/Observability/Runtime/DTOs/Builders/InvokeAgentDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/InvokeAgentDataBuilder.cs index dcc0ecf1..2d425e4c 100644 --- a/src/Observability/Runtime/DTOs/Builders/InvokeAgentDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/InvokeAgentDataBuilder.cs @@ -33,6 +33,7 @@ public class InvokeAgentDataBuilder : BaseDataBuilder /// Optional dictionary of extra attributes. /// Optional span kind override. Use or as appropriate. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. /// An InvokeAgentData object containing all telemetry data. public static InvokeAgentData Build( InvokeAgentScopeDetails invokeAgentScopeDetails, @@ -48,7 +49,8 @@ public static InvokeAgentData Build( string? parentSpanId = null, IDictionary? extraAttributes = null, string? spanKind = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var attributes = BuildAttributes( invokeAgentScopeDetails, @@ -60,14 +62,14 @@ public static InvokeAgentData Build( outputMessages, extraAttributes); - return new InvokeAgentData( + return ApplyStatus(new InvokeAgentData( attributes, startTime, endTime, spanId, parentSpanId, spanKind, - traceId); + traceId), error); } /// diff --git a/src/Observability/Runtime/DTOs/Builders/OutputDataBuilder.cs b/src/Observability/Runtime/DTOs/Builders/OutputDataBuilder.cs index 85069ef8..cc39b907 100644 --- a/src/Observability/Runtime/DTOs/Builders/OutputDataBuilder.cs +++ b/src/Observability/Runtime/DTOs/Builders/OutputDataBuilder.cs @@ -30,6 +30,7 @@ public class OutputDataBuilder : BaseDataBuilder /// Optional parent span ID for distributed tracing. /// Optional dictionary of extra attributes. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. /// An OutputData object containing all telemetry data. public static OutputData Build( AgentDetails agentDetails, @@ -42,11 +43,12 @@ public static OutputData Build( string? spanId = null, string? parentSpanId = null, IDictionary? extraAttributes = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var attributes = BuildAttributes(agentDetails, response, conversationId, channel, callerDetails, extraAttributes); - return new OutputData(attributes, startTime, endTime, spanId, parentSpanId, traceId); + return ApplyStatus(new OutputData(attributes, startTime, endTime, spanId, parentSpanId, traceId), error); } private static Dictionary BuildAttributes( diff --git a/src/Observability/Runtime/DTOs/Builders/SpanStatusBuilder.cs b/src/Observability/Runtime/DTOs/Builders/SpanStatusBuilder.cs new file mode 100644 index 00000000..36c571c7 --- /dev/null +++ b/src/Observability/Runtime/DTOs/Builders/SpanStatusBuilder.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; + +namespace Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders +{ + /// + /// Builds an OpenTelemetry-compliant from an exception and, for error + /// statuses, records the error.type attribute on the span attributes. + /// + /// + /// Centralizes the exception-to-status mapping so that the ETW DTO logging path stays consistent + /// with the Activity-based scope path (). + /// + public static class SpanStatusBuilder + { + /// + /// Creates a from an optional exception. + /// + /// + /// The exception to record. When null, an status is + /// returned and is left untouched. + /// + /// + /// Optional span attributes dictionary. When an error is supplied and this is non-null, the + /// error.type attribute is written following OTel semantic conventions. + /// + /// + /// An status (with the exception message) when + /// is non-null; otherwise an status. + /// + public static SpanStatus FromError(Exception? error, IDictionary? attributes = null) + { + if (error == null) + { + return new SpanStatus(SpanStatusCode.Unset); + } + + // Mirrors OpenTelemetryScope.RecordError: prefer the HTTP status from a RequestFailedException, + // otherwise fall back to the exception's full type name. + var errorType = error is RequestFailedException requestFailed && requestFailed.Status != 0 + ? requestFailed.Status.ToString() + : error.GetType().FullName ?? "error"; + + if (attributes != null) + { + attributes[OpenTelemetryConstants.ErrorTypeKey] = errorType; + } + + return new SpanStatus(SpanStatusCode.Error, error.Message); + } + } +} diff --git a/src/Observability/Runtime/DTOs/SpanStatus.cs b/src/Observability/Runtime/DTOs/SpanStatus.cs new file mode 100644 index 00000000..a3621afb --- /dev/null +++ b/src/Observability/Runtime/DTOs/SpanStatus.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Observability.Runtime.DTOs +{ + /// + /// Represents an OpenTelemetry span status (code and optional message), + /// following the OTel specification where = 0, + /// = 1, and = 2. + /// + public readonly struct SpanStatus + { + /// + /// Initializes a new instance of the struct. + /// + /// The status code. Defaults to . + /// Optional status message. Per the OTel spec this is only meaningful for an error status. + public SpanStatus(SpanStatusCode code = SpanStatusCode.Unset, string? message = null) + { + Code = code; + Message = message; + } + + /// + /// Gets the status code (, , or ). + /// + public SpanStatusCode Code { get; } + + /// + /// Gets the optional status message. Only populated for an error status. + /// + public string? Message { get; } + } +} diff --git a/src/Observability/Runtime/DTOs/SpanStatusCode.cs b/src/Observability/Runtime/DTOs/SpanStatusCode.cs new file mode 100644 index 00000000..d586c5c3 --- /dev/null +++ b/src/Observability/Runtime/DTOs/SpanStatusCode.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.Observability.Runtime.DTOs +{ + /// + /// Represents the OpenTelemetry span status code as defined by the OTLP specification. + /// The numeric values map directly onto the OTLP Status.StatusCode enum and are + /// emitted as-is on the ETW telemetry payload. + /// + public enum SpanStatusCode + { + /// + /// The default status; the span has not been explicitly marked successful or failed. + /// Maps to OTLP STATUS_CODE_UNSET. + /// + Unset = 0, + + /// + /// The operation completed successfully (explicitly set by the application). + /// Maps to OTLP STATUS_CODE_OK. + /// + Ok = 1, + + /// + /// The operation failed. Maps to OTLP STATUS_CODE_ERROR. + /// + Error = 2, + } +} diff --git a/src/Observability/Runtime/Etw/A365EtwLogger.cs b/src/Observability/Runtime/Etw/A365EtwLogger.cs index 92f5363a..1c0df7c9 100644 --- a/src/Observability/Runtime/Etw/A365EtwLogger.cs +++ b/src/Observability/Runtime/Etw/A365EtwLogger.cs @@ -48,7 +48,8 @@ public void LogInferenceCall( string? parentSpanId, Channel? channel, CallerDetails? callerDetails, - string? traceId) + string? traceId, + Exception? error = null) { var data = ExecuteInferenceDataBuilder.Build( inferenceCallDetails, @@ -62,7 +63,8 @@ public void LogInferenceCall( parentSpanId, channel, callerDetails: callerDetails, - traceId: traceId); + traceId: traceId, + error: error); logger.Log( LogLevel.Information, @@ -86,7 +88,8 @@ public void LogInvokeAgent( DateTimeOffset? endTime, string? spanId, string? parentSpanId, - string? traceId) + string? traceId, + Exception? error = null) { var data = InvokeAgentDataBuilder.Build( invokeAgentScopeDetails, @@ -100,7 +103,8 @@ public void LogInvokeAgent( endTime, spanId, parentSpanId, - traceId: traceId); + traceId: traceId, + error: error); logger.Log( LogLevel.Information, @@ -123,7 +127,8 @@ public void LogToolCall( string? parentSpanId, Channel? channel, CallerDetails? callerDetails, - string? traceId) + string? traceId, + Exception? error = null) { var data = ExecuteToolDataBuilder.Build( toolCallDetails, @@ -136,7 +141,8 @@ public void LogToolCall( parentSpanId, channel, callerDetails: callerDetails, - traceId: traceId); + traceId: traceId, + error: error); logger.Log( LogLevel.Information, @@ -158,7 +164,8 @@ public void LogOutput( DateTimeOffset? endTime = null, string? spanId = null, string? parentSpanId = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var data = OutputDataBuilder.Build( agentDetails, @@ -170,7 +177,8 @@ public void LogOutput( endTime, spanId, parentSpanId, - traceId: traceId); + traceId: traceId, + error: error); logger.Log( LogLevel.Information, @@ -192,7 +200,8 @@ public void LogApplyGuardrail( string? spanId = null, Channel? channel = null, CallerDetails? callerDetails = null, - string? traceId = null) + string? traceId = null, + Exception? error = null) { var data = ApplyGuardrailDataBuilder.Build( guardrailDetails, @@ -204,7 +213,8 @@ public void LogApplyGuardrail( spanId, channel, callerDetails: callerDetails, - traceId: traceId); + traceId: traceId, + error: error); logger.Log( LogLevel.Information, diff --git a/src/Observability/Runtime/Etw/IA365EtwLogger.cs b/src/Observability/Runtime/Etw/IA365EtwLogger.cs index 5dee4308..bb4b5dde 100644 --- a/src/Observability/Runtime/Etw/IA365EtwLogger.cs +++ b/src/Observability/Runtime/Etw/IA365EtwLogger.cs @@ -26,6 +26,7 @@ public interface IA365EtwLogger /// Optional span ID for tracing. /// Optional parent span ID for tracing. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. public void LogInvokeAgent( InvokeAgentScopeDetails invokeAgentScopeDetails, AgentDetails agentDetails, @@ -38,7 +39,8 @@ public void LogInvokeAgent( DateTimeOffset? endTime = null, string? spanId = null, string? parentSpanId = null, - string? traceId = null); + string? traceId = null, + Exception? error = null); /// /// Logs an inference event. @@ -55,6 +57,7 @@ public void LogInvokeAgent( /// Optional trace ID for distributed tracing. /// Optional channel information for the inference call. /// Optional details of the caller. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. public void LogInferenceCall( InferenceCallDetails inferenceCallDetails, AgentDetails agentDetails, @@ -67,7 +70,8 @@ public void LogInferenceCall( string? parentSpanId = null, Channel? channel = null, CallerDetails? callerDetails = null, - string? traceId = null); + string? traceId = null, + Exception? error = null); /// /// Logs an execute_tool event. @@ -83,6 +87,7 @@ public void LogInferenceCall( /// Optional trace ID for distributed tracing. /// Optional channel information for the tool call. /// Optional details of the caller. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. public void LogToolCall( ToolCallDetails toolCallDetails, AgentDetails agentDetails, @@ -94,7 +99,8 @@ public void LogToolCall( string? parentSpanId = null, Channel? channel = null, CallerDetails? callerDetails = null, - string? traceId = null); + string? traceId = null, + Exception? error = null); /// /// Logs an output_messages event. @@ -109,6 +115,7 @@ public void LogToolCall( /// Optional span ID for tracing. /// Optional parent span ID for tracing. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. public void LogOutput( AgentDetails agentDetails, Response response, @@ -119,7 +126,8 @@ public void LogOutput( DateTimeOffset? endTime = null, string? spanId = null, string? parentSpanId = null, - string? traceId = null); + string? traceId = null, + Exception? error = null); /// /// Logs an apply_guardrail event. @@ -134,6 +142,7 @@ public void LogOutput( /// Optional channel information. /// Optional details of the caller. /// Optional trace ID for distributed tracing. + /// Optional exception describing a failure; sets an OTel error status and the error.type attribute. public void LogApplyGuardrail( GuardrailDetails guardrailDetails, AgentDetails agentDetails, @@ -144,6 +153,7 @@ public void LogApplyGuardrail( string? spanId = null, Channel? channel = null, CallerDetails? callerDetails = null, - string? traceId = null); + string? traceId = null, + Exception? error = null); } } diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/ExportFormatterStatusTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/ExportFormatterStatusTests.cs new file mode 100644 index 00000000..9441e20c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/Common/ExportFormatterStatusTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.Observability.Runtime.DTOs; + +namespace Microsoft.Agents.A365.Observability.Runtime.Tests.Common; + +public partial class ExportFormatterTests +{ + [TestMethod] + public void FormatLogData_NoError_EmitsUnsetStatus() + { + // Arrange + var data = new InvokeAgentData( + new Dictionary { { "key", "val" } }, + spanId: "span-1"); + var formatter = CreateFormatter(); + + // Act + var json = formatter.FormatLogData(data.ToDictionary()); + + // Assert + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("Status"); + status.GetProperty("code").GetInt32().Should().Be(0); + status.GetProperty("message").GetString().Should().Be(""); + } + + [TestMethod] + public void FormatLogData_WithErrorStatus_EmitsErrorCodeAndMessage() + { + // Arrange + var data = new InvokeAgentData( + new Dictionary { { "key", "val" } }, + spanId: "span-2") + { + StatusCode = SpanStatusCode.Error, + StatusMessage = "something failed" + }; + var formatter = CreateFormatter(); + + // Act + var json = formatter.FormatLogData(data.ToDictionary()); + + // Assert + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("Status"); + status.GetProperty("code").GetInt32().Should().Be(2); + status.GetProperty("message").GetString().Should().Be("something failed"); + } + + [TestMethod] + public void FormatLogData_MissingStatusKey_DefaultsToUnset() + { + // Arrange — a dictionary without a "Status" entry + var bareData = new Dictionary + { + { "Name", "InvokeAgent" }, + { "Attributes", new Dictionary() }, + { "SpanId", "span-3" }, + { "ParentSpanId", null }, + }; + var formatter = CreateFormatter(); + + // Act + var json = formatter.FormatLogData(bareData); + + // Assert + using var doc = JsonDocument.Parse(json); + var status = doc.RootElement.GetProperty("Status"); + status.GetProperty("code").GetInt32().Should().Be(0); + status.GetProperty("message").GetString().Should().Be(""); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/BaseDataTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/BaseDataTests.cs index 4d8d6d60..fb9c5331 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/BaseDataTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/BaseDataTests.cs @@ -195,5 +195,40 @@ public void TraceId_NullInToDictionary_WhenNotProvided() var dict = data.ToDictionary(); dict.Should().ContainKey("TraceId").WhoseValue.Should().BeNull(); } + + [TestMethod] + public void StatusCode_DefaultsToUnset() + { + var data = new TestData(); + data.StatusCode.Should().Be(SpanStatusCode.Unset); + data.StatusMessage.Should().BeNull(); + } + + [TestMethod] + public void Status_DefaultInToDictionary_IsUnsetWithEmptyMessage() + { + var data = new TestData(); + var dict = data.ToDictionary(); + + dict.Should().ContainKey("Status"); + var status = dict["Status"].Should().BeAssignableTo>().Subject; + status["code"].Should().Be(0); + status["message"].Should().Be(""); + } + + [TestMethod] + public void Status_ErrorInToDictionary_EmitsCodeAndMessage() + { + var data = new TestData + { + StatusCode = SpanStatusCode.Error, + StatusMessage = "kaboom" + }; + var dict = data.ToDictionary(); + + var status = dict["Status"].Should().BeAssignableTo>().Subject; + status["code"].Should().Be(2); + status["message"].Should().Be("kaboom"); + } } } diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/InvokeAgentDataBuilderTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/InvokeAgentDataBuilderTests.cs index 91dcc78f..ffe05dff 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/InvokeAgentDataBuilderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/InvokeAgentDataBuilderTests.cs @@ -220,6 +220,43 @@ public void Build_OmitsMessages_WhenNull() telemetry.Attributes.Should().NotContainKey(OpenTelemetryConstants.GenAiOutputMessagesKey); } + [TestMethod] + public void Build_NoError_LeavesStatusUnset() + { + // Arrange + var endpoint = new Uri("https://example.com"); + var agentDetails = new AgentDetails("agent-123", "TestAgent"); + var scopeDetails = new InvokeAgentScopeDetails(endpoint: endpoint); + + // Act + var telemetry = InvokeAgentDataBuilder.Build(scopeDetails, agentDetails, "conv-1"); + + // Assert + telemetry.StatusCode.Should().Be(SpanStatusCode.Unset); + telemetry.StatusMessage.Should().BeNull(); + telemetry.Attributes.Should().NotContainKey(OpenTelemetryConstants.ErrorTypeKey); + } + + [TestMethod] + public void Build_WithError_SetsErrorStatusAndErrorType() + { + // Arrange + var endpoint = new Uri("https://example.com"); + var agentDetails = new AgentDetails("agent-123", "TestAgent"); + var scopeDetails = new InvokeAgentScopeDetails(endpoint: endpoint); + var error = new InvalidOperationException("agent blew up"); + + // Act + var telemetry = InvokeAgentDataBuilder.Build(scopeDetails, agentDetails, "conv-1", error: error); + + // Assert + telemetry.StatusCode.Should().Be(SpanStatusCode.Error); + telemetry.StatusMessage.Should().Be("agent blew up"); + telemetry.Attributes.Should().ContainKey(OpenTelemetryConstants.ErrorTypeKey); + telemetry.Attributes[OpenTelemetryConstants.ErrorTypeKey].Should().Be(typeof(InvalidOperationException).FullName); + } + + [TestMethod] public void Build_SetsTimingInformation_WhenProvided() { diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/SpanStatusBuilderTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/SpanStatusBuilderTests.cs new file mode 100644 index 00000000..54b77c42 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Observability.Runtime.Tests/DTOs/Builders/SpanStatusBuilderTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure; +using FluentAssertions; +using Microsoft.Agents.A365.Observability.Runtime.DTOs; +using Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes; + +namespace Microsoft.Agents.A365.Observability.Runtime.Tests.DTOs.Builders +{ + [TestClass] + public class SpanStatusBuilderTests + { + [TestMethod] + public void FromError_NullError_ReturnsUnset_AndDoesNotTouchAttributes() + { + var attributes = new Dictionary(); + + var status = SpanStatusBuilder.FromError(null, attributes); + + status.Code.Should().Be(SpanStatusCode.Unset); + status.Message.Should().BeNull(); + attributes.Should().NotContainKey(OpenTelemetryConstants.ErrorTypeKey); + } + + [TestMethod] + public void FromError_WithException_ReturnsError_WithMessage() + { + var error = new InvalidOperationException("boom"); + + var status = SpanStatusBuilder.FromError(error); + + status.Code.Should().Be(SpanStatusCode.Error); + status.Message.Should().Be("boom"); + } + + [TestMethod] + public void FromError_WithException_WritesErrorTypeAttribute_AsFullTypeName() + { + var attributes = new Dictionary(); + var error = new TimeoutException("timed out"); + + SpanStatusBuilder.FromError(error, attributes); + + attributes.Should().ContainKey(OpenTelemetryConstants.ErrorTypeKey); + attributes[OpenTelemetryConstants.ErrorTypeKey].Should().Be(typeof(TimeoutException).FullName); + } + + [TestMethod] + public void FromError_RequestFailedException_UsesHttpStatusAsErrorType() + { + var attributes = new Dictionary(); + var error = new RequestFailedException(404, "not found"); + + var status = SpanStatusBuilder.FromError(error, attributes); + + status.Code.Should().Be(SpanStatusCode.Error); + attributes[OpenTelemetryConstants.ErrorTypeKey].Should().Be("404"); + } + + [TestMethod] + public void FromError_NullAttributes_DoesNotThrow() + { + var act = () => SpanStatusBuilder.FromError(new Exception("x"), null); + + act.Should().NotThrow(); + } + } +}