diff --git a/.cspell/other.txt b/.cspell/other.txt index c23c022084..1f37df497e 100644 --- a/.cspell/other.txt +++ b/.cspell/other.txt @@ -29,6 +29,7 @@ ILOGGER inetsrv JIT LINQ +longdate MASSTRANSIT metricsexporter mkdir @@ -41,6 +42,7 @@ mycompanymyproductmylibrary MYSQLCONNECTOR MYSQLDATA NETRUNTIME +NLOG Npgsql NSERVICEBUS omnisharp diff --git a/CHANGELOG.md b/CHANGELOG.md index 996b695e15..27f4b7013d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ This component adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.h - Add `-NoReset` switch parameter to skip service restart in PowerShell registration functions. +- Support for [`NLog`](https://www.nuget.org/packages/NLog/) + logs instrumentation for versions `5.*` and `6.*` on .NET using duck typing + for zero-config auto-injection. ### Changed diff --git a/OpenTelemetry.AutoInstrumentation.sln b/OpenTelemetry.AutoInstrumentation.sln index 579e820207..2ef0e8b1e0 100644 --- a/OpenTelemetry.AutoInstrumentation.sln +++ b/OpenTelemetry.AutoInstrumentation.sln @@ -246,6 +246,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SdkVersionAnalyzer", "tools EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApplication.Log4NetBridge", "test\test-applications\integrations\TestApplication.Log4NetBridge\TestApplication.Log4NetBridge.csproj", "{926B7C03-42C2-4192-94A7-CD0B1C693279}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApplication.NLogBridge", "test\test-applications\integrations\TestApplication.NLogBridge\TestApplication.NLogBridge.csproj", "{A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApplication.SelectiveSampler", "test\test-applications\integrations\TestApplication.SelectiveSampler\TestApplication.SelectiveSampler.csproj", "{FD1A1ABD-6A48-4E94-B5F7-2081AFCD1BBB}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApplication.ProfilerSpanStoppageHandling", "test\test-applications\integrations\TestApplication.ProfilerSpanStoppageHandling\TestApplication.ProfilerSpanStoppageHandling.csproj", "{665280EB-F428-4C04-A293-33228C73BF8A}" @@ -1538,6 +1540,22 @@ Global {926B7C03-42C2-4192-94A7-CD0B1C693279}.Release|x64.Build.0 = Release|x64 {926B7C03-42C2-4192-94A7-CD0B1C693279}.Release|x86.ActiveCfg = Release|x86 {926B7C03-42C2-4192-94A7-CD0B1C693279}.Release|x86.Build.0 = Release|x86 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.ActiveCfg = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.Build.0 = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|ARM64.ActiveCfg = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|ARM64.Build.0 = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x64.ActiveCfg = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x64.Build.0 = Debug|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x86.ActiveCfg = Debug|x86 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Debug|x86.Build.0 = Debug|x86 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.ActiveCfg = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.Build.0 = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|ARM64.ActiveCfg = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|ARM64.Build.0 = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x64.ActiveCfg = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x64.Build.0 = Release|x64 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x86.ActiveCfg = Release|x86 + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D}.Release|x86.Build.0 = Release|x86 {FD1A1ABD-6A48-4E94-B5F7-2081AFCD1BBB}.Debug|Any CPU.ActiveCfg = Debug|x64 {FD1A1ABD-6A48-4E94-B5F7-2081AFCD1BBB}.Debug|Any CPU.Build.0 = Debug|x64 {FD1A1ABD-6A48-4E94-B5F7-2081AFCD1BBB}.Debug|ARM64.ActiveCfg = Debug|x64 @@ -1692,6 +1710,7 @@ Global {AA3E0C5C-A4E2-46AB-BD18-2D30D3ABF692} = {E409ADD3-9574-465C-AB09-4324D205CC7C} {C75FA076-D460-414B-97F7-6F8D0E85AE74} = {00F4C92D-6652-4BD8-A334-B35D3E711BE6} {926B7C03-42C2-4192-94A7-CD0B1C693279} = {E409ADD3-9574-465C-AB09-4324D205CC7C} + {A7B8C9D0-1E2F-3A4B-5C6D-7E8F9A0B1C2D} = {E409ADD3-9574-465C-AB09-4324D205CC7C} {FD1A1ABD-6A48-4E94-B5F7-2081AFCD1BBB} = {E409ADD3-9574-465C-AB09-4324D205CC7C} {665280EB-F428-4C04-A293-33228C73BF8A} = {E409ADD3-9574-465C-AB09-4324D205CC7C} {500BF40F-EECB-4F6A-377B-EDBDFFDE09BE} = {E409ADD3-9574-465C-AB09-4324D205CC7C} diff --git a/build/LibraryVersions.g.cs b/build/LibraryVersions.g.cs index dc2b2ef1b0..f045ce7374 100644 --- a/build/LibraryVersions.g.cs +++ b/build/LibraryVersions.g.cs @@ -68,6 +68,15 @@ public static partial class LibraryVersion new("3.2.0"), ] }, + { + "TestApplication.NLogBridge", + [ + new("5.0.0", additionalMetaData: new() { { "NLogExtensionsLogging", "5.0.0" } }), + new("5.3.4", additionalMetaData: new() { { "NLogExtensionsLogging", "5.3.15" } }), + new("6.0.0", additionalMetaData: new() { { "NLogExtensionsLogging", "6.0.0" } }), + new("6.0.6", additionalMetaData: new() { { "NLogExtensionsLogging", "6.1.0" } }), + ] + }, { "TestApplication.MassTransit", [ diff --git a/build/TargetFramework.cs b/build/TargetFramework.cs index 51d53a5828..b6e1208594 100644 --- a/build/TargetFramework.cs +++ b/build/TargetFramework.cs @@ -19,7 +19,7 @@ public class TargetFramework : Enumeration // should be in version order public static readonly TargetFramework[] NetFramework = { - NET462, NET47, NET471, NET472, NET472 + NET462, NET47, NET471, NET472 }; public static implicit operator string(TargetFramework framework) diff --git a/docs/config.md b/docs/config.md index d8cb59e44a..cfe3d28c55 100644 --- a/docs/config.md +++ b/docs/config.md @@ -206,6 +206,7 @@ due to lack of stable semantic convention. |-----------|---------------------------------------------------------------------------------------------------------------------------------|--------------------|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------| | `ILOGGER` | [Microsoft.Extensions.Logging](https://www.nuget.org/packages/Microsoft.Extensions.Logging) **Not supported on .NET Framework** | ≥8.0.0 | bytecode or source \[1\] | [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/versioning-and-stability.md) | | `LOG4NET` | [log4net](https://www.nuget.org/packages/log4net) \[2\] | ≥2.0.13 && < 4.0.0 | bytecode | [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/versioning-and-stability.md) | +| `NLOG` | [NLog](https://www.nuget.org/packages/NLog) \[3\] | ≥5.0.0 && < 7.0.0 | bytecode | [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/versioning-and-stability.md) | \[1\]: For ASP.NET Core applications, the `LoggingBuilder` instrumentation can be enabled without using the .NET CLR Profiler by setting @@ -215,6 +216,9 @@ the `ASPNETCORE_HOSTINGSTARTUPASSEMBLIES` environment variable to \[2\]: Instrumentation provides both [trace context injection](./log-trace-correlation.md#log4net-trace-context-injection) and [logs bridge](./log4net-bridge.md). +\[3\]: Instrumentation provides both [trace context injection](./log-trace-correlation.md#log4net-trace-context-injection) +and [logs bridge](./nlog-bridge.md). + ### Instrumentation options | Environment variable | Description | Default value | Status | diff --git a/docs/file-based-configuration.md b/docs/file-based-configuration.md index 3476f2184f..9c24a44603 100644 --- a/docs/file-based-configuration.md +++ b/docs/file-based-configuration.md @@ -526,6 +526,7 @@ instrumentation/development: logs: ilogger: # Microsoft.Extensions.Logging log4net: # Log4Net + nlog: # NLog ``` ## Instrumentation options @@ -567,6 +568,10 @@ instrumentation/development: # Logs bridge is disabled by default # More info about log4net bridge can be found at https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/blob/main/docs/log4net-bridge.md bridge_enabled: true + nlog: + # Logs bridge is disabled by default + # More info about NLog bridge can be found at https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/blob/main/docs/nlog-bridge.md + bridge_enabled: true ``` ## Instrumentation Additional Sources diff --git a/docs/log-trace-correlation.md b/docs/log-trace-correlation.md index 97c24fe249..e6471423f2 100644 --- a/docs/log-trace-correlation.md +++ b/docs/log-trace-correlation.md @@ -62,3 +62,27 @@ Following properties are set by default on the collection of logging event's pro This allows for trace context to be logged into currently configured log destination, e.g. a file. In order to use them, pattern needs to be updated. + +### `NLog` + +See [`nlog-bridge`](./nlog-bridge.md). + +## `NLog` trace context injection + +> [!IMPORTANT] +> NLog trace context injection is an experimental feature. + +The `NLog` trace context injection is enabled by default. +It can be disabled by setting +`OTEL_DOTNET_AUTO_LOGS_NLOG_INSTRUMENTATION_ENABLED` to `false`. + +Context injection is supported for `NLOG` in versions >= 5.0.0 && < 7.0.0 + +Following properties are set by default on the collection of logging event's properties: + +- `trace_id` +- `span_id` +- `trace_flags` + +This allows for trace context to be logged into currently configured log destination, + e.g. a file. In order to use them, pattern needs to be updated. diff --git a/docs/nlog-bridge.md b/docs/nlog-bridge.md new file mode 100644 index 0000000000..6719f4110d --- /dev/null +++ b/docs/nlog-bridge.md @@ -0,0 +1,45 @@ +# `NLog` [logs bridge](https://opentelemetry.io/docs/specs/otel/glossary/#log-appender--bridge) + +> [!IMPORTANT] +> NLog bridge is an experimental feature. + +The `NLog` logs bridge is disabled by default. In order to enable it, +set `OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE` to `true`. + +Bridge is supported for `NLOG` in versions >= 5.0.0 && < 7.0.0 + +If `NLOG` is used as a [logging provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers), +`NLOG` bridge should not be enabled, in order to reduce possibility of +duplicated logs export. + +## `NLog` logging events conversion + +`NLog`'s `ILoggingEvent`s are converted to OpenTelemetry log records in +a following way: + +- `TimeStamp` is set as a `Timestamp` +- `Level.Name` is set as a `SeverityText` +- `FormattedMessage` is set as a `Body` if it is available +- Otherwise, `Message` is set as a `Body` +- `LoggerName` is set as an `InstrumentationScope.Name` +- `GetProperties()`, apart from builtin properties prefixed with `nlog:`, `NLog.`, + are added as attributes +- `Exception` is used to populate the following properties: `exception.type`, + `exception.message`, `exception.stacktrace` +- `Level.Value` is mapped to `SeverityNumber` as outlined in the next section + +### `NLog` level severity mapping + +`NLog` levels are mapped to OpenTelemetry severity types according to + following rules based on their numerical values. + +Levels with numerical values of: + +- Equal to `LogLevel.Fatal` is mapped to `LogRecordSeverity.Fatal` +- Equal to `LogLevel.Error` is mapped to `LogRecordSeverity.Error` +- Equal to `LogLevel.Warn` is mapped to `LogRecordSeverity.Warn` +- Equal to `LogLevel.Info` is mapped to `LogRecordSeverity.Info` +- Equal to `LogLevel.Debug` is mapped to `LogRecordSeverity.Debug` +- Equal to `LogLevel.Trace` is mapped to `LogRecordSeverity.Trace` +- Equal to `LogLevel.Off` is mapped to `LogRecordSeverity.Trace` +- Any other is mapped to `LogRecordSeverity.Info`. diff --git a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt index 8b13789179..97cbd646f8 100644 --- a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ - +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration diff --git a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt index e69de29bb2..97cbd646f8 100644 --- a/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.AutoInstrumentation/.publicApi/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration +OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs index c6420d49a9..2781167ff2 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/ConfigurationKeys.cs @@ -240,6 +240,12 @@ public static class Logs /// public const string EnableLog4NetBridge = "OTEL_DOTNET_AUTO_LOGS_ENABLE_LOG4NET_BRIDGE"; + /// + /// Configuration key for whether or not experimental NLog bridge + /// should be enabled. + /// + public const string EnableNLogBridge = "OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE"; + /// /// Configuration key for disabling all log instrumentations. /// diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/DotNetLogs.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/DotNetLogs.cs index b854c06aca..3d97f6dd32 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/DotNetLogs.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/DotNetLogs.cs @@ -17,7 +17,13 @@ internal class DotNetLogs /// Gets or sets the Log4Net logs instrumentation configuration. /// [YamlMember(Alias = "log4net")] - public Log4NetBridgeEnabled? Log4Net { get; set; } + public LogBridgeEnabled? Log4Net { get; set; } + + /// + /// Gets or sets the Log4Net logs instrumentation configuration. + /// + [YamlMember(Alias = "nlog")] + public LogBridgeEnabled? NLog { get; set; } /// /// Returns the list of enabled log instrumentations. @@ -36,6 +42,11 @@ public IReadOnlyList GetEnabledInstrumentations() result.Add(LogInstrumentation.Log4Net); } + if (NLog != null) + { + result.Add(LogInstrumentation.NLog); + } + return result; } } diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/Log4NetBridgeEnabled.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/LogBridgeEnabled.cs similarity index 93% rename from src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/Log4NetBridgeEnabled.cs rename to src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/LogBridgeEnabled.cs index 288108f53e..84ab725719 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/Log4NetBridgeEnabled.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/FileBasedConfiguration/LogBridgeEnabled.cs @@ -7,7 +7,7 @@ namespace OpenTelemetry.AutoInstrumentation.Configurations.FileBasedConfiguration; [EmptyObjectOnEmptyYaml] -internal class Log4NetBridgeEnabled +internal class LogBridgeEnabled { /// /// Gets or sets a value indicating whether the Log4Net bridge is enabled. diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/LogInstrumentation.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/LogInstrumentation.cs index b6ea9f456e..20dbb7e356 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/LogInstrumentation.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/LogInstrumentation.cs @@ -17,4 +17,9 @@ internal enum LogInstrumentation /// Log4Net instrumentation. /// Log4Net = 1, + + /// + /// NLog instrumentation. + /// + NLog = 2, } diff --git a/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs b/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs index e95c167b33..f5a80bd9c1 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Configurations/LogSettings.cs @@ -34,6 +34,11 @@ internal class LogSettings : Settings /// public bool EnableLog4NetBridge { get; private set; } + /// + /// Gets a value indicating whether the experimental NLog bridge is enabled. + /// + public bool EnableNLogBridge { get; private set; } + /// /// Gets the list of enabled instrumentations. /// @@ -60,6 +65,7 @@ protected override void OnLoadEnvVar(Configuration configuration) IncludeFormattedMessage = configuration.GetBool(ConfigurationKeys.Logs.IncludeFormattedMessage) ?? false; EnableLog4NetBridge = configuration.GetBool(ConfigurationKeys.Logs.EnableLog4NetBridge) ?? false; + EnableNLogBridge = configuration.GetBool(ConfigurationKeys.Logs.EnableNLogBridge) ?? false; var instrumentationEnabledByDefault = configuration.GetBool(ConfigurationKeys.Logs.LogsInstrumentationEnabled) ?? @@ -85,6 +91,11 @@ protected override void OnLoadFile(YamlConfiguration configuration) { EnableLog4NetBridge = logs?.Log4Net?.BridgeEnabled ?? false; } + + if (EnabledInstrumentations.Contains(LogInstrumentation.NLog)) + { + EnableNLogBridge = logs?.NLog?.BridgeEnabled ?? false; + } } private static List ParseLogExporter(Configuration configuration) diff --git a/src/OpenTelemetry.AutoInstrumentation/Generated/net462/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/src/OpenTelemetry.AutoInstrumentation/Generated/net462/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index 0bbe29caa7..e65aea14b0 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Generated/net462/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Generated/net462/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -17,7 +17,7 @@ internal static partial class InstrumentationDefinitions { private static NativeCallTargetDefinition[] GetDefinitionsArray() { - var nativeCallTargetDefinitions = new List(36); + var nativeCallTargetDefinitions = new List(42); // Traces var tracerSettings = Instrumentation.TracerSettings.Value; if (tracerSettings.TracesEnabled) @@ -101,6 +101,17 @@ private static NativeCallTargetDefinition[] GetDefinitionsArray() nativeCallTargetDefinitions.Add(new("log4net", "log4net.Appender.AppenderCollection", "ToArray", ["log4net.Appender.IAppender[]"], 2, 0, 13, 3, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.Log4Net.Bridge.Integrations.AppenderCollectionIntegration")); nativeCallTargetDefinitions.Add(new("log4net", "log4net.Util.AppenderAttachedImpl", "AppendLoopOnAppenders", ["System.Int32", "log4net.Core.LoggingEvent"], 2, 0, 13, 3, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.Log4Net.TraceContextInjection.Integrations.AppenderAttachedImplIntegration")); } + + // NLog + if (logSettings.EnabledInstrumentations.Contains(LogInstrumentation.NLog)) + { + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteLogEventToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 6, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteLogEventToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 6, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + } } // Metrics diff --git a/src/OpenTelemetry.AutoInstrumentation/Generated/net8.0/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/src/OpenTelemetry.AutoInstrumentation/Generated/net8.0/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index e3f0643c0a..dcb4f6df17 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Generated/net8.0/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Generated/net8.0/SourceGenerators/SourceGenerators.InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -17,7 +17,7 @@ internal static partial class InstrumentationDefinitions { private static NativeCallTargetDefinition[] GetDefinitionsArray() { - var nativeCallTargetDefinitions = new List(39); + var nativeCallTargetDefinitions = new List(45); // Traces var tracerSettings = Instrumentation.TracerSettings.Value; if (tracerSettings.TracesEnabled) @@ -104,6 +104,17 @@ private static NativeCallTargetDefinition[] GetDefinitionsArray() { nativeCallTargetDefinitions.Add(new("Microsoft.Extensions.Logging", "Microsoft.Extensions.Logging.LoggingBuilder", ".ctor", ["System.Void", "Microsoft.Extensions.DependencyInjection.IServiceCollection"], 8, 0, 0, 10, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.Logger.LoggingBuilderIntegration")); } + + // NLog + if (logSettings.EnabledInstrumentations.Contains(LogInstrumentation.NLog)) + { + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteLogEventToTargets", ["System.Void", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 6, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain"], 5, 0, 0, 5, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + nativeCallTargetDefinitions.Add(new("NLog", "NLog.Logger", "WriteLogEventToTargets", ["System.Void", "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], 6, 0, 0, 6, 65535, 65535, AssemblyFullName, "OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations.NLogWriteToTargetsWithWrapperTypeIntegration")); + } } // Metrics diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentation.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentation.cs index bfd8f03edb..e1078fc950 100644 --- a/src/OpenTelemetry.AutoInstrumentation/Instrumentation.cs +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentation.cs @@ -358,7 +358,18 @@ private static void InitializeBufferProcessing(TimeSpan exportInterval, TimeSpan { // ILogger bridge is initialized using ILogger-specific extension methods in LoggerInitializer class. // That extension methods sets up its own LogProvider. - if (LogSettings.Value.EnableLog4NetBridge && LogSettings.Value.LogsEnabled && LogSettings.Value.EnabledInstrumentations.Contains(LogInstrumentation.Log4Net)) + + Logger.Debug($"InitializeLoggerProvider called. LogsEnabled={LogSettings.Value.LogsEnabled}, EnableLog4NetBridge={LogSettings.Value.EnableLog4NetBridge}, EnableNLogBridge={LogSettings.Value.EnableNLogBridge}"); + Logger.Debug($"EnabledInstrumentations: {string.Join(", ", LogSettings.Value.EnabledInstrumentations)}"); + + // Initialize logger provider if any bridge is enabled + var shouldInitialize = LogSettings.Value.LogsEnabled && ( + (LogSettings.Value.EnableLog4NetBridge && LogSettings.Value.EnabledInstrumentations.Contains(LogInstrumentation.Log4Net)) || + (LogSettings.Value.EnableNLogBridge && LogSettings.Value.EnabledInstrumentations.Contains(LogInstrumentation.NLog))); + + Logger.Debug($"ShouldInitialize logger provider: {shouldInitialize}"); + + if (shouldInitialize) { // TODO: Replace reflection usage when Logs Api is made public in non-rc builds. // Sdk.CreateLoggerProviderBuilder() diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryLogHelpers.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryLogHelpers.cs new file mode 100644 index 0000000000..16a6f8fa5e --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryLogHelpers.cs @@ -0,0 +1,356 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections; +using System.Diagnostics; +using System.Linq.Expressions; +using System.Reflection; +using OpenTelemetry.AutoInstrumentation.Logging; +using OpenTelemetry.Logs; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge; + +/// +/// Delegate for emitting log records to OpenTelemetry. +/// This delegate signature matches the requirements for creating OpenTelemetry log records +/// with all necessary metadata and context information. +/// +/// The OpenTelemetry logger instance. +/// The log message body or template. +/// The timestamp when the log event occurred. +/// The textual representation of the log level. +/// The numeric severity level mapped to OpenTelemetry standards. +/// The exception associated with the log event, if any. +/// Additional properties to include in the log record. +/// The current activity for trace context. +/// Message template arguments for structured logging. +/// The fully formatted message for inclusion as an attribute. +internal delegate void EmitLog(object loggerInstance, string? body, DateTime timestamp, string? severityText, int severityLevel, Exception? exception, IEnumerable>? properties, Activity? current, object?[]? args, string? renderedMessage); + +/// +/// Helper class for creating OpenTelemetry log records from NLog events. +/// This class provides the core functionality for bridging NLog logging to OpenTelemetry +/// by dynamically creating log emission functions that work with OpenTelemetry's internal APIs. +/// +/// TODO: Remove whole class when Logs Api is made public in non-rc builds. +/// +internal static class OpenTelemetryLogHelpers +{ + private static readonly IOtelLogger Logger = OtelLogging.GetLogger(); + + static OpenTelemetryLogHelpers() + { + try + { + // Use reflection to access OpenTelemetry's internal logging types + // This is necessary because the logging API is not yet public + var loggerProviderType = typeof(LoggerProvider); + var apiAssembly = loggerProviderType.Assembly; + var loggerType = typeof(Sdk).Assembly.GetType("OpenTelemetry.Logs.LoggerSdk"); + var logRecordDataType = apiAssembly.GetType("OpenTelemetry.Logs.LogRecordData")!; + var logRecordAttributesListType = apiAssembly.GetType("OpenTelemetry.Logs.LogRecordAttributeList")!; + + // Build the log emission delegate using expression trees + LogEmitter = BuildEmitLog(logRecordDataType, logRecordAttributesListType, loggerType!); + } + catch (Exception e) + { + Logger.Error(e, "Failed to initialize LogEmitter delegate for NLog bridge."); + } + } + + /// + /// Gets the log emitter delegate that can create OpenTelemetry log records. + /// This delegate is constructed dynamically using reflection and expression trees + /// to work with OpenTelemetry's internal logging APIs. + /// + public static EmitLog? LogEmitter { get; } + + /// + /// Builds an expression tree for creating OpenTelemetry log records. + /// This method constructs the necessary expressions to properly initialize + /// LogRecordData objects with all required properties and attributes. + /// + /// The type of LogRecordData from OpenTelemetry. + /// The type representing log severity levels. + /// Parameter expression for the log message body. + /// Parameter expression for the log timestamp. + /// Parameter expression for the severity text. + /// Parameter expression for the numeric severity level. + /// Parameter expression for the current activity. + /// A block expression that creates and initializes a LogRecordData object. + private static BlockExpression BuildLogRecord( + Type logRecordDataType, + Type severityType, + ParameterExpression body, + ParameterExpression timestamp, + ParameterExpression severityText, + ParameterExpression severityLevel, + ParameterExpression activity) + { + // Creates expression tree that generates code equivalent to: + // var instance = new LogRecordData(activity); + // if (body != null) instance.Body = body; + // instance.Timestamp = timestamp; + // if (severityText != null) instance.SeverityText = severityText; + // instance.Severity = (LogRecordSeverity?)severityLevel; + // return instance; + + var timestampSetterMethodInfo = logRecordDataType.GetProperty("Timestamp")!.GetSetMethod()!; + var bodySetterMethodInfo = logRecordDataType.GetProperty("Body")!.GetSetMethod()!; + var severityTextSetterMethodInfo = logRecordDataType.GetProperty("SeverityText")!.GetSetMethod()!; + var severityLevelSetterMethodInfo = logRecordDataType.GetProperty("Severity")!.GetSetMethod()!; + + var instanceVar = Expression.Variable(bodySetterMethodInfo.DeclaringType!, "instance"); + + var constructorInfo = logRecordDataType.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, CallingConventions.HasThis, [typeof(Activity)], null)!; + var assignInstanceVar = Expression.Assign(instanceVar, Expression.New(constructorInfo, activity)); + var setBody = Expression.IfThen(Expression.NotEqual(body, Expression.Constant(null)), Expression.Call(instanceVar, bodySetterMethodInfo, body)); + var setTimestamp = Expression.Call(instanceVar, timestampSetterMethodInfo, timestamp); + var setSeverityText = Expression.IfThen(Expression.NotEqual(severityText, Expression.Constant(null)), Expression.Call(instanceVar, severityTextSetterMethodInfo, severityText)); + var setSeverityLevel = Expression.Call(instanceVar, severityLevelSetterMethodInfo, Expression.Convert(severityLevel, typeof(Nullable<>).MakeGenericType(severityType))); + + return Expression.Block( + [instanceVar], + assignInstanceVar, + setBody, + setTimestamp, + setSeverityText, + setSeverityLevel, + instanceVar); + } + + /// + /// Builds an expression tree for creating and populating log record attributes. + /// This handles exceptions, custom properties, message template arguments, and rendered messages. + /// + /// The type of LogRecordAttributeList from OpenTelemetry. + /// Parameter expression for the exception. + /// Parameter expression for custom properties. + /// Parameter expression for message template arguments. + /// Parameter expression for the rendered message. + /// A block expression that creates and populates a LogRecordAttributeList. + private static BlockExpression BuildLogRecordAttributes( + Type logRecordAttributesListType, + ParameterExpression exception, + ParameterExpression properties, + ParameterExpression argsParam, + ParameterExpression renderedMessageParam) + { + // Creates expression tree that generates code to populate log attributes + // including exception details, custom properties, and structured logging parameters + + var instanceVar = Expression.Variable(logRecordAttributesListType, "instance"); + var constructorInfo = logRecordAttributesListType.GetConstructor(Type.EmptyTypes); + + Expression assignInstanceVar; + + // If no parameterless constructor, try to find other constructors or use default for structs + if (constructorInfo == null) + { + // Try to find a constructor that takes an int (capacity) + constructorInfo = logRecordAttributesListType.GetConstructor([typeof(int)]); + if (constructorInfo != null) + { + assignInstanceVar = Expression.Assign(instanceVar, Expression.New(constructorInfo, Expression.Constant(4))); + } + else if (logRecordAttributesListType.IsValueType) + { + // For structs, use default value + assignInstanceVar = Expression.Assign(instanceVar, Expression.Default(logRecordAttributesListType)); + } + else + { + throw new InvalidOperationException($"No suitable constructor found for {logRecordAttributesListType.Name}"); + } + } + else + { + assignInstanceVar = Expression.Assign(instanceVar, Expression.New(constructorInfo)); + } + + var addAttributeMethodInfo = logRecordAttributesListType.GetMethod("Add", [typeof(string), typeof(object)])!; + var recordExceptionMethodInfo = logRecordAttributesListType.GetMethod("RecordException", BindingFlags.Instance | BindingFlags.Public)!; + + var expressions = new List { assignInstanceVar }; + + // Record exception using RecordException which adds exception.type, exception.message, exception.stacktrace + var recordExceptionExpression = Expression.IfThen( + Expression.NotEqual(exception, Expression.Constant(null)), + Expression.Call(instanceVar, recordExceptionMethodInfo, exception)); + expressions.Add(recordExceptionExpression); + + // Add custom properties if present + var addPropertiesExpression = BuildAddPropertiesExpression(instanceVar, properties, addAttributeMethodInfo); + expressions.Add(addPropertiesExpression); + + // Add structured logging arguments if present + var addArgsExpression = BuildAddArgsExpression(instanceVar, argsParam, addAttributeMethodInfo); + expressions.Add(addArgsExpression); + + // Add rendered message if present + var addRenderedMessageExpression = Expression.IfThen( + Expression.NotEqual(renderedMessageParam, Expression.Constant(null)), + Expression.Call(instanceVar, addAttributeMethodInfo, Expression.Constant("RenderedMessage"), renderedMessageParam)); + expressions.Add(addRenderedMessageExpression); + + expressions.Add(instanceVar); + + return Expression.Block( + [instanceVar], + expressions); + } + + /// + /// Builds an expression for adding custom properties to the log record attributes. + /// + /// The LogRecordAttributeList instance variable. + /// The properties parameter expression. + /// The Add method for adding attributes. + /// An expression that adds all custom properties to the attributes list. + private static ConditionalExpression BuildAddPropertiesExpression(ParameterExpression instanceVar, ParameterExpression properties, MethodInfo addAttributeMethodInfo) + { + // Create a foreach loop to iterate over properties and add them as attributes + var enumerableType = typeof(IEnumerable>); + var kvpType = typeof(KeyValuePair); + + var getEnumeratorMethod = enumerableType.GetMethod("GetEnumerator")!; + var enumeratorType = getEnumeratorMethod.ReturnType; + var moveNextMethod = typeof(System.Collections.IEnumerator).GetMethod("MoveNext")!; + var currentProperty = enumeratorType.GetProperty("Current")!; + var keyProperty = kvpType.GetProperty("Key")!; + var valueProperty = kvpType.GetProperty("Value")!; + + var enumeratorVar = Expression.Variable(enumeratorType, "enumerator"); + var currentVar = Expression.Variable(kvpType, "current"); + var breakLabel = Expression.Label(); + + var loop = Expression.Loop( + Expression.Block( + Expression.IfThen( + Expression.IsFalse(Expression.Call(enumeratorVar, moveNextMethod)), + Expression.Break(breakLabel)), + Expression.Assign(currentVar, Expression.Property(enumeratorVar, currentProperty)), + Expression.Call( + instanceVar, + addAttributeMethodInfo, + Expression.Property(currentVar, keyProperty), + Expression.Property(currentVar, valueProperty))), + breakLabel); + + return Expression.IfThen( + Expression.NotEqual(properties, Expression.Constant(null)), + Expression.Block( + [enumeratorVar, currentVar], + Expression.Assign(enumeratorVar, Expression.Call(properties, getEnumeratorMethod)), + loop)); + } + + /// + /// Builds an expression for adding structured logging arguments to the log record attributes. + /// + /// The LogRecordAttributeList instance variable. + /// The arguments parameter expression. + /// The Add method for adding attributes. + /// An expression that adds structured logging arguments as attributes. + private static ConditionalExpression BuildAddArgsExpression(ParameterExpression instanceVar, ParameterExpression argsParam, MethodInfo addAttributeMethodInfo) + { + // Create a for loop to iterate over args array and add them as indexed attributes + var lengthProperty = typeof(Array).GetProperty("Length")!; + var indexVar = Expression.Variable(typeof(int), "i"); + var breakLabel = Expression.Label(); + + var loop = Expression.Loop( + Expression.Block( + Expression.IfThen( + Expression.GreaterThanOrEqual(indexVar, Expression.Property(argsParam, lengthProperty)), + Expression.Break(breakLabel)), + Expression.Call( + instanceVar, + addAttributeMethodInfo, + Expression.Call(indexVar, typeof(int).GetMethod("ToString", Type.EmptyTypes)!), + Expression.ArrayIndex(argsParam, indexVar)), + Expression.Assign(indexVar, Expression.Add(indexVar, Expression.Constant(1)))), + breakLabel); + + return Expression.IfThen( + Expression.NotEqual(argsParam, Expression.Constant(null)), + Expression.Block( + [indexVar], + Expression.Assign(indexVar, Expression.Constant(0)), + loop)); + } + + /// + /// Builds the complete EmitLog delegate using expression trees. + /// This method constructs a function that can create OpenTelemetry log records + /// from NLog event data. + /// + /// The LogRecordData type from OpenTelemetry. + /// The LogRecordAttributeList type from OpenTelemetry. + /// The Logger type from OpenTelemetry. + /// An EmitLog delegate that can create OpenTelemetry log records. + private static EmitLog BuildEmitLog(Type logRecordDataType, Type logRecordAttributesListType, Type loggerType) + { + // Get the LogRecordSeverity enum type + var severityType = logRecordDataType.Assembly.GetType("OpenTelemetry.Logs.LogRecordSeverity")!; + + // Define parameters for the delegate + var loggerInstance = Expression.Parameter(typeof(object), "loggerInstance"); + var body = Expression.Parameter(typeof(string), "body"); + var timestamp = Expression.Parameter(typeof(DateTime), "timestamp"); + var severityText = Expression.Parameter(typeof(string), "severityText"); + var severityLevel = Expression.Parameter(typeof(int), "severityLevel"); + var exception = Expression.Parameter(typeof(Exception), "exception"); + var properties = Expression.Parameter(typeof(IEnumerable>), "properties"); + var activity = Expression.Parameter(typeof(Activity), "activity"); + var args = Expression.Parameter(typeof(object[]), "args"); + var renderedMessage = Expression.Parameter(typeof(string), "renderedMessage"); + + // Build the log record creation expression + var logRecordExpression = BuildLogRecord(logRecordDataType, severityType, body, timestamp, severityText, severityLevel, activity); + + // Build the attributes creation expression + var attributesExpression = BuildLogRecordAttributes(logRecordAttributesListType, exception, properties, args, renderedMessage); + + // Get the EmitLog method from the logger + var emitLogRecordMethod = loggerType.GetMethod("EmitLog", BindingFlags.Instance | BindingFlags.Public, null, [logRecordDataType.MakeByRefType(), logRecordAttributesListType.MakeByRefType()], null)!; + + // Create local variables to hold the log record and attributes + // This is required for .NET Framework compatibility - expression trees with + // TryExpression cannot be passed directly to methods with ref parameters. + // By assigning to local variables first, we avoid this limitation. + var logRecordVar = Expression.Variable(logRecordDataType, "logRecord"); + var attributesVar = Expression.Variable(logRecordAttributesListType, "attributes"); + + // Build the complete expression that: + // 1. Creates the log record and assigns to local variable + // 2. Creates attributes and assigns to local variable + // 3. Calls EmitLog with the local variables by reference + var completeExpression = Expression.Block( + [logRecordVar, attributesVar], + Expression.Assign(logRecordVar, logRecordExpression), + Expression.Assign(attributesVar, attributesExpression), + Expression.Call( + Expression.Convert(loggerInstance, loggerType), + emitLogRecordMethod, + logRecordVar, + attributesVar)); + + // Compile the expression into a delegate + var lambda = Expression.Lambda( + completeExpression, + loggerInstance, + body, + timestamp, + severityText, + severityLevel, + exception, + properties, + activity, + args, + renderedMessage); + + return lambda.Compile(); + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryNLogConverter.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryNLogConverter.cs new file mode 100644 index 0000000000..b624769a57 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/Bridge/OpenTelemetryNLogConverter.cs @@ -0,0 +1,259 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using OpenTelemetry.AutoInstrumentation.DuckTyping; +using OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection; +using OpenTelemetry.AutoInstrumentation.Logging; +#if NET +using OpenTelemetry.AutoInstrumentation.Logger; // Only needed for LoggerInitializer +#endif +using OpenTelemetry.Logs; +using Exception = System.Exception; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge; + +/// +/// Converts NLog LogEventInfo into OpenTelemetry LogRecords. +/// +internal class OpenTelemetryNLogConverter +{ + private const int TraceOrdinal = 0; + private const int DebugOrdinal = 1; + private const int InfoOrdinal = 2; + private const int WarnOrdinal = 3; + private const int ErrorOrdinal = 4; + private const int FatalOrdinal = 5; + private const int OffOrdinal = 6; + + private static readonly IOtelLogger Logger = OtelLogging.GetLogger(); + private static readonly Lazy InstanceField = new(InitializeTarget, true); + + private readonly Func? _getLoggerFactory; + private readonly ConcurrentDictionary _loggers = new(StringComparer.Ordinal); + +#if NET + private int _warningLogged; +#endif + + private OpenTelemetryNLogConverter(LoggerProvider loggerProvider) + { + _getLoggerFactory = CreateGetLoggerDelegate(loggerProvider); + } + + public static OpenTelemetryNLogConverter Instance => InstanceField.Value; + + [DuckReverseMethod] + public string Name { get; set; } = nameof(OpenTelemetryNLogConverter); + + [DuckReverseMethod(ParameterTypeNames = ["NLog.LogEventInfo, NLog"])] + public void WriteLogEvent(ILoggingEvent loggingEvent) + { + if (Sdk.SuppressInstrumentation || loggingEvent.Level.Ordinal == OffOrdinal) + { + return; + } + + var emitter = OpenTelemetryLogHelpers.LogEmitter; + if (emitter == null) + { + return; + } + +#if NET + if (LoggerInitializer.IsInitializedAtLeastOnce) + { + if (Interlocked.Exchange(ref _warningLogged, 1) != default) + { + return; + } + + Logger.Warning("Disabling NLog bridge due to ILogger bridge initialization."); + return; + } +#endif + + var logger = GetLogger(loggingEvent.LoggerName); + var logEmitter = OpenTelemetryLogHelpers.LogEmitter; + if (logEmitter is null || logger is null) + { + return; + } + + var mappedLogLevel = MapLogLevel(loggingEvent.Level.Ordinal); + + string? messageTemplate = null; + string? formattedMessage = null; + object?[]? parameters = null; + var messageObject = loggingEvent.Message; + if (loggingEvent.Parameters is { Length: > 0 }) + { + messageTemplate = messageObject?.ToString(); + parameters = loggingEvent.Parameters; + } + + if (messageTemplate is not null && Instrumentation.LogSettings.Value.IncludeFormattedMessage) + { + formattedMessage = loggingEvent.FormattedMessage; + } + + logEmitter( + logger, + messageTemplate ?? loggingEvent.FormattedMessage, + loggingEvent.TimeStamp, + loggingEvent.Level.Name, + mappedLogLevel, + loggingEvent.Exception, + GetProperties(loggingEvent), + Activity.Current, + parameters, + formattedMessage); + } + + internal static int MapLogLevel(int levelOrdinal) + { + return levelOrdinal switch + { + FatalOrdinal => 21, + ErrorOrdinal => 17, + WarnOrdinal => 13, + InfoOrdinal => 9, + DebugOrdinal => 5, + TraceOrdinal => 1, + _ => 1 + }; + } + + private static List>? GetProperties(ILoggingEvent loggingEvent) + { + var result = new List>(); + + try + { + var properties = loggingEvent.Properties; + if (properties != null) + { + // Try to cast to IDictionary first, if that fails, try IEnumerable + if (properties is IDictionary dict) + { + foreach (var prop in GetFilteredProperties(dict)) + { + result.Add(prop); + } + } + else if (properties is IEnumerable> enumerable) + { + foreach (var prop in GetFilteredProperties(enumerable)) + { + result.Add(prop); + } + } + else + { + // If it's some other type, try to duck cast it + if (properties.TryDuckCast(out var duckDict)) + { + foreach (var prop in GetFilteredProperties(duckDict)) + { + result.Add(prop); + } + } + } + } + } + catch (Exception ex) + { + Logger.Debug($"Failed to get event properties: {ex.Message}"); + } + + return result.Count > 0 ? result : null; + } + + private static IEnumerable> GetFilteredProperties(IDictionary properties) + { + foreach (var propertyKey in properties.Keys) + { + if (propertyKey is not string key) + { + continue; + } + + if (EvaluateFilterCondition(key)) + { + continue; + } + + yield return new KeyValuePair(key, properties[key]); + } + } + + private static IEnumerable> GetFilteredProperties(IEnumerable> properties) + { + foreach (var property in properties) + { + if (EvaluateFilterCondition(property.Key)) + { + continue; + } + + yield return property; + } + } + + private static bool EvaluateFilterCondition(string key) + { + return key.StartsWith("NLog.", StringComparison.Ordinal) || + key.StartsWith("nlog:", StringComparison.Ordinal) || + key == LogsTraceContextInjectionConstants.SpanIdPropertyName || + key == LogsTraceContextInjectionConstants.TraceIdPropertyName || + key == LogsTraceContextInjectionConstants.TraceFlagsPropertyName; + } + + private static Func? CreateGetLoggerDelegate(LoggerProvider loggerProvider) + { + try + { + var methodInfo = typeof(LoggerProvider) + .GetMethod("GetLogger", BindingFlags.NonPublic | BindingFlags.Instance, null, [typeof(string)], null)!; +#if NET + return methodInfo.CreateDelegate>(loggerProvider); +#else + return (Func)methodInfo.CreateDelegate(typeof(Func), loggerProvider); +#endif + } + catch (Exception e) + { + Logger.Error(e, "Failed to create logger factory delegate."); + return null; + } + } + + private static OpenTelemetryNLogConverter InitializeTarget() + { + return new OpenTelemetryNLogConverter(Instrumentation.LoggerProvider!); + } + + private object? GetLogger(string? loggerName) + { + if (_getLoggerFactory is null) + { + return null; + } + + var name = loggerName ?? string.Empty; + if (_loggers.TryGetValue(name, out var logger)) + { + return logger; + } + + if (_loggers.Count < 100) + { + return _loggers.GetOrAdd(name, _getLoggerFactory!); + } + + return _getLoggerFactory(name); + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/ILoggingEvent.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/ILoggingEvent.cs new file mode 100644 index 0000000000..eeb167fac5 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/ILoggingEvent.cs @@ -0,0 +1,89 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.AutoInstrumentation.DuckTyping; +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value +#pragma warning disable SA1201 // Elements should appear in the correct order + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog; + +/// +/// Duck typing struct that wraps NLog's LogEventInfo struct. +/// This struct maps to NLog's LogEventInfo structure to extract logging information +/// for conversion to OpenTelemetry log records. +/// +/// Based on: https://github.com/NLog/NLog/blob/master/src/NLog/LogEventInfo.cs +/// +[DuckCopy] +internal struct ILoggingEvent +{ + /// + /// Gets the logging level of the log event. + /// Maps to NLog's LogLevel property. + /// + public LoggingLevel Level; + + /// + /// Gets the name of the logger that created the log event. + /// Maps to NLog's LoggerName property. + /// + public string? LoggerName; + + /// + /// Gets the formatted log message. + /// Maps to NLog's FormattedMessage property. + /// + public string? FormattedMessage; + + /// + /// Gets the exception associated with the log event, if any. + /// Maps to NLog's Exception property. + /// + public Exception? Exception; + + /// + /// Gets the timestamp when the log event was created. + /// Maps to NLog's TimeStamp property. + /// + public DateTime TimeStamp; + + /// + /// Gets the message object before formatting. + /// Maps to NLog's Message property. + /// + public object? Message; + + /// + /// Gets the parameters for the log message. + /// Maps to NLog's Parameters property. + /// + public object?[]? Parameters; + + /// + /// Gets the properties collection for custom properties. + /// Used for injecting trace context and storing additional metadata. + /// Maps to NLog's Properties property. + /// + public object? Properties; +} + +/// +/// Duck typing structure that wraps NLog's LogLevel. +/// This provides access to NLog's log level information for mapping to OpenTelemetry severity levels. +/// +/// Based on: https://github.com/NLog/NLog/blob/master/src/NLog/LogLevel.cs +/// +[DuckCopy] +internal struct LoggingLevel +{ + /// + /// Gets the numeric value of the log level. + /// NLog uses ordinal values: Trace=0, Debug=1, Info=2, Warn=3, Error=4, Fatal=5 + /// + public int Ordinal; + + /// + /// Gets the string name of the log level. + /// + public string Name; +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md new file mode 100644 index 0000000000..c730ecbd8e --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/README.md @@ -0,0 +1,299 @@ +# NLog OpenTelemetry Auto-Instrumentation + +This directory contains the NLog instrumentation for OpenTelemetry .NET +Auto-Instrumentation. This instrumentation provides automatic bytecode +interception for bridging NLog logging events to OpenTelemetry using duck typing. + +## Overview + +The NLog instrumentation offers automatic integration through: + +1. **Bytecode Interception**: Automatically intercepts NLog's internal + `WriteToTargets` methods via bytecode instrumentation +2. **Duck Typing Integration**: Uses duck typing to avoid direct NLog assembly references +3. **Log Event Bridging**: Converting NLog log events to OpenTelemetry log + records (when bridge is enabled) +4. **Structured Logging Support**: Leveraging NLog's layout abilities for enrichment +5. **Trace Context Injection**: Automatically injects trace context into NLog + for all targets +6. **Custom Properties**: Forwarding custom properties while filtering internal + NLog properties + +**Note**: No NLog configuration changes are required. The instrumentation works +exclusively through bytecode interception and relies on OpenTelemetry +environment variables for configuration. + +## Architecture + +### Bytecode Interception Path + +```text +NLog Logger.Info/Debug/Warn/Error/etc. Call + ↓ +NLog Internal WriteToTargets/WriteLogEventToTargets + ↓ +WriteToTargetsIntegration / WriteLogEventToTargetsIntegration (CallTarget - Bytecode Interception) + ├─ ALWAYS: Inject trace context into NLog properties + │ (Available to ALL NLog targets: file, console, database, etc.) + │ + └─ IF bridge enabled: Forward to OpenTelemetry + ↓ + OpenTelemetryNLogConverter.WriteLogEvent() + ↓ + OpenTelemetry LogRecord + ↓ + OTLP Exporters +``` + +The instrumentation intercepts NLog's internal `WriteToTargets` (NLog 5.x) and +`WriteLogEventToTargets` (NLog 6.x) methods at the bytecode level. This ensures +log events are captured, including those from convenience methods like +`log.Info()`, `log.Debug()`, etc. + +## Components + +### Core Components + +#### Duck Types (`ILoggingEvent.cs`) + +- **`ILoggingEvent`**: Duck typing struct for NLog's LogEventInfo +- **`LoggingLevel`**: Duck typing struct for NLog's LogLevel + +#### Bridge Components (`Bridge/`) + +- **`OpenTelemetryNLogConverter.cs`**: Converts NLog events to OpenTelemetry + log records +- **`OpenTelemetryLogHelpers.cs`**: Helper for creating OpenTelemetry log + records via expression trees + +### Trace Context Injection (`TraceContextInjection/`) + +#### Integrations + +- **`WriteToTargetsIntegration.cs`**: For NLog 5.3.0+ (uses + `ITargetWithFilterChain` interface) +- **`WriteToTargetsLegacyIntegration.cs`**: For NLog 5.0.0-5.2.x (uses + `TargetWithFilterChain` class) +- **`WriteToTargetsWithWrapperTypeIntegration.cs`**: For NLog 5.3.0+ with + wrapperType parameter +- **`WriteToTargetsWithWrapperTypeLegacyIntegration.cs`**: For NLog 5.0.0-5.2.x + with wrapperType parameter +- **`WriteLogEventToTargetsIntegration.cs`**: For NLog 6.x (method renamed to `WriteLogEventToTargets`) +- **`WriteLogEventToTargetsWithWrapperTypeIntegration.cs`**: For NLog 6.x with + wrapperType parameter +- **`NLogIntegrationHelper.cs`**: Shared helper with common trace context + injection and bridge logic + +The wrapperType integrations handle calls made via +`Logger.Log(Type wrapperType, LogEventInfo logEvent)`. + +#### Supporting Types + +- **`ILogEventInfoProperties.cs`**: Duck-typed interface for accessing LogEventInfo.Properties +- **`LogsTraceContextInjectionConstants.cs`**: Constants for trace context + property names + +## Configuration + +The NLog instrumentation is configured entirely through OpenTelemetry +environment variables. No programmatic configuration is supported to maintain +assembly loading safety. + +### Environment Variables + +The NLog bridge is controlled by: + +- `OTEL_DOTNET_AUTO_LOGS_ENABLED=true`: Enables logging instrumentation +- `OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE=true`: Enables the NLog bridge specifically + +Standard OpenTelemetry environment variables configure the OTLP exporter: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" +export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=abc123" +export OTEL_EXPORTER_OTLP_PROTOCOL="grpc" +export OTEL_RESOURCE_ATTRIBUTES="service.name=MyApp,service.version=1.0.0" +export OTEL_BSP_SCHEDULE_DELAY="5000" +export OTEL_BSP_MAX_QUEUE_SIZE="2048" +export OTEL_BSP_MAX_EXPORT_BATCH_SIZE="512" +``` + +### Behavior + +The instrumentation automatically: + +- **Injects trace context** into NLog properties (TraceId, SpanId, TraceFlags) + for ALL NLog targets +- Uses formatted message if available, otherwise raw message (when bridge enabled) +- Includes event parameters when present (when bridge enabled) +- Captures trace context from `Activity.Current` +- Forwards custom properties while filtering internal NLog properties (when + bridge enabled) + +#### Trace Context Injection + +Trace context is **always injected** into NLog's LogEventInfo properties, +regardless of whether the OpenTelemetry bridge is enabled. This allows NLog's +own targets (file, console, database, etc.) to access trace context using NLog's +layout renderers: + +```xml + +``` + +The following properties are injected when an active `Activity` exists: + +- `TraceId`: The W3C trace ID +- `SpanId`: The W3C span ID +- `TraceFlags`: The W3C trace flags ("01" if recorded, "00" otherwise) + +#### OpenTelemetry Bridge + +When `OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE=true`, log events are +additionally forwarded to OpenTelemetry's logging infrastructure for export via +OTLP or other configured exporters. + +## Supported Versions + +- **NLog 5.0.0 - 5.2.x**: Uses `WriteToTargetsLegacyIntegration` (TargetWithFilterChain) +- **NLog 5.3.0+**: Uses `WriteToTargetsIntegration` (ITargetWithFilterChain) +- **NLog 6.x**: Uses `WriteLogEventToTargetsIntegration` (WriteLogEventToTargets + method) +- **.NET Framework**: 4.6.2+ +- **.NET**: 8.0, 9.0 + +## Level Mapping + +NLog levels are mapped to OpenTelemetry log record severity levels: + +| NLog Level | Ordinal | OpenTelemetry Severity | Value | +|------------|---------|------------------------|-------| +| Trace | 0 | Trace | 1 | +| Debug | 1 | Debug | 5 | +| Info | 2 | Info | 9 | +| Warn | 3 | Warn | 13 | +| Error | 4 | Error | 17 | +| Fatal | 5 | Fatal | 21 | +| Off | 6 | (skipped) | - | + +## Duck Typing + +The instrumentation uses duck typing to interact with NLog without requiring +direct references: + +- **`ILoggingEvent`**: Maps to `NLog.LogEventInfo` (using `[DuckCopy]` struct) +- **`LoggingLevel`**: Maps to `NLog.LogLevel` (using `[DuckCopy]` struct) +- **`ILogEventInfoProperties`**: Maps to LogEventInfo.Properties for trace + context injection + +## Property Handling + +### Captured Properties + +- All custom properties from `LogEventInfo.Properties` +- Message template arguments (indexed as "0", "1", "2", etc.) + +### Filtered Properties + +The following properties are filtered out when forwarding to OpenTelemetry: + +- Properties starting with `NLog.` +- Properties starting with `nlog:` +- OpenTelemetry trace context properties (`SpanId`, `TraceId`, `TraceFlags`) + +### Exception Handling + +Exceptions are recorded using OpenTelemetry's `RecordException` method, which adds: + +- `exception.type`: The exception type name +- `exception.message`: The exception message +- `exception.stacktrace`: The full stack trace + +## Performance Considerations + +- **Logger Caching**: OpenTelemetry loggers are cached (up to 100 entries) to + avoid recreation overhead +- **Lazy Initialization**: Components are initialized only when needed +- **Minimal Overhead**: Bytecode interception adds minimal overhead to logging calls + +## Error Handling + +- **Graceful Degradation**: If OpenTelemetry components fail to initialize, + logging continues normally +- **Property Safety**: Property extraction is wrapped in try-catch to handle + potential NLog configuration issues +- **Instrumentation Conflicts**: Automatically disables NLog bridge when ILogger + bridge is active to prevent duplicate logs + +## Testing + +### Unit Tests + +Tests are located in `test/OpenTelemetry.AutoInstrumentation.Tests/` and cover: + +- Level mapping verification +- Edge case handling (invalid levels, off level) + +### Integration Tests + +Tests are located in `test/IntegrationTests/NLogBridgeTests.cs` and cover: + +- Direct NLog bridge logging (`SubmitLogs_ThroughNLogBridge_WhenNLogIsUsedDirectlyForLogging`) +- ILogger bridge with NLog provider (`SubmitLogs_ThroughILoggerBridge_WhenNLogIsUsedAsILoggerProviderForLogging`) +- Duplicate prevention (`SubmitLogs_ThroughILoggerBridge_WhenNLogIsUsedAsILoggerProviderForLogging_WithoutDuplicates`) +- Trace context injection (`TraceContext_IsInjectedIntoCurrentNLogLogsDestination`) + +### Test Application + +A complete test application is available at +`test/test-applications/integrations/TestApplication.NLogBridge/` that demonstrates: + +- Direct NLog usage +- Microsoft.Extensions.Logging integration via NLogLoggerProvider +- Structured logging scenarios +- Exception logging +- Trace context propagation + +## Troubleshooting + +### Common Issues + +1. **Bridge Not Working** + - Verify `OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE=true` + - Check that NLog version is 5.0.0 or higher + - Ensure auto-instrumentation is properly loaded + +2. **Missing Trace Context** + - Verify `OTEL_DOTNET_AUTO_TRACES_ENABLED=true` + - Ensure an `Activity` is active when logging + - Check NLog layout includes `${event-properties:TraceId}` etc. + +3. **Missing Properties** + - Check NLog configuration for property capture + - Verify properties don't start with filtered prefixes + +4. **Duplicate Logs** + - If using NLog as ILogger provider, logs go through ILogger bridge + - NLog bridge automatically disables when ILogger bridge is active + +### Debug Information + +Enable OpenTelemetry auto-instrumentation logging: + +```bash +export OTEL_DOTNET_AUTO_LOG_LEVEL=debug +export OTEL_DOTNET_AUTO_LOG_DIRECTORY=/path/to/logs +``` + +## Implementation Notes + +- Uses reflection to access internal OpenTelemetry logging APIs (until public + APIs are available) +- Builds expression trees dynamically for efficient log record creation +- Follows the same patterns as Log4Net instrumentation for consistency +- Designed to be thread-safe and performant in high-throughput scenarios +- Six separate integrations handle NLog version differences and method overloads: + - 2 for NLog 5.0-5.2 (with and without wrapperType parameter) + - 2 for NLog 5.3+ (with and without wrapperType parameter) + - 2 for NLog 6.x (with and without wrapperType parameter) diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/ILogEventInfoProperties.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/ILogEventInfoProperties.cs new file mode 100644 index 0000000000..b5d0a70131 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/ILogEventInfoProperties.cs @@ -0,0 +1,21 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection; + +/// +/// Duck-typed interface for accessing NLog's LogEventInfo.Properties. +/// This interface is used for trace context injection to add TraceId, SpanId, +/// and TraceFlags to the log event properties. +/// +/// +/// NLog's LogEventInfo.Properties is of type IDictionary{object, object}. +/// We use the generic interface to match NLog's property type. +/// +internal interface ILogEventInfoProperties +{ + /// + /// Gets the properties dictionary for the log event. + /// + public IDictionary? Properties { get; } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogIntegrationHelper.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogIntegrationHelper.cs new file mode 100644 index 0000000000..ff524a198c --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogIntegrationHelper.cs @@ -0,0 +1,70 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using OpenTelemetry.AutoInstrumentation.CallTarget; +using OpenTelemetry.AutoInstrumentation.DuckTyping; +using OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge; +using OpenTelemetry.AutoInstrumentation.Logging; +#if NET +using OpenTelemetry.AutoInstrumentation.Logger; +#endif + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations; + +/// +/// Shared helper for NLog integrations. +/// Provides common functionality for trace context injection and bridge forwarding. +/// +internal static class NLogIntegrationHelper +{ +#if NET + private static readonly IOtelLogger Logger = OtelLogging.GetLogger(); + + private static int _warningLogged; +#endif + + /// + /// Handles trace context injection and bridge forwarding for NLog log events. + /// + /// The NLog LogEventInfo being logged. + /// A CallTargetState (unused). + internal static CallTargetState OnMethodBegin(object logEvent) + { + // Duck cast to get properties for trace context injection + if (logEvent.TryDuckCast(out var propsEvent)) + { + var current = Activity.Current; + if (current != null && propsEvent.Properties != null) + { + propsEvent.Properties[LogsTraceContextInjectionConstants.TraceIdPropertyName] = current.TraceId.ToHexString(); + propsEvent.Properties[LogsTraceContextInjectionConstants.SpanIdPropertyName] = current.SpanId.ToHexString(); + propsEvent.Properties[LogsTraceContextInjectionConstants.TraceFlagsPropertyName] = (current.Context.TraceFlags & ActivityTraceFlags.Recorded) != 0 ? "01" : "00"; + } + } + + // Forward to OpenTelemetry bridge if enabled +#if NET + if (LoggerInitializer.IsInitializedAtLeastOnce) + { + if (Interlocked.Exchange(ref _warningLogged, 1) == default) + { + Logger.Warning("Disabling NLog bridge due to ILogger bridge initialization."); + } + + return CallTargetState.GetDefault(); + } +#endif + + if (Instrumentation.LogSettings.Value.EnableNLogBridge) + { + // Duck cast to the full ILoggingEvent struct for the bridge + if (logEvent.TryDuckCast(out var duckLogEvent)) + { + OpenTelemetryNLogConverter.Instance.WriteLogEvent(duckLogEvent); + } + } + + return CallTargetState.GetDefault(); + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsIntegration.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsIntegration.cs new file mode 100644 index 0000000000..18247414ca --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsIntegration.cs @@ -0,0 +1,65 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.AutoInstrumentation.CallTarget; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations; + +/// +/// NLog integration for WriteToTargets/WriteLogEventToTargets methods (2-parameter overloads). +/// This integration intercepts NLog's internal methods to: +/// 1. Inject trace context (TraceId, SpanId, TraceFlags) into the LogEventInfo properties +/// 2. Forward log events to OpenTelemetry when the bridge is enabled +/// +/// +/// Covers: +/// - NLog 5.x WriteToTargets with ITargetWithFilterChain (5.3.0+) +/// - NLog 5.x WriteToTargets with TargetWithFilterChain (5.0.0-5.2.x) +/// - NLog 6.x WriteLogEventToTargets with ITargetWithFilterChain +/// The native profiler will match the correct signature at runtime. +/// +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: new[] { "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain" }, + minimumVersion: "5.0.0", + maximumVersion: "5.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: new[] { "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain" }, + minimumVersion: "5.0.0", + maximumVersion: "5.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteLogEventToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: ["NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain"], + minimumVersion: "6.0.0", + maximumVersion: "6.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +public static class NLogWriteToTargetsIntegration +{ + /// + /// Intercepts NLog's WriteToTargets/WriteLogEventToTargets method to inject trace context and forward to OpenTelemetry. + /// + /// The type of the logger instance. + /// The NLog Logger instance. + /// The NLog LogEventInfo being logged. + /// The target filter chain. + /// A CallTargetState (unused in this case). + internal static CallTargetState OnMethodBegin(TTarget instance, object logEvent, object targetsForLevel) + { + return NLogIntegrationHelper.OnMethodBegin(logEvent); + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsWithWrapperTypeIntegration.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsWithWrapperTypeIntegration.cs new file mode 100644 index 0000000000..367e1225a7 --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/Integrations/NLogWriteToTargetsWithWrapperTypeIntegration.cs @@ -0,0 +1,66 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.AutoInstrumentation.CallTarget; + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection.Integrations; + +/// +/// NLog integration for WriteToTargets/WriteLogEventToTargets methods (3-parameter overloads with wrapperType). +/// This integration intercepts NLog's internal methods to: +/// 1. Inject trace context (TraceId, SpanId, TraceFlags) into the LogEventInfo properties +/// 2. Forward log events to OpenTelemetry when the bridge is enabled +/// +/// +/// Covers methods called when using Logger.Log(Type wrapperType, LogEventInfo logEvent): +/// - NLog 5.x WriteToTargets with ITargetWithFilterChain (5.3.0+) +/// - NLog 5.x WriteToTargets with TargetWithFilterChain (5.0.0-5.2.x) +/// - NLog 6.x WriteLogEventToTargets with ITargetWithFilterChain +/// The native profiler will match the correct signature at runtime. +/// +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: new[] { "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain" }, + minimumVersion: "5.0.0", + maximumVersion: "5.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: new[] { "System.Type", "NLog.LogEventInfo", "NLog.Internal.TargetWithFilterChain" }, + minimumVersion: "5.0.0", + maximumVersion: "5.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +[InstrumentMethod( + assemblyName: "NLog", + typeName: "NLog.Logger", + methodName: "WriteLogEventToTargets", + returnTypeName: ClrNames.Void, + parameterTypeNames: new[] { "System.Type", "NLog.LogEventInfo", "NLog.Internal.ITargetWithFilterChain" }, + minimumVersion: "6.0.0", + maximumVersion: "6.*.*", + integrationName: "NLog", + type: InstrumentationType.Log)] +public static class NLogWriteToTargetsWithWrapperTypeIntegration +{ + /// + /// Intercepts NLog's WriteToTargets/WriteLogEventToTargets method (with wrapperType) to inject trace context and forward to OpenTelemetry. + /// + /// The type of the logger instance. + /// The NLog Logger instance. + /// The wrapper type parameter. + /// The NLog LogEventInfo being logged. + /// The target filter chain. + /// A CallTargetState (unused in this case). + internal static CallTargetState OnMethodBegin(TTarget instance, object? wrapperType, object logEvent, object targetsForLevel) + { + return NLogIntegrationHelper.OnMethodBegin(logEvent); + } +} diff --git a/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/LogsTraceContextInjectionConstants.cs b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/LogsTraceContextInjectionConstants.cs new file mode 100644 index 0000000000..9e751efd0c --- /dev/null +++ b/src/OpenTelemetry.AutoInstrumentation/Instrumentations/NLog/TraceContextInjection/LogsTraceContextInjectionConstants.cs @@ -0,0 +1,27 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.TraceContextInjection; + +/// +/// Constants used for injecting trace context information into NLog log events. +/// These constants define the property names used to store OpenTelemetry trace data +/// within NLog's properties collection. +/// +internal static class LogsTraceContextInjectionConstants +{ + /// + /// Property name for storing the OpenTelemetry span ID in NLog properties. + /// + public const string SpanIdPropertyName = "SpanId"; + + /// + /// Property name for storing the OpenTelemetry trace ID in NLog properties. + /// + public const string TraceIdPropertyName = "TraceId"; + + /// + /// Property name for storing the OpenTelemetry trace flags in NLog properties. + /// + public const string TraceFlagsPropertyName = "TraceFlags"; +} diff --git a/test/Directory.Packages.props b/test/Directory.Packages.props index 9a5bbcb445..d748c492eb 100644 --- a/test/Directory.Packages.props +++ b/test/Directory.Packages.props @@ -35,6 +35,8 @@ + + diff --git a/test/IntegrationTests/LibraryVersions.g.cs b/test/IntegrationTests/LibraryVersions.g.cs index 31abc56226..a0ae818c68 100644 --- a/test/IntegrationTests/LibraryVersions.g.cs +++ b/test/IntegrationTests/LibraryVersions.g.cs @@ -124,6 +124,24 @@ public static TheoryData log4net #else "2.0.13", "3.2.0", +#endif + ]; + return theoryData; + } + } + public static TheoryData NLog + { + get + { + TheoryData theoryData = + [ +#if DEFAULT_TEST_PACKAGE_VERSIONS + string.Empty, +#else + "5.0.0", + "5.3.4", + "6.0.0", + "6.0.6", #endif ]; return theoryData; @@ -406,6 +424,7 @@ public static TheoryData Kafka { "GraphQL", GraphQL }, { "GrpcNetClient", GrpcNetClient }, { "log4net", log4net }, + { "NLog", NLog }, { "MassTransit", MassTransit }, { "SqlClientMicrosoft", SqlClientMicrosoft }, { "SqlClientSystem", SqlClientSystem }, diff --git a/test/IntegrationTests/NLogBridgeTests.cs b/test/IntegrationTests/NLogBridgeTests.cs new file mode 100644 index 0000000000..6c13f4a954 --- /dev/null +++ b/test/IntegrationTests/NLogBridgeTests.cs @@ -0,0 +1,206 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; +using Google.Protobuf; +using IntegrationTests.Helpers; +using OpenTelemetry.Proto.Logs.V1; +using Xunit.Abstractions; + +namespace IntegrationTests; + +public class NLogBridgeTests : TestHelper +{ + public NLogBridgeTests(ITestOutputHelper output) + : base("NLogBridge", output) + { + } + + [Theory] + [Trait("Category", "EndToEnd")] + [MemberData(nameof(LibraryVersion.NLog), MemberType = typeof(LibraryVersion))] + public void SubmitLogs_ThroughNLogBridge_WhenNLogIsUsedDirectlyForLogging(string packageVersion) + { + using var collector = new MockLogsCollector(Output); + SetExporter(collector); + + // Logged in scope of an activity + collector.Expect( + logRecord => + VerifyBody(logRecord, "{0}, {1} at {2:t}!") && + VerifyTraceContext(logRecord) && + logRecord is { SeverityText: "Info", SeverityNumber: SeverityNumber.Info } && + VerifyParameterAttributes(logRecord) && + logRecord.Attributes.Count == 3, + "Expected Info record."); + + // Logged via Logger.Log(Type wrapperType, LogEventInfo logEvent) overload + // This tests the 3-parameter WriteToTargets/WriteLogEventToTargets instrumentation + collector.Expect( + logRecord => + VerifyBody(logRecord, "Message via wrapperType overload") && + VerifyTraceContext(logRecord) && + logRecord is { SeverityText: "Info", SeverityNumber: SeverityNumber.Info }, + "Expected Info record via wrapperType overload."); + + // Logged with exception + collector.Expect( + logRecord => + VerifyBody(logRecord, "Exception occured") && + logRecord is { SeverityText: "Error", SeverityNumber: SeverityNumber.Error } && + VerifyExceptionAttributes(logRecord) && + logRecord.Attributes.Count == 3, + "Expected Error record."); + + EnableBytecodeInstrumentation(); + SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE", "true"); + + var (standardOutput, _, _) = RunTestApplication(new() + { + PackageVersion = packageVersion, + Arguments = "--api nlog" + }); + + AssertStandardOutputExpectations(standardOutput); + + collector.AssertExpectations(); + } + +#if NET + [Theory] + [Trait("Category", "EndToEnd")] + [MemberData(nameof(LibraryVersion.NLog), MemberType = typeof(LibraryVersion))] + public void SubmitLogs_ThroughILoggerBridge_WhenNLogIsUsedAsILoggerProviderForLogging(string packageVersion) + { + using var collector = new MockLogsCollector(Output); + SetExporter(collector); + + // Logged in scope of an activity + // When using ILogger with NLog as provider, logs go through ILogger bridge + // ILogger uses "Information" for Info level, not "Info" + collector.Expect( + logRecord => + VerifyBody(logRecord, "{0}, {1} at {2:t}!") && + VerifyTraceContext(logRecord) && + logRecord is { SeverityText: "Information", SeverityNumber: SeverityNumber.Info } && + // 0 : "Hello" + // 1 : "world" + // 2 : timestamp + logRecord.Attributes.Count == 3, + "Expected Info record."); + + // Logged with exception + collector.Expect( + logRecord => + VerifyBody(logRecord, "Exception occured") && + // OtlpLogExporter adds exception related attributes (ConsoleExporter doesn't show them) + logRecord is { SeverityText: "Error", SeverityNumber: SeverityNumber.Error } && + VerifyExceptionAttributes(logRecord) && + logRecord.Attributes.Count == 3, + "Expected Error record."); + + EnableBytecodeInstrumentation(); + SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE", "true"); + + var (standardOutput, _, _) = RunTestApplication(new() + { + PackageVersion = packageVersion, + Arguments = "--api ILogger" + }); + + AssertStandardOutputExpectations(standardOutput, expectWrapperTypeMessage: false); + + collector.AssertExpectations(); + } + + [Theory] + [Trait("Category", "EndToEnd")] + [MemberData(nameof(LibraryVersion.NLog), MemberType = typeof(LibraryVersion))] + public async Task SubmitLogs_ThroughILoggerBridge_WhenNLogIsUsedAsILoggerProviderForLogging_WithoutDuplicates(string packageVersion) + { + using var collector = new MockLogsCollector(Output); + SetExporter(collector); + + collector.ExpectCollected(records => records.Count == 3, "App logs should be exported once."); + + EnableBytecodeInstrumentation(); + SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE", "true"); + + var (standardOutput, _, _) = RunTestApplication(new() + { + PackageVersion = packageVersion, + Arguments = "--api ILogger" + }); + + AssertStandardOutputExpectations(standardOutput, expectWrapperTypeMessage: false); + + // wait for fixed amount of time for logs to be collected before asserting + await Task.Delay(TimeSpan.FromSeconds(5)); + + collector.AssertCollected(); + } + +#endif + + [Theory] + [Trait("Category", "EndToEnd")] + [MemberData(nameof(LibraryVersion.NLog), MemberType = typeof(LibraryVersion))] + public void TraceContext_IsInjectedIntoCurrentNLogLogsDestination(string packageVersion) + { + EnableBytecodeInstrumentation(); + SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGS_ENABLE_NLOG_BRIDGE", "false"); + + var (standardOutput, _, _) = RunTestApplication(new() + { + PackageVersion = packageVersion, + Arguments = "--api nlog" + }); + + var regex = new Regex(@"INFO TestApplication\.NLogBridge\.Program - Hello, world at \d{1,2}\:\d{2}(\s*[AP]M)?\! TraceId=[a-f0-9]{32} SpanId=[a-f0-9]{16} TraceFlags=0[01]"); + var output = standardOutput; + Assert.Matches(regex, output); + Assert.Contains("ERROR TestApplication.NLogBridge.Program - Exception occured", output); + Assert.Contains("TraceId=", output); + Assert.Contains("SpanId=", output); + Assert.Contains("TraceFlags=", output); + } + + private static bool VerifyParameterAttributes(LogRecord logRecord) + { + var firstArgAttribute = logRecord.Attributes.SingleOrDefault(value => value.Key == "0"); + var secondArgAttribute = logRecord.Attributes.SingleOrDefault(value => value.Key == "1"); + return firstArgAttribute?.Value.StringValue == "Hello" && + secondArgAttribute?.Value.StringValue == "world" && + logRecord.Attributes.Count(value => value.Key == "2") == 1; + } + + private static bool VerifyTraceContext(LogRecord logRecord) + { + return logRecord.TraceId != ByteString.Empty && + logRecord.SpanId != ByteString.Empty && + logRecord.Flags != 0; + } + + private static void AssertStandardOutputExpectations(string standardOutput, bool expectWrapperTypeMessage = true) + { + Assert.Contains("INFO TestApplication.NLogBridge.Program - Hello, world at", standardOutput); + if (expectWrapperTypeMessage) + { + Assert.Contains("INFO TestApplication.NLogBridge.Program - Message via wrapperType overload", standardOutput); + } + + Assert.Contains("ERROR TestApplication.NLogBridge.Program - Exception occured", standardOutput); + } + + private static bool VerifyBody(LogRecord logRecord, string expectedBody) + { + return Convert.ToString(logRecord.Body) == $"{{ \"stringValue\": \"{expectedBody}\" }}"; + } + + private static bool VerifyExceptionAttributes(LogRecord logRecord) + { + return logRecord.Attributes.Count(value => value.Key == "exception.stacktrace") == 1 && + logRecord.Attributes.Count(value => value.Key == "exception.message") == 1 && + logRecord.Attributes.Count(value => value.Key == "exception.type") == 1; + } +} diff --git a/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/FileBased/FilebasedInstrumentationSettingsTests.cs b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/FileBased/FilebasedInstrumentationSettingsTests.cs index 6bc9f18e9f..3842ffc8bb 100644 --- a/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/FileBased/FilebasedInstrumentationSettingsTests.cs +++ b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/FileBased/FilebasedInstrumentationSettingsTests.cs @@ -127,6 +127,10 @@ public void LoadFile_SetsEnabledLogsInstrumentations_IfPresent() { BridgeEnabled = true }, + NLog = new() + { + BridgeEnabled = true + } } }; @@ -145,7 +149,9 @@ public void LoadFile_SetsEnabledLogsInstrumentations_IfPresent() Assert.NotNull(settings.EnabledInstrumentations); Assert.Contains(LogInstrumentation.ILogger, settings.EnabledInstrumentations); Assert.Contains(LogInstrumentation.Log4Net, settings.EnabledInstrumentations); + Assert.Contains(LogInstrumentation.NLog, settings.EnabledInstrumentations); Assert.True(settings.EnableLog4NetBridge); + Assert.True(settings.EnableNLogBridge); } [Fact] diff --git a/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs index f226d3213f..c4fd8fa9ba 100644 --- a/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs +++ b/test/OpenTelemetry.AutoInstrumentation.Tests/Configurations/SettingsTests.cs @@ -355,6 +355,7 @@ internal void MeterSettings_Instrumentations_SupportedValues(string meterInstrum [Theory] [InlineData("ILOGGER", LogInstrumentation.ILogger)] [InlineData("LOG4NET", LogInstrumentation.Log4Net)] + [InlineData("NLOG", LogInstrumentation.NLog)] internal void LogSettings_Instrumentations_SupportedValues(string logInstrumentation, LogInstrumentation expectedLogInstrumentation) { Environment.SetEnvironmentVariable(ConfigurationKeys.Logs.LogsInstrumentationEnabled, "false"); diff --git a/test/OpenTelemetry.AutoInstrumentation.Tests/NLogTests.cs b/test/OpenTelemetry.AutoInstrumentation.Tests/NLogTests.cs new file mode 100644 index 0000000000..af79b84948 --- /dev/null +++ b/test/OpenTelemetry.AutoInstrumentation.Tests/NLogTests.cs @@ -0,0 +1,144 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using OpenTelemetry.AutoInstrumentation.Instrumentations.NLog.Bridge; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.AutoInstrumentation.Tests; + +/// +/// Unit tests for NLog instrumentation functionality. +/// These tests verify that NLog log levels are correctly mapped to OpenTelemetry severity levels +/// and that the NLog bridge functions properly. +/// +public class NLogTests +{ + // TODO: Remove when Logs Api is made public in non-rc builds. + private static readonly Type OpenTelemetryLogSeverityType = typeof(Tracer).Assembly.GetType("OpenTelemetry.Logs.LogRecordSeverity")!; + + /// + /// Provides test data for NLog level mapping tests. + /// This includes all standard NLog levels and their expected OpenTelemetry severity mappings. + /// + /// Theory data containing NLog level ordinals and expected OpenTelemetry severity values. + public static TheoryData GetLevelMappingData() + { + var theoryData = new TheoryData + { + // NLog.LogLevel.Trace (0) -> LogRecordSeverity.Trace (1) + { 0, GetOpenTelemetrySeverityValue("Trace") }, + + // NLog.LogLevel.Debug (1) -> LogRecordSeverity.Debug (5) + { 1, GetOpenTelemetrySeverityValue("Debug") }, + + // NLog.LogLevel.Info (2) -> LogRecordSeverity.Info (9) + { 2, GetOpenTelemetrySeverityValue("Info") }, + + // NLog.LogLevel.Warn (3) -> LogRecordSeverity.Warn (13) + { 3, GetOpenTelemetrySeverityValue("Warn") }, + + // NLog.LogLevel.Error (4) -> LogRecordSeverity.Error (17) + { 4, GetOpenTelemetrySeverityValue("Error") }, + + // NLog.LogLevel.Fatal (5) -> LogRecordSeverity.Fatal (21) + { 5, GetOpenTelemetrySeverityValue("Fatal") } + }; + + return theoryData; + } + + /// + /// Tests that standard NLog log levels are correctly mapped to OpenTelemetry severity levels. + /// This verifies that the bridge correctly translates NLog's ordinal-based level system + /// to OpenTelemetry's severity enumeration. + /// + /// The NLog level ordinal value. + /// The expected OpenTelemetry severity level. + [Theory] + [MemberData(nameof(GetLevelMappingData))] + public void StandardNLogLevels_AreMappedCorrectly(int nlogLevelOrdinal, int expectedOpenTelemetrySeverity) + { + // Act + var actualSeverity = OpenTelemetryNLogConverter.MapLogLevel(nlogLevelOrdinal); + + // Assert + Assert.Equal(expectedOpenTelemetrySeverity, actualSeverity); + } + + /// + /// Tests that the NLog "Off" level (6) is handled correctly. + /// The "Off" level should be mapped to Trace severity, though typically + /// log events with "Off" level should be filtered out before reaching the target. + /// + [Fact] + public void OffLevel_IsMappedToTrace() + { + // Arrange + const int offLevelOrdinal = 6; + var expectedSeverity = GetOpenTelemetrySeverityValue("Trace"); + + // Act + var actualSeverity = OpenTelemetryNLogConverter.MapLogLevel(offLevelOrdinal); + + // Assert + Assert.Equal(expectedSeverity, actualSeverity); + } + + /// + /// Tests that unknown or invalid log level ordinals are mapped to Trace severity. + /// This ensures the bridge is resilient to unexpected level values. + /// + /// An invalid or unknown level ordinal. + [Theory] + [InlineData(-1)] // Negative ordinal + [InlineData(7)] // Beyond "Off" + [InlineData(100)] // Arbitrary high value + [InlineData(int.MaxValue)] // Maximum integer value + public void InvalidLevelOrdinals_AreMappedToTrace(int invalidOrdinal) + { + // Arrange + var expectedSeverity = GetOpenTelemetrySeverityValue("Trace"); + + // Act + var actualSeverity = OpenTelemetryNLogConverter.MapLogLevel(invalidOrdinal); + + // Assert + Assert.Equal(expectedSeverity, actualSeverity); + } + + /// + /// Tests that unknown or custom NLog levels are mapped to the default Trace severity. + /// This verifies that the fallback logic works correctly for non-standard level ordinals. + /// The mapping logic uses a switch expression with a default case that returns 1 (Trace severity) + /// for any ordinal that doesn't match the standard NLog levels (0-5). + /// + /// The NLog ordinal value for an unknown/custom level. + /// The expected OpenTelemetry severity level (should be 1 for Trace). + [Theory] + [InlineData(7, 1)] // Beyond standard levels (after Off=6) -> Trace + [InlineData(10, 1)] // Custom level -> Trace + [InlineData(-1, 1)] // Invalid negative ordinal -> Trace + [InlineData(100, 1)] // High custom level -> Trace + [InlineData(999, 1)] // Very high custom level -> Trace + public void UnknownCustomLevels_AreMappedToTraceSeverity(int nlogOrdinal, int expectedSeverity) + { + // Act + var actualSeverity = OpenTelemetryNLogConverter.MapLogLevel(nlogOrdinal); + + // Assert + Assert.Equal(expectedSeverity, actualSeverity); + } + + /// + /// Gets the numeric value of an OpenTelemetry log severity level by name. + /// This helper method uses reflection to access the internal LogRecordSeverity enum + /// since the Logs API is not yet public. + /// + /// The name of the severity level (e.g., "Info", "Error"). + /// The numeric value of the severity level. + private static int GetOpenTelemetrySeverityValue(string severityName) + { + return (int)Enum.Parse(OpenTelemetryLogSeverityType, severityName); + } +} diff --git a/test/OpenTelemetry.AutoInstrumentation.Tests/OtelLoggingTests.cs b/test/OpenTelemetry.AutoInstrumentation.Tests/OtelLoggingTests.cs index 6a74665eda..40f7459604 100644 --- a/test/OpenTelemetry.AutoInstrumentation.Tests/OtelLoggingTests.cs +++ b/test/OpenTelemetry.AutoInstrumentation.Tests/OtelLoggingTests.cs @@ -147,7 +147,7 @@ public void WhenConsoleSinkIsUsed_Then_ConsoleContentIsDetected() Environment.SetEnvironmentVariable("OTEL_LOG_LEVEL", "debug"); Environment.SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGGER", "console"); - var currentWritter = Console.Out; + var currentWriter = Console.Out; using var ms = new MemoryStream(); using var tw = new StreamWriter(ms); @@ -174,7 +174,7 @@ public void WhenConsoleSinkIsUsed_Then_ConsoleContentIsDetected() } finally { - Console.SetOut(currentWritter); + Console.SetOut(currentWriter); } } @@ -184,7 +184,7 @@ public void AfterLoggerIsClosed_ConsecutiveLogCallsWithTheSameLoggerAreNotWritte Environment.SetEnvironmentVariable("OTEL_LOG_LEVEL", "debug"); Environment.SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGGER", "console"); - var currentWritter = Console.Out; + var currentWriter = Console.Out; using var ms = new MemoryStream(); using var tw = new StreamWriter(ms); @@ -219,7 +219,7 @@ public void AfterLoggerIsClosed_ConsecutiveLogCallsWithTheSameLoggerAreNotWritte } finally { - Console.SetOut(currentWritter); + Console.SetOut(currentWriter); } } @@ -229,7 +229,7 @@ public void AfterLoggerIsClosed_ConsecutiveCallsToGetLoggerReturnNoopLogger() Environment.SetEnvironmentVariable("OTEL_LOG_LEVEL", "debug"); Environment.SetEnvironmentVariable("OTEL_DOTNET_AUTO_LOGGER", "console"); - var currentWritter = Console.Out; + var currentWriter = Console.Out; using var ms = new MemoryStream(); using var tw = new StreamWriter(ms); @@ -265,7 +265,7 @@ public void AfterLoggerIsClosed_ConsecutiveCallsToGetLoggerReturnNoopLogger() } finally { - Console.SetOut(currentWritter); + Console.SetOut(currentWriter); } } diff --git a/test/test-applications/integrations/TestApplication.NLogBridge/Program.cs b/test/test-applications/integrations/TestApplication.NLogBridge/Program.cs new file mode 100644 index 0000000000..9cf0e345e7 --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/Program.cs @@ -0,0 +1,98 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Extensions.Logging; + +namespace TestApplication.NLogBridge; + +internal static class Program +{ + private static readonly ActivitySource Source = new("TestApplication.NLogBridge"); + + private static void Main(string[] args) + { + if (args.Length == 2) + { + var logApiName = args[1]; + switch (logApiName) + { + case "nlog": + LogUsingNLogDirectly(); + break; + case "ILogger": + LogUsingILogger(); + break; + default: + throw new NotSupportedException($"{logApiName} is not supported."); + } + } + else + { + throw new ArgumentException("Invalid arguments."); + } + } + + private static void LogUsingILogger() + { + var l = LogManager.GetLogger("TestApplication.NLogBridge"); + l.Warn("Before logger factory is built."); + + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new NLogLoggerProvider()); + }); + var logger = loggerFactory.CreateLogger(typeof(Program)); + + LogInsideActiveScope(() => logger.LogInformation("{0}, {1} at {2:t}!", "Hello", "world", DateTime.Now)); + + var (message, ex) = GetException(); + logger.LogError(ex, message); + } + + private static void LogInsideActiveScope(Action action) + { + // Use ActivitySource to create a properly sampled activity + // The auto-instrumentation sets up an ActivityListener that samples all activities + using var activity = Source.StartActivity("ManuallyStarted", ActivityKind.Internal); + action(); + } + + private static void LogUsingNLogDirectly() + { + var log = LogManager.GetLogger(typeof(Program).FullName!); + + LogInsideActiveScope(() => log.Info("{0}, {1} at {2:t}!", "Hello", "world", DateTime.Now)); + + // Test the Logger.Log(Type wrapperType, LogEventInfo logEvent) overload + // This exercises the 3-parameter WriteToTargets/WriteLogEventToTargets method + LogInsideActiveScope(() => LogWithWrapperType(log, "Message via wrapperType overload")); + + var (message, ex) = GetException(); + log.Error(ex, message); + } + + /// + /// Logs a message using the Logger.Log(Type wrapperType, LogEventInfo logEvent) overload. + /// This is used when creating custom logger wrappers. + /// + private static void LogWithWrapperType(NLog.ILogger logger, string message) + { + var logEvent = LogEventInfo.Create(NLog.LogLevel.Info, logger.Name, message); + logger.Log(typeof(Program), logEvent); + } + + private static (string Message, Exception Exception) GetException() + { + try + { + throw new InvalidOperationException("Example exception for testing"); + } + catch (Exception ex) + { + return ("Exception occured", ex); + } + } +} diff --git a/test/test-applications/integrations/TestApplication.NLogBridge/TestApplication.NLogBridge.csproj b/test/test-applications/integrations/TestApplication.NLogBridge/TestApplication.NLogBridge.csproj new file mode 100644 index 0000000000..d425ad43cb --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/TestApplication.NLogBridge.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test-applications/integrations/TestApplication.NLogBridge/nlog.config b/test/test-applications/integrations/TestApplication.NLogBridge/nlog.config new file mode 100644 index 0000000000..298968378f --- /dev/null +++ b/test/test-applications/integrations/TestApplication.NLogBridge/nlog.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/tools/LibraryVersionsGenerator/Models/NLogVersion.cs b/tools/LibraryVersionsGenerator/Models/NLogVersion.cs new file mode 100644 index 0000000000..e53a5b5116 --- /dev/null +++ b/tools/LibraryVersionsGenerator/Models/NLogVersion.cs @@ -0,0 +1,15 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace LibraryVersionsGenerator.Models; + +internal sealed class NLogVersion : PackageVersion +{ + public NLogVersion(string version) + : base(version) + { + } + + [PackageDependency("NLog.Extensions.Logging", "NLogExtensionsLogging")] + public required string NLogExtensionsLoggingVersion { get; set; } +} diff --git a/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs b/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs index 4259d63f46..fb03a6316d 100644 --- a/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs +++ b/tools/LibraryVersionsGenerator/PackageVersionDefinitions.cs @@ -101,6 +101,20 @@ all lower versions than 8.15.10 contains references impacted by } }, new() + { + IntegrationName = "NLog", + NugetPackageName = "NLog", + TestApplicationName = "TestApplication.NLogBridge", + Versions = new List + { + // NLog 5.0+ required for Layout typed layout support and .NET build-trimming + new("5.0.0") { NLogExtensionsLoggingVersion = "5.0.0" }, + new("5.3.4") { NLogExtensionsLoggingVersion = "5.3.15" }, // 5.3.0 - breaking change in the instrumented method contract + new("6.0.0") { NLogExtensionsLoggingVersion = "6.0.0" }, // 6.0.0 - breaking change in the instrumented method contract + new("*") { NLogExtensionsLoggingVersion = "*" } + } + }, + new() { IntegrationName = "MassTransit", NugetPackageName = "MassTransit",