@@ -26,6 +26,7 @@ public sealed class TryFailureTelemetryTests
2626{
2727 private const string OperationName = "try-failure-operation" ;
2828 private const string CallerCategory = "try-failure-caller" ;
29+ private const string DisabledCallerCategory = "disabled-try-failure-caller" ;
2930 private const string FailureDetail = "original failure detail" ;
3031 private const string BusinessTitle = "Access denied" ;
3132 private const string BusinessDetail = "The requested Company cannot be accessed." ;
@@ -37,6 +38,12 @@ public sealed class TryFailureTelemetryTests
3738 private const string StackTraceTag = "exception.stacktrace" ;
3839 private const string SensitiveHttpDetail = "secret-http-payload" ;
3940 private const int Value = 42 ;
41+ private const int ExplicitNullLogger = 0 ;
42+ private const int DisabledLogger = 1 ;
43+ private const int DefaultNullLogger = 2 ;
44+ private const int AbsentActivity = 0 ;
45+ private const int SampledActivity = 1 ;
46+ private const int UnsampledActivity = 2 ;
4047
4148 [ Test ]
4249 [ Arguments ( false ) ]
@@ -351,6 +358,188 @@ public async Task TryAsync_Success_ShouldPreserveShapeAndInvokeNoProblemFactory(
351358 observation . FlushFailures ( ) . ShouldBe ( 0 ) ;
352359 }
353360
361+ [ Test ]
362+ [ Arguments ( false , ExplicitNullLogger ) ]
363+ [ Arguments ( true , ExplicitNullLogger ) ]
364+ [ Arguments ( false , DisabledLogger ) ]
365+ [ Arguments ( true , DisabledLogger ) ]
366+ [ Arguments ( false , DefaultNullLogger ) ]
367+ [ Arguments ( true , DefaultNullLogger ) ]
368+ public async Task TryAsync_AfterUnavailableLogger_ShouldExportOriginalExceptionOnce ( bool useValueTask , int loggerSelection )
369+ {
370+ using var observation = new Observation ( ) ;
371+ ILogger ? unavailableLogger = loggerSelection switch
372+ {
373+ ExplicitNullLogger => NullLogger . Instance ,
374+ DisabledLogger => observation . DisabledLogger ,
375+ DefaultNullLogger => null ,
376+ _ => throw new ArgumentOutOfRangeException ( nameof ( loggerSelection ) )
377+ } ;
378+ if ( loggerSelection == DefaultNullLogger )
379+ {
380+ CommunicationLogger . Configure ( NullLoggerFactory . Instance ) ;
381+ CommunicationLogger . GetLogger ( ) . IsEnabled ( LogLevel . Error ) . ShouldBeFalse ( ) ;
382+ }
383+ else
384+ {
385+ unavailableLogger . ShouldNotBeNull ( ) ;
386+ unavailableLogger . IsEnabled ( LogLevel . Error ) . ShouldBeFalse ( ) ;
387+ }
388+ using ( var activity = CommunicationTelemetry . StartActivity ( OperationName ) )
389+ {
390+ activity . ShouldNotBeNull ( ) ;
391+ var exception = new InvalidOperationException ( FailureDetail ) ;
392+ var problem = CreateBusinessProblem ( ) ;
393+ var first = useValueTask
394+ ? await Result . TryAsync ( ( Func < ValueTask < int > > ) ( ( ) => ThrowValueTaskAsync ( exception ) ) , unavailableLogger , problemFactory : _ => problem )
395+ : await Result . TryAsync ( ( Func < Task < int > > ) ( ( ) => ThrowTaskAsync ( exception ) ) , unavailableLogger , problemFactory : _ => problem ) ;
396+ first . IsFailed . ShouldBeTrue ( ) ;
397+ first . Problem . ShouldBeSameAs ( problem ) ;
398+ observation . Logs . ShouldBeEmpty ( ) ;
399+ activity . Events . Count ( item => item . Name == ExceptionEvent ) . ShouldBe ( 1 ) ;
400+
401+ AssertForwardedFailure ( problem , exception , observation . Logger ) ;
402+ AssertException ( observation , activity , exception ) ;
403+ AssertBusinessProblem ( problem ) ;
404+ activity . Status . ShouldBe ( ActivityStatusCode . Unset ) ;
405+ }
406+
407+ observation . Spans . ShouldHaveSingleItem ( ) . Events . Count ( item => item . Name == ExceptionEvent ) . ShouldBe ( 1 ) ;
408+ observation . FlushFailures ( ) . ShouldBe ( 1 ) ;
409+ observation . CreatedErrorTypes . ShouldHaveSingleItem ( ) . ShouldBe ( nameof ( InvalidOperationException ) ) ;
410+ }
411+
412+ [ Test ]
413+ [ Arguments ( false , false ) ]
414+ [ Arguments ( false , true ) ]
415+ [ Arguments ( true , false ) ]
416+ [ Arguments ( true , true ) ]
417+ public async Task TryAsync_WithoutInitialActivity_ShouldExportLaterOriginalTraceOnce ( bool useValueTask , bool initialLoggerEnabled )
418+ {
419+ using var observation = new Observation ( ) ;
420+ var previousActivity = Activity . Current ;
421+ Activity . Current = null ;
422+ try
423+ {
424+ var exception = new InvalidOperationException ( FailureDetail ) ;
425+ var problem = CreateBusinessProblem ( ) ;
426+ var logger = initialLoggerEnabled ? observation . Logger : NullLogger . Instance ;
427+ var first = useValueTask
428+ ? await Result . TryAsync ( ( Func < ValueTask < int > > ) ( ( ) => ThrowValueTaskAsync ( exception ) ) , logger , problemFactory : _ => problem )
429+ : await Result . TryAsync ( ( Func < Task < int > > ) ( ( ) => ThrowTaskAsync ( exception ) ) , logger , problemFactory : _ => problem ) ;
430+ first . IsFailed . ShouldBeTrue ( ) ;
431+ first . Problem . ShouldBeSameAs ( problem ) ;
432+ observation . Logs . Count . ShouldBe ( initialLoggerEnabled ? 1 : 0 ) ;
433+ observation . Spans . ShouldBeEmpty ( ) ;
434+ using ( var activity = CommunicationTelemetry . StartActivity ( OperationName ) )
435+ {
436+ activity . ShouldNotBeNull ( ) ;
437+ AssertForwardedFailure ( problem , exception , observation . Logger ) ;
438+ var log = observation . Logs . ShouldHaveSingleItem ( ) ;
439+ log . Level . ShouldBe ( LogLevel . Error ) ;
440+ log . Exception . ShouldBeSameAs ( exception ) ;
441+ log . TraceId . ShouldBe ( initialLoggerEnabled ? default : activity . TraceId ) ;
442+ log . SpanId . ShouldBe ( initialLoggerEnabled ? default : activity . SpanId ) ;
443+ var trace = activity . Events . Single ( item => item . Name == ExceptionEvent ) ;
444+ trace . Tags . Single ( item => item . Key == StackTraceTag ) . Value ! . ToString ( ) ! . ShouldContain ( nameof ( Throw ) ) ;
445+ activity . Events . Count ( item => item . Name == CommunicationTelemetry . CreatedFailureEventName ) . ShouldBe ( 0 ) ;
446+ activity . Status . ShouldBe ( ActivityStatusCode . Unset ) ;
447+ AssertBusinessProblem ( problem ) ;
448+ }
449+ observation . Spans . ShouldHaveSingleItem ( ) . Events . Count ( item => item . Name == ExceptionEvent ) . ShouldBe ( 1 ) ;
450+ observation . FlushFailures ( ) . ShouldBe ( 1 ) ;
451+ observation . CreatedErrorTypes . ShouldHaveSingleItem ( ) . ShouldBe ( nameof ( InvalidOperationException ) ) ;
452+ }
453+ finally
454+ {
455+ Activity . Current = previousActivity ;
456+ }
457+ }
458+
459+ [ Test ]
460+ [ Arguments ( AbsentActivity ) ]
461+ [ Arguments ( SampledActivity ) ]
462+ [ Arguments ( UnsampledActivity ) ]
463+ public async Task TryAsync_AfterUnavailableHttpLogger_ShouldExportOnlySafeOriginalDiagnostics ( int activitySelection )
464+ {
465+ using var observation = new Observation ( sampleTraces : activitySelection != UnsampledActivity ) ;
466+ var previousActivity = Activity . Current ;
467+ Activity . Current = null ;
468+ try
469+ {
470+ using var activity = activitySelection == AbsentActivity ? null : CommunicationTelemetry . StartActivity ( OperationName ) ;
471+ if ( activitySelection != AbsentActivity )
472+ {
473+ activity . ShouldNotBeNull ( ) ;
474+ activity . Recorded . ShouldBe ( activitySelection == SampledActivity ) ;
475+ }
476+ using var privacy = HttpFailureDiagnostics . Begin ( true ) ;
477+ var exception = new InvalidOperationException ( SensitiveHttpDetail , new FormatException ( SensitiveHttpDetail ) ) ;
478+ var problem = CreateBusinessProblem ( ) ;
479+ var first = await Result . TryAsync ( ( Func < Task < int > > ) ( ( ) => ThrowTaskAsync ( exception ) ) , NullLogger . Instance , problemFactory : _ => problem ) ;
480+ first . IsFailed . ShouldBeTrue ( ) ;
481+ first . Problem . ShouldBeSameAs ( problem ) ;
482+ observation . Logs . ShouldBeEmpty ( ) ;
483+ AssertForwardedFailure ( problem , exception , observation . Logger ) ;
484+ var log = observation . Logs . ShouldHaveSingleItem ( ) ;
485+ log . Level . ShouldBe ( LogLevel . Error ) ;
486+ log . Exception . ShouldBeNull ( ) ;
487+ log . Message . ShouldContain ( nameof ( InvalidOperationException ) ) ;
488+ log . Message . ShouldContain ( nameof ( FormatException ) ) ;
489+ log . Message . ShouldContain ( nameof ( Throw ) ) ;
490+ log . Message . ShouldNotContain ( SensitiveHttpDetail ) ;
491+ log . Message . ShouldNotContain ( BusinessDetail ) ;
492+ if ( activity is not null )
493+ {
494+ var trace = activity . Events . Single ( item => item . Name == ExceptionEvent ) ;
495+ trace . Tags . Single ( item => item . Key == StackTraceTag ) . Value ! . ToString ( ) ! . ShouldContain ( nameof ( Throw ) ) ;
496+ foreach ( var tag in activity . Events . SelectMany ( item => item . Tags ) )
497+ {
498+ tag . Value ? . ToString ( ) ? . ShouldNotContain ( SensitiveHttpDetail ) ;
499+ tag . Value ? . ToString ( ) ? . ShouldNotContain ( BusinessDetail ) ;
500+ }
501+ activity . Status . ShouldBe ( ActivityStatusCode . Unset ) ;
502+ }
503+ observation . FlushFailures ( ) . ShouldBe ( 1 ) ;
504+ observation . CreatedErrorTypes . ShouldHaveSingleItem ( ) . ShouldBe ( nameof ( InvalidOperationException ) ) ;
505+ }
506+ finally
507+ {
508+ Activity . Current = previousActivity ;
509+ }
510+ }
511+
512+ private static Problem CreateBusinessProblem ( )
513+ {
514+ var problem = Problem . Create ( BusinessTitle , BusinessDetail , HttpStatusCode . Forbidden ) ;
515+ problem . ErrorCode = BusinessCode ;
516+ problem . Instance = BusinessInstance ;
517+ problem . Extensions [ BusinessExtension ] = BusinessOwner ;
518+ return problem ;
519+ }
520+
521+ private static void AssertBusinessProblem ( Problem problem )
522+ {
523+ problem . StatusCode . ShouldBe ( ( int ) HttpStatusCode . Forbidden ) ;
524+ problem . ErrorCode . ShouldBe ( BusinessCode ) ;
525+ problem . Title . ShouldBe ( BusinessTitle ) ;
526+ problem . Detail . ShouldBe ( BusinessDetail ) ;
527+ problem . Instance . ShouldBe ( BusinessInstance ) ;
528+ problem . Extensions [ BusinessExtension ] . ShouldBe ( BusinessOwner ) ;
529+ problem . Extensions . Values . OfType < Exception > ( ) . ShouldBeEmpty ( ) ;
530+ }
531+
532+ private static void AssertForwardedFailure ( Problem problem , Exception exception , ILogger logger )
533+ {
534+ var result = Result < int > . Fail ( exception , logger , problemFactory : _ => problem ) ;
535+ result . IsFailed . ShouldBeTrue ( ) ;
536+ result . Problem . ShouldBeSameAs ( problem ) ;
537+ Result . Fail ( exception , logger , problemFactory : _ => problem ) . Problem . ShouldBeSameAs ( problem ) ;
538+ CollectionResult < int > . Fail ( exception , logger , problemFactory : _ => problem ) . Problem . ShouldBeSameAs ( problem ) ;
539+ Result . Fail ( problem ) . Problem . ShouldBeSameAs ( problem ) ;
540+ JsonSerializer . Serialize ( result ) . ShouldNotContain ( exception . StackTrace ! ) ;
541+ }
542+
354543 private static void AssertException ( Observation observation , Activity activity , Exception exception )
355544 {
356545 var log = observation . Logs . ShouldHaveSingleItem ( ) ;
@@ -416,20 +605,27 @@ private sealed class Observation : IDisposable
416605 private readonly MeterProvider _meter ;
417606 private long _failures ;
418607
419- internal Observation ( )
608+ internal Observation ( bool sampleTraces = true )
420609 {
421- _factory = LoggerFactory . Create ( builder => builder . AddOpenTelemetry ( options =>
610+ _factory = LoggerFactory . Create ( builder => builder
611+ . AddFilter ( DisabledCallerCategory , LogLevel . Critical )
612+ . AddOpenTelemetry ( options =>
422613 {
423614 options . IncludeFormattedMessage = true ;
424615 options . AddProcessor ( new SimpleLogRecordExportProcessor ( new CaptureExporter < LogRecord > ( record =>
425616 Logs . Add ( new LogEntry ( record . LogLevel , record . Exception , record . CategoryName , record . TraceId , record . SpanId , record . FormattedMessage ?? string . Empty ) ) ) ) ) ;
426617 } ) ) ;
427618 CommunicationLogger . Configure ( _factory ) ;
428619 Logger = _factory . CreateLogger ( CallerCategory ) ;
429- _tracer = Sdk . CreateTracerProviderBuilder ( )
620+ DisabledLogger = _factory . CreateLogger ( DisabledCallerCategory ) ;
621+ var tracing = Sdk . CreateTracerProviderBuilder ( )
430622 . AddSource ( CommunicationTelemetry . SourceName )
431- . AddProcessor ( new SimpleActivityExportProcessor ( new CaptureExporter < Activity > ( Spans . Add ) ) )
432- . Build ( ) ;
623+ . AddProcessor ( new SimpleActivityExportProcessor ( new CaptureExporter < Activity > ( Spans . Add ) ) ) ;
624+ if ( ! sampleTraces )
625+ {
626+ tracing . SetSampler ( new AlwaysOffSampler ( ) ) ;
627+ }
628+ _tracer = tracing . Build ( ) ;
433629 _meter = Sdk . CreateMeterProviderBuilder ( )
434630 . AddMeter ( CommunicationTelemetry . SourceName )
435631 . AddReader ( new PeriodicExportingMetricReader ( new CaptureExporter < Metric > ( metric =>
@@ -439,15 +635,24 @@ internal Observation()
439635 foreach ( ref readonly var point in metric . GetMetricPoints ( ) )
440636 {
441637 _failures += point . GetSumLong ( ) ;
638+ foreach ( var tag in point . Tags )
639+ {
640+ if ( tag . Key == CommunicationTelemetry . ErrorTypeTag )
641+ {
642+ CreatedErrorTypes . Add ( tag . Value ? . ToString ( ) ) ;
643+ }
644+ }
442645 }
443646 }
444647 } ) , exportIntervalMilliseconds : int . MaxValue ) )
445648 . Build ( ) ;
446649 }
447650
448651 internal ILogger Logger { get ; }
652+ internal ILogger DisabledLogger { get ; }
449653 internal List < LogEntry > Logs { get ; } = [ ] ;
450654 internal List < Activity > Spans { get ; } = [ ] ;
655+ internal List < string ? > CreatedErrorTypes { get ; } = [ ] ;
451656 internal long FlushFailures ( )
452657 {
453658 _meter . ForceFlush ( ) . ShouldBeTrue ( ) ;
0 commit comments