@@ -138,7 +138,49 @@ public async Task<AuthenticationResult> RunAsync(CancellationToken cancellationT
138138 apiEvent . ApiErrorCode = ex . GetType ( ) . Name ;
139139 AuthenticationRequestParameters . RequestContext . Logger . ErrorPii ( ex ) ;
140140
141- LogFailureTelemetryToOtel ( ex . GetType ( ) . Name , apiEvent , apiEvent . CacheInfo , httpStatusCode : 0 , totalDurationInMs : requestStopwatch . ElapsedMilliseconds + measureTelemetryDurationResult . Milliseconds ) ;
141+ // Compute the total duration once so the value on the synthesized metadata matches the
142+ // value logged to OpenTelemetry (the stopwatch keeps running, so re-reading it drifts).
143+ long totalDurationInMs = requestStopwatch . ElapsedMilliseconds + measureTelemetryDurationResult . Milliseconds ;
144+
145+ AuthenticationResultMetadata failureMetadata = CreateFailureMetadata ( apiEvent , totalDurationInMs ) ;
146+
147+ // Expose the failure metadata on the ORIGINAL exception via its Data bag so downstream
148+ // header-creation providers - which catch the raw non-MSAL exception, not the enricher
149+ // wrapper - can surface token-acquisition diagnostics (Bug 3696194). The value is the same
150+ // strongly-typed object MSAL builds for the success path, so consumers reuse their mapper.
151+ // Guarded because a derived exception may expose a null or read-only Data bag, and telemetry
152+ // plumbing must never throw here and mask the caller's original exception. On .NET Framework /
153+ // netstandard the Data bag also rejects non-serializable values, so AuthenticationResultMetadata
154+ // is marked [Serializable] on those targets (see AuthenticationResultMetadata.cs).
155+ if ( ex . Data is { IsReadOnly : false } )
156+ {
157+ ex . Data [ MsalException . AuthenticationResultMetadataKey ] = failureMetadata ;
158+ }
159+
160+ // The original exception is re-thrown below; MSAL never surfaces this wrapper. It exists only
161+ // so the OpenTelemetry tag enricher observes a populated ExecutionResult.Exception (carrying
162+ // failure metadata) for non-MSAL failures, mirroring the MsalException path above. The
163+ // originating exception's type is captured as the ErrorCode and it is preserved as the
164+ // InnerException so consumers retain full fidelity. Fall back to the type name when
165+ // FullName is null (some generic/array types) or Message is empty/whitespace, because the
166+ // MsalException ctor rejects a null/empty errorCode or errorMessage - without the fallback
167+ // that ArgumentNullException would replace the original exception we re-throw below.
168+ string enricherErrorCode = ex . GetType ( ) . FullName ?? ex . GetType ( ) . Name ;
169+ string enricherErrorMessage = string . IsNullOrWhiteSpace ( ex . Message ) ? ex . GetType ( ) . Name : ex . Message ;
170+
171+ MsalException enricherException = new MsalException ( enricherErrorCode , enricherErrorMessage , ex )
172+ {
173+ AuthenticationResultMetadata = failureMetadata ,
174+ CorrelationId = AuthenticationRequestParameters . CorrelationId . ToString ( ) ,
175+ } ;
176+
177+ LogFailureTelemetryToOtel (
178+ ex . GetType ( ) . Name ,
179+ apiEvent ,
180+ apiEvent . CacheInfo ,
181+ httpStatusCode : 0 ,
182+ totalDurationInMs : totalDurationInMs ,
183+ exception : enricherException ) ;
142184 throw ;
143185 }
144186 }
0 commit comments