diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..16f0f98 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- When reporting test health for this repository, prefer validating with `dotnet test --configuration Release` because the user runs tests in Release and gets passing results. diff --git a/samples/centralized-error-handling-and-problem-details/Sample/ApplicationExceptionHandler.cs b/samples/centralized-error-handling-and-problem-details/Sample/ApplicationExceptionHandler.cs index 3774e3a..50e2a22 100644 --- a/samples/centralized-error-handling-and-problem-details/Sample/ApplicationExceptionHandler.cs +++ b/samples/centralized-error-handling-and-problem-details/Sample/ApplicationExceptionHandler.cs @@ -18,6 +18,8 @@ public async ValueTask TryHandleAsync( ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(exception); + cancellationToken.ThrowIfCancellationRequested(); + if (httpContext.Response.HasStarted) { return false; @@ -51,6 +53,8 @@ public async ValueTask TryHandleAsync( httpContext.Response.StatusCode = problem.StatusCode; + cancellationToken.ThrowIfCancellationRequested(); + return await problemDetailsService.TryWriteAsync( new ProblemDetailsContext { diff --git a/samples/cross-system-capability-exchange/Sample/CrossSystemGateway.cs b/samples/cross-system-capability-exchange/Sample/CrossSystemGateway.cs index 22a432f..7ebdd46 100644 --- a/samples/cross-system-capability-exchange/Sample/CrossSystemGateway.cs +++ b/samples/cross-system-capability-exchange/Sample/CrossSystemGateway.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace CrossSystemCapabilityExchange; public sealed class CrossSystemGateway( @@ -6,6 +8,9 @@ public sealed class CrossSystemGateway( IExportExecutor executor, string executionDestination) { + private static readonly ActivitySource _activitySource = + new("CrossSystemCapabilityExchange.Gateway"); + private long _decisionSequence; public async Task ExecuteAsync( @@ -82,10 +87,20 @@ await executor.ExportAsync( { throw; } - catch (Exception) + catch (Exception exception) { + string failureCategory = ClassifyExecutionFailure(exception); + + RecordExecutionFailure( + exception, + failureCategory, + recipientDecisionId, + executionId, + artifact.Capability.CapabilityId, + context.CorrelationId); + // Do not propagate executor exception details across the recipient - // boundary. Internal telemetry may capture the exception separately; + // boundary. Internal telemetry captures categorized failure details; // the gateway returns only the stable failure category here. return GatewayResult.ExecutionFailed( recipientDecisionId, @@ -97,4 +112,47 @@ await executor.ExportAsync( recipientDecisionId, executionId); } + + private static string ClassifyExecutionFailure(Exception exception) + { + return exception switch + { + TimeoutException => "executor.timeout", + UnauthorizedAccessException => "executor.authorization", + ArgumentException => "executor.contract", + InvalidOperationException => "executor.state", + IOException => "executor.io", + _ => "executor.unexpected" + }; + } + + private static void RecordExecutionFailure( + Exception exception, + string failureCategory, + string recipientDecisionId, + string executionId, + string capabilityId, + string correlationId) + { + using Activity? activity = + _activitySource.StartActivity( + "cross-system.execution.failure", + ActivityKind.Internal); + + activity?.SetTag("error.type", exception.GetType().FullName); + activity?.SetTag("error.category", failureCategory); + activity?.SetTag("gateway.recipient_decision_id", recipientDecisionId); + activity?.SetTag("gateway.execution_id", executionId); + activity?.SetTag("gateway.capability_id", capabilityId); + activity?.SetTag("gateway.correlation_id", correlationId); + + Trace.TraceError( + "CrossSystemGateway execution failed. Category={0}; ExceptionType={1}; RecipientDecisionId={2}; ExecutionId={3}; CapabilityId={4}; CorrelationId={5}", + failureCategory, + exception.GetType().Name, + recipientDecisionId, + executionId, + capabilityId, + correlationId); + } } diff --git a/samples/cross-system-capability-exchange/Tests/CrossSystemCapabilityExchangeTests.cs b/samples/cross-system-capability-exchange/Tests/CrossSystemCapabilityExchangeTests.cs index d982c1d..028bdbd 100644 --- a/samples/cross-system-capability-exchange/Tests/CrossSystemCapabilityExchangeTests.cs +++ b/samples/cross-system-capability-exchange/Tests/CrossSystemCapabilityExchangeTests.cs @@ -395,31 +395,42 @@ public async Task TwoActuallyConcurrentClaimsProduceOneExecution() RecipientExportContext context = SampleScenarios.CreateContext(); - using var ready = new CountdownEvent(2); - using var start = new ManualResetEventSlim(false); + CancellationToken cancellationToken = + TestContext.Current.CancellationToken; + var allReady = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var start = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; Task[] tasks = - [.. Enumerable.Range(0, 2) - .Select(_ => Task.Run(async () => - { - ready.Signal(); - start.Wait(); - - return await gateway.ExecuteAsync( - artifact, - context, - CancellationToken.None); - }))]; - - Assert.True( - ready.Wait( - TimeSpan.FromSeconds(5), - TestContext.Current.CancellationToken)); - start.Set(); + [RunAttemptAsync(), RunAttemptAsync()]; + + await allReady.Task.WaitAsync( + TimeSpan.FromSeconds(5), + cancellationToken); + start.TrySetResult(true); GatewayResult[] results = await Task.WhenAll(tasks); + async Task RunAttemptAsync() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + allReady.TrySetResult(true); + } + + await start.Task.WaitAsync(cancellationToken); + + return await gateway.ExecuteAsync( + artifact, + context, + cancellationToken); + } + Assert.Single(results, result => result.Executed); Assert.Single( results, diff --git a/samples/distributed-acknowledgment-continuation/Tests/DistributedAcknowledgmentContinuationTests.cs b/samples/distributed-acknowledgment-continuation/Tests/DistributedAcknowledgmentContinuationTests.cs index ee561ff..3a11315 100644 --- a/samples/distributed-acknowledgment-continuation/Tests/DistributedAcknowledgmentContinuationTests.cs +++ b/samples/distributed-acknowledgment-continuation/Tests/DistributedAcknowledgmentContinuationTests.cs @@ -475,30 +475,39 @@ public async Task TwoActuallyConcurrentContinuationClaimsProduceOneExecution() CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using var ready = new CountdownEvent(2); - using var start = new ManualResetEventSlim(false); + var allReady = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var start = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; Task[] tasks = - [.. Enumerable.Range(0, 2) - .Select(_ => Task.Run(async () => - { - ready.Signal(); - start.Wait(cancellationToken); - - return await gateway.ExecuteAsync( - request, - evidence, - cancellationToken); - }, cancellationToken))]; + [RunAttemptAsync(), RunAttemptAsync()]; - Assert.True( - ready.Wait( - TimeSpan.FromSeconds(5), - cancellationToken)); - start.Set(); + await allReady.Task.WaitAsync( + TimeSpan.FromSeconds(5), + cancellationToken); + start.TrySetResult(true); GatewayResult[] results = await Task.WhenAll(tasks); + async Task RunAttemptAsync() + { + if (Interlocked.Increment(ref readyCount) == 2) + { + allReady.TrySetResult(true); + } + + await start.Task.WaitAsync(cancellationToken); + + return await gateway.ExecuteAsync( + request, + evidence, + cancellationToken); + } + Assert.Single(results, result => result.Executed); Assert.Single( results, diff --git a/samples/middleware-ordering-changes-behavior/Sample/MiddlewareOrderDemo.cs b/samples/middleware-ordering-changes-behavior/Sample/MiddlewareOrderDemo.cs index 4960272..c93bff4 100644 --- a/samples/middleware-ordering-changes-behavior/Sample/MiddlewareOrderDemo.cs +++ b/samples/middleware-ordering-changes-behavior/Sample/MiddlewareOrderDemo.cs @@ -79,6 +79,7 @@ private static void UseExceptionBoundary( catch (InvalidOperationException exception) { observe("exception-boundary:handled"); + observe($"exception-boundary:exception:{exception.GetType().Name}"); context.Response.StatusCode = StatusCodes.Status500InternalServerError; @@ -87,7 +88,7 @@ private static void UseExceptionBoundary( "text/plain"; await context.Response.WriteAsync( - $"Handled by demo exception boundary: {exception.Message}"); + "Handled by demo exception boundary."); } finally { diff --git a/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs b/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs index 68009d0..041285c 100644 --- a/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs +++ b/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs @@ -75,7 +75,7 @@ public async Task CorrectOrder_FaultProducesControlledProblemBoundaryResponse() Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode); Assert.Equal("text/plain", context.Response.ContentType); Assert.Equal( - "Handled by demo exception boundary: Demonstration failure.", + "Handled by demo exception boundary.", body); }