From 59ff5ca66a8b9c181fed333746ea4e87203fcc84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Wed, 16 Sep 2026 07:32:14 +0200 Subject: [PATCH 1/4] Fix lazy logger provider retry after failed build --- .../Logs/Builder/LoggerProviderBuilderSdk.cs | 8 ++++ src/OpenTelemetry/Logs/LoggerProviderSdk.cs | 1 + .../OpenTelemetryLoggingExtensionsTests.cs | 47 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs b/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs index e235c5e2f7a..d49275ff38b 100644 --- a/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs +++ b/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs @@ -39,6 +39,14 @@ public void RegisterProvider(LoggerProviderSdk loggerProvider) this.loggerProvider = loggerProvider; } + public void UnregisterProvider(LoggerProviderSdk loggerProvider) + { + if (ReferenceEquals(this.loggerProvider, loggerProvider)) + { + this.loggerProvider = null; + } + } + public override LoggerProviderBuilder AddInstrumentation(Func instrumentationFactory) { var instance = instrumentationFactory(); diff --git a/src/OpenTelemetry/Logs/LoggerProviderSdk.cs b/src/OpenTelemetry/Logs/LoggerProviderSdk.cs index 08f15f434ac..3f6962fc298 100644 --- a/src/OpenTelemetry/Logs/LoggerProviderSdk.cs +++ b/src/OpenTelemetry/Logs/LoggerProviderSdk.cs @@ -79,6 +79,7 @@ public LoggerProviderSdk( } catch (Exception) { + state.UnregisterProvider(this); this.DisposeBuiltState(state); throw; } diff --git a/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs b/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs index f1b5e95ccfd..12d6b4db8bf 100644 --- a/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs +++ b/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs @@ -303,6 +303,44 @@ public void CircularReferenceTest(bool requestLoggerProviderDirectly) Assert.IsType(loggerProvider.Processor); } + [Fact] + public void LazyProviderRetriesAfterFailedBuildTest() + { + var configureInvocationCount = 0; + TestLogProcessor? configuredProcessor = null; + var services = new ServiceCollection(); + + services.AddLogging(logging => logging.AddOpenTelemetry()); + + services.ConfigureOpenTelemetryLoggerProvider((sp, builder) => + { + if (Interlocked.Increment(ref configureInvocationCount) == 1) + { + throw new InvalidOperationException("The first logger provider build is expected to fail."); + } + + configuredProcessor = new TestLogProcessor(); + builder.AddProcessor(configuredProcessor); + }); + + using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + + Assert.Throws(() => loggerFactory.CreateLogger("FirstLogger")); + + var logger = loggerFactory.CreateLogger("SecondLogger"); + logger.Log( + LogLevel.Information, + new EventId(1), + "This record should be processed.", + exception: null, + static (state, _) => state); + + var processor = Assert.IsType(configuredProcessor); + Assert.Equal(2, configureInvocationCount); + Assert.Equal(1, processor.OnEndCount); + } + [Theory] [InlineData(true, false)] [InlineData(false, true)] @@ -388,6 +426,15 @@ private sealed class TestLogProcessor : BaseProcessor { public bool Disposed; + public int OnEndCount; + + public override void OnEnd(LogRecord data) + { + this.OnEndCount++; + + base.OnEnd(data); + } + protected override void Dispose(bool disposing) { this.Disposed = true; From c23bfc9166e56713a7edcc4eea221b58223251be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Wed, 16 Sep 2026 10:12:48 +0200 Subject: [PATCH 2/4] Harden lazy logger provider retry handling --- src/OpenTelemetry/CHANGELOG.md | 4 + .../Logs/Builder/LoggerProviderBuilderSdk.cs | 24 +++++- .../ILogger/DeferredOpenTelemetryLogger.cs | 81 +++++++++++++++++++ .../ILogger/OpenTelemetryLoggerProvider.cs | 26 ++++-- src/OpenTelemetry/Logs/LoggerProviderSdk.cs | 9 ++- .../OpenTelemetryLoggingExtensionsTests.cs | 73 +++++++++++++++++ 6 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs diff --git a/src/OpenTelemetry/CHANGELOG.md b/src/OpenTelemetry/CHANGELOG.md index 639da2593e4..ecc6df7fc0e 100644 --- a/src/OpenTelemetry/CHANGELOG.md +++ b/src/OpenTelemetry/CHANGELOG.md @@ -20,6 +20,10 @@ Notes](../../RELEASENOTES.md). reset does not result in an unnecessary scale reduction. ([#7749](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7749)) +* Fixed lazy logger provider builds after a failure from reusing partially + initialized provider state. + ([#7761](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7761)) + ## 1.18.0 Released 2026-Aug-21 diff --git a/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs b/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs index d49275ff38b..f0b9e332dac 100644 --- a/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs +++ b/src/OpenTelemetry/Logs/Builder/LoggerProviderBuilderSdk.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Runtime.ExceptionServices; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Resources; @@ -14,6 +15,7 @@ internal sealed class LoggerProviderBuilderSdk : LoggerProviderBuilder, ILoggerP private const string DefaultInstrumentationVersion = "1.0.0.0"; private readonly IServiceProvider serviceProvider; + private ExceptionDispatchInfo? providerBuildException; private LoggerProviderSdk? loggerProvider; public LoggerProviderBuilderSdk(IServiceProvider serviceProvider) @@ -31,6 +33,8 @@ public LoggerProviderBuilderSdk(IServiceProvider serviceProvider) public void RegisterProvider(LoggerProviderSdk loggerProvider) { + this.providerBuildException?.Throw(); + if (this.loggerProvider != null) { throw new NotSupportedException("LoggerProvider cannot be accessed while build is executing."); @@ -39,14 +43,32 @@ public void RegisterProvider(LoggerProviderSdk loggerProvider) this.loggerProvider = loggerProvider; } - public void UnregisterProvider(LoggerProviderSdk loggerProvider) + public void HandleProviderBuildFailure(LoggerProviderSdk loggerProvider, Exception exception) { if (ReferenceEquals(this.loggerProvider, loggerProvider)) { this.loggerProvider = null; + + // Processors and instrumentations are disposed when provider + // construction fails. If any were added, retrying would either + // reuse disposed instances or lose configuration that was + // transferred destructively from OpenTelemetryLoggerOptions. + if (this.Processors.Count != 0 + || this.Instrumentation.Count != 0 + || this.ResourceBuilder != null) + { + this.providerBuildException = ExceptionDispatchInfo.Capture(exception); + } } } + public void ResetBuildState() + { + this.Processors.Clear(); + this.Instrumentation.Clear(); + this.ResourceBuilder = null; + } + public override LoggerProviderBuilder AddInstrumentation(Func instrumentationFactory) { var instance = instrumentationFactory(); diff --git a/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs new file mode 100644 index 00000000000..e03cc960617 --- /dev/null +++ b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs @@ -0,0 +1,81 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.Logging; + +namespace OpenTelemetry.Logs; + +internal sealed class DeferredOpenTelemetryLogger : ILogger +{ + private readonly string categoryName; + private readonly OpenTelemetryLoggerProvider loggerProvider; + private readonly Lock syncObject = new(); + private LoggerState? loggerState; + + public DeferredOpenTelemetryLogger( + OpenTelemetryLoggerProvider loggerProvider, + string categoryName) + { + this.loggerProvider = loggerProvider; + this.categoryName = categoryName; + } + + public IDisposable BeginScope(TState state) + where TState : notnull => this.loggerProvider.ScopeProvider?.Push(state) ?? NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) + => logLevel != LogLevel.None && !Sdk.SuppressInstrumentation; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + this.GetLogger()?.Log(logLevel, eventId, state, exception, formatter); + } + + private OpenTelemetryLogger? GetLogger() + { + if (this.loggerProvider.Provider is not LoggerProviderSdk loggerProviderSdk) + { + return null; + } + + var loggerState = Volatile.Read(ref this.loggerState); + if (loggerState == null || !ReferenceEquals(loggerState.Provider, loggerProviderSdk)) + { + lock (this.syncObject) + { + loggerState = this.loggerState; + if (loggerState == null || !ReferenceEquals(loggerState.Provider, loggerProviderSdk)) + { + loggerState = new( + loggerProviderSdk, + new OpenTelemetryLogger(loggerProviderSdk, this.loggerProvider.Options, this.categoryName)); + Volatile.Write(ref this.loggerState, loggerState); + } + } + } + + loggerState.Logger.ScopeProvider = this.loggerProvider.ScopeProvider; + return loggerState.Logger; + } + + private sealed class LoggerState(LoggerProviderSdk provider, OpenTelemetryLogger logger) + { + public OpenTelemetryLogger Logger { get; } = logger; + + public LoggerProviderSdk Provider { get; } = provider; + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLoggerProvider.cs b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLoggerProvider.cs index fce1770df0a..87a99f0f456 100644 --- a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLoggerProvider.cs +++ b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLoggerProvider.cs @@ -96,8 +96,15 @@ internal LoggerProvider Provider // it is intentionally left in place so the next access // retries instead of caching the failure. provider = this.loggerProviderFactory!(); - Volatile.Write(ref this.provider, provider); - this.loggerProviderFactory = null; + + // A provider which is still being built may be returned to + // break an ILoggerFactory circular dependency. Do not cache + // it until construction has completed successfully. + if (provider is not LoggerProviderSdk { IsBuilt: false }) + { + Volatile.Write(ref this.provider, provider); + this.loggerProviderFactory = null; + } } return provider; @@ -141,12 +148,19 @@ public ILogger CreateLogger(string categoryName) logger = (this.loggers[categoryName] as ILogger)!; if (logger == null) { - logger = this.Provider is not LoggerProviderSdk loggerProviderSdk - ? NullLogger.Instance - : new OpenTelemetryLogger(loggerProviderSdk, this.Options, categoryName) + var provider = this.Provider; + logger = provider switch + { + LoggerProviderSdk { IsBuilt: true } loggerProviderSdk => new OpenTelemetryLogger( + loggerProviderSdk, + this.Options, + categoryName) { ScopeProvider = this.ScopeProvider, - }; + }, + LoggerProviderSdk => new DeferredOpenTelemetryLogger(this, categoryName), + _ => NullLogger.Instance, + }; this.loggers[categoryName] = logger; } diff --git a/src/OpenTelemetry/Logs/LoggerProviderSdk.cs b/src/OpenTelemetry/Logs/LoggerProviderSdk.cs index 3f6962fc298..9052a41949e 100644 --- a/src/OpenTelemetry/Logs/LoggerProviderSdk.cs +++ b/src/OpenTelemetry/Logs/LoggerProviderSdk.cs @@ -19,6 +19,7 @@ internal sealed class LoggerProviderSdk : LoggerProvider internal IDisposable? OwnedServiceProvider; internal bool Disposed; internal int ShutdownCount; + private int isBuilt; private ILogRecordPool? threadStaticPool = LogRecordThreadStaticPool.Instance; public LoggerProviderSdk( @@ -76,11 +77,13 @@ public LoggerProviderSdk( } OpenTelemetrySdkEventSource.Log.LoggerProviderSdkEvent("LoggerProviderSdk built successfully."); + Volatile.Write(ref this.isBuilt, 1); } - catch (Exception) + catch (Exception ex) { - state.UnregisterProvider(this); + state.HandleProviderBuildFailure(this, ex); this.DisposeBuiltState(state); + state.ResetBuildState(); throw; } } @@ -93,6 +96,8 @@ public LoggerProviderSdk( public ILogRecordPool LogRecordPool => this.threadStaticPool ?? LogRecordSharedPool.Current; + internal bool IsBuilt => Volatile.Read(ref this.isBuilt) != 0; + public static bool ContainsBatchProcessor(BaseProcessor processor) { if (processor is BatchExportProcessor) diff --git a/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs b/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs index 12d6b4db8bf..0bf8eceae9d 100644 --- a/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs +++ b/test/OpenTelemetry.Tests/Logs/OpenTelemetryLoggingExtensionsTests.cs @@ -341,6 +341,79 @@ public void LazyProviderRetriesAfterFailedBuildTest() Assert.Equal(1, processor.OnEndCount); } + [Fact] + public void LazyProviderDoesNotRetryAfterFailedBuildMutatesBuilderStateTest() + { + var configureInvocationCount = 0; +#pragma warning disable CA2000 // Dispose objects before losing scope + var processor = new TestLogProcessor(); +#pragma warning restore CA2000 // Dispose objects before losing scope + var services = new ServiceCollection(); + + services.AddLogging(logging => logging.AddOpenTelemetry()); + + services.ConfigureOpenTelemetryLoggerProvider((sp, builder) => + { + configureInvocationCount++; + builder.AddProcessor(processor); + throw new InvalidOperationException("The logger provider build is expected to fail."); + }); + + using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + + var firstException = Assert.Throws( + () => loggerFactory.CreateLogger("FirstLogger")); + + Assert.True(processor.Disposed); + + var secondException = Assert.Throws( + () => loggerFactory.CreateLogger("SecondLogger")); + + Assert.Same(firstException, secondException); + Assert.Equal(1, configureInvocationCount); + } + + [Fact] + public void LazyProviderRetriesAfterReentrantFailedBuildTest() + { + var configureInvocationCount = 0; + TestLogProcessor? configuredProcessor = null; + ILogger? reentrantLogger = null; + var services = new ServiceCollection(); + + services.AddLogging(logging => logging.AddOpenTelemetry()); + + services.ConfigureOpenTelemetryLoggerProvider((sp, builder) => + { + if (Interlocked.Increment(ref configureInvocationCount) == 1) + { + reentrantLogger = sp.GetRequiredService().CreateLogger("ReentrantLogger"); + throw new InvalidOperationException("The first logger provider build is expected to fail."); + } + + configuredProcessor = new TestLogProcessor(); + builder.AddProcessor(configuredProcessor); + }); + + using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + + Assert.Throws(() => loggerFactory.CreateLogger("FirstLogger")); + + var logger = Assert.IsAssignableFrom(reentrantLogger); + logger.Log( + LogLevel.Information, + new EventId(1), + "This record should be processed after the retry.", + exception: null, + static (state, _) => state); + + var processor = Assert.IsType(configuredProcessor); + Assert.Equal(2, configureInvocationCount); + Assert.Equal(1, processor.OnEndCount); + } + [Theory] [InlineData(true, false)] [InlineData(false, true)] From aee20304f0d55d8e2b13cb96c2262b0771e23b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Wed, 16 Sep 2026 11:31:43 +0200 Subject: [PATCH 3/4] Reuse logger null scope --- .../Logs/ILogger/DeferredOpenTelemetryLogger.cs | 11 +---------- src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs index e03cc960617..5c6a4ad6631 100644 --- a/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs +++ b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs @@ -21,7 +21,7 @@ public DeferredOpenTelemetryLogger( } public IDisposable BeginScope(TState state) - where TState : notnull => this.loggerProvider.ScopeProvider?.Push(state) ?? NullScope.Instance; + where TState : notnull => this.loggerProvider.ScopeProvider?.Push(state) ?? OpenTelemetryLogger.NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && !Sdk.SuppressInstrumentation; @@ -69,13 +69,4 @@ private sealed class LoggerState(LoggerProviderSdk provider, OpenTelemetryLogger public LoggerProviderSdk Provider { get; } = provider; } - - private sealed class NullScope : IDisposable - { - public static NullScope Instance { get; } = new(); - - public void Dispose() - { - } - } } diff --git a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs index 0b4b36c14d2..d41fae99507 100644 --- a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs +++ b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs @@ -230,7 +230,7 @@ private static bool TryGetOriginalFormatFromAttributes( return false; } - private sealed class NullScope : IDisposable + internal sealed class NullScope : IDisposable { public static NullScope Instance { get; } = new(); From 42ba94eaf4d6db65a2ea66652fa53d68814b0883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Wed, 16 Sep 2026 11:40:31 +0200 Subject: [PATCH 4/4] Move logger null scope to shared class --- .../Logs/ILogger/DeferredOpenTelemetryLogger.cs | 2 +- src/OpenTelemetry/Logs/ILogger/NullScope.cs | 17 +++++++++++++++++ .../Logs/ILogger/OpenTelemetryLogger.cs | 9 --------- 3 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/OpenTelemetry/Logs/ILogger/NullScope.cs diff --git a/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs index 5c6a4ad6631..9b7804b1ccf 100644 --- a/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs +++ b/src/OpenTelemetry/Logs/ILogger/DeferredOpenTelemetryLogger.cs @@ -21,7 +21,7 @@ public DeferredOpenTelemetryLogger( } public IDisposable BeginScope(TState state) - where TState : notnull => this.loggerProvider.ScopeProvider?.Push(state) ?? OpenTelemetryLogger.NullScope.Instance; + where TState : notnull => this.loggerProvider.ScopeProvider?.Push(state) ?? NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && !Sdk.SuppressInstrumentation; diff --git a/src/OpenTelemetry/Logs/ILogger/NullScope.cs b/src/OpenTelemetry/Logs/ILogger/NullScope.cs new file mode 100644 index 00000000000..cbc825668aa --- /dev/null +++ b/src/OpenTelemetry/Logs/ILogger/NullScope.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Logs; + +internal sealed class NullScope : IDisposable +{ + private NullScope() + { + } + + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } +} diff --git a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs index d41fae99507..8be5b26b005 100644 --- a/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs +++ b/src/OpenTelemetry/Logs/ILogger/OpenTelemetryLogger.cs @@ -229,13 +229,4 @@ private static bool TryGetOriginalFormatFromAttributes( originalFormat = null; return false; } - - internal sealed class NullScope : IDisposable - { - public static NullScope Instance { get; } = new(); - - public void Dispose() - { - } - } }